Skip to content
Clone
f0a1552
191 lines 4.9 KB
/**
* 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 { useState, type ReactNode } from 'react'
import { Copy, Loader2 } from 'lucide-react'
import { Alert, Button, Checkbox } from '@/shared/ui'
export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
return (
<div className="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
}) {
return (
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={onSave}
disabled={busy}
className={danger ? 'settings-danger-btn' : undefined}
>
{busy ? '保存中…' : label}
</Button>
{extra}
{notice ? <Alert tone="success">{notice}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
</div>
)
}
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label="复制"
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]">已复制</span> : null}
</button>
)
}
export function Busy() {
return <Loader2 className="my-10 h-6 w-6 animate-spin text-shell-ink-muted" aria-label="加载中" />
}
/** 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>
)
}