From 35187338819c3ca4bd3a4c5de1e7d895c0ee0d54 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 6 Aug 2026 17:03:20 -0400 Subject: [PATCH 1/5] UI polish on corners, settings, consistent components --- src/api/collections.ts | 2 +- src/components/BaseLayout.tsx | 5 +- src/components/CreateMenu.tsx | 4 +- src/components/SettingsLayout.tsx | 191 +++++ src/components/UserMenu.tsx | 39 +- src/components/WebhooksSettings.tsx | 159 ++-- src/components/ui.tsx | 351 +++++++++ src/global.css | 9 + src/lib/api-keys.ts | 19 + src/lib/format.ts | 21 + src/lib/use-is-owner.ts | 21 + src/routes/[owner]/[collection]/diff.tsx | 39 +- src/routes/[owner]/[collection]/index.tsx | 248 ++++-- .../[owner]/[collection]/schemas.data.ts | 13 +- src/routes/[owner]/[collection]/schemas.tsx | 31 +- .../[owner]/[collection]/settings.data.ts | 3 + src/routes/[owner]/[collection]/settings.tsx | 733 +++++++++--------- src/routes/[owner]/[collection]/v/[n].tsx | 450 ++++++----- src/routes/[owner]/[collection]/versions.tsx | 28 +- src/routes/[owner]/index.tsx | 8 +- src/routes/[owner]/settings/index.data.ts | 3 + src/routes/[owner]/settings/index.tsx | 496 ++++++------ src/routes/[owner]/settings/keys.data.ts | 17 +- src/routes/[owner]/settings/keys.tsx | 389 ++++------ src/routes/[owner]/settings/members.data.ts | 13 +- src/routes/[owner]/settings/members.tsx | 289 ++++--- src/routes/dashboard.data.ts | 22 + src/routes/dashboard.tsx | 279 ++++--- src/routes/login.tsx | 10 +- src/routes/new-org.tsx | 61 +- src/routes/new.tsx | 87 +-- src/routes/records/[hash].data.ts | 5 +- src/routes/settings/avatar.data.ts | 4 - src/routes/settings/avatar.tsx | 131 ---- src/routes/settings/index.tsx | 460 +++++------ src/routes/settings/keys.tsx | 306 ++++---- src/routes/settings/sessions.tsx | 118 ++- 37 files changed, 2761 insertions(+), 2303 deletions(-) create mode 100644 src/components/SettingsLayout.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/lib/api-keys.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/use-is-owner.ts delete mode 100644 src/routes/settings/avatar.data.ts delete mode 100644 src/routes/settings/avatar.tsx diff --git a/src/api/collections.ts b/src/api/collections.ts index 0117200..1d36528 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -774,7 +774,7 @@ const app = new Hono() }) .from(schema.collections) .where(and(...conditions)) - .orderBy(schema.collections.updatedAt) + .orderBy(desc(schema.collections.updatedAt)) return c.json(results) }, diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index b3004d7..2689a3b 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -74,7 +74,10 @@ export default function BaseLayout({ children }: { children: React.ReactNode })
- + GitHub
diff --git a/src/components/CreateMenu.tsx b/src/components/CreateMenu.tsx index 20d4577..d6beb9f 100644 --- a/src/components/CreateMenu.tsx +++ b/src/components/CreateMenu.tsx @@ -19,12 +19,12 @@ export default function CreateMenu() { {open && ( -
+
setOpen(false)} diff --git a/src/components/SettingsLayout.tsx b/src/components/SettingsLayout.tsx new file mode 100644 index 0000000..853912d --- /dev/null +++ b/src/components/SettingsLayout.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router' + +import BaseLayout from '~/components/BaseLayout' + +/** + * Shared shell for every settings surface (user, org, collection): a sticky + * left rail of sections, one title style, one content width. + * + * Rail items navigate routes (`to: '/settings/keys'`) or in-page anchors + * (`to: '#webhooks'`). Anchor rails get scrollspy: wrap each section in an + * element with `id` and `data-settings-section` and the rail tracks scroll. + */ + +export interface SettingsRailItem { + label: string + to: string + danger?: boolean +} + +export interface SettingsRailGroup { + heading?: string + items: SettingsRailItem[] +} + +export const userSettingsRail: SettingsRailGroup[] = [ + { + heading: 'Account', + items: [ + { label: 'Profile', to: '/settings' }, + { label: 'API keys', to: '/settings/keys' }, + { label: 'Sessions', to: '/settings/sessions' }, + ], + }, +] + +export function orgSettingsRail(owner: string): SettingsRailGroup[] { + return [ + { + heading: 'Organization', + items: [ + { label: 'Profile', to: `/${owner}/settings` }, + { label: 'Members', to: `/${owner}/settings/members` }, + ], + }, + { + heading: 'Access', + items: [{ label: 'API keys', to: `/${owner}/settings/keys` }], + }, + ] +} + +export const collectionSettingsRail: SettingsRailGroup[] = [ + { + heading: 'Collection', + items: [ + { label: 'Basics', to: '#basics' }, + { label: 'Metadata', to: '#metadata' }, + { label: 'Export', to: '#export' }, + ], + }, + { + heading: 'Integrations', + items: [ + { label: 'ARK identifiers', to: '#ark' }, + { label: 'Webhooks', to: '#webhooks' }, + ], + }, + { + heading: 'Advanced', + items: [ + { label: 'Transfer', to: '#transfer' }, + { label: 'Danger zone', to: '#danger', danger: true }, + ], + }, +] + +const itemBase = 'block border-l-2 px-2.5 py-1.5 text-sm transition-colors' +const itemActive = `${itemBase} border-ink bg-parchment-dark text-ink font-medium` +const itemInactive = `${itemBase} border-transparent text-ink-light hover:text-ink` +const itemDanger = `${itemBase} border-transparent text-red-700/80 hover:text-red-700` + +function RailLink({ item, active }: { item: SettingsRailItem; active: boolean }) { + const className = active ? itemActive : item.danger ? itemDanger : itemInactive + if (item.to.startsWith('#')) { + return ( + + {item.label} + + ) + } + return ( + + {item.label} + + ) +} + +export default function SettingsLayout({ + crumb, + title, + description, + groups, + children, +}: { + /** Breadcrumb above the rail+content area, e.g. owner / collection. */ + crumb?: React.ReactNode + title: string + description?: string + groups: SettingsRailGroup[] + children: React.ReactNode +}) { + const location = useLocation() + const anchorIds = groups.flatMap((g) => + g.items.filter((i) => i.to.startsWith('#')).map((i) => i.to.slice(1)), + ) + const [activeAnchor, setActiveAnchor] = useState(null) + + useEffect(() => { + if (anchorIds.length === 0) return + const sections = Array.from(document.querySelectorAll('[data-settings-section]')) + if (sections.length === 0) return + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) setActiveAnchor(entry.target.id) + } + }, + { rootMargin: '-10% 0px -70% 0px' }, + ) + sections.forEach((s) => observer.observe(s)) + return () => observer.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [anchorIds.length]) + + function isActive(item: SettingsRailItem): boolean { + if (item.to.startsWith('#')) { + const id = item.to.slice(1) + return activeAnchor ? activeAnchor === id : anchorIds[0] === id + } + return location.pathname === item.to + } + + return ( + +
+ {crumb &&
{crumb}
} +
+ + +
+ {/* Mobile: the rail collapses to a scrollable row above the content. */} + + +

{title}

+ {description ? ( +

{description}

+ ) : ( +
+ )} + {children} +
+
+
+ + ) +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index c45bb11..9c5c613 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -37,6 +37,13 @@ export default function UserMenu({ const initial = (displayName || slug || '?').charAt(0).toUpperCase() + // Personal org first, then alphabetical — the raw membership order is arbitrary. + const sortedOrgs = [...orgs].sort( + (a, b) => + Number(b.isDefault ?? false) - Number(a.isDefault ?? false) || + a.displayName.localeCompare(b.displayName), + ) + return (
+ {/* List */} @@ -237,15 +229,13 @@ export default function WebhooksSettings({ ) : (
{webhooks.map((hook) => ( -
+

{hook.url}

{hook.bumpFilter.map((b) => ( - - {b} - + {b} ))} {!hook.enabled && · disabled} {hook.lastDeliveryAt && ( @@ -254,21 +244,15 @@ export default function WebhooksSettings({
- - + - + +
@@ -290,53 +274,50 @@ export default function WebhooksSettings({ ) : (deliveries[hook.id]?.length ?? 0) === 0 ? (

No deliveries yet.

) : ( -
- - - - - - - - - +
WhenEventStatusCodeAttempts
+ + + + + + + + + + + + {deliveries[hook.id]!.map((d) => ( + + + + + + + - - - {deliveries[hook.id]!.map((d) => ( - - - - - - - - - ))} - -
WhenEventStatusCodeAttempts
+ {new Date(d.createdAt).toLocaleString()} + + {d.event} + {d.semver && · {d.semver}} + + {d.status} + {d.error && d.status === 'failed' && ( + {d.error} + )} + {d.responseCode ?? '—'}{d.attempts} + {d.status !== 'success' && ( + + )} +
- {new Date(d.createdAt).toLocaleString()} - - {d.event} - {d.semver && · {d.semver}} - - {d.status} - {d.error && d.status === 'failed' && ( - - {d.error} - - )} - {d.responseCode ?? '—'}{d.attempts} - {d.status !== 'success' && ( - - )} -
-
+ ))} + + )}
diff --git a/src/components/ui.tsx b/src/components/ui.tsx new file mode 100644 index 0000000..c5f2301 --- /dev/null +++ b/src/components/ui.tsx @@ -0,0 +1,351 @@ +import { Link } from 'react-router' + +import { TokenLink } from '~/lib/share-token' + +/** + * Shared UI primitives. One visual voice for the whole app: + * - controls (buttons, inputs, chips, menus) round at --radius-control (4px) + * - surfaces (tables, panels, alerts) round at --radius-surface (2px) + * - one hover treatment per variant, one disabled treatment, 100ms color transitions + * - keyboard focus comes from the global :focus-visible rule in global.css + */ + +// ---------------------------------------------------------------- Button + +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'link' | 'dangerLink' +export type ButtonSize = 'sm' | 'md' + +const solidButtonBase = + 'inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-control transition-colors disabled:pointer-events-none disabled:opacity-50' +const textButtonBase = + 'inline-flex cursor-pointer items-center gap-1 rounded-control transition-colors disabled:pointer-events-none disabled:opacity-50' + +const buttonVariants: Record = { + primary: `${solidButtonBase} bg-ink text-parchment font-medium hover:bg-ink-light`, + secondary: `${solidButtonBase} border border-rule bg-parchment text-ink hover:bg-parchment-dark`, + danger: `${solidButtonBase} bg-red-700 font-medium text-white hover:bg-red-800`, + ghost: `${textButtonBase} text-ink-muted hover:text-ink`, + link: `${textButtonBase} text-link hover:underline`, + dangerLink: `${textButtonBase} text-red-700 hover:underline`, +} + +const solidButtonSizes: Record = { + sm: 'px-3 py-1.5 text-xs', + md: 'px-4 py-2 text-sm', +} +const textButtonSizes: Record = { + sm: 'text-xs', + md: 'text-sm', +} + +export function buttonClasses( + variant: ButtonVariant = 'primary', + size: ButtonSize = 'md', + className?: string, +): string { + const isText = variant === 'ghost' || variant === 'link' || variant === 'dangerLink' + const sizes = isText ? textButtonSizes[size] : solidButtonSizes[size] + return `${buttonVariants[variant]} ${sizes}${className ? ` ${className}` : ''}` +} + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize +} + +export function Button({ + variant = 'primary', + size = 'md', + className, + type, + ...rest +}: ButtonProps) { + return ( +