412 lines
16 KiB
Markdown
412 lines
16 KiB
Markdown
---
|
|
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: "<FilterBar"
|
|
- from: "frontend/src/main.tsx"
|
|
to: "localStorage"
|
|
via: "theme init reads localStorage('theme')"
|
|
pattern: "localStorage.getItem.*theme"
|
|
- from: "frontend/src/components/Header.tsx"
|
|
to: "document.documentElement.classList"
|
|
via: "toggleTheme toggles dark class and writes localStorage"
|
|
pattern: "classList.toggle.*dark"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
|
|
<interfaces>
|
|
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<string, UpdateEntry>
|
|
```
|
|
|
|
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
|
|
<button
|
|
{...attributes}
|
|
{...listeners}
|
|
className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none"
|
|
>
|
|
```
|
|
|
|
From frontend/src/lib/utils.ts:
|
|
```typescript
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs))
|
|
}
|
|
```
|
|
|
|
From frontend/src/main.tsx (current hardcoded dark mode):
|
|
```typescript
|
|
document.documentElement.classList.add('dark')
|
|
```
|
|
|
|
From frontend/src/index.css (CSS vars - note: no --destructive or --card defined):
|
|
```css
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 222.2 84% 4.9%;
|
|
/* ... light theme vars ... */
|
|
}
|
|
.dark {
|
|
--background: 240 10% 3.9%;
|
|
--foreground: 0 0% 98%;
|
|
/* ... dark theme vars ... */
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Theme toggle, drag handle fix, and shared getRegistry utility</name>
|
|
<files>frontend/src/main.tsx, frontend/src/index.css, frontend/src/components/Header.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/lib/utils.ts</files>
|
|
<read_first>
|
|
- frontend/src/main.tsx
|
|
- frontend/src/index.css
|
|
- frontend/src/components/Header.tsx
|
|
- frontend/src/components/ServiceCard.tsx
|
|
- frontend/src/lib/utils.ts
|
|
</read_first>
|
|
<action>
|
|
1. **main.tsx** (per D-15): Replace `document.documentElement.classList.add('dark')` with theme initialization:
|
|
```typescript
|
|
const stored = localStorage.getItem('theme')
|
|
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark')
|
|
}
|
|
```
|
|
|
|
2. **index.css**: Add `--destructive` and `--destructive-foreground` CSS variables to both `:root` and `.dark` blocks (needed for destructive button variant used in Plan 03). Also add `--card` and `--card-foreground` if missing:
|
|
In `:root` block, add:
|
|
```css
|
|
--destructive: 0 84.2% 60.2%;
|
|
--destructive-foreground: 0 0% 98%;
|
|
--card: 0 0% 100%;
|
|
--card-foreground: 222.2 84% 4.9%;
|
|
```
|
|
In `.dark` block, add:
|
|
```css
|
|
--destructive: 0 62.8% 30.6%;
|
|
--destructive-foreground: 0 85.7% 97.3%;
|
|
--card: 240 10% 3.9%;
|
|
--card-foreground: 0 0% 98%;
|
|
```
|
|
|
|
3. **Header.tsx** (per D-14): Add theme toggle button. Import `Sun, Moon` from `lucide-react`. Add a `toggleTheme` function:
|
|
```typescript
|
|
function toggleTheme() {
|
|
const isDark = document.documentElement.classList.toggle('dark')
|
|
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
|
}
|
|
```
|
|
Add a second Button next to the refresh button:
|
|
```tsx
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleTheme}
|
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
title="Toggle theme"
|
|
>
|
|
<Sun className="h-4 w-4 hidden dark:block" />
|
|
<Moon className="h-4 w-4 block dark:hidden" />
|
|
</Button>
|
|
```
|
|
Wrap both buttons in a `<div className="flex items-center gap-1">`.
|
|
|
|
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'`).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: FilterBar component and client-side search/filter/sort logic in App.tsx</name>
|
|
<files>frontend/src/components/FilterBar.tsx, frontend/src/App.tsx</files>
|
|
<read_first>
|
|
- frontend/src/App.tsx
|
|
- frontend/src/types/diun.ts
|
|
- frontend/src/lib/utils.ts
|
|
- frontend/src/components/TagSection.tsx
|
|
</read_first>
|
|
<action>
|
|
1. **Create FilterBar.tsx** (per D-06, D-07): New component placed above sections list, below stats row. Uses native `<select>` elements styled with Tailwind (no Radix Select dependency). Props interface:
|
|
```typescript
|
|
interface FilterBarProps {
|
|
search: string
|
|
onSearchChange: (value: string) => void
|
|
statusFilter: 'all' | 'pending' | 'acknowledged'
|
|
onStatusFilterChange: (value: 'all' | 'pending' | 'acknowledged') => void
|
|
tagFilter: 'all' | 'untagged' | number
|
|
onTagFilterChange: (value: 'all' | 'untagged' | number) => void
|
|
sortOrder: 'date-desc' | 'date-asc' | 'name' | 'registry'
|
|
onSortOrderChange: (value: 'date-desc' | 'date-asc' | 'name' | 'registry') => void
|
|
tags: Tag[]
|
|
}
|
|
```
|
|
|
|
Layout: flex row with wrap, gap-3. Responsive: on small screens wraps to multiple rows.
|
|
- Search input: `<input type="text" placeholder="Search images..." />` 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 `<FilterBar>` in the JSX between the stats grid and the loading state, wrapped in `{!loading && entries.length > 0 && (...)}`:
|
|
```tsx
|
|
{!loading && entries.length > 0 && (
|
|
<FilterBar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
statusFilter={statusFilter}
|
|
onStatusFilterChange={setStatusFilter}
|
|
tagFilter={tagFilter}
|
|
onTagFilterChange={setTagFilter}
|
|
sortOrder={sortOrder}
|
|
onSortOrderChange={setSortOrder}
|
|
tags={tags}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
Import `UpdateEntry` type if needed for the `as` cast.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- FilterBar.tsx exists and exports `FilterBar` component
|
|
- FilterBar.tsx contains `Search images` (placeholder text)
|
|
- FilterBar.tsx contains `<select` elements (native selects, not Radix)
|
|
- FilterBar.tsx contains `All Status` and `Pending` and `Acknowledged` as option labels
|
|
- FilterBar.tsx contains `Newest First` and `Name A-Z` as option labels
|
|
- App.tsx contains `import { FilterBar }` from `@/components/FilterBar`
|
|
- App.tsx contains `const [search, setSearch] = useState`
|
|
- App.tsx contains `const [statusFilter, setStatusFilter] = useState`
|
|
- App.tsx contains `const [sortOrder, setSortOrder] = useState`
|
|
- App.tsx contains `useMemo` for filteredEntries
|
|
- App.tsx contains `<FilterBar` JSX element
|
|
- App.tsx taggedSections uses `filteredEntries` (not raw `entries`)
|
|
- `bun run build` exits 0
|
|
</acceptance_criteria>
|
|
<done>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</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend
|
|
npx tsc --noEmit
|
|
bun run build
|
|
```
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`
|
|
</output>
|