docs: refine routing plan and dl components (#221)

This commit is contained in:
shenlan 2025-09-15 17:33:40 +08:00 committed by GitHub
parent b41f1ba8b6
commit 9181a65dce
7 changed files with 264 additions and 77 deletions

View 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 toplevel folders | `app/download/page.tsx` | `CardGrid` | `dl.svc.plus` toplevel JSON fetched at build time |
| `/download/<name>/[...path]` | File listing for any nested folder | `app/download/[name]/[[...path]]/page.tsx` | `FileTable` | perfolder 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 intrapage 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 stepbystep 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 Subsite**
- 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 Subsite**
- 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
View 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
View 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[];
}

View File

@ -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>

View File

@ -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>
))}

View File

@ -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>

View File

@ -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"
}
}