Import rpm.lair.cafe SPA from lair/mistralrs-package/ui @34a28b5
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
Dedicated repo for the RPM repository web UI, moved out of the mistralrs-package repo now that multiple package repos publish to rpm.lair.cafe. Includes an adapted deploy workflow (SPA at repo root).
This commit is contained in:
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: fedora-43
|
||||||
|
env:
|
||||||
|
RPM_REPO_HOST: oolon.kosherinata.internal
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Set up SSH
|
||||||
|
run: |
|
||||||
|
install --directory --mode 700 ~/.ssh
|
||||||
|
echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519
|
||||||
|
env:
|
||||||
|
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
|
||||||
|
|
||||||
|
- name: Test SSH connectivity
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit
|
||||||
|
|
||||||
|
- name: Deploy to web root
|
||||||
|
run: |
|
||||||
|
# Only the built SPA files are synced; --exclude="*" protects the
|
||||||
|
# co-located RPM trees (fedora/**, *.gpg, packages.json) from
|
||||||
|
# --delete (excluded files are not deletion candidates).
|
||||||
|
rsync \
|
||||||
|
--recursive \
|
||||||
|
--links \
|
||||||
|
--verbose \
|
||||||
|
--delete \
|
||||||
|
--chmod D755,F644 \
|
||||||
|
--include="index.html" \
|
||||||
|
--include="*.repo" \
|
||||||
|
--include="assets/***" \
|
||||||
|
--exclude="*" \
|
||||||
|
dist/ \
|
||||||
|
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/"
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
32
README.md
Normal file
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# rpm.lair.cafe
|
||||||
|
|
||||||
|
Web UI for the [`rpm.lair.cafe`](https://rpm.lair.cafe) dnf repository — a small
|
||||||
|
React + Vite SPA that lists the packages published across the Fedora trees and
|
||||||
|
links to their RPMs and changelogs.
|
||||||
|
|
||||||
|
It reads the per-tree `packages.json` manifests generated by the package repos
|
||||||
|
(e.g. `lair/mistralrs-package`, `lair/claude-desktop-package`) when they publish.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` builds the SPA and rsyncs `dist/` to
|
||||||
|
`gitea_ci@oolon.kosherinata.internal:/var/www/rpm/` on every push to `main`.
|
||||||
|
The rsync filter only touches `index.html`, `*.repo`, and `assets/**`, so the
|
||||||
|
co-located RPM trees (`fedora/**`, GPG keys, `packages.json`) are left untouched.
|
||||||
|
Requires the `RSYNC_SSH_KEY` Actions secret.
|
||||||
|
|
||||||
|
## Adding a Fedora release
|
||||||
|
|
||||||
|
The SPA fetches `packages.json` from each Fedora tree. To surface a new
|
||||||
|
releasever, add it to `RELEASEVERS` in `src/hooks/usePackages.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
History note: this SPA previously lived in `lair/mistralrs-package/ui/`;
|
||||||
|
imported here from that repo at commit `34a28b5`.
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>rpm.lair.cafe</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1733
package-lock.json
generated
Normal file
1733
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router": "^7.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
public/lair-cafe-unstable.repo
Normal file
6
public/lair-cafe-unstable.repo
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[lair-cafe-unstable]
|
||||||
|
name=lair.cafe RPM Repository (unstable)
|
||||||
|
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/unstable/
|
||||||
|
enabled=0
|
||||||
|
gpgcheck=1
|
||||||
|
gpgkey=https://rpm.lair.cafe/8b2023ce.gpg
|
||||||
6
public/lair-cafe.repo
Normal file
6
public/lair-cafe.repo
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[lair-cafe]
|
||||||
|
name=lair.cafe RPM Repository
|
||||||
|
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/
|
||||||
|
enabled=1
|
||||||
|
gpgcheck=1
|
||||||
|
gpgkey=https://rpm.lair.cafe/8b2023ce.gpg
|
||||||
25
src/App.tsx
Normal file
25
src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||||
|
import { ThemeProvider } from "./theme/ThemeContext.tsx";
|
||||||
|
import { Layout } from "./components/Layout.tsx";
|
||||||
|
import { Home } from "./pages/Home.tsx";
|
||||||
|
import { PackageList } from "./pages/PackageList.tsx";
|
||||||
|
import { PackageDetail } from "./pages/PackageDetail.tsx";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
element: <Layout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Home /> },
|
||||||
|
{ path: "packages", element: <PackageList /> },
|
||||||
|
{ path: "packages/:name", element: <PackageDetail /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/CodeBlock.tsx
Normal file
35
src/components/CodeBlock.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
children: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ children, language }: CodeBlockProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(children.trim());
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="position-relative">
|
||||||
|
<pre className="bg-body-tertiary rounded p-3 overflow-auto">
|
||||||
|
<code className={language ? `language-${language}` : undefined}>
|
||||||
|
{children.trim()}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
className="position-absolute top-0 end-0 m-2"
|
||||||
|
onClick={copy}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/Layout.tsx
Normal file
14
src/components/Layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Container } from "react-bootstrap";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
import { NavHeader } from "./NavHeader.tsx";
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavHeader />
|
||||||
|
<Container as="main" className="pb-5">
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/NavHeader.tsx
Normal file
37
src/components/NavHeader.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Container, Nav, Navbar } from "react-bootstrap";
|
||||||
|
import { Link, useLocation } from "react-router";
|
||||||
|
import { ThemeToggle } from "../theme/ThemeToggle.tsx";
|
||||||
|
|
||||||
|
export function NavHeader() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar expand="sm" className="border-bottom mb-4">
|
||||||
|
<Container>
|
||||||
|
<Navbar.Brand as={Link} to="/">
|
||||||
|
rpm.lair.cafe
|
||||||
|
</Navbar.Brand>
|
||||||
|
<Navbar.Toggle />
|
||||||
|
<Navbar.Collapse>
|
||||||
|
<Nav className="me-auto">
|
||||||
|
<Nav.Link
|
||||||
|
as={Link}
|
||||||
|
to="/"
|
||||||
|
active={location.pathname === "/"}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Nav.Link>
|
||||||
|
<Nav.Link
|
||||||
|
as={Link}
|
||||||
|
to="/packages"
|
||||||
|
active={location.pathname.startsWith("/packages")}
|
||||||
|
>
|
||||||
|
Packages
|
||||||
|
</Nav.Link>
|
||||||
|
</Nav>
|
||||||
|
<ThemeToggle />
|
||||||
|
</Navbar.Collapse>
|
||||||
|
</Container>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/hooks/usePackages.ts
Normal file
81
src/hooks/usePackages.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Channel, PackagesManifest, PackageVersion } from "../types/packages.ts";
|
||||||
|
|
||||||
|
// Fedora releasevers served at rpm.lair.cafe. Add new versions here as trees
|
||||||
|
// come online; missing trees (e.g. a releasever with no unstable repo) 404 and
|
||||||
|
// are skipped gracefully.
|
||||||
|
const RELEASEVERS = ["43", "44"];
|
||||||
|
|
||||||
|
interface ManifestSource {
|
||||||
|
url: string;
|
||||||
|
channel: Channel;
|
||||||
|
releasever: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestSources(): ManifestSource[] {
|
||||||
|
return RELEASEVERS.flatMap((releasever) => [
|
||||||
|
{
|
||||||
|
url: `/fedora/${releasever}/x86_64/packages.json`,
|
||||||
|
channel: "stable" as const,
|
||||||
|
releasever,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `/fedora/${releasever}/x86_64/unstable/packages.json`,
|
||||||
|
channel: "unstable" as const,
|
||||||
|
releasever,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagPackages(
|
||||||
|
manifest: PackagesManifest,
|
||||||
|
channel: Channel,
|
||||||
|
releasever: string,
|
||||||
|
): PackageVersion[] {
|
||||||
|
return manifest.packages.map((p) => ({
|
||||||
|
...p,
|
||||||
|
channel,
|
||||||
|
releasever,
|
||||||
|
baseUrl: manifest.baseUrl,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePackages() {
|
||||||
|
const [packages, setPackages] = useState<PackageVersion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchManifest = async (
|
||||||
|
source: ManifestSource,
|
||||||
|
): Promise<PackageVersion[]> => {
|
||||||
|
const res = await fetch(source.url);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return [];
|
||||||
|
throw new Error(`HTTP ${res.status} fetching ${source.url}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as PackagesManifest;
|
||||||
|
return tagPackages(data, source.channel, source.releasever);
|
||||||
|
};
|
||||||
|
|
||||||
|
Promise.all(manifestSources().map(fetchManifest))
|
||||||
|
.then((lists) => {
|
||||||
|
if (!cancelled) setPackages(lists.flat());
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { packages, loading, error };
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
91
src/pages/Home.tsx
Normal file
91
src/pages/Home.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Card, Col, Row } from "react-bootstrap";
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.tsx";
|
||||||
|
|
||||||
|
const GPG_KEY_URL = "https://rpm.lair.cafe/8b2023ce.gpg";
|
||||||
|
const REPO_URL = "https://rpm.lair.cafe/lair-cafe.repo";
|
||||||
|
const UNSTABLE_REPO_URL = "https://rpm.lair.cafe/lair-cafe-unstable.repo";
|
||||||
|
|
||||||
|
export function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-3">rpm.lair.cafe</h1>
|
||||||
|
<p className="lead mb-4">
|
||||||
|
Self-hosted RPM repository for Fedora, currently hosting
|
||||||
|
CUDA-accelerated builds of{" "}
|
||||||
|
<a href="https://github.com/EricLBuehler/mistral.rs">mistral.rs</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Row className="g-4">
|
||||||
|
<Col lg={12}>
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title>Quick start</Card.Title>
|
||||||
|
|
||||||
|
<h6 className="mt-4">1. Import the signing key</h6>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`sudo rpm --import ${GPG_KEY_URL}`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<h6 className="mt-4">2. Add the repository</h6>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`sudo dnf config-manager addrepo --from-repofile=${REPO_URL}`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<h6 className="mt-4">3. Install a package</h6>
|
||||||
|
<p className="text-body-secondary">
|
||||||
|
Choose the package matching your GPU generation:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`# RTX 3000 series (Ampere)\nsudo dnf install mistralrs-ampere\n\n# RTX 4000 series (Ada Lovelace)\nsudo dnf install mistralrs-ada\n\n# RTX 5000 series (Blackwell)\nsudo dnf install mistralrs-blackwell`}
|
||||||
|
</CodeBlock>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col lg={12}>
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title>Unstable (prerelease) packages</Card.Title>
|
||||||
|
<p>
|
||||||
|
Unstable packages are built automatically from the latest
|
||||||
|
upstream <code>main</code> branch commit. They use the
|
||||||
|
next release version from <code>Cargo.toml</code> with a
|
||||||
|
prerelease suffix (e.g.{" "}
|
||||||
|
<code>0.8.1-0.1.20260511git1a2b3c4</code>). When the
|
||||||
|
upstream version is officially released, the stable package
|
||||||
|
will automatically supersede any installed prerelease.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 className="mt-4">Add the unstable repository</h6>
|
||||||
|
<p className="text-body-secondary">
|
||||||
|
The unstable repo is disabled by default. Add it alongside the
|
||||||
|
stable repo:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`sudo dnf config-manager addrepo --from-repofile=${UNSTABLE_REPO_URL}`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<h6 className="mt-4">
|
||||||
|
Install or update from unstable
|
||||||
|
</h6>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`sudo dnf --enablerepo=lair-cafe-unstable install mistralrs-ada`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<h6 className="mt-4">
|
||||||
|
Pin to stable
|
||||||
|
</h6>
|
||||||
|
<p className="text-body-secondary">
|
||||||
|
If you have the unstable repo enabled and want to stay on
|
||||||
|
stable releases, exclude prerelease versions:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`sudo dnf --disablerepo=lair-cafe-unstable update mistralrs-ada`}
|
||||||
|
</CodeBlock>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/pages/PackageDetail.tsx
Normal file
119
src/pages/PackageDetail.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Accordion, Alert, Badge, Spinner, Table } from "react-bootstrap";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { usePackages } from "../hooks/usePackages.ts";
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.tsx";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PackageDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const { packages, loading, error } = usePackages();
|
||||||
|
|
||||||
|
if (loading) return <Spinner animation="border" />;
|
||||||
|
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
|
||||||
|
if (packages.length === 0) return <Alert variant="info">No package data available.</Alert>;
|
||||||
|
|
||||||
|
const versions = packages
|
||||||
|
.filter((p) => p.name === name)
|
||||||
|
.sort((a, b) => b.buildTime - a.buildTime);
|
||||||
|
|
||||||
|
if (versions.length === 0)
|
||||||
|
return <Alert variant="warning">Package not found: {name}</Alert>;
|
||||||
|
|
||||||
|
const latest = versions[0];
|
||||||
|
const hasUnstable = versions.some((v) => v.channel === "unstable");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-1">{name}</h1>
|
||||||
|
<p className="text-body-secondary mb-4">{latest.summary}</p>
|
||||||
|
|
||||||
|
<CodeBlock language="bash">{`sudo dnf install ${name}`}</CodeBlock>
|
||||||
|
|
||||||
|
{hasUnstable && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<CodeBlock language="bash">
|
||||||
|
{`# install latest unstable version\nsudo dnf --enablerepo=lair-cafe-unstable install ${name}`}
|
||||||
|
</CodeBlock>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="mt-4 mb-3">
|
||||||
|
Versions <Badge bg="secondary">{versions.length}</Badge>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Table striped hover responsive>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Fedora</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Built</th>
|
||||||
|
<th>Download</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{versions.map((pkg) => (
|
||||||
|
<tr key={`${pkg.version}-${pkg.release}-${pkg.channel}`}>
|
||||||
|
<td>
|
||||||
|
{pkg.version}-{pkg.release}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge bg="secondary">fc{pkg.releasever}</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge bg={pkg.channel === "stable" ? "success" : "warning"}>
|
||||||
|
{pkg.channel}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>{formatBytes(pkg.size)}</td>
|
||||||
|
<td>{new Date(pkg.buildTime * 1000).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
<a href={`${pkg.baseUrl}/${pkg.rpmFilename}`}>
|
||||||
|
{pkg.rpmFilename}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{versions.some((v) => v.changelog.length > 0) && (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-4 mb-3">Changelog</h2>
|
||||||
|
<Accordion>
|
||||||
|
{versions
|
||||||
|
.filter((v) => v.changelog.length > 0)
|
||||||
|
.map((pkg) => (
|
||||||
|
<Accordion.Item
|
||||||
|
key={`${pkg.version}-${pkg.release}`}
|
||||||
|
eventKey={`${pkg.version}-${pkg.release}`}
|
||||||
|
>
|
||||||
|
<Accordion.Header>
|
||||||
|
{pkg.version}-{pkg.release} —{" "}
|
||||||
|
{new Date(pkg.buildTime * 1000).toLocaleDateString()}
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Body>
|
||||||
|
{pkg.changelog.map((entry, i) => (
|
||||||
|
<div key={i} className="mb-3">
|
||||||
|
<small className="text-body-secondary">
|
||||||
|
{new Date(entry.date * 1000).toLocaleDateString()}{" "}
|
||||||
|
— {entry.author}
|
||||||
|
</small>
|
||||||
|
<pre className="mb-0 mt-1">{entry.text}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Accordion.Body>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/pages/PackageList.tsx
Normal file
83
src/pages/PackageList.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Alert, Badge, Spinner, Table } from "react-bootstrap";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { usePackages } from "../hooks/usePackages.ts";
|
||||||
|
|
||||||
|
export function PackageList() {
|
||||||
|
const { packages, loading, error } = usePackages();
|
||||||
|
|
||||||
|
if (loading) return <Spinner animation="border" />;
|
||||||
|
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
|
||||||
|
if (packages.length === 0)
|
||||||
|
return <Alert variant="info">No packages published yet.</Alert>;
|
||||||
|
|
||||||
|
const byName = Map.groupBy(packages, (p) => p.name);
|
||||||
|
const summaries = [...byName.entries()].map(([name, versions]) => {
|
||||||
|
const stable = versions.filter((v) => v.channel === "stable");
|
||||||
|
const unstable = versions.filter((v) => v.channel === "unstable");
|
||||||
|
const releasevers = [...new Set(versions.map((v) => v.releasever))].sort();
|
||||||
|
const latest = versions.reduce((a, b) =>
|
||||||
|
a.buildTime >= b.buildTime ? a : b,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
latest,
|
||||||
|
releasevers,
|
||||||
|
stableCount: stable.length,
|
||||||
|
unstableCount: unstable.length,
|
||||||
|
versionCount: versions.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
summaries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-3">Packages</h1>
|
||||||
|
<Table striped hover responsive>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Package</th>
|
||||||
|
<th>Latest version</th>
|
||||||
|
<th>Fedora</th>
|
||||||
|
<th>Versions</th>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th>Built</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summaries.map(
|
||||||
|
({ name, latest, releasevers, stableCount, unstableCount }) => (
|
||||||
|
<tr key={name}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/packages/${name}`}>{name}</Link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{latest.version}-{latest.release}{" "}
|
||||||
|
<Badge bg={latest.channel === "stable" ? "success" : "warning"} className="ms-1">
|
||||||
|
{latest.channel}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{releasevers.map((rv) => (
|
||||||
|
<Badge key={rv} bg="secondary" className="me-1">
|
||||||
|
fc{rv}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{stableCount > 0 && (
|
||||||
|
<Badge bg="success" className="me-1">{stableCount} stable</Badge>
|
||||||
|
)}
|
||||||
|
{unstableCount > 0 && (
|
||||||
|
<Badge bg="warning">{unstableCount} unstable</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{latest.summary}</td>
|
||||||
|
<td>{new Date(latest.buildTime * 1000).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/theme/ThemeContext.tsx
Normal file
74
src/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type ThemeChoice = "system" | "light" | "dark";
|
||||||
|
type ResolvedTheme = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
choice: ThemeChoice;
|
||||||
|
resolved: ResolvedTheme;
|
||||||
|
setChoice: (choice: ThemeChoice) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
function resolveSystemTheme(): ResolvedTheme {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(choice: ThemeChoice): ResolvedTheme {
|
||||||
|
return choice === "system" ? resolveSystemTheme() : choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [choice, setChoiceState] = useState<ThemeChoice>(() => {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
return stored === "light" || stored === "dark" ? stored : "system";
|
||||||
|
});
|
||||||
|
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||||
|
resolve(choice),
|
||||||
|
);
|
||||||
|
|
||||||
|
function setChoice(next: ThemeChoice) {
|
||||||
|
setChoiceState(next);
|
||||||
|
if (next === "system") {
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResolved(resolve(choice));
|
||||||
|
|
||||||
|
if (choice !== "system") return;
|
||||||
|
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handler = () => setResolved(resolveSystemTheme());
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, [choice]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", resolved);
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ choice, resolved, setChoice }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
32
src/theme/ThemeToggle.tsx
Normal file
32
src/theme/ThemeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Dropdown } from "react-bootstrap";
|
||||||
|
import { useTheme } from "./ThemeContext.tsx";
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ key: "light" as const, label: "Light", icon: "☀️" },
|
||||||
|
{ key: "dark" as const, label: "Dark", icon: "🌙" },
|
||||||
|
{ key: "system" as const, label: "System", icon: "💻" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { choice, setChoice } = useTheme();
|
||||||
|
const current = options.find((o) => o.key === choice) ?? options[2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown align="end">
|
||||||
|
<Dropdown.Toggle variant="outline-secondary" size="sm">
|
||||||
|
{current.icon}
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={o.key}
|
||||||
|
active={o.key === choice}
|
||||||
|
onClick={() => setChoice(o.key)}
|
||||||
|
>
|
||||||
|
{o.icon} {o.label}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/types/packages.ts
Normal file
30
src/types/packages.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface ChangelogEntry {
|
||||||
|
author: string;
|
||||||
|
date: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Channel = "stable" | "unstable";
|
||||||
|
|
||||||
|
export interface PackageVersion {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
release: string;
|
||||||
|
arch: string;
|
||||||
|
summary: string;
|
||||||
|
size: number;
|
||||||
|
buildTime: number;
|
||||||
|
rpmFilename: string;
|
||||||
|
changelog: ChangelogEntry[];
|
||||||
|
channel: Channel;
|
||||||
|
baseUrl: string;
|
||||||
|
// Fedora releasever ("43", "44", ...) — attached at fetch time from the
|
||||||
|
// tree each manifest was loaded from (manifests are per-releasever).
|
||||||
|
releasever: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackagesManifest {
|
||||||
|
generated: string;
|
||||||
|
baseUrl: string;
|
||||||
|
packages: PackageVersion[];
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: "/",
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user