M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 13,8 13,8 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
- <script type="module" crossorigin src="/assets/index-Ch2vbLAm.js"></script>
- <link rel="stylesheet" crossorigin href="/assets/index-BjmMFw4W.css">
+ <script type="module" crossorigin src="/assets/index-CObHBPUh.js"></script>
+ <link rel="stylesheet" crossorigin href="/assets/index-BqeYZ54s.css">
</head>
<body class="density-compact">
<div id="root"></div>
M web/src/api/client.ts => web/src/api/client.ts +31 -0
@@ 43,3 43,34 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
return resp.json() as Promise<T>
}
+
+/**
+ * apiFetchVoid is a variant of apiFetch for endpoints that return no body
+ * (e.g. DELETE → 204 No Content). It shares the same auth and error handling
+ * as apiFetch but does not attempt to parse the response body.
+ */
+export async function apiFetchVoid(path: string, init?: RequestInit): Promise<void> {
+ const resp = await fetch(path, {
+ ...init,
+ headers: {
+ Accept: 'application/json',
+ ...init?.headers,
+ },
+ })
+
+ if (resp.status === 401) {
+ throw new AuthError('not authenticated')
+ }
+
+ if (!resp.ok) {
+ const ct = resp.headers.get('Content-Type') ?? ''
+ if (ct.includes('application/problem+json')) {
+ const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string }
+ throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '')
+ }
+ if (resp.status >= 500) {
+ throw new APIError('server error', resp.status, '')
+ }
+ throw new APIError(`request failed: ${resp.status}`, resp.status, '')
+ }
+}
A web/src/features/settings/SavedSearchesSection.tsx => web/src/features/settings/SavedSearchesSection.tsx +227 -0
@@ 0,0 1,227 @@
+import React, { useState } from 'react'
+import { EmptyState, Sub } from '../../primitives'
+import { AuthError, APIError } from '../../api/client'
+import type { SavedSearch } from '../../api/adapters'
+import {
+ useSavedSearches,
+ useCreateSavedSearch,
+ useUpdateSavedSearch,
+ useDeleteSavedSearch,
+} from './useSavedSearches'
+
+// ── Inline "Add new" form ─────────────────────────────────────────────────────
+
+function AddForm(): React.JSX.Element {
+ const [name, setName] = useState('')
+ const [query, setQuery] = useState('')
+ const createMutation = useCreateSavedSearch()
+
+ function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ if (!name.trim() || !query.trim()) return
+ createMutation.mutate(
+ { name: name.trim(), query: query.trim() },
+ {
+ onSuccess: () => {
+ setName('')
+ setQuery('')
+ },
+ },
+ )
+ }
+
+ return (
+ <form className="saved-search-form" onSubmit={handleSave}>
+ <input
+ className="saved-search-input"
+ placeholder="name"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ autoComplete="off"
+ />
+ <input
+ className="saved-search-input saved-search-input-mono"
+ placeholder="query"
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ autoComplete="off"
+ />
+ <button
+ className="saved-search-btn"
+ type="submit"
+ disabled={createMutation.isPending}
+ >
+ save
+ </button>
+ {createMutation.isError && (
+ <span className="saved-search-error">
+ {createMutation.error instanceof APIError
+ ? createMutation.error.message
+ : String(createMutation.error)}
+ </span>
+ )}
+ </form>
+ )
+}
+
+// ── Per-row inline edit form ──────────────────────────────────────────────────
+
+interface RowProps {
+ row: SavedSearch
+}
+
+function SavedSearchRow({ row }: RowProps): React.JSX.Element {
+ const [editing, setEditing] = useState(false)
+ const [name, setName] = useState(row.name)
+ const [query, setQuery] = useState(row.query)
+ const updateMutation = useUpdateSavedSearch()
+ const deleteMutation = useDeleteSavedSearch()
+
+ function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ updateMutation.mutate(
+ { oldName: row.name, name: name.trim() || undefined, query: query.trim() || undefined },
+ { onSuccess: () => setEditing(false) },
+ )
+ }
+
+ function handleDelete() {
+ deleteMutation.mutate({ name: row.name })
+ }
+
+ if (editing) {
+ return (
+ <div className="saved-searches-row saved-searches-row-editing">
+ <form className="saved-search-form" onSubmit={handleSave}>
+ <input
+ className="saved-search-input"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ autoComplete="off"
+ />
+ <input
+ className="saved-search-input saved-search-input-mono"
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ autoComplete="off"
+ />
+ <button className="saved-search-btn" type="submit" disabled={updateMutation.isPending}>
+ save
+ </button>
+ <button
+ className="saved-search-btn"
+ type="button"
+ onClick={() => {
+ setEditing(false)
+ setName(row.name)
+ setQuery(row.query)
+ }}
+ >
+ cancel
+ </button>
+ </form>
+ {updateMutation.isError && (
+ <span className="saved-search-error">
+ {updateMutation.error instanceof APIError
+ ? updateMutation.error.message
+ : String(updateMutation.error)}
+ </span>
+ )}
+ </div>
+ )
+ }
+
+ return (
+ <div className="saved-searches-row">
+ <span className="saved-searches-cell saved-searches-name">{row.name}</span>
+ <span className="saved-searches-cell saved-searches-query mono">{row.query}</span>
+ <span className="saved-searches-cell saved-searches-updated muted">
+ {new Date(row.updatedAt).toLocaleDateString()}
+ </span>
+ <span className="saved-searches-cell saved-searches-actions">
+ <button
+ className="saved-search-btn"
+ type="button"
+ onClick={() => setEditing(true)}
+ >
+ edit
+ </button>
+ <button
+ className="saved-search-btn saved-search-btn-danger"
+ type="button"
+ onClick={handleDelete}
+ disabled={deleteMutation.isPending}
+ >
+ delete
+ </button>
+ </span>
+ {deleteMutation.isError && (
+ <span className="saved-search-error">
+ {deleteMutation.error instanceof APIError
+ ? deleteMutation.error.message
+ : String(deleteMutation.error)}
+ </span>
+ )}
+ </div>
+ )
+}
+
+// ── Main section ──────────────────────────────────────────────────────────────
+
+export function SavedSearchesSection(): React.JSX.Element {
+ const { data, isLoading, error } = useSavedSearches()
+
+ if (isLoading) {
+ return <Sub>loading…</Sub>
+ }
+
+ if (error != null) {
+ if (error instanceof AuthError) {
+ return (
+ <div
+ className="body body-pad"
+ style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
+ >
+ <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
+ <div className="uppercase-mono" style={{ marginBottom: 8 }}>not authenticated</div>
+ <div className="muted">Sign in to manage your saved searches.</div>
+ </div>
+ </div>
+ )
+ }
+ const detail = error instanceof APIError ? error.message : String(error)
+ return (
+ <div
+ className="body body-pad"
+ style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
+ >
+ <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
+ <div className="uppercase-mono" style={{ marginBottom: 8 }}>error</div>
+ <div className="muted">{detail}</div>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="saved-searches-section">
+ <div className="saved-searches-heading">Saved searches</div>
+ <AddForm />
+ {data != null && data.length === 0 ? (
+ <EmptyState glyph="∅" copy="no saved searches yet" />
+ ) : (
+ <div className="saved-searches-table">
+ <div className="saved-searches-thead">
+ <span>Name</span>
+ <span>Query</span>
+ <span>Updated</span>
+ <span></span>
+ </div>
+ {data?.map((row) => (
+ <SavedSearchRow key={row.name} row={row} />
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}
A web/src/features/settings/SectionRail.tsx => web/src/features/settings/SectionRail.tsx +57 -0
@@ 0,0 1,57 @@
+import React from 'react'
+
+interface Section<K extends string> {
+ key: K
+ label: string
+ disabled?: boolean
+ tag?: string
+}
+
+interface SectionRailProps<K extends string> {
+ sections: Section<K>[]
+ active: K
+ onSelect: (k: K) => void
+}
+
+export function SectionRail<K extends string>({
+ sections,
+ active,
+ onSelect,
+}: SectionRailProps<K>): React.JSX.Element {
+ return (
+ <div className="section-rail">
+ {sections.map((s) => {
+ const isActive = s.key === active
+ const cls = [
+ 'section-row',
+ isActive ? 'active' : '',
+ s.disabled ? 'disabled' : '',
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ return (
+ <div
+ key={s.key}
+ className={cls}
+ onClick={s.disabled ? undefined : () => onSelect(s.key)}
+ role={s.disabled ? undefined : 'button'}
+ tabIndex={s.disabled ? undefined : 0}
+ onKeyDown={
+ s.disabled
+ ? undefined
+ : (e) => {
+ if (e.key === 'Enter' || e.key === ' ') onSelect(s.key)
+ }
+ }
+ >
+ {s.label}
+ {s.tag != null && (
+ <span className="section-row-tag">{s.tag}</span>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ )
+}
A web/src/features/settings/useSavedSearches.test.ts => web/src/features/settings/useSavedSearches.test.ts +94 -0
@@ 0,0 1,94 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import React from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useSavedSearches, useCreateSavedSearch } from './useSavedSearches'
+import type { SavedSearchDTO } from '../../api/adapters'
+
+// ── Mock apiFetch ─────────────────────────────────────────────────────────────
+
+const mockApiFetch = vi.fn()
+
+vi.mock('../../api/client', () => ({
+ apiFetch: (...args: unknown[]) => mockApiFetch(...args),
+ apiFetchVoid: vi.fn().mockResolvedValue(undefined),
+ AuthError: class AuthError extends Error {
+ name = 'AuthError'
+ constructor(message: string) { super(message) }
+ },
+ APIError: class APIError extends Error {
+ name = 'APIError'
+ status: number
+ code: string
+ constructor(message: string, status: number, code: string) {
+ super(message)
+ this.status = status
+ this.code = code
+ }
+ },
+}))
+
+// ── Test wrapper ──────────────────────────────────────────────────────────────
+
+function makeWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return React.createElement(QueryClientProvider, { client: queryClient }, children)
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('useSavedSearches + useCreateSavedSearch — cache invalidation', () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ mockApiFetch.mockReset()
+ })
+
+ it('invalidates saved-searches after createSavedSearch succeeds', async () => {
+ const emptyResponse = { saved_searches: [] as SavedSearchDTO[] }
+ const filledResponse = {
+ saved_searches: [
+ { name: 'a', query: 'q', created_at: 0, updated_at: 0 } satisfies SavedSearchDTO,
+ ],
+ }
+ // POST returns the created row
+ const createdRow: SavedSearchDTO = { name: 'a', query: 'q', created_at: 0, updated_at: 0 }
+
+ // First call → initial list fetch (empty)
+ // Second call → re-fetch after invalidation (one row)
+ // Third call may come from the mutation POST
+ mockApiFetch
+ .mockResolvedValueOnce(emptyResponse) // initial GET
+ .mockResolvedValueOnce(createdRow) // POST create
+ .mockResolvedValueOnce(filledResponse) // re-fetch after invalidation
+
+ const wrapper = makeWrapper(queryClient)
+
+ const { result: listResult } = renderHook(() => useSavedSearches(), { wrapper })
+ const { result: mutResult } = renderHook(() => useCreateSavedSearch(), { wrapper })
+
+ // Wait for initial fetch to settle
+ await waitFor(() => expect(listResult.current.isSuccess).toBe(true))
+ expect(listResult.current.data).toEqual([])
+
+ // Call mutate
+ act(() => {
+ mutResult.current.mutate({ name: 'a', query: 'q' })
+ })
+
+ // Wait for mutation success + re-fetch
+ await waitFor(() => expect(mutResult.current.isSuccess).toBe(true))
+ await waitFor(() => {
+ expect(listResult.current.data?.length).toBe(1)
+ })
+
+ expect(listResult.current.data?.[0].name).toBe('a')
+ })
+})
A web/src/features/settings/useSavedSearches.ts => web/src/features/settings/useSavedSearches.ts +79 -0
@@ 0,0 1,79 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'
+import { apiFetch, apiFetchVoid } from '../../api/client'
+import { adaptSavedSearch } from '../../api/adapters'
+import type { SavedSearch, SavedSearchDTO } from '../../api/adapters'
+
+interface SavedSearchesResponse {
+ saved_searches: SavedSearchDTO[]
+}
+
+export function useSavedSearches(): UseQueryResult<SavedSearch[]> {
+ return useQuery({
+ queryKey: ['saved-searches'],
+ queryFn: async () => {
+ const data = await apiFetch<SavedSearchesResponse>('/api/v1/saved-searches')
+ return data.saved_searches.map(adaptSavedSearch)
+ },
+ staleTime: 30_000,
+ })
+}
+
+export function useCreateSavedSearch(): UseMutationResult<SavedSearch, Error, { name: string; query: string }> {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({ name, query }) => {
+ const dto = await apiFetch<SavedSearchDTO>('/api/v1/saved-searches', {
+ method: 'POST',
+ body: JSON.stringify({ name, query }),
+ headers: { 'Content-Type': 'application/json' },
+ })
+ return adaptSavedSearch(dto)
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
+ },
+ })
+}
+
+export function useUpdateSavedSearch(): UseMutationResult<
+ SavedSearch,
+ Error,
+ { oldName: string; name?: string; query?: string }
+> {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({ oldName, name, query }) => {
+ const body: Record<string, string> = {}
+ if (name !== undefined) body['name'] = name
+ if (query !== undefined) body['query'] = query
+
+ const dto = await apiFetch<SavedSearchDTO>(
+ `/api/v1/saved-searches/${encodeURIComponent(oldName)}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' },
+ },
+ )
+ return adaptSavedSearch(dto)
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
+ },
+ })
+}
+
+export function useDeleteSavedSearch(): UseMutationResult<void, Error, { name: string }> {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({ name }) => {
+ await apiFetchVoid(`/api/v1/saved-searches/${encodeURIComponent(name)}`, {
+ method: 'DELETE',
+ })
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
+ },
+ })
+}
M web/src/routes/settings.tsx => web/src/routes/settings.tsx +19 -4
@@ 1,20 1,35 @@
import { createFileRoute } from '@tanstack/react-router'
-import React from 'react'
-import { EmptyState, Tag } from '../primitives'
+import React, { useState } from 'react'
+import { Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'
+import { SectionRail } from '../features/settings/SectionRail'
+import { SavedSearchesSection } from '../features/settings/SavedSearchesSection'
+import '../styles/settings.css'
+
+type SectionKey = 'saved-searches' | 'display'
+
+const SECTIONS: { key: SectionKey; label: string; disabled?: boolean; tag?: string }[] = [
+ { key: 'saved-searches', label: 'Saved searches' },
+ { key: 'display', label: 'Display', disabled: true, tag: 'in #8' },
+]
export const Route = createFileRoute('/settings')({
component: SettingsRoute,
})
function SettingsRoute(): React.JSX.Element {
+ const [active, setActive] = useState<SectionKey>('saved-searches')
+
return (
<>
<SubBar>
<Tag kind="neutral">settings</Tag>
</SubBar>
- <div className="body body-pad">
- <EmptyState glyph="∅" copy="coming in a later task" />
+ <div className="settings-shell">
+ <SectionRail sections={SECTIONS} active={active} onSelect={setActive} />
+ <div className="settings-panel">
+ {active === 'saved-searches' && <SavedSearchesSection />}
+ </div>
</div>
</>
)
A web/src/styles/settings.css => web/src/styles/settings.css +225 -0
@@ 0,0 1,225 @@
+/* Settings route */
+
+/* Two-column shell: narrow sidebar + main panel */
+.settings-shell {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ gap: 0;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.settings-panel {
+ padding: 16px 20px;
+ overflow: auto;
+ min-height: 0;
+}
+
+/* ── Section rail (vertical nav) ────────────────────────────────────────────── */
+
+.section-rail {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--rule-2);
+ padding: 8px 0;
+ overflow: auto;
+}
+
+.section-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ font-size: 12px;
+ color: var(--ink-2);
+ cursor: pointer;
+ transition: background 80ms;
+ border-left: 2px solid transparent;
+ user-select: none;
+}
+
+.section-row:hover:not(.disabled) {
+ background: var(--paper-2);
+ color: var(--ink);
+}
+
+.section-row.active {
+ background: var(--accent-soft);
+ border-left: 2px solid var(--accent);
+ color: var(--ink);
+ font-weight: 500;
+}
+
+.section-row.disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.section-row-tag {
+ font-size: 10px;
+ font-family: var(--mono);
+ color: var(--ink-3);
+ margin-left: auto;
+}
+
+/* ── Saved searches section ──────────────────────────────────────────────────── */
+
+.saved-searches-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.saved-searches-heading {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink);
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--rule-2);
+}
+
+/* Inline add/edit form: inputs + button on one line */
+.saved-search-form {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.saved-search-input {
+ padding: 4px 8px;
+ font-size: 12px;
+ font-family: var(--sans);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ background: var(--paper-4);
+ color: var(--ink);
+ outline: none;
+ min-width: 120px;
+}
+
+.saved-search-input:focus {
+ border-color: var(--accent);
+}
+
+.saved-search-input-mono {
+ font-family: var(--mono);
+ min-width: 200px;
+}
+
+.saved-search-btn {
+ padding: 3px 10px;
+ font-size: 11px;
+ font-family: var(--mono);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ink-2);
+ cursor: pointer;
+ transition: background 80ms;
+ white-space: nowrap;
+}
+
+.saved-search-btn:hover:not(:disabled) {
+ background: var(--paper-2);
+ color: var(--ink);
+}
+
+.saved-search-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.saved-search-btn-danger {
+ border-color: var(--err);
+ color: var(--err);
+}
+
+.saved-search-btn-danger:hover:not(:disabled) {
+ background: var(--err-bg);
+}
+
+.saved-search-error {
+ font-size: 11px;
+ color: var(--err);
+ font-family: var(--mono);
+}
+
+/* Table */
+.saved-searches-table {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--rule-2);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.saved-searches-thead {
+ display: grid;
+ grid-template-columns: 180px 1fr 90px 120px;
+ padding: 5px 10px;
+ background: var(--paper-3);
+ font-size: 10px;
+ color: var(--ink-3);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-family: var(--mono);
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--rule-2);
+}
+
+.saved-searches-row {
+ display: grid;
+ grid-template-columns: 180px 1fr 90px 120px;
+ padding: 5px 10px;
+ border-bottom: 1px solid var(--rule-2);
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--ink);
+}
+
+.saved-searches-row:last-child {
+ border-bottom: none;
+}
+
+.saved-searches-row:hover {
+ background: var(--paper-2);
+}
+
+.saved-searches-row-editing {
+ display: block;
+ padding: 6px 10px;
+}
+
+.saved-searches-cell {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.saved-searches-query {
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.saved-searches-updated {
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.saved-searches-actions {
+ display: flex;
+ gap: 4px;
+ overflow: visible;
+}
+
+.mono {
+ font-family: var(--mono);
+}
+
+.muted {
+ color: var(--ink-3);
+}