Add urapt-web: web UI for urapt-server instances

A Vite + React 18 + TypeScript SPA, same-origin with the API (no CORS,
no Go server changes). Full management parity with the urapt CLI:

- First-run setup (admin bootstrap via needs_setup), login/logout
- Repositories: list, create, tabbed detail (overview edit/delete,
  members CRUD, distributions/components/architectures manager,
  package list + .deb upload + detail + delete, apt-config panel)
- API tokens: show-once creation + revoke
- Admin users: toggle admin / delete
- Settings + dark mode

Stack: Tailwind + shadcn/ui (New York), React Router v7 (lazy routes),
TanStack Query v5, Zustand, Vitest. Hand-written typed API client
mirroring shared/api + shared/models. Dev proxy forwards /api + /apt
to a local urapt-server; deploy behind nginx/Caddy (see urapt-web/README).
This commit is contained in:
2026-06-28 18:42:46 -05:00
parent bff58fd842
commit 39525bd5f0
85 changed files with 10653 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# API base URL. Defaults to same-origin /api/v1 for same-origin deployments.
# Override only for cross-origin dev against a remote server (requires CORS).
VITE_API_BASE_URL=/api/v1
+35
View File
@@ -0,0 +1,35 @@
# dependencies
node_modules
# build
dist
dist-ssr
*.local
*.tsbuildinfo
# logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# editor / os
.DS_Store
.idea
.vscode/*
!.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# test
coverage
# env
.env
.env.*
!.env.example
+1
View File
@@ -0,0 +1 @@
20
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
coverage
pnpm-lock.yaml
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 90,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
+117
View File
@@ -0,0 +1,117 @@
# urapt-web
The web UI for [urapt](https://github.com/owenqwenstarsky/urapt), a self-hostable
APT repository server. A React single-page app that talks to a urapt-server's
REST API (`/api/v1`) to manage repositories, members, distributions, packages,
tokens, and users — full management parity with the `urapt` CLI.
## Stack
- **Vite** + **React 18** + **TypeScript**
- **Tailwind CSS** + **shadcn/ui** (New York, neutral)
- **React Router v7**, **TanStack Query v5**, **Zustand**, **Vitest**
## Requirements
- Node.js 20+ (see `.nvmrc`) and pnpm 10+
## Development
Start a local urapt-server (from the repo root):
```bash
make run-server # listens on 0.0.0.0:8080
```
Then in `urapt-web/`:
```bash
pnpm install
pnpm dev # http://localhost:5173
```
Vite proxies `/api` and `/apt` to `http://localhost:8080` (override with the
`URAPT_SERVER` env var), so the UI is same-origin with the API in dev — no CORS
needed. If `needs_setup` is true (no users yet), you'll be sent to the first-run
setup screen to create the admin account.
## Scripts
| Script | Description |
| ---------------- | ------------------------------------ |
| `pnpm dev` | Start the Vite dev server |
| `pnpm build` | Type-check and build to `dist/` |
| `pnpm preview` | Preview the production build locally |
| `pnpm typecheck` | Run `tsc` with no emit |
| `pnpm lint` | Run ESLint |
| `pnpm format` | Run Prettier (write) |
| `pnpm test` | Run Vitest once |
## Configuration
| Env var | Default | Notes |
| ------------------- | ----------------------- | -------------------------------------------------------------------------------- |
| `VITE_API_BASE_URL` | `/api/v1` | REST API base. Override only for cross-origin dev (requires CORS on the server). |
| `URAPT_SERVER` | `http://localhost:8080` | Dev proxy target only. |
## Deployment (same-origin)
The UI is designed to be served from the **same origin** as a urapt-server,
behind a reverse proxy that serves the SPA from `dist/` and forwards `/api/v1`
and `/apt` to `urapt-server`. No CORS, no server changes — the API base is just
`/api/v1`.
```bash
pnpm build # outputs dist/
```
### Caddy
```caddy
apt.example.com {
# SPA
root * /srv/urapt-web/dist
try_files {path} /index.html
file_server
# REST API + APT endpoint
reverse_proxy /api/* 127.0.0.1:8080
reverse_proxy /apt/* 127.0.0.1:8080
}
```
### nginx
```nginx
server {
listen 80;
server_name apt.example.com;
root /srv/urapt-web/dist;
location / { try_files $uri /index.html; }
location /api/ { proxy_pass http://127.0.0.1:8080; }
location /apt/ { proxy_pass http://127.0.0.1:8080; }
}
```
## Architecture
```
src/
lib/api/ typed client mirroring urapt's shared/api + shared/models
(client, errors, endpoints, types)
stores/ Zustand auth store (persisted token)
hooks/ shared server/user queries, logout
components/ app shell, common UI (copy button, confirm, states), shadcn ui
features/ auth, setup, repos (tabs), tokens, users, settings
routes.tsx route tree with lazy-loaded pages + protected/admin guards
```
The API client (`src/lib/api/`) is hand-written and typed against urapt's Go
DTOs in `shared/api/api.go` and `shared/models/models.go`. A `401` anywhere
clears the local session and redirects to `/login`.
## License
GPL-3.0-or-later, matching the urapt project.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+29
View File
@@ -0,0 +1,29 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "coverage"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
);
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>urapt</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
{
"name": "urapt-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Web UI for urapt-server (self-hosted APT repository server).",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.59.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.451.0",
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.10.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8",
"vitest": "^2.1.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}
+4795
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#171717" />
<path d="M9 11h14M16 11v12" stroke="#fafafa" stroke-width="2.5" stroke-linecap="round" />
<circle cx="16" cy="23" r="1.6" fill="#fafafa" />
</svg>

After

Width:  |  Height:  |  Size: 280 B

+11
View File
@@ -0,0 +1,11 @@
import { RouterProvider } from "react-router-dom";
import { Providers } from "./providers";
import { router } from "./routes";
export default function App() {
return (
<Providers>
<RouterProvider router={router} />
</Providers>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { NavLink as RRNavLink, Outlet } from "react-router-dom";
import {
LogOut,
Package,
Settings,
Terminal,
User as UserIcon,
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ThemeToggle } from "@/components/common/theme-toggle";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth";
import { useMe, useServerInfo } from "@/hooks/use-server";
import { useLogout } from "@/hooks/use-logout";
interface NavItem {
to: string;
label: string;
icon: typeof Package;
adminOnly?: boolean;
}
const NAV: NavItem[] = [
{ to: "/", label: "Repositories", icon: Package },
{ to: "/tokens", label: "Tokens", icon: Terminal },
{ to: "/users", label: "Users", icon: Users, adminOnly: true },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function AppShell() {
const signOut = useLogout();
const token = useAuthStore((s) => s.token);
const me = useMe(!!token);
const serverInfo = useServerInfo();
const user = me.data;
const isAdmin = user?.is_admin ?? false;
return (
<div className="flex min-h-dvh">
<aside className="hidden w-60 shrink-0 flex-col border-r bg-card md:flex">
<div className="flex h-14 items-center gap-2 border-b px-5">
<div className="flex size-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Terminal className="size-4" />
</div>
<span className="font-semibold tracking-tight">urapt</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV.filter((item) => !item.adminOnly || isAdmin).map((item) => (
<NavLink key={item.to} item={item} />
))}
</nav>
<div className="border-t p-3 text-xs text-muted-foreground">
{serverInfo.data ? <span>urapt v{serverInfo.data.version}</span> : null}
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 md:hidden">
<div className="flex size-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Terminal className="size-4" />
</div>
<span className="font-semibold tracking-tight">urapt</span>
</div>
<div className="hidden md:block" />
<div className="flex items-center gap-1">
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Avatar className="size-6">
<AvatarFallback className="text-xs">
{user?.username?.slice(0, 2).toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<span className="max-w-[10rem] truncate">{user?.username ?? "…"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="flex items-center gap-2">
<UserIcon className="size-4" />
<span className="truncate">{user?.username}</span>
{isAdmin ? (
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium">
admin
</span>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={signOut}>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-y-auto bg-muted/30 p-4 md:p-6">
{/* Mobile nav */}
<nav className="mb-4 flex gap-1 overflow-x-auto md:hidden">
{NAV.filter((item) => !item.adminOnly || isAdmin).map((item) => (
<NavLink key={item.to} item={item} compact />
))}
</nav>
<div className="mx-auto max-w-5xl">
<Outlet />
</div>
</main>
</div>
</div>
);
}
function NavLink({ item, compact }: { item: NavItem; compact?: boolean }) {
const Icon = item.icon;
return (
<RRNavLink
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"text-muted-foreground hover:bg-accent hover:text-foreground",
isActive && "bg-accent text-foreground",
compact && "shrink-0",
)
}
>
<Icon className="size-4" />
{item.label}
</RRNavLink>
);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth";
import { useMe } from "@/hooks/use-server";
/** Wraps admin-only routes; redirects non-admins home. */
export function AdminRoute({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token);
const me = useMe(!!token);
if (me.data && !me.data.is_admin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { Access, Visibility } from "@/lib/api/types";
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
return (
<Badge
variant={visibility === "public" ? "secondary" : "outline"}
className="capitalize"
>
{visibility}
</Badge>
);
}
const ACCESS_STYLES: Record<Access, string> = {
read: "bg-muted text-muted-foreground",
write: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
"read-write": "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300",
admin: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
};
export function AccessBadge({ access }: { access: Access }) {
return (
<span
className={cn(
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium capitalize",
ACCESS_STYLES[access],
)}
>
{access}
</span>
);
}
@@ -0,0 +1,67 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
destructive = false,
onConfirm,
}: ConfirmDialogProps) {
const [busy, setBusy] = useState(false);
async function handleConfirm() {
try {
setBusy(true);
await onConfirm();
onOpenChange(false);
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={busy}
>
{busy ? "…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,47 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn, copyToClipboard } from "@/lib/utils";
interface CopyButtonProps {
value: string;
label?: string;
className?: string;
size?: "default" | "sm" | "icon";
variant?: "default" | "outline" | "ghost" | "secondary";
}
export function CopyButton({
value,
label = "Copy",
className,
size = "sm",
variant = "outline",
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
async function onCopy() {
try {
await copyToClipboard(value);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Couldn't copy to clipboard");
}
}
return (
<Button
type="button"
variant={variant}
size={size}
className={cn(className)}
onClick={onCopy}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{size === "icon" ? null : <span>{copied ? "Copied" : label}</span>}
</Button>
);
}
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
className?: string;
}
export function EmptyState({
icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center",
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="space-y-1">
<p className="font-medium">{title}</p>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="mt-1">{action}</div> : null}
</div>
);
}
@@ -0,0 +1,27 @@
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ApiError } from "@/lib/api";
interface ErrorStateProps {
error: unknown;
onRetry?: () => void;
}
export function ErrorState({ error, onRetry }: ErrorStateProps) {
const message = ApiError.isApiError(error) ? error.display() : "Something went wrong.";
return (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-4">
<span>{message}</span>
{onRetry ? (
<Button size="sm" variant="outline" onClick={onRetry}>
Retry
</Button>
) : null}
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,14 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
export function NotFound() {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<p className="text-4xl font-semibold tracking-tight">404</p>
<p className="text-muted-foreground">This page doesn't exist.</p>
<Button asChild>
<Link to="/">Back to repositories</Link>
</Button>
</div>
);
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 pb-2">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,39 @@
import { Navigate } from "react-router-dom";
import { FullPageSpinner } from "@/components/common/spinner";
import { AppShell } from "@/components/app-shell";
import { useAuthStore } from "@/stores/auth";
import { useMe, useServerInfo } from "@/hooks/use-server";
/**
* Root guard: boots server info + current session before rendering the app
* shell. Redirects to /setup on first run and /login when unauthenticated.
*/
export function RootGuard() {
const token = useAuthStore((s) => s.token);
const serverInfo = useServerInfo();
const me = useMe(!!token);
if (serverInfo.isLoading || serverInfo.isPending) {
return <FullPageSpinner label="Connecting to urapt…" />;
}
// First-run setup takes priority: no admin exists yet.
if (serverInfo.data?.needs_setup) {
return <Navigate to="/setup" replace />;
}
if (!token) {
return <Navigate to="/login" replace />;
}
if (me.isLoading || me.isPending) {
return <FullPageSpinner label="Loading session…" />;
}
// Token present but invalid (401 already cleared the store; handle races).
if (me.isError || !me.data) {
return <Navigate to="/login" replace />;
}
return <AppShell />;
}
@@ -0,0 +1,15 @@
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cn("size-4 animate-spin", className)} />;
}
export function FullPageSpinner({ label }: { label?: string }) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-3">
<Spinner className="size-6" />
{label ? <p className="text-sm text-muted-foreground">{label}</p> : null}
</div>
);
}
@@ -0,0 +1,19 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
title={isDark ? "Switch to light" : "Switch to dark"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
@@ -0,0 +1,9 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+50
View File
@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer grid h-4 w-4 shrink-0 place-content-center rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+108
View File
@@ -0,0 +1,108 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,194 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+150
View File
@@ -0,0 +1,150 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+9
View File
@@ -0,0 +1,9 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />
);
}
export { Skeleton };
+28
View File
@@ -0,0 +1,28 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+112
View File
@@ -0,0 +1,112 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AuthLayout } from "./auth-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/common/spinner";
import { ApiError, login } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
export function Login() {
const navigate = useNavigate();
const qc = useQueryClient();
const setSession = useAuthStore((s) => s.setSession);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
const res = await login({ username: username.trim(), password });
setSession(res.token, res.user);
await qc.invalidateQueries({ queryKey: ["me"] });
qc.setQueryData(["me"], res.user);
navigate("/");
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Login failed");
} finally {
setBusy(false);
}
}
return (
<AuthLayout title="urapt" description="Sign in to manage your repositories">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={busy}>
{busy ? <Spinner className="size-4" /> : null}
Sign in
</Button>
</form>
</AuthLayout>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { Terminal } from "lucide-react";
interface AuthLayoutProps {
title: string;
description: string;
children: ReactNode;
footer?: ReactNode;
}
export function AuthLayout({ title, description, children, footer }: AuthLayoutProps) {
return (
<div className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
<div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Terminal className="size-5" />
</div>
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
{footer ? (
<div className="text-center text-xs text-muted-foreground">{footer}</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/common/spinner";
import { ApiError } from "@/lib/api";
import type { Visibility } from "@/lib/api/types";
import { useCreateRepository } from "./repos-hooks";
interface CreateRepoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const NAME_RE = /^[a-z0-9_-]{3,64}$/;
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
const navigate = useNavigate();
const create = useCreateRepository();
const [name, setName] = useState("");
const [visibility, setVisibility] = useState<Visibility>("private");
const [description, setDescription] = useState("");
useEffect(() => {
if (open) {
setName("");
setVisibility("private");
setDescription("");
}
}, [open]);
const nameInvalid = name.length > 0 && !NAME_RE.test(name);
const canSubmit = NAME_RE.test(name) && !create.isPending;
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
try {
const repo = await create.mutateAsync({
name,
visibility,
description: description.trim() || undefined,
});
toast.success(`Repository "${repo.name}" created`);
onOpenChange(false);
navigate(`/repos/${repo.name}`);
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to create repository",
);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New repository</DialogTitle>
<DialogDescription>
Create a new APT repository to push packages into.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="repo-name">Name</Label>
<Input
id="repo-name"
autoCapitalize="none"
spellCheck={false}
placeholder="myrepo"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
{nameInvalid ? (
<p className="text-xs text-destructive">
Lowercase letters, digits, <code>-</code> and <code>_</code>. 3–64 chars.
</p>
) : (
<p className="text-xs text-muted-foreground">
Used in the apt URL: <code>/apt/&lt;name&gt;/</code>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="repo-visibility">Visibility</Label>
<Select
value={visibility}
onValueChange={(v) => setVisibility(v as Visibility)}
>
<SelectTrigger id="repo-visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private — members only</SelectItem>
<SelectItem value="public">Public — anonymous reads</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="repo-desc">Description (optional)</Label>
<Input
id="repo-desc"
placeholder="What is this repo for?"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={280}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{create.isPending ? <Spinner className="size-4" /> : null}
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useParams, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/common/page-header";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { VisibilityBadge } from "@/components/common/badges";
import { useRepository, useDeleteRepository } from "./repos-hooks";
import { useMembers } from "./repo-detail-hooks";
import { RepoProvider } from "./repo-context.tsx";
import { computeAccess, canWriteWith, canManageWith } from "./repo-context";
import { RepoOverview } from "./tabs/RepoOverview";
import { Members } from "./tabs/Members";
import { Structure } from "./tabs/Structure";
import { Packages } from "./tabs/Packages";
import { AptConfig } from "./tabs/AptConfig";
import { useMe } from "@/hooks/use-server";
import { useAuthStore } from "@/stores/auth";
export function RepoDetail() {
const { repo, tab } = useParams<{ repo: string; tab?: string }>();
const token = useAuthStore((s) => s.token);
const me = useMe(!!token);
const { data, isLoading, isError, error } = useRepository(repo ?? "");
const members = useMembers(repo ?? "");
const del = useDeleteRepository();
if (isLoading) {
return (
<div className="flex justify-center py-16">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return <ErrorState error={error} />;
}
if (!data) return null;
const myMember = members.data?.find((m) => m.user_id === me.data?.id);
const access = computeAccess(data, me.data ?? null, myMember?.access ?? null);
const canWrite = canWriteWith(access);
const canManage = canManageWith(access);
return (
<RepoProvider
value={{
repo: data,
me: me.data ?? null,
access,
canWrite,
canManage,
}}
>
<div className="space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/">
<ArrowLeft className="size-4" /> Repositories
</Link>
</Button>
<PageHeader
title={data.name}
description={data.description || "No description"}
actions={<VisibilityBadge visibility={data.visibility} />}
/>
<Tabs value={tab ?? "overview"}>
<TabsList>
<TabsTrigger value="overview" asChild>
<Link to={`/repos/${data.name}`}>Overview</Link>
</TabsTrigger>
<TabsTrigger value="members" asChild>
<Link to={`/repos/${data.name}/members`}>Members</Link>
</TabsTrigger>
<TabsTrigger value="structure" asChild>
<Link to={`/repos/${data.name}/structure`}>Distributions</Link>
</TabsTrigger>
<TabsTrigger value="packages" asChild>
<Link to={`/repos/${data.name}/packages`}>Packages</Link>
</TabsTrigger>
<TabsTrigger value="apt-config" asChild>
<Link to={`/repos/${data.name}/apt-config`}>Apt config</Link>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-2">
{tab === "members" ? (
<Members />
) : tab === "structure" ? (
<Structure />
) : tab === "packages" ? (
<Packages />
) : tab === "apt-config" ? (
<AptConfig />
) : (
<RepoOverview onDelete={del} />
)}
</div>
</div>
</RepoProvider>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Package, Plus } from "lucide-react";
import { PageHeader } from "@/components/common/page-header";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { VisibilityBadge } from "@/components/common/badges";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { formatDate } from "@/lib/utils";
import { useRepositories } from "./repos-hooks";
import { CreateRepoDialog } from "./CreateRepoDialog";
export function ReposList() {
const { data, isLoading, isError, error, refetch } = useRepositories();
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
const repos = data?.items ?? [];
return (
<div className="space-y-6">
<PageHeader
title="Repositories"
description="Your APT repositories and those shared with you"
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New repository
</Button>
}
/>
{isLoading ? (
<div className="flex justify-center py-16">
<Spinner className="size-6" />
</div>
) : isError ? (
<ErrorState error={error} onRetry={() => refetch()} />
) : repos.length === 0 ? (
<EmptyState
icon={<Package className="size-8" />}
title="No repositories yet"
description="Create your first repository to start pushing .deb packages."
action={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New repository
</Button>
}
/>
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Visibility</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Description</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{repos.map((repo) => (
<TableRow
key={repo.id}
className="cursor-pointer"
onClick={() => navigate(`/repos/${repo.name}`)}
>
<TableCell className="font-mono font-medium">{repo.name}</TableCell>
<TableCell>
<VisibilityBadge visibility={repo.visibility} />
</TableCell>
<TableCell className="text-muted-foreground">
{repo.owner?.username ?? "—"}
</TableCell>
<TableCell className="max-w-[20rem] truncate text-muted-foreground">
{repo.description || "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(repo.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CreateRepoDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}
@@ -0,0 +1,44 @@
import { createContext, useContext } from "react";
import type { Repository, User } from "@/lib/api/types";
export type EffectiveAccess = "read" | "write" | "read-write" | "admin" | "owner" | null;
export interface RepoContextValue {
repo: Repository;
me: User | null;
access: EffectiveAccess;
canWrite: boolean;
canManage: boolean;
}
export const RepoCtx = createContext<RepoContextValue | null>(null);
export function useRepoContext(): RepoContextValue {
const v = useContext(RepoCtx);
if (!v) throw new Error("useRepoContext must be used within a RepoProvider");
return v;
}
/** Compute the current user's effective access on a repo. */
export function computeAccess(
repo: Repository,
me: User | null,
myMemberAccess: string | null,
): EffectiveAccess {
if (!me) return null;
if (me.is_admin || repo.owner_user_id === me.id) return "owner";
return (myMemberAccess as EffectiveAccess) ?? null;
}
export function canWriteWith(access: EffectiveAccess): boolean {
return (
access === "owner" ||
access === "admin" ||
access === "write" ||
access === "read-write"
);
}
export function canManageWith(access: EffectiveAccess): boolean {
return access === "owner" || access === "admin";
}
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import { RepoCtx, type RepoContextValue } from "./repo-context";
export function RepoProvider({
value,
children,
}: {
value: RepoContextValue;
children: ReactNode;
}) {
return <RepoCtx.Provider value={value}>{children}</RepoCtx.Provider>;
}
@@ -0,0 +1,181 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addMember,
createArchitecture,
createComponent,
createDistribution,
deleteArchitecture,
deleteComponent,
deleteDistribution,
deletePackage,
getPackage,
listArchitectures,
listComponents,
listDistributions,
listMembers,
listPackages,
pushPackage,
removeMember,
updateMember,
updateRepository,
} from "@/lib/api";
import type {
AddMemberRequest,
CreateNamedRequest,
UpdateMemberRequest,
} from "@/lib/api/types";
import { REPO_KEY } from "./repos-hooks";
const DIST_KEY = (repo: string) => ["repo", repo, "distributions"] as const;
const COMP_KEY = (repo: string, dist: string) =>
["repo", repo, "distributions", dist, "components"] as const;
const ARCH_KEY = (repo: string, dist: string) =>
["repo", repo, "distributions", dist, "architectures"] as const;
const PKGS_KEY = (repo: string, dist: string) =>
["repo", repo, "distributions", dist, "packages"] as const;
const MEMBERS_KEY = (repo: string) => ["repo", repo, "members"] as const;
// --- members ---
export function useMembers(repo: string) {
return useQuery({ queryKey: MEMBERS_KEY(repo), queryFn: () => listMembers(repo) });
}
export function useAddMember(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: AddMemberRequest) => addMember(repo, body),
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
});
}
export function useUpdateMember(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ username, body }: { username: string; body: UpdateMemberRequest }) =>
updateMember(repo, username, body),
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
});
}
export function useRemoveMember(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (username: string) => removeMember(repo, username),
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
});
}
// --- distributions ---
export function useDistributions(repo: string) {
return useQuery({ queryKey: DIST_KEY(repo), queryFn: () => listDistributions(repo) });
}
export function useCreateDistribution(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateNamedRequest) => createDistribution(repo, body),
onSuccess: () => qc.invalidateQueries({ queryKey: DIST_KEY(repo) }),
});
}
export function useDeleteDistribution(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dist: string) => deleteDistribution(repo, dist),
onSuccess: () => qc.invalidateQueries({ queryKey: DIST_KEY(repo) }),
});
}
// --- components ---
export function useComponents(repo: string, dist: string) {
return useQuery({
queryKey: COMP_KEY(repo, dist),
queryFn: () => listComponents(repo, dist),
enabled: !!dist,
});
}
export function useCreateComponent(repo: string, dist: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateNamedRequest) => createComponent(repo, dist, body),
onSuccess: () => qc.invalidateQueries({ queryKey: COMP_KEY(repo, dist) }),
});
}
export function useDeleteComponent(repo: string, dist: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (comp: string) => deleteComponent(repo, dist, comp),
onSuccess: () => qc.invalidateQueries({ queryKey: COMP_KEY(repo, dist) }),
});
}
// --- architectures ---
export function useArchitectures(repo: string, dist: string) {
return useQuery({
queryKey: ARCH_KEY(repo, dist),
queryFn: () => listArchitectures(repo, dist),
enabled: !!dist,
});
}
export function useCreateArchitecture(repo: string, dist: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateNamedRequest) => createArchitecture(repo, dist, body),
onSuccess: () => qc.invalidateQueries({ queryKey: ARCH_KEY(repo, dist) }),
});
}
export function useDeleteArchitecture(repo: string, dist: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (arch: string) => deleteArchitecture(repo, dist, arch),
onSuccess: () => qc.invalidateQueries({ queryKey: ARCH_KEY(repo, dist) }),
});
}
// --- packages ---
export function usePackages(repo: string, dist: string) {
return useQuery({
queryKey: PKGS_KEY(repo, dist),
queryFn: () => listPackages(repo, dist, { per_page: 500 }),
enabled: !!dist,
});
}
export function usePushPackage(repo: string, dist: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ file, component }: { file: File; component: string }) =>
pushPackage(repo, dist, file, component),
onSuccess: () => qc.invalidateQueries({ queryKey: PKGS_KEY(repo, dist) }),
});
}
export function usePackage(repo: string, id: string) {
return useQuery({
queryKey: ["repo", repo, "package", id],
queryFn: () => getPackage(repo, id),
enabled: !!id,
});
}
export function useDeletePackage(repo: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id }: { id: string; dist: string }) => deletePackage(repo, id),
onSuccess: (_data, { dist }) =>
qc.invalidateQueries({ queryKey: PKGS_KEY(repo, dist) }),
});
}
// re-export repo mutations used by detail
export { updateRepository };
export { REPO_KEY };
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createRepository,
deleteRepository,
getRepository,
listRepositories,
updateRepository,
} from "@/lib/api";
import type { CreateRepoRequest, UpdateRepoRequest } from "@/lib/api/types";
export const REPO_KEY = (name: string) => ["repo", name] as const;
export const REPO_LIST_KEY = ["repos", "list"] as const;
export function useRepositories() {
return useQuery({
queryKey: REPO_LIST_KEY,
queryFn: () => listRepositories({ per_page: 200 }),
});
}
export function useRepository(name: string) {
return useQuery({
queryKey: REPO_KEY(name),
queryFn: () => getRepository(name),
});
}
export function useCreateRepository() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateRepoRequest) => createRepository(body),
onSuccess: () => qc.invalidateQueries({ queryKey: REPO_LIST_KEY }),
});
}
export function useUpdateRepository(name: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpdateRepoRequest) => updateRepository(name, body),
onSuccess: (updated) => {
qc.setQueryData(REPO_KEY(name), updated);
if (updated.name && updated.name !== name) {
qc.invalidateQueries({ queryKey: REPO_LIST_KEY });
}
},
});
}
export function useDeleteRepository() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => deleteRepository(name),
onSuccess: () => qc.invalidateQueries({ queryKey: REPO_LIST_KEY }),
});
}
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { KeyRound, ShieldAlert } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyButton } from "@/components/common/copy-button";
import { EmptyState } from "@/components/common/empty-state";
import { Spinner } from "@/components/common/spinner";
import { useAuthStore } from "@/stores/auth";
import { useRepoContext } from "../repo-context";
import { useArchitectures, useComponents, useDistributions } from "../repo-detail-hooks";
export function AptConfig() {
const { repo } = useRepoContext();
const dists = useDistributions(repo.name);
const [dist, setDist] = useState("");
const [useMyToken, setUseMyToken] = useState(false);
const sessionToken = useAuthStore((s) => s.token);
useEffect(() => {
if (!dist && dists.data && dists.data.length > 0) setDist(dists.data[0].name);
}, [dists.data, dist]);
const comps = useComponents(repo.name, dist);
const arches = useArchitectures(repo.name, dist);
const isPrivate = repo.visibility === "private";
const origin = window.location.origin;
const signedBy = `/usr/share/keyrings/urapt-${repo.name}.gpg`;
const block = useMemo(() => {
if (!dist) return "";
const compList = (comps.data ?? []).map((c) => c.name).join(" ") || "main";
const archStr = (arches.data ?? []).map((a) => a.name).join(",");
const lines: string[] = [];
lines.push("# 1. Install the repository signing key:");
lines.push(
`curl -fsSL ${origin}/api/v1/server/pubkey | sudo gpg --dearmor -o ${signedBy}`,
);
lines.push("");
lines.push("# 2. Add the repository to apt:");
const archPart = archStr ? `arch=${archStr} ` : "";
const debLine = `deb [${archPart}signed-by=${signedBy}] ${origin}/apt/${repo.name}/ ${dist} ${compList}`;
lines.push(`echo '${debLine}' | sudo tee /etc/apt/sources.list.d/${repo.name}.list`);
lines.push("");
lines.push("# 3. Update apt:");
lines.push("sudo apt update");
if (isPrivate) {
const host = hostOf(origin);
const password = useMyToken && sessionToken ? sessionToken : "<your-api-token>";
lines.push("");
lines.push("# 4. This repository is private. Configure apt credentials:");
lines.push(`sudo tee /etc/apt/auth.conf.d/${repo.name}.conf <<EOF`);
lines.push(`machine ${host}`);
lines.push(`login ${"<your-username>"}`);
lines.push(`password ${password}`);
lines.push("EOF");
}
return lines.join("\n");
}, [
dist,
comps.data,
arches.data,
origin,
signedBy,
repo.name,
isPrivate,
useMyToken,
sessionToken,
]);
if (dists.isLoading) {
return (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if ((dists.data ?? []).length === 0) {
return (
<EmptyState
icon={<KeyRound className="size-8" />}
title="No distributions yet"
description="Create a distribution on the Distributions tab to generate apt config."
/>
);
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Run these commands on a Debian/Ubuntu client to install packages from this
repository.
</p>
<div className="flex items-center gap-2">
<Label className="text-muted-foreground">Distribution</Label>
<Select value={dist} onValueChange={setDist}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{dists.data!.map((d) => (
<SelectItem key={d.id} value={d.name}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isPrivate ? (
<Alert>
<ShieldAlert className="size-4" />
<AlertTitle>Private repository</AlertTitle>
<AlertDescription className="space-y-2">
<p>
Clients must authenticate with an API token. Create one on the{" "}
<Link to="/tokens" className="underline">
Tokens
</Link>{" "}
page and use it as the password.
</p>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={useMyToken}
onChange={(e) => setUseMyToken(e.target.checked)}
/>
Insert my current session token as the password
</label>
{useMyToken ? (
<p className="text-xs text-destructive">
Warning: this is your login session token. Anyone with it has full account
access. Prefer a dedicated token.
</p>
) : null}
</AlertDescription>
</Alert>
) : null}
<div className="relative rounded-lg border bg-card p-4">
<div className="absolute right-3 top-3">
<CopyButton value={block} />
</div>
<pre className="overflow-x-auto whitespace-pre pr-20 font-mono text-xs leading-relaxed">
{block}
</pre>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/tokens">
<KeyRound className="size-4" />
Manage tokens
</Link>
</Button>
</div>
);
}
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { AccessBadge } from "@/components/common/badges";
import { ApiError } from "@/lib/api";
import type { Access } from "@/lib/api/types";
import { formatDate } from "@/lib/utils";
import { useRepoContext } from "../repo-context";
import {
useAddMember,
useMembers,
useRemoveMember,
useUpdateMember,
} from "../repo-detail-hooks";
const ACCESS_OPTIONS: Access[] = ["read", "write", "read-write", "admin"];
export function Members() {
const { repo, canManage } = useRepoContext();
const { data, isLoading, isError, error } = useMembers(repo.name);
const [addOpen, setAddOpen] = useState(false);
const [removeTarget, setRemoveTarget] = useState<string | null>(null);
const remove = useRemoveMember(repo.name);
const update = useUpdateMember(repo.name);
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return <ErrorState error={error} />;
}
const members = data ?? [];
async function doRemove(username: string) {
try {
await remove.mutateAsync(username);
toast.success(`Removed ${username}`);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to remove member");
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Members can read or push packages based on their access level.
</p>
{canManage ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
<UserPlus className="size-4" />
Add member
</Button>
) : null}
</div>
{members.length === 0 && !repo.owner ? (
<EmptyState title="No members" description="Add a member to grant access." />
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Access</TableHead>
<TableHead>Added</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{repo.owner ? (
<TableRow>
<TableCell className="font-medium">
{repo.owner.username}
<span className="ml-2 text-xs text-muted-foreground">(owner)</span>
</TableCell>
<TableCell>
<AccessBadge access="admin" />
</TableCell>
<TableCell className="text-muted-foreground">—</TableCell>
<TableCell />
</TableRow>
) : null}
{members.map((m) => (
<TableRow key={m.user_id}>
<TableCell className="font-medium">
{m.user?.username ?? m.user_id}
</TableCell>
<TableCell>
{canManage ? (
<AccessSelect
value={m.access}
onChange={(access) =>
onChangeAccess(m.user?.username ?? "", access)
}
/>
) : (
<AccessBadge access={m.access} />
)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(m.created_at)}
</TableCell>
<TableCell>
{canManage ? (
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => setRemoveTarget(m.user?.username ?? "")}
>
Remove
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{canManage ? <AddMemberDialog open={addOpen} onOpenChange={setAddOpen} /> : null}
<ConfirmDialog
open={!!removeTarget}
onOpenChange={(o) => !o && setRemoveTarget(null)}
title="Remove member?"
description={`Remove ${removeTarget} from this repository? They will lose access immediately.`}
confirmLabel="Remove"
destructive
onConfirm={() => {
if (removeTarget) void doRemove(removeTarget);
}}
/>
</div>
);
async function onChangeAccess(username: string, access: Access) {
try {
await update.mutateAsync({ username, body: { access } });
toast.success(`Updated ${username} to ${access}`);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to update member");
}
}
}
function AccessSelect({
value,
onChange,
}: {
value: Access;
onChange: (a: Access) => void;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as Access)}>
<SelectTrigger className="h-8 w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_OPTIONS.map((a) => (
<SelectItem key={a} value={a}>
{a}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function AddMemberDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const { repo } = useRepoContext();
const add = useAddMember(repo.name);
const [username, setUsername] = useState("");
const [access, setAccess] = useState<Access>("read");
useEffect(() => {
if (open) {
setUsername("");
setAccess("read");
}
}, [open]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!username.trim()) return;
try {
await add.mutateAsync({ username: username.trim(), access });
toast.success(`Added ${username} as ${access}`);
onOpenChange(false);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to add member");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add member</DialogTitle>
<DialogDescription>Grant a user access to this repository.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="member-username">Username</Label>
<Input
id="member-username"
autoCapitalize="none"
spellCheck={false}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="member-access">Access</Label>
<Select value={access} onValueChange={(v) => setAccess(v as Access)}>
<SelectTrigger id="member-access">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">read — pull packages</SelectItem>
<SelectItem value="write">write — push packages</SelectItem>
<SelectItem value="read-write">read-write — pull & push</SelectItem>
<SelectItem value="admin">admin — manage repo</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={add.isPending || !username.trim()}>
{add.isPending ? <Spinner className="size-4" /> : null}
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,419 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Download, Package as PackageIcon, Trash2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { ApiError, getPackageFile } from "@/lib/api";
import type { Package } from "@/lib/api/types";
import { formatBytes, formatDate } from "@/lib/utils";
import { useRepoContext } from "../repo-context";
import {
useComponents,
useDeletePackage,
useDistributions,
usePackages,
usePushPackage,
} from "../repo-detail-hooks";
export function Packages() {
const { repo, canWrite } = useRepoContext();
const dists = useDistributions(repo.name);
const [dist, setDist] = useState<string>("");
const [uploadOpen, setUploadOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Package | null>(null);
const [detail, setDetail] = useState<Package | null>(null);
const delPkg = useDeletePackage(repo.name);
// Auto-select the first distribution once loaded.
useEffect(() => {
if (!dist && dists.data && dists.data.length > 0) {
setDist(dists.data[0].name);
}
}, [dists.data, dist]);
const packages = usePackages(repo.name, dist);
async function doDelete() {
if (!deleteTarget) return;
try {
await delPkg.mutateAsync({ id: deleteTarget.id, dist });
toast.success(`Deleted ${deleteTarget.name} ${deleteTarget.version}`);
setDeleteTarget(null);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to delete package");
}
}
async function download(pkg: Package) {
try {
const blob = await getPackageFile(repo.name, pkg.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = pkg.filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Download failed");
}
}
if (dists.isLoading) {
return (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (dists.isError) {
return <ErrorState error={dists.error} />;
}
if ((dists.data ?? []).length === 0) {
return (
<EmptyState
icon={<PackageIcon className="size-8" />}
title="No distributions yet"
description="Create a distribution on the Distributions tab before pushing packages."
/>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Label className="text-muted-foreground">Distribution</Label>
<Select value={dist} onValueChange={setDist}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{dists.data!.map((d) => (
<SelectItem key={d.id} value={d.name}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{canWrite && dist ? (
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload className="size-4" />
Upload package
</Button>
) : null}
</div>
{!dist ? null : packages.isLoading ? (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
) : packages.isError ? (
<ErrorState error={packages.error} />
) : (packages.data?.items ?? []).length === 0 ? (
<EmptyState
icon={<PackageIcon className="size-8" />}
title="No packages"
description={`No .deb packages in ${dist} yet.`}
action={
canWrite ? (
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload className="size-4" />
Upload package
</Button>
) : undefined
}
/>
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Arch</TableHead>
<TableHead>Size</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{packages.data!.items.map((pkg) => (
<TableRow
key={pkg.id}
className="cursor-pointer"
onClick={() => setDetail(pkg)}
>
<TableCell className="font-mono font-medium">{pkg.name}</TableCell>
<TableCell className="font-mono text-xs">{pkg.version}</TableCell>
<TableCell className="font-mono text-xs">{pkg.architecture}</TableCell>
<TableCell className="text-muted-foreground">
{formatBytes(pkg.size)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(pkg.created_at)}
</TableCell>
<TableCell>
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="size-8"
title="Download .deb"
onClick={() => download(pkg)}
>
<Download className="size-4" />
</Button>
{canWrite ? (
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive"
title="Delete"
onClick={() => setDeleteTarget(pkg)}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{canWrite && dist ? (
<UploadDialog dist={dist} open={uploadOpen} onOpenChange={setUploadOpen} />
) : null}
<PackageDetailDialog pkg={detail} onClose={() => setDetail(null)} />
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title="Delete package?"
description={
deleteTarget
? `Delete ${deleteTarget.name} ${deleteTarget.version} ${deleteTarget.architecture}?`
: ""
}
confirmLabel="Delete"
destructive
onConfirm={doDelete}
/>
</div>
);
}
function UploadDialog({
dist,
open,
onOpenChange,
}: {
dist: string;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const { repo } = useRepoContext();
const comps = useComponents(repo.name, dist);
const push = usePushPackage(repo.name, dist);
const [file, setFile] = useState<File | null>(null);
const [component, setComponent] = useState("");
useEffect(() => {
if (open) {
setFile(null);
setComponent("");
}
}, [open]);
// Auto-pick the first component if only one exists.
useEffect(() => {
if (open && !component && comps.data && comps.data.length === 1) {
setComponent(comps.data[0].name);
}
}, [open, comps.data, component]);
const noComponents = (comps.data ?? []).length === 0;
const canSubmit = !!file && !!component && !push.isPending;
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!file || !component) return;
try {
const pkg = await push.mutateAsync({ file, component });
toast.success(`Uploaded ${pkg.name} ${pkg.version}`);
onOpenChange(false);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Upload failed");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload package to {dist}</DialogTitle>
<DialogDescription>
Select a <code>.deb</code> file and the target component.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pkg-file">Package file</Label>
<Input
id="pkg-file"
type="file"
accept=".deb,application/vnd.debian.binary-package"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
{file ? (
<p className="text-xs text-muted-foreground">
{file.name} ({formatBytes(file.size)})
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="pkg-component">Component</Label>
{noComponents ? (
<p className="text-sm text-muted-foreground">
No components in {dist}. Add one on the Distributions tab first.
</p>
) : (
<Select value={component} onValueChange={setComponent}>
<SelectTrigger id="pkg-component">
<SelectValue placeholder="Select component…" />
</SelectTrigger>
<SelectContent>
{comps.data!.map((c) => (
<SelectItem key={c.id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{push.isPending ? <Spinner className="size-4" /> : null}
Upload
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function PackageDetailDialog({
pkg,
onClose,
}: {
pkg: Package | null;
onClose: () => void;
}) {
const fields = useMemo(() => {
if (!pkg) return [];
const entries: [string, string][] = [
["Name", pkg.name],
["Version", pkg.version],
["Architecture", pkg.architecture],
["Component", pkg.component_id],
["Source", pkg.source ?? ""],
["Maintainer", pkg.maintainer ?? ""],
["Priority", pkg.priority ?? ""],
["Section", pkg.section ?? ""],
["Origin", pkg.origin ?? ""],
["Homepage", pkg.homepage ?? ""],
["Installed-Size", pkg.installed_size ? String(pkg.installed_size) : ""],
["Depends", pkg.depends ?? ""],
["Pre-Depends", pkg.pre_depends ?? ""],
["Recommends", pkg.recommends ?? ""],
["Suggests", pkg.suggests ?? ""],
["Conflicts", pkg.conflicts ?? ""],
["Breaks", pkg.breaks ?? ""],
["Provides", pkg.provides ?? ""],
["Replaces", pkg.replaces ?? ""],
["Enhances", pkg.enhances ?? ""],
];
return entries.filter(([, v]) => v);
}, [pkg]);
return (
<Dialog open={!!pkg} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="font-mono">
{pkg?.name} {pkg?.version}
</DialogTitle>
<DialogDescription>Package control metadata</DialogDescription>
</DialogHeader>
{pkg ? (
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
<div className="space-y-1 rounded-md border p-3 text-sm">
{fields.map(([k, v]) => (
<div key={k} className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">{k}</span>
<span className="break-all font-mono text-xs">{v}</span>
</div>
))}
</div>
<div className="space-y-1 rounded-md border p-3 text-sm">
<div className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">SHA256</span>
<span className="break-all font-mono text-xs">{pkg.sha256}</span>
</div>
<div className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">Size</span>
<span className="font-mono text-xs">{formatBytes(pkg.size)}</span>
</div>
<div className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">Filename</span>
<span className="break-all font-mono text-xs">{pkg.filename}</span>
</div>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/common/spinner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { CopyButton } from "@/components/common/copy-button";
import { ApiError } from "@/lib/api";
import type { Visibility } from "@/lib/api/types";
import { formatDate } from "@/lib/utils";
import { useRepoContext } from "../repo-context";
import { useUpdateRepository, useDeleteRepository } from "../repos-hooks";
type DeleteHook = ReturnType<typeof useDeleteRepository>;
export function RepoOverview({ onDelete }: { onDelete: DeleteHook }) {
const { repo, canManage } = useRepoContext();
const navigate = useNavigate();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
async function doDelete() {
try {
await onDelete.mutateAsync(repo.name);
toast.success("Repository deleted");
navigate("/");
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to delete repository",
);
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Details</CardTitle>
{canManage ? (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
Edit
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row label="Name" value={repo.name} mono />
<Row label="Owner" value={repo.owner?.username ?? "—"} />
<Row label="Visibility" value={repo.visibility} />
<div className="flex items-start justify-between gap-4">
<span className="text-muted-foreground">Description</span>
<span className="max-w-[24rem] text-right">{repo.description || "—"}</span>
</div>
<Row label="Repository ID" value={repo.id} mono />
<Row label="Created" value={formatDate(repo.created_at)} />
<Row label="Updated" value={formatDate(repo.updated_at)} />
</CardContent>
</Card>
{canManage ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive">Danger zone</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Deleting this repository removes all its distributions, packages, and
members. This cannot be undone.
</p>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete repository
</Button>
</CardContent>
</Card>
) : null}
<EditDialog open={editOpen} onOpenChange={setEditOpen} />
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={`Delete ${repo.name}?`}
description="This permanently deletes the repository and all packages within it."
confirmLabel="Delete"
destructive
onConfirm={doDelete}
/>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className={mono ? "font-mono text-xs" : ""}>{value}</span>
{mono ? <CopyButton value={value} size="icon" /> : null}
</div>
</div>
);
}
function EditDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const { repo } = useRepoContext();
const update = useUpdateRepository(repo.name);
const navigate = useNavigate();
const [name, setName] = useState(repo.name);
const [visibility, setVisibility] = useState<Visibility>(repo.visibility);
const [description, setDescription] = useState(repo.description);
useEffect(() => {
if (open) {
setName(repo.name);
setVisibility(repo.visibility);
setDescription(repo.description);
}
}, [open, repo]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const updated = await update.mutateAsync({
name: name !== repo.name ? name : undefined,
visibility: visibility !== repo.visibility ? visibility : undefined,
description: description !== repo.description ? description : undefined,
});
toast.success("Repository updated");
onOpenChange(false);
if (updated.name && updated.name !== repo.name) {
navigate(`/repos/${updated.name}`);
}
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to update repository",
);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit repository</DialogTitle>
<DialogDescription>Update repository settings.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoCapitalize="none"
spellCheck={false}
/>
{name !== repo.name ? (
<p className="text-xs text-muted-foreground">
Renaming changes the apt URL. Existing clients will need reconfiguration.
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="edit-visibility">Visibility</Label>
<Select
value={visibility}
onValueChange={(v) => setVisibility(v as Visibility)}
>
<SelectTrigger id="edit-visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private — members only</SelectItem>
<SelectItem value="public">Public — anonymous reads</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-desc">Description</Label>
<Input
id="edit-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={280}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={update.isPending}>
{update.isPending ? <Spinner className="size-4" /> : null}
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,325 @@
import { useState } from "react";
import { toast } from "sonner";
import { Layers, Plus, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { ApiError } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useRepoContext } from "../repo-context";
import {
useArchitectures,
useComponents,
useCreateArchitecture,
useCreateComponent,
useCreateDistribution,
useDeleteArchitecture,
useDeleteComponent,
useDeleteDistribution,
useDistributions,
} from "../repo-detail-hooks";
export function Structure() {
const { repo, canWrite } = useRepoContext();
const { data, isLoading, isError, error } = useDistributions(repo.name);
const createDist = useCreateDistribution(repo.name);
const deleteDist = useDeleteDistribution(repo.name);
const [newDist, setNewDist] = useState("");
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
async function addDist(e: React.FormEvent) {
e.preventDefault();
const name = newDist.trim();
if (!name) return;
try {
await createDist.mutateAsync({ name });
setNewDist("");
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to create distribution",
);
}
}
async function doDeleteDist() {
if (!deleteTarget) return;
try {
await deleteDist.mutateAsync(deleteTarget);
toast.success(`Deleted distribution ${deleteTarget}`);
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to delete distribution",
);
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return <ErrorState error={error} />;
}
const dists = data ?? [];
return (
<div className="space-y-6">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Distributions (suites) group packages by release, with components and
architectures.
</p>
{canWrite ? (
<form onSubmit={addDist} className="flex items-center gap-2">
<Input
placeholder="e.g. stable"
value={newDist}
onChange={(e) => setNewDist(e.target.value)}
autoCapitalize="none"
spellCheck={false}
className="max-w-xs"
/>
<Button
type="submit"
size="sm"
disabled={createDist.isPending || !newDist.trim()}
>
<Plus className="size-4" />
Add distribution
</Button>
</form>
) : null}
</div>
{dists.length === 0 ? (
<EmptyState
icon={<Layers className="size-8" />}
title="No distributions yet"
description="Add a distribution like 'stable' to start organizing packages."
/>
) : (
<div className="space-y-4">
{dists.map((dist) => (
<DistributionCard
key={dist.id}
name={dist.name}
canWrite={canWrite}
onDelete={() => setDeleteTarget(dist.name)}
/>
))}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title="Delete distribution?"
description={`Delete ${deleteTarget} and all of its components, architectures, and packages?`}
confirmLabel="Delete"
destructive
onConfirm={doDeleteDist}
/>
</div>
);
}
function DistributionCard({
name,
canWrite,
onDelete,
}: {
name: string;
canWrite: boolean;
onDelete: () => void;
}) {
const { repo } = useRepoContext();
const comps = useComponents(repo.name, name);
const arches = useArchitectures(repo.name, name);
const createComp = useCreateComponent(repo.name, name);
const deleteComp = useDeleteComponent(repo.name, name);
const createArch = useCreateArchitecture(repo.name, name);
const deleteArch = useDeleteArchitecture(repo.name, name);
const [newComp, setNewComp] = useState("");
const [newArch, setNewArch] = useState("");
async function addComp(e: React.FormEvent) {
e.preventDefault();
const v = newComp.trim();
if (!v) return;
try {
await createComp.mutateAsync({ name: v });
setNewComp("");
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to add component");
}
}
async function addArch(e: React.FormEvent) {
e.preventDefault();
const v = newArch.trim();
if (!v) return;
try {
await createArch.mutateAsync({ name: v });
setNewArch("");
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to add architecture",
);
}
}
async function delComp(c: string) {
try {
await deleteComp.mutateAsync(c);
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to delete component",
);
}
}
async function delArch(a: string) {
try {
await deleteArch.mutateAsync(a);
} catch (err) {
toast.error(
ApiError.isApiError(err) ? err.display() : "Failed to delete architecture",
);
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="font-mono">{name}</CardTitle>
{canWrite ? (
<Button
variant="ghost"
size="icon"
className="text-destructive"
title="Delete distribution"
onClick={onDelete}
>
<Trash2 className="size-4" />
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Components</p>
<ChipList
items={(comps.data ?? []).map((c) => c.name)}
onRemove={canWrite ? delComp : undefined}
emptyText="No components"
/>
{canWrite ? (
<form onSubmit={addComp} className="flex items-center gap-2">
<Input
placeholder="e.g. main"
value={newComp}
onChange={(e) => setNewComp(e.target.value)}
autoCapitalize="none"
spellCheck={false}
className="h-8 max-w-[12rem]"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={!newComp.trim()}
>
<Plus className="size-3.5" /> Add
</Button>
</form>
) : null}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Architectures</p>
<ChipList
items={(arches.data ?? []).map((a) => a.name)}
extras={["all"]}
onRemove={canWrite ? delArch : undefined}
emptyText="No architectures"
/>
{canWrite ? (
<form onSubmit={addArch} className="flex items-center gap-2">
<Input
placeholder="e.g. amd64"
value={newArch}
onChange={(e) => setNewArch(e.target.value)}
autoCapitalize="none"
spellCheck={false}
className="h-8 max-w-[12rem]"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={!newArch.trim()}
>
<Plus className="size-3.5" /> Add
</Button>
</form>
) : null}
<p className="text-xs text-muted-foreground">
<code>all</code> is implicit and available to every distribution.
</p>
</div>
</CardContent>
</Card>
);
}
function ChipList({
items,
extras = [],
onRemove,
emptyText,
}: {
items: string[];
extras?: string[];
onRemove?: (item: string) => void;
emptyText: string;
}) {
if (items.length === 0 && extras.length === 0) {
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
}
return (
<div className="flex flex-wrap gap-2">
{extras.map((e) => (
<Badge key={e} variant="secondary" className="font-mono">
{e}
</Badge>
))}
{items.map((item) => (
<Badge
key={item}
variant="outline"
className={cn("gap-1 font-mono", onRemove && "pr-1")}
>
{item}
{onRemove ? (
<button
type="button"
className="rounded-sm hover:bg-destructive/20"
onClick={() => onRemove(item)}
aria-label={`Remove ${item}`}
>
<X className="size-3" />
</button>
) : null}
</Badge>
))}
</div>
);
}
@@ -0,0 +1,115 @@
import { Download } from "lucide-react";
import { toast } from "sonner";
import { PageHeader } from "@/components/common/page-header";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { CopyButton } from "@/components/common/copy-button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ApiError, getServerPubkey } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import { useMe, useServerInfo } from "@/hooks/use-server";
import { useLogout } from "@/hooks/use-logout";
export function Settings() {
const signOut = useLogout();
const token = useAuthStore((s) => s.token);
const me = useMe(!!token);
const serverInfo = useServerInfo();
async function downloadPubkey() {
try {
const armored = await getServerPubkey();
const blob = new Blob([armored], { type: "application/pgp-keys" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "urapt-signing-key.asc";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to fetch pubkey");
}
}
if (me.isLoading) {
return (
<div className="flex justify-center py-16">
<Spinner className="size-6" />
</div>
);
}
if (me.isError) {
return <ErrorState error={me.error} />;
}
const user = me.data;
const info = serverInfo.data;
return (
<div className="space-y-6">
<PageHeader title="Settings" description="Your account and this urapt instance" />
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row label="Username" value={user?.username ?? "—"} />
<Row label="Role" value={user?.is_admin ? "Server admin" : "Member"} />
<Row label="User ID" value={user?.id ?? "—"} mono />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Instance</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row label="Version" value={info ? `urapt v${info.version}` : "—"} />
<Row
label="Registration"
value={info ? (info.open_registration ? "Open" : "Closed") : "—"}
/>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-muted-foreground">Signing key fingerprint</p>
<p className="break-all font-mono text-xs">
{info?.default_key_fingerprint || "—"}
</p>
</div>
{info?.default_key_fingerprint ? (
<CopyButton value={info.default_key_fingerprint} />
) : null}
</div>
<div className="pt-1">
<Button variant="outline" onClick={downloadPubkey}>
<Download className="size-4" />
Download public key
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={signOut}>
Sign out
</Button>
</CardContent>
</Card>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">{label}</span>
<span className={mono ? "font-mono text-xs" : ""}>{value}</span>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AuthLayout } from "@/features/auth/auth-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/common/spinner";
import { ApiError, register } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
export function FirstRun() {
const navigate = useNavigate();
const qc = useQueryClient();
const setSession = useAuthStore((s) => s.setSession);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const tooShort = password.length > 0 && password.length < 8;
const mismatch = confirm.length > 0 && password !== confirm;
const canSubmit =
username.trim().length >= 3 && password.length >= 8 && password === confirm && !busy;
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setBusy(true);
try {
const res = await register({ username: username.trim(), password });
setSession(res.token, res.user);
qc.setQueryData(["me"], res.user);
qc.invalidateQueries({ queryKey: ["server-info"] });
toast.success("Admin account created. Welcome to urapt.");
navigate("/");
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Registration failed");
} finally {
setBusy(false);
}
}
return (
<AuthLayout
title="Set up urapt"
description="Create the first admin account for this instance"
>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Admin username</Label>
<Input
id="username"
autoCapitalize="none"
spellCheck={false}
autoComplete="username"
placeholder="admin"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={32}
/>
<p className="text-xs text-muted-foreground">
Lowercase letters, digits, <code>-</code> and <code>_</code>. 3–32 chars.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
{tooShort ? (
<p className="text-xs text-destructive">At least 8 characters.</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
{mismatch ? (
<p className="text-xs text-destructive">Passwords don't match.</p>
) : null}
</div>
<Button type="submit" className="w-full" disabled={!canSubmit}>
{busy ? <Spinner className="size-4" /> : null}
Create admin account
</Button>
</form>
</AuthLayout>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { KeyRound, Plus } from "lucide-react";
import { PageHeader } from "@/components/common/page-header";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { CopyButton } from "@/components/common/copy-button";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { ApiError } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { useCreateToken, useRevokeToken, useTokens } from "./tokens-hooks";
export function Tokens() {
const { data, isLoading, isError, error } = useTokens();
const revoke = useRevokeToken();
const [createOpen, setCreateOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(
null,
);
async function doRevoke() {
if (!revokeTarget) return;
try {
await revoke.mutateAsync(revokeTarget.id);
toast.success(`Revoked ${revokeTarget.name}`);
setRevokeTarget(null);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to revoke token");
}
}
const tokens = data?.items ?? [];
return (
<div className="space-y-6">
<PageHeader
title="API tokens"
description="Tokens authenticate the urapt CLI and private-repo apt clients"
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New token
</Button>
}
/>
<Alert>
<KeyRound className="size-4" />
<AlertTitle>Treat tokens like passwords</AlertTitle>
<AlertDescription>
A token grants full access to your account. Use it as the password in apt's
<code> auth.conf</code> for private repositories, or with{" "}
<code>urapt login</code>.
</AlertDescription>
</Alert>
{isLoading ? (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
) : isError ? (
<ErrorState error={error} />
) : tokens.length === 0 ? (
<EmptyState
icon={<KeyRound className="size-8" />}
title="No tokens"
description="Create a token to use the urapt CLI or authenticate apt clients."
action={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New token
</Button>
}
/>
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Last used</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{t.prefix}…
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(t.last_used_at)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(t.created_at)}
</TableCell>
<TableCell>
{t.revoked_at ? (
<Badge variant="outline" className="text-muted-foreground">
revoked
</Badge>
) : (
<Badge variant="secondary">active</Badge>
)}
</TableCell>
<TableCell>
{!t.revoked_at ? (
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => setRevokeTarget({ id: t.id, name: t.name })}
>
Revoke
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CreateTokenDialog open={createOpen} onOpenChange={setCreateOpen} />
<ConfirmDialog
open={!!revokeTarget}
onOpenChange={(o) => !o && setRevokeTarget(null)}
title="Revoke token?"
description={`Revoke "${revokeTarget?.name}"? Any client using it will lose access immediately.`}
confirmLabel="Revoke"
destructive
onConfirm={doRevoke}
/>
</div>
);
}
function CreateTokenDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const create = useCreateToken();
const [name, setName] = useState("");
const [createdToken, setCreatedToken] = useState<string | null>(null);
useEffect(() => {
if (open) {
setName("");
setCreatedToken(null);
}
}, [open]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
try {
const token = await create.mutateAsync({ name: name.trim() });
setCreatedToken(token.token ?? null);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to create token");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{createdToken ? (
<div className="space-y-4">
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy this token now. It won't be shown again.
</DialogDescription>
</DialogHeader>
<Alert variant="destructive">
<AlertTitle>Store it safely</AlertTitle>
<AlertDescription>
The server stores only a hash of the token. You can't recover it later.
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 rounded-md border bg-muted p-3">
<code className="flex-1 break-all font-mono text-xs">{createdToken}</code>
<CopyButton value={createdToken} />
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={onSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>New API token</DialogTitle>
<DialogDescription>
Name it so you can recognize it later.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="token-name">Name</Label>
<Input
id="token-name"
placeholder="e.g. ci-builder"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
maxLength={64}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? <Spinner className="size-4" /> : null}
Create
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createToken, listTokens, revokeToken } from "@/lib/api";
import type { CreateTokenRequest } from "@/lib/api/types";
export const TOKENS_KEY = ["me", "tokens"] as const;
export function useTokens() {
return useQuery({ queryKey: TOKENS_KEY, queryFn: listTokens });
}
export function useCreateToken() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateTokenRequest) => createToken(body),
onSuccess: () => qc.invalidateQueries({ queryKey: TOKENS_KEY }),
});
}
export function useRevokeToken() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => revokeToken(id),
onSuccess: () => qc.invalidateQueries({ queryKey: TOKENS_KEY }),
});
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ShieldCheck, Trash2 } from "lucide-react";
import { PageHeader } from "@/components/common/page-header";
import { EmptyState } from "@/components/common/empty-state";
import { ErrorState } from "@/components/common/error-state";
import { Spinner } from "@/components/common/spinner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ApiError, deleteUser, listUsers, updateUser } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import { useMe } from "@/hooks/use-server";
import { formatDate } from "@/lib/utils";
const USERS_KEY = ["users"] as const;
export function Users() {
const { data, isLoading, isError, error } = useQuery({
queryKey: USERS_KEY,
queryFn: () => listUsers({ per_page: 500 }),
});
const qc = useQueryClient();
const me = useMe(!!useAuthStore.getState().token);
const myId = me.data?.id;
const toggleAdmin = useMutation({
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) =>
updateUser(id, { is_admin: isAdmin }),
onSuccess: () => qc.invalidateQueries({ queryKey: USERS_KEY }),
});
const remove = useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
qc.invalidateQueries({ queryKey: ["me"] });
},
});
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
username: string;
} | null>(null);
const users = data?.items ?? [];
async function doDelete() {
if (!deleteTarget) return;
try {
await remove.mutateAsync(deleteTarget.id);
toast.success(`Deleted user ${deleteTarget.username}`);
setDeleteTarget(null);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to delete user");
}
}
async function onToggle(id: string, isAdmin: boolean, username: string) {
try {
await toggleAdmin.mutateAsync({ id, isAdmin });
toast.success(`${username} is ${isAdmin ? "now an admin" : "no longer an admin"}`);
} catch (err) {
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to update user");
}
}
return (
<div className="space-y-6">
<PageHeader
title="Users"
description="All accounts on this urapt instance (server admin only)"
/>
{isLoading ? (
<div className="flex justify-center py-12">
<Spinner className="size-6" />
</div>
) : isError ? (
<ErrorState error={error} />
) : users.length === 0 ? (
<EmptyState title="No users" />
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
<TableHead>Admin</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => {
const isSelf = u.id === myId;
return (
<TableRow key={u.id}>
<TableCell className="font-medium">
{u.username}
{isSelf ? (
<span className="ml-2 text-xs text-muted-foreground">(you)</span>
) : null}
</TableCell>
<TableCell>
{u.is_admin ? (
<Badge variant="secondary">
<ShieldCheck className="mr-1 size-3" />
admin
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
member
</Badge>
)}
</TableCell>
<TableCell>
<Switch
checked={u.is_admin}
disabled={isSelf || toggleAdmin.isPending}
onCheckedChange={(v) => onToggle(u.id, v, u.username)}
/>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(u.created_at)}
</TableCell>
<TableCell>
{!isSelf ? (
<Button
variant="ghost"
size="icon"
className="text-destructive"
title="Delete user"
onClick={() =>
setDeleteTarget({ id: u.id, username: u.username })
}
>
<Trash2 className="size-4" />
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title="Delete user?"
description={`Delete ${deleteTarget?.username}? Their API tokens are revoked and they lose access to all repositories.`}
confirmLabel="Delete"
destructive
onConfirm={doDelete}
/>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { logout } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import { ME_KEY, SERVER_INFO_KEY } from "@/hooks/use-server";
/** Shared sign-out handler: revokes server-side token, clears local state. */
export function useLogout() {
const navigate = useNavigate();
const qc = useQueryClient();
const clear = useAuthStore((s) => s.clear);
return async function signOut() {
try {
await logout().catch(() => {
// Best-effort: clear locally even if the server call fails.
});
} finally {
clear();
qc.setQueryData(ME_KEY, null);
qc.removeQueries({ queryKey: ["repos"] });
qc.removeQueries({ queryKey: SERVER_INFO_KEY });
toast.success("Signed out");
navigate("/login");
}
};
}
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { getMe, getServerInfo } from "@/lib/api";
export const SERVER_INFO_KEY = ["server-info"] as const;
export const ME_KEY = ["me"] as const;
export function useServerInfo() {
return useQuery({ queryKey: SERVER_INFO_KEY, queryFn: getServerInfo });
}
/** Current user. Pass `enabled` based on whether a token exists. */
export function useMe(enabled: boolean) {
return useQuery({ queryKey: ME_KEY, queryFn: getMe, enabled, retry: false });
}
+62
View File
@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
+147
View File
@@ -0,0 +1,147 @@
import { ApiError, NETWORK_ERROR, toApiError } from "./errors";
const DEFAULT_BASE = "/api/v1";
export function getApiBase(): string {
const fromEnv = import.meta.env.VITE_API_BASE_URL as string | undefined;
return (fromEnv && fromEnv.trim()) || DEFAULT_BASE;
}
type TokenGetter = () => string | null;
type UnauthorizedHandler = () => void;
let tokenGetter: TokenGetter = () => null;
let onUnauthorized: UnauthorizedHandler = () => {};
/** Wire the auth store's token into the client (called once at boot). */
export function configureAuth(getter: TokenGetter, handler: UnauthorizedHandler) {
tokenGetter = getter;
onUnauthorized = handler;
}
function authHeaders(): Record<string, string> {
const token = tokenGetter();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function buildUrl(path: string, params?: Record<string, unknown>): string {
const base = getApiBase().replace(/\/$/, "");
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
if (!params) return url;
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
search.append(k, String(v));
}
const qs = search.toString();
return qs ? `${url}?${qs}` : url;
}
async function handle<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as T;
if (res.status === 401) {
// Defer to avoid throwing during render of a reactively-cleared store.
try {
onUnauthorized();
} catch {
// ignore handler errors
}
}
if (!res.ok) throw await toApiError(res);
if (res.status === 204 || res.status === 205) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
export const http = {
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
let res: Response;
try {
res = await fetch(buildUrl(path, params), {
headers: { Accept: "application/json", ...authHeaders() },
});
} catch {
throw NETWORK_ERROR;
}
return handle<T>(res);
},
async sendJson<T>(
method: "POST" | "PATCH" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
let res: Response;
try {
res = await fetch(buildUrl(path), {
method,
headers: {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...authHeaders(),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw NETWORK_ERROR;
}
return handle<T>(res);
},
async post<T>(path: string, body?: unknown): Promise<T> {
return this.sendJson<T>("POST", path, body);
},
async patch<T>(path: string, body?: unknown): Promise<T> {
return this.sendJson<T>("PATCH", path, body);
},
async put<T>(path: string, body?: unknown): Promise<T> {
return this.sendJson<T>("PUT", path, body);
},
async del<T>(path: string): Promise<T> {
return this.sendJson<T>("DELETE", path);
},
/** Multipart upload (file + optional form fields). Returns parsed JSON. */
async postForm<T>(
path: string,
fields: Record<string, string>,
file: File,
fileField = "file",
): Promise<T> {
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, v);
form.append(fileField, file);
let res: Response;
try {
res = await fetch(buildUrl(path), {
method: "POST",
headers: { Accept: "application/json", ...authHeaders() },
body: form,
});
} catch {
throw NETWORK_ERROR;
}
return handle<T>(res);
},
/** Fetch a non-JSON resource (text, blob) with auth headers attached. */
async raw(path: string, init?: RequestInit): Promise<Response> {
let res: Response;
try {
res = await fetch(buildUrl(path), {
...init,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
});
} catch {
throw NETWORK_ERROR;
}
if (!res.ok) throw await toApiError(res);
return res;
},
};
export { ApiError };
+190
View File
@@ -0,0 +1,190 @@
import { http } from "./client";
import type {
APIToken,
AddMemberRequest,
Architecture,
AuthResponse,
Component,
CreateNamedRequest,
CreateRepoRequest,
CreateTokenRequest,
Distribution,
ListParams,
ListResponse,
LoginRequest,
Package,
RegisterRequest,
Repository,
RepositoryMember,
ServerInfo,
UpdateMemberRequest,
UpdateRepoRequest,
UpdateUserRequest,
User,
} from "./types";
// --- server / setup ---
export const getServerInfo = () => http.get<ServerInfo>("/server/info");
export const getServerPubkey = () =>
http
.raw("/server/pubkey", { headers: { Accept: "application/pgp-keys" } })
.then((r) => r.text());
// --- auth ---
export const register = (body: RegisterRequest) =>
http.post<AuthResponse>("/auth/register", body);
export const login = (body: LoginRequest) => http.post<AuthResponse>("/auth/login", body);
export const logout = () => http.post<void>("/auth/logout");
export const getMe = () => http.get<User>("/me");
// --- tokens ---
export const listTokens = () => http.get<ListResponse<APIToken>>("/me/tokens");
export const createToken = (body: CreateTokenRequest) =>
http.post<APIToken>("/me/tokens", body);
export const revokeToken = (id: string) => http.del<void>(`/me/tokens/${id}`);
// --- repositories ---
export const listRepositories = (params?: ListParams) =>
http.get<ListResponse<Repository>>("/repositories", params);
export const createRepository = (body: CreateRepoRequest) =>
http.post<Repository>("/repositories", body);
export const getRepository = (name: string) =>
http.get<Repository>(`/repositories/${encodeURIComponent(name)}`);
export const updateRepository = (name: string, body: UpdateRepoRequest) =>
http.patch<Repository>(`/repositories/${encodeURIComponent(name)}`, body);
export const deleteRepository = (name: string) =>
http.del<void>(`/repositories/${encodeURIComponent(name)}`);
export const getRepoPubkey = (name: string) =>
http
.raw(`/repositories/${encodeURIComponent(name)}/pubkey`, {
headers: { Accept: "application/pgp-keys" },
})
.then((r) => r.text());
// --- members ---
export const listMembers = (repo: string) =>
http.get<RepositoryMember[]>(`/repositories/${encodeURIComponent(repo)}/members`);
export const addMember = (repo: string, body: AddMemberRequest) =>
http.post<RepositoryMember>(`/repositories/${encodeURIComponent(repo)}/members`, body);
export const updateMember = (repo: string, username: string, body: UpdateMemberRequest) =>
http.patch<RepositoryMember>(
`/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
body,
);
export const removeMember = (repo: string, username: string) =>
http.del<void>(
`/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
);
// --- distributions ---
export const listDistributions = (repo: string) =>
http.get<Distribution[]>(`/repositories/${encodeURIComponent(repo)}/distributions`);
export const createDistribution = (repo: string, body: CreateNamedRequest) =>
http.post<Distribution>(
`/repositories/${encodeURIComponent(repo)}/distributions`,
body,
);
export const deleteDistribution = (repo: string, dist: string) =>
http.del<void>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}`,
);
// --- components ---
export const listComponents = (repo: string, dist: string) =>
http.get<Component[]>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
);
export const createComponent = (repo: string, dist: string, body: CreateNamedRequest) =>
http.post<Component>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
body,
);
export const deleteComponent = (repo: string, dist: string, comp: string) =>
http.del<void>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components/${encodeURIComponent(comp)}`,
);
// --- architectures ---
export const listArchitectures = (repo: string, dist: string) =>
http.get<Architecture[]>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
);
export const createArchitecture = (
repo: string,
dist: string,
body: CreateNamedRequest,
) =>
http.post<Architecture>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
body,
);
export const deleteArchitecture = (repo: string, dist: string, arch: string) =>
http.del<void>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures/${encodeURIComponent(arch)}`,
);
// --- packages ---
export const listPackages = (repo: string, dist: string, params?: ListParams) =>
http.get<ListResponse<Package>>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
params,
);
export const pushPackage = (repo: string, dist: string, file: File, component: string) =>
http.postForm<Package>(
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
{ component },
file,
);
export const getPackage = (repo: string, id: string) =>
http.get<Package>(`/repositories/${encodeURIComponent(repo)}/packages/${id}`);
export const getPackageFile = (repo: string, id: string) =>
http
.raw(`/repositories/${encodeURIComponent(repo)}/packages/${id}/file`)
.then((r) => r.blob());
export const deletePackage = (repo: string, id: string) =>
http.del<void>(`/repositories/${encodeURIComponent(repo)}/packages/${id}`);
// --- users (admin) ---
export const listUsers = (params?: ListParams) =>
http.get<ListResponse<User>>("/users", params);
export const getUser = (id: string) => http.get<User>(`/users/${id}`);
export const updateUser = (id: string, body: UpdateUserRequest) =>
http.patch<User>(`/users/${id}`, body);
export const deleteUser = (id: string) => http.del<void>(`/users/${id}`);
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { toApiError, ApiError } from "./errors";
function mockRes(body: unknown, status: number, statusText = ""): Response {
return {
status,
statusText,
ok: status >= 200 && status < 300,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
} as Response;
}
describe("toApiError", () => {
it("parses a uniform error envelope", async () => {
const err = await toApiError(
mockRes({ error: { code: "not_found", message: "no such repo" } }, 404),
);
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(err.code).toBe("not_found");
expect(err.message).toBe("no such repo");
});
it("falls back to statusText for non-JSON bodies", async () => {
const res = {
...mockRes({}, 500, "Internal Server Error"),
json: () => Promise.reject(new Error("bad")),
};
const err = await toApiError(res as Response);
expect(err.status).toBe(500);
expect(err.code).toBe("internal");
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { ApiErrorBody, ErrorCode } from "./types";
/**
* ApiError wraps a uniform urapt error response:
* {"error":{"code":"...","message":"...","details":...}}
*/
export class ApiError extends Error {
readonly status: number;
readonly code: ErrorCode | string;
readonly details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.details = details;
}
static isApiError(e: unknown): e is ApiError {
return e instanceof ApiError;
}
/** A human-friendly message suitable for toast/UI display. */
display(): string {
return this.message || this.code || "request failed";
}
}
/** Parse a Response into an ApiError, falling back to a network error. */
export async function toApiError(res: Response): Promise<ApiError> {
let body: ApiErrorBody | undefined;
try {
body = (await res.json()) as ApiErrorBody;
} catch {
// non-JSON response
}
if (body?.error) {
return new ApiError(
res.status,
body.error.code,
body.error.message,
body.error.details,
);
}
return new ApiError(res.status, "internal", res.statusText || "request failed");
}
export const NETWORK_ERROR = new ApiError(0, "internal", "network error");
+4
View File
@@ -0,0 +1,4 @@
export * from "./types";
export * from "./errors";
export { http, getApiBase, configureAuth, ApiError } from "./client";
export * from "./endpoints";
+204
View File
@@ -0,0 +1,204 @@
// DTOs mirroring urapt's shared/models and shared/api packages.
// Field names match the server's JSON tags exactly.
export type Visibility = "public" | "private";
export type Access = "read" | "write" | "read-write" | "admin";
export interface User {
id: string;
username: string;
is_admin: boolean;
created_at: string;
updated_at: string;
}
export interface APIToken {
id: string;
user_id: string;
name: string;
prefix: string;
/** Plaintext token. Only present on creation/login. */
token?: string;
created_at: string;
last_used_at?: string | null;
revoked_at?: string | null;
}
export interface Repository {
id: string;
name: string;
owner_user_id: string;
owner?: User;
visibility: Visibility;
description: string;
created_at: string;
updated_at: string;
}
export interface RepositoryMember {
repository_id: string;
user_id: string;
user?: User;
access: Access;
created_at: string;
}
export interface Distribution {
id: string;
repository_id: string;
name: string;
created_at: string;
}
export interface Component {
id: string;
distribution_id: string;
name: string;
created_at: string;
}
export interface Architecture {
id: string;
distribution_id: string;
name: string;
created_at: string;
}
export interface Package {
id: string;
repository_id: string;
distribution_id: string;
component_id: string;
name: string;
version: string;
architecture: string;
source?: string;
maintainer?: string;
priority?: string;
section?: string;
origin?: string;
homepage?: string;
description?: string;
description_md5?: string;
depends?: string;
pre_depends?: string;
recommends?: string;
suggests?: string;
conflicts?: string;
breaks?: string;
provides?: string;
replaces?: string;
enhances?: string;
installed_size?: number;
essential?: string;
built_using?: string;
tag?: string;
raw_control: string;
filename: string;
pool_path: string;
size: number;
md5sum: string;
sha1: string;
sha256: string;
uploaded_by_user_id: string;
created_at: string;
}
// --- server / setup ---
export interface ServerInfo {
version: string;
needs_setup: boolean;
default_key_fingerprint: string;
open_registration: boolean;
}
// --- auth DTOs ---
export interface RegisterRequest {
username: string;
password: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface AuthResponse {
user: User;
token: string;
}
export interface CreateTokenRequest {
name: string;
}
// --- repository DTOs ---
export interface CreateRepoRequest {
name: string;
visibility: Visibility;
description?: string;
}
export interface UpdateRepoRequest {
name?: string;
visibility?: Visibility;
description?: string;
}
export interface AddMemberRequest {
username: string;
access: Access;
}
export interface UpdateMemberRequest {
access: Access;
}
// --- structure DTOs ---
export interface CreateNamedRequest {
name: string;
}
// --- users (admin) ---
export interface UpdateUserRequest {
is_admin?: boolean;
}
// --- list envelope ---
export interface ListResponse<T> {
items: T[];
page: number;
per_page: number;
total: number;
}
export interface ListParams extends Record<string, unknown> {
page?: number;
per_page?: number;
}
// --- error envelope ---
export type ErrorCode =
| "bad_request"
| "unauthorized"
| "forbidden"
| "not_found"
| "conflict"
| "payload_too_large"
| "internal";
export interface ApiErrorBody {
error: {
code: ErrorCode | string;
message: string;
details?: unknown;
};
}
+34
View File
@@ -0,0 +1,34 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
return Promise.reject(new Error("clipboard unavailable"));
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+47
View File
@@ -0,0 +1,47 @@
import { useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { configureAuth } from "@/lib/api";
import { getToken, useAuthStore } from "@/stores/auth";
/** Wire the auth store's token + 401 handler into the API client once. */
configureAuth(getToken, () => {
// A 401 means the token is invalid/expired. Clear local session; the root
// guard will redirect to /login on the next render cycle.
useAuthStore.getState().clear();
});
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Don't retry on auth errors; the user must re-authenticate.
const status = (error as { status?: number })?.status;
if (status === 401 || status === 403 || status === 404) return false;
return failureCount < 2;
},
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryClientProvider client={client}>
{children}
<Toaster richColors position="top-right" />
</QueryClientProvider>
</ThemeProvider>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { lazy, Suspense } from "react";
import { createBrowserRouter } from "react-router-dom";
import { RootGuard } from "@/components/common/root-guard";
import { AdminRoute } from "@/components/common/admin-route";
import { NotFound } from "@/components/common/not-found";
import { FullPageSpinner } from "@/components/common/spinner";
import { ReposList } from "@/features/repos/ReposList";
const RepoDetail = lazy(() =>
import("@/features/repos/RepoDetail").then((m) => ({ default: m.RepoDetail })),
);
const Tokens = lazy(() =>
import("@/features/tokens/Tokens").then((m) => ({ default: m.Tokens })),
);
const Users = lazy(() =>
import("@/features/users/Users").then((m) => ({ default: m.Users })),
);
const Settings = lazy(() =>
import("@/features/settings/Settings").then((m) => ({ default: m.Settings })),
);
const Login = lazy(() =>
import("@/features/auth/Login").then((m) => ({ default: m.Login })),
);
const FirstRun = lazy(() =>
import("@/features/setup/FirstRun").then((m) => ({ default: m.FirstRun })),
);
const fallback = <FullPageSpinner />;
export const router = createBrowserRouter([
{
path: "/login",
element: (
<Suspense fallback={fallback}>
<Login />
</Suspense>
),
},
{
path: "/setup",
element: (
<Suspense fallback={fallback}>
<FirstRun />
</Suspense>
),
},
{
path: "/",
element: <RootGuard />,
children: [
{ index: true, element: <ReposList /> },
{
path: "repos/:repo",
element: (
<Suspense fallback={fallback}>
<RepoDetail />
</Suspense>
),
},
{
path: "repos/:repo/:tab",
element: (
<Suspense fallback={fallback}>
<RepoDetail />
</Suspense>
),
},
{
path: "tokens",
element: (
<Suspense fallback={fallback}>
<Tokens />
</Suspense>
),
},
{
path: "users",
element: (
<AdminRoute>
<Suspense fallback={fallback}>
<Users />
</Suspense>
</AdminRoute>
),
},
{
path: "settings",
element: (
<Suspense fallback={fallback}>
<Settings />
</Suspense>
),
},
{ path: "*", element: <NotFound /> },
],
},
]);
+35
View File
@@ -0,0 +1,35 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "@/lib/api/types";
const STORAGE_KEY = "urapt.auth";
interface AuthState {
token: string | null;
user: User | null;
setSession: (token: string, user: User) => void;
setUser: (user: User | null) => void;
clear: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setSession: (token, user) => set({ token, user }),
setUser: (user) => set({ user }),
clear: () => set({ token: null, user: null }),
}),
{
name: STORAGE_KEY,
// Only persist the token; user is re-fetched on boot via GET /me.
partialize: (s) => ({ token: s.token }) as AuthState,
},
),
);
/** Synchronous token getter for the API client (avoids React render cycles). */
export function getToken(): string | null {
return useAuthStore.getState().token;
}
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vitest" />
+69
View File
@@ -0,0 +1,69 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"exactOptionalPropertyTypes": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const SERVER_TARGET = process.env.URAPT_SERVER ?? "http://localhost:8080";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
// Same-origin dev: forward API + APT endpoints to a local urapt-server.
"/api": { target: SERVER_TARGET, changeOrigin: true },
"/apt": { target: SERVER_TARGET, changeOrigin: true },
},
},
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks: {
"react-vendor": ["react", "react-dom", "react-router-dom"],
"query-vendor": ["@tanstack/react-query"],
"radix-vendor": [
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-select",
"@radix-ui/react-tabs",
"@radix-ui/react-toast",
"@radix-ui/react-tooltip",
],
},
},
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
},
});