searchbar improvements

This commit is contained in:
2026-03-20 18:12:35 -05:00
parent 5a3cf8cde3
commit 1eca9f147d
3 changed files with 544 additions and 26 deletions
+36 -24
View File
@@ -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">
+2 -2
View File
@@ -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>
+506
View File
@@ -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>
</>
);
};