haixunMaster/haixun-backend/web/src/components/ui.tsx

171 lines
5.0 KiB
TypeScript
Raw Normal View History

2026-06-23 09:54:27 +00:00
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
2026-06-23 10:10:22 +00:00
import type { AcAppKey } from '../lib/acAssets'
import { AcIcon } from './AcIcon'
2026-06-23 09:54:27 +00:00
export function PageTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="mb-8">
2026-06-23 10:10:22 +00:00
<div className="ac-title-bar">{title}</div>
2026-06-23 09:54:27 +00:00
{subtitle ? (
2026-06-23 10:10:22 +00:00
<p className="max-w-2xl text-base leading-relaxed text-ink-secondary">{subtitle}</p>
2026-06-23 09:54:27 +00:00
) : null}
</div>
)
}
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
2026-06-23 10:10:22 +00:00
<div className={`ac-dialog rounded-[var(--radius-lg)] p-5 md:p-6 ${className}`}>{children}</div>
2026-06-23 09:54:27 +00:00
)
}
export function Field({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<label className="block text-sm">
2026-06-23 10:10:22 +00:00
<span className="mb-2 block font-bold text-ink">{label}</span>
2026-06-23 09:54:27 +00:00
{children}
</label>
)
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
2026-06-23 10:10:22 +00:00
className={`ac-field w-full px-4 py-3 text-[15px] text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
2026-06-23 09:54:27 +00:00
/>
)
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
{...props}
2026-06-23 10:10:22 +00:00
className={`ac-field w-full px-4 py-3 font-mono text-[15px] text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
2026-06-23 09:54:27 +00:00
/>
)
}
export function Button({
children,
variant = 'primary',
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' | 'soft' }) {
const styles =
variant === 'primary'
2026-06-23 10:10:22 +00:00
? 'ac-btn-primary'
2026-06-23 09:54:27 +00:00
: variant === 'danger'
2026-06-23 10:10:22 +00:00
? 'ac-btn-danger'
2026-06-23 09:54:27 +00:00
: variant === 'soft'
2026-06-23 10:10:22 +00:00
? 'ac-btn-secondary bg-brand-soft text-brand'
: 'ac-btn-secondary'
2026-06-23 09:54:27 +00:00
const { className, ...rest } = props
return (
<button
{...rest}
2026-06-23 10:10:22 +00:00
className={`inline-flex min-h-11 items-center justify-center rounded-[var(--radius-pill)] px-5 py-2.5 text-sm whitespace-nowrap transition disabled:opacity-50 ${styles} ${className ?? ''}`}
2026-06-23 09:54:27 +00:00
>
{children}
</button>
)
}
export function Badge({
children,
tone = 'neutral',
}: {
children: ReactNode
tone?: 'neutral' | 'brand' | 'sky' | 'success' | 'warning' | 'danger'
}) {
const tones = {
2026-06-23 10:10:22 +00:00
neutral: 'border-wood-dark bg-accent-soft text-ink',
brand: 'border-brand bg-brand-soft text-brand',
sky: 'border-accent bg-accent-soft text-accent',
success: 'border-success bg-success-soft text-success',
warning: 'border-warning bg-warning-soft text-warning',
danger: 'border-danger bg-danger-soft text-danger',
2026-06-23 09:54:27 +00:00
}
return (
<span
2026-06-23 10:10:22 +00:00
className={`inline-flex items-center rounded-[var(--radius-pill)] border-2 px-3 py-1 text-[13px] font-bold ${tones[tone]}`}
2026-06-23 09:54:27 +00:00
>
{children}
</span>
)
}
export function ErrorText({ message }: { message?: string }) {
if (!message) return null
2026-06-23 10:10:22 +00:00
return <p className="mt-2 text-sm font-semibold text-danger">{message}</p>
2026-06-23 09:54:27 +00:00
}
export function CopyableId({ label, value }: { label: string; value: string }) {
const copy = async () => {
if (!value) return
await navigator.clipboard.writeText(value)
}
return (
2026-06-23 10:10:22 +00:00
<div className="ac-slot px-4 py-3">
<div className="text-xs font-bold text-ink-secondary">{label}</div>
2026-06-23 09:54:27 +00:00
<div className="mt-1 flex items-start justify-between gap-2">
<code className="break-all text-sm text-ink">{value || '—'}</code>
{value ? (
2026-06-23 10:10:22 +00:00
<button type="button" onClick={copy} className="ac-btn-secondary shrink-0 px-3 py-1 text-xs">
2026-06-23 09:54:27 +00:00
</button>
) : null}
</div>
</div>
)
}
export function QuickLinkCard({
to,
title,
desc,
2026-06-23 10:10:22 +00:00
icon,
2026-06-23 09:54:27 +00:00
}: {
to: string
title: string
desc: string
2026-06-23 10:10:22 +00:00
icon: AcAppKey
2026-06-23 09:54:27 +00:00
tag?: string
}) {
return (
2026-06-23 10:10:22 +00:00
<Link to={to} className="ac-app-card group block p-5">
<AcIcon app={icon} size="lg" className="mx-auto" />
<h3 className="mt-3 text-center text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3>
<p className="mt-2 text-center text-sm leading-relaxed text-ink-secondary">{desc}</p>
<p className="mt-3 text-center text-sm font-bold text-brand"> </p>
2026-06-23 09:54:27 +00:00
</Link>
)
}
export function StatCard({
label,
value,
hint,
tone = 'default',
}: {
label: string
value: ReactNode
hint?: string
tone?: 'default' | 'brand' | 'sky'
}) {
2026-06-23 10:10:22 +00:00
const slot =
tone === 'brand' ? 'ac-slot ac-slot--brand' : tone === 'sky' ? 'ac-slot ac-slot--sky' : 'ac-slot'
2026-06-23 09:54:27 +00:00
return (
2026-06-23 10:10:22 +00:00
<div className={`${slot} p-5`}>
<p className="text-sm font-bold text-ink-secondary">{label}</p>
<div className="mt-2 text-2xl font-black tracking-tight text-ink md:text-3xl">{value}</div>
2026-06-23 09:54:27 +00:00
{hint ? <p className="mt-2 text-sm text-muted">{hint}</p> : null}
</div>
)
}