178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
|
|
import type { ReactNode } from 'react'
|
||
|
|
import { Link } from 'react-router-dom'
|
||
|
|
|
||
|
|
export function PageTitle({ title, subtitle }: { title: string; subtitle?: string }) {
|
||
|
|
return (
|
||
|
|
<div className="mb-8">
|
||
|
|
<h1 className="text-balance text-3xl font-bold tracking-tight text-ink md:text-4xl">{title}</h1>
|
||
|
|
{subtitle ? (
|
||
|
|
<p className="mt-2 max-w-2xl text-base leading-relaxed text-ink-secondary">{subtitle}</p>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||
|
|
return (
|
||
|
|
<div className={`rounded-[var(--radius-lg)] border border-line bg-surface p-6 shadow-card ${className}`}>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Field({
|
||
|
|
label,
|
||
|
|
children,
|
||
|
|
}: {
|
||
|
|
label: string
|
||
|
|
children: ReactNode
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<label className="block text-sm">
|
||
|
|
<span className="mb-2 block font-semibold text-ink">{label}</span>
|
||
|
|
{children}
|
||
|
|
</label>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||
|
|
return (
|
||
|
|
<input
|
||
|
|
{...props}
|
||
|
|
className={`w-full rounded-[var(--radius-md)] border border-line bg-surface px-4 py-3 text-[15px] font-normal text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||
|
|
return (
|
||
|
|
<textarea
|
||
|
|
{...props}
|
||
|
|
className={`w-full rounded-[var(--radius-md)] border border-line bg-surface 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 ?? ''}`}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Button({
|
||
|
|
children,
|
||
|
|
variant = 'primary',
|
||
|
|
...props
|
||
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' | 'soft' }) {
|
||
|
|
const styles =
|
||
|
|
variant === 'primary'
|
||
|
|
? 'bg-brand text-white hover:bg-brand-hover shadow-soft'
|
||
|
|
: variant === 'danger'
|
||
|
|
? 'bg-danger text-white hover:opacity-90'
|
||
|
|
: variant === 'soft'
|
||
|
|
? 'bg-brand-soft text-brand hover:opacity-90'
|
||
|
|
: 'border border-line bg-surface text-ink hover:bg-surface-muted'
|
||
|
|
const { className, ...rest } = props
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
{...rest}
|
||
|
|
className={`inline-flex min-h-11 items-center justify-center rounded-[var(--radius-pill)] px-5 py-2.5 text-sm font-semibold whitespace-nowrap transition active:scale-[0.98] disabled:opacity-50 ${styles} ${className ?? ''}`}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</button>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Badge({
|
||
|
|
children,
|
||
|
|
tone = 'neutral',
|
||
|
|
}: {
|
||
|
|
children: ReactNode
|
||
|
|
tone?: 'neutral' | 'brand' | 'sky' | 'success' | 'warning' | 'danger'
|
||
|
|
}) {
|
||
|
|
const tones = {
|
||
|
|
neutral: 'bg-accent-soft text-ink',
|
||
|
|
brand: 'bg-brand-soft text-brand',
|
||
|
|
sky: 'bg-glow text-brand',
|
||
|
|
success: 'bg-success-soft text-success',
|
||
|
|
warning: 'bg-warning-soft text-warning',
|
||
|
|
danger: 'bg-danger-soft text-danger',
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
className={`inline-flex items-center rounded-[var(--radius-pill)] px-3 py-1 text-[13px] font-bold ${tones[tone]}`}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</span>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ErrorText({ message }: { message?: string }) {
|
||
|
|
if (!message) return null
|
||
|
|
return <p className="mt-2 text-sm text-danger">{message}</p>
|
||
|
|
}
|
||
|
|
|
||
|
|
export function CopyableId({ label, value }: { label: string; value: string }) {
|
||
|
|
const copy = async () => {
|
||
|
|
if (!value) return
|
||
|
|
await navigator.clipboard.writeText(value)
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<div className="rounded-[var(--radius-md)] border border-line bg-surface-muted px-4 py-3">
|
||
|
|
<div className="text-xs font-semibold text-ink-secondary">{label}</div>
|
||
|
|
<div className="mt-1 flex items-start justify-between gap-2">
|
||
|
|
<code className="break-all text-sm text-ink">{value || '—'}</code>
|
||
|
|
{value ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={copy}
|
||
|
|
className="shrink-0 rounded-[var(--radius-pill)] border border-line bg-surface px-3 py-1 text-xs font-semibold text-ink transition hover:bg-brand-soft hover:text-brand"
|
||
|
|
>
|
||
|
|
複製
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function QuickLinkCard({
|
||
|
|
to,
|
||
|
|
title,
|
||
|
|
desc,
|
||
|
|
tag,
|
||
|
|
}: {
|
||
|
|
to: string
|
||
|
|
title: string
|
||
|
|
desc: string
|
||
|
|
tag?: string
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
to={to}
|
||
|
|
className="group block rounded-[var(--radius-xl)] border border-line bg-surface p-6 shadow-card transition hover:-translate-y-1 hover:border-brand/40 hover:shadow-soft"
|
||
|
|
>
|
||
|
|
{tag ? <Badge tone="brand">{tag}</Badge> : null}
|
||
|
|
<h3 className="mt-3 text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3>
|
||
|
|
<p className="mt-2 text-[15px] leading-relaxed text-ink-secondary">{desc}</p>
|
||
|
|
<span className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand">
|
||
|
|
前往 <span className="transition group-hover:translate-x-0.5">→</span>
|
||
|
|
</span>
|
||
|
|
</Link>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function StatCard({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
hint,
|
||
|
|
tone = 'default',
|
||
|
|
}: {
|
||
|
|
label: string
|
||
|
|
value: ReactNode
|
||
|
|
hint?: string
|
||
|
|
tone?: 'default' | 'brand' | 'sky'
|
||
|
|
}) {
|
||
|
|
const bg = tone === 'brand' ? 'bg-brand-soft' : tone === 'sky' ? 'bg-glow' : 'bg-surface'
|
||
|
|
return (
|
||
|
|
<div className={`rounded-[var(--radius-xl)] border border-line p-6 shadow-card ${bg}`}>
|
||
|
|
<p className="text-sm font-semibold text-ink-secondary">{label}</p>
|
||
|
|
<div className="mt-3 text-3xl font-bold tracking-tight text-ink">{value}</div>
|
||
|
|
{hint ? <p className="mt-2 text-sm text-muted">{hint}</p> : null}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|