Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit 9a62a31

Browse files
committed
feat(admin): flatten /setting/:type detail layout and consolidate modals
- Drop boxed Panel borders on settings detail; introduce flat SettingsSection (title + description + optional actions + dirty dot). - Lift dirty-form action bar to the persistent detail header via a context setter to avoid layout shift; replace per-section Save with single Save-all / Discard. - Rework /setting/account into a single-column layout; Token and Passkey lists now open via Drawer instead of a nested sub master-detail. - Restructure AI settings around features: Providers list (row + edit Drawer) + Summary / Insights / Translation feature blocks + Other models, dropping the standalone "Model assignments" section. - Move TestAiReview, ChangePassword, CreateToken, MetaPreset modals to the imperative present() API; drop legacy declarative Modal from SettingsPrimitives. - Unify ConfigFieldEditor to a 2-column grid (label / control) with consistent baseline; render switches via headless Toggle so they align with input rows. - Flatten Switch primitive (no default border; opt-in via bordered). - Settings nav becomes a FocusScope with useScopeArrowNav for j/k navigation; active style switched from primary blue to neutral.
1 parent 3a6cba4 commit 9a62a31

27 files changed

Lines changed: 1522 additions & 1263 deletions

apps/admin/src/features/settings/components/OwnerSettings.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ import { getOwner, updateOwner } from '~/api/options'
1818
import { IpInfoPopover } from '~/features/_shared/components/ip-info-popover'
1919
import { useI18n } from '~/i18n'
2020
import { Button } from '~/ui/primitives/button'
21-
import { Panel } from '~/ui/primitives/panel'
2221
import { SelectField } from '~/ui/primitives/select'
2322
import { TextArea, TextInput } from '~/ui/primitives/text-field'
2423

2524
import { settingsQueryKey, socialOptions } from '../constants'
2625
import { formatDateTime, getErrorMessage } from '../utils/settings'
27-
import { SettingsSkeleton } from './SettingsPrimitives'
26+
import { SettingsSection, SettingsSkeleton } from './SettingsPrimitives'
2827

2928
export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
3029
const { t } = useI18n()
@@ -116,18 +115,18 @@ export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
116115
return <SettingsSkeleton title={t('settings.owner.section.title')} />
117116

118117
return (
119-
<Panel
118+
<SettingsSection
120119
description={t('settings.owner.description')}
121120
title={t('settings.owner.section.title')}
122121
>
123122
<form
124-
className="space-y-4 p-4"
123+
className="space-y-6"
125124
onSubmit={(event) => {
126125
event.preventDefault()
127126
mutation.mutate()
128127
}}
129128
>
130-
<div className="flex flex-wrap items-center gap-4 border-b border-neutral-100 pb-4 dark:border-neutral-900">
129+
<div className="flex flex-wrap items-center gap-4">
131130
<input
132131
accept="image/*"
133132
className="hidden"
@@ -250,8 +249,8 @@ export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
250249
value={form.introduce ?? ''}
251250
/>
252251

253-
<section className="rounded border border-neutral-200 dark:border-neutral-800">
254-
<div className="flex items-center justify-between border-b border-neutral-100 px-3 py-2 dark:border-neutral-900">
252+
<div className="space-y-3">
253+
<div className="flex items-center justify-between">
255254
<h3 className="text-sm font-medium">
256255
{t('settings.owner.social.title')}
257256
</h3>
@@ -265,7 +264,7 @@ export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
265264
{t('settings.owner.social.add')}
266265
</Button>
267266
</div>
268-
<div className="space-y-2 p-3">
267+
<div className="space-y-2">
269268
{socialEntries.length === 0 ? (
270269
<p className="text-sm text-neutral-500">
271270
{t('settings.owner.social.empty')}
@@ -318,7 +317,7 @@ export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
318317
))
319318
)}
320319
</div>
321-
</section>
320+
</div>
322321

323322
<div className="flex justify-end">
324323
<Button disabled={mutation.isPending} type="submit">
@@ -331,6 +330,6 @@ export function OwnerSettings(props: { onSaved: () => Promise<unknown> }) {
331330
</Button>
332331
</div>
333332
</form>
334-
</Panel>
333+
</SettingsSection>
335334
)
336335
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createContext, useContext } from 'react'
2+
3+
export type SettingsDirtyAction = {
4+
count: number
5+
onDiscard: () => void
6+
onSaveAll: () => void
7+
saving: boolean
8+
}
9+
10+
export const SettingsActionBarContext = createContext<
11+
(action: SettingsDirtyAction | null) => void
12+
>(() => {})
13+
14+
export function useSettingsActionBarSetter() {
15+
return useContext(SettingsActionBarContext)
16+
}
Lines changed: 41 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,7 @@
1-
import { ArrowLeft, X } from 'lucide-react'
21
import type { ReactNode } from 'react'
32

