import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } from "@/store"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film, FileText } from "lucide-solid"; import { StatusCircle } from "./StatusCircle"; import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store"; import { Button } from "./ui/button"; import { TagPicker } from "./TagPicker"; import { Badge } from "./ui/badge"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS, getStatusOptionsForTask } from "@/lib/constants"; import { store } from "@/store"; import { cn } from "@/lib/utils"; import { toast } from "solid-sonner"; import { lazy, Suspense } from "solid-js"; import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; import { pb } from "@/lib/pocketbase"; const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); interface TaskDetailProps { task: Task; isOpen: boolean; onClose: () => void; } export const TaskDetail: Component = (props) => { const { resolvedTheme } = useTheme(); // Local state for immediate feedback, synced with props const [title, setTitle] = createSignal(props.task.title); const [editorInstance, setEditorInstance] = createSignal(null); // Sync input string for datetime-local const [dateString, setDateString] = createSignal(""); createEffect(() => { setTitle(props.task.title); // Format for datetime-local: yyyy-MM-ddThh:mm (local time) const dateObj = new Date(props.task.dueDate); const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); setDateString(localIso); // Load content if missing and panel is open if (props.isOpen && props.task.content === undefined) { loadTaskContent(props.task.id); } }); const [trackedMentions, setTrackedMentions] = createSignal>(new Set()); createEffect(() => { if (props.isOpen && props.task.content) { const html = props.task.content; const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g; let match; const initial = new Set(); while ((match = mentionRegex.exec(html)) !== null) { initial.add(match[1]); } setTrackedMentions(initial); } }); let debounceTimer: number | undefined; let pendingContent: string | null = null; const updateTaskContent = (html: string) => { pendingContent = html; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { if (pendingContent !== null) { updateTask(props.task.id, { content: pendingContent }); // Sync mentions deletion const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g; let match; const currentMentions = new Set(); while ((match = mentionRegex.exec(pendingContent)) !== null) { currentMentions.add(match[1].toLowerCase()); } const currentTags = props.task.tags || []; let tagsChanged = false; const newTags = currentTags.filter(t => { const tagLower = t.toLowerCase(); const isTracked = [...trackedMentions()].some(tm => tm.toLowerCase() === tagLower); if (isTracked && !currentMentions.has(tagLower)) { tagsChanged = true; // Also remove from tracked setTrackedMentions(prev => { const next = new Set(prev); for (let p of next) { if (p.toLowerCase() === tagLower) next.delete(p); } return next; }); return false; // remove from tags } return true; }); if (tagsChanged) { updateTags(newTags); } pendingContent = null; } }, 1000); }; // Sync activeTaskId for slash commands and run cleanup on unmount/change createEffect(() => { const currentTaskId = props.task.id; onCleanup(() => { // 1. Immediately flush any pending debounce updates for the outgoing task if (debounceTimer) { clearTimeout(debounceTimer); if (pendingContent !== null) { updateTask(currentTaskId, { content: pendingContent }); } } // 2. Clear pending state debounceTimer = undefined; pendingContent = null; // 3. Trigger cleanup after a tiny delay so the updateTask above processes first if (currentTaskId) { setTimeout(() => { cleanupTaskAttachments(currentTaskId).catch(console.error); }, 100); } }); }); const updateTitle = (val: string) => { setTitle(val); updateTask(props.task.id, { title: val }); }; const updatePriority = (val: any) => { const value = typeof val === 'object' ? val.value : val; const p = parseInt(value); if (!isNaN(p)) { updateTask(props.task.id, { priority: p }); } }; const updateStatus = (val: any) => { const value = typeof val === 'object' ? val.value : val; const s = parseInt(value); if (!isNaN(s)) { updateTask(props.task.id, { status: s }); } }; const updateUrgency = (val: any) => { const value = typeof val === 'object' ? val.value : val; const u = parseInt(value); if (!isNaN(u)) { const newDate = calculateDateFromUrgency(u); updateTask(props.task.id, { dueDate: newDate }); } }; const updateTags = (tags: string[]) => { const ctx = currentTaskContext(); let newTags = [...tags]; // If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags') if (typeof ctx === 'object' && 'bucketId' in ctx) { const bucketTagName = `@${(ctx as { name: string }).name}`; // Check case-insensitive existence if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) { newTags.push(bucketTagName); } } updateTask(props.task.id, { tags: newTags }); }; const onDateInputChange = (e: Event) => { const val = (e.currentTarget as HTMLInputElement).value; if (val) { const iso = new Date(val).toISOString(); updateTask(props.task.id, { dueDate: iso }); } }; // Calculate current urgency level for display const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString(); // Filter out bucket tags if we are currently INSIDE that bucket view const visibleTags = createMemo(() => { const tags = props.task.tags || []; const ctx = currentTaskContext(); if (typeof ctx === 'object' && 'bucketId' in ctx) { // We are in a bucket view. Hide the tag that matches this bucket's name. // Buckets are now tagged with '@' + name. const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`; return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__")); } return tags.filter(t => !t.endsWith("_favorite__")); }); const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence)); return ( { if (!open) { // Flash save before closing to avoid lockup if (pendingContent !== null) { updateTask(props.task.id, { content: pendingContent }); pendingContent = null; } props.onClose(); } }}> { // Prevent auto-focus on mobile to avoid keyboard popup if (window.innerWidth < 768) { e.preventDefault(); } }} class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl" > {/* Header Area */}
value={(props.task.status ?? 0).toString()} onChange={(val: string | null) => val && updateStatus(val)} options={statusOptions()} optionValue="value" optionTextValue="label" placeholder="Status" itemComponent={(props: any) => {props.item.rawValue.label}} > { e.preventDefault(); e.stopPropagation(); updateStatus("10"); }} >