feat: add Custom Views UI — create dialog, filter builder, sidebar integration

CreateViewDialog with FilterBuilder for constructing filter conditions
(field/operator/value rows). Wire onCreateView and onDeleteView in App.tsx
with Tauri command integration. Sidebar VIEWS section shows "+" button
for discoverability even when empty, and delete buttons on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-02 20:54:06 +02:00
parent 3ed1fb4ec4
commit a093ff4631
6 changed files with 257 additions and 11 deletions

View File

@@ -0,0 +1,92 @@
import { useState, useRef, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FilterBuilder } from './FilterBuilder'
import type { FilterCondition, ViewDefinition } from '../types'
interface CreateViewDialogProps {
open: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
availableFields: string[]
}
export function CreateViewDialog({ open, onClose, onCreate, availableFields }: CreateViewDialogProps) {
const [name, setName] = useState('')
const [icon, setIcon] = useState('')
const [conditions, setConditions] = useState<FilterCondition[]>([
{ field: 'type', op: 'equals', value: '' },
])
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setConditions([{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }]) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, availableFields])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const validConditions = conditions.filter((c) => c.field)
const definition: ViewDefinition = {
name: trimmed,
icon: icon || null,
color: null,
sort: null,
filters: { all: validConditions },
}
onCreate(definition)
onClose()
}
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Create View</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex gap-2">
<div className="w-16 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<Input
placeholder="📋"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="text-center"
maxLength={2}
/>
</div>
<div className="flex-1 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
ref={inputRef}
placeholder="e.g. Active Projects, Reading List..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
conditions={conditions}
onChange={setConditions}
availableFields={availableFields}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim()}>Create</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,105 @@
import { Plus, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { FilterCondition, FilterOp } from '../types'
const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
{ value: 'is_empty', label: 'is empty' },
{ value: 'is_not_empty', label: 'is not empty' },
]
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
interface FilterBuilderProps {
conditions: FilterCondition[]
onChange: (conditions: FilterCondition[]) => void
availableFields: string[]
}
function FilterRow({ condition, fields, onUpdate, onRemove }: {
condition: FilterCondition
fields: string[]
onUpdate: (c: FilterCondition) => void
onRemove: () => void
}) {
return (
<div className="flex items-center gap-1.5">
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
value={condition.field}
onChange={(e) => onUpdate({ ...condition, field: e.target.value })}
>
{fields.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
value={condition.op}
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
>
{OPERATORS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{!NO_VALUE_OPS.has(condition.op) && (
<Input
className="h-8 flex-1 min-w-0"
placeholder="value"
value={String(condition.value ?? '')}
onChange={(e) => onUpdate({ ...condition, value: e.target.value })}
/>
)}
<button
type="button"
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
onClick={onRemove}
title="Remove filter"
>
<X size={14} />
</button>
</div>
)
}
export function FilterBuilder({ conditions, onChange, availableFields }: FilterBuilderProps) {
const fields = availableFields.length > 0 ? availableFields : ['type']
const handleUpdate = (index: number, updated: FilterCondition) => {
const next = [...conditions]
next[index] = updated
onChange(next)
}
const handleRemove = (index: number) => {
onChange(conditions.filter((_, i) => i !== index))
}
const handleAdd = () => {
onChange([...conditions, { field: fields[0], op: 'equals', value: '' }])
}
return (
<div className="space-y-2">
{conditions.length > 1 && (
<span className="text-[11px] font-medium text-muted-foreground">Match all of:</span>
)}
{conditions.map((c, i) => (
<FilterRow
key={i}
condition={c}
fields={fields}
onUpdate={(updated) => handleUpdate(i, updated)}
onRemove={() => handleRemove(i)}
/>
))}
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={handleAdd}>
<Plus size={12} className="mr-1" /> Add filter
</Button>
</div>
)
}

View File

@@ -40,6 +40,7 @@ interface SidebarProps {
onReorderFavorites?: (orderedPaths: string[]) => void
views?: ViewFile[]
onCreateView?: () => void
onDeleteView?: (filename: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
inboxCount?: number
@@ -303,7 +304,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
views = [], onCreateView,
views = [], onCreateView, onDeleteView,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -392,7 +393,7 @@ export const Sidebar = memo(function Sidebar({
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
{/* Views */}
{views.length > 0 && (
{(views.length > 0 || onCreateView) && (
<div style={{ padding: '4px 6px' }}>
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
<span className="text-[11px] font-medium text-muted-foreground">Views</span>
@@ -403,14 +404,24 @@ export const Sidebar = memo(function Sidebar({
)}
</div>
{views.map((v) => (
<NavItem
key={v.filename}
icon={Funnel}
label={v.definition.name}
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
compact
/>
<div key={v.filename} className="group relative">
<NavItem
icon={Funnel}
label={v.definition.name}
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
compact
/>
{onDeleteView && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
))}
</div>
)}