chore: kanban template
This commit is contained in:
90
apps/kanban/src/i18n/config.ts
Normal file
90
apps/kanban/src/i18n/config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import en from './en.json'
|
||||
|
||||
// Typed tokens: every `t('key')` is checked against en.json's shape (i18next
|
||||
// flattens it into dot-path keys), so a typo or removed key is a compile error.
|
||||
// Other locale files are loaded dynamically and are not key-checked against en;
|
||||
// a missing key falls back to the default locale at runtime.
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'translation'
|
||||
resources: {
|
||||
translation: typeof en
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a language: drop `i18n/<lang>.json` in this folder — Vite's
|
||||
// import.meta.glob auto-registers it below into `resources`, and
|
||||
// `supportedLocales` is derived from the files present, so no edits are needed
|
||||
// here. Content is reachable via the `/<lang>` URL prefix (e.g. `/de`, `/es`);
|
||||
// the default locale (`en`) needs no prefix.
|
||||
const localeModules = import.meta.glob('./*.json', { eager: true }) as Record<
|
||||
string,
|
||||
{ default: Record<string, unknown> }
|
||||
>
|
||||
|
||||
const resources: Record<string, { translation: Record<string, unknown> }> = {}
|
||||
for (const path in localeModules) {
|
||||
const code = path.slice(path.lastIndexOf('/') + 1).replace(/\.json$/, '')
|
||||
resources[code] = { translation: localeModules[path].default }
|
||||
}
|
||||
|
||||
export const supportedLocales = Object.keys(resources)
|
||||
export type Locale = string
|
||||
export const defaultLocale = 'en'
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||
// on <html dir> at the root so the browser mirrors the layout; see the
|
||||
// pikku-rtl skill. Returns 'ltr' while the app is English-only.
|
||||
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
export function detectLocale(pathname: string): Locale {
|
||||
const segment = pathname.split('/')[1]
|
||||
if (supportedLocales.includes(segment as Locale)) {
|
||||
return segment as Locale
|
||||
}
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const browserLang = navigator.language?.split('-')[0]
|
||||
if (supportedLocales.includes(browserLang as Locale)) {
|
||||
return browserLang as Locale
|
||||
}
|
||||
}
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
// i18n debug mode: when enabled, every *translated* string is masked to block
|
||||
// glyphs (█), so any readable text left on screen is text that never went
|
||||
// through a token — a hardcoded/inlined string. Surfaces missing i18n at a
|
||||
// glance. Toggle with `?i18n-debug` in the URL or `localStorage['i18n-debug']
|
||||
// = '1'` (or `I18N_DEBUG=1` for server-rendered builds). Off by default.
|
||||
export function isI18nDebug(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||
if (typeof window === 'undefined') return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||
}
|
||||
|
||||
const i18nDebugPostProcessor = {
|
||||
type: 'postProcessor' as const,
|
||||
name: 'i18nDebug',
|
||||
process: (value: string) => (isI18nDebug() ? value.replace(/\S/g, '█') : value),
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.use(i18nDebugPostProcessor)
|
||||
.init({
|
||||
resources,
|
||||
lng: typeof window !== 'undefined' ? detectLocale(window.location.pathname) : defaultLocale,
|
||||
fallbackLng: defaultLocale,
|
||||
interpolation: { escapeValue: false },
|
||||
postProcess: ['i18nDebug'],
|
||||
})
|
||||
|
||||
export default i18n
|
||||
123
apps/kanban/src/i18n/en.json
Normal file
123
apps/kanban/src/i18n/en.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"shell": {
|
||||
"eyebrow": "Pikku Fabric template",
|
||||
"title": "Kanban operations cockpit",
|
||||
"subtitle": "A lightweight board for work intake, automation visibility, and deployable app surfaces. Built to grow into a real product, not stay a demo.",
|
||||
"stats": {
|
||||
"boardHealth": "Board health",
|
||||
"cardsTracked": "Cards tracked",
|
||||
"runtime": "Runtime"
|
||||
},
|
||||
"runtimeConnected": "connected",
|
||||
"runtimeDegraded": "degraded",
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"board": "Board",
|
||||
"automations": "Automations"
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"idle": "idle",
|
||||
"backlogged": "backlogged",
|
||||
"flowing": "flowing"
|
||||
},
|
||||
"status": {
|
||||
"todo": "Backlog",
|
||||
"doing": "In progress",
|
||||
"done": "Done"
|
||||
},
|
||||
"relativeTime": {
|
||||
"seconds": "{{count}}s ago",
|
||||
"minutes": "{{count}}m ago",
|
||||
"hours": "{{count}}h ago",
|
||||
"days": "{{count}}d ago"
|
||||
},
|
||||
"overview": {
|
||||
"heading": "Overview",
|
||||
"sub": "Snapshot of current delivery flow and template wiring.",
|
||||
"loadFailed": "Failed to load cards: {{error}}",
|
||||
"loading": "Loading current board state…",
|
||||
"kpi": {
|
||||
"boardHealth": "Board health",
|
||||
"boardHealthDetail": "Live signal from current card distribution",
|
||||
"backlog": "Backlog",
|
||||
"backlogDetail": "Items waiting for pickup",
|
||||
"inProgress": "In progress",
|
||||
"inProgressDetail": "Cards currently moving",
|
||||
"done": "Done",
|
||||
"doneDetail": "Completed work ready to review",
|
||||
"latest": "Latest card",
|
||||
"latestNone": "None yet",
|
||||
"latestDetail": "{{status}} · {{time}}",
|
||||
"latestEmptyDetail": "Create the first card to get started"
|
||||
},
|
||||
"executionModel": {
|
||||
"heading": "Execution model",
|
||||
"sub": "What this template already includes out of the box.",
|
||||
"bullets": [
|
||||
"Board CRUD over Pikku RPC and HTTP wires.",
|
||||
"Workflow hooks for card onboarding and notifications.",
|
||||
"Queue processing for async card events.",
|
||||
"MCP and CLI entrypoints for ops-driven control."
|
||||
]
|
||||
},
|
||||
"nextScreens": {
|
||||
"heading": "Suggested next screens",
|
||||
"sub": "Natural expansion points once the app goes beyond the starter board.",
|
||||
"bullets": [
|
||||
"Card detail with history, notes, and workflow runs.",
|
||||
"Release view for branch deploys and environment promotions.",
|
||||
"Team inbox for triage, alerts, and assignment queues."
|
||||
]
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"heading": "Board",
|
||||
"sub": "Track work across the three canonical lanes.",
|
||||
"loadFailed": "Failed to load cards: {{error}}",
|
||||
"loading": "Loading board…",
|
||||
"columnEmpty": "No cards yet.",
|
||||
"createHeading": "Create card",
|
||||
"createSub": "Use the generated mutation, not ad hoc fetches.",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Audit workspace webhooks",
|
||||
"columnLabel": "Column",
|
||||
"creating": "Creating…",
|
||||
"createCta": "Create card",
|
||||
"createFailed": "Failed to create card: {{error}}"
|
||||
},
|
||||
"automations": {
|
||||
"heading": "Automations",
|
||||
"sub": "Operational surfaces already included in the template.",
|
||||
"rails": {
|
||||
"onboarding": {
|
||||
"name": "Card onboarding workflow",
|
||||
"detail": "Handles enrich → create → notify when new work enters the system."
|
||||
},
|
||||
"queue": {
|
||||
"name": "Process card event queue",
|
||||
"detail": "Queues side effects away from the request path for safer scaling."
|
||||
},
|
||||
"surface": {
|
||||
"name": "Kanban MCP + CLI surface",
|
||||
"detail": "Lets operators inspect or create board state from tools outside the UI."
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"active": "active",
|
||||
"idle": "idle",
|
||||
"warming": "warming",
|
||||
"standby": "standby",
|
||||
"ready": "ready"
|
||||
},
|
||||
"why": {
|
||||
"heading": "Why routing now",
|
||||
"sub": "TanStack Router gives this app room to grow past a demo board.",
|
||||
"bullets": [
|
||||
"Separate product surfaces without a one-file SPA.",
|
||||
"Clear paths for card details, releases, and diagnostics.",
|
||||
"Navigation semantics that match future expansion."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
422
apps/kanban/src/index.css
Normal file
422
apps/kanban/src/index.css
Normal file
@@ -0,0 +1,422 @@
|
||||
:root {
|
||||
--bg: #081018;
|
||||
--bg-accent: #101d2a;
|
||||
--surface: rgba(17, 27, 39, 0.9);
|
||||
--surface-strong: rgba(22, 35, 50, 0.96);
|
||||
--surface-soft: rgba(255, 255, 255, 0.04);
|
||||
--border: rgba(160, 196, 255, 0.16);
|
||||
--border-strong: rgba(160, 196, 255, 0.28);
|
||||
--text: #edf4ff;
|
||||
--muted: #8ba0bb;
|
||||
--primary: #82aaff;
|
||||
--secondary: #5eead4;
|
||||
--warning: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(94, 234, 212, 0.08), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(130, 170, 255, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 52%, #060b11 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 28px 56px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 10px;
|
||||
color: var(--secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.2rem, 5vw, 4.25rem);
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 56ch;
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-stat,
|
||||
.panel,
|
||||
.task-card,
|
||||
.automation-card,
|
||||
.kpi-card,
|
||||
.column {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero-stat span,
|
||||
.kpi-card span,
|
||||
.task-card-meta,
|
||||
.column-empty,
|
||||
.automation-card p,
|
||||
.section-heading p,
|
||||
.bullet-list,
|
||||
.field span,
|
||||
.inline-error,
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-stat strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hero-stat strong[data-tone='flowing'] {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.hero-stat strong[data-tone='backlogged'] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.hero-stat strong[data-tone='idle'] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 28px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(9, 15, 24, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
color: var(--muted);
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.is-active {
|
||||
color: var(--text);
|
||||
background: rgba(130, 170, 255, 0.12);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 22px;
|
||||
border-radius: 22px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.panel.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.kpi-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kpi-card small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bullet-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.column {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.column-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.column-header span {
|
||||
min-width: 28px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(130, 170, 255, 0.12);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.column-cards {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.task-card-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.column-empty,
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-state,
|
||||
.inline-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
padding: 13px 16px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--primary), #6ee7f9);
|
||||
color: #071018;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.automation-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.automation-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.automation-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.automation-topline h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.automation-topline span {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(130, 170, 255, 0.12);
|
||||
color: var(--primary);
|
||||
text-transform: capitalize;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.automation-topline span[data-state='active'] {
|
||||
background: rgba(94, 234, 212, 0.12);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.automation-topline span[data-state='warming'] {
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero,
|
||||
.page-grid,
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
46
apps/kanban/src/lib/api.ts
Normal file
46
apps/kanban/src/lib/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
|
||||
import { apiUrl } from './env'
|
||||
|
||||
// The Pikku-RPC shim. `rpc.invoke` is typed by the generated FlattenedRPCMap, so
|
||||
// `makeApi().invoke('listCards', input)` checks the name and payload at compile
|
||||
// time. Pikku emits unversioned latest-aliases (`listCards` → `listCards@v2`),
|
||||
// so you call clean names. Used from TanStack Start loaders (SSR + client) and
|
||||
// from event handlers for mutations.
|
||||
export function makeApi(): PikkuRPC {
|
||||
const rpc = new PikkuRPC()
|
||||
rpc.setServerUrl(apiUrl())
|
||||
return rpc
|
||||
}
|
||||
|
||||
export async function formatApiError(error: unknown): Promise<string> {
|
||||
if (error instanceof Response) {
|
||||
const status = [error.status, error.statusText].filter(Boolean).join(' ')
|
||||
|
||||
try {
|
||||
const payload = (await error.clone().json()) as {
|
||||
message?: unknown
|
||||
errorId?: unknown
|
||||
}
|
||||
if (typeof payload?.message === 'string') {
|
||||
const detail =
|
||||
typeof payload.errorId === 'string'
|
||||
? `${payload.message} (${payload.errorId})`
|
||||
: payload.message
|
||||
return status ? `${status}: ${detail}` : detail
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plain text/body-less formatting.
|
||||
}
|
||||
|
||||
try {
|
||||
const text = (await error.clone().text()).trim()
|
||||
if (text) return status ? `${status}: ${text}` : text
|
||||
} catch {
|
||||
// Ignore body-read failures and fall through.
|
||||
}
|
||||
|
||||
return status || 'Request failed'
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
17
apps/kanban/src/lib/env.ts
Normal file
17
apps/kanban/src/lib/env.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Endpoints come from env, never hardcoded. Throws on a missing var rather than
|
||||
// falling back to a localhost default, so a misconfigured deploy fails loudly.
|
||||
//
|
||||
// SSR (workerd inside the sandbox container) can't reach the external host port
|
||||
// that VITE_API_URL points to. VITE_SERVER_API_URL holds the internal Caddy
|
||||
// address (http://127.0.0.1:8080/api) and is set by generate-frontend-runtime.mjs
|
||||
// for local sandbox mode. In deployed/hosted contexts VITE_SERVER_API_URL is
|
||||
// unset and we fall back to VITE_API_URL, which is correct there.
|
||||
export function apiUrl(): string {
|
||||
const url = import.meta.env.SSR
|
||||
? (import.meta.env.VITE_SERVER_API_URL ?? import.meta.env.VITE_API_URL)
|
||||
: import.meta.env.VITE_API_URL
|
||||
if (!url) {
|
||||
throw new Error('VITE_API_URL is not set — point it at the Pikku API base URL')
|
||||
}
|
||||
return url
|
||||
}
|
||||
49
apps/kanban/src/lib/kanban.ts
Normal file
49
apps/kanban/src/lib/kanban.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import i18n from '../i18n/config'
|
||||
|
||||
export type CardStatus = 'todo' | 'doing' | 'done'
|
||||
|
||||
export interface Card {
|
||||
cardId: string
|
||||
title: string
|
||||
status: CardStatus | string
|
||||
position: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const STATUSES: CardStatus[] = ['todo', 'doing', 'done']
|
||||
|
||||
export function sortCards(cards: Card[]) {
|
||||
return [...cards].sort((a, b) => {
|
||||
if (a.status !== b.status) return a.status.localeCompare(b.status)
|
||||
return a.position - b.position
|
||||
})
|
||||
}
|
||||
|
||||
export function classifyHealth(cards: Card[]) {
|
||||
const todo = cards.filter((card) => card.status === 'todo').length
|
||||
const doing = cards.filter((card) => card.status === 'doing').length
|
||||
if (doing === 0) return 'idle'
|
||||
if (todo > doing * 2) return 'backlogged'
|
||||
return 'flowing'
|
||||
}
|
||||
|
||||
export function prettyStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return i18n.t('status.todo')
|
||||
case 'doing':
|
||||
return i18n.t('status.doing')
|
||||
case 'done':
|
||||
return i18n.t('status.done')
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function relativeTime(timestamp: string) {
|
||||
const delta = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
|
||||
if (delta < 60) return i18n.t('relativeTime.seconds', { count: delta })
|
||||
if (delta < 3600) return i18n.t('relativeTime.minutes', { count: Math.floor(delta / 60) })
|
||||
if (delta < 86400) return i18n.t('relativeTime.hours', { count: Math.floor(delta / 3600) })
|
||||
return i18n.t('relativeTime.days', { count: Math.floor(delta / 86400) })
|
||||
}
|
||||
104
apps/kanban/src/routeTree.gen.ts
Normal file
104
apps/kanban/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as BoardRouteImport } from './routes/board'
|
||||
import { Route as AutomationsRouteImport } from './routes/automations'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const BoardRoute = BoardRouteImport.update({
|
||||
id: '/board',
|
||||
path: '/board',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AutomationsRoute = AutomationsRouteImport.update({
|
||||
id: '/automations',
|
||||
path: '/automations',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/automations': typeof AutomationsRoute
|
||||
'/board': typeof BoardRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/automations': typeof AutomationsRoute
|
||||
'/board': typeof BoardRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/automations': typeof AutomationsRoute
|
||||
'/board': typeof BoardRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/automations' | '/board'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/automations' | '/board'
|
||||
id: '__root__' | '/' | '/automations' | '/board'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AutomationsRoute: typeof AutomationsRoute
|
||||
BoardRoute: typeof BoardRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/board': {
|
||||
id: '/board'
|
||||
path: '/board'
|
||||
fullPath: '/board'
|
||||
preLoaderRoute: typeof BoardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/automations': {
|
||||
id: '/automations'
|
||||
path: '/automations'
|
||||
fullPath: '/automations'
|
||||
preLoaderRoute: typeof AutomationsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AutomationsRoute: AutomationsRoute,
|
||||
BoardRoute: BoardRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
20
apps/kanban/src/router.tsx
Normal file
20
apps/kanban/src/router.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
// TanStack Start discovers this `getRouter` factory to build a router per
|
||||
// request (server) and once on the client (hydration). routeTree.gen.ts is
|
||||
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
|
||||
// hand-edit it.
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
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