Todo
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { listPosts } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -12,6 +13,14 @@ export default async function AdminHomePage() {
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
||||
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/todo"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Open roadmap and progress
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
|
||||
346
apps/admin/src/app/todo/page.tsx
Normal file
346
apps/admin/src/app/todo/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import Link from "next/link"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type TodoState = "done" | "partial" | "planned"
|
||||
|
||||
type TodoTask = {
|
||||
state: TodoState
|
||||
text: string
|
||||
}
|
||||
|
||||
type TodoBlock = {
|
||||
title: string
|
||||
tasks: TodoTask[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
type TodoSection = {
|
||||
title: string
|
||||
blocks: TodoBlock[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
type ProgressCounts = {
|
||||
done: number
|
||||
partial: number
|
||||
planned: number
|
||||
}
|
||||
|
||||
const metaSections = new Set(["Status Legend"])
|
||||
|
||||
function isMetaSection(section: TodoSection | null): boolean {
|
||||
return section ? metaSections.has(section.title) : false
|
||||
}
|
||||
|
||||
function toTaskState(token: string): TodoState {
|
||||
const value = token.toLowerCase()
|
||||
|
||||
if (value === "x") {
|
||||
return "done"
|
||||
}
|
||||
|
||||
if (value === "~") {
|
||||
return "partial"
|
||||
}
|
||||
|
||||
return "planned"
|
||||
}
|
||||
|
||||
function parseTodo(content: string): TodoSection[] {
|
||||
const sections: TodoSection[] = []
|
||||
let currentSection: TodoSection | null = null
|
||||
let currentBlock: TodoBlock | null = null
|
||||
|
||||
const ensureSection = (title = "General"): TodoSection => {
|
||||
if (!currentSection) {
|
||||
currentSection = {
|
||||
title,
|
||||
blocks: [],
|
||||
notes: [],
|
||||
}
|
||||
sections.push(currentSection)
|
||||
}
|
||||
|
||||
return currentSection
|
||||
}
|
||||
|
||||
const ensureBlock = (title = "Overview"): TodoBlock => {
|
||||
const section = ensureSection()
|
||||
|
||||
if (!currentBlock) {
|
||||
currentBlock = {
|
||||
title,
|
||||
tasks: [],
|
||||
notes: [],
|
||||
}
|
||||
section.blocks.push(currentBlock)
|
||||
}
|
||||
|
||||
return currentBlock
|
||||
}
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("# ")) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("## ")) {
|
||||
currentSection = {
|
||||
title: line.replace("## ", "").trim(),
|
||||
blocks: [],
|
||||
notes: [],
|
||||
}
|
||||
sections.push(currentSection)
|
||||
currentBlock = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("### ")) {
|
||||
const section = ensureSection()
|
||||
|
||||
currentBlock = {
|
||||
title: line.replace("### ", "").trim(),
|
||||
tasks: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
section.blocks.push(currentBlock)
|
||||
continue
|
||||
}
|
||||
|
||||
const taskMatch = line.match(/^- \[([ x~X])\] (.+)$/)
|
||||
|
||||
if (taskMatch) {
|
||||
if (isMetaSection(currentSection)) {
|
||||
ensureSection().notes.push(taskMatch[0].replace("- ", ""))
|
||||
continue
|
||||
}
|
||||
|
||||
ensureBlock().tasks.push({
|
||||
state: toTaskState(taskMatch[1]),
|
||||
text: taskMatch[2].trim(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(/^- (.+)$/)
|
||||
|
||||
if (bulletMatch) {
|
||||
if (currentBlock) {
|
||||
currentBlock.notes.push(bulletMatch[1].trim())
|
||||
} else {
|
||||
ensureSection().notes.push(bulletMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
function getProgressCounts(sections: TodoSection[]): ProgressCounts {
|
||||
const counts: ProgressCounts = {
|
||||
done: 0,
|
||||
partial: 0,
|
||||
planned: 0,
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
if (isMetaSection(section)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const block of section.blocks) {
|
||||
for (const task of block.tasks) {
|
||||
counts[task.state] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
function getProgressPercent(progress: ProgressCounts): number {
|
||||
const total = progress.done + progress.partial + progress.planned
|
||||
|
||||
if (total === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round(((progress.done + progress.partial * 0.5) / total) * 100)
|
||||
}
|
||||
|
||||
async function getTodoMarkdown(): Promise<string> {
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), "TODO.md"),
|
||||
path.resolve(process.cwd(), "../TODO.md"),
|
||||
path.resolve(process.cwd(), "../../TODO.md"),
|
||||
]
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
return await readFile(filePath, "utf8")
|
||||
} catch {
|
||||
// Try next candidate path.
|
||||
}
|
||||
}
|
||||
|
||||
return "# TODO file not found\n\nCreate `/TODO.md` in the repository root to track progress."
|
||||
}
|
||||
|
||||
function statusBadgeClass(state: TodoState): string {
|
||||
if (state === "done") {
|
||||
return "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
}
|
||||
|
||||
if (state === "partial") {
|
||||
return "bg-amber-50 text-amber-700 border-amber-200"
|
||||
}
|
||||
|
||||
return "bg-slate-50 text-slate-700 border-slate-200"
|
||||
}
|
||||
|
||||
function statusLabel(state: TodoState): string {
|
||||
if (state === "done") {
|
||||
return "Done"
|
||||
}
|
||||
|
||||
if (state === "partial") {
|
||||
return "Partial"
|
||||
}
|
||||
|
||||
return "Planned"
|
||||
}
|
||||
|
||||
export default async function AdminTodoPage() {
|
||||
const content = await getTodoMarkdown()
|
||||
const sections = parseTodo(content)
|
||||
const progress = getProgressCounts(sections)
|
||||
const completionPercent = getProgressPercent(progress)
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-12">
|
||||
<header className="space-y-4">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Roadmap and Progress</h1>
|
||||
<p className="text-neutral-600">
|
||||
Structured view from root `TODO.md` (single source of truth).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
|
||||
<p className="text-sm font-semibold text-neutral-900">{completionPercent}%</p>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full rounded-full bg-neutral-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-neutral-900"
|
||||
style={{ width: `${completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
<article className="rounded-xl border border-emerald-200 bg-emerald-50/50 p-4">
|
||||
<p className="text-sm text-emerald-700">Completed items</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-emerald-900">{progress.done}</p>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-amber-200 bg-amber-50/60 p-4">
|
||||
<p className="text-sm text-amber-700">Partially done items</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-amber-900">{progress.partial}</p>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-300 bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-700">Planned items</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-slate-900">{progress.planned}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5">
|
||||
{sections.map((section) => (
|
||||
<article key={section.title} className="rounded-xl border border-neutral-200 p-5">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-neutral-900">
|
||||
{section.title}
|
||||
</h2>
|
||||
|
||||
{section.notes.length > 0 ? (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-600">
|
||||
{section.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{section.blocks.map((block) => (
|
||||
<section
|
||||
key={`${section.title}-${block.title}`}
|
||||
className="rounded-lg bg-neutral-50 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{block.title}
|
||||
</h3>
|
||||
|
||||
<ul className="mt-3 space-y-2">
|
||||
{block.tasks.map((task) => (
|
||||
<li
|
||||
key={`${block.title}-${task.text}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2"
|
||||
>
|
||||
<p className="text-sm text-neutral-800">{task.text}</p>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusBadgeClass(task.state)}`}
|
||||
>
|
||||
{statusLabel(task.state)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{block.notes.length > 0 ? (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-600">
|
||||
{block.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<details className="rounded-xl border border-neutral-200 p-5">
|
||||
<summary className="cursor-pointer text-sm font-medium text-neutral-700">
|
||||
View raw markdown source (`TODO.md`)
|
||||
</summary>
|
||||
<pre className="mt-4 overflow-x-auto whitespace-pre-wrap rounded-lg bg-neutral-50 p-4 text-sm leading-6 text-neutral-800">
|
||||
{content}
|
||||
</pre>
|
||||
</details>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user