Files
gitlab-instance-0a899031_no…/app/community/components/CommunityMemoForm.tsx
2026-03-15 04:25:45 -05:00

320 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}