| // src/components/threads/CreateThreadModal.tsx | |
| import { useState } from "react"; | |
| import type { ThreadType } from "@/types/thread"; | |
| import { X, Hash, MessageSquare, Lightbulb, Tag, Loader2 } from "lucide-react"; | |
| import { toast } from "react-toastify"; | |
| interface CreateThreadModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onCreate: (data: { | |
| title: string; | |
| type: ThreadType; | |
| parentMessageId?: number; | |
| initialMessage?: string; | |
| tags?: string[]; | |
| }) => Promise<void>; // now async | |
| parentMessageId?: number; | |
| parentMessagePreview?: string; | |
| } | |
| const THREAD_TYPES: { value: ThreadType; icon: typeof Hash; label: string; desc: string }[] = [ | |
| { | |
| value: "continuation", | |
| icon: MessageSquare, | |
| label: "Continuation", | |
| desc: "Continue discussing a specific message", | |
| }, | |
| { | |
| value: "standalone", | |
| icon: Hash, | |
| label: "Standalone Topic", | |
| desc: "Start a new discussion topic", | |
| }, | |
| { | |
| value: "topic", | |
| icon: Lightbulb, | |
| label: "Project Thread", | |
| desc: "Organize around a project or paper", | |
| }, | |
| ]; | |
| export function CreateThreadModal({ | |
| isOpen, | |
| onClose, | |
| onCreate, | |
| parentMessageId, | |
| parentMessagePreview, | |
| }: CreateThreadModalProps) { | |
| const [title, setTitle] = useState(""); | |
| const [type, setType] = useState<ThreadType>("continuation"); | |
| const [initialMessage, setInitialMessage] = useState(""); | |
| const [tagInput, setTagInput] = useState(""); | |
| const [tags, setTags] = useState<string[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| if (!isOpen) return null; | |
| const resetForm = () => { | |
| setTitle(""); | |
| setType("continuation"); | |
| setInitialMessage(""); | |
| setTags([]); | |
| setTagInput(""); | |
| }; | |
| const handleCreate = async () => { | |
| if (!title.trim()) { | |
| toast.warning("Please enter a thread title"); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| await onCreate({ | |
| title: title.trim(), | |
| type, | |
| parentMessageId, | |
| initialMessage: initialMessage.trim() || undefined, | |
| tags: tags.length > 0 ? tags : undefined, | |
| }); | |
| toast.success("Thread created"); | |
| resetForm(); | |
| onClose(); | |
| } catch (err: any) { | |
| toast.error(err.message || "Failed to create thread"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const addTag = () => { | |
| const t = tagInput.trim().toLowerCase().replace(/[^a-z0-9-]/g, ""); | |
| if (t && !tags.includes(t)) { | |
| setTags([...tags, t]); | |
| } else if (tags.includes(t)) { | |
| toast.info("Tag already added"); | |
| } | |
| setTagInput(""); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); | |
| if (tagInput.trim()) addTag(); | |
| else if (title.trim() && initialMessage.trim()) handleCreate(); | |
| } | |
| }; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}> | |
| <div | |
| className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="mb-4 flex items-center justify-between"> | |
| <h2 className="text-lg font-bold">New Thread</h2> | |
| <button onClick={onClose} className="text-gray-500 hover:text-gray-700"> | |
| <X className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| {/* Parent message preview */} | |
| {parentMessagePreview && ( | |
| <div className="mb-4 rounded-xl border border-yellow-200 bg-yellow-50 p-3 text-sm text-gray-600 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-gray-400"> | |
| <p className="mb-1 text-xs font-semibold text-yellow-700 dark:text-yellow-400"> | |
| Replying to: | |
| </p> | |
| <p className="line-clamp-2">{parentMessagePreview}</p> | |
| </div> | |
| )} | |
| {/* Title */} | |
| <input | |
| type="text" | |
| value={title} | |
| onChange={(e) => setTitle(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Thread title (e.g., Discussion of methodology)" | |
| className="mb-4 w-full rounded-xl border border-gray-200 bg-transparent p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:border-gray-700" | |
| autoFocus | |
| disabled={loading} | |
| /> | |
| {/* Type selector */} | |
| <div className="mb-4 grid grid-cols-3 gap-2"> | |
| {THREAD_TYPES.map((t) => ( | |
| <button | |
| key={t.value} | |
| onClick={() => setType(t.value)} | |
| disabled={loading} | |
| className={`flex flex-col items-center gap-1 rounded-xl border-2 p-3 text-center transition ${ | |
| type === t.value | |
| ? "border-primary bg-primary/5 text-primary" | |
| : "border-gray-200 text-gray-500 hover:border-gray-300 dark:border-gray-700" | |
| }`} | |
| title={t.desc} | |
| > | |
| <t.icon className="h-5 w-5" /> | |
| <span className="text-xs font-medium">{t.label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| {/* Initial message */} | |
| <textarea | |
| value={initialMessage} | |
| onChange={(e) => setInitialMessage(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Start the discussion..." | |
| rows={3} | |
| className="mb-4 w-full resize-none rounded-xl border border-gray-200 bg-transparent p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:border-gray-700" | |
| disabled={loading} | |
| /> | |
| {/* Tags input */} | |
| <div className="mb-4"> | |
| <div className="mb-2 flex gap-2"> | |
| <input | |
| type="text" | |
| value={tagInput} | |
| onChange={(e) => setTagInput(e.target.value)} | |
| onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())} | |
| placeholder="Add tags (e.g., methodology)" | |
| className="flex-1 rounded-lg border border-gray-200 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary dark:border-gray-700" | |
| disabled={loading} | |
| /> | |
| <button | |
| onClick={addTag} | |
| disabled={loading} | |
| className="btn btn-secondary btn-xs" | |
| > | |
| Add | |
| </button> | |
| </div> | |
| {tags.length > 0 && ( | |
| <div className="flex flex-wrap gap-1"> | |
| {tags.map((t) => ( | |
| <span | |
| key={t} | |
| className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary" | |
| > | |
| <Tag className="h-3 w-3" /> | |
| {t} | |
| <button | |
| onClick={() => setTags(tags.filter((x) => x !== t))} | |
| className="ml-0.5 hover:text-red-500" | |
| aria-label={`Remove tag ${t}`} | |
| > | |
| × | |
| </button> | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Create button */} | |
| <button | |
| onClick={handleCreate} | |
| disabled={!title.trim() || loading} | |
| className="btn-primary flex w-full items-center justify-center gap-2" | |
| > | |
| {loading && <Loader2 className="h-4 w-4 animate-spin" />} | |
| {loading ? "Creating..." : "Create Thread"} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 7.46 kB
- Xet hash:
- a279509a072c2f05106236a779d874340e2196a8a450e20e5952cbac61c632c6
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.