2026-06-23 09:54:27 +00:00
|
|
|
import type { ReactNode } from 'react'
|
2026-06-23 16:55:10 +00:00
|
|
|
import { Link, NavLink } 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
|
|
|
|
2026-06-23 16:55:10 +00:00
|
|
|
const fieldClass =
|
2026-06-24 16:48:56 +00:00
|
|
|
'ac-field w-full px-4 py-3 text-base text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft'
|
2026-06-23 16:55:10 +00:00
|
|
|
|
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-24 16:48:56 +00:00
|
|
|
<p className="max-w-2xl text-lg 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,
|
2026-06-24 10:02:42 +00:00
|
|
|
hint,
|
2026-06-23 09:54:27 +00:00
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
label: string
|
2026-06-24 10:02:42 +00:00
|
|
|
hint?: string
|
2026-06-23 09:54:27 +00:00
|
|
|
children: ReactNode
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-06-24 16:48:56 +00:00
|
|
|
<label className="block text-base">
|
2026-06-23 10:10:22 +00:00
|
|
|
<span className="mb-2 block font-bold text-ink">{label}</span>
|
2026-06-24 10:02:42 +00:00
|
|
|
{hint ? <p className="ac-hint -mt-1 mb-2">{hint}</p> : null}
|
2026-06-23 09:54:27 +00:00
|
|
|
{children}
|
|
|
|
|
</label>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
2026-06-23 16:55:10 +00:00
|
|
|
return <input {...props} className={`${fieldClass} ${props.className ?? ''}`} />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Textarea(
|
|
|
|
|
props: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { mono?: boolean },
|
|
|
|
|
) {
|
|
|
|
|
const { mono, className, ...rest } = props
|
2026-06-23 09:54:27 +00:00
|
|
|
return (
|
2026-06-23 16:55:10 +00:00
|
|
|
<textarea
|
|
|
|
|
{...rest}
|
|
|
|
|
className={`${fieldClass} ${mono ? 'font-mono' : ''} ${className ?? ''}`}
|
2026-06-23 09:54:27 +00:00
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 16:55:10 +00:00
|
|
|
export function Select(props: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
|
|
|
|
return <select {...props} className={`${fieldClass} ${props.className ?? ''}`} />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SectionTitle({ children }: { children: ReactNode }) {
|
|
|
|
|
return <h2 className="ac-section-title">{children}</h2>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AcLink({
|
|
|
|
|
to,
|
|
|
|
|
children,
|
|
|
|
|
className = '',
|
|
|
|
|
}: {
|
|
|
|
|
to: string
|
|
|
|
|
children: ReactNode
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
2026-06-23 09:54:27 +00:00
|
|
|
return (
|
2026-06-23 16:55:10 +00:00
|
|
|
<Link to={to} className={`ac-link ${className}`}>
|
|
|
|
|
{children}
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TabLink({ to, children }: { to: string; children: ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<NavLink
|
|
|
|
|
to={to}
|
|
|
|
|
className={({ isActive }) => `ac-tab ${isActive ? 'ac-tab--active' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</NavLink>
|
2026-06-23 09:54:27 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 16:55:10 +00:00
|
|
|
export function ChoiceCard({
|
|
|
|
|
active,
|
|
|
|
|
title,
|
|
|
|
|
hint,
|
|
|
|
|
disabled,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
active?: boolean
|
|
|
|
|
title: string
|
|
|
|
|
hint?: string
|
|
|
|
|
disabled?: boolean
|
|
|
|
|
onClick: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={`ac-choice-card ${active ? 'ac-choice-card--active' : ''}`}
|
|
|
|
|
>
|
2026-06-24 16:48:56 +00:00
|
|
|
<p className="text-base font-bold text-ink">{title}</p>
|
2026-06-23 16:55:10 +00:00
|
|
|
{hint ? <p className="ac-hint mt-1">{hint}</p> : null}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SuccessText({ message }: { message?: string }) {
|
|
|
|
|
if (!message) return null
|
|
|
|
|
return <p className="ac-success-text">{message}</p>
|
|
|
|
|
}
|
|
|
|
|
|
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-24 16:48:56 +00:00
|
|
|
className={`inline-flex min-h-11 items-center justify-center rounded-[var(--radius-pill)] px-5 py-2.5 text-base 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-24 16:48:56 +00:00
|
|
|
className={`inline-flex items-center rounded-[var(--radius-pill)] border-2 px-3 py-1 text-sm 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-24 16:48:56 +00:00
|
|
|
return <p className="mt-2 text-base font-bold text-danger">{message}</p>
|
2026-06-23 16:55:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Notice({
|
|
|
|
|
tone = 'info',
|
|
|
|
|
title,
|
|
|
|
|
message,
|
|
|
|
|
}: {
|
|
|
|
|
tone?: 'info' | 'success' | 'warning'
|
|
|
|
|
title: string
|
|
|
|
|
message?: string
|
|
|
|
|
}) {
|
|
|
|
|
const tones = {
|
|
|
|
|
info: 'border-accent bg-accent-soft text-ink',
|
|
|
|
|
success: 'border-success/40 bg-success-soft text-ink',
|
|
|
|
|
warning: 'border-warning/40 bg-warning-soft text-ink',
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div className={`rounded-[var(--radius-lg)] border-2 px-4 py-3 ${tones[tone]}`}>
|
2026-06-24 16:48:56 +00:00
|
|
|
<p className="text-base font-bold">{title}</p>
|
|
|
|
|
{message ? <p className="mt-1 text-base leading-relaxed text-ink-secondary">{message}</p> : null}
|
2026-06-23 16:55:10 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
2026-06-23 09:54:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 09:34:28 +00:00
|
|
|
export function CopyableId({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
className = '',
|
|
|
|
|
}: {
|
|
|
|
|
label: string
|
|
|
|
|
value: string
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
2026-06-23 09:54:27 +00:00
|
|
|
const copy = async () => {
|
|
|
|
|
if (!value) return
|
|
|
|
|
await navigator.clipboard.writeText(value)
|
|
|
|
|
}
|
|
|
|
|
return (
|
2026-06-25 09:34:28 +00:00
|
|
|
<div className={`ac-slot px-5 py-5 ${className}`.trim()}>
|
2026-06-24 16:48:56 +00:00
|
|
|
<div className="text-sm font-bold text-ink-secondary">{label}</div>
|
2026-06-25 09:34:28 +00:00
|
|
|
<div className="mt-3 flex items-start justify-between gap-3">
|
2026-06-24 16:48:56 +00:00
|
|
|
<code className="break-all text-base font-en text-ink">{value || '—'}</code>
|
2026-06-23 09:54:27 +00:00
|
|
|
{value ? (
|
2026-06-24 16:48:56 +00:00
|
|
|
<button type="button" onClick={copy} className="ac-btn-secondary shrink-0 px-3 py-1 text-sm">
|
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-24 06:04:54 +00:00
|
|
|
className = '',
|
|
|
|
|
guideBadge = false,
|
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
|
2026-06-24 06:04:54 +00:00
|
|
|
className?: string
|
|
|
|
|
guideBadge?: boolean
|
2026-06-23 09:54:27 +00:00
|
|
|
}) {
|
|
|
|
|
return (
|
2026-06-24 06:04:54 +00:00
|
|
|
<Link to={to} className={`ac-app-card group relative block p-5 ${className}`.trim()}>
|
|
|
|
|
{guideBadge ? (
|
|
|
|
|
<span className="hx-guide-badge" aria-hidden>
|
|
|
|
|
完成連線
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-06-23 10:10:22 +00:00
|
|
|
<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>
|
2026-06-24 16:48:56 +00:00
|
|
|
<p className="mt-2 text-center text-base leading-relaxed text-ink-secondary">{desc}</p>
|
|
|
|
|
<p className="mt-3 text-center text-base 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`}>
|
2026-06-24 16:48:56 +00:00
|
|
|
<p className="text-base font-bold text-ink-secondary">{label}</p>
|
2026-06-23 10:10:22 +00:00
|
|
|
<div className="mt-2 text-2xl font-black tracking-tight text-ink md:text-3xl">{value}</div>
|
2026-06-24 16:48:56 +00:00
|
|
|
{hint ? <p className="mt-2 text-base text-muted">{hint}</p> : null}
|
2026-06-23 09:54:27 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
2026-06-23 16:55:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TablePanel({
|
|
|
|
|
title,
|
|
|
|
|
count,
|
|
|
|
|
children,
|
|
|
|
|
className = '',
|
|
|
|
|
}: {
|
|
|
|
|
title: string
|
|
|
|
|
count?: number | string
|
|
|
|
|
children: ReactNode
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={`ac-table-panel ${className}`}>
|
|
|
|
|
<div className="ac-table-panel__head">
|
|
|
|
|
<span>{title}</span>
|
|
|
|
|
{count !== undefined ? (
|
|
|
|
|
<span>{typeof count === 'number' ? `共 ${count} 筆` : count}</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TableShell({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
|
|
|
return <div className={`ac-table-shell ${className}`}>{children}</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Table({
|
|
|
|
|
children,
|
|
|
|
|
className = '',
|
|
|
|
|
minWidth,
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode
|
|
|
|
|
className?: string
|
|
|
|
|
minWidth?: string
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<table className={`ac-table ${className}`} style={minWidth ? { minWidth } : undefined}>
|
|
|
|
|
{children}
|
|
|
|
|
</table>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TableEmpty({ children }: { children: ReactNode }) {
|
|
|
|
|
return <p className="ac-table-empty">{children}</p>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TableAction({
|
|
|
|
|
children,
|
|
|
|
|
variant = 'default',
|
|
|
|
|
className,
|
|
|
|
|
...props
|
|
|
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
|
|
|
variant?: 'default' | 'danger' | 'link'
|
|
|
|
|
}) {
|
|
|
|
|
const variantClass =
|
|
|
|
|
variant === 'danger'
|
|
|
|
|
? 'ac-table-action--danger'
|
|
|
|
|
: variant === 'link'
|
|
|
|
|
? 'ac-table-action--link'
|
|
|
|
|
: ''
|
|
|
|
|
return (
|
|
|
|
|
<button type="button" {...props} className={`ac-table-action ${variantClass} ${className ?? ''}`}>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function StatusBadge({
|
|
|
|
|
children,
|
|
|
|
|
className = '',
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
|
|
|
|
return <span className={`ac-status-badge ${className}`}>{children}</span>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
|
|
|
|
const pct = Math.min(100, Math.max(0, value))
|
|
|
|
|
return (
|
|
|
|
|
<div className={`ac-progress ${className}`}>
|
|
|
|
|
<div className="ac-progress__fill" style={{ width: `${pct}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2026-06-25 09:34:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function HoverTip({
|
|
|
|
|
label,
|
|
|
|
|
children,
|
|
|
|
|
placement = 'top',
|
|
|
|
|
className = '',
|
|
|
|
|
}: {
|
|
|
|
|
label: string
|
|
|
|
|
children: ReactNode
|
|
|
|
|
placement?: 'top' | 'bottom'
|
|
|
|
|
className?: string
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<span className={`ac-hover-tip ac-hover-tip--${placement} ${className}`.trim()}>
|
|
|
|
|
{children}
|
|
|
|
|
<span className="ac-hover-tip__panel" role="tooltip">
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
)
|
2026-06-23 09:54:27 +00:00
|
|
|
}
|