320 lines
9.3 KiB
TypeScript
320 lines
9.3 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type ClipboardEvent,
|
||
type DragEvent,
|
||
type FormEvent,
|
||
type KeyboardEvent,
|
||
} from "react";
|
||
import { EmojiPicker } from "./EmojiPicker";
|
||
import { renderCommunityMarkdown } from "./renderMarkdown";
|
||
import styles from "./community.module.css";
|
||
|
||
type CommunityMemoFormProps = {
|
||
onSubmit: (content: string) => Promise<void>;
|
||
disabled?: boolean;
|
||
maxLength?: number;
|
||
userId?: string | null;
|
||
restoreContent?: string | null;
|
||
onRestoreConsumed?: () => void;
|
||
};
|
||
|
||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||
const MAX_SIZE = 10 * 1024 * 1024;
|
||
const QUICK_TOPICS = ["#出海复盘", "#AI工具", "#远程工作", "#副业实验"];
|
||
|
||
export function CommunityMemoForm({
|
||
onSubmit,
|
||
disabled = false,
|
||
maxLength = 10000,
|
||
userId,
|
||
restoreContent,
|
||
onRestoreConsumed,
|
||
}: CommunityMemoFormProps) {
|
||
const [content, setContent] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [dragOver, setDragOver] = useState(false);
|
||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||
|
||
const insertAtCursor = useCallback(
|
||
(text: string) => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) {
|
||
setContent((current) => (current + text).slice(0, maxLength));
|
||
return;
|
||
}
|
||
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const next = `${content.slice(0, start)}${text}${content.slice(end)}`.slice(0, maxLength);
|
||
setContent(next);
|
||
|
||
setTimeout(() => {
|
||
textarea.focus();
|
||
const nextCursor = Math.min(start + text.length, next.length);
|
||
textarea.setSelectionRange(nextCursor, nextCursor);
|
||
}, 0);
|
||
},
|
||
[content, maxLength]
|
||
);
|
||
|
||
const uploadFile = useCallback(
|
||
async (file: File): Promise<string | null> => {
|
||
if (!userId || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
|
||
return null;
|
||
}
|
||
|
||
setUploading(true);
|
||
try {
|
||
const body = new FormData();
|
||
body.append("user_id", userId);
|
||
body.append("file", file, file.name);
|
||
|
||
const response = await fetch("/api/community/upload", {
|
||
method: "POST",
|
||
body,
|
||
});
|
||
const data = await response.json().catch(() => null);
|
||
if (response.ok && data?.ok && data?.markdown) {
|
||
return data.markdown;
|
||
}
|
||
return null;
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
},
|
||
[userId]
|
||
);
|
||
|
||
const handlePaste = useCallback(
|
||
async (event: ClipboardEvent) => {
|
||
const items = event.clipboardData?.items;
|
||
if (!items || !userId) {
|
||
return;
|
||
}
|
||
|
||
for (const item of Array.from(items)) {
|
||
if (item.kind !== "file" || !item.type.startsWith("image/")) {
|
||
continue;
|
||
}
|
||
const file = item.getAsFile();
|
||
if (!file || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
|
||
continue;
|
||
}
|
||
|
||
event.preventDefault();
|
||
const markdown = await uploadFile(file);
|
||
if (markdown) {
|
||
insertAtCursor(`${markdown}\n`);
|
||
}
|
||
break;
|
||
}
|
||
},
|
||
[insertAtCursor, uploadFile, userId]
|
||
);
|
||
|
||
const handleDrop = useCallback(
|
||
async (event: DragEvent) => {
|
||
event.preventDefault();
|
||
setDragOver(false);
|
||
if (!userId || disabled) {
|
||
return;
|
||
}
|
||
|
||
const file = event.dataTransfer?.files?.[0];
|
||
if (!file || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
|
||
return;
|
||
}
|
||
|
||
const markdown = await uploadFile(file);
|
||
if (markdown) {
|
||
insertAtCursor(`${markdown}\n`);
|
||
}
|
||
},
|
||
[disabled, insertAtCursor, uploadFile, userId]
|
||
);
|
||
|
||
const handleFileSelect = useCallback(
|
||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file || !userId || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
|
||
return;
|
||
}
|
||
|
||
const markdown = await uploadFile(file);
|
||
if (markdown) {
|
||
insertAtCursor(`${markdown}\n`);
|
||
}
|
||
},
|
||
[insertAtCursor, uploadFile, userId]
|
||
);
|
||
|
||
const handleSubmit = useCallback(
|
||
async (event?: FormEvent) => {
|
||
event?.preventDefault();
|
||
const trimmed = content.trim();
|
||
if (!trimmed || submitting || disabled) {
|
||
return;
|
||
}
|
||
|
||
setSubmitting(true);
|
||
setContent("");
|
||
try {
|
||
await onSubmit(trimmed);
|
||
} catch {
|
||
setContent(trimmed);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
},
|
||
[content, disabled, onSubmit, submitting]
|
||
);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(event: KeyboardEvent) => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||
event.preventDefault();
|
||
void handleSubmit();
|
||
}
|
||
},
|
||
[handleSubmit]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!restoreContent) {
|
||
return;
|
||
}
|
||
setContent(restoreContent);
|
||
onRestoreConsumed?.();
|
||
}, [onRestoreConsumed, restoreContent]);
|
||
|
||
return (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className={styles.memoForm}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
if (userId && !disabled) {
|
||
setDragOver(true);
|
||
}
|
||
}}
|
||
onDragLeave={() => setDragOver(false)}
|
||
onDrop={handleDrop}
|
||
>
|
||
<div className={styles.memoFormHeader}>
|
||
<div>
|
||
<span className={styles.memoFormEyebrow}>Planet Post</span>
|
||
<h2 className={styles.memoFormTitle}>发布一条新动态</h2>
|
||
<p className={styles.memoFormHint}>记录复盘、问题、工具推荐,或者最近做成的一件事。</p>
|
||
</div>
|
||
<div className={styles.memoQuickTopics}>
|
||
{QUICK_TOPICS.map((topic) => (
|
||
<button
|
||
key={topic}
|
||
type="button"
|
||
className={styles.quickTopicBtn}
|
||
onClick={() => insertAtCursor(`${topic} `)}
|
||
>
|
||
{topic}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`${styles.textareaWrapper} ${dragOver ? styles.textareaDropActive : ""}`}>
|
||
<textarea
|
||
ref={textareaRef}
|
||
className={styles.memoTextarea}
|
||
value={content}
|
||
onChange={(event) => setContent(event.target.value.slice(0, maxLength))}
|
||
onPaste={handlePaste}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="写下你的观察、复盘、问题或资源推荐。支持 Markdown、标签和图片。"
|
||
rows={5}
|
||
disabled={disabled || submitting}
|
||
maxLength={maxLength}
|
||
/>
|
||
{dragOver ? <div className={styles.dropOverlay}>松开鼠标即可上传图片</div> : null}
|
||
</div>
|
||
|
||
<div className={styles.memoFormFooter}>
|
||
<div className={styles.footerTop}>
|
||
<div className={styles.charCountRow}>
|
||
<span className={styles.charCount}>
|
||
{content.length} / {maxLength}
|
||
{uploading ? " · 图片上传中..." : ""}
|
||
</span>
|
||
<div className={styles.charCountActions}>
|
||
<button
|
||
ref={emojiBtnRef}
|
||
type="button"
|
||
className={styles.emojiTrigger}
|
||
onClick={() => setEmojiOpen((open) => !open)}
|
||
disabled={disabled || submitting}
|
||
title="插入表情"
|
||
aria-label="插入表情"
|
||
>
|
||
🙂
|
||
</button>
|
||
<EmojiPicker
|
||
open={emojiOpen}
|
||
onClose={() => setEmojiOpen(false)}
|
||
onSelect={(emoji) => insertAtCursor(emoji)}
|
||
anchorRef={emojiBtnRef}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.formActions}>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||
className={styles.hiddenInput}
|
||
onChange={handleFileSelect}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.uploadBtn}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={disabled || submitting || !userId || uploading}
|
||
>
|
||
上传图片
|
||
</button>
|
||
<button type="submit" className={styles.submitBtn} disabled={!content.trim() || submitting || disabled}>
|
||
{submitting ? "发布中..." : "发布动态"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{content.trim() ? (
|
||
<div className={styles.draftPreview}>
|
||
<span className={styles.draftLabel}>预览</span>
|
||
<div
|
||
className={styles.draftContent}
|
||
dangerouslySetInnerHTML={{
|
||
__html: renderCommunityMarkdown(content),
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className={styles.publishTip}>
|
||
<span>提示:</span>
|
||
<span>按 `Ctrl/Cmd + Enter` 可快速发布,复制图片或拖拽图片也能直接插入。</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|