feat: add React UI for rpm.lair.cafe
Some checks failed
poll-upstream / check (push) Successful in 1s
deploy-ui / build-and-deploy (push) Failing after 19s

- Vite + React + SWC + TypeScript SPA with react-router and
  react-bootstrap
- Dark/light/system theme with Bootstrap 5.3 data-bs-theme
- Home page with repo setup instructions and copyable code blocks
- Package list and detail pages driven by packages.json
- Python script to generate packages.json from repodata XML
- Nginx config updated for SPA fallback, asset caching, removed
  autoindex
- New deploy-ui workflow triggered on ui/ or nginx config changes,
  requires runners with nvm label
- packages.json generation added to publish job after createrepo_c
- Runner setup docs for nvm and sequoia-sq added to readme

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:55:38 +03:00
parent a6cebc76ba
commit 7f9e857695
23 changed files with 2577 additions and 4 deletions

View File

@@ -190,3 +190,12 @@ jobs:
run: | run: |
ssh "gitea_ci@${RPM_REPO_HOST}" \ ssh "gitea_ci@${RPM_REPO_HOST}" \
"cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update ." "cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update ."
- name: Generate packages.json
run: |
scp script/generate-packages-json.py "gitea_ci@${RPM_REPO_HOST}:/tmp/"
ssh "gitea_ci@${RPM_REPO_HOST}" \
"python3 /tmp/generate-packages-json.py \
--repodata-dir /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/repodata \
--output /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/packages.json \
--base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64"

View File

@@ -0,0 +1,70 @@
name: deploy-ui
on:
push:
branches: [main]
paths:
- "ui/**"
- "asset/nginx/**"
workflow_dispatch: {}
jobs:
build-and-deploy:
runs-on:
- fedora-43
- nvm
env:
RPM_REPO_HOST: oolon.kosherinata.internal
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
run: |
source ~/.nvm/nvm.sh
nvm install --lts
echo "$(dirname "$(nvm which current)")" >> "$GITHUB_PATH"
- name: Install dependencies
run: |
cd ui
npm ci
- name: Build UI
run: |
cd ui
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 UI to web root
run: |
rsync \
--archive \
--verbose \
--delete \
--chmod D755,F644 \
--include="index.html" \
--include="assets/***" \
--exclude="*" \
ui/dist/ \
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/"
- name: Deploy nginx config
run: |
rsync \
--archive \
--verbose \
--rsync-path="sudo rsync" \
--chown=root:root \
asset/nginx/rpm.lair.cafe.conf \
"gitea_ci@${RPM_REPO_HOST}:/etc/nginx/sites-available/rpm.lair.cafe.conf"
ssh "gitea_ci@${RPM_REPO_HOST}" "sudo nginx -t && sudo systemctl reload nginx"

View File

