diff --git a/ui/homepage/app/download/[name]/[[...path]]/page.tsx b/ui/homepage/app/download/[name]/[[...path]]/page.tsx index 95705cf..68c6d82 100644 --- a/ui/homepage/app/download/[name]/[[...path]]/page.tsx +++ b/ui/homepage/app/download/[name]/[[...path]]/page.tsx @@ -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 ( -
-
Directory not found.
+
+
+ Directory not found. +
) } - 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 ( -
- -
-
-

Meta

-

Path: {dir.path}

-

from dl.svc.plus

+
+
+ +
+
+
+

{displayTitle}

+

+ Explore downloads and artifacts available under the {displayTitle} directory. +

+
+
+
Subdirectories
+
+ {subdirectorySections.length.toLocaleString()} +
+
+
+
Files
+
{totalFiles.toLocaleString()}
+
+ {latestModified && ( +
+
Last updated
+
{formatDate(latestModified)}
+
+ )} +
+
+ + {subdirectorySections.length > 0 && ( +
+
+

Collections

+ + {subdirectorySections.length.toLocaleString()} entr{subdirectorySections.length === 1 ? 'y' : 'ies'} + +
+ +
+ )} + + {fileListing.entries.length > 0 && ( +
+ +
+ )} + + {subdirectorySections.length === 0 && fileListing.entries.length === 0 && ( +
+ This directory does not contain downloadable artifacts yet. +
+ )} +
+ +
diff --git a/ui/homepage/app/download/page.tsx b/ui/homepage/app/download/page.tsx index f34a915..ceed62e 100644 --- a/ui/homepage/app/download/page.tsx +++ b/ui/homepage/app/download/page.tsx @@ -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 = {} - 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 + 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 ( +
+
+
+

Download Center

+

+ Browse offline packages, releases, and other curated resources hosted on dl.svc.plus. +

+
+
+
Top-level categories
+
{topLevelDirectories.length.toLocaleString()}
+
+
+
Resource collections
+
{totalCollections.toLocaleString()}
+
+
+
Files tracked
+
{totalFiles.toLocaleString()}
+
+
+
+ +
+
+ ) } diff --git a/ui/homepage/components/download/CardGrid.tsx b/ui/homepage/components/download/CardGrid.tsx index ab06e55..8e5ce15 100644 --- a/ui/homepage/components/download/CardGrid.tsx +++ b/ui/homepage/components/download/CardGrid.tsx @@ -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 (
-
- setSort(event.target.value as any)}> @@ -41,25 +39,30 @@ export default function CardGrid({ sections }: { sections: Section[] }) { setSearch(e.target.value)} - className="border rounded p-2" + onChange={(event) => setSearch(event.target.value)} + className="rounded border p-2" />
-
+
{filtered.map((section) => ( -
- {section.title.charAt(0).toUpperCase()} -
-
{section.title}
-
- {section.lastModified &&

Updated: {formatDate(section.lastModified)}

} - {section.count !== undefined &&

Items: {section.count}

} +
+ {section.root && ( + + {formatSegmentLabel(section.root)} + + )} +
{section.title.charAt(0).toUpperCase()}
+
{section.title}
+
+ {section.lastModified &&

Updated: {formatDate(section.lastModified)}

} + {section.count !== undefined &&

Items: {section.count.toLocaleString()}

} +
))} diff --git a/ui/homepage/components/download/DownloadBrowser.tsx b/ui/homepage/components/download/DownloadBrowser.tsx index ef6d0e5..20e5b32 100644 --- a/ui/homepage/components/download/DownloadBrowser.tsx +++ b/ui/homepage/components/download/DownloadBrowser.tsx @@ -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 } -export default function DownloadBrowser({ - sectionsMap, -}: { - sectionsMap: Record -}) { +export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) { + const roots = useMemo(() => Object.keys(sectionsMap), [sectionsMap]) const [current, setCurrent] = useState('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 = {} + 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 ( -
-
-
- -
-
+
+
+
+
+

{activeLabel}

+

{description}

+
+ + {sections.length.toLocaleString()} item{sections.length === 1 ? '' : 's'} + +
+
+ {sections.length > 0 ? ( + + ) : ( +
+ No downloadable resources found for this category yet. +
+ )} +
+ ) } diff --git a/ui/homepage/components/download/FileTable.tsx b/ui/homepage/components/download/FileTable.tsx index 97f3ee0..29f35f9 100644 --- a/ui/homepage/components/download/FileTable.tsx +++ b/ui/homepage/components/download/FileTable.tsx @@ -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 (
- -
- setSort(event.target.value as any)}> setExt(e.target.value)} + onChange={(event) => setExt(event.target.value)} />
- - - - + + + + @@ -59,13 +65,15 @@ export default function FileTable({ listing, breadcrumb }: { listing: DirListing - - + ))} diff --git a/ui/homepage/lib/download-data.ts b/ui/homepage/lib/download-data.ts new file mode 100644 index 0000000..61ed17f --- /dev/null +++ b/ui/homepage/lib/download-data.ts @@ -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 { + const rootListing = findListing(allListings, []) + if (!rootListing) { + return {} + } + + const sectionsMap: Record = {} + + 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 +} diff --git a/ui/homepage/public/dl-index/all.json b/ui/homepage/public/dl-index/all.json new file mode 100644 index 0000000..ff330b9 --- /dev/null +++ b/ui/homepage/public/dl-index/all.json @@ -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" + } + ] + } +]
NameSizeUpdatedActionsNameSizeUpdatedActions
{formatBytes(item.size || 0)}{formatDate(item.lastModified || '')} - - - {item.sha256 && ( - - )} + {item.lastModified ? formatDate(item.lastModified) : '--'} +
+ + + {item.sha256 && ( + + )} +