--- phase: 04-ux-improvements plan: 02 type: execute wave: 1 depends_on: [] files_modified: - frontend/src/main.tsx - frontend/src/index.css - frontend/src/components/ServiceCard.tsx - frontend/src/components/FilterBar.tsx - frontend/src/components/Header.tsx - frontend/src/App.tsx - frontend/src/lib/utils.ts autonomous: true requirements: - SRCH-01 - SRCH-02 - SRCH-03 - SRCH-04 - A11Y-01 - A11Y-02 must_haves: truths: - "User can search updates by image name and results filter instantly" - "User can filter updates by status (all/pending/acknowledged)" - "User can filter updates by tag (all/specific tag/untagged)" - "User can sort updates by date, name, or registry" - "User can toggle between light and dark themes" - "Theme preference persists across page reloads via localStorage" - "System prefers-color-scheme is respected on first visit" - "Drag handle is always visible on ServiceCard (not hover-only)" artifacts: - path: "frontend/src/components/FilterBar.tsx" provides: "Search input + 3 filter/sort dropdowns" min_lines: 40 - path: "frontend/src/main.tsx" provides: "Theme initialization from localStorage + prefers-color-scheme" - path: "frontend/src/App.tsx" provides: "Filter state, filtered/sorted entries, FilterBar integration" contains: "FilterBar" - path: "frontend/src/components/Header.tsx" provides: "Theme toggle button with sun/moon icon" contains: "toggleTheme" - path: "frontend/src/lib/utils.ts" provides: "Shared getRegistry function" contains: "export function getRegistry" key_links: - from: "frontend/src/App.tsx" to: "frontend/src/components/FilterBar.tsx" via: "FilterBar component with onChange callbacks" pattern: " Add client-side search/filter/sort controls, light/dark theme toggle, and fix the hover-only drag handle to be always visible. Purpose: Makes the dashboard usable at scale (finding specific images) and accessible (theme choice, visible drag handles). Output: New FilterBar component, theme toggle in Header, updated ServiceCard drag handle, filter logic in App.tsx. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-ux-improvements/04-CONTEXT.md @.planning/phases/04-ux-improvements/04-RESEARCH.md From frontend/src/types/diun.ts: ```typescript export interface Tag { id: number name: string } export interface UpdateEntry { event: DiunEvent received_at: string acknowledged: boolean tag: Tag | null } export type UpdatesMap = Record ``` From frontend/src/App.tsx (current entries derivation): ```typescript const entries = Object.entries(updates) const taggedSections = tags.map(tag => ({ tag, rows: entries .filter(([, e]) => e.tag?.id === tag.id) .map(([image, entry]) => ({ image, entry })), })) const untaggedRows = entries .filter(([, e]) => !e.tag) .map(([image, entry]) => ({ image, entry })) ``` From frontend/src/components/Header.tsx: ```typescript interface HeaderProps { onRefresh: () => void } ``` From frontend/src/components/ServiceCard.tsx (drag handle - current opacity pattern): ```tsx ``` Wrap both buttons in a `
`. 4. **ServiceCard.tsx** (per D-16): Change the drag handle button's className from `opacity-0 group-hover:opacity-100` to `opacity-40 hover:opacity-100`. The full className becomes: ``` text-muted-foreground opacity-40 hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none ``` 5. **lib/utils.ts**: Extract `getRegistry` function from ServiceCard.tsx and add it as a named export in utils.ts: ```typescript export function getRegistry(image: string): string { const parts = image.split('/') if (parts.length === 1) return 'Docker Hub' const first = parts[0] if (!first.includes('.') && !first.includes(':') && first !== 'localhost') return 'Docker Hub' if (first === 'ghcr.io') return 'GitHub' if (first === 'gcr.io') return 'GCR' return first } ``` Then in ServiceCard.tsx, remove the local `getRegistry` function and add `import { getRegistry } from '@/lib/utils'` (alongside the existing `cn` import: `import { cn, getRegistry } from '@/lib/utils'`). cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit - main.tsx contains `localStorage.getItem('theme')` and `prefers-color-scheme` - main.tsx does NOT contain `classList.add('dark')` as a standalone statement (only inside the conditional) - index.css `:root` block contains `--destructive: 0 84.2% 60.2%` - index.css `.dark` block contains `--destructive: 0 62.8% 30.6%` - Header.tsx contains `import` with `Sun` and `Moon` - Header.tsx contains `toggleTheme` - Header.tsx contains `localStorage.setItem('theme'` - ServiceCard.tsx drag handle button contains `opacity-40 hover:opacity-100` - ServiceCard.tsx does NOT contain `opacity-0 group-hover:opacity-100` on the drag handle - lib/utils.ts contains `export function getRegistry` - ServiceCard.tsx contains `import` with `getRegistry` from `@/lib/utils` - `npx tsc --noEmit` exits 0 Theme toggle works (sun/moon icon in header, persists to localStorage, respects system preference on first visit); drag handle always visible at 40% opacity; getRegistry is a shared utility Task 2: FilterBar component and client-side search/filter/sort logic in App.tsx frontend/src/components/FilterBar.tsx, frontend/src/App.tsx - frontend/src/App.tsx - frontend/src/types/diun.ts - frontend/src/lib/utils.ts - frontend/src/components/TagSection.tsx 1. **Create FilterBar.tsx** (per D-06, D-07): New component placed above sections list, below stats row. Uses native `` with magnifying glass icon (import `Search` from lucide-react). Full width on mobile, `w-64` on desktop. - Status select: options "All Status", "Pending", "Acknowledged" - Tag select: options "All Tags", "Untagged", then one option per tag (tag.name, value=tag.id) - Sort select: options "Newest First" (date-desc), "Oldest First" (date-asc), "Name A-Z" (name), "Registry" (registry) Style all selects with: `h-9 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50` Tag select onChange handler must parse value: `"all"` and `"untagged"` stay as strings, numeric values become `parseInt(value, 10)`. 2. **App.tsx** (per D-05, D-08): Add filter state and filtering logic. Add imports: ```typescript import { useMemo } from 'react' import { FilterBar } from '@/components/FilterBar' import { getRegistry } from '@/lib/utils' ``` Add filter state (per D-08 -- no persistence, resets on reload): ```typescript const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'acknowledged'>('all') const [tagFilter, setTagFilter] = useState<'all' | 'untagged' | number>('all') const [sortOrder, setSortOrder] = useState<'date-desc' | 'date-asc' | 'name' | 'registry'>('date-desc') ``` Replace the direct `entries` usage with a `filteredEntries` useMemo: ```typescript const filteredEntries = useMemo(() => { let result = Object.entries(updates) as [string, UpdateEntry][] if (search) { const q = search.toLowerCase() result = result.filter(([image]) => image.toLowerCase().includes(q)) } if (statusFilter === 'pending') result = result.filter(([, e]) => !e.acknowledged) if (statusFilter === 'acknowledged') result = result.filter(([, e]) => e.acknowledged) if (tagFilter === 'untagged') result = result.filter(([, e]) => !e.tag) if (typeof tagFilter === 'number') result = result.filter(([, e]) => e.tag?.id === tagFilter) result.sort(([ia, ea], [ib, eb]) => { switch (sortOrder) { case 'date-asc': return ea.received_at < eb.received_at ? -1 : 1 case 'name': return ia.localeCompare(ib) case 'registry': return getRegistry(ia).localeCompare(getRegistry(ib)) default: return ea.received_at > eb.received_at ? -1 : 1 } }) return result }, [updates, search, statusFilter, tagFilter, sortOrder]) ``` Update stats to use `entries` (unfiltered) for total counts but `filteredEntries` for display. The `pending` and `acknowledgedCount` and `lastReceived` remain computed from the unfiltered `entries` (dashboard stats always show global counts). Update `taggedSections` and `untaggedRows` derivation to use `filteredEntries` instead of `entries`: ```typescript const taggedSections = tags.map(tag => ({ tag, rows: filteredEntries .filter(([, e]) => e.tag?.id === tag.id) .map(([image, entry]) => ({ image, entry })), })) const untaggedRows = filteredEntries .filter(([, e]) => !e.tag) .map(([image, entry]) => ({ image, entry })) ``` Add `` in the JSX between the stats grid and the loading state, wrapped in `{!loading && entries.length > 0 && (...)}`: ```tsx {!loading && entries.length > 0 && ( )} ``` Import `UpdateEntry` type if needed for the `as` cast. cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build - FilterBar.tsx exists and exports `FilterBar` component - FilterBar.tsx contains `Search images` (placeholder text) - FilterBar.tsx contains ` FilterBar renders above sections; searching by image name filters instantly; status/tag/sort dropdowns work; default sort is newest-first; filters reset on page reload ```bash cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend npx tsc --noEmit bun run build ``` - FilterBar component renders search input and 3 dropdowns - Filtering by image name is case-insensitive substring match - Status filter shows only pending or acknowledged updates - Tag filter shows only updates in a specific tag or untagged - Sort order changes entry display order - Theme toggle button visible in header - Theme persists in localStorage - First visit respects prefers-color-scheme - Drag handle visible at 40% opacity without hover - Frontend builds without errors After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`