Patchpass V1
Deploy / Build (push) Successful in 28s
Deploy / Test & Lint (push) Failing after 29s
Deploy / Build and Push Docker Image (push) Has been skipped

This commit is contained in:
Space-Banane
2026-07-18 20:14:44 +02:00
commit 7e05dd918c
101 changed files with 15183 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
export function AgentAvatar({
name,
iconUrl,
size = 36,
}: {
name: string;
iconUrl?: string | null;
size?: number;
}) {
const [errored, setErrored] = useState(false);
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
if (iconUrl && !errored) {
return (
<img
src={iconUrl}
alt={name}
width={size}
height={size}
onError={() => setErrored(true)}
className="rounded-lg border border-border object-cover"
style={{ width: size, height: size }}
/>
);
}
return (
<div
className="flex items-center justify-center rounded-lg border border-border bg-primary-dim/40 font-semibold text-primary"
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{initials || "?"}
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { agents as agentsApi } from "../api/client";
import type { Agent } from "../api/types";
import { Modal } from "./Modal";
import { Button, Input, Textarea } from "./ui";
import { CodeBlock } from "./CodeBlock";
// ── Create / edit form ───────────────────────────────────────────────────────────
export function AgentFormModal({
open,
onClose,
agent,
onSaved,
}: {
open: boolean;
onClose: () => void;
agent?: Agent | null;
onSaved: (agent: Agent, created: boolean) => void;
}) {
const editing = !!agent;
const [name, setName] = useState(agent?.name ?? "");
const [description, setDescription] = useState(agent?.description ?? "");
const [website, setWebsite] = useState(agent?.website ?? "");
const [iconUrl, setIconUrl] = useState(agent?.icon_url ?? "");
const [maxPending, setMaxPending] = useState(agent?.max_pending_requests ?? 5);
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!name.trim()) return toast.error("Name is required");
setLoading(true);
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
website: website.trim() || null,
icon_url: iconUrl.trim() || null,
max_pending_requests: maxPending,
};
const saved = editing
? await agentsApi.update(agent!.id, payload)
: await agentsApi.create(payload);
toast.success(editing ? "Agent updated" : "Agent created");
onSaved(saved, !editing);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={editing ? "Edit agent" : "New agent"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} loading={loading}>
{editing ? "Save" : "Create agent"}
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
<Input
label="Website (optional)"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…"
/>
<Input
label="Icon URL (optional)"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://…/icon.png"
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
/>
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-muted">Max pending requests</span>
<span className="font-medium text-text">{maxPending}</span>
</div>
<input
type="range"
min={1}
max={10}
value={maxPending}
onChange={(e) => setMaxPending(Number(e.target.value))}
className="w-full accent-[var(--color-primary)]"
/>
<p className="mt-1 text-xs text-faint">
How many requests this agent may have awaiting review at once (110).
</p>
</div>
</div>
</Modal>
);
}
// ── Connect (config + key) ─────────────────────────────────────────────────────────
type Tab = "openclaw" | "mcp" | "rest";
export function ConnectModal({
open,
onClose,
agent,
revealedKey,
}: {
open: boolean;
onClose: () => void;
agent: Agent;
revealedKey?: string | null;
}) {
const [tab, setTab] = useState<Tab>("openclaw");
const origin = window.location.origin;
const key = revealedKey ?? "YOUR_API_KEY";
const hasKey = !!revealedKey;
const openclawConfig = JSON.stringify(
{
mcp: {
servers: {
patchpass: {
transport: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
},
null,
2,
);
const mcpConfig = JSON.stringify(
{
mcpServers: {
patchpass: {
type: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
null,
2,
);
const restExample = `# Submit a change request
curl -X POST ${origin}/v1/change-requests \\
-H "x-api-key: ${key}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Update deployment",
"changes": [
{ "type": "config", "path": "timeout", "before": 30, "after": 60, "content_type": "integer" }
]
}'
# Poll for the decision
curl ${origin}/v1/change-requests/{request_id} -H "x-api-key: ${key}"
# Consume the approval before proceeding
curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${key}"`;
const tabs: { id: Tab; label: string }[] = [
{ id: "openclaw", label: "OpenClaw" },
{ id: "mcp", label: "MCP (generic)" },
{ id: "rest", label: "REST API" },
];
return (
<Modal open={open} onClose={onClose} title={`Connect "${agent.name}"`} wide footer={<Button onClick={onClose}>Done</Button>}>
<div className="space-y-4">
{hasKey ? (
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
<p className="text-xs font-semibold text-pending">Save this API key now it won't be shown again.</p>
<div className="mt-2">
<CodeBlock code={revealedKey!} />
</div>
</div>
) : (
<p className="text-sm text-muted">
The API key is only shown once, at creation or after regeneration. Configs below use a
placeholder substitute your saved key.
</p>
)}
<div className="flex gap-1 border-b border-border">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-2 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "openclaw" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Add this to <code className="text-text">~/.openclaw/openclaw.json</code>, then tell your
agent to request approval via PatchPass before taking action.
</p>
<CodeBlock code={openclawConfig} language="json" />
</div>
)}
{tab === "mcp" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Generic streamable-HTTP MCP client config (Claude Desktop, Cursor, etc.).
</p>
<CodeBlock code={mcpConfig} language="json" />
</div>
)}
{tab === "rest" && (
<div className="space-y-2">
<p className="text-sm text-muted">Or call the REST API directly with the agent key.</p>
<CodeBlock code={restExample} language="bash" />
</div>
)}
<div className="rounded-lg border border-border bg-surface-raised/40 p-3 text-xs text-muted">
<p className="mb-1 font-semibold text-text">Recommended agent instruction</p>
Before taking any consequential action (deploys, config changes, code edits), call
<code className="mx-1 text-text">create_request</code>, wait for approval, then
<code className="mx-1 text-text">consume_approval</code> before proceeding.
</div>
</div>
</Modal>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Change } from "../api/types";
function DiffView({ content }: { content: string }) {
const lines = content.split("\n");
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
<code>
{lines.map((line, i) => {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "diff-meta";
else if (line.startsWith("@@")) cls = "diff-hunk";
else if (line.startsWith("+")) cls = "diff-add";
else if (line.startsWith("-")) cls = "diff-del";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
return (
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
{line || " "}
</div>
);
})}
</code>
</pre>
);
}
function fmtValue(v: unknown): string {
if (v === null || v === undefined) return "∅";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
function percentDelta(before: unknown, after: unknown): string | null {
const b = Number(before);
const a = Number(after);
if (!Number.isFinite(b) || !Number.isFinite(a) || b === 0) return null;
const pct = Math.round(((a - b) / Math.abs(b)) * 100);
if (pct === 0) return null;
return `${pct > 0 ? "+" : ""}${pct}%`;
}
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
const delta =
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
<span className="text-faint"></span>
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
{contentType && <span className="text-xs text-faint">({contentType})</span>}
</div>
);
}
function typeChip(label: string, color: string) {
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
}
export function ChangeCard({ change, index }: { change: Change; index: number }) {
return (
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-mono text-faint">#{index + 1}</span>
{change.type === "unified_diff" && (
<>
{typeChip("diff", "bg-primary-dim/40 text-primary")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "config" && (
<>
{typeChip("config", "bg-accent/15 text-accent")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="text-sm text-text">{change.label}</span>
</>
)}
</div>
{change.type === "unified_diff" && <DiffView content={change.content} />}
{change.type === "config" && (
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
)}
{change.type === "custom" && <BeforeAfter before={change.before} after={change.after} />}
</div>
);
}
export function ChangeList({ changes }: { changes: Change[] }) {
return (
<div className="space-y-3">
{changes.map((c, i) => (
<ChangeCard key={i} change={c} index={i} />
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from "react";
export function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative">
{language && (
<span className="absolute left-3 top-2 text-[10px] uppercase tracking-wide text-faint">
{language}
</span>
)}
<button
onClick={copy}
className="absolute right-2 top-2 rounded-md border border-border-strong bg-surface px-2 py-1 text-xs text-muted hover:text-text"
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className={`overflow-x-auto rounded-lg border border-border bg-bg p-3 ${language ? "pt-7" : ""} text-xs leading-relaxed text-text`}>
<code>{code}</code>
</pre>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { requests } from "../api/client";
import type { ChangeRequest } from "../api/types";
import { Modal } from "./Modal";
import { Button } from "./ui";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
const MAX = 500;
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
APPROVE: {
title: "Approve request",
verb: "Approve",
variant: "success",
blurb: "The agent will be allowed to proceed. A platform-signed receipt will be issued.",
commentRequired: false,
},
REJECT: {
title: "Reject request",
verb: "Reject",
variant: "danger",
blurb: "This is a hard blocker. The agent must submit a new request to try again.",
commentRequired: false,
},
REQUEST_CHANGES: {
title: "Request changes",
verb: "Request changes",
variant: "primary",
blurb: "The agent will see your notes and can update the request for re-review.",
commentRequired: true,
},
};
export function DecisionModal({
request,
decision,
onClose,
onDone,
}: {
request: ChangeRequest;
decision: Decision | null;
onClose: () => void;
onDone: (updated: ChangeRequest) => void;
}) {
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
setComment("");
}, [decision]);
if (!decision) return null;
const m = meta[decision];
const tooLong = comment.length > MAX;
const missingRequired = m.commentRequired && comment.trim().length === 0;
const submit = async () => {
if (tooLong || missingRequired) return;
setLoading(true);
try {
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
toast.success(`${m.verb}d`);
onDone(updated);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={!!decision}
onClose={onClose}
title={m.title}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
{m.verb}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
<p className="text-sm font-medium text-text">{request.title}</p>
<p className="mt-1 text-xs text-muted">
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
{request.agent?.name}
</p>
</div>
<p className="text-sm text-muted">{m.blurb}</p>
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm text-muted">
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
</span>
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
{comment.length}/{MAX}
</span>
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
autoFocus
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
tooLong ? "border-rejected" : "border-border-strong"
}`}
/>
{tooLong && <p className="mt-1 text-xs text-rejected">Comment is too long (max {MAX}).</p>}
{missingRequired && (
<p className="mt-1 text-xs text-changes">A comment is required when requesting changes.</p>
)}
</div>
</div>
</Modal>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { ReactNode, useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { NotificationBell } from "./NotificationBell";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/requests", label: "Requests" },
{ to: "/agents", label: "Agents" },
{ to: "/settings", label: "Settings" },
];
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="min-h-screen">
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2">
<span className="text-xl"></span>
<span className="text-lg font-bold tracking-tight">
Patch<span className="text-primary">Pass</span>
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-text" : "text-muted hover:text-text"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink
to="/admin"
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-accent" : "text-accent/70 hover:text-accent"
}`
}
>
Admin
</NavLink>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<NotificationBell />
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{user?.display_name?.[0]?.toUpperCase()}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{menuOpen && (
<div
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
onMouseLeave={() => setMenuOpen(false)}
>
<Link
to="/settings"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<Link
to="/notifications"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
onClick={() => setMenuOpen(false)}
>
Notifications
</Link>
<button
onClick={async () => {
await logout();
navigate("/login");
}}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
</div>
</div>
</div>
{/* mobile nav */}
<nav className="flex items-center gap-1 overflow-x-auto border-t border-border px-4 py-2 md:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium ${
isActive ? "bg-surface-raised text-text" : "text-muted"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink to="/admin" className="whitespace-nowrap rounded-lg px-3 py-1.5 text-sm text-accent">
Admin
</NavLink>
)}
</nav>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
<div className="flex flex-wrap items-center justify-center gap-3">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span>·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
<span>·</span>
<span>PatchPass review changes, approve intent, let agents proceed.</span>
</div>
</footer>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { ReactNode, useEffect } from "react";
export function Modal({
open,
onClose,
title,
children,
footer,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className={`animate-fade-in relative z-10 w-full ${wide ? "max-w-3xl" : "max-w-lg"} rounded-2xl border border-border-strong bg-surface shadow-2xl`}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</div>}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
import { relativeTime } from "../utils";
export function NotificationBell() {
const { items, unreadCount, markRead, markAllRead, clear } = useNotifications();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="relative rounded-lg p-2 text-muted transition hover:bg-surface-raised hover:text-text"
aria-label="Notifications"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="animate-fade-in absolute right-0 z-40 mt-2 w-80 rounded-xl border border-border-strong bg-surface shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex gap-2 text-xs">
<button className="text-muted hover:text-text" onClick={() => markAllRead()}>
Mark all read
</button>
<button className="text-muted hover:text-rejected" onClick={() => clear()}>
Clear
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted">No notifications yet</p>
)}
{items.map((n) => (
<button
key={n.id}
onClick={() => {
markRead(n.id);
setOpen(false);
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
className={`flex w-full flex-col items-start gap-0.5 border-b border-border/60 px-4 py-3 text-left transition hover:bg-surface-raised ${
n.read ? "opacity-60" : ""
}`}
>
<div className="flex w-full items-center gap-2">
{!n.read && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />}
<span className="text-sm font-medium text-text">{n.title}</span>
</div>
<span className="text-xs text-muted">{n.message}</span>
<span className="text-[11px] text-faint">{relativeTime(n.created_at)}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { RequestState } from "../api/types";
const config: Record<RequestState, { label: string; cls: string; dot: string }> = {
PENDING: { label: "Pending", cls: "bg-pending/15 text-pending border-pending/30", dot: "bg-pending" },
CHANGES_REQUESTED: {
label: "Changes requested",
cls: "bg-changes/15 text-changes border-changes/30",
dot: "bg-changes",
},
APPROVED: { label: "Approved", cls: "bg-approved/15 text-approved border-approved/30", dot: "bg-approved" },
REJECTED: { label: "Rejected", cls: "bg-rejected/15 text-rejected border-rejected/30", dot: "bg-rejected" },
EXPIRED: { label: "Expired", cls: "bg-expired/15 text-expired border-expired/30", dot: "bg-expired" },
CONSUMED: { label: "Consumed", cls: "bg-consumed/15 text-consumed border-consumed/30", dot: "bg-consumed" },
CANCELLED: { label: "Cancelled", cls: "bg-cancelled/15 text-cancelled border-cancelled/30", dot: "bg-cancelled" },
};
export function StateBadge({ state, className = "" }: { state: RequestState; className?: string }) {
const c = config[state];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${c.cls} ${className}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${c.dot}`} />
{c.label}
</span>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "danger" | "success";
const variants: Record<Variant, string> = {
primary: "bg-primary text-white hover:brightness-110 border border-transparent",
secondary: "bg-surface-raised text-text hover:bg-border border border-border-strong",
ghost: "bg-transparent text-muted hover:text-text hover:bg-surface-raised border border-transparent",
danger: "bg-rejected/90 text-white hover:bg-rejected border border-transparent",
success: "bg-approved/90 text-[#06231a] hover:bg-approved border border-transparent font-semibold",
};
export function Button({
variant = "primary",
loading,
children,
className = "",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; loading?: boolean }) {
return (
<button
{...props}
disabled={props.disabled || loading}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${className}`}
>
{loading && <Spinner className="h-4 w-4" />}
{children}
</button>
);
}
export function Spinner({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export function Card({
children,
className = "",
onClick,
}: {
children: ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div onClick={onClick} className={`rounded-xl border border-border bg-surface ${className}`}>
{children}
</div>
);
}
export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<input
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
{hint && <span className="mt-1 block text-xs text-faint">{hint}</span>}
</label>
);
}
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<textarea
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
</label>
);
}
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-3"
>
<span
className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-primary" : "bg-border-strong"}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`}
/>
</span>
{label && <span className="text-sm text-text">{label}</span>}
</button>
);
}
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-center">
{icon && <div className="mb-3 text-4xl opacity-70">{icon}</div>}
<p className="text-text font-medium">{title}</p>
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
</div>
);
}
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}