added templates and fixed UX for virtual keyboard opening when opening a task

This commit is contained in:
2026-01-31 17:35:22 -06:00
parent 2e17ffee27
commit 064aca3ced
4 changed files with 544 additions and 151 deletions
+71 -6
View File
@@ -1,6 +1,6 @@
import { type Component, createSignal, createEffect, onCleanup, onMount, For } from "solid-js";
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions } from "@/store";
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions, type TaskTemplate } from "@/store";
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@@ -8,6 +8,8 @@ import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker";
import { lazy, Suspense } from "solid-js";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { toast } from "solid-sonner";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -219,6 +221,29 @@ export const QuickEntry: Component = () => {
if (instance) instance.commands.setContent("");
};
const applyTemplate = (template: TaskTemplate) => {
setInput(template.title);
setPriority(template.priority.toString());
setUrgency(template.urgency.toString());
setTags([...(template.tags || [])]);
setDescription(template.content || "");
// Sync urgency to date
const level = template.urgency;
const newDateIso = calculateDateFromUrgency(level);
const dateObj = new Date(newDateIso);
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
setDateString(localIso);
// Update editor if ready
const instance = editorInstance();
if (instance) {
instance.commands.setContent(template.content || "");
}
toast.info(`Applied template: ${template.name}`);
};
return (
<>
<Button
@@ -352,9 +377,49 @@ export const QuickEntry: Component = () => {
<span class="hidden sm:inline">K to focus Enter to Add</span>
<span class="sm:hidden">Enter to Add</span>
</div>
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
Create Task
</Button>
<div class="flex items-center gap-2">
<Show when={(store.templates || []).length > 0}>
<Popover>
<PopoverTrigger
as={Button}
variant="ghost"
size="sm"
class="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
>
<Copy size={14} />
<span class="text-[10px] font-bold uppercase tracking-wider hidden sm:inline">Use Template</span>
<ChevronDown size={12} class="opacity-50" />
</PopoverTrigger>
<PopoverContent class="w-64 p-2 bg-card border-border shadow-xl">
<div class="space-y-1">
<For each={store.templates}>
{(template) => (
<button
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-muted transition-colors text-left group"
onClick={() => applyTemplate(template)}
>
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0 group-hover:bg-primary group-hover:text-primary-foreground transition-all">
<Copy size={14} />
</div>
<div class="min-w-0">
<p class="text-sm font-bold truncate tracking-tight">{template.name}</p>
<p class="text-[9px] uppercase font-black text-muted-foreground opacity-60 tracking-widest">
P:{template.priority} / U:{template.urgency}
</p>
</div>
</button>
)}
</For>
</div>
</PopoverContent>
</Popover>
</Show>
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
Create Task
</Button>
</div>
</div>
</div>
</div>
+41 -13
View File
@@ -1,8 +1,8 @@
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 { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X } from "lucide-solid";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy } from "lucide-solid";
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
import { TagPicker } from "./TagPicker";
@@ -81,7 +81,16 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
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">
<SheetContent
hideCloseButton
onOpenAutoFocus={(e) => {
// 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 */}
<div class="px-6 pt-6 pb-2 shrink-0">
<textarea
@@ -263,16 +272,35 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<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 class="flex items-center gap-2">
{/* Save as Template (Desktop + Mobile) */}
<Button
variant="ghost"
size="sm"
class="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
onClick={async () => {
const name = prompt("Enter a name for this template:");
if (name) {
await saveTaskAsTemplate(props.task.id, name);
toast.success(`Template "${name}" created`);
}
}}
>
<Copy size={16} />
<span class="text-xs font-bold uppercase tracking-wider">Template</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>
</div>
</SheetContent>
</Sheet>
+70
View File
@@ -27,6 +27,16 @@ export interface Task {
updated: string; // ISO string from PB
}
export interface TaskTemplate {
id: string;
name: string;
title: string;
priority: number;
urgency: number;
tags: string[];
content: string;
}
export interface Filter {
query: string;
tags: string[];
@@ -43,6 +53,7 @@ interface TaskStore {
matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record
tagDefinitions?: Record<string, number>;
templates?: TaskTemplate[];
filter: Filter;
}
@@ -53,6 +64,7 @@ export const [store, setStore] = createStore<TaskStore>({
uWeight: 1.0,
matrixScaleDays: 30,
tagDefinitions: {}, // Map<Name, Value 0-10>
templates: [],
filter: {
query: "",
tags: [],
@@ -190,6 +202,7 @@ export const initStore = async () => {
uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30,
tagDefinitions: prefs.tagDefinitions || {},
templates: prefs.templates || [],
prefId: userId // The ID to update is the User ID
});
}
@@ -583,6 +596,63 @@ export const renameTagDefinition = async (oldName: string, newName: string) => {
};
// -- Templates --
const syncPreferences = async () => {
const userId = pb.authStore.model?.id;
if (!userId) return;
try {
const prefs = {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: store.matrixScaleDays,
tagDefinitions: store.tagDefinitions,
templates: store.templates
};
await pb.collection('users').update(userId, {
Taskgrid_pref: prefs
}, { requestKey: null });
} catch (e: any) {
if (e.isAbort) return;
console.error("Failed to sync preferences", e);
}
};
export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
const newTemplate = { ...template, id: crypto.randomUUID() };
setStore("templates", (prev) => [...(prev || []), newTemplate]);
await syncPreferences();
return newTemplate;
};
export const updateTemplate = async (id: string, updates: Partial<TaskTemplate>) => {
setStore("templates", (t) => t!.id === id, updates);
await syncPreferences();
};
export const removeTemplate = async (id: string) => {
setStore("templates", (t) => t.filter(tmpl => tmpl.id !== id));
await syncPreferences();
};
export const saveTaskAsTemplate = async (taskId: string, name: string) => {
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const template: Omit<TaskTemplate, "id"> = {
name,
title: task.title,
priority: task.priority,
urgency: calculateUrgencyFromDate(task.dueDate),
tags: [...(task.tags || [])],
content: task.content || ""
};
return await addTemplate(template);
};
export const setFilter = (update: Partial<Filter>) => {
setStore("filter", (f) => ({ ...f, ...update }));
};
+362 -132
View File
@@ -1,16 +1,22 @@
import { type Component, For } from "solid-js";
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays } from "@/store";
import { Trash2, Undo2, ArrowLeftRight, Hash } from "lucide-solid";
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "solid-sonner";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { pb } from "@/lib/pocketbase";
import { upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
import { createSignal } from "solid-js";
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
export const SettingsView: Component = () => {
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
return (
<div class="w-full max-w-2xl mx-auto py-4 sm:py-10 px-4 sm:px-0 space-y-8 sm:space-y-12 animate-in fade-in slide-in-from-bottom-2 duration-500 min-w-0 overflow-hidden text-balance">
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -31,6 +37,7 @@ export const SettingsView: Component = () => {
</header>
<div class="grid gap-6">
{/* Appearance */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="space-y-0.5">
@@ -70,149 +77,372 @@ export const SettingsView: Component = () => {
</div>
</section>
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Hash size={16} />
Global Tags
</h3>
<p class="text-sm text-muted-foreground">Manage your global tag definitions and values.</p>
{/* Global Tag Manager */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
<div
class="flex items-center justify-between cursor-pointer group"
onClick={() => setIsTagsOpen(!isTagsOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Hash size={16} />
Global Tags
</h3>
<p class="text-sm text-muted-foreground">Manage your global tag definitions and values.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isTagsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<div class="space-y-2">
<For each={Object.entries(store.tagDefinitions || {}).sort((a, b) => a[0].localeCompare(b[0]))} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No specific tag definitions found.
</div>
}>
{([tagName, val]) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tagName);
<Show when={isTagsOpen()}>
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={Object.entries(store.tagDefinitions || {}).sort((a, b) => a[0].localeCompare(b[0]))} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No specific tag definitions found.
</div>
}>
{([tagName, val]) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tagName);
const handleRename = () => {
if (tempName() && tempName() !== tagName) {
renameTagDefinition(tagName, tempName());
}
setIsEditingName(false);
};
const handleRename = () => {
if (tempName() && tempName() !== tagName) {
renameTagDefinition(tagName, tempName());
}
setIsEditingName(false);
};
return (
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 gap-3 sm:gap-4">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class={cn("w-2 h-2 rounded-full shrink-0", val > 5 ? "bg-green-500" : val < 5 ? "bg-red-500" : "bg-gray-400")} />
return (
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 gap-3 sm:gap-4 group">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class={cn("w-2 h-2 rounded-full shrink-0", val > 5 ? "bg-green-500" : val < 5 ? "bg-red-500" : "bg-gray-400")} />
{isEditingName() ? (
<input
class="flex h-7 min-w-0 w-full rounded-md border border-input bg-background px-2 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tempName()}
onInput={(e) => setTempName(e.currentTarget.value)}
onBlur={handleRename}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autofocus
/>
) : (
<span
class="font-medium cursor-pointer hover:underline decoration-dashed underline-offset-4 truncate"
onClick={() => setIsEditingName(true)}
title="Click to rename"
>
{tagName}
</span>
)}
</div>
<div class="flex items-center grow sm:grow-0 justify-between sm:justify-end gap-3 sm:gap-4 shrink-0 border-t sm:border-t-0 pt-2 sm:pt-0 border-border/20">
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={String(val)}
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
class="flex h-8 w-16 items-center justify-between rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
{isEditingName() ? (
<input
class="flex h-7 min-w-0 w-full rounded-md border border-input bg-background px-2 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tempName()}
onInput={(e) => setTempName(e.currentTarget.value)}
onBlur={handleRename}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autofocus
/>
) : (
<span
class="font-medium cursor-pointer hover:underline decoration-dashed underline-offset-4 truncate"
onClick={() => setIsEditingName(true)}
title="Click to rename"
>
{tagName}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
removeTagDefinition(tagName);
}
}}
>
<Trash2 size={14} />
</Button>
<div class="flex items-center grow sm:grow-0 justify-between sm:justify-end gap-3 sm:gap-4 shrink-0 border-t sm:border-t-0 pt-2 sm:pt-0 border-border/20">
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={String(val)}
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
class="flex h-8 w-16 items-center justify-between rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
removeTagDefinition(tagName);
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</div>
);
}}
</For>
</div>
);
}}
</For>
</div>
</Show>
</section>
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 min-w-0 overflow-hidden">
<div class="space-y-0.5 min-w-0">
<h3 class="text-base font-semibold flex items-center gap-2">
<Trash2 size={16} />
Trash
</h3>
<p class="text-sm text-muted-foreground truncate">Items are permanently deleted after 7 days.</p>
</div>
<div class="space-y-2">
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
Trash is empty.
{/* Task Template Manager */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
<div class="flex items-center justify-between">
<div
class="flex flex-1 items-center justify-between cursor-pointer group mr-4"
onClick={() => setIsTemplatesOpen(!isTemplatesOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Copy size={16} />
Task Templates
</h3>
<p class="text-sm text-muted-foreground">Save frequent task configurations for quick reuse.</p>
</div>
}>
{(task) => {
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
<div class="min-w-0 flex-1 px-1">
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
<p class="text-[11px] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
</div>
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
<Button
variant="secondary"
size="sm"
class="h-9 px-4 text-[10px] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
onClick={() => {
restoreTask(task.id);
toast.success("Task restored");
}}
>
<Undo2 size={14} class="mr-2" />
Recover
</Button>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
onClick={() => {
if (confirm("Permanently delete this task? This cannot be undone.")) {
deleteTaskPermanently(task.id);
toast.error("Task permanently deleted");
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isTemplatesOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Button
size="sm"
class="h-9 gap-2 rounded-xl font-bold uppercase tracking-wider text-[10px]"
onClick={() => {
addTemplate({
name: "New Template",
title: "",
priority: 5,
urgency: 5,
tags: [],
content: ""
});
toast.success("New template created");
}}
</For>
>
<Plus size={14} />
New
</Button>
</div>
<Show when={isTemplatesOpen()}>
<div class="grid gap-3 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={store.templates || []} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No templates created yet.
</div>
}>
{(template) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [editName, setEditName] = createSignal(template.name);
const isExpanded = () => expandedTemplateId() === template.id;
const handleSaveName = () => {
if (editName() && editName() !== template.name) {
updateTemplate(template.id, { name: editName() });
}
setIsEditingName(false);
};
return (
<div class={cn(
"flex flex-col rounded-2xl border transition-all overflow-hidden shadow-sm",
isExpanded() ? "bg-card border-primary/50 ring-1 ring-primary/20" : "bg-muted/20 border-border/40 hover:bg-muted/40"
)}>
<div class="flex items-center justify-between gap-3 p-3.5">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class={cn(
"w-8 h-8 rounded-xl flex items-center justify-center shrink-0 transition-colors cursor-pointer",
isExpanded() ? "bg-primary text-primary-foreground" : "bg-primary/10 text-primary"
)}
onClick={() => setExpandedTemplateId(isExpanded() ? null : template.id)}
>
<Copy size={16} />
</div>
{isEditingName() ? (
<input
class="flex h-8 min-w-0 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
onBlur={handleSaveName}
onKeyDown={(e) => e.key === "Enter" && handleSaveName()}
autofocus
/>
) : (
<div class="min-w-0 cursor-pointer flex-1" onClick={() => setExpandedTemplateId(isExpanded() ? null : template.id)}>
<p class="font-bold text-sm tracking-tight truncate">{template.name}</p>
<p class="text-[10px] text-muted-foreground uppercase font-black tracking-widest opacity-60">
P:{template.priority} / U:{template.urgency} / {template.tags?.length || 0} Tags
</p>
</div>
)}
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-primary/10 hover:text-primary rounded-xl"
onClick={(e) => {
e.stopPropagation();
setIsEditingName(true);
}}
>
<Type size={14} />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600 rounded-xl"
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete template "${template.name}"?`)) {
removeTemplate(template.id);
toast.error("Template removed");
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
<Show when={isExpanded()}>
<div class="border-t border-border/50 p-4 space-y-4 bg-background/50 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Title Placeholder</label>
<input
class="flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={template.title}
onInput={(e) => updateTemplate(template.id, { title: e.currentTarget.value })}
placeholder="Default task title..."
/>
</div>
<div class="flex gap-4">
<div class="flex-1 space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Priority</label>
<select
class="flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus:ring-1 focus:ring-primary appearance-none px-2"
value={template.priority}
onChange={(e) => updateTemplate(template.id, { priority: parseInt(e.currentTarget.value) })}
>
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
</div>
<div class="flex-1 space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Urgency</label>
<select
class="flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus:ring-1 focus:ring-primary appearance-none px-2"
value={template.urgency}
onChange={(e) => updateTemplate(template.id, { urgency: parseInt(e.currentTarget.value) })}
>
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
</div>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Tags</label>
<div class="flex flex-wrap gap-2 p-2 rounded-xl bg-muted/30 border border-border/40">
<TagPicker
selectedTags={template.tags || []}
onTagsChange={(tags) => updateTemplate(template.id, { tags })}
/>
<For each={template.tags || []}>
{(tag) => (
<button
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-background border border-border/50 text-xs hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
onClick={() => updateTemplate(template.id, { tags: (template.tags || []).filter(t => t !== tag) })}
>
<div class={cn("w-1.5 h-1.5 rounded-full", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
{tag}
</button>
)}
</For>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Description (Content)</label>
<div class="min-h-[120px] rounded-xl border border-border/40 bg-background/50 p-2 overflow-hidden prose-sm">
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
<TaskEditor
content={template.content}
onUpdate={(html) => updateTemplate(template.id, { content: html })}
class="min-h-[100px]"
/>
</Suspense>
</div>
</div>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</section>
{/* Trash Manager */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
<div
class="flex items-center justify-between cursor-pointer group"
onClick={() => setIsTrashOpen(!isTrashOpen())}
>
<div class="space-y-0.5 min-w-0">
<h3 class="text-base font-semibold flex items-center gap-2">
<Trash2 size={16} />
Trash
</h3>
<p class="text-sm text-muted-foreground truncate">Items are permanently deleted after 7 days.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isTrashOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isTrashOpen()}>
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
Trash is empty.
</div>
}>
{(task) => {
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
<div class="min-w-0 flex-1 px-1">
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
<p class="text-[11px] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
</div>
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
<Button
variant="secondary"
size="sm"
class="h-9 px-4 text-[10px] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
onClick={() => {
restoreTask(task.id);
toast.success("Task restored");
}}
>
<Undo2 size={14} class="mr-2" />
Recover
</Button>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
onClick={() => {
if (confirm("Permanently delete this task? This cannot be undone.")) {
deleteTaskPermanently(task.id);
toast.error("Task permanently deleted");
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
</Show>
</section>
</div>
</div>
);