954 lines
69 KiB
TypeScript
954 lines
69 KiB
TypeScript
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
|
import { ThemeToggle } from "../components/ThemeToggle";
|
|
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
|
import { useTheme } from "@/components/ThemeProvider";
|
|
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, 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 = () => {
|
|
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 [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-[10px] sm:text-xs text-muted-foreground truncate opacity-70 font-mono tracking-tight" title={pb.authStore.model?.email}>
|
|
{pb.authStore.model?.email}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="w-full sm:w-auto font-black uppercase tracking-widest text-[10px] h-10 sm:h-8 rounded-xl shadow-sm border-border/60"
|
|
onClick={() => { pb.authStore.clear(); location.reload(); }}
|
|
>
|
|
Sign Out
|
|
</Button>
|
|
</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">
|
|
<h3 class="text-base font-semibold">Appearance</h3>
|
|
<p class="text-sm text-muted-foreground">Select your preferred color scheme.</p>
|
|
</div>
|
|
<div class="flex justify-start sm:justify-end">
|
|
<ThemeToggle />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 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>
|
|
|
|
<Show when={shareType() === 'tag'}>
|
|
<Select
|
|
value={shareTag()}
|
|
onChange={(val) => val && setShareTag(val)}
|
|
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);
|
|
setShareUserId("");
|
|
}
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
Share
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active share rules */}
|
|
{/* Active share rules - Outgoing */}
|
|
<div class="space-y-3">
|
|
<h4 class="text-[10px] 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 isSystem = r.type === 'tag' && store.tagDefinitions.find(t => t.name === r.tagName)?.isUser;
|
|
const isOwner = r.ownerId === pb.authStore.model?.id;
|
|
return !isSystem && isOwner;
|
|
})} fallback={
|
|
<div class="text-center py-4 text-muted-foreground text-[10px] 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-[9px] text-muted-foreground uppercase font-black tracking-widest opacity-60 flex items-center gap-1">
|
|
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`}
|
|
<ArrowLeftRight size={8} class="rotate-45" />
|
|
</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-[10px] 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 isSystem = r.type === 'tag' && store.tagDefinitions.find(t => t.name === r.tagName)?.isUser;
|
|
const isTarget = r.targetUserId === pb.authStore.model?.id;
|
|
return !isSystem && isTarget;
|
|
})} fallback={
|
|
<div class="text-center py-4 text-muted-foreground text-[10px] 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-[9px] text-muted-foreground uppercase font-black tracking-widest opacity-60 flex items-center gap-1">
|
|
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`}
|
|
<ArrowLeftRight size={8} class="-rotate-45" />
|
|
</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 isSystem = r.type === 'tag' && store.tagDefinitions.find(t => t.name === r.tagName)?.isUser;
|
|
return isSystem;
|
|
})}>
|
|
{(rule) => (
|
|
<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">
|
|
{getUserName(rule.targetUserId)}
|
|
<Users size={10} class="text-muted-foreground" />
|
|
</p>
|
|
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
Auto-shared via User Tag
|
|
</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} />
|
|
Buckets (Global Workspaces)
|
|
</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-[10px] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket</h4>
|
|
<form
|
|
class="flex 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();
|
|
}
|
|
}}
|
|
>
|
|
<input
|
|
name="color"
|
|
type="color"
|
|
class="w-9 h-9 rounded-lg border border-input p-0.5 bg-background cursor-pointer"
|
|
value="#6366f1"
|
|
/>
|
|
<input
|
|
name="name"
|
|
class="flex h-9 w-full sm:w-48 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
|
|
/>
|
|
<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">
|
|
<Plus size={14} />
|
|
Create
|
|
</Button>
|
|
</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-[10px] 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>
|
|
|
|
{/* Matrix Configuration */}
|
|
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div class="space-y-0.5 min-w-0">
|
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
|
<ArrowLeftRight size={16} />
|
|
Matrix Horizon
|
|
</h3>
|
|
<p class="text-sm text-muted-foreground truncate sm:whitespace-normal">Adjust the time scale for your strategy grid.</p>
|
|
</div>
|
|
<div class="w-full sm:w-[120px] shrink-0">
|
|
<Select
|
|
value={store.matrixScaleDays.toString()}
|
|
onChange={(val) => val && setMatrixScaleDays(parseInt(val))}
|
|
options={["7", "14", "30", "60", "90"]}
|
|
placeholder="Select days"
|
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue} days</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-9 w-full">
|
|
<span class="text-sm font-medium">{store.matrixScaleDays} days</span>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 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>
|
|
|
|
<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 && !store.buckets.some(b => b.name === t.name)).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-[10px] 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)
|
|
</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 || store.buckets.some(b => b.name === t.name)).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-[10px] 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>
|
|
|
|
{/* 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-[10px]"
|
|
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-[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: 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-[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={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-[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={PRIORITY_OPTIONS}>
|
|
{(opt) => <option value={opt.value}>{opt.label}</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={URGENCY_OPTIONS}>
|
|
{(opt) => <option value={opt.value}>{opt.label}</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="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-[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) => {
|
|
// 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-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>
|
|
);
|
|
};
|