feat: implement download center navigation (#234)
This commit is contained in:
parent
20788fb6b4
commit
f80e8627dc
@ -1,50 +1,162 @@
|
||||
import CardGrid from '../../../../components/download/CardGrid'
|
||||
import Breadcrumbs, { Crumb } from '../../../../components/download/Breadcrumbs'
|
||||
import FileTable from '../../../../components/download/FileTable'
|
||||
import type { DirListing } from '../../../../types/download'
|
||||
import type { Crumb } from '../../../../components/download/Breadcrumbs'
|
||||
import { formatDate } from '../../../../lib/format'
|
||||
import {
|
||||
buildSectionsForListing,
|
||||
countFiles,
|
||||
findListing,
|
||||
formatSegmentLabel,
|
||||
} from '../../../../lib/download-data'
|
||||
import listings from '../../../../public/dl-index/all.json'
|
||||
import type { DirListing } from '../../../../types/download'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return (listings as DirListing[])
|
||||
.filter((l) => l.path)
|
||||
.map((l) => {
|
||||
const segs = l.path.split('/').filter(Boolean)
|
||||
return { name: segs[0], path: segs.slice(1) }
|
||||
const allListings = listings as DirListing[]
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allListings
|
||||
.filter((listing) => listing.path)
|
||||
.map((listing) => {
|
||||
const segments = listing.path.split('/').filter(Boolean)
|
||||
return { name: segments[0], path: segments.slice(1) }
|
||||
})
|
||||
}
|
||||
|
||||
export const dynamicParams = false
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
function buildBreadcrumb(segments: string[]): Crumb[] {
|
||||
const items: Crumb[] = [{ label: 'Download', href: '/download' }]
|
||||
segments.forEach((segment, index) => {
|
||||
items.push({
|
||||
label: formatSegmentLabel(segment),
|
||||
href: '/download/' + segments.slice(0, index + 1).join('/'),
|
||||
})
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
function getLatestModified(listing: DirListing): string | undefined {
|
||||
let latest: string | undefined
|
||||
for (const entry of listing.entries) {
|
||||
if (entry.lastModified && (!latest || new Date(entry.lastModified).getTime() > new Date(latest).getTime())) {
|
||||
latest = entry.lastModified
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export default function DownloadListing({
|
||||
params,
|
||||
}: {
|
||||
params: { name: string; path?: string[] }
|
||||
}) {
|
||||
const { name, path = [] } = params
|
||||
const segs = [name, ...path]
|
||||
const fullPath = segs.join('/') + '/'
|
||||
const dir = (listings as DirListing[]).find((l) => l.path === fullPath)
|
||||
if (!dir) {
|
||||
const segments = [name, ...path]
|
||||
const listing = findListing(allListings, segments)
|
||||
|
||||
if (!listing) {
|
||||
return (
|
||||
<main className="p-4">
|
||||
<div className="text-center text-red-500">Directory not found.</div>
|
||||
<main className="px-4 py-8 md:px-8">
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-dashed p-10 text-center text-sm text-red-500">
|
||||
Directory not found.
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const breadcrumb: Crumb[] = segs.map((seg, idx) => ({
|
||||
label: seg,
|
||||
href: '/download/' + segs.slice(0, idx + 1).join('/'),
|
||||
}))
|
||||
const breadcrumb = buildBreadcrumb(segments)
|
||||
const subdirectorySections = buildSectionsForListing(listing, allListings, segments)
|
||||
const fileEntries = listing.entries.filter((entry) => entry.type === 'file')
|
||||
const fileListing: DirListing = { path: listing.path, entries: fileEntries }
|
||||
|
||||
const totalFiles = countFiles(listing, allListings)
|
||||
const latestModified = getLatestModified(listing)
|
||||
const displayTitle = formatSegmentLabel(segments[segments.length - 1] ?? name)
|
||||
const relativePath = segments.join('/')
|
||||
const remotePath = `https://dl.svc.plus/${listing.path}`
|
||||
|
||||
return (
|
||||
<main className="p-4 max-w-6xl mx-auto">
|
||||
<FileTable listing={dir} breadcrumb={breadcrumb} />
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl shadow p-4">
|
||||
<h2 className="font-semibold mb-2">Meta</h2>
|
||||
<p className="text-sm">Path: {dir.path}</p>
|
||||
<p className="text-sm mt-2">from dl.svc.plus</p>
|
||||
<main className="px-4 py-8 md:px-8">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<Breadcrumbs items={breadcrumb} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-2xl border bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{displayTitle}</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Explore downloads and artifacts available under the {displayTitle} directory.
|
||||
</p>
|
||||
<dl className="mt-4 grid gap-4 text-xs sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-gray-500">Subdirectories</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{subdirectorySections.length.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Files</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-gray-900">{totalFiles.toLocaleString()}</dd>
|
||||
</div>
|
||||
{latestModified && (
|
||||
<div>
|
||||
<dt className="text-gray-500">Last updated</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-gray-900">{formatDate(latestModified)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{subdirectorySections.length > 0 && (
|
||||
<div className="rounded-2xl border bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Collections</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
{subdirectorySections.length.toLocaleString()} entr{subdirectorySections.length === 1 ? 'y' : 'ies'}
|
||||
</span>
|
||||
</div>
|
||||
<CardGrid sections={subdirectorySections} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileListing.entries.length > 0 && (
|
||||
<div className="rounded-2xl border bg-white p-4 shadow-sm">
|
||||
<FileTable listing={fileListing} breadcrumb={breadcrumb} showBreadcrumbs={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subdirectorySections.length === 0 && fileListing.entries.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed p-10 text-center text-sm text-gray-500">
|
||||
This directory does not contain downloadable artifacts yet.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-24">
|
||||
<div className="rounded-2xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Directory info</h2>
|
||||
<dl className="mt-4 space-y-3 text-xs text-gray-600">
|
||||
<div>
|
||||
<dt className="text-gray-500">Path</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-gray-900">/{relativePath}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Source</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
<a
|
||||
href={remotePath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 hover:underline"
|
||||
>
|
||||
{remotePath}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-4 text-xs text-gray-500">Data sourced from dl.svc.plus.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,34 +1,45 @@
|
||||
import DownloadBrowser from '../../components/download/DownloadBrowser'
|
||||
import { buildDownloadSections, countFiles, findListing } from '../../lib/download-data'
|
||||
import listings from '../../public/dl-index/all.json'
|
||||
import type { DirListing } from '../../types/download'
|
||||
|
||||
interface Section {
|
||||
key: string
|
||||
title: string
|
||||
href: string
|
||||
lastModified?: string
|
||||
count?: number
|
||||
root: string
|
||||
}
|
||||
|
||||
export default function DownloadHome() {
|
||||
const sectionsMap: Record<string, Section[]> = {}
|
||||
const root = (listings as DirListing[]).find((l) => l.path === '')
|
||||
if (root) {
|
||||
for (const entry of root.entries.filter((e) => e.type === 'dir')) {
|
||||
const child = (listings as DirListing[]).find((l) => l.path === `${entry.name}/`)
|
||||
const secs: Section[] = (child?.entries || [])
|
||||
.filter((e) => e.type === 'dir')
|
||||
.map((e) => ({
|
||||
key: `${entry.name}/${e.name}`,
|
||||
title: e.name,
|
||||
href: `/download/${entry.name}/${e.name}`,
|
||||
lastModified: e.lastModified,
|
||||
count: (e as any).count,
|
||||
root: entry.name,
|
||||
}))
|
||||
sectionsMap[entry.name] = secs
|
||||
}
|
||||
}
|
||||
return <DownloadBrowser sectionsMap={sectionsMap} />
|
||||
const allListings = listings as DirListing[]
|
||||
const sectionsMap = buildDownloadSections(allListings)
|
||||
const rootListing = findListing(allListings, [])
|
||||
const topLevelDirectories = rootListing?.entries.filter((entry) => entry.type === 'dir') ?? []
|
||||
|
||||
const totalCollections = Object.values(sectionsMap).reduce((total, sections) => total + sections.length, 0)
|
||||
const totalFiles = topLevelDirectories.reduce((total, entry) => {
|
||||
const listing = findListing(allListings, [entry.name])
|
||||
return total + (listing ? countFiles(listing, allListings) : 0)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<main className="px-4 py-8 md:px-8">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<section className="rounded-2xl border bg-gradient-to-br from-purple-50 to-white p-6 shadow-sm md:p-8">
|
||||
<h1 className="text-3xl font-bold">Download Center</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Browse offline packages, releases, and other curated resources hosted on dl.svc.plus.
|
||||
</p>
|
||||
<dl className="mt-6 grid gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-gray-500">Top-level categories</dt>
|
||||
<dd className="mt-1 text-xl font-semibold">{topLevelDirectories.length.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Resource collections</dt>
|
||||
<dd className="mt-1 text-xl font-semibold">{totalCollections.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Files tracked</dt>
|
||||
<dd className="mt-1 text-xl font-semibold">{totalFiles.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<DownloadBrowser sectionsMap={sectionsMap} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { formatDate } from '../../lib/format'
|
||||
import { formatSegmentLabel } from '../../lib/download-data'
|
||||
|
||||
interface Section {
|
||||
key: string
|
||||
@ -10,6 +11,7 @@ interface Section {
|
||||
href: string
|
||||
lastModified?: string
|
||||
count?: number
|
||||
root?: string
|
||||
}
|
||||
|
||||
export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
@ -18,22 +20,18 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return sections
|
||||
.filter((s) => s.title.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter((section) => section.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()
|
||||
: new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime(),
|
||||
)
|
||||
}, [sections, search, sort])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-white flex items-center mb-4 gap-2 border-b pb-2">
|
||||
<select
|
||||
className="border rounded p-2"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as any)}
|
||||
>
|
||||
<div className="sticky top-20 z-10 mb-4 flex items-center gap-2 border-b bg-white pb-2">
|
||||
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
|
||||
<option value="lastModified">Sort by Updated</option>
|
||||
<option value="title">Sort by Name</option>
|
||||
</select>
|
||||
@ -41,25 +39,30 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
<input
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border rounded p-2"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="rounded border p-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4">
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{filtered.map((section) => (
|
||||
<Link
|
||||
key={section.key}
|
||||
href={section.href}
|
||||
className="mb-4 block break-inside-avoid border rounded p-4 hover:shadow"
|
||||
className="mb-4 block break-inside-avoid rounded-2xl border bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div className="text-4xl font-bold mb-2">
|
||||
{section.title.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-sm font-semibold mb-2">{section.title}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{section.lastModified && <p>Updated: {formatDate(section.lastModified)}</p>}
|
||||
{section.count !== undefined && <p>Items: {section.count}</p>}
|
||||
<div className="flex flex-col gap-3">
|
||||
{section.root && (
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-600">
|
||||
{formatSegmentLabel(section.root)}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-4xl font-bold text-gray-900">{section.title.charAt(0).toUpperCase()}</div>
|
||||
<div className="text-base font-semibold text-gray-900">{section.title}</div>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
{section.lastModified && <p>Updated: {formatDate(section.lastModified)}</p>}
|
||||
{section.count !== undefined && <p>Items: {section.count.toLocaleString()}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@ -1,55 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { formatSegmentLabel, type DownloadSection } from '../../lib/download-data'
|
||||
import CardGrid from './CardGrid'
|
||||
|
||||
interface Section {
|
||||
key: string
|
||||
title: string
|
||||
href: string
|
||||
lastModified?: string
|
||||
count?: number
|
||||
root: string
|
||||
interface DownloadBrowserProps {
|
||||
sectionsMap: Record<string, DownloadSection[]>
|
||||
}
|
||||
|
||||
export default function DownloadBrowser({
|
||||
sectionsMap,
|
||||
}: {
|
||||
sectionsMap: Record<string, Section[]>
|
||||
}) {
|
||||
export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) {
|
||||
const roots = useMemo(() => Object.keys(sectionsMap), [sectionsMap])
|
||||
const [current, setCurrent] = useState<string>('all')
|
||||
|
||||
const roots = Object.keys(sectionsMap)
|
||||
const allSections = useMemo(() => Object.values(sectionsMap).flat(), [sectionsMap])
|
||||
const sections = current === 'all' ? allSections : sectionsMap[current] || []
|
||||
const totalsByRoot = useMemo(() => {
|
||||
const totals: Record<string, number> = {}
|
||||
for (const root of roots) {
|
||||
totals[root] = sectionsMap[root]?.length ?? 0
|
||||
}
|
||||
return totals
|
||||
}, [roots, sectionsMap])
|
||||
|
||||
const allSections = useMemo(
|
||||
() => roots.flatMap((root) => sectionsMap[root] ?? []),
|
||||
[roots, sectionsMap],
|
||||
)
|
||||
|
||||
const sections = current === 'all' ? allSections : sectionsMap[current] ?? []
|
||||
|
||||
const activeLabel = current === 'all' ? 'All downloads' : formatSegmentLabel(current)
|
||||
const description =
|
||||
current === 'all'
|
||||
? 'Browse the complete catalog of offline packages, releases, and artifacts.'
|
||||
: `Showing resources from the ${formatSegmentLabel(current)} collection.`
|
||||
|
||||
return (
|
||||
<main className="flex">
|
||||
<aside className="w-48 border-r p-4">
|
||||
<ul className="space-y-2">
|
||||
<li key="all">
|
||||
<button
|
||||
className={`text-left w-full ${current === 'all' ? 'font-semibold' : ''}`}
|
||||
onClick={() => setCurrent('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</li>
|
||||
{roots.map((r) => (
|
||||
<li key={r}>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<aside className="lg:w-64">
|
||||
<div className="sticky top-24 rounded-2xl border bg-white p-4 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Categories</h2>
|
||||
<ul className="mt-4 space-y-1 text-sm">
|
||||
<li key="all">
|
||||
<button
|
||||
className={`text-left w-full ${current === r ? 'font-semibold' : ''}`}
|
||||
onClick={() => setCurrent(r)}
|
||||
type="button"
|
||||
onClick={() => setCurrent('all')}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 transition-colors ${
|
||||
current === 'all' ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
<span>All resources</span>
|
||||
<span className="text-xs text-gray-500">{allSections.length.toLocaleString()}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{roots.map((root) => (
|
||||
<li key={root}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrent(root)}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 transition-colors ${
|
||||
current === root ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{formatSegmentLabel(root)}</span>
|
||||
<span className="text-xs text-gray-500">{(totalsByRoot[root] ?? 0).toLocaleString()}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex-1 p-4">
|
||||
<CardGrid sections={sections} />
|
||||
</div>
|
||||
</main>
|
||||
<section className="flex-1 space-y-6">
|
||||
<div className="rounded-2xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{activeLabel}</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-purple-50 px-3 py-1 text-xs font-medium text-purple-600">
|
||||
{sections.length.toLocaleString()} item{sections.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{sections.length > 0 ? (
|
||||
<CardGrid sections={sections} />
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-10 text-center text-sm text-gray-500">
|
||||
No downloadable resources found for this category yet.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,18 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import CopyButton from './CopyButton'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Breadcrumbs, { Crumb } from './Breadcrumbs'
|
||||
import CopyButton from './CopyButton'
|
||||
import { formatBytes, formatDate } from '../../lib/format'
|
||||
import type { DirListing } from '../../types/download'
|
||||
|
||||
export default function FileTable({ listing, breadcrumb }: { listing: DirListing; breadcrumb: Crumb[] }) {
|
||||
interface FileTableProps {
|
||||
listing: DirListing
|
||||
breadcrumb: Crumb[]
|
||||
showBreadcrumbs?: boolean
|
||||
}
|
||||
|
||||
export default function FileTable({ listing, breadcrumb, showBreadcrumbs = true }: FileTableProps) {
|
||||
const [sort, setSort] = useState<'name' | 'lastModified' | 'size'>('name')
|
||||
const [ext, setExt] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return listing.entries
|
||||
.filter((i) => !ext || i.name.endsWith(ext))
|
||||
.filter((item) => !ext || item.name.toLowerCase().endsWith(ext.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'lastModified':
|
||||
@ -27,27 +33,27 @@ export default function FileTable({ listing, breadcrumb }: { listing: DirListing
|
||||
|
||||
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)}>
|
||||
{showBreadcrumbs && <Breadcrumbs items={breadcrumb} />}
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
|
||||
<option value="name">Name</option>
|
||||
<option value="lastModified">Updated</option>
|
||||
<option value="size">Size</option>
|
||||
</select>
|
||||
<input
|
||||
className="border rounded p-2"
|
||||
className="rounded border p-2"
|
||||
placeholder="Filter ext (.tar.gz)"
|
||||
value={ext}
|
||||
onChange={(e) => setExt(e.target.value)}
|
||||
onChange={(event) => setExt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2">Name</th>
|
||||
<th className="text-left py-2 w-24">Size</th>
|
||||
<th className="text-left py-2 w-48">Updated</th>
|
||||
<th className="text-left py-2 w-32">Actions</th>
|
||||
<th className="py-2 text-left">Name</th>
|
||||
<th className="w-24 py-2 text-left">Size</th>
|
||||
<th className="w-48 py-2 text-left">Updated</th>
|
||||
<th className="w-40 py-2 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -59,13 +65,15 @@ export default function FileTable({ listing, breadcrumb }: { listing: DirListing
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-1">{formatBytes(item.size || 0)}</td>
|
||||
<td className="py-1">{formatDate(item.lastModified || '')}</td>
|
||||
<td className="py-1 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 -`} />
|
||||
)}
|
||||
<td className="py-1">{item.lastModified ? formatDate(item.lastModified) : '--'}</td>
|
||||
<td className="py-1">
|
||||
<div className="flex flex-wrap 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 -`} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
119
ui/homepage/lib/download-data.ts
Normal file
119
ui/homepage/lib/download-data.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type { DirListing } from '../types/download'
|
||||
|
||||
export interface DownloadSection {
|
||||
key: string
|
||||
title: string
|
||||
href: string
|
||||
lastModified?: string
|
||||
count?: number
|
||||
root: string
|
||||
}
|
||||
|
||||
export function formatSegmentLabel(segment: string): string {
|
||||
return segment
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => (part.match(/^[a-z]+$/) ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(' ') || segment
|
||||
}
|
||||
|
||||
export function findListing(allListings: DirListing[], segments: string[]): DirListing | undefined {
|
||||
const normalized = segments.filter((segment) => segment.length > 0).join('/')
|
||||
const key = normalized ? `${normalized}/` : ''
|
||||
return allListings.find((listing) => listing.path === key)
|
||||
}
|
||||
|
||||
export function countFiles(listing: DirListing, allListings: DirListing[]): number {
|
||||
const baseSegments = listing.path.split('/').filter(Boolean)
|
||||
return listing.entries.reduce((total, entry) => {
|
||||
if (entry.type === 'file') {
|
||||
return total + 1
|
||||
}
|
||||
if (entry.type === 'dir') {
|
||||
const child = findListing(allListings, [...baseSegments, entry.name])
|
||||
if (child) {
|
||||
return total + countFiles(child, allListings)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function buildSectionsForListing(
|
||||
listing: DirListing,
|
||||
allListings: DirListing[],
|
||||
baseSegments: string[],
|
||||
): DownloadSection[] {
|
||||
return listing.entries
|
||||
.filter((entry) => entry.type === 'dir')
|
||||
.map((entry) => {
|
||||
const segments = [...baseSegments, entry.name]
|
||||
const childListing = findListing(allListings, segments)
|
||||
return {
|
||||
key: segments.join('/'),
|
||||
title: formatSegmentLabel(entry.name),
|
||||
href: `/download/${segments.join('/')}`,
|
||||
lastModified: entry.lastModified,
|
||||
count: childListing ? countFiles(childListing, allListings) : undefined,
|
||||
root: baseSegments[0] ?? entry.name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildDownloadSections(allListings: DirListing[]): Record<string, DownloadSection[]> {
|
||||
const rootListing = findListing(allListings, [])
|
||||
if (!rootListing) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const sectionsMap: Record<string, DownloadSection[]> = {}
|
||||
|
||||
for (const entry of rootListing.entries) {
|
||||
if (entry.type !== 'dir') continue
|
||||
const rootSegments = [entry.name]
|
||||
const listing = findListing(allListings, rootSegments)
|
||||
if (!listing) {
|
||||
sectionsMap[entry.name] = [
|
||||
{
|
||||
key: rootSegments.join('/'),
|
||||
title: formatSegmentLabel(entry.name),
|
||||
href: `/download/${rootSegments.join('/')}`,
|
||||
lastModified: entry.lastModified,
|
||||
root: entry.name,
|
||||
},
|
||||
]
|
||||
continue
|
||||
}
|
||||
|
||||
const childSections = buildSectionsForListing(listing, allListings, rootSegments)
|
||||
const hasFiles = listing.entries.some((item) => item.type === 'file')
|
||||
if (childSections.length > 0) {
|
||||
sectionsMap[entry.name] = hasFiles
|
||||
? [
|
||||
{
|
||||
key: rootSegments.join('/'),
|
||||
title: formatSegmentLabel(entry.name),
|
||||
href: `/download/${rootSegments.join('/')}`,
|
||||
lastModified: entry.lastModified,
|
||||
count: countFiles(listing, allListings),
|
||||
root: entry.name,
|
||||
},
|
||||
...childSections,
|
||||
]
|
||||
: childSections;
|
||||
} else {
|
||||
sectionsMap[entry.name] = [
|
||||
{
|
||||
key: rootSegments.join('/'),
|
||||
title: formatSegmentLabel(entry.name),
|
||||
href: `/download/${rootSegments.join('/')}`,
|
||||
lastModified: entry.lastModified,
|
||||
count: countFiles(listing, allListings),
|
||||
root: entry.name,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return sectionsMap
|
||||
}
|
||||
189
ui/homepage/public/dl-index/all.json
Normal file
189
ui/homepage/public/dl-index/all.json
Normal file
@ -0,0 +1,189 @@
|
||||
[
|
||||
{
|
||||
"path": "",
|
||||
"entries": [
|
||||
{
|
||||
"name": "offline-package",
|
||||
"href": "/offline-package/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-07-01T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "utilities",
|
||||
"href": "/utilities/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-06-15T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "docs",
|
||||
"href": "/docs/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-05-10T08:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "k3s",
|
||||
"href": "/offline-package/k3s/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-07-01T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "rke2",
|
||||
"href": "/offline-package/rke2/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-06-20T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "bundle-readme.md",
|
||||
"href": "/offline-package/bundle-readme.md",
|
||||
"type": "file",
|
||||
"size": 2048,
|
||||
"lastModified": "2024-05-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/k3s/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v1.28.0",
|
||||
"href": "/offline-package/k3s/v1.28.0/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-07-01T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "v1.27.0",
|
||||
"href": "/offline-package/k3s/v1.27.0/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-06-10T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "k3s-offline.tar.gz",
|
||||
"href": "/offline-package/k3s/k3s-offline.tar.gz",
|
||||
"type": "file",
|
||||
"size": 524288000,
|
||||
"lastModified": "2024-07-01T12:00:00Z",
|
||||
"sha256": "/offline-package/k3s/sha256sum.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/k3s/v1.28.0/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "k3s-linux-amd64.tar.gz",
|
||||
"href": "/offline-package/k3s/v1.28.0/k3s-linux-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 104857600,
|
||||
"lastModified": "2024-07-01T12:00:00Z",
|
||||
"sha256": "/offline-package/k3s/v1.28.0/sha256sum.txt"
|
||||
},
|
||||
{
|
||||
"name": "k3s-windows.zip",
|
||||
"href": "/offline-package/k3s/v1.28.0/k3s-windows.zip",
|
||||
"type": "file",
|
||||
"size": 209715200,
|
||||
"lastModified": "2024-07-01T12:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/k3s/v1.27.0/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "k3s-linux-amd64.tar.gz",
|
||||
"href": "/offline-package/k3s/v1.27.0/k3s-linux-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 104857600,
|
||||
"lastModified": "2024-06-10T12:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/rke2/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v1.27.0",
|
||||
"href": "/offline-package/rke2/v1.27.0/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-06-20T09:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/rke2/v1.27.0/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "rke2-linux-amd64.tar.gz",
|
||||
"href": "/offline-package/rke2/v1.27.0/rke2-linux-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 157286400,
|
||||
"lastModified": "2024-06-20T09:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "utilities/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "cluster-toolkit",
|
||||
"href": "/utilities/cluster-toolkit/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-05-30T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "diagnostic-bundle.zip",
|
||||
"href": "/utilities/diagnostic-bundle.zip",
|
||||
"type": "file",
|
||||
"size": 31457280,
|
||||
"lastModified": "2024-05-18T15:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "utilities/cluster-toolkit/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "README.md",
|
||||
"href": "/utilities/cluster-toolkit/README.md",
|
||||
"type": "file",
|
||||
"size": 4096,
|
||||
"lastModified": "2024-05-30T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "cluster-toolkit-linux.tar.gz",
|
||||
"href": "/utilities/cluster-toolkit/cluster-toolkit-linux.tar.gz",
|
||||
"type": "file",
|
||||
"size": 52428800,
|
||||
"lastModified": "2024-05-30T10:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "docs/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "k3s",
|
||||
"href": "/docs/k3s/",
|
||||
"type": "dir",
|
||||
"lastModified": "2024-06-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "docs/k3s/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "getting-started.md",
|
||||
"href": "/docs/k3s/getting-started.md",
|
||||
"type": "file",
|
||||
"size": 8192,
|
||||
"lastModified": "2024-06-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user