feat(web/app): add the session and shared form layers
Session helpers read the current user through /auth/me and drop the cached identity on 401/403. Shared form pieces keep validation, submit state and the settings primitives in the product layer instead of the system layers.
ZhenYi committed
Added view/app/src/domains/session/model/session.ts view/app/src/domains/session/model/session.ts +14 −0 View view/app/src/domains/session/model/session.ts
@@ -0,0 +1,14 @@
1import { ApiFailure, meKey, unwrapApi } from '@app/lib'
2import Apis, { type EnvelopeMeResp } from '@app/services/api'
3import { invalidateCache } from '@app/services/storage'
4
5export type SessionUser = NonNullable<EnvelopeMeResp['data']>
6
7export function fetchSessionUser(): Promise<SessionUser> {
8 return unwrapApi<SessionUser>(Apis.account.me()).catch((err) => {
9 if (err instanceof ApiFailure && (err.code === 401 || err.code === 403)) {
10 invalidateCache(meKey())
11 }
12 throw err
13 })
14}
Added view/app/src/domains/session/ui/auth-card.tsx view/app/src/domains/session/ui/auth-card.tsx +28 −0 View view/app/src/domains/session/ui/auth-card.tsx
@@ -0,0 +1,28 @@
1import type { ReactNode } from 'react'
2
3export interface AuthCardProps {
4 /** Header badge (KernelMark or IconBadge), rendered centered above the title. */
5 badge?: ReactNode
6 heading: string
7 subheading?: ReactNode
8 /** Extra header content under the subheading (address chips, notes, ...). */
9 headerExtra?: ReactNode
10 children: ReactNode
11}
12
13/** 440px white card: the single content column of every auth screen. */
14export function AuthCard({ badge, heading, subheading, headerExtra, children }: AuthCardProps) {
15 return (
16 <section className="flex w-full max-w-[460px] flex-col gap-[18px] rounded-card border border-border bg-card p-6 sm:gap-[22px] sm:p-8 lg:max-w-[440px]">
17 <header className="flex w-full flex-col items-center gap-3 text-center">
18 {badge}
19 <h1 className="w-full font-mono text-[21px] font-bold leading-tight tracking-[-0.6px] text-foreground sm:text-[23px]">
20 {heading}
21 </h1>
22 {subheading && <p className="max-w-[340px] text-sm leading-[1.5] text-muted-foreground">{subheading}</p>}
23 {headerExtra}
24 </header>
25 {children}
26 </section>
27 )
28}
Added view/app/src/domains/session/ui/auth-footer.tsx view/app/src/domains/session/ui/auth-footer.tsx +19 −0 View view/app/src/domains/session/ui/auth-footer.tsx
@@ -0,0 +1,19 @@
1import { Link } from 'react-router-dom'
2
3export interface AuthFooterProps {
4 question: string
5 action: string
6 to: string
7}
8
9/** "No account yet? Request access" row at the bottom of a card. */
10export function AuthFooter({ question, action, to }: AuthFooterProps) {
11 return (
12 <p className="flex w-full items-center justify-center gap-1.5 text-[13px] text-muted-foreground">
13 <span>{question}</span>
14 <Link to={to} className="font-bold text-foreground underline-offset-4 hover:underline">
15 {action}
16 </Link>
17 </p>
18 )
19}
Added view/app/src/domains/session/ui/auth-shell.tsx view/app/src/domains/session/ui/auth-shell.tsx +87 −0 View view/app/src/domains/session/ui/auth-shell.tsx
@@ -0,0 +1,87 @@
1import { KernelMark } from '@app/components/brand'
2import { useT } from '@app/i18n'
3import { getRegion, toggleTheme, useTheme } from '@app/lib'
4import { Moon, Sun } from 'lucide-react'
5import { type ReactNode, useEffect } from 'react'
6import { Link } from 'react-router-dom'
7
8export interface AuthShellProps {
9 /** Breadcrumb current segment, e.g. "SIGN IN". */
10 current: string
11 /** Mono caption above the status row, unique per screen. */
12 hint: string
13 children: ReactNode
14}
15
16const mark = 'pointer-events-none absolute size-[18px] border-border'
17const markTL = 'left-4 top-4 sm:left-6 sm:top-6 lg:left-9 lg:top-9'
18const markTR = 'right-4 top-4 sm:right-6 sm:top-6 lg:right-9 lg:top-9'
19const markBL = 'bottom-4 left-4 sm:bottom-6 sm:left-6 lg:bottom-9 lg:left-9'
20const markBR = 'bottom-4 right-4 sm:bottom-6 sm:right-6 lg:bottom-9 lg:right-9'
21const crumbSep = 'font-mono text-[10px] text-border'
22
23/** Full-viewport chrome shared by all auth screens. */
24export function AuthShell({ current, hint, children }: AuthShellProps) {
25 const { t } = useT()
26 const theme = useTheme()
27 const region = getRegion()
28
29 // Each auth screen is a full document: keep the tab title in step with the
30 // active step and the interface language (current is already localized).
31 useEffect(() => {
32 document.title = `${current} · ${t('common.app.name')}`
33 }, [current, t])
34 return (
35 <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 sm:px-8 lg:p-6">
36 <div
37 aria-hidden
38 className="pointer-events-none absolute left-1/2 top-1/2 h-[400px] w-[340px] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(closest-side,rgba(255,132,0,0.15),rgba(255,132,0,0))] sm:h-[480px] sm:w-[620px] lg:h-[600px] lg:w-[900px]"
39 />
40 <div
41 aria-hidden
42 className="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent via-transparent via-55% to-black/[0.04] dark:to-white/[0.05]"
43 />
44
45 <div aria-hidden className={`${mark} ${markTL} border-l border-t`} />
46 <div aria-hidden className={`${mark} ${markTR} border-r border-t`} />
47 <div aria-hidden className={`${mark} ${markBL} border-b border-l`} />
48 <div aria-hidden className={`${mark} ${markBR} border-b border-r`} />
49
50 <nav
51 aria-label={t('auth.shell.breadcrumb')}
52 className="absolute left-8 top-9 hidden items-center gap-2 font-mono text-[10px] tracking-[1.2px] sm:flex lg:left-[70px]"
53 >
54 <KernelMark size={14} radius={4} />
55 <Link to="/" className="font-bold text-foreground">
56 GitDataAI
57 </Link>
58 <span className={crumbSep}>/</span>
59 <span className="text-muted-foreground">AUTH</span>
60 <span className={crumbSep}>/</span>
61 <span className="font-bold text-warning-foreground">{current}</span>
62 </nav>
63
64 <button
65 type="button"
66 onClick={toggleTheme}
67 aria-label={theme === 'dark' ? t('auth.shell.switchToLight') : t('auth.shell.switchToDark')}
68 className="absolute right-[40px] top-[16px] z-20 flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring sm:right-[52px] sm:top-[20px] lg:right-[60px] lg:top-[30px]"
69 >
70 {theme === 'dark' ? <Sun size={15} aria-hidden /> : <Moon size={15} aria-hidden />}
71 </button>
72
73 <div className="relative z-10 flex w-full justify-center">{children}</div>
74
75 <div className="pointer-events-none absolute inset-x-0 bottom-[39px] flex flex-col items-center gap-[10px] px-4">
76 <p className="text-center font-mono text-[10px] leading-relaxed tracking-[1.2px] text-muted-foreground">
77 {hint}
78 </p>
79 <p className="flex items-center gap-2.5 font-mono text-[11px] tracking-[0.5px] text-muted-foreground">
80 <span>{t('auth.shell.region', { region }).toUpperCase()}</span>
81 <span className="text-border">·</span>
82 <span>© 2026 GitDataAI</span>
83 </p>
84 </div>
85 </main>
86 )
87}
Added view/app/src/domains/session/ui/chip.tsx view/app/src/domains/session/ui/chip.tsx +26 −0 View view/app/src/domains/session/ui/chip.tsx
@@ -0,0 +1,26 @@
1import type { ReactNode } from 'react'
2
3export interface ChipProps {
4 children: ReactNode
5 className?: string
6}
7
8/** Small muted pill used for email addresses, purpose tags and token states. */
9export function Chip({ children, className = '' }: ChipProps) {
10 return (
11 <span className={`inline-flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 ${className}`}>{children}</span>
12 )
13}
14
15export interface PillProps {
16 children: ReactNode
17}
18
19/** Tighter uppercase mono tag next to an address chip. */
20export function Pill({ children }: PillProps) {
21 return (
22 <span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[1px] text-muted-foreground uppercase">
23 {children}
24 </span>
25 )
26}
Added view/app/src/domains/session/ui/icon-badge.tsx view/app/src/domains/session/ui/icon-badge.tsx +14 −0 View view/app/src/domains/session/ui/icon-badge.tsx
@@ -0,0 +1,14 @@
1import type { ReactNode } from 'react'
2
3export interface IconBadgeProps {
4 children: ReactNode
5}
6
7/** 44px orange rounded badge holding the screen's header icon. */
8export function IconBadge({ children }: IconBadgeProps) {
9 return (
10 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-[12px] bg-primary text-primary-foreground">
11 {children}
12 </span>
13 )
14}
Added view/app/src/domains/session/index.ts view/app/src/domains/session/index.ts +6 −0 View view/app/src/domains/session/index.ts
@@ -0,0 +1,6 @@
1export { fetchSessionUser, type SessionUser } from './model/session'
2export { AuthCard, type AuthCardProps } from './ui/auth-card'
3export { AuthFooter, type AuthFooterProps } from './ui/auth-footer'
4export { AuthShell, type AuthShellProps } from './ui/auth-shell'
5export { Chip, type ChipProps, Pill, type PillProps } from './ui/chip'
6export { IconBadge, type IconBadgeProps } from './ui/icon-badge'
Added view/app/src/domains/shared/form-modal.tsx view/app/src/domains/shared/form-modal.tsx +340 −0 View view/app/src/domains/shared/form-modal.tsx
@@ -0,0 +1,340 @@
1import { Alert } from '@app/components'
2import { useT } from '@app/i18n'
3import { ApiFailure } from '@app/lib'
4import { Button, Checkbox } from '@app/ui'
5import { Loader2, X } from 'lucide-react'
6import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
7import { parseList } from './form-values'
8
9export type FormValues = Record<string, string | boolean>
10
11interface BaseField {
12 key: string
13 label: string
14 optional?: boolean
15 initialValue?: string | boolean
16}
17
18export type FormField =
19 | (BaseField & {
20 kind: 'text'
21 placeholder?: string
22 hint?: string
23 type?: 'text' | 'email'
24 maxLength?: number
25 sanitize?: (v: string) => string
26 })
27 | (BaseField & {
28 kind: 'textarea'
29 placeholder?: string
30 maxLength?: number
31 hint?: string
32 rows?: number
33 /** Monospace rendering — for machine blobs (SSH keys, tokens). */
34 mono?: boolean
35 })
36 | (BaseField & { kind: 'number'; placeholder?: string; hint?: string })
37 | (BaseField & { kind: 'toggle' })
38 | (BaseField & { kind: 'select'; options: { value: string; label: string }[] })
39 /** `<input type=datetime-local>`; the value is a local date-time string. */
40 | (BaseField & { kind: 'datetime'; hint?: string; placeholder?: string })
41 /** Toggle chips. The value is the selected option values joined by commas. */
42 | (BaseField & {
43 kind: 'multiselect'
44 options: { value: string; label: string }[]
45 hint?: string
46 emptyLabel?: string
47 })
48
49export interface FormModalProps {
50 title: string
51 description?: string
52 /** Free-form block between the description and the fields (tips, snippets). */
53 extra?: ReactNode
54 fields: FormField[]
55 open: boolean
56 danger?: boolean
57 confirmLabel?: string
58 /** Wider dialog (420px → 560px) for forms with long single-line payloads. */
59 wide?: boolean
60 /** Async initial values (e.g. personal preferences). */
61 load?: () => Promise<FormValues>
62 /** Return a string to keep the modal open with a success note, void to close. */
63 onSubmit: (values: FormValues) => Promise<string | void>
64 onClose: () => void
65}
66
67/**
68 * Config-driven modal dialog shared by the space menu's create/settings flows and
69 * the repo branch tools. Errors surface through `ApiFailure` (see shared/lib), so any
70 * slice can throw a translated message and have it shown verbatim.
71 */
72export function FormModal({
73 title,
74 description,
75 extra,
76 fields,
77 open,
78 danger = false,
79 confirmLabel,
80 wide = false,
81 load,
82 onSubmit,
83 onClose,
84}: FormModalProps) {
85 const { t } = useT()
86 const [values, setValues] = useState<FormValues>({})
87 const [loading, setLoading] = useState(false)
88 const [busy, setBusy] = useState(false)
89 const [error, setError] = useState<string | null>(null)
90 const [notice, setNotice] = useState<string | null>(null)
91
92 useEffect(() => {
93 if (!open) return
94 setError(null)
95 setNotice(null)
96 setBusy(false)
97 const base: FormValues = {}
98 for (const field of fields) {
99 base[field.key] = field.kind === 'toggle' ? Boolean(field.initialValue) : String(field.initialValue ?? '')
100 }
101 setValues(base)
102 if (!load) return
103 let active = true
104 setLoading(true)
105 load().then(
106 (loaded) => {
107 if (!active) return
108 setValues((prev) => ({ ...prev, ...loaded }))
109 setLoading(false)
110 },
111 (err) => {
112 if (!active) return
113 setError(err instanceof ApiFailure ? err.message : t('ui.loadFailed'))
114 setLoading(false)
115 },
116 )
117 return () => {
118 active = false
119 }
120 // Re-initialise only when the dialog opens; fields/load are stable per dialog.
121 // eslint-disable-next-line react-hooks/exhaustive-deps
122 }, [open])
123
124 useEffect(() => {
125 if (!open) return
126 const onKey = (event: KeyboardEvent) => {
127 if (event.key !== 'Escape') return
128 // Capture + stop: this is the innermost dialog, so a parent modal
129 // (e.g. the user-settings overlay) must not close along with it.
130 event.stopPropagation()
131 onClose()
132 }
133 window.addEventListener('keydown', onKey, true)
134 return () => window.removeEventListener('keydown', onKey, true)
135 }, [open, onClose])
136
137 if (!open) return null
138
139 const setField = (key: string, value: string | boolean) => {
140 setNotice(null)
141 setValues((prev) => ({ ...prev, [key]: value }))
142 }
143
144 async function handleSubmit(event: FormEvent) {
145 event.preventDefault()
146 if (busy || loading) return
147 const missing = fields.find((f) => {
148 if (f.optional) return false
149 const raw = String(values[f.key] ?? '').trim()
150 return raw === '' && (f.kind === 'text' || f.kind === 'datetime' || f.kind === 'multiselect')
151 })
152 if (missing) {
153 setError(t('ui.fieldRequired', { field: missing.label }))
154 return
155 }
156 const badNumber = fields.find((f) => {
157 if (f.kind !== 'number') return false
158 const raw = String(values[f.key] ?? '')
159 return raw !== '' && (!Number.isFinite(Number(raw)) || Number(raw) < 0)
160 })
161 if (badNumber) {
162 setError(t('ui.fieldNonNegativeInteger', { field: badNumber.label }))
163 return
164 }
165 setBusy(true)
166 setError(null)
167 try {
168 const message = await onSubmit(values)
169 if (message) {
170 setNotice(message)
171 setBusy(false)
172 } else {
173 onClose()
174 }
175 } catch (err) {
176 setError(err instanceof ApiFailure ? err.message : t('ui.actionFailed'))
177 setBusy(false)
178 }
179 }
180
181 const labelClass = 'font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted'
182 const inputClass =
183 'h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink'
184
185 return (
186 <div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
187 <div
188 role="dialog"
189 aria-modal
190 aria-label={title}
191 className={`workspace-dialog w-full bg-shell-surface ${wide ? 'max-w-[560px]' : 'max-w-[420px]'}`}
192 onClick={(e) => e.stopPropagation()}
193 >
194 <div className="mb-4 flex items-center justify-between">
195 <h2 className="text-[16px] font-semibold">{title}</h2>
196 <button
197 type="button"
198 aria-label={t('ui.close')}
199 className="dialog-close text-shell-ink-muted transition-colors hover:text-shell-ink"
200 onClick={onClose}
201 >
202 <X className="h-4 w-4" />
203 </button>
204 </div>
205 {description ? <p className="mb-4 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p> : null}
206 {extra ? <div className="mb-4">{extra}</div> : null}
207 {loading ? (
208 <div className="flex h-32 items-center justify-center">
209 <Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
210 </div>
211 ) : (
212 <form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
213 {fields.map((field) => (
214 <div key={field.key} className="flex flex-col gap-1.5">
215 {field.kind === 'toggle' ? (
216 <div className="flex items-center gap-2 text-[13px]">
217 <Checkbox
218 checked={Boolean(values[field.key])}
219 onChange={(v) => setField(field.key, v)}
220 aria-label={field.label}
221 />
222 <span className="cursor-pointer" onClick={() => setField(field.key, !values[field.key])}>
223 {field.label}
224 </span>
225 </div>
226 ) : (
227 <>
228 <span className={labelClass}>{field.label}</span>
229 {field.kind === 'text' ? (
230 <input
231 value={String(values[field.key] ?? '')}
232 onChange={(e) =>
233 setField(field.key, field.sanitize ? field.sanitize(e.target.value) : e.target.value)
234 }
235 type={field.type ?? 'text'}
236 maxLength={field.maxLength}
237 placeholder={field.placeholder}
238 className={inputClass}
239 />
240 ) : field.kind === 'datetime' ? (
241 <input
242 type="datetime-local"
243 value={String(values[field.key] ?? '')}
244 onChange={(e) => setField(field.key, e.target.value)}
245 placeholder={field.placeholder}
246 className={inputClass}
247 />
248 ) : field.kind === 'multiselect' ? (
249 <div className="flex flex-wrap gap-2 pt-0.5" role="group" aria-label={field.label}>
250 {field.options.length === 0 ? (
251 <span className="text-[12px] text-shell-ink-muted">
252 {field.emptyLabel ?? t('ui.noOptions')}
253 </span>
254 ) : (
255 field.options.map((option) => {
256 const picked = parseList(values[field.key])
257 const on = picked.includes(option.value)
258 return (
259 <button
260 key={option.value}
261 type="button"
262 aria-pressed={on}
263 className={`settings-chip${on ? ' is-on' : ''}`}
264 onClick={() =>
265 setField(
266 field.key,
267 (on
268 ? picked.filter((item) => item !== option.value)
269 : [...picked, option.value]
270 ).join(','),
271 )
272 }
273 >
274 {option.label}
275 </button>
276 )
277 })
278 )}
279 </div>
280 ) : field.kind === 'number' ? (
281 <input
282 type="number"
283 min={0}
284 step={1}
285 value={String(values[field.key] ?? '')}
286 onChange={(e) => setField(field.key, e.target.value)}
287 placeholder={field.placeholder}
288 className={inputClass}
289 />
290 ) : field.kind === 'textarea' ? (
291 <textarea
292 value={String(values[field.key] ?? '')}
293 onChange={(e) => setField(field.key, e.target.value)}
294 maxLength={field.maxLength}
295 rows={field.rows ?? 3}
296 placeholder={field.placeholder}
297 className={`w-full resize-none rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink ${
298 field.mono ? 'font-mono text-[12.5px] leading-5' : ''
299 }`}
300 />
301 ) : (
302 <select
303 value={String(values[field.key] ?? '')}
304 onChange={(e) => setField(field.key, e.target.value)}
305 className={inputClass}
306 >
307 {field.options.map((option) => (
308 <option key={option.value} value={option.value}>
309 {option.label}
310 </option>
311 ))}
312 </select>
313 )}
314 {'hint' in field && field.hint ? (
315 <span className="text-[11px] text-shell-ink-muted">{field.hint}</span>
316 ) : null}
317 </>
318 )}
319 </div>
320 ))}
321 {notice && <Alert tone="success">{notice}</Alert>}
322 {error && <Alert tone="error">{error}</Alert>}
323 <div className="flex justify-end gap-2 pt-1">
324 <Button variant="secondary" onClick={onClose} disabled={busy}>
325 {t('common.action.cancel')}
326 </Button>
327 <Button
328 type="submit"
329 disabled={busy || loading}
330 className={danger ? 'bg-[#f23f43] text-white hover:brightness-95' : undefined}
331 >
332 {busy ? t('common.action.working') : (confirmLabel ?? t('common.action.confirm'))}
333 </Button>
334 </div>
335 </form>
336 )}
337 </div>
338 </div>
339 )
340}
Added view/app/src/domains/shared/form-values.ts view/app/src/domains/shared/form-values.ts +11 −0 View view/app/src/domains/shared/form-values.ts
@@ -0,0 +1,11 @@
1/**
2 * Value helpers for {@link FormModal}'s field kinds. Kept out of form-modal.tsx
3 * so that file exports components only (react-refresh requirement).
4 */
5
6/** Read a `multiselect` value back out of the flat form value record. */
7export const parseList = (value: unknown): string[] =>
8 String(value ?? '')
9 .split(',')
10 .map((item) => item.trim())
11 .filter(Boolean)
Added view/app/src/domains/shared/index.ts view/app/src/domains/shared/index.ts +5 −0 View view/app/src/domains/shared/index.ts
@@ -0,0 +1,5 @@
1export * from './form-modal'
2export * from './form-values'
3export * from './settings-modal'
4export * from './settings-primitives'
5export * from './settings-utils'
Added view/app/src/domains/shared/settings-modal.tsx view/app/src/domains/shared/settings-modal.tsx +168 −0 View view/app/src/domains/shared/settings-modal.tsx
@@ -0,0 +1,168 @@
1import { useT } from '@app/i18n'
2import { type LucideIcon, SquareArrowOutUpRight, X } from 'lucide-react'
3import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
4import { Link } from 'react-router-dom'
5
6export interface SettingsSection {
7 id: string
8 label: string
9 /** Row glyph, mirroring the reference settings dialog. */
10 icon?: LucideIcon
11 /** Rows sharing a group sit under one small caption. */
12 group?: string
13 /** Rows that leave the dialog for a full page instead of switching panes. */
14 href?: string
15}
16
17export interface SettingsModalShellProps {
18 /** Nav heading, e.g. 用户设置 / 社区设置. Also the dialog's accessible name. */
19 title: string
20 sections: SettingsSection[]
21 active: string
22 onSelect: (id: string) => void
23 onClose: () => void
24 children: ReactNode
25 /** Replaces the section list while a section runs its own sub-navigation. */
26 navOverride?: ReactNode
27 /** Swap key for the content pane (defaults to the active section id). */
28 contentToken?: string
29}
30
31/**
32 * The large two-column settings overlay shared by every settings surface, so a
33 * community's settings cannot drift away from the user's own in size or spacing.
34 *
35 * The backdrop deliberately does NOT close: these forms hold unsaved edits
36 * across many sections, and click-away is far too lossy to be the dismiss path.
37 */
38export function SettingsModalShell({
39 title,
40 sections,
41 active,
42 onSelect,
43 onClose,
44 children,
45 navOverride,
46 contentToken,
47}: SettingsModalShellProps) {
48 const { t } = useT()
49 useEffect(() => {
50 const onKey = (event: KeyboardEvent) => {
51 if (event.key === 'Escape') onClose()
52 }
53 window.addEventListener('keydown', onKey)
54 return () => window.removeEventListener('keydown', onKey)
55 }, [onClose])
56
57 const activeSection =
58 sections.find((item) => item.id === active && !item.href) ?? sections.find((item) => !item.href) ?? sections[0]
59
60 return (
61 <div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-6">
62 <div role="dialog" aria-modal aria-label={title} className="settings-dialog text-shell-ink">
63 <nav className="settings-nav" aria-label={t('ui.settingsCategories', { title })}>
64 <FadeSwap token={navOverride ? 'sub' : 'sections'} className="settings-nav-body">
65 {navOverride ?? (
66 <>
67 <p className="settings-nav-title">{title}</p>
68 {sections.map((item, index) => {
69 const Icon = item.icon
70 const opensGroup = Boolean(item.group) && item.group !== sections[index - 1]?.group
71 return (
72 <Fragment key={item.id}>
73 {opensGroup ? <p className="settings-nav-caption">{item.group}</p> : null}
74 {item.href ? (
75 <Link
76 to={item.href}
77 onClick={onClose}
78 className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
79 >
80 {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
81 <span className="truncate">{item.label}</span>
82 <SquareArrowOutUpRight
83 className="ml-auto h-3.5 w-3.5 shrink-0 text-shell-ink-muted"
84 aria-hidden
85 />
86 </Link>
87 ) : (
88 <button
89 type="button"
90 aria-current={active === item.id ? 'page' : undefined}
91 onClick={() => onSelect(item.id)}
92 className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
93 >
94 {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
95 <span className="truncate">{item.label}</span>
96 </button>
97 )}
98 </Fragment>
99 )
100 })}
101 </>
102 )}
103 </FadeSwap>
104 </nav>
105 <div className="settings-pane">
106 <header className="settings-pane-header">
107 <h2
108 key={activeSection?.id}
109 className={`settings-swap-in ${activeSection?.id === 'danger' ? 'text-[#f23f43]' : ''}`}
110 >
111 {activeSection?.label ?? title}
112 </h2>
113 <button
114 type="button"
115 aria-label={t('ui.closeSettings')}
116 onClick={onClose}
117 className="settings-close workspace-icon-button"
118 >
119 <X className="h-5 w-5" />
120 </button>
121 </header>
122 <FadeSwap token={contentToken ?? active} className="settings-content">
123 {children}
124 </FadeSwap>
125 </div>
126 </div>
127 </div>
128 )
129}
130
131/**
132 * Cross-fades a swapped subtree: the outgoing tree is parked on top while the
133 * replacement fades in, so the pane never flashes empty and never shifts.
134 */
135function FadeSwap({ token, className, children }: { token: string; className?: string; children: ReactNode }) {
136 const [current, setCurrent] = useState<{ token: string; node: ReactNode }>({ token, node: children })
137 const [outgoing, setOutgoing] = useState<{ token: string; node: ReactNode } | null>(null)
138 const frame = useRef<HTMLDivElement>(null)
139 const shown = useRef({ token, node: children })
140 const exitTimer = useRef<number | undefined>(undefined)
141
142 useEffect(() => {
143 const previous = shown.current
144 shown.current = { token, node: children }
145 setCurrent(shown.current)
146 // Same view: only refresh the children (async data, edits) — no fade.
147 if (token === previous.token) return
148 setOutgoing(previous)
149 frame.current?.scrollTo({ top: 0 })
150 window.clearTimeout(exitTimer.current)
151 exitTimer.current = window.setTimeout(() => setOutgoing(null), 220)
152 }, [token, children])
153
154 useEffect(() => () => window.clearTimeout(exitTimer.current), [])
155
156 return (
157 <div ref={frame} className={`settings-swap ${className ?? ''}`}>
158 {outgoing ? (
159 <div key={outgoing.token} className="settings-swap-out" aria-hidden>
160 {outgoing.node}
161 </div>
162 ) : null}
163 <div key={current.token} className="settings-swap-in">
164 {current.node}
165 </div>
166 </div>
167 )
168}
Added view/app/src/domains/shared/settings-primitives.tsx view/app/src/domains/shared/settings-primitives.tsx +176 −0 View view/app/src/domains/shared/settings-primitives.tsx
@@ -0,0 +1,176 @@
1/**
2 * Presentational primitives shared by every settings surface (user, space,
3 * repo). Non-component helpers (useSave / inputCls / fmtDate) live in
4 * settings-utils.ts so this file only exports components (react-refresh).
5 */
6
7import { Alert } from '@app/components'
8import { useT } from '@app/i18n'
9import { Button, Checkbox } from '@app/ui'
10import { Copy, Loader2 } from 'lucide-react'
11import { type ReactNode, useState } from 'react'
12
13export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
14 return (
15 <div className="settings-section-head mb-6">
16 <h2 className="text-[19px] font-bold">{title}</h2>
17 {sub ? <p className="mt-1 text-[13px] text-shell-ink-muted">{sub}</p> : null}
18 </div>
19 )
20}
21
22export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
23 return (
24 <label className="mb-5 block">
25 <span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
26 {label}
27 </span>
28 {children}
29 {hint ? <span className="mt-1 block text-[11px] text-shell-ink-muted">{hint}</span> : null}
30 </label>
31 )
32}
33
34export function Toggle({
35 checked,
36 onChange,
37 label,
38 disabled = false,
39}: {
40 checked: boolean
41 onChange: (v: boolean) => void
42 label: string
43 disabled?: boolean
44}) {
45 return (
46 <div className={`mb-3.5 flex items-center gap-2.5 text-[13.5px] ${disabled ? 'text-shell-ink-muted' : ''}`}>
47 <Checkbox checked={checked} onChange={onChange} aria-label={label} disabled={disabled} />
48 <span
49 className={disabled ? 'cursor-default' : 'cursor-pointer'}
50 onClick={disabled ? undefined : () => onChange(!checked)}
51 >
52 {label}
53 </span>
54 </div>
55 )
56}
57
58export function SaveBar({
59 onSave,
60 busy,
61 notice,
62 error,
63 label,
64 danger = false,
65 extra,
66}: {
67 onSave: () => void
68 busy: boolean
69 notice: string | null
70 error: string | null
71 label?: string
72 danger?: boolean
73 extra?: ReactNode
74}) {
75 const { t } = useT()
76 return (
77 <div className="flex flex-wrap items-center gap-3">
78 <Button onClick={onSave} disabled={busy} className={danger ? 'settings-danger-btn' : undefined}>
79 {busy ? t('ui.saving') : (label ?? t('ui.saveChanges'))}
80 </Button>
81 {extra}
82 {notice ? <Alert tone="success">{notice}</Alert> : null}
83 {error ? <Alert tone="error">{error}</Alert> : null}
84 </div>
85 )
86}
87
88export function CopyButton({ text }: { text: string }) {
89 const { t } = useT()
90 const [copied, setCopied] = useState(false)
91 return (
92 <button
93 type="button"
94 aria-label={t('common.action.copy')}
95 onClick={async () => {
96 try {
97 await navigator.clipboard.writeText(text)
98 setCopied(true)
99 setTimeout(() => setCopied(false), 1500)
100 } catch {
101 /* clipboard unavailable */
102 }
103 }}
104 className="text-shell-ink-muted transition-colors hover:text-shell-ink"
105 >
106 <Copy className="h-3.5 w-3.5" />
107 {copied ? <span className="ml-1 text-[11px]">{t('common.action.copied')}</span> : null}
108 </button>
109 )
110}
111
112export function Busy() {
113 const { t } = useT()
114 return <Loader2 className="my-10 h-6 w-6 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
115}
116
117/** One entry in a settings list: leading mark, stacked text, trailing actions. */
118export function SettingRow({
119 lead,
120 title,
121 meta,
122 actions,
123}: {
124 lead?: ReactNode
125 title: ReactNode
126 meta?: ReactNode
127 actions?: ReactNode
128}) {
129 return (
130 <div className="settings-row">
131 {lead}
132 <div className="min-w-0 flex-1">
133 <p className="truncate text-[13.5px] font-semibold">{title}</p>
134 {meta ? <p className="mt-0.5 text-[12px] text-shell-ink-muted">{meta}</p> : null}
135 </div>
136 {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
137 </div>
138 )
139}
140
141export function EmptyNote({ children }: { children: ReactNode }) {
142 return <p className="py-3 text-[13px] text-shell-ink-muted">{children}</p>
143}
144
145/** Circular initials mark; used wherever a member or role needs a glyph. */
146export function InitialAvatar({ name, url, size = 30 }: { name: string; url?: string | null; size?: number }) {
147 if (url) {
148 return (
149 <img
150 src={url}
151 alt=""
152 width={size}
153 height={size}
154 className="shrink-0 rounded-full border border-shell-hairline object-cover"
155 />
156 )
157 }
158 return (
159 <span
160 aria-hidden
161 className="grid shrink-0 place-items-center rounded-full border border-shell-hairline bg-shell-surface font-semibold text-shell-ink-muted"
162 style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
163 >
164 {(name.trim()[0] ?? '?').toUpperCase()}
165 </span>
166 )
167}
168
169/** A short run of inline chips (roles, permissions, states). */
170export function Chip({ children }: { children: ReactNode }) {
171 return (
172 <span className="inline-flex items-center gap-1 rounded-full border border-shell-hairline bg-shell-field px-2 py-0.5 text-[11px] text-shell-ink-muted">
173 {children}
174 </span>
175 )
176}
Added view/app/src/domains/shared/settings-utils.ts view/app/src/domains/shared/settings-utils.ts +90 −0 View view/app/src/domains/shared/settings-utils.ts
@@ -0,0 +1,90 @@
1/**
2 * Non-component helpers for settings sections: the busy/notice/error save trio
3 * plus formatting and field-styling utilities. Kept out of
4 * settings-primitives.tsx so that file stays export-only-components
5 * (react-refresh requirement).
6 */
7
8import { getLocale, translate } from '@app/i18n'
9import { ApiFailure } from '@app/lib'
10import { useState } from 'react'
11
12/**
13 * Fields lean on the shared stylesheet for the border weight, corner radius and
14 * focus ring, so this only carries layout and type.
15 */
16export const inputCls =
17 'h-10 w-full max-w-[440px] rounded-md border border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors'
18
19/**
20 * `ApiFailure` carries the server's own wording; a plain Error thrown by a
21 * section is local copy written for the user (size limits, missing ids), so it
22 * is shown as-is rather than replaced by the generic fallback.
23 */
24export function errText(err: unknown) {
25 if (err instanceof ApiFailure) return err.message
26 if (err instanceof Error && err.message) return err.message
27 return translate(getLocale(), 'ui.actionFailed')
28}
29
30export function useSave() {
31 const [busy, setBusy] = useState(false)
32 const [notice, setNotice] = useState<string | null>(null)
33 const [error, setError] = useState<string | null>(null)
34 async function run<T>(fn: () => Promise<T>, ok?: string | ((value: T) => string)) {
35 setBusy(true)
36 setNotice(null)
37 setError(null)
38 try {
39 const value = await fn()
40 if (ok) setNotice(typeof ok === 'function' ? ok(value) : ok)
41 } catch (err) {
42 setError(errText(err))
43 } finally {
44 setBusy(false)
45 }
46 }
47 return { busy, notice, error, run }
48}
49
50/**
51 * Rust's time crate emits `… +00:00:00` (seconds inside the UTC offset), which
52 * `Date` rejects — fold it back to `+00:00` before parsing. Every date the
53 * settings read out of the space API goes through here.
54 */
55export function parseDate(value?: string | null): Date | null {
56 if (!value) return null
57 const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
58 return Number.isNaN(date.getTime()) ? null : date
59}
60
61export function fmtDate(value?: string | null) {
62 if (!value) return '—'
63 const date = parseDate(value)
64 if (!date) return value
65 // Locale-aware so the settings tables switch conventions along with the UI
66 // language (the old hardcoded `zh-CN` made the whole app read as Chinese).
67 return new Intl.DateTimeFormat(getLocale(), { dateStyle: 'medium', timeStyle: 'short' }).format(date)
68}
69
70/** `2026-09-06T02:00:00+00:00` → the `YYYY-MM-DDTHH:mm` that `<input type=date>` wants. */
71export function toDateInput(value?: string | null) {
72 const date = parseDate(value)
73 if (!date) return ''
74 const pad = (n: number) => String(n).padStart(2, '0')
75 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
76}
77
78/** Read a file as the `data_base64` payload the image endpoints expect. */
79export function fileToBase64(file: File): Promise<string> {
80 return new Promise((resolve, reject) => {
81 const reader = new FileReader()
82 reader.onerror = () => reject(new Error(translate(getLocale(), 'ui.readFileFailed')))
83 reader.onload = () => {
84 const result = String(reader.result ?? '')
85 // `data:image/png;base64,AAAA…` → the part after the comma.
86 resolve(result.slice(result.indexOf(',') + 1))
87 }
88 reader.readAsDataURL(file)
89 })
90}