329 lines
16 KiB
TypeScript
329 lines
16 KiB
TypeScript
import { type Component, createEffect, createSignal, For } from "solid-js";
|
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
import { type Task, removeTask, restoreTask, updateTask } from "@/store";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X } from "lucide-solid";
|
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
|
import { Button } from "./ui/button";
|
|
import { TagPicker } from "./TagPicker";
|
|
import { Badge } from "./ui/badge";
|
|
import { store } from "@/store";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "solid-sonner";
|
|
import { lazy, Suspense } from "solid-js";
|
|
|
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
|
|
|
interface TaskDetailProps {
|
|
task: Task;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|
// Local state for immediate feedback, synced with props
|
|
const [title, setTitle] = createSignal(props.task.title);
|
|
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
|
|
|
// Sync input string for datetime-local
|
|
const [dateString, setDateString] = createSignal<string>("");
|
|
|
|
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);
|
|
});
|
|
|
|
let debounceTimer: number | undefined;
|
|
const updateTaskContent = (html: string) => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
updateTask(props.task.id, { content: html });
|
|
}, 1000);
|
|
};
|
|
|
|
const updateTitle = (val: string) => {
|
|
setTitle(val);
|
|
updateTask(props.task.id, { title: val });
|
|
};
|
|
|
|
const updatePriority = (val: string) => {
|
|
const p = parseInt(val);
|
|
if (!isNaN(p)) {
|
|
updateTask(props.task.id, { priority: p });
|
|
}
|
|
};
|
|
|
|
const updateUrgency = (val: string) => {
|
|
const u = parseInt(val);
|
|
if (!isNaN(u)) {
|
|
const newDate = calculateDateFromUrgency(u);
|
|
updateTask(props.task.id, { dueDate: newDate });
|
|
}
|
|
};
|
|
|
|
const updateTags = (tags: string[]) => {
|
|
updateTask(props.task.id, { tags });
|
|
};
|
|
|
|
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();
|
|
|
|
return (
|
|
<Sheet open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
|
|
<SheetContent hideCloseButton 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 */}
|
|
<div class="px-6 pt-6 pb-2 shrink-0">
|
|
<textarea
|
|
id="task-detail-title"
|
|
name="task-title"
|
|
value={title()}
|
|
onInput={(e) => {
|
|
updateTitle(e.currentTarget.value);
|
|
// Auto-resize
|
|
e.currentTarget.style.height = 'auto';
|
|
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
|
}}
|
|
ref={(el) => {
|
|
setTimeout(() => {
|
|
el.style.height = 'auto';
|
|
el.style.height = el.scrollHeight + 'px';
|
|
}, 0);
|
|
}}
|
|
rows={1}
|
|
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight"
|
|
placeholder="Untitled Task"
|
|
/>
|
|
|
|
{/* Properties Bar */}
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 text-sm">
|
|
{/* Priority */}
|
|
<div class="flex items-center gap-1 group shrink-0">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
|
<Select
|
|
value={props.task.priority.toString()}
|
|
onChange={(val: string | null) => val && updatePriority(val)}
|
|
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
|
placeholder="Priority"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
|
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
|
|
<ArrowUpCircle size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
|
|
<span class="text-xs sm:text-sm">{props.task.priority}</span>
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Urgency */}
|
|
<div class="flex items-center gap-1 group shrink-0">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Time</span>
|
|
<Select
|
|
value={currentUrgency()}
|
|
onChange={(val: string | null) => val && updateUrgency(val)}
|
|
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
|
placeholder="Urgency"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
|
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
|
|
<Clock size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
|
|
<span class="text-xs sm:text-sm">{currentUrgency()}</span>
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Commands Shortcut */}
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-8 px-1 sm:px-2 text-[10px] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => {
|
|
const instance = editorInstance();
|
|
if (instance) {
|
|
instance.chain().focus().insertContent("/").run();
|
|
}
|
|
}}
|
|
>
|
|
<Type size={14} class="opacity-70" />
|
|
<span class="hidden sm:inline">Commands</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metadata Row (Dates/Timestamps) */}
|
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 px-1">
|
|
<MetadataItem
|
|
label="Due"
|
|
date={new Date(props.task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.dueDate).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
icon={<Calendar size={12} />}
|
|
editable={true}
|
|
onClick={() => (document.getElementById('task-detail-date') as HTMLInputElement)?.showPicker?.()}
|
|
>
|
|
<input
|
|
id="task-detail-date"
|
|
name="due-date"
|
|
type="datetime-local"
|
|
value={dateString()}
|
|
onInput={onDateInputChange}
|
|
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
|
/>
|
|
</MetadataItem>
|
|
|
|
<MetadataItem
|
|
label="Created"
|
|
date={new Date(props.task.created).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.created).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
/>
|
|
|
|
<MetadataItem
|
|
label="Updated"
|
|
date={new Date(props.task.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.updated).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
/>
|
|
</div>
|
|
|
|
<div class="h-px w-full bg-border mt-5" />
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
|
|
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
|
|
<TaskEditor
|
|
content={props.task.content}
|
|
onUpdate={updateTaskContent}
|
|
onEditorReady={setEditorInstance}
|
|
/>
|
|
</Suspense>
|
|
|
|
{/* Tags at the bottom */}
|
|
<div class="mt-auto pt-6 border-t border-border/50">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
|
<div class="flex flex-wrap items-center gap-1.5 min-w-0 flex-1">
|
|
<TagPicker
|
|
selectedTags={props.task.tags || []}
|
|
onTagsChange={updateTags}
|
|
/>
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<For each={props.task.tags || []}>
|
|
{(tag) => (
|
|
<Badge
|
|
variant="secondary"
|
|
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
|
|
onClick={() => updateTags((props.task.tags || []).filter(t => t !== tag))}
|
|
>
|
|
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
|
{tag}
|
|
</Badge>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky Footer Actions */}
|
|
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm">
|
|
{/* Delete button (persistent) */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-9 px-3 gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40"
|
|
onClick={() => {
|
|
const taskId = props.task.id;
|
|
removeTask(taskId);
|
|
props.onClose();
|
|
toast.info("Task moved to trash", {
|
|
action: {
|
|
label: "Undo",
|
|
onClick: () => restoreTask(taskId)
|
|
}
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
<span class="text-xs font-bold uppercase tracking-wider">Delete</span>
|
|
</Button>
|
|
|
|
{/* Close button (Mobile only) */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="md:hidden h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40"
|
|
onClick={() => props.onClose()}
|
|
>
|
|
<X size={16} />
|
|
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
|
</Button>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
};
|
|
|
|
// Reusable Metadata Item with slide transition
|
|
const MetadataItem: Component<{
|
|
label: string,
|
|
date: string,
|
|
time: string,
|
|
icon?: any,
|
|
editable?: boolean,
|
|
urgency?: string,
|
|
children?: any,
|
|
onClick?: () => void
|
|
}> = (props) => {
|
|
const [isToggled, setIsToggled] = createSignal(false);
|
|
|
|
return (
|
|
<div
|
|
class="group relative flex flex-col gap-0.5 cursor-pointer select-none"
|
|
onMouseEnter={() => setIsToggled(true)}
|
|
onMouseLeave={() => setIsToggled(false)}
|
|
onClick={() => {
|
|
setIsToggled(!isToggled());
|
|
props.onClick?.();
|
|
}}
|
|
>
|
|
<span class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60 transition-colors group-hover:text-primary/70">
|
|
{props.label}
|
|
</span>
|
|
<div class="h-4 overflow-hidden relative min-w-[100px]">
|
|
<div class={cn(
|
|
"flex flex-col transition-transform duration-500 cubic-bezier(0.4, 0, 0.2, 1)",
|
|
isToggled() ? "-translate-y-4" : "translate-y-0"
|
|
)}>
|
|
{/* Layer 1: Date */}
|
|
<div class="h-4 flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 whitespace-nowrap">
|
|
{props.icon}
|
|
{props.date}
|
|
</div>
|
|
{/* Layer 2: Time */}
|
|
<div class="h-4 flex items-center gap-2 text-[11px] font-bold text-foreground whitespace-nowrap">
|
|
<Clock size={11} class="text-primary/60 shrink-0" />
|
|
<span>{props.time}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
};
|