main
/**
* Presentational primitives shared by every settings surface (user, space,
* repo). Non-component helpers (useSave / inputCls / fmtDate) live in
* settings-utils.ts so this file only exports components (react-refresh).
*/
import { Alert } from '@app/components'
import { useT } from '@app/i18n'
import { Button, Checkbox } from '@app/ui'
import { IconCopy as Copy, IconLoader2 as Loader2 } from '@tabler/icons-react'
import { type ReactNode, useState } from 'react'
export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
return (
<div className="settings-section-head mb-6">
<h2 className="text-[19px] font-bold">{title}</h2>
{sub ? <p className="mt-1 text-[13px] text-shell-ink-muted">{sub}</p> : null}
</div>
)
}
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
return (
<label className="mb-5 block">
<span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
{label}
</span>
{children}
{hint ? <span className="mt-1 block text-[11px] text-shell-ink-muted">{hint}</span> : null}
</label>
)
}
export function Toggle({
checked,
onChange,
label,
disabled = false,
}: {
checked: boolean
onChange: (v: boolean) => void
label: string
disabled?: boolean
}) {
return (
<div className={`mb-3.5 flex items-center gap-2.5 text-[13.5px] ${disabled ? 'text-shell-ink-muted' : ''}`}>
<Checkbox checked={checked} onChange={onChange} aria-label={label} disabled={disabled} />
<span
className={disabled ? 'cursor-default' : 'cursor-pointer'}
onClick={disabled ? undefined : () => onChange(!checked)}
>
{label}
</span>
</div>
)
}
export function SaveBar({
onSave,
busy,
notice,
error,
label,
danger = false,
extra,
}: {
onSave: () => void
busy: boolean
notice: string | null
error: string | null
label?: string
danger?: boolean
extra?: ReactNode
}) {
const { t } = useT()
return (
<div className="flex flex-wrap items-center gap-3">
<Button onClick={onSave} disabled={busy} className={danger ? 'settings-danger-btn' : undefined}>
{busy ? t('ui.saving') : (label ?? t('ui.saveChanges'))}
</Button>
{extra}
{notice ? <Alert tone="success">{notice}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
</div>
)
}
export function CopyButton({ text }: { text: string }) {
const { t } = useT()
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label={t('common.action.copy')}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}}
className="text-shell-ink-muted transition-colors hover:text-shell-ink"
>
<Copy className="h-3.5 w-3.5" />
{copied ? <span className="ml-1 text-[11px]">{t('common.action.copied')}</span> : null}
</button>
)
}
export function Busy() {
const { t } = useT()
return <Loader2 className="my-10 h-6 w-6 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
}
/** One entry in a settings list: leading mark, stacked text, trailing actions. */
export function SettingRow({
lead,
title,
meta,
actions,
}: {
lead?: ReactNode
title: ReactNode
meta?: ReactNode
actions?: ReactNode
}) {
return (
<div className="settings-row">
{lead}
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-semibold">{title}</p>
{meta ? <p className="mt-0.5 text-[12px] text-shell-ink-muted">{meta}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
export function EmptyNote({ children }: { children: ReactNode }) {
return <p className="py-3 text-[13px] text-shell-ink-muted">{children}</p>
}
/** Circular initials mark; used wherever a member or role needs a glyph. */
export function InitialAvatar({ name, url, size = 30 }: { name: string; url?: string | null; size?: number }) {
if (url) {
return (
<img
src={url}
alt=""
width={size}
height={size}
className="shrink-0 rounded-full border border-shell-hairline object-cover"
/>
)
}
return (
<span
aria-hidden
className="grid shrink-0 place-items-center rounded-full border border-shell-hairline bg-shell-surface font-semibold text-shell-ink-muted"
style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
>
{(name.trim()[0] ?? '?').toUpperCase()}
</span>
)
}
/** A short run of inline chips (roles, permissions, states). */
export function Chip({ children }: { children: ReactNode }) {
return (
<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">
{children}
</span>
)
}