From d32d88ea0edcb7bb5fcc27ca2bb4ae390c1b38f6 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 21:55:14 +0300 Subject: [PATCH] refactor(webui): add shared badges to form kit - add a reusable shared Badge primitive alongside the existing form controls\n- use it for the import auto-filter count pills and remove the route-local badge styles\n- tighten option button spacing so embedded badges read cleanly --- webui/src/components/form/form.module.css | 50 ++++++++++- webui/src/components/form/form.test.tsx | 4 + webui/src/components/form/form.tsx | 33 +++++++ webui/src/components/form/index.ts | 1 + webui/src/routes/import/-route.test.tsx | 3 +- .../src/routes/import/-ui/auto-import-tab.tsx | 85 +++++++++++++------ .../routes/import/-ui/import-page.module.css | 21 ----- 7 files changed, 145 insertions(+), 52 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index 4e440e9f..30e6a60c 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -379,6 +379,9 @@ } .optionButton { + display: inline-flex; + align-items: center; + gap: 8px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 999px; background: rgba(255, 255, 255, 0.05); @@ -388,7 +391,7 @@ font-size: 13px; font-weight: 600; min-width: 80px; - padding: 10px 14px; + padding: 8px 16px; transition: transform 0.18s ease, border-color 0.18s ease, @@ -416,6 +419,51 @@ 0 10px 24px rgba(0, 0, 0, 0.14); } +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2ch; + padding: 1px 7px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + vertical-align: middle; +} + +.badgeNeutral { + color: rgba(255, 255, 255, 0.45); + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.08); +} + +.badgeInfo { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.12); + border-color: rgba(var(--accent-light-rgb), 0.12); +} + +.badgeSuccess { + color: #4ade80; + background: rgba(74, 222, 128, 0.12); + border-color: rgba(74, 222, 128, 0.12); +} + +.badgeWarning { + color: #fbbf24; + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.12); +} + +.badgeDanger { + color: #f87171; + background: rgba(248, 113, 113, 0.12); + border-color: rgba(248, 113, 113, 0.12); +} + .button { display: inline-flex; align-items: center; diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index cadef869..7a060555 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { describe, expect, it } from 'vitest'; import { + Badge, Button, Checkbox, FormActions, @@ -114,6 +115,8 @@ function FormDemo() { /> + 12 + @@ -166,6 +169,7 @@ describe('form primitives', () => { fireEvent.click(highPriority); expect(highPriority).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning'); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute( 'data-variant', diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 789fa55e..11e46227 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -261,6 +261,39 @@ export const OptionButton = forwardRef(fun ); }); +type BaseBadgeProps = ComponentPropsWithoutRef<'span'>; + +export type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + +export type BadgeProps = Omit & { + className?: string; + tone?: BadgeTone; +}; + +export const Badge = forwardRef(function Badge( + { className, tone = 'neutral', ...props }, + ref, +) { + return ( + + ); +}); + type BaseButtonProps = ComponentPropsWithoutRef; export type ButtonProps = Omit & { diff --git a/webui/src/components/form/index.ts b/webui/src/components/form/index.ts index f9a268a6..0f6ffb0d 100644 --- a/webui/src/components/form/index.ts +++ b/webui/src/components/form/index.ts @@ -1,5 +1,6 @@ export { Button, + Badge, FormActions, FormError, FormField, diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx index 9020992e..79d686c9 100644 --- a/webui/src/routes/import/-route.test.tsx +++ b/webui/src/routes/import/-route.test.tsx @@ -301,9 +301,8 @@ describe('import route', () => { it('renders auto-import results from route search state', async () => { renderImportRoute(['/import/auto?autoFilter=pending']); - expect(await screen.findByText('1 review')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument(); expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); - expect(screen.getByText('Needs Review')).toBeInTheDocument(); expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false); expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe( false, diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index 13025853..63ee5f57 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -1,7 +1,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { Button, OptionButton, OptionButtonGroup, RangeInput, Select, Switch } from '@/components/form/form'; +import { + Badge, + Button, + OptionButton, + OptionButtonGroup, + RangeInput, + Select, + Switch, +} from '@/components/form/form'; import type { ImportAutoFilter, @@ -250,31 +258,13 @@ export function AutoImportPanel({ {allResults.length > 0 ? ( <> -
- - {counts.imported} imported - - - {counts.review} review - - - {counts.failed} failed - -
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - onFilterChange(filter)} - > - {filter === 'pending' ? 'Needs Review' : titleCase(filter)} + onFilterChange(filter)}> + {getAutoImportFilterLabel(filter)} + + {getAutoImportFilterCount(filter, counts, allResults.length)} + ))}
@@ -555,6 +545,49 @@ function getAutoImportConfidenceClass(status: string): string { return classes[status] ?? ''; } +function getAutoImportFilterLabel(filter: ImportAutoFilter): string { + switch (filter) { + case 'all': + return 'All'; + case 'pending': + return 'Needs Review'; + case 'imported': + return 'Imported'; + case 'failed': + return 'Failed'; + } +} + +function getAutoImportFilterCount( + filter: ImportAutoFilter, + counts: ReturnType, + totalCount: number, +): number { + switch (filter) { + case 'all': + return totalCount; + case 'pending': + return counts.review; + case 'imported': + return counts.imported; + case 'failed': + return counts.failed; + } +} + +function getAutoImportFilterTone(filter: ImportAutoFilter): 'neutral' | 'warning' | 'success' | 'danger' { + switch (filter) { + case 'pending': + return 'warning'; + case 'imported': + return 'success'; + case 'failed': + return 'danger'; + case 'all': + return 'neutral'; + } +} + function getMethodLabel(method: string | null | undefined): string { const labels: Record = { tags: 'Tags', @@ -565,10 +598,6 @@ function getMethodLabel(method: string | null | undefined): string { return method ? labels[method] || method : ''; } -function titleCase(value: string): string { - return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} - async function confirmAction({ title, message, diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 5470d25d..f30e8e7a 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -1122,27 +1122,6 @@ animation: adlPulse 1.5s ease-in-out infinite; } -/* Stats summary */ -.autoImportStats { - display: flex; - gap: 16px; - padding: 8px 0; - margin-bottom: 4px; -} - -.autoImportStat { - font-size: 12px; - color: rgba(255, 255, 255, 0.5); - font-weight: 500; -} - -.autoImportStatReview { - color: #fbbf24; -} -.autoImportStatFailed { - color: #f87171; -} - /* Filter pills */ .autoImportFilters { padding: 6px 0;