added system user tags/sharing and workspaces for recieving share-all rules

This commit is contained in:
2026-02-10 17:09:08 -06:00
parent 414eead28d
commit bf12c6f814
3 changed files with 469 additions and 102 deletions
+145 -1
View File
@@ -3,6 +3,9 @@ import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClo
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
import { pb } from "@/lib/pocketbase";
import { createResource, createSignal, createEffect } from "solid-js";
interface NavItem { interface NavItem {
icon: any; icon: any;
@@ -109,6 +112,130 @@ export const BottomNav: Component<{ currentView: string; setView: (v: string) =>
); );
}; };
const ContextSwitcher: Component<{
isLocked: boolean;
isPeeking: boolean;
setIsPeeking: (v: boolean) => void;
onOpenChange?: (open: boolean) => void
}> = (props) => {
const currentUserId = pb.authStore.model?.id;
const [open, setOpen] = createSignal(false);
// Sync with parent if needed
createEffect(() => {
props.onOpenChange?.(open());
});
// Close popover when sidebar disappears
createEffect(() => {
if (!props.isLocked && !props.isPeeking) {
setOpen(false);
}
});
const [oversightUsers] = createResource(
() => store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId),
async (ownerIds) => {
if (ownerIds.length === 0) return [];
try {
// Fetch user names for these IDs
// Optimization: Could check if we already have them or just fetch
const users = await Promise.all(ownerIds.map(id => pb.collection('users').getOne(id, { fields: 'id,name,email' })));
return users.map(u => ({ id: u.id, name: u.name || u.email }));
} catch (e) {
console.error("Failed to load oversight users", e);
return [];
}
}
);
const ctx = currentTaskContext; // Accessor
const label = () => {
const c = ctx();
return c === 'mine' ? "My Workspace" : c.name;
};
const handleSwitch = (userId: string, name: string) => {
if (userId === 'mine') {
setCurrentTaskContext('mine');
} else {
setCurrentTaskContext({ userId, name });
}
// Close popover
setOpen(false);
// Collapse sidebar if peaking
if (!props.isLocked && props.isPeeking) {
props.setIsPeeking(false);
}
};
return (
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
<Popover open={open()} onOpenChange={setOpen}>
<PopoverTrigger class="w-full">
<div class={cn(
"flex items-center justify-between w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
"text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent hover:border-border/50"
)}>
<div class="flex items-center gap-3 min-w-0">
<div class={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
)}>
<div class="w-2 h-2 rounded-full bg-current" />
</div>
<span class="font-medium truncate">{label()}</span>
</div>
<ChevronDown size={14} class="opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-1 bg-card border-border shadow-xl">
<div class="space-y-0.5">
<button
onClick={() => handleSwitch('mine', '')}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-primary" />
</div>
My Workspace
</button>
<div class="py-1">
<div class="h-px bg-border/50" />
</div>
<div class="px-2 py-1 text-[10px] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
<span>Oversight</span>
<span class="ml-auto bg-muted px-1.5 rounded text-[9px]">{oversightUsers()?.length}</span>
</div>
<For each={oversightUsers()}>
{(user) => (
<button
onClick={() => handleSwitch(user.id, user.name)}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
(ctx() as any).userId === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-orange-500" />
</div>
<span class="truncate">{user.name}</span>
</button>
)}
</For>
</div>
</PopoverContent>
</Popover>
</Show>
);
};
export const Sidebar: Component<{ export const Sidebar: Component<{
currentView: string; currentView: string;
setView: (v: string) => void; setView: (v: string) => void;
@@ -117,10 +244,16 @@ export const Sidebar: Component<{
isPeeking: boolean; isPeeking: boolean;
setIsPeeking: (v: boolean) => void; setIsPeeking: (v: boolean) => void;
}> = (props) => { }> = (props) => {
const [switcherOpen, setSwitcherOpen] = createSignal(false);
return ( return (
<aside <aside
onMouseEnter={() => !props.isLocked && props.setIsPeeking(true)} onMouseEnter={() => !props.isLocked && props.setIsPeeking(true)}
onMouseLeave={() => !props.isLocked && props.setIsPeeking(false)} onMouseLeave={() => {
if (!props.isLocked && !switcherOpen()) {
props.setIsPeeking(false);
}
}}
class={cn( class={cn(
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]", "hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]",
props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full", props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full",
@@ -151,6 +284,17 @@ export const Sidebar: Component<{
</Button> </Button>
</Show> </Show>
</div> </div>
{/* Context Switcher - Only visible if there are oversight contexts */}
<div class={cn("px-3 mb-2", !props.isLocked && !props.isPeeking && "hidden")}>
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={setSwitcherOpen}
/>
</div>
<nav class="flex-1 px-3 space-y-1 mt-2"> <nav class="flex-1 px-3 space-y-1 mt-2">
<For each={desktopNavItems}> <For each={desktopNavItems}>
{(item) => ( {(item) => (
+140 -5
View File
@@ -10,7 +10,9 @@ const getStorageKey = () => {
}; };
export const [now, setNow] = createSignal(Date.now()); export const [now, setNow] = createSignal(Date.now());
// Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null); export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export const [currentTaskContext, setCurrentTaskContext] = createSignal<'mine' | { userId: string, name: string }>('mine');
export type UrgencyLevel = number; export type UrgencyLevel = number;
@@ -22,6 +24,7 @@ export interface Task {
priority: number; // 1-10 priority: number; // 1-10
completed: boolean; completed: boolean;
tags: string[]; tags: string[];
ownerId: string; // Add ownerId to interface
content?: string; // HTML content from Tiptap content?: string; // HTML content from Tiptap
deletedAt?: number; // Timestamp of soft delete deletedAt?: number; // Timestamp of soft delete
created: string; // ISO string from PB created: string; // ISO string from PB
@@ -123,6 +126,7 @@ export interface TagDefinition {
value: number; value: number;
color?: string; color?: string;
theme?: "light" | "dark"; theme?: "light" | "dark";
isUser?: boolean; // New flag to identify user-based tags
} }
export interface FilterTag { export interface FilterTag {
@@ -181,6 +185,31 @@ export const [store, setStore] = createStore<TaskStore>({
}); });
export const matchesFilter = (task: Task) => { export const matchesFilter = (task: Task) => {
const currentUserId = pb.authStore.model?.id;
const ctx = currentTaskContext();
// 0. Context Filter
if (ctx === 'mine') {
const isOwner = task.ownerId === currentUserId;
const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId);
// Tag-based share rules targeting me
const isTagRuleShared = store.shareRules.some(r =>
r.targetUserId === currentUserId &&
r.ownerId === task.ownerId &&
r.type === 'tag' &&
task.tags?.includes(r.tagName || "")
);
// If context is "mine", we hide tasks that only match a "Share All" rule
// (i.e., not owner, not explicit, not tag rule)
if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false;
} else {
// Context is Oversight for specific user
// Show ONLY tasks owned by that user
if (task.ownerId !== ctx.userId) return false;
}
// Hide templates from all regular views // Hide templates from all regular views
if (task.tags?.includes("__template__")) return false; if (task.tags?.includes("__template__")) return false;
@@ -316,6 +345,7 @@ const mapRecordToTask = (r: any): Task => {
priority: r.priority, priority: r.priority,
completed: r.completed, completed: r.completed,
tags: tags, tags: tags,
ownerId: r.user,
content: r.content, content: r.content,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined, deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
created: r.created, created: r.created,
@@ -583,6 +613,9 @@ export const initStore = async () => {
console.warn("Failed to load tags:", tagErr); console.warn("Failed to load tags:", tagErr);
} }
// 1.8 Sync User Tags & Share Rules
await syncUserTagsAndRules();
// 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL // 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL
const currentUserId = pb.authStore.model?.id; const currentUserId = pb.authStore.model?.id;
@@ -692,9 +725,11 @@ export const initStore = async () => {
}); });
} }
// Set store ONCE with all tasks // Set store ONCE with all tasks and rules
setStore("tasks", reconcile(allTasks)); setStore("tasks", reconcile(allTasks));
setStore("templates", loadedTemplates); setStore("templates", loadedTemplates);
// Make sure we include incomingRules in the store so ContextSwitcher can see them
setStore("shareRules", incomingRules.map(mapRecordToShareRule));
// Check recurring tasks after load // Check recurring tasks after load
checkRecurringTasks(); checkRecurringTasks();
@@ -754,6 +789,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
priority, priority,
completed: false, completed: false,
tags, tags,
ownerId: pb.authStore.model?.id || "",
content, content,
size, size,
created: new Date().toISOString(), created: new Date().toISOString(),
@@ -789,7 +825,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
...task, ...task,
id: record.id, id: record.id,
created: record.created, created: record.created,
updated: record.updated updated: record.updated,
ownerId: record.user // Add missing ownerId from record
} : task); } : task);
}); });
} catch (err) { } catch (err) {
@@ -1001,6 +1038,104 @@ export const removeTagDefinition = async (name: string) => {
} }
}; };
export const syncUserTagsAndRules = async () => {
if (!pb.authStore.isValid) return;
const currentUserId = pb.authStore.model?.id;
try {
// 1. Fetch only verified users
const usersPromise = pb.collection('users').getFullList({
filter: 'verified = true',
fields: 'id,name,email,verified',
requestKey: null
});
// 2. Fetch existing rules DIRECTLY from PB to avoid store race conditions
const rulesPromise = pb.collection(SHARE_RULES_COLLECTION).getFullList({
filter: `ownerId = "${currentUserId}"`,
requestKey: null
});
const [users, pbRules] = await Promise.all([usersPromise, rulesPromise]);
// 2a. Map PB rules to check for duplicates
const existingTags = store.tagDefinitions;
const normalizedRules = pbRules.map(r => ({
id: r.id,
targetUserId: r.targetUserId,
type: r.type,
tagName: r.tagName
}));
for (const user of users) {
const userName = user.name || user.email;
if (!userName) continue;
// --- Tag Sync ---
const tagExists = existingTags.find(t => t.name === userName);
if (!tagExists) {
// ... create tag (logic unchanged) ...
try {
const record = await pb.collection(TAGS_COLLECTION).create({
user: currentUserId,
name: userName,
value: 5,
color: "#64748b",
theme: "dark"
});
setStore("tagDefinitions", prev => [...prev, {
id: record.id,
name: record.name,
value: record.value,
color: record.color,
theme: record.theme,
isUser: true
}]);
} catch (e) {
// console.error(`Failed to create user tag ${userName}`, e);
}
} else {
if (!tagExists.isUser) {
setStore("tagDefinitions", d => d.id === tagExists.id, { isUser: true });
}
}
// --- Share Rule Sync ---
if (user.id !== currentUserId) {
// Find ALL rules matching this criteria
const matchingRules = normalizedRules.filter(r =>
r.targetUserId === user.id &&
r.type === 'tag' &&
r.tagName === userName
);
if (matchingRules.length === 0) {
console.log(`Creating auto-share rule for ${userName}`);
await addShareRule('tag', user.id, userName);
} else if (matchingRules.length > 1) {
// DUPLICATE DETECTED: Keep first, delete others
console.warn(`Duplicate share rules found for ${userName}. Cleaning up...`);
const [, ...remove] = matchingRules;
for (const rule of remove) {
try {
await pb.collection(SHARE_RULES_COLLECTION).delete(rule.id);
// Update local store immediately to reflect deletion
setStore("shareRules", list => list.filter(r => r.id !== rule.id));
} catch (e) {
console.error("Failed to delete duplicate rule:", e);
}
}
}
}
}
} catch (err) {
console.error("Failed to sync user tags/rules:", err);
}
};
export const renameTagDefinition = async (oldName: string, newName: string) => { export const renameTagDefinition = async (oldName: string, newName: string) => {
if (!newName || !newName.trim() || newName === oldName) return; if (!newName || !newName.trim() || newName === oldName) return;
if (!pb.authStore.isValid) return; if (!pb.authStore.isValid) return;
@@ -1227,8 +1362,9 @@ export const loadShareRules = async () => {
if (!pb.authStore.isValid) return; if (!pb.authStore.isValid) return;
try { try {
const currentUserId = pb.authStore.model?.id;
const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({ const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({
filter: `ownerId = "${pb.authStore.model?.id}"` filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"`
}); });
const rules: ShareRule[] = records.map(r => ({ const rules: ShareRule[] = records.map(r => ({
@@ -1239,10 +1375,9 @@ export const loadShareRules = async () => {
tagName: r.tagName tagName: r.tagName
})); }));
setStore("shareRules", rules); setStore("shareRules", reconcile(rules));
} catch (err) { } catch (err) {
console.error("Failed to load share rules:", err); console.error("Failed to load share rules:", err);
// Collection might not exist yet, that's okay
} }
}; };
+184 -96
View File
@@ -179,33 +179,73 @@ export const SettingsView: Component = () => {
</div> </div>
{/* Active share rules */} {/* Active share rules */}
<For each={store.shareRules} fallback={ {/* Active share rules - Custom */}
<div class="text-center py-6 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl"> <div class="space-y-2">
No active share rules. <h4 class="text-xs font-bold uppercase tracking-wider text-muted-foreground pb-1">Custom Rules</h4>
</div> <For each={store.shareRules.filter(r => {
}> const isSystem = r.type === 'tag' && store.tagDefinitions.find(t => t.name === r.tagName)?.isUser;
{(rule) => ( return !isSystem;
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3"> })} fallback={
<div class="flex items-center gap-3 min-w-0 flex-1"> <div class="text-center py-4 text-muted-foreground text-xs italic border border-dashed border-border rounded-xl">
{rule.type === 'all' ? <Users size={14} class="text-primary shrink-0" /> : <Tag size={14} class="text-primary shrink-0" />} No custom share rules.
<div class="min-w-0">
<p class="text-sm font-medium truncate">{getUserName(rule.targetUserId)}</p>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={() => removeShareRule(rule.id)}
>
<Trash2 size={14} />
</Button>
</div> </div>
)} }>
</For> {(rule) => (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
{rule.type === 'all' ? <Users size={14} class="text-primary shrink-0" /> : <Tag size={14} class="text-primary shrink-0" />}
<div class="min-w-0">
<p class="text-sm font-medium truncate">{getUserName(rule.targetUserId)}</p>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={() => removeShareRule(rule.id)}
>
<Trash2 size={14} />
</Button>
</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> </div>
</Show> </Show>
</section> </section>
@@ -257,84 +297,132 @@ export const SettingsView: Component = () => {
<Show when={isTagsOpen()}> <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 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={store.tagDefinitions.sort((a, b) => a.name.localeCompare(b.name))} fallback={ <div class="space-y-2">
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl"> <For each={store.tagDefinitions.filter(t => !t.isUser).sort((a, b) => a.name.localeCompare(b.name))} fallback={
No specific tag definitions found. <div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
</div> No custom tags found.
}> </div>
{(tag) => { }>
const [isEditingName, setIsEditingName] = createSignal(false); {(tag) => {
const [tempName, setTempName] = createSignal(tag.name); const [isEditingName, setIsEditingName] = createSignal(false);
const [tempName, setTempName] = createSignal(tag.name);
const handleRename = () => { const handleRename = () => {
if (tempName() && tempName() !== tag.name) { if (tempName() && tempName() !== tag.name) {
renameTagDefinition(tag.name, tempName()); renameTagDefinition(tag.name, tempName());
} }
setIsEditingName(false); setIsEditingName(false);
}; };
return ( 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 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="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 <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" type="color"
value={tempName()} value={tag.color || "#6366f1"}
onInput={(e) => setTempName(e.currentTarget.value)} onInput={(e) => upsertTagDefinition(tag.name, tag.value, e.currentTarget.value, resolvedTheme())}
onBlur={handleRename} class="w-4 h-4 rounded-full border-none p-0 bg-transparent cursor-pointer shrink-0 overflow-hidden"
onKeyDown={(e) => e.key === "Enter" && handleRename()} title="Tag accent color"
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"> {isEditingName() ? (
<div class="flex items-center gap-2"> <input
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span> 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"
<select value={tempName()}
value={String(tag.value)} onInput={(e) => setTempName(e.currentTarget.value)}
onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())} onBlur={handleRename}
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" onKeyDown={(e) => e.key === "Enter" && handleRename()}
> autofocus
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}> />
{(v) => <option value={v}>{v}</option>} ) : (
</For> <span
</select> 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>
<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">
variant="ghost" <div class="flex items-center gap-2">
size="icon" <span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10" <select
onClick={() => { value={String(tag.value)}
if (confirm(`Delete tag definition for "${tag.name}" ? `)) { onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())}
removeTagDefinition(tag.name); 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>}
<Trash2 size={14} /> </For>
</Button> </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> </div>
</div> );
); }}
}} </For>
</For> </div>
{/* User 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>
User Tags (System)
</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).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> </div>
</Show> </Show>
</section> </section>