16 KiB
16 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-ux-improvements | 02 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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):
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:
interface HeaderProps {
onRefresh: () => void
}
From frontend/src/components/ServiceCard.tsx (drag handle - current opacity pattern):
<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:
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
From frontend/src/main.tsx (current hardcoded dark mode):
document.documentElement.classList.add('dark')
From frontend/src/index.css (CSS vars - note: no --destructive or --card defined):
: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 ... */
}
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'`).
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && bunx 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`
- `bunx 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 `` 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.
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && bunx 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
bunx tsc --noEmit
bun run build
```
<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>
After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`