4-
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
5-
import { Panel } from '~/ui/primitives/panel'
6-
import { Scroll } from '~/ui/primitives/scroll'
73
import { cn } from '~/utils/cn'
84

9-
export function PanelHeader(props: {
10-
children?: ReactNode
11-
onBack: () => void
12-
title: string
13-
}) {
14-
return (
15-
<div
16-
className={cn(
17-
'flex shrink-0 items-center justify-between border-b border-neutral-200 px-4 dark:border-neutral-800',
18-
APP_SHELL_HEADER_HEIGHT_CLASS,
19-
)}
20-
>
21-
<div className="flex items-center gap-3">
22-
<button
23-
className="flex size-8 items-center justify-center rounded text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-900 dark:hover:text-neutral-100"
24-
onClick={props.onBack}
25-
type="button"
26-
>
27-
<ArrowLeft aria-hidden="true" className="size-5" />
28-
</button>
29-
<h2 className="text-base font-semibold">{props.title}</h2>
30-
</div>
31-
{props.children}
32-
</div>
33-
)
34-
}
35-
36-
export function Modal(props: {
37-
children: ReactNode
38-
onClose: () => void
39-
open: boolean
40-
title: string
41-
}) {
42-
if (!props.open) return null
43-
44-
return (
45-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
46-
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded border border-neutral-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-950">
47-
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
48-
<h2 className="text-lg font-semibold">{props.title}</h2>
49-
<button
50-
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-900 dark:hover:text-neutral-100"
51-
onClick={props.onClose}
52-
type="button"
53-
>
54-
<X aria-hidden="true" className="size-5" />
55-
</button>
56-
</div>
57-
<Scroll className="flex-1" innerClassName="p-5">
58-
{props.children}
59-
</Scroll>
60-
</div>
61-
</div>
62-
)
63-
}
64-
655
export function FieldShell(props: { children: ReactNode; label: string }) {
666
return (
677
<label className="grid gap-1.5 text-sm">
@@ -84,16 +24,16 @@ export function EmptyState(props: { icon: ReactNode; label: string }) {
8424

8525
export function SettingsSkeleton(props: { title: string }) {
8626
return (
87-
<Panel title={props.title}>
88-
<div className="space-y-3 p-4">
27+
<SettingsSection title={props.title}>
28+
<div className="space-y-3">
8929
{Array.from({ length: 6 }).map((_, index) => (
9030
<div
9131
className="h-9 animate-pulse rounded bg-neutral-100 dark:bg-neutral-900"
9232
key={index}
9333
/>
9434
))}
9535
</div>
96-
</Panel>
36+
</SettingsSection>
9737
)
9838
}
9939

@@ -104,3 +44,41 @@ export function SmallBadge(props: { children: ReactNode }) {
10444
</span>
10545
)
10646
}
47+
48+
export function SettingsSection(props: {
49+
actions?: ReactNode
50+
children?: ReactNode
51+
className?: string
52+
description?: ReactNode
53+
dirty?: boolean
54+
title: ReactNode
55+
}) {
56+
return (
57+
<section className={cn('space-y-4', props.className)}>
58+
<header className="flex flex-wrap items-start justify-between gap-3">
59+
<div className="min-w-0">
60+
<h2 className="inline-flex items-center gap-2 text-base font-medium text-neutral-950 dark:text-neutral-50">
61+
{props.title}
62+
{props.dirty ? (
63+
<span
64+
aria-label="unsaved"
65+
className="size-1.5 shrink-0 rounded-full bg-amber-500"
66+
/>
67+
) : null}
68+
</h2>
69+
{props.description ? (
70+
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
71+
{props.description}
72+
</p>
73+
) : null}
74+
</div>
75+
{props.actions ? (
76+
<div className="flex shrink-0 items-center gap-2">
77+
{props.actions}
78+
</div>
79+
) : null}
80+
</header>
81+
{props.children}
82+
</section>
83+
)
84+
}

0 commit comments

Comments
 (0)