app: rebuild interface base on scificn-ui Mission Control layout (placeholder content)
CI / build + unit + e2e (pull_request) Failing after 13m21s
CI / build + unit + e2e (pull_request) Failing after 13m21s
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
|
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
|
||||||
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
||||||
- Frontend component layer: scificn-ui v0.1.0 integrated copy-paste (Radix + Tailwind v4, SCI-FI theme) — Button, Panel, Badge, Card, Alert in `components/ui/`; theme tokens in `app/globals.css` with a brightened `--text-muted` override (`#4A4A40` → `#6B6B5C`).
|
- Frontend component layer: scificn-ui v0.1.0 integrated copy-paste (Radix + Tailwind v4, SCI-FI theme) — Button, Panel, Badge, Card, Alert in `components/ui/`; theme tokens in `app/globals.css` with a brightened `--text-muted` override (`#4A4A40` → `#6B6B5C`).
|
||||||
|
- Interface base: the scificn-ui **Mission Control** showcase (https://www.scificn.dev/showcase/mission-control) rebuilt as the app's main UI — `app/page.tsx` renders `components/mission-control.tsx` (client dashboard: sticky header, stat-card row, 3-column grid of Panel/Tabs/StatusGrid/ProgressRing/BarChart/LineChart/Terminal panels, sticky footer), placeholder ship content only, no game logic; additional upstream components copied into `components/ui/` (Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart) + `@radix-ui/react-tabs` dependency.
|
||||||
- DB plumbing: SQLite via Drizzle ORM + sql.js (WASM) at `db/space-wanderer.db`, volume-mounted; empty schema placeholder — game tables land with the first game systems.
|
- DB plumbing: SQLite via Drizzle ORM + sql.js (WASM) at `db/space-wanderer.db`, volume-mounted; empty schema placeholder — game tables land with the first game systems.
|
||||||
- API pattern established: `/api/version` + OpenAPI 3.x spec generated in code (`lib/openapi.ts`) served at `/api/openapi`.
|
- API pattern established: `/api/version` + OpenAPI 3.x spec generated in code (`lib/openapi.ts`) served at `/api/openapi`.
|
||||||
- CI/e2e/docker pipeline: single "build + unit + e2e" job on the dind runner, isolated stack `space-wanderer-ci` with static IP 172.39.0.10 (fresh DB volume per run), Playwright e2e (`smoke.spec.ts`, `state.spec.ts`) CI-only against that stack; prod compose behind Traefik at `https://space-wanderer.dev.kkhost.pl` (not deployed yet).
|
- CI/e2e/docker pipeline: single "build + unit + e2e" job on the dind runner, isolated stack `space-wanderer-ci` with static IP 172.39.0.10 (fresh DB volume per run), Playwright e2e (`smoke.spec.ts`, `state.spec.ts`) CI-only against that stack; prod compose behind Traefik at `https://space-wanderer.dev.kkhost.pl` (not deployed yet).
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
|||||||
## 7. App architecture quick map
|
## 7. App architecture quick map
|
||||||
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
|
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
|
||||||
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
|
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
|
||||||
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert), theme tokens in `app/globals.css`. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
||||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
|
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
|
||||||
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
||||||
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
||||||
|
|||||||
+6
-78
@@ -1,81 +1,9 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { MissionControl } from '@/components/mission-control';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
export const metadata = {
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
title: 'SpaceWanderer',
|
||||||
import { Panel, PanelContent, PanelFooter, PanelHeader } from '@/components/ui/panel';
|
};
|
||||||
import pkg from '@/package.json';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <MissionControl />;
|
||||||
<main className="min-h-screen flex items-center justify-center p-6">
|
|
||||||
<Panel className="w-full max-w-xl" notch="md">
|
|
||||||
<PanelHeader>
|
|
||||||
<Badge variant="ACTIVE">SYS:ONLINE</Badge>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
v{pkg.version} — STACK PROOF
|
|
||||||
</span>
|
|
||||||
</PanelHeader>
|
|
||||||
<PanelContent>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: '1.6rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--color-green)',
|
|
||||||
textShadow: 'var(--text-glow-green)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SpaceWanderer
|
|
||||||
</h1>
|
|
||||||
<p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
|
||||||
Turn-based space adventure — build & expand your ship from found parts, survive the void.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Ship status</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>HULL</span>
|
|
||||||
<Badge variant="ACTIVE">NOMINAL</Badge>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>CREW</span>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>—</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>NEXT ENCOUNTER</span>
|
|
||||||
<Badge variant="SCANNING">SCANNING...</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Alert variant="STATUS" className="mt-4">
|
|
||||||
<AlertTitle>Scaffold online</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Base stack + scificn-ui frontend integrated. No game systems yet — single player only, by design.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</PanelContent>
|
|
||||||
<PanelFooter>
|
|
||||||
<Button variant="EXEC" size="SM">INITIATE</Button>
|
|
||||||
<span style={{ marginLeft: 'auto', fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
|
|
||||||
SCIFICN/UI v0.1.0 · SCI-FI THEME
|
|
||||||
</span>
|
|
||||||
</PanelFooter>
|
|
||||||
</Panel>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -119,10 +120,38 @@
|
|||||||
|
|
||||||
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
|
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||||
|
|
||||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="],
|
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="],
|
||||||
|
|||||||
@@ -0,0 +1,653 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { BarChart } from '@/components/ui/bar-chart'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { LineChart } from '@/components/ui/line-chart'
|
||||||
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
|
import { ProgressRing } from '@/components/ui/progress-ring'
|
||||||
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
|
|
||||||
|
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||||
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
|
||||||
|
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
|
||||||
|
|
||||||
|
function useNarrow(threshold = 900): boolean {
|
||||||
|
const [narrow, setNarrow] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth < threshold
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = () => setNarrow(window.innerWidth < threshold)
|
||||||
|
window.addEventListener('resize', handle, { passive: true })
|
||||||
|
return () => window.removeEventListener('resize', handle)
|
||||||
|
}, [threshold])
|
||||||
|
return narrow
|
||||||
|
}
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||||
|
|
||||||
|
const shipSystems: SystemEntry[] = [
|
||||||
|
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
|
||||||
|
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
|
||||||
|
{ name: 'NAV COMPUTER', status: 'SCANNING' },
|
||||||
|
{ name: 'WEAPONS', status: 'OFFLINE' },
|
||||||
|
{ name: 'COMMS', status: 'ACTIVE' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const bayOutput = [
|
||||||
|
{ label: 'PWR', value: 14 },
|
||||||
|
{ label: 'NAV', value: 9 },
|
||||||
|
{ label: 'COM', value: 6 },
|
||||||
|
{ label: 'WPN', value: 3 },
|
||||||
|
{ label: 'LSS', value: 12 },
|
||||||
|
{ label: 'ENG', value: 11 },
|
||||||
|
{ label: 'BAY', value: 7 },
|
||||||
|
{ label: 'MED', value: 5 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const hullTrack = [100, 98, 97, 96, 94, 95, 92, 90, 89, 87]
|
||||||
|
const hullLabels = hullTrack.map((_, i) => `T-${hullTrack.length - 1 - i}`)
|
||||||
|
|
||||||
|
const encounters = [
|
||||||
|
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||||
|
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
||||||
|
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shipData = [
|
||||||
|
{ label: 'TOTAL MODULES', value: '34' },
|
||||||
|
{ label: 'HULL INTEGRITY', value: '87%' },
|
||||||
|
{ label: 'REACTOR OUTPUT', value: '94.2%' },
|
||||||
|
{ label: 'CREW ACTIVE', value: '3 / 6' },
|
||||||
|
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
|
||||||
|
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
|
||||||
|
{ label: 'TURN', value: '12' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logLines: TerminalLine[] = [
|
||||||
|
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
||||||
|
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
||||||
|
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
||||||
|
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
||||||
|
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
||||||
|
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MissionControl() {
|
||||||
|
const narrow = useNarrow()
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--background)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── HEADER ── */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
height: '44px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 1.25rem',
|
||||||
|
gap: '0.75rem',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
◄ SHIP
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SPACEWANDERER
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{!narrow && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
// SHIP STATUS // CREW MANIFEST
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{!narrow && (
|
||||||
|
<Badge variant="ACTIVE">SHIP NOMINAL</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: tick % 2 === 0 ? 1 : 0.5,
|
||||||
|
transition: 'opacity 0.3s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── BODY ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1px',
|
||||||
|
background: 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* STAT CARDS — placeholder ship readouts */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr 1fr' : 'repeat(4, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
label="HULL INTEGRITY"
|
||||||
|
value="87%"
|
||||||
|
sublabel="NOMINAL · NO BREACHES"
|
||||||
|
variant="ACTIVE"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="REACTOR OUTPUT"
|
||||||
|
value="94.2%"
|
||||||
|
sublabel="STABLE · IDLE CYCLE"
|
||||||
|
variant="ACTIVE"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="CREW ON BOARD"
|
||||||
|
value="3 / 6"
|
||||||
|
sublabel="AWAITING MANIFEST"
|
||||||
|
variant="DEFAULT"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="NEXT ENCOUNTER"
|
||||||
|
value="T-07:14"
|
||||||
|
sublabel="SECTOR 7G // DRIFT LANE"
|
||||||
|
variant="WARNING"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-COLUMN GRID */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
||||||
|
gap: '1px',
|
||||||
|
background: 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Panel notch="sm">
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
||||||
|
</div>
|
||||||
|
</PanelHeader>
|
||||||
|
|
||||||
|
<StatusGrid systems={shipSystems} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel notch="sm">
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
|
</PanelHeader>
|
||||||
|
<PanelContent>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
paddingTop: '0.5rem',
|
||||||
|
paddingBottom: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProgressRing value={87} label="HULL" variant="ACTIVE" size={74} />
|
||||||
|
<ProgressRing value={94} label="REACTOR" variant="ACTIVE" size={74} />
|
||||||
|
<ProgressRing value={50} label="CREW / 6" variant="WARNING" size={74} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: '0.25rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'HULL INTEGRITY', note: 'last scan T-02' },
|
||||||
|
{ label: 'REACTOR OUTPUT', note: 'idle cycle' },
|
||||||
|
{ label: 'CREW ROSTER', note: 'of 6 berths' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.52rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.48rem',
|
||||||
|
color: 'var(--border)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginTop: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PanelContent>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CENTER: OPERATIONS TABS ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Panel notch="md" style={{ flex: 1 }}>
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<Badge variant="ACTIVE">SYS NOMINAL</Badge>
|
||||||
|
</div>
|
||||||
|
</PanelHeader>
|
||||||
|
|
||||||
|
<PanelContent style={{ padding: 0 }}>
|
||||||
|
<Tabs defaultValue="systems">
|
||||||
|
<TabsList
|
||||||
|
style={{ padding: '0 1rem', background: 'var(--surface-raised)' }}
|
||||||
|
>
|
||||||
|
<TabsTrigger value="systems">SYSTEMS</TabsTrigger>
|
||||||
|
<TabsTrigger value="next">NEXT MISSION</TabsTrigger>
|
||||||
|
<TabsTrigger value="log">LOG</TabsTrigger>
|
||||||
|
<TabsTrigger value="hull">HULL TRACK</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* SYSTEMS TAB */}
|
||||||
|
<TabsContent value="systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<BarChart
|
||||||
|
data={bayOutput}
|
||||||
|
orientation="vertical"
|
||||||
|
variant="ACTIVE"
|
||||||
|
title="MODULE OUTPUT BY BAY (PLACEHOLDER)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '0.625rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'TOTAL MODULES', value: 34 },
|
||||||
|
{ label: 'ONLINE', value: 29 },
|
||||||
|
{ label: 'EFFICIENCY', value: '85%' },
|
||||||
|
].map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.55rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
marginBottom: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* NEXT MISSION TAB */}
|
||||||
|
<TabsContent value="next" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<Alert variant="STATUS">
|
||||||
|
<AlertTitle>ENCOUNTER #E-102 — DERELICT FREIGHTER</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Placeholder briefing. Real encounter resolution lands with the turn engine.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : '1fr 1fr',
|
||||||
|
gap: '0.625rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
|
||||||
|
{ label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
|
||||||
|
{ label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
|
||||||
|
{ label: 'CARGO MANIFEST', value: 'UNKNOWN' },
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.55rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
marginBottom: '0.35rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* LOG TAB */}
|
||||||
|
<TabsContent value="log" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<Terminal
|
||||||
|
lines={logLines}
|
||||||
|
title="SHIP LOG // PLACEHOLDER FEED"
|
||||||
|
height="22rem"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* HULL TRACK TAB */}
|
||||||
|
<TabsContent value="hull" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<LineChart
|
||||||
|
series={[{ id: 'hull', label: 'HULL INTEGRITY %', data: hullTrack }]}
|
||||||
|
labels={hullLabels}
|
||||||
|
title="HULL INTEGRITY TRACK // PLACEHOLDER"
|
||||||
|
showArea
|
||||||
|
animated={false}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PanelContent>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Panel notch="sm">
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
|
||||||
|
</div>
|
||||||
|
</PanelHeader>
|
||||||
|
<PanelContent style={{ padding: 0 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{encounters.map((enc, i) => {
|
||||||
|
const isNext = i === 0
|
||||||
|
const isLast = i === encounters.length - 1
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={enc.id}
|
||||||
|
style={{
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
borderBottom: isLast ? 'none' : '1px solid var(--border)',
|
||||||
|
background: isNext ? 'var(--surface-raised)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enc.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.57rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enc.when} // {enc.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
|
||||||
|
{isNext ? 'NEXT' : 'SCHED'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PanelContent>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel notch="sm">
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>SHIP DATA</PanelTitle>
|
||||||
|
</PanelHeader>
|
||||||
|
<PanelContent>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{shipData.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.4rem 0',
|
||||||
|
borderBottom:
|
||||||
|
i < shipData.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PanelContent>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── FOOTER ── */}
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
minHeight: '44px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
padding: '0.5rem 1.25rem',
|
||||||
|
gap: '0.5rem',
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
INTERFACE BASE: SCIFICN MISSION CONTROL
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--border)' }}>│</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
CONTENT: PLACEHOLDER — NO GAME LOGIC YET
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--border)' }}>│</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
SINGLE PLAYER // NO AUTH
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type BarChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
|
||||||
|
export interface BarChartEntry {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
data: BarChartEntry[]
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
variant?: BarChartVariant
|
||||||
|
title?: string
|
||||||
|
showValues?: boolean
|
||||||
|
/** Override the max value for scaling. Defaults to max(data). */
|
||||||
|
max?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERTICAL_CHART_HEIGHT = 144 // px — 9rem
|
||||||
|
|
||||||
|
const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
data,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
variant = 'ACTIVE',
|
||||||
|
title,
|
||||||
|
showValues = true,
|
||||||
|
max,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const computedMax = max ?? Math.max(...data.map((d) => d.value), 1)
|
||||||
|
|
||||||
|
const accentColor =
|
||||||
|
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||||
|
'var(--text-secondary)'
|
||||||
|
|
||||||
|
const accentGlow =
|
||||||
|
variant === 'ACTIVE' ? 'var(--glow-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--glow-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--glow-red)' :
|
||||||
|
'none'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Title bar */}
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orientation === 'horizontal' ? (
|
||||||
|
/* ── Horizontal bars ── */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((entry, i) => {
|
||||||
|
const pct = Math.max((entry.value / computedMax) * 100, 0)
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
{/* Label */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
width: 'clamp(3rem, 20%, 7rem)',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Track */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '0.65rem',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: accentColor,
|
||||||
|
boxShadow: accentGlow,
|
||||||
|
opacity: 0.85,
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
{showValues && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: accentColor,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
width: '3rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Vertical bars ── */
|
||||||
|
<div>
|
||||||
|
{/* Bar area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '0.4rem',
|
||||||
|
height: VERTICAL_CHART_HEIGHT,
|
||||||
|
padding: '0.75rem 0.75rem 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((entry, i) => {
|
||||||
|
const pct = Math.max((entry.value / computedMax) * 100, 0)
|
||||||
|
const barPx = Math.round((pct / 100) * VERTICAL_CHART_HEIGHT)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: VERTICAL_CHART_HEIGHT,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Value above bar */}
|
||||||
|
{showValues && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.5rem',
|
||||||
|
color: accentColor,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: barPx > 0 ? barPx : 0,
|
||||||
|
minHeight: entry.value > 0 ? 2 : 0,
|
||||||
|
background: accentColor,
|
||||||
|
boxShadow: accentGlow,
|
||||||
|
opacity: 0.85,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'height 0.4s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.4rem',
|
||||||
|
padding: '0.25rem 0.75rem 0.75rem',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.5rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
BarChart.displayName = 'BarChart'
|
||||||
|
|
||||||
|
export { BarChart }
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type LineChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
|
||||||
|
export interface LineChartSeries {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
data: number[]
|
||||||
|
variant?: LineChartVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
series: LineChartSeries[]
|
||||||
|
labels: string[]
|
||||||
|
title?: string
|
||||||
|
height?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
showDots?: boolean
|
||||||
|
showArea?: boolean
|
||||||
|
showGrid?: boolean
|
||||||
|
showLegend?: boolean
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEFT = 40
|
||||||
|
const RIGHT = 8
|
||||||
|
const TOP = 8
|
||||||
|
const BOTTOM = 24
|
||||||
|
|
||||||
|
const SERIES_COLORS = [
|
||||||
|
'var(--color-green)',
|
||||||
|
'var(--color-amber)',
|
||||||
|
'var(--color-red)',
|
||||||
|
'var(--color-blue)',
|
||||||
|
]
|
||||||
|
|
||||||
|
const VARIANT_COLORS: Record<LineChartVariant, string> = {
|
||||||
|
DEFAULT: 'var(--text-secondary)',
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesColor(s: LineChartSeries, idx: number): string {
|
||||||
|
if (s.variant) return VARIANT_COLORS[s.variant]
|
||||||
|
return SERIES_COLORS[idx % SERIES_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
series,
|
||||||
|
labels,
|
||||||
|
title,
|
||||||
|
height = 160,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
showDots = true,
|
||||||
|
showArea = false,
|
||||||
|
showGrid = true,
|
||||||
|
showLegend,
|
||||||
|
animated = true,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [svgW, setSvgW] = React.useState(500)
|
||||||
|
const [drawn, setDrawn] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
setSvgW(entries[0].contentRect.width)
|
||||||
|
})
|
||||||
|
ro.observe(containerRef.current)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => setDrawn(true))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const allValues = series.flatMap(s => s.data)
|
||||||
|
const dataMin = allValues.length > 0 ? Math.min(...allValues) : 0
|
||||||
|
const dataMax = allValues.length > 0 ? Math.max(...allValues) : 100
|
||||||
|
const minV = min ?? dataMin
|
||||||
|
const maxV = max ?? dataMax
|
||||||
|
const range = Math.max(maxV - minV, 1)
|
||||||
|
|
||||||
|
const plotW = svgW - LEFT - RIGHT
|
||||||
|
const plotH = height - TOP - BOTTOM
|
||||||
|
const n = labels.length
|
||||||
|
|
||||||
|
function xPos(i: number) {
|
||||||
|
return LEFT + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW)
|
||||||
|
}
|
||||||
|
|
||||||
|
function yPos(v: number) {
|
||||||
|
return TOP + (1 - (v - minV) / range) * plotH
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinePath(data: number[]): string {
|
||||||
|
return data
|
||||||
|
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xPos(i).toFixed(1)} ${yPos(v).toFixed(1)}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAreaPath(data: number[]): string {
|
||||||
|
if (data.length === 0) return ''
|
||||||
|
const line = buildLinePath(data)
|
||||||
|
const firstX = xPos(0).toFixed(1)
|
||||||
|
const lastX = xPos(data.length - 1).toFixed(1)
|
||||||
|
const bottomY = (TOP + plotH).toFixed(1)
|
||||||
|
return `${line} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridFracs = [0, 1 / 3, 2 / 3, 1]
|
||||||
|
const showLeg = showLegend ?? series.length > 1
|
||||||
|
const step = Math.max(1, Math.ceil(n / 8))
|
||||||
|
|
||||||
|
const shouldAnimate = animated
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||||
|
{/* Grid */}
|
||||||
|
{showGrid && gridFracs.map((f, gi) => {
|
||||||
|
const y = TOP + f * plotH
|
||||||
|
const val = minV + (1 - f) * range
|
||||||
|
return (
|
||||||
|
<g key={gi}>
|
||||||
|
<line
|
||||||
|
x1={LEFT}
|
||||||
|
y1={y.toFixed(1)}
|
||||||
|
x2={(LEFT + plotW).toFixed(1)}
|
||||||
|
y2={y.toFixed(1)}
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={LEFT - 4}
|
||||||
|
y={y}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{val.toFixed(0)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{labels.map((label, i) => {
|
||||||
|
if (i % step !== 0 && i !== n - 1) return null
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={xPos(i)}
|
||||||
|
y={TOP + plotH + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Series */}
|
||||||
|
{series.map((s, si) => {
|
||||||
|
if (s.data.length === 0) return null
|
||||||
|
const color = seriesColor(s, si)
|
||||||
|
const linePath = buildLinePath(s.data)
|
||||||
|
const areaPath = buildAreaPath(s.data)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={s.id}>
|
||||||
|
{/* Area fill */}
|
||||||
|
{showArea && (
|
||||||
|
<path d={areaPath} fill={color} opacity={0.1} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Glow layer */}
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={6}
|
||||||
|
opacity={0.12}
|
||||||
|
pathLength={100}
|
||||||
|
strokeDasharray={100}
|
||||||
|
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
|
||||||
|
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main line */}
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
pathLength={100}
|
||||||
|
strokeDasharray={100}
|
||||||
|
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
|
||||||
|
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{showDots && s.data.map((v, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={xPos(i)}
|
||||||
|
cy={yPos(v)}
|
||||||
|
r={3}
|
||||||
|
fill={color}
|
||||||
|
opacity={shouldAnimate ? (drawn ? 1 : 0) : 1}
|
||||||
|
style={shouldAnimate ? { transition: 'opacity 0.8s ease 0.4s' } : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
{showLeg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{series.map((s, si) => {
|
||||||
|
const color = seriesColor(s, si)
|
||||||
|
return (
|
||||||
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '0.5rem',
|
||||||
|
height: '0.5rem',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LineChart.displayName = 'LineChart'
|
||||||
|
|
||||||
|
export { LineChart }
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type ProgressRingVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
|
||||||
|
export interface ProgressRingProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
label?: string
|
||||||
|
showValue?: boolean
|
||||||
|
variant?: ProgressRingVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressRing = React.forwardRef<HTMLDivElement, ProgressRingProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
size = 120,
|
||||||
|
strokeWidth = 6,
|
||||||
|
label,
|
||||||
|
showValue = true,
|
||||||
|
variant = 'DEFAULT',
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const pct = Math.min(Math.max(value, 0), 100)
|
||||||
|
const cx = size / 2
|
||||||
|
const cy = size / 2
|
||||||
|
const r = (size - strokeWidth) / 2
|
||||||
|
const circ = 2 * Math.PI * r
|
||||||
|
const offset = circ * (1 - pct / 100)
|
||||||
|
|
||||||
|
const accentColor =
|
||||||
|
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||||
|
'var(--text-secondary)'
|
||||||
|
|
||||||
|
const textGlow =
|
||||||
|
variant === 'ACTIVE' ? 'var(--text-glow-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--text-glow-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--text-glow-red)' :
|
||||||
|
'none'
|
||||||
|
|
||||||
|
const isSmall = size < 80
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
flexShrink: 0,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Track ring */}
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cy} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glow layer — wider, low opacity */}
|
||||||
|
{pct > 0 && (
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cy} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={strokeWidth + 8}
|
||||||
|
strokeDasharray={circ}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
opacity={0.12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main arc */}
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cy} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circ}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.4s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Center label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.1rem',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showValue && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: isSmall ? '0.7rem' : '1.2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: accentColor,
|
||||||
|
textShadow: textGlow,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(pct)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: isSmall ? '0.45rem' : '0.55rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: r * 1.2,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ProgressRing.displayName = 'ProgressRing'
|
||||||
|
|
||||||
|
export { ProgressRing }
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const statCardVariants = cva(
|
||||||
|
[
|
||||||
|
'relative flex flex-col font-mono',
|
||||||
|
'border border-solid rounded-none',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
DEFAULT: ['border-[var(--border)]'],
|
||||||
|
ACTIVE: ['border-[var(--color-green)]'],
|
||||||
|
WARNING: ['border-[var(--color-amber)]'],
|
||||||
|
CRITICAL: ['border-[var(--color-red)]'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'DEFAULT' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface StatCardProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof statCardVariants> {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
/** Delta text, e.g. "+2.3%" */
|
||||||
|
delta?: string
|
||||||
|
/** true = positive (▲ green), false = negative (▼ red), undefined = neutral */
|
||||||
|
deltaPositive?: boolean
|
||||||
|
/** Small status line shown at the bottom of the card */
|
||||||
|
sublabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'DEFAULT',
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
delta,
|
||||||
|
deltaPositive,
|
||||||
|
sublabel,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const accentColor =
|
||||||
|
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||||
|
'var(--text-secondary)'
|
||||||
|
|
||||||
|
const accentGlow =
|
||||||
|
variant === 'ACTIVE' ? 'var(--text-glow-green)' :
|
||||||
|
variant === 'WARNING' ? 'var(--text-glow-amber)' :
|
||||||
|
variant === 'CRITICAL' ? 'var(--text-glow-red)' :
|
||||||
|
'none'
|
||||||
|
|
||||||
|
const deltaColor =
|
||||||
|
deltaPositive === true ? 'var(--color-green)' :
|
||||||
|
deltaPositive === false ? 'var(--color-red)' :
|
||||||
|
'var(--text-muted)'
|
||||||
|
|
||||||
|
const deltaSymbol =
|
||||||
|
deltaPositive === true ? '▲ ' :
|
||||||
|
deltaPositive === false ? '▼ ' :
|
||||||
|
'— '
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(statCardVariants({ variant }), className)}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
gap: '0.4rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: accentColor,
|
||||||
|
textShadow: accentGlow,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delta */}
|
||||||
|
{delta !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: deltaColor,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deltaSymbol}{delta}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sublabel */}
|
||||||
|
{sublabel && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: '0.6rem',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StatCard.displayName = 'StatCard'
|
||||||
|
|
||||||
|
export { StatCard, statCardVariants }
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type SystemStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'SCANNING'
|
||||||
|
|
||||||
|
export interface SystemEntry {
|
||||||
|
name: string
|
||||||
|
status: SystemStatus
|
||||||
|
/** Optional secondary text shown beside the name */
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
systems: SystemEntry[]
|
||||||
|
title?: string
|
||||||
|
columns?: 1 | 2 | 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(s: SystemStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'ACTIVE': return 'var(--color-green)'
|
||||||
|
case 'OFFLINE': return 'var(--text-muted)'
|
||||||
|
case 'WARNING': return 'var(--color-amber)'
|
||||||
|
case 'CRITICAL': return 'var(--color-red)'
|
||||||
|
case 'SCANNING': return 'var(--color-green)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusGlow(s: SystemStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'ACTIVE': return 'var(--glow-green)'
|
||||||
|
case 'SCANNING': return 'var(--glow-green)'
|
||||||
|
case 'WARNING': return 'var(--glow-amber)'
|
||||||
|
case 'CRITICAL': return 'var(--glow-red)'
|
||||||
|
default: return 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusGrid = React.forwardRef<HTMLDivElement, StatusGridProps>(
|
||||||
|
({ className, systems, title, columns = 1, style, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid of rows */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{systems.map((system, i) => {
|
||||||
|
const color = statusColor(system.status)
|
||||||
|
const glow = statusGlow(system.status)
|
||||||
|
const isScanning = system.status === 'SCANNING'
|
||||||
|
|
||||||
|
// border logic: no bottom border on last row(s), no right on last column
|
||||||
|
const totalRows = Math.ceil(systems.length / columns)
|
||||||
|
const rowIndex = Math.floor(i / columns)
|
||||||
|
const colIndex = i % columns
|
||||||
|
const isLastRow = rowIndex === totalRows - 1
|
||||||
|
const isLastCol = colIndex === columns - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderBottom: isLastRow ? 'none' : '1px solid var(--border)',
|
||||||
|
borderRight: isLastCol ? 'none' : '1px solid var(--border)',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Name + optional detail */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
letterSpacing:'0.06em',
|
||||||
|
textTransform:'uppercase',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{system.name}
|
||||||
|
{system.detail && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', marginLeft: '0.5rem', textTransform: 'none' }}>
|
||||||
|
{system.detail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status dot + label */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
boxShadow: glow,
|
||||||
|
animation: isScanning ? 'blink-cursor 1s step-end infinite' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{system.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StatusGrid.displayName = 'StatusGrid'
|
||||||
|
|
||||||
|
export { StatusGrid }
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
const TabsGroup = TabsPrimitive.List // alias
|
||||||
|
|
||||||
|
/* ── List ── */
|
||||||
|
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 items-end',
|
||||||
|
'border-b border-[var(--border)]',
|
||||||
|
'w-full gap-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
/* ── Trigger ── */
|
||||||
|
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',
|
||||||
|
'px-4 py-2',
|
||||||
|
'text-[0.7rem] font-mono font-medium uppercase tracking-widest',
|
||||||
|
'border-b-2 border-transparent',
|
||||||
|
'-mb-px',
|
||||||
|
'text-[var(--text-muted)]',
|
||||||
|
'cursor-pointer',
|
||||||
|
'outline-none',
|
||||||
|
'transition-all duration-150',
|
||||||
|
'hover:text-[var(--text-secondary)]',
|
||||||
|
'data-[state=active]:text-[var(--color-green)]',
|
||||||
|
'data-[state=active]:border-b-[var(--color-green)]',
|
||||||
|
'data-[state=active]:[text-shadow:var(--text-glow-green)]',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-40',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
/* ── Content ── */
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-4',
|
||||||
|
'focus-visible:outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsGroup, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type TerminalLineType = 'input' | 'output' | 'warn' | 'error' | 'system'
|
||||||
|
|
||||||
|
export interface TerminalLine {
|
||||||
|
type?: TerminalLineType
|
||||||
|
text: string
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
lines?: TerminalLine[]
|
||||||
|
prompt?: string
|
||||||
|
title?: string
|
||||||
|
height?: string | number
|
||||||
|
blinkCursor?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineColor(type: TerminalLineType = 'output'): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'input': return 'var(--color-green)'
|
||||||
|
case 'output': return 'var(--text-secondary)'
|
||||||
|
case 'warn': return 'var(--color-amber)'
|
||||||
|
case 'error': return 'var(--color-red)'
|
||||||
|
case 'system': return 'var(--text-muted)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linePrefix(type: TerminalLineType = 'output'): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'input': return '>'
|
||||||
|
case 'output': return ' '
|
||||||
|
case 'warn': return '⚠'
|
||||||
|
case 'error': return '✕'
|
||||||
|
case 'system': return '#'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Terminal = React.forwardRef<HTMLDivElement, TerminalProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
lines = [],
|
||||||
|
prompt = '>',
|
||||||
|
title = 'TERMINAL',
|
||||||
|
height = '16rem',
|
||||||
|
blinkCursor = true,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const bodyRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (bodyRef.current) {
|
||||||
|
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [lines])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Title bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{/* Status dot */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-green)',
|
||||||
|
boxShadow: 'var(--glow-green)',
|
||||||
|
animation: 'blink-cursor 2s step-end infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output body */}
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '0.75rem',
|
||||||
|
height,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: lineColor(line.type),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.timestamp && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.timestamp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flexShrink: 0, opacity: 0.5, userSelect: 'none' }}>
|
||||||
|
{linePrefix(line.type)}
|
||||||
|
</span>
|
||||||
|
<span style={{ wordBreak: 'break-all' }}>{line.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Blinking cursor */}
|
||||||
|
{blinkCursor && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
marginTop: '0.1rem',
|
||||||
|
gap: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-muted)', userSelect: 'none' }}>{prompt}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '0.55rem',
|
||||||
|
height: '1em',
|
||||||
|
background: 'var(--color-green)',
|
||||||
|
boxShadow: 'var(--glow-green)',
|
||||||
|
animation: 'blink-cursor 1s step-end infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Terminal.displayName = 'Terminal'
|
||||||
|
|
||||||
|
export { Terminal }
|
||||||
+11
-4
@@ -5,11 +5,18 @@ test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('scificn-ui themed components render on the landing page', async ({ page }) => {
|
test('mission control layout renders scificn-ui components', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Panel header badge + footer button prove the copy-paste component layer is wired in.
|
// Stat card, tab trigger and footer badge prove the copied component layer is wired in.
|
||||||
await expect(page.getByText('SYS:ONLINE')).toBeVisible();
|
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'INITIATE' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'SYSTEMS' })).toBeVisible();
|
||||||
|
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('operations tabs switch panels client-side', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'LOG' }).click();
|
||||||
|
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user