feat: add React UI for rpm.lair.cafe
- 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:
24
ui/.gitignore
vendored
Normal file
24
ui/.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?
|
||||
12
ui/index.html
Normal file
12
ui/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
ui/package-lock.json
generated
Normal file
1733
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
ui/package.json
Normal file
25
ui/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"
|
||||
}
|
||||
}
|
||||
25
ui/src/App.tsx
Normal file
25
ui/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
ui/src/components/CodeBlock.tsx
Normal file
35
ui/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
ui/src/components/Layout.tsx
Normal file
14
ui/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
ui/src/components/NavHeader.tsx
Normal file
37
ui/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>
|
||||
);
|
||||
}
|
||||
36
ui/src/hooks/usePackages.ts
Normal file
36
ui/src/hooks/usePackages.ts
Normal 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
10
ui/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>,
|
||||
);
|
||||
49
ui/src/pages/Home.tsx
Normal file
49
ui/src/pages/Home.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
ui/src/pages/PackageDetail.tsx
Normal file
100
ui/src/pages/PackageDetail.tsx
Normal 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} —{" "}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
ui/src/pages/PackageList.tsx
Normal file
53
ui/src/pages/PackageList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
ui/src/theme/ThemeContext.tsx
Normal file
74
ui/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
ui/src/theme/ThemeToggle.tsx
Normal file
32
ui/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>
|
||||
);
|
||||
}
|
||||
23
ui/src/types/packages.ts
Normal file
23
ui/src/types/packages.ts
Normal 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
25
ui/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
ui/vite.config.ts
Normal file
7
ui/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