Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25403ecaf0 | |||
| bf12c6f814 |
@@ -1,6 +1,6 @@
|
|||||||
import { type Component, createSignal, For, Show } from "solid-js";
|
import { type Component, createSignal, For, Show } from "solid-js";
|
||||||
import { Search, X, Hash, ArrowUpCircle, Clock, Minus } from "lucide-solid";
|
import { Search, X, Hash, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
|
||||||
import { store, setFilter, clearFilter } from "@/store";
|
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
@@ -12,6 +12,8 @@ import { getThemeAdjustedColor } from "@/lib/colors";
|
|||||||
export const FilterBar: Component = () => {
|
export const FilterBar: Component = () => {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const [isOpen, setIsOpen] = createSignal(false);
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
const [newTemplateName, setNewTemplateName] = createSignal("");
|
||||||
|
const [isSaving, setIsSaving] = createSignal(false);
|
||||||
|
|
||||||
const hasActiveFilters = () => {
|
const hasActiveFilters = () => {
|
||||||
const f = store.filter;
|
const f = store.filter;
|
||||||
@@ -251,6 +253,109 @@ export const FilterBar: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Templates Section */}
|
||||||
|
<div class="px-4 pb-4 pt-4 border-t border-border/50 space-y-4 bg-muted/5">
|
||||||
|
<div class="flex items-center justify-between ml-1">
|
||||||
|
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||||
|
<Bookmark size={12} class="text-primary" />
|
||||||
|
Saved Filters
|
||||||
|
</label>
|
||||||
|
<Show when={!isSaving() && hasActiveFilters()}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSaving(true)}
|
||||||
|
class="text-[9px] font-bold uppercase tracking-wider text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save size={10} />
|
||||||
|
Save Current
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isSaving()}>
|
||||||
|
<div class="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Template name..."
|
||||||
|
value={newTemplateName()}
|
||||||
|
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
|
||||||
|
class="flex-1 bg-muted/30 border border-primary/20 rounded-lg px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (newTemplateName().trim()) {
|
||||||
|
saveFilterTemplate(newTemplateName().trim());
|
||||||
|
setNewTemplateName("");
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-3"
|
||||||
|
disabled={!newTemplateName().trim()}
|
||||||
|
onClick={() => {
|
||||||
|
saveFilterTemplate(newTemplateName().trim());
|
||||||
|
setNewTemplateName("");
|
||||||
|
setIsSaving(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onClick={() => setIsSaving(false)}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<For each={store.filterTemplates} fallback={
|
||||||
|
<div class="col-span-2 py-4 text-center border border-dashed border-border/40 rounded-xl bg-muted/5">
|
||||||
|
<p class="text-[10px] text-muted-foreground uppercase tracking-widest">No saved filters yet</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(template) => (
|
||||||
|
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
|
||||||
|
<button
|
||||||
|
class="flex-1 text-left px-1 min-w-0"
|
||||||
|
onClick={() => applyFilterTemplate(template.id)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-[11px] font-bold text-foreground truncate">{template.name}</p>
|
||||||
|
<div class="flex gap-1 opacity-60 shrink-0">
|
||||||
|
<Show when={template.filter.query}>
|
||||||
|
<div class="w-1 h-1 rounded-full bg-primary" />
|
||||||
|
</Show>
|
||||||
|
<Show when={template.filter.tags.length > 0}>
|
||||||
|
<div class="w-1 h-1 rounded-full bg-indigo-500" />
|
||||||
|
</Show>
|
||||||
|
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
|
||||||
|
<div class="w-1 h-1 rounded-full bg-amber-500" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeFilterTemplate(template.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
+217
-6
@@ -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 {
|
||||||
@@ -140,6 +144,13 @@ export interface Filter {
|
|||||||
editedToday: boolean;
|
editedToday: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FilterTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
filter: Filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ShareRule {
|
export interface ShareRule {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -158,6 +169,7 @@ interface TaskStore {
|
|||||||
templates: TaskTemplate[];
|
templates: TaskTemplate[];
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
shareRules: ShareRule[];
|
shareRules: ShareRule[];
|
||||||
|
filterTemplates: FilterTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial empty state
|
// Initial empty state
|
||||||
@@ -177,10 +189,36 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
urgencyMax: 10,
|
urgencyMax: 10,
|
||||||
editedToday: false
|
editedToday: false
|
||||||
},
|
},
|
||||||
shareRules: []
|
shareRules: [],
|
||||||
|
filterTemplates: []
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +354,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 +622,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 +734,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();
|
||||||
@@ -702,6 +746,9 @@ export const initStore = async () => {
|
|||||||
// 2.7. Fetch Share Rules (owned by current user)
|
// 2.7. Fetch Share Rules (owned by current user)
|
||||||
await loadShareRules();
|
await loadShareRules();
|
||||||
|
|
||||||
|
// 2.8 Fetch Filter Templates
|
||||||
|
await loadFilterTemplates();
|
||||||
|
|
||||||
// 3. Subscribe to Realtime Updates
|
// 3. Subscribe to Realtime Updates
|
||||||
await subscribeToRealtime();
|
await subscribeToRealtime();
|
||||||
|
|
||||||
@@ -754,6 +801,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 +837,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 +1050,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 +1374,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 +1387,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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1277,6 +1424,70 @@ export const removeShareRule = async (ruleId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -- Filter Templates --
|
||||||
|
const FILTER_TEMPLATES_COLLECTION = 'TasGrid_FilterTemplates';
|
||||||
|
|
||||||
|
export const loadFilterTemplates = async () => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(FILTER_TEMPLATES_COLLECTION).getFullList({
|
||||||
|
filter: `user = "${pb.authStore.model?.id}"`,
|
||||||
|
requestKey: null
|
||||||
|
});
|
||||||
|
const templates: FilterTemplate[] = records.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
filter: r.filter as Filter
|
||||||
|
}));
|
||||||
|
setStore("filterTemplates", templates);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load filter templates:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveFilterTemplate = async (name: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
try {
|
||||||
|
const record = await pb.collection(FILTER_TEMPLATES_COLLECTION).create({
|
||||||
|
user: pb.authStore.model?.id,
|
||||||
|
name,
|
||||||
|
filter: store.filter
|
||||||
|
}, { requestKey: null });
|
||||||
|
|
||||||
|
const newTemplate: FilterTemplate = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
filter: record.filter as Filter
|
||||||
|
};
|
||||||
|
|
||||||
|
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||||
|
toast.success(`Filter template "${name}" saved`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save filter template:", err);
|
||||||
|
toast.error("Failed to save filter template.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyFilterTemplate = (id: string) => {
|
||||||
|
const template = store.filterTemplates.find(t => t.id === id);
|
||||||
|
if (template) {
|
||||||
|
setFilter(template.filter);
|
||||||
|
toast.success(`Applied filter: ${template.name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFilterTemplate = async (id: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
try {
|
||||||
|
await pb.collection(FILTER_TEMPLATES_COLLECTION).delete(id);
|
||||||
|
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||||
|
toast.success("Filter template deleted");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete filter template:", err);
|
||||||
|
toast.error("Failed to delete filter template.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const clearFilter = () => {
|
export const clearFilter = () => {
|
||||||
setStore("filter", {
|
setStore("filter", {
|
||||||
query: "",
|
query: "",
|
||||||
|
|||||||
+184
-96
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user