import { useState, useCallback, useEffect, useRef } from "react"; import { Link } from "lucide-react"; import type { Task, TaskCreateInput } from "@kb/core"; import type { ToastType } from "../hooks/useToast"; import { uploadAttachment } from "../api"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; interface PendingImage { file: File; previewUrl: string; } interface InlineCreateCardProps { tasks: Task[]; onSubmit: (input: TaskCreateInput) => Promise; onCancel: () => void; addToast: (msg: string, type?: ToastType) => void; } export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: InlineCreateCardProps) { const [description, setDescription] = useState(""); const [dependencies, setDependencies] = useState([]); const [showDeps, setShowDeps] = useState(false); const [submitting, setSubmitting] = useState(false); const [pendingImages, setPendingImages] = useState([]); const inputRef = useRef(null); const cardRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); // Cancel when focus leaves the card entirely and there's no content useEffect(() => { const card = cardRef.current; if (!card) return; const handleFocusOut = (e: FocusEvent) => { // relatedTarget is the element receiving focus — if it's inside the card, ignore if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return; // Only cancel if empty if (description.trim() === "" && pendingImages.length === 0) { onCancel(); } }; card.addEventListener("focusout", handleFocusOut); return () => card.removeEventListener("focusout", handleFocusOut); }, [description, pendingImages, onCancel]); // Clean up object URLs on unmount to prevent memory leaks useEffect(() => { return () => { pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount }, []); /** * Handles paste events on the textarea. Extracts image files from the * clipboard data, creates object URL previews, and appends them to * the pendingImages state. Non-image files are silently ignored. */ const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (submitting) return; const files = e.clipboardData?.files; if (!files || files.length === 0) return; const newImages: PendingImage[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (ALLOWED_IMAGE_TYPES.includes(file.type)) { newImages.push({ file, previewUrl: URL.createObjectURL(file) }); } } if (newImages.length > 0) { setPendingImages((prev) => [...prev, ...newImages]); } }, [submitting], ); const removeImage = useCallback((index: number) => { setPendingImages((prev) => { const removed = prev[index]; if (removed) URL.revokeObjectURL(removed.previewUrl); return prev.filter((_, i) => i !== index); }); }, []); const handleKeyDown = useCallback( async (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!description.trim() || submitting) return; setSubmitting(true); try { const task = await onSubmit({ description: description.trim(), column: "triage", dependencies: dependencies.length ? dependencies : undefined, }); // Upload pending images as attachments if (pendingImages.length > 0) { const failures: string[] = []; for (const img of pendingImages) { try { await uploadAttachment(task.id, img.file); } catch { failures.push(img.file.name); } } if (failures.length > 0) { addToast(`Failed to upload: ${failures.join(", ")}`, "error"); } } // Clean up preview URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); addToast(`Created ${task.id}`, "success"); } catch (err: any) { addToast(err.message, "error"); } finally { setSubmitting(false); } } }, [description, dependencies, submitting, pendingImages, onSubmit, onCancel, addToast], ); const toggleDep = useCallback((id: string) => { setDependencies((prev) => prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], ); }, []); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; return (