Files
TasGrid/src/views/SettingsView.tsx
T
tcardoza 6a963a32fd
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m19s
CI / deploy_dev (push) Has been skipped
Added custom PWA reloading - 10 minutes of idle or when moved to the background or when loading initially or manually with the new button in settings
2026-03-04 19:29:18 -06:00

1109 lines
88 KiB
TypeScript

import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
import { useTheme } from "@/components/ThemeProvider";
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } 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, SelectValue } from "@/components/ui/select";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
import { pb } from "@/lib/pocketbase";
import { createEffect } from "solid-js";
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
const ImportTool = lazy(() => import("../components/ImportTool").then(m => ({ default: m.ImportTool })));
export const SettingsView: Component<{ setView?: (v: string) => void }> = (props) => {
const { resolvedTheme } = useTheme();
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false);
const [isBucketsOpen, setIsBucketsOpen] = createSignal(false);
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
const [shareUserId, setShareUserId] = createSignal("");
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER');
const [shareTag, setShareTag] = createSignal<string>("");
const [allUsers, setAllUsers] = createSignal<Array<{ id: string; name: string }>>([]);
const [usersLoading, setUsersLoading] = createSignal(false);
// Fetch users when sharing section opens
createEffect(() => {
if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) {
setUsersLoading(true);
pb.collection('users').getFullList({ fields: 'id,name,email' })
.then(records => {
setAllUsers(records.map((r: any) => ({ id: r.id, name: r.name || r.email || r.id })));
})
.catch(err => console.error("Failed to load users:", err))
.finally(() => setUsersLoading(false));
}
});
const getUserName = (userId: string) => {
return allUsers().find(u => u.id === userId)?.name || userId;
};
const availableUsers = () => {
const currentUserId = pb.authStore.model?.id;
return allUsers().filter(u => u.id !== currentUserId);
};
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">
<div class="min-w-0 flex-1">
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter">Settings</h1>
<p class="text-[0.625rem] sm:text-xs text-muted-foreground truncate opacity-70 font-mono tracking-tight" title={pb.authStore.model?.email}>
{pb.authStore.model?.email}
</p>
</div>
<div class="flex items-center gap-3 w-full sm:w-auto">
<div class="shrink-0">
<ThemeToggle />
</div>
<Button
variant="outline"
size="sm"
class="flex-1 sm:w-auto font-black uppercase tracking-widest text-[0.625rem] h-10 sm:h-8 rounded-xl shadow-sm border-border/60"
onClick={() => { pb.authStore.clear(); location.reload(); }}
>
Sign Out
</Button>
</div>
</header>
<div class="flex flex-col gap-12 mt-4">
{/* Resources Category (Mostly for Mobile) */}
<div class="space-y-3 md:hidden">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Resources</h2>
<button
onClick={() => props.setView ? props.setView("help") : (window as any).setCurrentView?.("help")}
class="w-full p-4 rounded-2xl border border-primary/20 bg-primary/5 shadow-sm flex items-center justify-between group active:scale-[0.98] transition-all"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<HelpCircle size={20} />
</div>
<div class="text-left">
<h3 class="text-sm font-bold">User Help Guide</h3>
<p class="text-[0.625rem] text-muted-foreground">Learn how to use TaskGrid effectively.</p>
</div>
</div>
<ChevronRight size={16} class="text-muted-foreground group-hover:text-primary transition-colors" />
</button>
</div>
{/* Sharing Category */}
<div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Sharing</h2>
<div class="grid gap-4">
{/* Sharing */}
<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={() => setIsSharingOpen(!isSharingOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Share2 size={16} />
Task Sharing
</h3>
<p class="text-sm text-muted-foreground">Share all tasks or tasks with specific tags with others.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isSharingOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isSharingOpen()}>
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Add new share rule */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<div class="flex gap-2">
<Button
variant={shareType() === 'all' ? 'default' : 'outline'}
size="sm"
class="h-8 text-xs gap-1"
onClick={() => setShareType('all')}
>
<Users size={12} />
All Tasks
</Button>
<Button
variant={shareType() === 'tag' ? 'default' : 'outline'}
size="sm"
class="h-8 text-xs gap-1"
onClick={() => setShareType('tag')}
>
<Tag size={12} />
By Tag
</Button>
</div>
{/* Share Mode Selection */}
<div class="flex gap-2">
<Button
variant={shareMode() === 'ADD_USER' ? 'default' : 'outline'}
size="sm"
class="h-7 text-[0.625rem] gap-1 px-2"
title="Recipient gets edit access (Collaboration)"
onClick={() => setShareMode('ADD_USER')}
>
<Users size={10} />
Collaborate
</Button>
<Button
variant={shareMode() === 'SEND_TASK' ? 'default' : 'outline'}
size="sm"
class="h-7 text-[0.625rem] gap-1 px-2"
title="Task ownership is transferred to recipient (Handoff)"
onClick={() => setShareMode('SEND_TASK')}
>
<ArrowLeftRight size={10} />
Handoff
</Button>
</div>
<Show when={shareType() === 'tag'}>
<Select
value={shareTag()}
onChange={(val) => {
if (val) {
setShareTag(val);
const tagDef = store.tagDefinitions.find(t => t.name === val);
const isBucket = tagDef?.isBucket || store.buckets.some(b => b.name === val);
const isUser = tagDef?.isUser;
if (isBucket) {
setShareMode('SEND_TASK');
} else if (isUser) {
setShareMode('ADD_USER');
}
}
}}
options={store.tagDefinitions.map(t => t.name)}
placeholder="Select a tag"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 text-sm">
<span>{shareTag() || "Select a tag..."}</span>
</SelectTrigger>
<SelectContent />
</Select>
</Show>
<div class="flex gap-2">
<Select<{ id: string; name: string }>
value={allUsers().find(u => u.id === shareUserId()) || null}
onChange={(user) => setShareUserId(user?.id || "")}
options={availableUsers()}
optionValue="id"
optionTextValue="name"
placeholder={usersLoading() ? "Loading..." : "Select user..."}
itemComponent={(itemProps: any) => (
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
)}
>
<SelectTrigger class="flex-1 h-9 text-sm">
<SelectValue<{ id: string; name: string }>>
{(state) => state.selectedOption()?.name || "Select user..."}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
<Button
size="sm"
class="h-9 gap-2"
disabled={!shareUserId()}
onClick={async () => {
if (shareUserId()) {
await addShareRule(shareType(), shareUserId(), shareType() === 'tag' ? shareTag() : undefined, shareMode());
setShareUserId("");
}
}}
>
<Plus size={14} />
Share
</Button>
</div>
<p class="text-[0.625rem] text-muted-foreground italic px-1 pt-1">
{shareMode() === 'ADD_USER'
? "User will be added to the task's shared list."
: "Task owner will change to the recipient immediately when tagged."}
</p>
</div>
{/* Active share rules */}
{/* Active share rules - Outgoing */}
<div class="space-y-3">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pb-1 flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-primary" />
Sharing With Others
</h4>
<For each={store.shareRules.filter(r => {
const tagDef = r.type === 'tag' ? store.tagDefinitions.find(t => t.name === r.tagName) : null;
const isBucketTag = r.type === 'tag' && store.buckets.some(b => b.name === r.tagName);
const isSystem = tagDef?.isUser || tagDef?.isBucket || isBucketTag;
const isOwner = r.ownerId === pb.authStore.model?.id;
return !isSystem && isOwner;
})} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
You aren't sharing with anyone yet.
</div>
}>
{(rule) => (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3 group/rule hover:bg-muted/30 transition-all">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0 transition-transform group-hover/rule:scale-110">
{rule.type === 'all' ? <Users size={14} /> : <Tag size={14} />}
</div>
<div class="min-w-0">
<p class="text-xs font-bold truncate">To: {getUserName(rule.targetUserId)}</p>
<p class="text-[0.5625rem] text-muted-foreground uppercase font-black tracking-widest opacity-60 flex items-center gap-1">
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName} `}
<span class="mx-1">•</span>
<button
class="hover:text-primary transition-colors hover:underline"
onClick={() => updateShareRule(rule.id, {
share_mode: rule.share_mode === 'SEND_TASK' ? 'ADD_USER' : 'SEND_TASK'
})}
>
{rule.share_mode === 'SEND_TASK' ? 'HANDOFF' : 'COLLAB'}
</button>
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all opacity-0 group-hover/rule:opacity-100"
onClick={() => removeShareRule(rule.id)}
>
<Trash2 size={14} />
</Button>
</div>
)}
</For>
</div>
{/* Active share rules - Incoming */}
<div class="space-y-3 pt-4 border-t border-border/20">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pb-1 flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-indigo-500" />
Shared With You
</h4>
<For each={store.shareRules.filter(r => {
const tagDef = r.type === 'tag' ? store.tagDefinitions.find(t => t.name === r.tagName) : null;
const isBucketTag = r.type === 'tag' && store.buckets.some(b => b.name === r.tagName);
const isSystem = tagDef?.isUser || tagDef?.isBucket || isBucketTag;
const isTarget = r.targetUserId === pb.authStore.model?.id;
return !isSystem && isTarget;
})} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
No one has shared with you yet.
</div>
}>
{(rule) => (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3 group/rule hover:bg-muted/30 transition-all opacity-80">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500 shrink-0">
{rule.type === 'all' ? <Users size={14} /> : <Tag size={14} />}
</div>
<div class="min-w-0">
<p class="text-xs font-bold truncate">From: {getUserName(rule.ownerId)}</p>
<p class="text-[0.5625rem] text-muted-foreground uppercase font-black tracking-widest opacity-60 flex items-center gap-1">
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName} `}
<span class="mx-1">•</span>
{rule.share_mode === 'SEND_TASK' ? 'HANDOFF' : 'COLLAB'}
</p>
</div>
</div>
{/* No delete button for incoming rules unless we add leave logic later */}
</div>
)}
</For>
</div>
{/* Active share rules - System */}
<details class="group/details">
<summary class="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-muted-foreground cursor-pointer hover:text-foreground transition-colors py-2 select-none">
<div class="bg-muted/50 p-1 rounded-md group-open/details:rotate-90 transition-transform">
<ChevronRight size={12} />
</div>
System Rules (Auto-Generated)
</summary>
<div class="pt-2 pl-4 space-y-2 animate-in fade-in slide-in-from-top-1 duration-200">
<For each={store.shareRules.filter(r => {
const tagDef = r.type === 'tag' ? store.tagDefinitions.find(t => t.name === r.tagName) : null;
const isBucketTag = r.type === 'tag' && store.buckets.some(b => b.name === r.tagName);
return tagDef?.isUser || tagDef?.isBucket || isBucketTag;
})}>
{(rule) => {
const tagDef = store.tagDefinitions.find(t => t.name === rule.tagName);
return (
<div class="flex items-center justify-between p-2 rounded-lg bg-muted/10 border border-border/20 gap-3 opacity-70">
<div class="flex items-center gap-3 min-w-0 flex-1">
<Tag size={14} class="text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate flex items-center gap-2">
{/* If it's a user tag, show the person's name. If it's a bucket, show the bucket name (tagName). */}
{tagDef?.isUser ? getUserName(rule.targetUserId) : rule.tagName}
{tagDef?.isUser ? <Users size={10} class="text-muted-foreground" /> : <Archive size={10} class="text-muted-foreground" />}
</p>
<p class="text-[0.625rem] text-muted-foreground uppercase tracking-wider flex items-center gap-1">
Auto-shared via {tagDef?.isUser ? 'User' : 'Bucket'} Tag
<span class="mx-1"></span>
<button
class="hover:text-primary transition-colors hover:underline"
onClick={() => updateShareRule(rule.id, {
share_mode: rule.share_mode === 'SEND_TASK' ? 'ADD_USER' : 'SEND_TASK'
})}
>
{rule.share_mode === 'SEND_TASK' ? 'HANDOFF' : 'COLLAB'}
</button>
</p>
</div>
</div>
</div>
);
}}
</For>
</div>
</details>
</div>
</Show>
</section>
{/* Buckets 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={() => setIsBucketsOpen(!isBucketsOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Box size={16} />
Shared Global Buckets
</h3>
<p class="text-sm text-muted-foreground">Manage public task buckets and your subscriptions.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isBucketsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isBucketsOpen()}>
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Create Bucket */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket</h4>
<form
class="flex flex-col sm:flex-row gap-2"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const nameInput = form.elements.namedItem('name') as HTMLInputElement;
const colorInput = form.elements.namedItem('color') as HTMLInputElement;
const descInput = form.elements.namedItem('desc') as HTMLInputElement;
if (nameInput.value) {
createBucket(nameInput.value, colorInput.value || "#6366f1", descInput.value);
nameInput.value = "";
descInput.value = "";
form.reset();
}
}}
>
<div class="flex gap-2 w-full sm:w-auto min-w-0">
<input
name="color"
type="color"
class="w-9 h-9 rounded-lg border border-input p-0.5 bg-background cursor-pointer shrink-0"
value="#6366f1"
/>
<input
name="name"
class="flex h-9 flex-1 sm:w-48 sm:flex-none rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Bucket Name"
required
/>
</div>
<div class="flex gap-2 w-full sm:flex-1 min-w-0">
<input
name="desc"
class="flex flex-1 h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Description (optional)"
/>
<Button size="sm" type="submit" class="h-9 gap-2 shrink-0">
<Plus size={14} />
Create
</Button>
</div>
</form>
</div>
{/* List Buckets */}
<div class="space-y-2">
<For each={store.buckets} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No buckets found.
</div>
}>
{(bucket) => {
const isSubscribed = () => store.subscribedBuckets.includes(bucket.id);
const [isEditing, setIsEditing] = createSignal(false);
const [editName, setEditName] = createSignal(bucket.name);
return (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3 group/bucket hover:bg-muted/30 transition-all">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-2 h-8 rounded-full shrink-0"
style={{ "background-color": bucket.color }}
/>
<div class="min-w-0 flex-1">
<Show when={isEditing()} fallback={
<div class="flex items-center gap-2">
<p class="font-bold text-sm truncate">{bucket.name}</p>
<button onClick={() => setIsEditing(true)} class="opacity-0 group-hover/bucket:opacity-50 hover:opacity-100 transition-opacity">
<Edit2 size={10} />
</button>
</div>
}>
<input
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
onBlur={() => {
if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() });
setIsEditing(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() });
setIsEditing(false);
}
}}
class="h-6 w-full max-w-[150px] rounded border border-input bg-transparent px-1 text-sm"
autofocus
/>
</Show>
<p class="text-[0.625rem] text-muted-foreground truncate opacity-80">{bucket.description || "No description"}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant={isSubscribed() ? "secondary" : "outline"}
size="sm"
class={cn(
"h-8 text-xs gap-2 transition-all",
isSubscribed() ? "bg-primary/20 text-primary hover:bg-primary/30" : "opacity-70 group-hover/bucket:opacity-100"
)}
onClick={() => toggleBucketSubscription(bucket.id)}
>
{isSubscribed() ? (
<>
<Archive size={12} class="rotate-180" />
Pinned
</>
) : (
<>
<Archive size={12} />
Pin
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover/bucket:opacity-100 transition-opacity"
onClick={() => {
if (confirm(`Delete the bucket "${bucket.name}" ? This cannot be undone and will affect all users.`)) {
deleteBucket(bucket.id);
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
</section>
</div>
</div>
{/* Tasks Category */}
<div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Tasks</h2>
<div class="grid gap-4">
{/* 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>
<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-[0.625rem]"
onClick={() => {
addTemplate({
name: "New Template",
title: "",
priority: 5,
urgency: 5,
tags: [],
content: ""
});
toast.success("New template created");
}}
>
<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 [editTitle, setEditTitle] = createSignal(template.title);
const isExpanded = () => expandedTemplateId() === template.id;
const handleSaveName = () => {
if (editName() && editName() !== template.name) {
updateTemplate(template.id, { name: editName() });
}
setIsEditingName(false);
};
const handleSaveTitle = () => {
if (editTitle() !== template.title) {
updateTemplate(template.id, { title: editTitle() });
}
};
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-[0.625rem] 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: MouseEvent) => {
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: MouseEvent) => {
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-[0.625rem] 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={editTitle()}
onInput={(e) => setEditTitle(e.currentTarget.value)}
onBlur={handleSaveTitle}
placeholder="Default task title..."
/>
</div>
<div class="flex gap-4">
<div class="flex-1 space-y-1.5">
<label class="text-[0.625rem] 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={PRIORITY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="flex-1 space-y-1.5">
<label class="text-[0.625rem] 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={URGENCY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[0.625rem] 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="w-1.5 h-1.5 rounded-full"
style={{
"background-color": store.tagDefinitions.find(d => d.name === tag)?.color ||
((store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8")
}}
/>
{tag}
</button>
)}
</For>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[0.625rem] 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) => {
// For the rich editor, we can use a slightly debounced store update
// to avoid losing focus if the parent re-renders,
// although Tiptap usually handles its own state well.
// But to be safe, let's just update the store.
updateTemplate(template.id, { content: html });
}}
class="min-h-[100px]"
/>
</Suspense>
</div>
</div>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</section>
{/* Import Tasks */}
<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={() => setIsImportOpen(!isImportOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Upload size={16} />
Import Tasks
</h3>
<p class="text-sm text-muted-foreground">Paste a list of tasks from another app to import them.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isImportOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isImportOpen()}>
<div class="pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
<ImportTool />
</Suspense>
</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-6 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Tasks Trash */}
<div class="space-y-2">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Tasks</h4>
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
No trashed tasks.
</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-[0.5rem] 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-[0.625rem] 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>
{/* Notes Trash */}
<div class="space-y-2">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Notes</h4>
<For each={store.notes.filter(n => n.deletedAt)} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
No trashed notes.
</div>
}>
{(note) => {
const daysLeft = Math.ceil(((note.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">{note.title || "Untitled"}</p>
<p class="text-[0.5rem] 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-[0.625rem] 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={() => {
restoreNote(note.id);
toast.success("Note 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 note? This cannot be undone.")) {
deleteNotePermanently(note.id);
toast.error("Note permanently deleted");
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
</section>
</div>
</div>
{/* Organization Category */}
<div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Organization</h2>
<div class="grid gap-4">
{/* 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">
<Tag size={16} />
Tag List
</h3>
<p class="text-sm text-muted-foreground">Manage 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>
<Show when={isTagsOpen()}>
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="space-y-2">
<For each={store.tagDefinitions.filter(t => !t.isUser && !t.isBucket && !t.name.startsWith('#') && !t.name.startsWith('@')).sort((a, b) => a.name.localeCompare(b.name))} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No custom tags found.
</div>
}>
{(tag) => {
const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tag.name);
const handleRename = () => {
if (tempName() && tempName() !== tag.name) {
renameTagDefinition(tag.name, 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 group">
<div class="flex items-center gap-3 min-w-0 flex-1">
<input
type="color"
value={tag.color || "#6366f1"}
onInput={(e) => upsertTagDefinition(tag.name, tag.value, e.currentTarget.value, resolvedTheme())}
class="w-4 h-4 rounded-full border-none p-0 bg-transparent cursor-pointer shrink-0 overflow-hidden"
title="Tag accent color"
/>
{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"
>
{tag.name}
</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-[0.625rem] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={String(tag.value)}
onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())}
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 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm(`Delete tag definition for "${tag.name}" ? `)) {
removeTagDefinition(tag.name);
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
{/* User/System Tags Expansion */}
<details class="group/details">
<summary class="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-muted-foreground cursor-pointer hover:text-foreground transition-colors py-2 select-none pt-4">
<div class="bg-muted/50 p-1 rounded-md group-open/details:rotate-90 transition-transform">
<ChevronRight size={12} />
</div>
System Tags (Users, Buckets & Notes)
</summary>
<div class="pt-2 pl-4 space-y-2 animate-in fade-in slide-in-from-top-1 duration-200">
<For each={store.tagDefinitions.filter(t => t.isUser || t.isBucket || t.name.startsWith('#') || t.name.startsWith('@')).sort((a, b) => a.name.localeCompare(b.name))}>
{(tag) => (
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-2 rounded-lg bg-muted/10 border border-border/20 gap-3 sm:gap-4 group opacity-80">
<div class="flex items-center gap-3 min-w-0 flex-1">
<input
type="color"
value={tag.color || "#6366f1"}
onInput={(e) => upsertTagDefinition(tag.name, tag.value, e.currentTarget.value, resolvedTheme())}
class="w-4 h-4 rounded-full border-none p-0 bg-transparent cursor-pointer shrink-0 overflow-hidden"
title="Tag accent color"
/>
<span class="font-medium truncate flex items-center gap-2 text-sm">
{tag.name}
<Users size={12} class="text-muted-foreground opacity-70" />
</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-[0.625rem] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
<select
value={String(tag.value)}
onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())}
class="flex h-7 w-14 items-center justify-between rounded-md border border-input bg-background/50 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>
</div>
</div>
)}
</For>
</div>
</details>
</div>
</Show>
</section>
</div>
</div>
</div>
<footer class="pt-40 pb-20 flex flex-col items-center gap-4">
<Button
variant="ghost"
size="sm"
class="h-8 gap-2 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground opacity-30 hover:opacity-100 transition-opacity"
onClick={async () => {
const { forceServiceWorkerUpdate } = await import('@/components/PwaUpdater');
forceServiceWorkerUpdate();
toast.success("Checking for updates...");
}}
>
<Upload class="rotate-180" size={14} />
Check for Updates
</Button>
<p class="text-[0.5rem] font-mono text-muted-foreground/30 uppercase tracking-[0.2em]">Tasgrid v1.0.0</p>
</footer>
</div>
);
};