chore: kanban template
This commit is contained in:
111
apps/kanban/src/routes/__root.tsx
Normal file
111
apps/kanban/src/routes/__root.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createRootRoute, HeadContent, Link, Outlet, Scripts } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultLocale, localeDir } from '../i18n/config'
|
||||
import { classifyHealth, sortCards, type Card } from '../lib/kanban'
|
||||
import { formatApiError, makeApi } from '../lib/api'
|
||||
import '../i18n/config'
|
||||
import '../index.css'
|
||||
|
||||
export interface BoardData {
|
||||
cards: Card[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Root route renders the full HTML document for SSR (owns <html lang dir> so the
|
||||
// tree mirrors for RTL locales) and the shared board chrome. Its loader fetches
|
||||
// the cards once on the server; child routes read this same data via
|
||||
// RootRoute.useLoaderData(), and mutations call router.invalidate() to re-run it.
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'Kanban Board' },
|
||||
],
|
||||
}),
|
||||
loader: async (): Promise<BoardData> => {
|
||||
try {
|
||||
const { cards } = await makeApi().invoke('listCards', {})
|
||||
return { cards: sortCards(cards as Card[]), error: null }
|
||||
} catch (thrown: unknown) {
|
||||
let message: string
|
||||
try {
|
||||
message = await formatApiError(thrown)
|
||||
} catch {
|
||||
message = 'Request failed'
|
||||
}
|
||||
return { cards: [], error: message }
|
||||
}
|
||||
},
|
||||
component: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument() {
|
||||
const locale = defaultLocale
|
||||
const { t } = useTranslation()
|
||||
const { cards, error } = Route.useLoaderData()
|
||||
const health = classifyHealth(cards)
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={localeDir(locale)}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<div className="app-shell">
|
||||
<header className="hero">
|
||||
<div className="hero-copy">
|
||||
<div className="eyebrow">{t('shell.eyebrow')}</div>
|
||||
<h1>{t('shell.title')}</h1>
|
||||
<p>{t('shell.subtitle')}</p>
|
||||
</div>
|
||||
<div className="hero-status">
|
||||
<div className="hero-stat">
|
||||
<span>{t('shell.stats.boardHealth')}</span>
|
||||
<strong data-tone={health}>{t(`health.${health}`)}</strong>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span>{t('shell.stats.cardsTracked')}</span>
|
||||
<strong>{cards.length}</strong>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span>{t('shell.stats.runtime')}</span>
|
||||
<strong>{error ? t('shell.runtimeDegraded') : t('shell.runtimeConnected')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="top-nav">
|
||||
<Link
|
||||
to="/"
|
||||
className="nav-link"
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
{t('shell.nav.overview')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/board"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
{t('shell.nav.board')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/automations"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
{t('shell.nav.automations')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<main className="page-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
63
apps/kanban/src/routes/automations.tsx
Normal file
63
apps/kanban/src/routes/automations.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Route as RootRoute } from './__root'
|
||||
|
||||
export const Route = createFileRoute('/automations')({
|
||||
component: AutomationsPage,
|
||||
})
|
||||
|
||||
function AutomationsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { cards } = RootRoute.useLoaderData()
|
||||
const total = cards.length
|
||||
const active = cards.filter((card) => card.status === 'doing').length
|
||||
|
||||
const rails = [
|
||||
{
|
||||
key: 'onboarding',
|
||||
state: total > 0 ? 'active' : 'idle',
|
||||
},
|
||||
{
|
||||
key: 'queue',
|
||||
state: active > 0 ? 'warming' : 'standby',
|
||||
},
|
||||
{
|
||||
key: 'surface',
|
||||
state: 'ready',
|
||||
},
|
||||
] as const
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<div className="panel wide">
|
||||
<div className="section-heading">
|
||||
<h2>{t('automations.heading')}</h2>
|
||||
<p>{t('automations.sub')}</p>
|
||||
</div>
|
||||
<div className="automation-list">
|
||||
{rails.map((rail) => (
|
||||
<article key={rail.key} className="automation-card">
|
||||
<div className="automation-topline">
|
||||
<h3>{t(`automations.rails.${rail.key}.name`)}</h3>
|
||||
<span data-state={rail.state}>{t(`automations.states.${rail.state}`)}</span>
|
||||
</div>
|
||||
<p>{t(`automations.rails.${rail.key}.detail`)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="section-heading">
|
||||
<h2>{t('automations.why.heading')}</h2>
|
||||
<p>{t('automations.why.sub')}</p>
|
||||
</div>
|
||||
<ul className="bullet-list">
|
||||
{(t('automations.why.bullets', { returnObjects: true }) as string[]).map((bullet) => (
|
||||
<li key={bullet}>{bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
121
apps/kanban/src/routes/board.tsx
Normal file
121
apps/kanban/src/routes/board.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Route as RootRoute } from './__root'
|
||||
import { formatApiError, makeApi } from '../lib/api'
|
||||
import { type CardStatus, STATUSES, prettyStatus, relativeTime } from '../lib/kanban'
|
||||
|
||||
export const Route = createFileRoute('/board')({
|
||||
component: BoardPage,
|
||||
})
|
||||
|
||||
function BoardPage() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { cards, error } = RootRoute.useLoaderData()
|
||||
const [title, setTitle] = useState('')
|
||||
const [status, setStatus] = useState<CardStatus>('todo')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const columns = STATUSES.map((columnStatus) => ({
|
||||
key: columnStatus,
|
||||
title: prettyStatus(columnStatus),
|
||||
cards: cards.filter((card) => card.status === columnStatus),
|
||||
}))
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!title.trim()) return
|
||||
setPending(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
await makeApi().invoke('createCard', { title: title.trim(), status })
|
||||
setTitle('')
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setCreateError(await formatApiError(err))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<div className="panel wide">
|
||||
<div className="section-heading">
|
||||
<h2>{t('board.heading')}</h2>
|
||||
<p>{t('board.sub')}</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="empty-state error-state">{t('board.loadFailed', { error })}</div>
|
||||
) : (
|
||||
<div className="board-grid">
|
||||
{columns.map((column) => (
|
||||
<div key={column.key} className="column">
|
||||
<div className="column-header">
|
||||
<h3>{column.title}</h3>
|
||||
<span>{column.cards.length}</span>
|
||||
</div>
|
||||
<div className="column-cards">
|
||||
{column.cards.length === 0 ? (
|
||||
<div className="column-empty">{t('board.columnEmpty')}</div>
|
||||
) : (
|
||||
column.cards.map((card) => (
|
||||
<article key={card.cardId} className="task-card">
|
||||
<div className="task-card-title">{card.title}</div>
|
||||
<div className="task-card-meta">
|
||||
<span>#{card.position}</span>
|
||||
<span>{relativeTime(card.createdAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="section-heading">
|
||||
<h2>{t('board.createHeading')}</h2>
|
||||
<p>{t('board.createSub')}</p>
|
||||
</div>
|
||||
<form className="card-form" onSubmit={handleSubmit}>
|
||||
<label className="field">
|
||||
<span>{t('board.titleLabel')}</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={t('board.titlePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t('board.columnLabel')}</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as CardStatus)}
|
||||
>
|
||||
{STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{prettyStatus(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="primary-button" disabled={pending || !title.trim()}>
|
||||
{pending ? t('board.creating') : t('board.createCta')}
|
||||
</button>
|
||||
|
||||
{createError && (
|
||||
<div className="inline-error">{t('board.createFailed', { error: createError })}</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
105
apps/kanban/src/routes/index.tsx
Normal file
105
apps/kanban/src/routes/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Route as RootRoute } from './__root'
|
||||
import { classifyHealth, prettyStatus, relativeTime } from '../lib/kanban'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: OverviewPage,
|
||||
})
|
||||
|
||||
function KpiCard(props: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<article className="kpi-card">
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
<small>{props.detail}</small>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewPage() {
|
||||
const { t } = useTranslation()
|
||||
const { cards, error } = RootRoute.useLoaderData()
|
||||
const todo = cards.filter((card) => card.status === 'todo').length
|
||||
const doing = cards.filter((card) => card.status === 'doing').length
|
||||
const done = cards.filter((card) => card.status === 'done').length
|
||||
const newest = cards.at(-1) ?? null
|
||||
const health = classifyHealth(cards)
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<div className="panel wide">
|
||||
<div className="section-heading">
|
||||
<h2>{t('overview.heading')}</h2>
|
||||
<p>{t('overview.sub')}</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="empty-state error-state">{t('overview.loadFailed', { error })}</div>
|
||||
) : (
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label={t('overview.kpi.boardHealth')}
|
||||
value={t(`health.${health}`)}
|
||||
detail={t('overview.kpi.boardHealthDetail')}
|
||||
/>
|
||||
<KpiCard
|
||||
label={t('overview.kpi.backlog')}
|
||||
value={String(todo)}
|
||||
detail={t('overview.kpi.backlogDetail')}
|
||||
/>
|
||||
<KpiCard
|
||||
label={t('overview.kpi.inProgress')}
|
||||
value={String(doing)}
|
||||
detail={t('overview.kpi.inProgressDetail')}
|
||||
/>
|
||||
<KpiCard
|
||||
label={t('overview.kpi.done')}
|
||||
value={String(done)}
|
||||
detail={t('overview.kpi.doneDetail')}
|
||||
/>
|
||||
<KpiCard
|
||||
label={t('overview.kpi.latest')}
|
||||
value={newest ? newest.title : t('overview.kpi.latestNone')}
|
||||
detail={
|
||||
newest
|
||||
? t('overview.kpi.latestDetail', {
|
||||
status: prettyStatus(newest.status),
|
||||
time: relativeTime(newest.createdAt),
|
||||
})
|
||||
: t('overview.kpi.latestEmptyDetail')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="section-heading">
|
||||
<h2>{t('overview.executionModel.heading')}</h2>
|
||||
<p>{t('overview.executionModel.sub')}</p>
|
||||
</div>
|
||||
<ul className="bullet-list">
|
||||
{(t('overview.executionModel.bullets', { returnObjects: true }) as string[]).map(
|
||||
(bullet) => (
|
||||
<li key={bullet}>{bullet}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="section-heading">
|
||||
<h2>{t('overview.nextScreens.heading')}</h2>
|
||||
<p>{t('overview.nextScreens.sub')}</p>
|
||||
</div>
|
||||
<ul className="bullet-list">
|
||||
{(t('overview.nextScreens.bullets', { returnObjects: true }) as string[]).map(
|
||||
(bullet) => (
|
||||
<li key={bullet}>{bullet}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user