@@ -10,16 +10,17 @@ server {
root /var/www/rpm; root /var/www/rpm;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
types { types {
application/x-rpm rpm; application/x-rpm rpm;
application/xml xml; application/xml xml;
} }
default_type application/octet-stream; default_type application/octet-stream;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~ \.rpm$ { location ~ \.rpm$ {
expires 30d; expires 30d;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
@@ -30,7 +31,16 @@ server {
add_header Cache-Control "no-cache, must-revalidate"; add_header Cache-Control "no-cache, must-revalidate";
} }
location ~ packages\.json$ {
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
location ~ \.gpg$ { location ~ \.gpg$ {
default_type text/plain; default_type text/plain;
} }
location / {
try_files $uri $uri/ /index.html;
}
} }

View File

@@ -93,6 +93,35 @@ gpg --homedir ~/.gnupg/lair --quick-add-key <master-fpr> ed25519 sign 1y
Then update the `RPM_SIGNING_KEY` secret in Gitea with the new subkey. The public key served to users doesn't change since it's anchored to the master key. Then update the `RPM_SIGNING_KEY` secret in Gitea with the new subkey. The public key served to users doesn't change since it's anchored to the master key.
### 5. Runner prerequisites
#### nvm (for UI builds)
Runners that build the UI need [nvm](https://github.com/nvm-sh/nvm) installed for the `gitea_runner` user and an `nvm` label in their runner config:
```bash
sudo -u gitea_runner bash -c 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash'
```
Then add `nvm` to the labels in `/etc/act_runner/config.yml`:
```yaml
runner:
labels:
- "fedora-43:host"
- "nvm"
```
Restart the runner after changing labels. The `deploy-ui` workflow uses `runs-on: [fedora-43, nvm]` to select runners with Node.js capability.
#### sequoia-sq (for RPM signing)
Runners that run the publish job need `sequoia-sq` installed:
```bash
sudo dnf install sequoia-sq
```
## Client setup ## Client setup
```bash ```bash

141
script/generate-packages-json.py Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Parse RPM repodata and emit a packages.json manifest for the UI."""
import argparse
import gzip
import json
import os
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
RPM_NS = "http://linux.duke.edu/metadata/common"
OTHER_NS = "http://linux.duke.edu/metadata/other"
REPO_NS = "http://linux.duke.edu/metadata/repo"
def find_repodata_file(repodata_dir, data_type):
"""Read repomd.xml and return the path to a specific data type's file."""
repomd_path = os.path.join(repodata_dir, "repomd.xml")
tree = ET.parse(repomd_path)
root = tree.getroot()
for data in root.findall(f"{{{REPO_NS}}}data"):
if data.get("type") == data_type:
location = data.find(f"{{{REPO_NS}}}location")
if location is not None:
href = location.get("href", "")
return os.path.join(os.path.dirname(repodata_dir), href)
return None
def parse_primary(repodata_dir):
"""Parse primary.xml.gz and return package metadata."""
path = find_repodata_file(repodata_dir, "primary")
if not path:
print("error: primary metadata not found in repomd.xml", file=sys.stderr)
sys.exit(1)
packages = {}
with gzip.open(path, "rb") as f:
tree = ET.parse(f)
for pkg in tree.getroot().findall(f"{{{RPM_NS}}}package"):
if pkg.get("type") != "rpm":
continue
name = pkg.findtext(f"{{{RPM_NS}}}name", "")
version_el = pkg.find(f"{{{RPM_NS}}}version")
ver = version_el.get("ver", "") if version_el is not None else ""
rel = version_el.get("rel", "") if version_el is not None else ""
arch = pkg.findtext(f"{{{RPM_NS}}}arch", "")
size_el = pkg.find(f"{{{RPM_NS}}}size")
size = int(size_el.get("package", "0")) if size_el is not None else 0
time_el = pkg.find(f"{{{RPM_NS}}}time")
build_time = int(time_el.get("build", "0")) if time_el is not None else 0
location_el = pkg.find(f"{{{RPM_NS}}}location")
filename = os.path.basename(location_el.get("href", "")) if location_el is not None else ""
key = f"{name}-{ver}-{rel}"
packages[key] = {
"name": name,
"version": ver,
"release": rel,
"arch": arch,
"summary": pkg.findtext(f"{{{RPM_NS}}}summary", ""),
"size": size,
"buildTime": build_time,
"rpmFilename": filename,
"changelog": [],
}
return packages
def parse_other(repodata_dir, packages):
"""Parse other.xml.gz and attach changelog entries to packages."""
path = find_repodata_file(repodata_dir, "other")
if not path:
return
with gzip.open(path, "rb") as f:
tree = ET.parse(f)
for pkg in tree.getroot().findall(f"{{{OTHER_NS}}}package"):
name = pkg.get("name", "")
version_el = pkg.find(f"{{{OTHER_NS}}}version")
ver = version_el.get("ver", "") if version_el is not None else ""
rel = version_el.get("rel", "") if version_el is not None else ""
key = f"{name}-{ver}-{rel}"
if key not in packages:
continue
for entry in pkg.findall(f"{{{OTHER_NS}}}changelog"):
packages[key]["changelog"].append({
"author": entry.get("author", ""),
"date": int(entry.get("date", "0")),
"text": (entry.text or "").strip(),
})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repodata-dir",
required=True,
help="path to the repodata/ directory",
)
parser.add_argument(
"--output",
required=True,
help="path to write packages.json",
)
parser.add_argument(
"--base-url",
required=True,
help="public base URL for the repo (e.g. https://rpm.lair.cafe/fedora/43/x86_64)",
)
args = parser.parse_args()
packages = parse_primary(args.repodata_dir)
parse_other(args.repodata_dir, packages)
manifest = {
"generated": datetime.now(timezone.utc).isoformat(),
"baseUrl": args.base_url,
"packages": list(packages.values()),
}
with open(args.output, "w") as f:
json.dump(manifest, f, indent=2)
print(f"wrote {len(packages)} packages to {args.output}")
if __name__ == "__main__":
main()

24
ui/.gitignore vendored Normal file
View 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?

12
ui/index.html Normal file
View 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
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
ui/package.json Normal file
View 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"
}
}

