Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@app/admin",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Adminpanel (spec §57)",
"scripts": {
"dev": "vite --port 5173",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.0.0"
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { BRAND, hasToken, setToken } from "./api.js";
import { LoginPage } from "./pages/Login.js";
import { DashboardPage } from "./pages/Dashboard.js";
import { UsersPage } from "./pages/Users.js";
import { ModerationPage } from "./pages/Moderation.js";
import { FlagsPage } from "./pages/Flags.js";
import { SubscriptionsPage } from "./pages/Subscriptions.js";
import { AuditLogsPage } from "./pages/AuditLogs.js";
export function App() {
const [authed, setAuthed] = useState(hasToken());
if (!authed) {
return <LoginPage onLogin={() => setAuthed(true)} />;
}
return (
<div className="layout">
<nav className="sidebar">
<h1>{BRAND.name} Admin</h1>
<NavLink to="/" end>
Översikt
</NavLink>
<NavLink to="/users">Användare</NavLink>
<NavLink to="/moderation">Moderering</NavLink>
<NavLink to="/flags">Feature flags</NavLink>
<NavLink to="/subscriptions">Prenumerationer</NavLink>
<NavLink to="/audit">Audit logs</NavLink>
<button
onClick={() => {
setToken(null);
setAuthed(false);
}}
>
Logga ut
</button>
</nav>
<main className="main">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/moderation" element={<ModerationPage />} />
<Route path="/flags" element={<FlagsPage />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/audit" element={<AuditLogsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import brand from "../../../brand.config.json";
export const BRAND = brand as { name: string; slug: string };
const ADMIN_TOKEN_KEY = `${BRAND.slug}_admin_token`;
/** Enkel API-klient för adminpanelen. Token hålls i minne + sessionStorage. */
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:4000";
let accessToken: string | null = sessionStorage.getItem(ADMIN_TOKEN_KEY);
export function setToken(token: string | null): void {
accessToken = token;
if (token) sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
else sessionStorage.removeItem(ADMIN_TOKEN_KEY);
}
export function hasToken(): boolean {
return accessToken != null;
}
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) {
super(message);
}
}
export async function api<T = unknown>(
path: string,
options: { method?: string; body?: unknown } = {},
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: options.method ?? "GET",
headers: {
"content-type": "application/json",
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
},
body: options.body != null ? JSON.stringify(options.body) : undefined,
});
const json = (await res.json().catch(() => ({}))) as {
error?: { code?: string; message?: string };
} & T;
if (!res.ok) {
if (res.status === 401) setToken(null);
throw new ApiRequestError(
res.status,
json.error?.code ?? "UNKNOWN",
json.error?.message ?? `HTTP ${res.status}`,
);
}
return json as T;
}
export interface LoginResult {
totpRequired: boolean;
preAuthToken?: string;
}
export async function login(email: string, password: string): Promise<LoginResult> {
const result = await api<{
accessToken?: string;
user?: { role: string };
totpRequired?: boolean;
preAuthToken?: string;
}>("/v1/auth/login", { method: "POST", body: { email, password } });
if (result.totpRequired && result.preAuthToken) {
return { totpRequired: true, preAuthToken: result.preAuthToken };
}
if (!result.user || !result.accessToken) {
throw new ApiRequestError(500, "INTERNAL", "Oväntat svar från inloggningen.");
}
if (result.user.role !== "admin" && result.user.role !== "moderator") {
throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet.");
}
setToken(result.accessToken);
return { totpRequired: false };
}
/** Steg 2 av inloggningen: engångskoden från autentiseringsappen. */
export async function verifyTotp(preAuthToken: string, code: string): Promise<void> {
const result = await api<{ accessToken: string; user: { role: string } }>(
"/v1/auth/totp-verify",
{ method: "POST", body: { preAuthToken, code } },
);
if (result.user.role !== "admin" && result.user.role !== "moderator") {
throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet.");
}
setToken(result.accessToken);
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import "./styles.css";
import { BRAND } from "./api.js";
document.title = `${BRAND.name} Admin`;
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface AuditLog {
id: string;
actorUserId: string | null;
actorType: string;
action: string;
targetType: string | null;
targetId: string | null;
createdAt: string;
}
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [filter, setFilter] = useState("");
const [error, setError] = useState<string | null>(null);
const load = (action = "") => {
api<{ logs: AuditLog[] }>(`/admin/v1/audit-logs?action=${encodeURIComponent(action)}`)
.then((r) => setLogs(r.logs))
.catch((e) => setError(String(e.message)));
};
useEffect(() => load(), []);
return (
<div>
<h2>Audit logs</h2>
<div className="row" style={{ marginBottom: "1rem" }}>
<input
placeholder="Filtrera på action …"
value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load(filter)}
/>
<button className="ghost" onClick={() => load(filter)}>
Filtrera
</button>
</div>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Tid</th>
<th>Aktör</th>
<th>Typ</th>
<th>Action</th>
<th>Mål</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{new Date(log.createdAt).toLocaleString("sv-SE")}</td>
<td>
<code>{log.actorUserId?.slice(0, 8) ?? "system"}</code>
</td>
<td>{log.actorType}</td>
<td>
<code>{log.action}</code>
</td>
<td>{log.targetType ? `${log.targetType}:${log.targetId?.slice(0, 8)}` : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Health {
checks: Record<string, { ok: boolean; detail?: string }>;
}
interface JobsOverview {
scanJobs: Array<{ status: string; count: number }>;
queue: Record<string, number>;
outboxPending: number;
}
export function DashboardPage() {
const [health, setHealth] = useState<Health | null>(null);
const [jobs, setJobs] = useState<JobsOverview | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = () => {
api<Health>("/admin/v1/system/health")
.then(setHealth)
.catch((e) => setError(String(e.message)));
api<JobsOverview>("/admin/v1/jobs/overview")
.then(setJobs)
.catch(() => undefined);
};
load();
const timer = setInterval(load, 15_000);
return () => clearInterval(timer);
}, []);
return (
<div>
<h2>Systemöversikt</h2>
{error && <div className="error-text">{error}</div>}
<div className="stat-grid">
{health &&
Object.entries(health.checks).map(([name, check]) => (
<div className="stat" key={name}>
<div className="value">
<span className={`badge ${check.ok ? "ok" : "err"}`}>
{check.ok ? "OK" : "FEL"}
</span>
</div>
<div className="label">
{name} {check.detail ? ` ${check.detail}` : ""}
</div>
</div>
))}
{jobs && (
<div className="stat">
<div className="value">{jobs.outboxPending}</div>
<div className="label">Outbox väntar</div>
</div>
)}
</div>
{jobs && (
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h3>Jobbköer</h3>
<table>
<thead>
<tr>
<th>-status</th>
<th>Antal</th>
</tr>
</thead>
<tbody>
{Object.entries(jobs.queue).map(([status, count]) => (
<tr key={status}>
<td>{status}</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
<h3>Skanningsjobb</h3>
<table>
<thead>
<tr>
<th>Status</th>
<th>Antal</th>
</tr>
</thead>
<tbody>
{jobs.scanJobs.map((row) => (
<tr key={row.status}>
<td>{row.status}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Flag {
key: string;
enabled: boolean;
rolloutPercent: number;
descriptionSv: string | null;
}
export function FlagsPage() {
const [flags, setFlags] = useState<Flag[]>([]);
const [error, setError] = useState<string | null>(null);
const load = () => {
api<{ flags: Flag[] }>("/admin/v1/flags")
.then((r) => setFlags(r.flags))
.catch((e) => setError(String(e.message)));
};
useEffect(load, []);
const toggle = async (flag: Flag) => {
await api(`/admin/v1/flags/${flag.key}`, {
method: "PUT",
body: {
enabled: !flag.enabled,
rolloutPercent: flag.rolloutPercent,
descriptionSv: flag.descriptionSv ?? undefined,
},
});
load();
};
const setRollout = async (flag: Flag, percent: number) => {
await api(`/admin/v1/flags/${flag.key}`, {
method: "PUT",
body: {
enabled: flag.enabled,
rolloutPercent: percent,
descriptionSv: flag.descriptionSv ?? undefined,
},
});
load();
};
return (
<div>
<h2>Feature flags</h2>
<p className="muted">
Launch Advanced-funktioner rullas ut gradvis härifrån (spec §1, Del 3).
</p>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Flagga</th>
<th>Beskrivning</th>
<th>Rollout</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{flags.map((flag) => (
<tr key={flag.key}>
<td>
<code>{flag.key}</code>
</td>
<td>{flag.descriptionSv}</td>
<td>
<select
value={flag.rolloutPercent}
onChange={(e) => void setRollout(flag, Number(e.target.value))}
>
{[0, 5, 10, 25, 50, 75, 100].map((p) => (
<option key={p} value={p}>
{p} %
</option>
))}
</select>
</td>
<td>
<span className={`badge ${flag.enabled ? "ok" : ""}`}>
{flag.enabled ? "PÅ" : "AV"}
</span>
</td>
<td>
<button className="ghost" onClick={() => void toggle(flag)}>
{flag.enabled ? "Stäng av" : "Slå på"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, type FormEvent } from "react";
import { BRAND, login, verifyTotp } from "../api.js";
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// 2FA-steget: sätts när kontot har TOTP aktiverat.
const [preAuthToken, setPreAuthToken] = useState<string | null>(null);
const [code, setCode] = useState("");
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const result = await login(email, password);
if (result.totpRequired && result.preAuthToken) {
setPreAuthToken(result.preAuthToken);
} else {
onLogin();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Inloggningen misslyckades.");
} finally {
setBusy(false);
}
};
const submitCode = async (e: FormEvent) => {
e.preventDefault();
if (!preAuthToken) return;
setBusy(true);
setError(null);
try {
await verifyTotp(preAuthToken, code);
onLogin();
} catch (err) {
setError(err instanceof Error ? err.message : "Fel engångskod.");
} finally {
setBusy(false);
}
};
if (preAuthToken) {
return (
<div className="login-wrap">
<form className="login-box" onSubmit={submitCode}>
<h1>Tvåfaktorsautentisering</h1>
<p className="muted">Ange den sexsiffriga koden från din autentiseringsapp.</p>
<input
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
autoFocus
required
/>
{error && <div className="error-text">{error}</div>}
<button className="primary" disabled={busy || code.length !== 6}>
{busy ? "Verifierar …" : "Verifiera"}
</button>
<button
type="button"
className="ghost"
onClick={() => {
setPreAuthToken(null);
setCode("");
setError(null);
}}
>
Avbryt
</button>
</form>
</div>
);
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={submit}>
<h1>{BRAND.name} Admin</h1>
<input
type="email"
placeholder="E-post"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Lösenord"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <div className="error-text">{error}</div>}
<button className="primary" disabled={busy}>
{busy ? "Loggar in …" : "Logga in"}
</button>
<div className="muted">Kräver konto med admin-roll.</div>
</form>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface PendingRecipe {
id: string;
titleSv: string;
descriptionSv: string;
status: string;
creatorDisplayName: string | null;
moderationNote: string | null;
createdAt: string;
}
export function ModerationPage() {
const [recipes, setRecipes] = useState<PendingRecipe[]>([]);
const [error, setError] = useState<string | null>(null);
const load = () => {
api<{ recipes: PendingRecipe[] }>("/admin/v1/moderation/recipes")
.then((r) => setRecipes(r.recipes))
.catch((e) => setError(String(e.message)));
};
useEffect(load, []);
const act = async (id: string, action: "approve" | "reject" | "request_changes") => {
const note =
action === "approve" ? undefined : (prompt("Motivering till skaparen:") ?? undefined);
await api(`/admin/v1/moderation/recipes/${id}`, { method: "POST", body: { action, note } });
load();
};
return (
<div>
<h2>Receptmoderering</h2>
<p className="muted">
Flöde enligt spec §35: submission AI-kontroll dubblettkontroll moderering
publicering.
</p>
{error && <div className="error-text">{error}</div>}
{recipes.length === 0 && <div className="panel">Inga recept väntar granskning. 🎉</div>}
{recipes.map((recipe) => (
<div className="panel" key={recipe.id}>
<div className="row" style={{ justifyContent: "space-between" }}>
<div>
<strong>{recipe.titleSv}</strong> <span className="badge">{recipe.status}</span>
<div className="muted">
av {recipe.creatorDisplayName ?? "okänd"} ·{" "}
{new Date(recipe.createdAt).toLocaleString("sv-SE")}
</div>
</div>
<div className="row">
<button className="primary" onClick={() => act(recipe.id, "approve")}>
Godkänn
</button>
<button className="ghost" onClick={() => act(recipe.id, "request_changes")}>
Begär ändringar
</button>
<button className="ghost danger" onClick={() => act(recipe.id, "reject")}>
Avslå
</button>
</div>
</div>
<p>{recipe.descriptionSv}</p>
{recipe.moderationNote && (
<div className="muted">AI-kontroll & dubbletter: {recipe.moderationNote}</div>
)}
</div>
))}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface Sub {
id: string;
userId: string;
provider: string;
plan: string;
status: string;
expiresAt: string | null;
lastVerifiedAt: string | null;
}
export function SubscriptionsPage() {
const [subs, setSubs] = useState<Sub[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<{ subscriptions: Sub[] }>("/admin/v1/subscriptions")
.then((r) => setSubs(r.subscriptions))
.catch((e) => setError(String(e.message)));
}, []);
const statusBadge = (status: string) =>
status === "active" || status === "trial" ? "ok" : status === "in_grace" ? "warn" : "err";
return (
<div>
<h2>Prenumerationer</h2>
<p className="muted">
Backend är source of truth (spec §47). Promo-planer delas ut via API:t.
</p>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>Användare</th>
<th>Plan</th>
<th>Provider</th>
<th>Status</th>
<th>Går ut</th>
<th>Senast verifierad</th>
</tr>
</thead>
<tbody>
{subs.map((s) => (
<tr key={s.id}>
<td>
<code>{s.userId.slice(0, 8)}</code>
</td>
<td>{s.plan}</td>
<td>{s.provider}</td>
<td>
<span className={`badge ${statusBadge(s.status)}`}>{s.status}</span>
</td>
<td>{s.expiresAt ? new Date(s.expiresAt).toLocaleDateString("sv-SE") : ""}</td>
<td>
{s.lastVerifiedAt ? new Date(s.lastVerifiedAt).toLocaleString("sv-SE") : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
interface AdminUser {
id: string;
email: string;
displayName: string;
role: string;
onboardingCompleted: boolean;
createdAt: string;
deletedAt: string | null;
}
export function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const load = (q = "") => {
api<{ users: AdminUser[] }>(`/admin/v1/users?search=${encodeURIComponent(q)}`)
.then((r) => setUsers(r.users))
.catch((e) => setError(String(e.message)));
};
useEffect(() => load(), []);
return (
<div>
<h2>Användare</h2>
<div className="row" style={{ marginBottom: "1rem" }}>
<input
placeholder="Sök e-post …"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load(search)}
/>
<button className="ghost" onClick={() => load(search)}>
Sök
</button>
</div>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<table>
<thead>
<tr>
<th>E-post</th>
<th>Namn</th>
<th>Roll</th>
<th>Onboarding</th>
<th>Skapad</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.email}</td>
<td>{u.displayName}</td>
<td>
<span className={`badge ${u.role === "admin" ? "warn" : ""}`}>{u.role}</span>
</td>
<td>{u.onboardingCompleted ? "✓" : ""}</td>
<td>{new Date(u.createdAt).toLocaleDateString("sv-SE")}</td>
<td>
{u.deletedAt ? (
<span className="badge err">raderad</span>
) : (
<span className="badge ok">aktiv</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
:root {
--bg: #f7f8fa;
--panel: #ffffff;
--text: #1a202c;
--muted: #64748b;
--brand: #16803c;
--brand-dark: #11602d;
--border: #e2e8f0;
--danger: #b91c1c;
--warn: #b45309;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px;
background: #0f2417;
color: #d7e5db;
padding: 1.25rem 0.75rem;
flex-shrink: 0;
}
.sidebar h1 { font-size: 1.1rem; margin: 0 0 1.5rem 0.5rem; color: #fff; }
.sidebar a {
display: block;
padding: 0.55rem 0.75rem;
color: inherit;
text-decoration: none;
border-radius: 8px;
margin-bottom: 2px;
font-size: 0.92rem;
}
.sidebar a:hover { background: rgba(255, 255, 255, 0.08); }
.sidebar a.active { background: var(--brand); color: #fff; }
.sidebar button {
margin: 1.5rem 0.5rem 0;
background: none;
border: 1px solid rgba(255, 255, 255, 0.25);
color: inherit;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
}
.main { flex: 1; padding: 1.5rem 2rem; max-width: 1100px; }
.main h2 { margin-top: 0; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
th { text-align: left; color: var(--muted); font-weight: 600; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.badge {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: #e2e8f0;
}
.badge.ok { background: #dcfce7; color: var(--brand-dark); }
.badge.warn { background: #fef3c7; color: var(--warn); }
.badge.err { background: #fee2e2; color: var(--danger); }
button.primary {
background: var(--brand);
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
}
button.primary:hover { background: var(--brand-dark); }
button.ghost {
background: none;
border: 1px solid var(--border);
padding: 0.35rem 0.8rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
}
button.danger { border-color: #fecaca; color: var(--danger); }
input, select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
}
.login-box {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
width: 340px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.error-text { color: var(--danger); font-size: 0.85rem; }
.muted { color: var(--muted); font-size: 0.85rem; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 1rem; }
.stat .value { font-size: 1.6rem; font-weight: 700; }
.stat .label { color: var(--muted); font-size: 0.8rem; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "vite.config.ts"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
build: {
outDir: "dist",
sourcemap: true,
},
});