docs: refine routing plan and dl components (#221)
This commit is contained in:
parent
b41f1ba8b6
commit
9181a65dce
103
docs/Frontend-Routing-design.md
Normal file
103
docs/Frontend-Routing-design.md
Normal file
@ -0,0 +1,103 @@
|
||||
# svc.plus Frontend Routing Design
|
||||
|
||||
This document outlines the routing plan and page skeleton for the **svc.plus** site
|
||||
implemented with **Next.js 14 App Router** and exported as static HTML (`output: 'export'`).
|
||||
The site uses **TypeScript** and **Tailwind CSS**. All pages are statically generated at
|
||||
build time; runtime servers are not required.
|
||||
|
||||
## Routes Overview
|
||||
|
||||
| Route | Description | Page File | Components | Data Source |
|
||||
|-------|-------------|-----------|------------|-------------|
|
||||
| `/` | Site home with entry cards to Downloads and Docs | `app/page.tsx` | custom `Card` components | static |
|
||||
| `/download/` | Downloads home displaying top‑level folders | `app/download/page.tsx` | `CardGrid` | `dl.svc.plus` top‑level JSON fetched at build time |
|
||||
| `/download/<name>/[...path]` | File listing for any nested folder | `app/download/[name]/[[...path]]/page.tsx` | `FileTable` | per‑folder JSON fetched at build time |
|
||||
| `/docs/` | Docs home listing available ebooks | `app/docs/page.tsx` | optional `DocCard` grid | local `content/` processed by Contentlayer |
|
||||
| `/docs/<name>` | Reading page for a single ebook | `app/docs/[name]/page.tsx` | reader layout with side TOC | `content/<name>/**` Markdown files |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
page.tsx # Home
|
||||
download/
|
||||
page.tsx # Downloads home
|
||||
[name]/
|
||||
[[...path]]/
|
||||
page.tsx # File listings for arbitrary depth
|
||||
docs/
|
||||
page.tsx # Docs home
|
||||
[name]/
|
||||
page.tsx # Ebook reader
|
||||
content/ # Markdown sources for docs
|
||||
ui/
|
||||
dl/
|
||||
components/
|
||||
CardGrid.tsx
|
||||
FileTable.tsx
|
||||
docs/
|
||||
components/ # (optional) shared doc components
|
||||
```
|
||||
|
||||
## Static Generation Requirements
|
||||
|
||||
### Downloads
|
||||
1. `scripts/fetch-dl-index.ts` recursively crawls `https://dl.svc.plus/` (overridable by
|
||||
`DL_BASE`) and writes `public/dl-index/top.json` and `public/dl-index/all.json`.
|
||||
`top.json` feeds the downloads home while `all.json` contains a `DirListing[]` with
|
||||
every directory and its `DirEntry` children. The script runs automatically via the
|
||||
`prebuild` npm script.
|
||||
2. `types/download.ts` defines shared types:
|
||||
```ts
|
||||
interface DirEntry { name; href; type: 'file'|'dir'; size?; lastModified?; sha256? }
|
||||
interface DirListing { path; entries: DirEntry[] }
|
||||
```
|
||||
3. Use `all.json` to enumerate every `/<name>/path/...` combination.
|
||||
4. Implement `generateStaticParams` in
|
||||
`app/download/[name]/[[...path]]/page.tsx` to return `{ name, path?: string[] }` for
|
||||
each known directory including the empty path.
|
||||
5. Implement `generateMetadata` to set titles and OpenGraph info for each folder.
|
||||
6. Render a `FileTable` with breadcrumb navigation and sorting by name/size/time.
|
||||
|
||||
### Docs
|
||||
1. Store Markdown under `content/<doc>/<chapter>.md`.
|
||||
2. Configure **Contentlayer** to parse metadata (title, cover, order, etc.).
|
||||
3. `app/docs/page.tsx` queries the Contentlayer output to list available docs.
|
||||
4. `app/docs/[name]/page.tsx` loads chapters for the given doc and renders a reader with
|
||||
a sidebar table of contents, progress indicator, and intra‑page anchors for navigation.
|
||||
5. Implement `generateStaticParams` for `/docs/[name]` based on `allDocs` from
|
||||
Contentlayer, and `generateMetadata` for SEO and OpenGraph.
|
||||
|
||||
## CodeX Automation Prompt
|
||||
|
||||
Use the following step‑by‑step tasks for automated implementation.
|
||||
|
||||
1. **Initialize Next.js project**
|
||||
- Create a Next.js 14 project in `ui/` with `output: 'export'` and Tailwind CSS.
|
||||
- Acceptance: running `npm run build` produces static `out/` directory.
|
||||
|
||||
2. **Home Page**
|
||||
- Implement `app/page.tsx` with two cards linking to `/download/` and `/docs/`.
|
||||
- Acceptance: cards render with Tailwind styling.
|
||||
|
||||
3. **Downloads Sub‑site**
|
||||
- Add `CardGrid` and `FileTable` components under `ui/dl/components/`.
|
||||
- Use `types/download.ts` and run `pnpm prebuild` to fetch directory JSON during
|
||||
build. `CardGrid` props: `sections: { key; title; href; lastModified?; count? }[]`.
|
||||
`FileTable` props: `listing: DirListing`, `breadcrumb: { label; href }[]`.
|
||||
- Implement `generateStaticParams` and `generateMetadata` for
|
||||
`app/download/[name]/[[...path]]/page.tsx`.
|
||||
- Acceptance: visiting any known folder in exported build shows correct listing.
|
||||
|
||||
4. **Docs Sub‑site**
|
||||
- Configure Contentlayer for `content/` Markdown.
|
||||
- Create `app/docs/page.tsx` that lists docs and `app/docs/[name]/page.tsx` that renders
|
||||
a reader with sidebar TOC and navigation anchors.
|
||||
- Acceptance: static export contains pages for all docs with working navigation.
|
||||
|
||||
5. **Build Validation**
|
||||
- Run `pnpm i && pnpm build && pnpm export` to ensure a full static export.
|
||||
- Acceptance: the `out/` directory contains working `/download/` and `/docs/` pages,
|
||||
e.g. `/download/offline-package/k3s/` renders a file table and `/docs/<name>`
|
||||
retains reading progress.
|
||||
|
||||
44
scripts/fetch-dl-index.ts
Normal file
44
scripts/fetch-dl-index.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { DirEntry, DirListing } from '../types/download'
|
||||
|
||||
const BASE = (process.env.DL_BASE || 'https://dl.svc.plus/').replace(/\/+$/, '/')
|
||||
|
||||
async function crawl(rel: string): Promise<DirListing[]> {
|
||||
const url = BASE + rel
|
||||
const res = await fetch(url + 'index.json')
|
||||
if (!res.ok) throw new Error(`failed to fetch ${url}: ${res.status}`)
|
||||
const entries = (await res.json()) as DirEntry[]
|
||||
const listing: DirListing = { path: rel, entries }
|
||||
const all: DirListing[] = [listing]
|
||||
for (const e of entries) {
|
||||
if (e.type === 'dir') {
|
||||
const childRel = rel + e.name + '/'
|
||||
const child = await crawl(childRel)
|
||||
all.push(...child)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const listings = await crawl('')
|
||||
const top = listings.find(l => l.path === '')
|
||||
const sections = top ? top.entries.filter(e => e.type === 'dir').map(e => ({
|
||||
key: e.name,
|
||||
title: e.name,
|
||||
href: '/' + e.name + '/',
|
||||
lastModified: e.lastModified,
|
||||
count: undefined
|
||||
})) : []
|
||||
|
||||
const outDir = path.join(process.cwd(), 'public', 'dl-index')
|
||||
await fs.mkdir(outDir, { recursive: true })
|
||||
await fs.writeFile(path.join(outDir, 'all.json'), JSON.stringify(listings, null, 2))
|
||||
await fs.writeFile(path.join(outDir, 'top.json'), JSON.stringify(sections, null, 2))
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
13
types/download.ts
Normal file
13
types/download.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
href: string;
|
||||
type: 'file' | 'dir';
|
||||
size?: number;
|
||||
lastModified?: string;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
path: string;
|
||||
entries: DirEntry[];
|
||||
}
|
||||
@ -1,15 +1,20 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Breadcrumbs({ segments }: { segments: string[] }) {
|
||||
const paths = segments.map((_, i) => '/' + segments.slice(0, i + 1).join('/') + '/')
|
||||
export interface Crumb {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav className="text-sm mb-4">
|
||||
<ol className="flex flex-wrap gap-1 items-center">
|
||||
<li><Link href="/" className="text-blue-600">dl.svc.plus</Link></li>
|
||||
{segments.map((seg, idx) => (
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center gap-1">
|
||||
<span>/</span>
|
||||
<Link href={paths[idx]} className="text-blue-600">{seg}</Link>
|
||||
{idx > 0 && <span>/</span>}
|
||||
<Link href={item.href} className="text-blue-600">
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@ -2,72 +2,63 @@
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/card'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
|
||||
import { Input } from './ui/input'
|
||||
import { Button } from './ui/button'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './ui/dropdown-menu'
|
||||
import { MoreVertical } from 'lucide-react'
|
||||
import { copyText } from '../utils/copy'
|
||||
import { formatDate } from '../utils/format'
|
||||
|
||||
interface Root {
|
||||
name: string
|
||||
interface Section {
|
||||
key: string
|
||||
title: string
|
||||
href: string
|
||||
updated_at: string
|
||||
item_count: number
|
||||
summary?: string
|
||||
lastModified?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export default function CardGrid({ roots }: { roots: Root[] }) {
|
||||
export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState<'updated_at' | 'name'>('updated_at')
|
||||
const [sort, setSort] = useState<'lastModified' | 'title'>('lastModified')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return roots
|
||||
.filter(r => r.name.toLowerCase().includes(search.toLowerCase()) || (r.summary ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a,b) => sort === 'name' ? a.name.localeCompare(b.name) : new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
}, [roots, search, sort])
|
||||
return sections
|
||||
.filter(s => s.title.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) =>
|
||||
sort === 'title'
|
||||
? a.title.localeCompare(b.title)
|
||||
: new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime()
|
||||
)
|
||||
}, [sections, search, sort])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-4 items-center">
|
||||
<Input placeholder="Search" value={search} onChange={e=>setSearch(e.target.value)} className="max-w-xs" />
|
||||
<select className="border rounded p-2" value={sort} onChange={e=>setSort(e.target.value as any)}>
|
||||
<option value="updated_at">Sort by Updated</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
className="border rounded p-2"
|
||||
value={sort}
|
||||
onChange={e => setSort(e.target.value as any)}
|
||||
>
|
||||
<option value="lastModified">Sort by Updated</option>
|
||||
<option value="title">Sort by Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map(root => (
|
||||
<Link key={root.name} href={root.href} className="block">
|
||||
<Card className="relative hover:shadow-lg transition-shadow">
|
||||
{filtered.map(section => (
|
||||
<Link key={section.key} href={section.href} className="block">
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold">{root.name[0].toUpperCase()}</span>
|
||||
{root.name}
|
||||
</CardTitle>
|
||||
{root.summary && <CardDescription>{root.summary}</CardDescription>}
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm text-gray-600">
|
||||
<p>Updated: {formatDate(root.updated_at)}</p>
|
||||
<p>Items: {root.item_count}</p>
|
||||
{section.lastModified && (
|
||||
<p>Updated: {formatDate(section.lastModified)}</p>
|
||||
)}
|
||||
{section.count !== undefined && <p>Items: {section.count}</p>}
|
||||
</CardContent>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={e=>e.preventDefault()}>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={e=>{e.preventDefault();copyText(`https://dl.svc.plus${root.href}`)}}>Copy Link</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={e=>{e.preventDefault();copyText(`wget -r --no-parent https://dl.svc.plus${root.href}`)}}>Copy wget -r</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={e=>e.preventDefault()}>
|
||||
<a href={`https://docs.svc.plus${root.href}`} target="_blank" rel="noopener noreferrer" className="ml-2">Docs</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@ -1,46 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './ui/table'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell
|
||||
} from './ui/table'
|
||||
import CopyButton from './CopyButton'
|
||||
import { formatBytes, formatDate } from '../utils/format'
|
||||
import { DirListing } from '../../../types/download'
|
||||
import Breadcrumbs, { Crumb } from './Breadcrumbs'
|
||||
|
||||
interface Item {
|
||||
name: string
|
||||
size: number
|
||||
updated_at: string
|
||||
href: string
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
export default function FileTable({ basePath, items }: { basePath: string, items: Item[] }) {
|
||||
const [sort, setSort] = useState<'name' | 'updated_at' | 'size'>('name')
|
||||
export default function FileTable({
|
||||
listing,
|
||||
breadcrumb
|
||||
}: {
|
||||
listing: DirListing
|
||||
breadcrumb: Crumb[]
|
||||
}) {
|
||||
const [sort, setSort] = useState<'name' | 'lastModified' | 'size'>('name')
|
||||
const [ext, setExt] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return items
|
||||
return listing.entries
|
||||
.filter(i => !ext || i.name.endsWith(ext))
|
||||
.sort((a,b) => {
|
||||
switch(sort) {
|
||||
case 'updated_at':
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'lastModified':
|
||||
return (
|
||||
new Date(b.lastModified || 0).getTime() -
|
||||
new Date(a.lastModified || 0).getTime()
|
||||
)
|
||||
case 'size':
|
||||
return b.size - a.size
|
||||
return (b.size || 0) - (a.size || 0)
|
||||
default:
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
}, [items, sort, ext])
|
||||
}, [listing.entries, sort, ext])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={breadcrumb} />
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select className="border rounded p-2" value={sort} onChange={e=>setSort(e.target.value as any)}>
|
||||
<select
|
||||
className="border rounded p-2"
|
||||
value={sort}
|
||||
onChange={e => setSort(e.target.value as any)}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="updated_at">Updated</option>
|
||||
<option value="lastModified">Updated</option>
|
||||
<option value="size">Size</option>
|
||||
</select>
|
||||
<input className="border rounded p-2" placeholder="Filter ext (.tar.gz)" value={ext} onChange={e=>setExt(e.target.value)} />
|
||||
<input
|
||||
className="border rounded p-2"
|
||||
placeholder="Filter ext (.tar.gz)"
|
||||
value={ext}
|
||||
onChange={e => setExt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -55,15 +75,24 @@ export default function FileTable({ basePath, items }: { basePath: string, items
|
||||
{filtered.map(item => (
|
||||
<TableRow key={item.name}>
|
||||
<TableCell>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer" className="text-blue-600">{item.name}</a>
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{formatBytes(item.size)}</TableCell>
|
||||
<TableCell>{formatDate(item.updated_at)}</TableCell>
|
||||
<TableCell>{formatBytes(item.size || 0)}</TableCell>
|
||||
<TableCell>{formatDate(item.lastModified || '')}</TableCell>
|
||||
<TableCell className="flex gap-2">
|
||||
<CopyButton text={`https://dl.svc.plus${item.href}`} />
|
||||
<CopyButton text={`wget -c https://dl.svc.plus${item.href}`} />
|
||||
{item.sha256 && (
|
||||
<CopyButton text={`wget -O - https://dl.svc.plus${item.sha256} | grep ${item.name} | sha256sum -c -`} />
|
||||
<CopyButton
|
||||
text={`wget -O - https://dl.svc.plus${item.sha256} | grep ${item.name} | sha256sum -c -`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "ts-node ../../scripts/fetch-dl-index.ts",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@ -28,6 +29,7 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user