25
ui/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import type { PackagesManifest } from "../types/packages.ts";
const MANIFEST_URL = "/fedora/43/x86_64/packages.json";
export function usePackages() {
const [manifest, setManifest] = useState<PackagesManifest | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(MANIFEST_URL)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PackagesManifest>;
})
.then((data) => {
if (!cancelled) setManifest(data);
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { manifest, loading, error };
}

10
ui/src/main.tsx Normal file
View 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>,
);

49
ui/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,49 @@
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_FILE = `[lair-cafe]
name=lair.cafe RPM Repository
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/
enabled=1
gpgcheck=1
gpgkey=${GPG_KEY_URL}`;
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=/dev/stdin <<'EOF'\n${REPO_FILE}\nEOF`}
</CodeBlock>
<h6 className="mt-4">3. Install a package</h6>
<CodeBlock language="bash">
{`sudo dnf install mistralrs-server-cuda13`}
</CodeBlock>
</Card.Body>
</Card>
</Col>
</Row>
</>
);
}

View File

@@ -0,0 +1,100 @@
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 { manifest, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (!manifest) return <Alert variant="info">No package data available.</Alert>;
const versions = manifest.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];
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>
<h2 className="mt-4 mb-3">
Versions <Badge bg="secondary">{versions.length}</Badge>
</h2>
<Table striped hover responsive>
<thead>
<tr>
<th>Version</th>
<th>Size</th>
<th>Built</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{versions.map((pkg) => (
<tr key={`${pkg.version}-${pkg.release}`}>
<td>
{pkg.version}-{pkg.release}
</td>
<td>{formatBytes(pkg.size)}</td>
<td>{new Date(pkg.buildTime * 1000).toLocaleDateString()}</td>
<td>
<a href={`${manifest.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} &mdash;{" "}
{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()}{" "}
&mdash; {entry.author}
</small>
<pre className="mb-0 mt-1">{entry.text}</pre>
</div>
))}
</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
</>
)}
</>
);
}

View File

@@ -0,0 +1,53 @@
import { Alert, Spinner, Table } from "react-bootstrap";
import { Link } from "react-router";
import { usePackages } from "../hooks/usePackages.ts";
export function PackageList() {
const { manifest, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (!manifest || manifest.packages.length === 0)
return <Alert variant="info">No packages published yet.</Alert>;
const byName = Map.groupBy(manifest.packages, (p) => p.name);
const summaries = [...byName.entries()].map(([name, versions]) => {
const latest = versions.reduce((a, b) =>
a.buildTime >= b.buildTime ? a : b,
);
return { name, latest, 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>Versions</th>
<th>Summary</th>
<th>Built</th>
</tr>
</thead>
<tbody>
{summaries.map(({ name, latest, versionCount }) => (
<tr key={name}>
<td>
<Link to={`/packages/${name}`}>{name}</Link>
</td>
<td>
{latest.version}-{latest.release}
</td>
<td>{versionCount}</td>
<td>{latest.summary}</td>
<td>{new Date(latest.buildTime * 1000).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}

View 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;
}

View 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>
);
}

23
ui/src/types/packages.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface ChangelogEntry {
author: string;
date: number;
text: string;
}
export interface PackageVersion {
name: string;
version: string;
release: string;
arch: string;
summary: string;
size: number;
buildTime: number;
rpmFilename: string;
changelog: ChangelogEntry[];
}
export interface PackagesManifest {
generated: string;
baseUrl: string;
packages: PackageVersion[];
}

25
ui/tsconfig.json Normal file
View 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
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig({
plugins: [react()],
base: "/",
});