Skip to content
Clone

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
parent 14cb218 commit c960763 Browse files

13 files changed

+984 −0

File tree 13
Added view/app/src/domains/session/model/session.ts 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 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 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 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 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 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 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 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 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 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 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 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 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}