searchbar improvements
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { type Component, createSignal, For, Show } from "solid-js";
|
import { type Component, createSignal, For, Show } from "solid-js";
|
||||||
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star } from "lucide-solid";
|
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } from "lucide-solid";
|
||||||
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory, setNoteFilter, clearNoteFilter, saveNoteFilterTemplate, applyNoteFilterTemplate, removeNoteFilterTemplate, type Filter } from "@/store";
|
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory, setNoteFilter, clearNoteFilter, saveNoteFilterTemplate, applyNoteFilterTemplate, removeNoteFilterTemplate, type Filter } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -76,10 +76,20 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
|
|||||||
isOpen() && "bg-muted ring-1 ring-border",
|
isOpen() && "bg-muted ring-1 ring-border",
|
||||||
props.triggerClass
|
props.triggerClass
|
||||||
)}
|
)}
|
||||||
title="Filters & Search"
|
title={mode() === 'tasks' ? "Filters & Search" : "Filters"}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search
|
{mode() === 'tasks' ? (
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
class={cn(
|
||||||
|
"transition-transform duration-300",
|
||||||
|
isOpen() && "scale-110",
|
||||||
|
hasActiveFilters() && "text-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SlidersHorizontal
|
||||||
size={18}
|
size={18}
|
||||||
class={cn(
|
class={cn(
|
||||||
"transition-transform duration-300",
|
"transition-transform duration-300",
|
||||||
@@ -87,6 +97,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
|
|||||||
hasActiveFilters() && "text-primary"
|
hasActiveFilters() && "text-primary"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
|
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
|
||||||
@@ -173,28 +184,29 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div class="p-4 space-y-6">
|
<div class="p-4 space-y-6">
|
||||||
{/* Search Input */}
|
<Show when={mode() === 'tasks'}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
|
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
|
||||||
<input
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) setTimeout(() => el.focus(), 50);
|
if (el) setTimeout(() => el.focus(), 50);
|
||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={mode() === 'tasks' ? "Search tasks, descriptions, tags..." : "Search notes, tags..."}
|
placeholder="Search tasks, descriptions, tags..."
|
||||||
value={currentFilter().query}
|
value={currentFilter().query}
|
||||||
onInput={(e) => currentSetFilter({ query: e.currentTarget.value })}
|
onInput={(e) => currentSetFilter({ query: e.currentTarget.value })}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
|
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === 'tasks'}>
|
<Show when={mode() === 'tasks'}>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const NotepadView = lazy(() => import("../views/NotepadView").then(m => ({ defau
|
|||||||
import { NotesSidebar } from "./NotesSidebar";
|
import { NotesSidebar } from "./NotesSidebar";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FilterBar } from "./FilterBar";
|
import { TaskSearchPopover } from "./TaskSearchPopover";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
@@ -212,7 +212,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
|||||||
)}>
|
)}>
|
||||||
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
|
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
|
||||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||||
<FilterBar />
|
<TaskSearchPopover />
|
||||||
</Show>
|
</Show>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import { type Component, createEffect, createSignal, For, onCleanup, Show } from "solid-js";
|
||||||
|
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } from "lucide-solid";
|
||||||
|
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory } from "@/store";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||||
|
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||||
|
import { useTheme } from "./ThemeProvider";
|
||||||
|
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
export const TaskSearchPopover: Component = () => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
const [isExpanded, setIsExpanded] = createSignal(false);
|
||||||
|
const [isTagOpen, setIsTagOpen] = createSignal(false);
|
||||||
|
const [tagSearch, setTagSearch] = createSignal("");
|
||||||
|
const [newTemplateName, setNewTemplateName] = createSignal("");
|
||||||
|
const [isSaving, setIsSaving] = createSignal(false);
|
||||||
|
let triggerRef: HTMLDivElement | undefined;
|
||||||
|
let panelRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const hasActiveFilters = () => {
|
||||||
|
const f = store.filter;
|
||||||
|
return (f.query || "") !== ""
|
||||||
|
|| (f.tags || []).length > 0
|
||||||
|
|| f.editedToday
|
||||||
|
|| f.ownedByMe
|
||||||
|
|| f.starred
|
||||||
|
|| f.priorityMin !== 1
|
||||||
|
|| f.priorityMax !== 10
|
||||||
|
|| f.urgencyMin !== 1
|
||||||
|
|| f.urgencyMax !== 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasExpandedFilters = () => {
|
||||||
|
const f = store.filter;
|
||||||
|
return (f.tags || []).length > 0
|
||||||
|
|| f.editedToday
|
||||||
|
|| f.ownedByMe
|
||||||
|
|| f.starred
|
||||||
|
|| f.priorityMin !== 1
|
||||||
|
|| f.priorityMax !== 10
|
||||||
|
|| f.urgencyMin !== 1
|
||||||
|
|| f.urgencyMax !== 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsExpanded(false);
|
||||||
|
setIsSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isOpen()) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (triggerRef?.contains(target)) return;
|
||||||
|
if (panelRef?.contains(target)) return;
|
||||||
|
closePanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={triggerRef} class="fixed top-4 right-5 sm:right-6 z-[60]">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class={cn(
|
||||||
|
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
|
||||||
|
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
|
||||||
|
isOpen() && "bg-muted ring-1 ring-border"
|
||||||
|
)}
|
||||||
|
title="Search & Filter Tasks"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !isOpen();
|
||||||
|
setIsOpen(next);
|
||||||
|
if (!next) {
|
||||||
|
setIsExpanded(false);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
class={cn(
|
||||||
|
"transition-transform duration-300",
|
||||||
|
isOpen() && "scale-110",
|
||||||
|
hasActiveFilters() && "text-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isOpen()}>
|
||||||
|
<div ref={panelRef} class="fixed top-[4.25rem] left-1/2 -translate-x-1/2 z-[95] w-[min(calc(100vw-2rem),32rem)]">
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-md">
|
||||||
|
<div class="flex items-center gap-2 p-2">
|
||||||
|
<div class="relative flex-1 group">
|
||||||
|
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) setTimeout(() => el.focus(), 50);
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tasks, descriptions, tags..."
|
||||||
|
value={store.filter.query}
|
||||||
|
onInput={(e) => setFilter({ query: e.currentTarget.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class={cn(
|
||||||
|
"h-10 w-10 rounded-xl border border-border/50 bg-muted/30 hover:bg-muted/60 shrink-0",
|
||||||
|
(isExpanded() || hasExpandedFilters()) && "border-primary/30 bg-primary/5 text-primary"
|
||||||
|
)}
|
||||||
|
title="More filters"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded())}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-10 w-10 rounded-xl shrink-0"
|
||||||
|
title="Close search"
|
||||||
|
onClick={closePanel}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isExpanded()}>
|
||||||
|
<>
|
||||||
|
<div class="px-4 py-3 border-t border-border/50 bg-muted/20 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2 mr-1">
|
||||||
|
<SlidersHorizontal size={12} />
|
||||||
|
Filters
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
|
||||||
|
class={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||||
|
store.filter.editedToday
|
||||||
|
? "bg-primary/10 text-primary border-primary/20"
|
||||||
|
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
|
||||||
|
Edited Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter({ ownedByMe: !store.filter.ownedByMe })}
|
||||||
|
class={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||||
|
store.filter.ownedByMe
|
||||||
|
? "bg-primary/10 text-primary border-primary/20"
|
||||||
|
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User size={10} class={cn(store.filter.ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
|
||||||
|
Mine
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter({ starred: !store.filter.starred })}
|
||||||
|
class={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||||
|
store.filter.starred
|
||||||
|
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||||
|
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star size={10} class={cn(store.filter.starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
|
||||||
|
Starred
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => {
|
||||||
|
loadAllHistory();
|
||||||
|
closePanel();
|
||||||
|
}}
|
||||||
|
title="Load all completed tasks"
|
||||||
|
>
|
||||||
|
Load All
|
||||||
|
</Button>
|
||||||
|
<Show when={hasActiveFilters()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[min(70vh,40rem)] overflow-y-auto">
|
||||||
|
<div class="p-4 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
|
||||||
|
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||||
|
<Select
|
||||||
|
value={store.filter.priorityMin.toString()}
|
||||||
|
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
|
||||||
|
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||||
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||||
|
<div class="flex items-center gap-2 justify-center">
|
||||||
|
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
|
||||||
|
<span>Min: {store.filter.priorityMin}</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
<div class="w-px h-4 bg-border/50" />
|
||||||
|
<Select
|
||||||
|
value={store.filter.priorityMax.toString()}
|
||||||
|
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
|
||||||
|
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||||
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||||
|
<div class="flex items-center gap-2 justify-center">
|
||||||
|
<span>Max: {store.filter.priorityMax}</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
|
||||||
|
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||||
|
<Select
|
||||||
|
value={store.filter.urgencyMin.toString()}
|
||||||
|
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
|
||||||
|
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||||
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||||
|
<div class="flex items-center gap-2 justify-center">
|
||||||
|
<Clock size={14} class="text-muted-foreground/70" />
|
||||||
|
<span>Min: {store.filter.urgencyMin}</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
<div class="w-px h-4 bg-border/50" />
|
||||||
|
<Select
|
||||||
|
value={store.filter.urgencyMax.toString()}
|
||||||
|
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
|
||||||
|
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||||
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||||
|
<div class="flex items-center gap-2 justify-center">
|
||||||
|
<span>Max: {store.filter.urgencyMax}</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
|
||||||
|
Filter by Tags
|
||||||
|
<span class="text-[0.5625rem] lowercase font-medium opacity-50 tracking-normal text-right">
|
||||||
|
Click tag name to toggle exclude<br />
|
||||||
|
Click X to remove
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
|
||||||
|
<For each={store.filter.tags}>
|
||||||
|
{(tag) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class={cn(
|
||||||
|
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
|
||||||
|
tag.excluded
|
||||||
|
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/20"
|
||||||
|
: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20"
|
||||||
|
)}
|
||||||
|
style={!tag.excluded ? (() => {
|
||||||
|
const def = store.tagDefinitions.find(d => d.name === tag.name);
|
||||||
|
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||||
|
return color ? {
|
||||||
|
"background-color": `${color}15`,
|
||||||
|
"color": color,
|
||||||
|
"border-color": `${color}30`
|
||||||
|
} : {};
|
||||||
|
})() : {}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer h-full"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter({
|
||||||
|
tags: store.filter.tags.map(t =>
|
||||||
|
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
|
||||||
|
<Minus size={12} class="opacity-100" />
|
||||||
|
</Show>
|
||||||
|
<span class={cn(tag.excluded && "line-through opacity-70")}>{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
|
||||||
|
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||||
|
<span>+ Tag</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search tags..."
|
||||||
|
value={tagSearch()}
|
||||||
|
onValueChange={setTagSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<Show when={allTags().filter(t => {
|
||||||
|
if (t.startsWith("_")) return false;
|
||||||
|
const search = tagSearch().toLowerCase();
|
||||||
|
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
|
||||||
|
return t.toLowerCase().includes(search);
|
||||||
|
}).length === 0}>
|
||||||
|
<CommandEmpty>No tags found.</CommandEmpty>
|
||||||
|
</Show>
|
||||||
|
<CommandGroup>
|
||||||
|
<For each={allTags().filter(t => {
|
||||||
|
if (t.startsWith("_")) return false;
|
||||||
|
const search = tagSearch().toLowerCase();
|
||||||
|
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
|
||||||
|
return t.toLowerCase().includes(search);
|
||||||
|
})}>
|
||||||
|
{(tag) => (
|
||||||
|
<CommandItem
|
||||||
|
value={tag}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!store.filter.tags.some(t => t.name === tag)) {
|
||||||
|
setFilter({ tags: [...store.filter.tags, { name: tag, excluded: false }] });
|
||||||
|
}
|
||||||
|
setIsTagOpen(false);
|
||||||
|
setTagSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-[0.625rem] 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-[0.5625rem] 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" && 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-[0.625rem] 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-[0.6875rem] 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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user