feat(frontend): redesign console UI with new component tree and design system

This commit is contained in:
2026-08-03 14:22:50 +01:00
parent 0a1c219941
commit bbc5db473a
40 changed files with 1469 additions and 1120 deletions
+211
View File
@@ -0,0 +1,211 @@
---
name: Maestro · dcntrlized console
description: Dark ops console for running a self-hosted node — precise, calm, alive at night.
colors:
primary: "oklch(0.4981 0.1996 354.66)"
primary-foreground: "oklch(0.97 0.012 354)"
secondary: "oklch(0.63 0.15 354.66)"
tertiary: "oklch(0.39 0.14 354.66)"
signal: "oklch(0.74 0.13 160)"
ember: "oklch(0.78 0.15 55)"
alert: "oklch(0.66 0.2 24)"
night-ink: "oklch(0.14 0.015 265)"
panel: "oklch(0.173 0.017 265)"
raised: "oklch(0.19 0.018 265)"
slate: "oklch(0.218 0.026 354)"
muted-fill: "oklch(0.205 0.018 265)"
frost: "oklch(0.955 0.004 265)"
ash: "oklch(0.7 0.02 265)"
edge: "oklch(100% 0 0 / 8%)"
typography:
title:
fontFamily: "IBM Plex Sans Variable, system-ui, sans-serif"
fontSize: "15px"
fontWeight: 600
body:
fontFamily: "IBM Plex Sans Variable, system-ui, sans-serif"
fontSize: "13px"
fontWeight: 400
lineHeight: 1.55
label:
fontFamily: "JetBrains Mono Variable, ui-monospace, monospace"
fontSize: "11px"
fontWeight: 500
rounded:
sm: "7px"
md: "9px"
lg: "11px"
xl: "15px"
pill: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "12px"
lg: "16px"
xl: "24px"
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: "12px 16px"
button-primary-hover:
backgroundColor: "color-mix(in oklab, {colors.primary} 80%, transparent)"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.ash}"
rounded: "{rounded.lg}"
chip-running:
backgroundColor: "color-mix(in oklab, {colors.signal} 10%, transparent)"
textColor: "{colors.signal}"
rounded: "{rounded.pill}"
card-service:
backgroundColor: "{colors.panel}"
rounded: "{rounded.xl}"
input-search:
backgroundColor: "color-mix(in oklab, {colors.muted-fill} 40%, transparent)"
rounded: "{rounded.lg}"
---
# Design System: Maestro · dcntrlized console
## Overview
**Creative North Star: "The Night Conductor"**
Maestro is the dark booth at the edge of the night. A single operator runs every service on the rack from a near-black console — a raspberry glow overhead, warm ember and signal lights breathing along the rails below. Nothing on screen shouts; everything is readable at a glance, like instrument faces in a dim cockpit. The interface is calm, precise, and quietly alive: status lights pulse only when something is actually happening, surfaces sit in hairline-defined layers, and every action reads as a deliberate act of control rather than a marketing flourish.
Density is operator-dense but never crowded. One service equals one card; the card compresses identity (monogram tile), state (status light), provenance (vendor, image, source), and the single correct next action into a tight unit. The header is a glance-line: brand, installed count, update flag, and connection state in one row. The visual system is built on tonal layering over flat ink — near-black blue-zinc surfaces stacked by elevation, separated by 8% white hairlines — with soft black drop shadows used only as ambient grounding, never as decoration.
**Key Characteristics:**
- Nocturnal and utilitarian — near-black blue-zinc ink, never pure black, never white-page light.
- Raspberry is the only accent and it is rare; it marks brand, the primary action, and focus.
- Warm status lights (signal / ember) carry operational truth; blue is banned from status semantics.
- Hairline borders over tonal surfaces convey structure; shadows are ambient, not structural.
- Mono type does the technical talking; the sans voice is quiet, structured, legible.
- Motion is a response to state — pings, toasts, hovers — gated behind `prefers-reduced-motion`.
## Colors
The palette is one raspberry accent against a cool blue-zinc ink scene. Status colors are warm lights (green, orange, red) tuned to sit inside the scene and read at a glance without clashing with the accent.
### Primary
- **Raspberry** (oklch(0.4981 0.1996 354.66) · #b00b69): The single accent. Used for the brand mark, the primary action button, the active search/filter treatment, and the focus ring. Its rarity is the point — it should never paint a whole screen.
- **Primary Frost** (oklch(0.97 0.012 354)): Frost-white text *on* raspberry. The dark accent needs light ink — holds AA at 6.2:1 while staying inside the night scene.
### Secondary
- **Rose** (oklch(0.63 0.15 354.66)): The brand family's lighter voice — supporting accents, chart fills, hover tints. Derived from the raspberry hue, never picked ad hoc.
- **Ember** (oklch(0.78 0.15 55)): Warm orange. Warns — stopping, restarting, transitions, "update available", registry unreachable.
- **Signal** (oklch(0.74 0.13 160)): Warm emerald. Running / connected / success — the "all clear" light.
### Tertiary
- **Wine** (oklch(0.39 0.14 354.66)): The deep bookend of the raspberry family — pressed states and depth accents. Too dark for text on the scene; surfaces only.
- **Alert** (oklch(0.66 0.2 24)): Crimson red. Destructive intent and hard failures — uninstall, errors, lost connection. Deepened to read as danger beside the raspberry accent.
### Neutral
- **Night Ink** (oklch(0.14 0.015 265)): Page background. The scene's floor.
- **Frost** (oklch(0.955 0.004 265)): Primary text — headings, names, labels.
- **Ash** (oklch(0.7 0.02 265)): Secondary text and metadata. Set bright enough to hold AA at 11px (4.5:1 on secondary).
- **Panel** (oklch(0.173 0.017 265)): Card surface — one step up from the page.
- **Raised** (oklch(0.19 0.018 265)): Popover / toast surface — the top layer.
- **Blush** (oklch(0.218 0.026 354)): Secondary and selected fills — quiet chips, monogram tiles. Near-black ink with a faint raspberry cast.
- **Muted Fill** (oklch(0.205 0.018 265)): Quiet fills — inputs, version chips.
- **Edge** (oklch(100% 0 0 / 8%)): Hairline borders and dividers. Structure without weight.
### Named Rules
**The One Voice Rule.** Raspberry appears on ≤10% of any given screen. Its rarity is the signal — when raspberry appears, it means "act here" or "this is Maestro itself."
**The Derived Family Rule.** Rose and wine are the raspberry family's lightness bookends, derived in OKLCH from the primary hue — never chosen ad hoc. Rose carries support (accents, tints, charts); wine carries depth (press, shadows of brand surfaces).
**The No Blue Light Rule.** Blue is banned from status semantics. Operational states are warm (signal / ember / alert) only; a blue accent reads as the old stock dashboard and breaks the scene.
**The Warm-Second Rule.** Warm accents are derived to sit *inside* the dark scene: on-card chips use a 10–25% tint of the accent over the surface, never a full-strength saturated wash.
## Typography
**Display Font:** IBM Plex Sans Variable (fallback system-ui, sans-serif)
**Body Font:** IBM Plex Sans Variable (fallback system-ui, sans-serif)
**Label/Mono Font:** JetBrains Mono Variable (fallback ui-monospace, Menlo, monospace)
**Character:** A structured, technical-but-human pairing. IBM Plex Sans is quietly engineered — clear, slightly industrial, no flourish — while JetBrains Mono carries the machine voice: names, images, versions, and counts. Mono is used for anything the operator might copy or compare; sans for anything meant to be read.
### Hierarchy
Type sizes are authored at 100% density in the DESIGN tokens and rendered 1.5× by the root `font-size: 150%` on `<html>` (rem-based utilities scale, px and viewport units don't). The relative steps below are the design intent; on screen they read 18 / 21 / 24 / 27px.
- **Title** (600, 15px, 1.2): Brand wordmark, card names. The only semibold in the system.
- **Body** (400, 13px, 1.55): Descriptions and secondary reading. Capped at 2 lines in cards.
- **Label** (500, 11px, mono): Versions, image refs, counts, vendor, service names, timestamps. Tabular-nums where numeric (`tabular-nums`).
### Named Rules
**The Mono Voice Rule.** Anything technical is mono: image references, versions, service names, counts. If a user could copy it, set it in JetBrains Mono.
**The One-Weight Rule.** Sans weight stays at 400 (body) and 600 (title). No 500 intermediates; hierarchy comes from size and mono, not weight gradations.
## Layout
**Density baseline: desktop-first at 150%, a single root rule.** The console targets desktop operators on 1080p displays at 100% zoom. Density is achieved with one line in `src/index.css` — `html { font-size: 150% }` — so every rem-based utility (`text-sm`, `text-lg`, `max-w-xl`, `h-8`, `gap-4`, `rounded-lg`) scales 1.5× while keeping its standard Tailwind name. No theme values are rewritten; the only density token is `--radius` (0.6875rem), the design's 11px corner base, which scales with the root font-size. `zoom` was ruled out because it scales viewport units and px too, breaking `min-h-screen`/`dvh` filling and the hairline px focus ring. Px values (focus ring `ring-[3px]`, shadows, borders) stay hairline. Breakpoints are pinned to fixed CSS px (`--breakpoint-*: 640/768/1024/1280px`) rather than rem, so the responsive grid stays tied to the real viewport and keeps 3 columns on desktop instead of collapsing.
The console is a single centered column capped at 1800px (`max-w-console` = `--container-console: 75rem`, scaled 1.5× by the root font-size) with horizontal padding 24px / 36px desktop. The header is a fixed glance-line: 96px tall (`h-16`), sticky, hairline bottom border, frosted (`bg-background/85` + `backdrop-blur-md`) so cards scroll under it. Below it, the page stacks with 36px rhythm: toolbar → result count → grid.
Services render in a 3-column grid at desktop (`xl:grid-cols-3`); narrower viewports drop to 2 columns at `sm` and 1 below, but mobile is not a design target — the desktop layout is the committed truth. Cards are equal-height units; each card is a self-contained column with footer actions pinned to the bottom. The toolbar keeps its single-row search + segmented filters on desktop.
Spacing follows the tailwind half-step rhythm (6 / 12 / 18 / 24 / 36px at the 150% root). Inside cards: content padding 24–30px, gaps 18px between identity row, description, and metadata; footer separated by a hairline with 18px top padding.
## Elevation & Depth
**Hairline precision** — depth is carried by tonal layering and hairline borders first, ambient shadow second. Every surface is flat at rest; elevation reads as a lighter fill (Night Ink → Panel → Raised) plus an 8% white hairline. Shadows are soft black ambient pools used only to lift interactive/critical layers off the page.
### Shadow Vocabulary
- **Card** (`0 1px 0 rgba(0,0,0,0.4), 0 10px 28px -14px rgba(0,0,0,0.6)`): The resting card pool. Ambient, non-structural.
- **Card Hover** (`0 1px 0 rgba(0,0,0,0.4), 0 16px 36px -16px rgba(0,0,0,0.75)`): A card under the cursor — a slightly deeper pool, nothing dramatic.
- **Toast** (`0 8px 30px -8px rgba(0,0,0,0.7)`): The floating layer — toasts hover above everything.
### Named Rules
**The Flat-At-Rest Rule.** Surfaces are flat at rest. Shadows appear only as a state response (hover, floating layers) or as ambient grounding. No shadows on chips, badges, or buttons — borders do that work.
## Shapes
The form language is **precision with a softened edge**: corners are rounded but never pill-soft on structural surfaces. Cards use 15px (`rounded-xl`), buttons and inputs 11px (`rounded-lg`), the brand mark 10px. Only status/state *chips* and the connection badge break into full pills (`rounded-full`) — the pill shape is reserved for state, never structure. Monogram tiles are 36px squares at `rounded-lg` with a hairline border and a tinted fill matching their status tone. The overall silhouette reads as a rack of neat, softly-cornered instruments.
## Components
### Buttons
- **Shape:** 11px radius, tight 1–3px gutters, 28px resting height (24/32/36 by size step).
- **Primary (Raspberry):** raspberry fill with frost-white text (6.2:1). Hover dims to 80% (`hover:bg-primary/80`); press nudges down 1px.
- **Status actions (Start / Update):** transparent fills with a tinted 30% border and tinted text — signal for Start, ember for Update. These read as "operational" rather than "branded".
- **Ghost:** transparent, ash text; hover warms to a muted fill. Used for uninstall (hover flips to alert) and the connection badge.
- **Focus:** raspberry ring at 30% (`ring-3 ring-primary/30`) + border shift; always visible for keyboard.
- **Disabled:** 50% opacity, pointer-events off.
### Chips
- **Status chip:** full pill, 11px mono-adjacent sans, 1.5px gutter with a 2px status light. Tint follows the tone: signal/ember/alert at 10% fill over the surface with a 25% border.
- **Filter pill:** segmented control in a hairline tray (`bg-muted/60`); active pill lifts to `bg-card` with a hairline ring, inactive stays ash on hover.
### Cards / Containers
- **Corner Style:** 15px (`rounded-xl`).
- **Background:** Panel (oklch(0.173 0.017 265)) + hairline Edge border.
- **Shadow Strategy:** ambient Card pool; hover deepens to Card Hover (see Elevation).
- **Internal Padding:** 20px horizontal, 16px top, content gap 12px; footer hairline-separated, 12px top.
- **Behavior:** hover deepens border to `border-border/90` and the shadow pool. The update badge is an ember chip with an up-arrow; version → latest is a mono "vX → vY" pair.
### Inputs / Fields
- **Style:** 11px radius, 36px tall, muted-fill at 40% opacity over the surface, hairline `--input` border, ash placeholder.
- **Focus:** raspberry ring at 30% + border shift to `--ring`. No shadow.
### Navigation
- **Style:** the sticky header is a hairline bar: brand block (mark + wordmark + mono subtitle) left; right side holds the installed count chip, the ember "updates" pill, and the connection badge. On desktop (the design target) all elements show in the glance-line.
### Service Card (signature)
The rack unit. Left: 36px status-tinted monogram tile. Middle: title (sans 600) + vendor (mono), the image ref as a compact mono copy chip (host-stripped, truncating, click-to-copy with a toast), 2-line description clamp. Bottom metadata strip: current version chip, ember update chip when stale, container-ID copy chip when running, and a right-aligned mono "Source" link. Footer: uninstall ghost icon left; right side the correct action — Install (raspberry), Start (signal), Stop (outline), Update (ember) — one primary action per state, disabled while busy with a spinner.
## Do's and Don'ts
### Do:
- **Do** keep raspberry rare and purposeful — brand mark, one primary action, focus ring.
- **Do** use signal for running/connected and ember for warning/updates; these warm lights are the operator's truth.
- **Do** set every technical value (versions, images, names, counts) in JetBrains Mono.
- **Do** separate surfaces with hairlines and tonal steps, and keep shadows ambient-only.
- **Do** show exactly one primary action per card state, and disable it with a spinner while pending.
- **Do** gate all motion (status pings, toast entrance) behind `prefers-reduced-motion`.
### Don't:
- **Don't** use blue for any status or accent role — the scene is raspberry + warm status lights only.
- **Don't** paint surfaces with full-strength saturated washes; status tints sit at 10–25% over the surface.
- **Don't** put shadows on chips, badges, or buttons — borders carry that weight.
- **Don't** add sans weights beyond 400/600, or introduce a second sans family.
- **Don't** pill-round structural elements (cards, buttons, inputs); pills are reserved for state chips and the connection badge.
+59
View File
@@ -0,0 +1,59 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- Primary: dcntrlized node operators and self-hosters who run their own infrastructure and deploy services from the dcntrlized registry onto a Docker host. [Inferred from README positioning and docker-compose evidence; confirm.]
- Adjacent: developers and tinkerers evaluating Maestro and the dcntrlized ecosystem.
## Product Purpose
Maestro is the coordination layer of the dcntrlized ecosystem: a single web console that runs on a Docker host, lists the services installed on that node, lets the operator install new services from the dcntrlized container registry, and start, stop, update, and uninstall them. Success is a node operator managing the whole service stack from one console without dropping to the shell.
## Positioning
The mechanism is a Git-backed, decentralized service registry: services are published as OCI images with rich metadata in a Gitea registry, discovered through `/api/registry`, and installed by reference onto the node. The dashboard merges that remote catalog with the live local Docker state so installed services, available updates, and registry-only (not-yet-installed) services live in one view. Plainly: a decentralized app store that runs on your own hardware.
## Operating Context
- Web dashboard served from the Go backend on `:8080`, which talks to the local Docker socket.
- Live state flows over WebSocket (`ws://127.0.0.1:8080/api/ws`); the catalog is fetched once from `/api/registry`.
- Real-time statuses come from Docker (running, exited, stopped, created, restarting) plus UI states (installed, uninstalled).
- Service metadata (title, vendor, author, description, source, licenses, revision) comes from OCI annotations published with each image.
- Upstream registry is git.dcntrlized.org (Gitea), org `Dcntrlized`.
- Maestro itself runs as a container (image `maestro`); its own container is hidden from the list.
- Update detection compares the installed version against the registry latest via semver; versions are normalized to `X.Y.Z`.
## Capabilities and Constraints
- Capabilities: list installed + registry services; filter (all / running / updates / registry-only); search by name, image, or source; install, update (reinstall), start, stop, uninstall; live connection indicator; repository link per registry service.
- Constraints: browser talks to the backend on `127.0.0.1:8080` (dev proxy in Vite); no auth in the current UI; the data model merges local containers with remote registry items and must keep both sources distinct.
- Terminology: containers/services, registry, installed, running, "update available".
## Brand Commitments
- Name: Maestro. Organization: dcntrlized (lowercase, `.org`). Docker image name `maestro`.
- Open source (LICENSE present); dcntrlized ethos is decentralized, self-hosted, own-your-infrastructure.
## Evidence on Hand
- Backend Go source in `../src/` (handlers, lib) is the API and data-model truth.
- `services.json` at repo root lists registry examples: KeepNote (dcntrlized.org:8081), SafeGuard (dcntrlized.org:8082).
- README, docker-compose.yaml (mounts docker.sock, Gitea tokens), LICENSE.
- No DESIGN.md. Incumbent UI is a stock shadcn template — evidence of what it looked like, not authority over what it becomes.
## Product Principles
- Own your stack: the console runs on the node, beside the services it manages.
- One surface, live state: local Docker reality and the remote catalog appear in a single merged view.
- Act without the shell: install, start, stop, update, uninstall all happen from the UI.
- Honest status: every service shows a truthful current state and the action that fits it.
## Accessibility & Inclusion
- No product-specific requirement established yet; standard WCAG 2.x AA contrast and full keyboard support expected.
+11 -16
View File
@@ -5,17 +5,16 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/semver": "^7.7.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"react-aria-components": "^1.19.0", "react-aria-components": "^1.19.0",
"react-icons": "^5.7.0",
"semver": "^7.8.5",
"shadcn": "^4.15.0", "shadcn": "^4.15.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -101,7 +100,9 @@
"@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], "@fontsource-variable/ibm-plex-sans": ["@fontsource-variable/ibm-plex-sans@5.3.0", "", {}, "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/[email protected]", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
"@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], "@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -227,8 +228,6 @@
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/semver": ["@types/[email protected]", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -675,8 +674,6 @@
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-icons": ["[email protected]", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="],
"react-stately": ["[email protected]", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.36.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA=="], "react-stately": ["[email protected]", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.36.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA=="],
"recast": ["[email protected]", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], "recast": ["[email protected]", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="],
@@ -701,7 +698,7 @@
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["[email protected]", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "send": ["[email protected]", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -729,6 +726,8 @@
"sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sonner": ["[email protected]", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -819,12 +818,6 @@
"zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -853,6 +846,8 @@
"conf/json-schema-typed": ["[email protected]", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], "conf/json-schema-typed": ["[email protected]", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="],
"conf/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dot-prop/is-obj": ["[email protected]", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "dot-prop/is-obj": ["[email protected]", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
+5 -2
View File
@@ -4,9 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="theme-color" content="#101116" />
<meta name="color-scheme" content="dark" />
<title>Maestro · dcntrlized console</title>
</head> </head>
<body> <body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body> </body>
<script type="module" src="/src/main.tsx"></script>
</html> </html>
+3 -4
View File
@@ -9,17 +9,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/semver": "^7.7.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"react-aria-components": "^1.19.0", "react-aria-components": "^1.19.0",
"react-icons": "^5.7.0",
"semver": "^7.8.5",
"shadcn": "^4.15.0", "shadcn": "^4.15.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
View File
+68 -325
View File
@@ -1,341 +1,84 @@
import './app.css' import { useMemo, useState } from 'preact/hooks'
import { ConnectionBadge } from '@lib/components/connectionBadge' import { useContainerStore } from '@lib/hooks/useContainerStore'
import { registry } from '@lib/ws/registry' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
import { useEffect, useState, useMemo } from 'preact/hooks' import { mergeServices } from '@lib/domain/merge'
import { import { filterServices, serviceCounts } from '@lib/domain/filter'
type Container, import type { ServiceFilter } from '@lib/types'
type RegistryItem, import { Header } from '@components/layout/header'
STATUS_PRIORITY import { Toolbar } from '@components/layout/toolbar'
} from '@lib/types' import { ServiceGrid } from '@components/service/service-grid'
import semver from 'semver' import { Toaster } from '@components/ui/sonner'
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
// Shadcn UI Component Imports
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@shadcn/components/ui/card'
import { Input } from '@shadcn/components/ui/input'
import { Badge } from '@shadcn/components/ui/badge'
import { InstallButton } from '@lib/components/installButton'
import { UninstallButton } from '@lib/components/uninstallButton'
import { StartButton } from '@lib/components/startButton'
import { StopButton } from '@lib/components/stopButton'
// Helper to extract base image without tag or digest
function getBaseImage(imageRef?: string): string {
if (!imageRef) return ''
const untagged = imageRef.split('@')[0].split(':')[0]
return untagged.toLowerCase()
}
// Helper to format version strings for semver comparison
function cleanVersion(v?: string): string | null {
if (!v) return null
const trimmed = v.trim()
return semver.valid(semver.coerce(trimmed))
}
export function App() { export function App() {
const [rawContainers, setRawContainers] = useState<Container[]>(() => registry.getState().containers) const { containers, status } = useContainerStore()
const [masterRegistry, setMasterRegistry] = useState<RegistryItem[]>([]) const registry = useMasterRegistry()
const [searchQuery, setSearchQuery] = useState('') const [query, setQuery] = useState('')
const [filter, setFilter] = useState<'all' | 'running' | 'updates' | 'missing'>('all') const [filter, setFilter] = useState<ServiceFilter>('all')
useEffect(() => { const services = useMemo(() => {
const unsubscribe = registry.subscribe(() => { const items = registry.state.kind === 'ok' ? registry.state.items : []
setRawContainers(registry.getState().containers) return mergeServices(containers, items)
}) }, [containers, registry.state])
return unsubscribe
}, [])
useEffect(() => { const counts = useMemo(() => serviceCounts(services), [services])
const getRegistryItems = async () => { const visible = useMemo(
try { () => filterServices(services, query, filter),
const response = await fetch('/api/registry') [services, query, filter]
const data: Array<RegistryItem> = await response.json() )
setMasterRegistry(data)
} catch (err) {
console.error("Failed to fetch remote registry:", err)
}
}
getRegistryItems()
}, [])
// Hydrate into a single clean RegistryItem list const loading = registry.state.kind === 'loading' && containers.length === 0
const containers = useMemo(() => { const registryError = registry.state.kind === 'error'
const map = new Map<string, RegistryItem>()
// 1. Process local containers from Websocket const showUpdates = () => setFilter('updates')
for (const c of rawContainers) { const clearFilters = () => {
if (c.name.toUpperCase().includes("MAESTRO")) continue setQuery('')
setFilter('all')
const key = c.name.toLowerCase() }
const existing = map.get(key)
const currentPriority = STATUS_PRIORITY[(c.status || '').toLowerCase()] ?? 0
if (!existing) {
map.set(key, {
id: c.id,
name: c.name,
version: c.version || '',
image: c.image,
source: c.source,
status: c.status,
origin: 'local'
})
} else {
const existingPriority = STATUS_PRIORITY[(existing.status || '').toLowerCase()] ?? 0
if (currentPriority > existingPriority) {
existing.id = c.id
existing.status = c.status
existing.image = c.image
}
}
}
// 2. Match remote registry items against local containers
for (const r of masterRegistry) {
if (r.name.toUpperCase().includes("MAESTRO")) continue
const rNameLower = r.name.toLowerCase()
const rBaseImage = getBaseImage(r.image)
let existingKey: string | undefined
for (const [key, item] of map.entries()) {
const itemBaseImage = getBaseImage(item.image)
if (key === rNameLower || (rBaseImage && itemBaseImage === rBaseImage)) {
existingKey = key
break
}
}
const existing = existingKey ? map.get(existingKey) : undefined
if (existing) {
existing.origin = 'both'
existing.remoteSource = r.source
existing.url = r.url || existing.url
existing.latestVersion = r.version
existing.title = r.title || existing.title
existing.vendor = r.vendor || existing.vendor
existing.author = r.author || existing.author
existing.description = r.description || existing.description
const currentV = cleanVersion(existing.version)
const remoteV = cleanVersion(r.version)
if (currentV && remoteV && semver.gt(remoteV, currentV)) {
existing.hasUpdate = true
}
} else {
map.set(rNameLower, {
...r,
id: `remote-${r.name}`,
status: 'uninstalled',
origin: 'remote',
version: '',
latestVersion: r.version,
remoteSource: r.source
})
}
}
return Array.from(map.values())
}, [rawContainers, masterRegistry])
const filteredContainers = useMemo(() => {
return containers.filter((c) => {
const searchStr = searchQuery.toLowerCase()
const matchesSearch =
c.name.toLowerCase().includes(searchStr) ||
c.image?.toLowerCase().includes(searchStr) ||
c.source?.toLowerCase().includes(searchStr) ||
c.remoteSource?.toLowerCase().includes(searchStr)
if (filter === 'running') return matchesSearch && c.status?.toLowerCase() === 'running'
if (filter === 'updates') return matchesSearch && c.hasUpdate
if (filter === 'missing') return matchesSearch && c.origin === 'remote'
return matchesSearch
})
}, [containers, searchQuery, filter])
const totalInstalled = containers.filter(c => c.origin !== 'remote').length
const totalUpdates = containers.filter(c => c.hasUpdate).length
const totalMissing = containers.filter(c => c.origin === 'remote').length
return ( return (
<div className="min-h-screen bg-background text-foreground font-sans antialiased"> <div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <Header
<div className="max-w-7xl mx-auto flex h-16 items-center justify-between px-6"> installed={counts.installed}
<div className="flex items-center gap-3"> updates={counts.updates}
<h1 className="text-lg font-semibold tracking-tight text-foreground m-0">Container Manager</h1> connection={status}
<Badge variant="secondary" className="px-2.5 py-0.5 text-xs font-semibold"> onShowUpdates={showUpdates}
{totalInstalled} Installed />
</Badge>
{totalUpdates > 0 && (
<Badge variant="default" className="bg-blue-600 px-2.5 py-0.5 text-xs font-semibold">
{totalUpdates} {totalUpdates === 1 ? 'Update Available' : 'Updates Available'}
</Badge>
)}
</div>
<ConnectionBadge />
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8 space-y-6"> <main data-slot="main" className="mx-auto flex min-h-[calc(100dvh-4rem)] w-full max-w-console flex-col gap-6 px-4 py-6 pb-16 sm:px-6">
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 bg-card text-card-foreground p-4 rounded-xl border border-border shadow-sm"> {loading ? (
<div className="relative flex-1"> <LoadingState />
<svg className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground z-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <>
</svg> {registryError && <RegistryErrorState onRetry={registry.retry} />}
<Input
type="text" <Toolbar
placeholder="Search containers, images, or repositories..." query={query}
value={searchQuery} onQuery={setQuery}
onInput={(e: Event) => setSearchQuery((e.target as HTMLInputElement).value)} filter={filter}
className="pl-9" onFilter={setFilter}
counts={{
total: counts.total,
running: counts.running,
updates: counts.updates,
available: counts.available,
}}
/> />
</div>
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"> <p className="text-xs text-muted-foreground" role="status">
<button Showing {visible.length} of {counts.total} services
onClick={() => setFilter('all')} </p>
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'all'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
All ({containers.length})
</button>
<button
onClick={() => setFilter('running')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'running'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Running
</button>
<button
onClick={() => setFilter('updates')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'updates'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Updates ({totalUpdates})
</button>
<button
onClick={() => setFilter('missing')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'missing'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Git Registry ({totalMissing})
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> {visible.length === 0 ? (
{filteredContainers.map((container) => { <EmptyState query={query} filter={filter} total={counts.total} onClear={clearFilters} />
const isRemoteOnly = container.origin === 'remote' ) : (
const isInGitRegistry = container.origin === 'both' || isRemoteOnly <ServiceGrid services={visible} />
const isRunning = (container.status || '').toLowerCase() === 'running' )}
</>
return ( )}
<Card
key={container.id || `installed-${container.name}`}
className={`transition-all duration-200 hover:shadow-md flex flex-col justify-between ${isRemoteOnly
? 'border-dashed border-border bg-card/40 hover:border-muted-foreground/50'
: container.hasUpdate
? 'border-blue-500/60 shadow-blue-500/5'
: isInGitRegistry
? 'border-primary/50'
: 'border-border'
}`}
>
<CardHeader className="space-y-0 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className={`text-base font-semibold truncate m-0 ${isRemoteOnly ? 'text-muted-foreground' : ''}`}>
{container.title || container.name}
</CardTitle>
<Badge
variant="outline"
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-semibold ${isRemoteOnly
? 'text-muted-foreground bg-muted'
: isRunning
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
}`}
>
{!isRemoteOnly && (
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${isRunning ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
)}
{isRemoteOnly ? 'Not Installed' : container.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1 text-xs text-muted-foreground pb-3 flex-grow">
{container.image && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Image:</span>
<span className="font-mono text-foreground truncate max-w-[190px]" title={container.image}>
{container.image}
</span>
</div>
)}
{(container.source || container.remoteSource) && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Source:</span>
<span className="font-mono text-foreground/80 truncate max-w-[170px]" title={container.source || container.remoteSource}>
<a href={container.source || container.remoteSource} target="_blank">{container.source || container.remoteSource}</a>
</span>
</div>
)}
{!isRemoteOnly && container.version && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Installed Version:</span>
<span className="font-mono text-foreground/80">{container.version}</span>
</div>
)}
{isRemoteOnly && container.latestVersion && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Available Ver:</span>
<span className="font-mono text-foreground/80">{container.latestVersion}</span>
</div>
)}
{container.hasUpdate && (
<div className="flex justify-between items-center py-1 text-blue-600 dark:text-blue-400 font-semibold">
<span>Latest Ver:</span>
<span className="font-mono">{container.latestVersion}</span>
</div>
)}
</CardContent>
{isInGitRegistry && (
<CardFooter className="pt-3 border-t border-border flex items-center justify-between gap-2">
<a
href={container.source || container.remoteSource}
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors flex items-center gap-1"
target="_blank"
rel="noreferrer"
>
Repo &rarr;
</a>
<div className="flex gap-1.5">
{ isRemoteOnly ? <InstallButton container={container} /> : null }
{ container.hasUpdate ? <InstallButton container={container} type='update' /> : null}
{ container.status === 'installed' ? <><UninstallButton container={container} /><StartButton container={container} /></>: null}
{ isRunning ? <StopButton container={container} /> : null}
{ container.status === 'stopped' || container.status === 'exited' ? <><UninstallButton container={container} /><StartButton container={container} /></>: null}
</div>
</CardFooter>
)}
</Card>
)
})}
</div>
</main> </main>
<Toaster />
</div> </div>
) )
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296"><path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path><path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path><path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path><path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,26 @@
import { cn } from '@lib/utils'
/**
* The Maestro mark: a stacked deck with a lit LED — the conductor's rack.
*/
export function BrandMark({ className }: { className?: string }) {
return (
<span
data-slot="brand-mark"
aria-hidden="true"
className={cn(
'inline-grid size-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground',
className
)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="block size-5">
<g stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.85">
<line x1="5" y1="6" x2="15" y2="6" />
<line x1="5" y1="10" x2="15" y2="10" />
<line x1="5" y1="14" x2="12" y2="14" />
</g>
<circle cx="15" cy="14" r="1.75" fill="currentColor" />
</svg>
</span>
)
}
@@ -0,0 +1,46 @@
import { cn } from '@lib/utils'
import { connectWebSocket } from '@lib/ws/client'
import type { ConnectionStatus } from '@lib/ws/registry'
import { Button } from '@components/ui/button'
interface ConnectionProps {
status: ConnectionStatus
}
function connectionMeta(status: ConnectionStatus) {
switch (status) {
case 'open':
return { label: 'Live', dot: 'bg-success', ping: 'bg-success' }
case 'connecting':
return { label: 'Connecting…', dot: 'bg-warning', ping: 'bg-warning' }
case 'closing':
return { label: 'Connecting…', dot: 'bg-warning', ping: 'bg-warning' }
default:
return { label: 'Reconnecting…', dot: 'bg-destructive', ping: 'bg-destructive' }
}
}
export function ConnectionBadge({ status }: ConnectionProps) {
const meta = connectionMeta(status)
return (
<Button
data-slot="connection-badge"
variant="ghost"
size="sm"
onPress={() => connectWebSocket()}
aria-label={`${meta.label} — click to reconnect`}
className={cn(
'h-7 gap-2 rounded-full border border-border bg-muted/40 px-3 text-xs font-medium text-muted-foreground hover:bg-muted/70 hover:text-foreground'
)}
>
<span className="relative inline-flex size-2" aria-hidden="true">
{status !== 'open' && (
<span className={cn('absolute inline-flex size-2 animate-status-ping rounded-full opacity-70', meta.ping)} />
)}
<span className={cn('relative inline-flex size-2 rounded-full', meta.dot)} />
</span>
{meta.label}
</Button>
)
}
@@ -0,0 +1,81 @@
export interface StatusMeta {
label: string
dot: string
ping: boolean
chip: string
tile: string
}
/**
* Single source of truth for how a service state reads: the light, the chip,
* and the monogram tile all derive from the same tone mapping.
*/
export function statusMeta(status: string): StatusMeta {
switch (status.toLowerCase()) {
case 'running':
return {
label: 'Running',
dot: 'bg-success',
ping: true,
chip: 'border-success/25 bg-success/10 text-success',
tile: 'border-success/20 bg-success/10 text-success',
}
case 'starting':
return {
label: 'Starting',
dot: 'bg-warning',
ping: true,
chip: 'border-warning/25 bg-warning/10 text-warning',
tile: 'border-warning/20 bg-warning/10 text-warning',
}
case 'restarting':
return {
label: 'Restarting',
dot: 'bg-warning',
ping: true,
chip: 'border-warning/25 bg-warning/10 text-warning',
tile: 'border-warning/20 bg-warning/10 text-warning',
}
case 'stopped':
case 'exited':
return {
label: 'Stopped',
dot: 'bg-warning',
ping: false,
chip: 'border-warning/25 bg-warning/10 text-warning',
tile: 'border-warning/20 bg-warning/10 text-warning',
}
case 'created':
return {
label: 'Created',
dot: 'bg-foreground/60',
ping: false,
chip: 'border-border bg-secondary text-secondary-foreground',
tile: 'border-border bg-secondary text-muted-foreground',
}
case 'installed':
return {
label: 'Installed',
dot: 'bg-foreground/60',
ping: false,
chip: 'border-border bg-secondary text-secondary-foreground',
tile: 'border-border bg-secondary text-muted-foreground',
}
case 'uninstalled':
return {
label: 'Not installed',
dot: 'bg-muted-foreground/60',
ping: false,
chip: 'border-border bg-muted text-muted-foreground',
tile: 'border-border bg-muted text-muted-foreground',
}
default:
return {
label: status || 'Unknown',
dot: 'bg-foreground/50',
ping: false,
chip: 'border-border bg-muted text-muted-foreground',
tile: 'border-border bg-muted text-muted-foreground',
}
}
}
@@ -0,0 +1,53 @@
import { BrandMark } from '@components/brand-mark'
import { ConnectionBadge } from '@components/connection/connection-badge'
import type { ConnectionStatus } from '@lib/ws/registry'
interface HeaderProps {
installed: number
updates: number
connection: ConnectionStatus
onShowUpdates: () => void
}
export function Header({ installed, updates, connection, onShowUpdates }: HeaderProps) {
return (
<header data-slot="header" className="sticky top-0 z-50 border-b border-border/70 bg-background/85 backdrop-blur-md">
<div className="mx-auto flex h-16 w-full max-w-console items-center justify-between gap-4 px-4 sm:px-6">
<div data-slot="header-brand" className="flex min-w-0 items-center gap-3">
<BrandMark />
<div className="min-w-0 leading-tight">
<p data-slot="header-title" className="truncate text-base font-semibold tracking-tight text-foreground">
Maestro
</p>
<p data-slot="header-subtitle" className="hidden truncate font-mono text-xs text-muted-foreground sm:block">
dcntrlized console
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span data-slot="header-installed" className="hidden rounded-full border border-border bg-muted/40 px-2.5 py-1 text-xs font-medium text-muted-foreground sm:inline-flex">
{installed} installed
</span>
{updates > 0 && (
<button
data-slot="header-updates"
type="button"
onClick={onShowUpdates}
className="inline-flex items-center gap-1.5 rounded-full border border-warning/25 bg-warning/10 px-2.5 py-1 text-xs font-medium text-warning transition-colors hover:bg-warning/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="relative inline-flex size-1.5" aria-hidden="true">
<span className="absolute inline-flex size-1.5 animate-status-ping rounded-full bg-warning opacity-70" />
<span className="relative inline-flex size-1.5 rounded-full bg-warning" />
</span>
{updates} {updates === 1 ? 'update' : 'updates'}
</button>
)}
<ConnectionBadge status={connection} />
</div>
</div>
</header>
)
}
@@ -0,0 +1,70 @@
import { ServerCrash, Boxes } from 'lucide-react'
import { BrandMark } from '@components/brand-mark'
import { Button } from '@components/ui/button'
import { Spinner } from '@components/ui/spinner'
export function LoadingState() {
return (
<div data-slot="loading-state" className="flex flex-col items-center justify-center gap-4 py-28">
<div className="relative">
<BrandMark className="opacity-90" />
<span className="absolute -inset-2 -z-10 rounded-2xl bg-primary/10 blur-xl" aria-hidden="true" />
</div>
<div data-slot="loading-label" className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4 text-primary" />
Connecting to the node…
</div>
</div>
)
}
interface RegistryErrorProps {
onRetry: () => void
}
export function RegistryErrorState({ onRetry }: RegistryErrorProps) {
return (
<div data-slot="registry-error" className="flex items-center justify-between gap-3 rounded-lg border border-warning/25 bg-warning/10 px-4 py-3">
<div className="flex items-center gap-3">
<ServerCrash aria-hidden="true" className="size-4 shrink-0 text-warning" />
<p data-slot="registry-error-text" className="text-sm text-warning">
The dcntrlized registry is unreachable. Catalog and install actions are unavailable — live services still shown.
</p>
</div>
<Button data-slot="registry-error-retry" size="sm" variant="outline" onPress={onRetry} className="shrink-0 border-warning/25 text-warning hover:bg-warning/10">
Retry
</Button>
</div>
)
}
interface EmptyStateProps {
query: string
filter: string
total: number
onClear: () => void
}
export function EmptyState({ query, filter, total, onClear }: EmptyStateProps) {
const emptyNode = total === 0 && !query && filter === 'all'
return (
<div data-slot="empty-state" className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-muted/20 py-20">
<Boxes aria-hidden="true" className="size-8 text-muted-foreground/60" />
<div className="text-center">
<p data-slot="empty-title" className="text-sm font-medium text-foreground">{emptyNode ? 'Nothing on the node yet' : 'No services here'}</p>
<p data-slot="empty-detail" className="mt-1 text-sm text-muted-foreground">
{emptyNode
? 'Install something from the registry to see it on the console.'
: query
? `Nothing matches “${query}” under ${filter}.`
: `No services match the ${filter} filter.`}
</p>
</div>
{!emptyNode && (
<Button data-slot="empty-clear" size="sm" variant="ghost" onPress={onClear}>
Clear filters
</Button>
)}
</div>
)
}
@@ -0,0 +1,92 @@
import { Search } from 'lucide-react'
import { Input } from '@components/ui/input'
import { FILTERS } from '@lib/domain/filter'
import type { ServiceFilter } from '@lib/types'
import { cn } from '@lib/utils'
export interface ToolbarCounts {
total: number
running: number
updates: number
available: number
}
interface ToolbarProps {
query: string
onQuery: (query: string) => void
filter: ServiceFilter
onFilter: (filter: ServiceFilter) => void
counts: ToolbarCounts
}
const COUNT_KEY: Record<ServiceFilter, keyof ToolbarCounts> = {
all: 'total',
running: 'running',
updates: 'updates',
available: 'available',
}
export function Toolbar({ query, onQuery, filter, onFilter, counts }: ToolbarProps) {
return (
<div data-slot="toolbar" className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div data-slot="toolbar-search" className="relative w-full max-w-md">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
role="searchbox"
aria-label="Search services"
placeholder="Search services, images, sources…"
value={query}
onInput={(event) => onQuery((event.target as HTMLInputElement).value)}
className="pl-9"
/>
</div>
<div
data-slot="toolbar-filters"
role="group"
aria-label="Filter services"
className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg border border-border bg-muted/60 p-1 lg:w-fit"
>
{FILTERS.map((option) => {
const active = filter === option.id
const count = counts[COUNT_KEY[option.id]]
return (
<button
key={option.id}
data-slot={`filter-${option.id}`}
type="button"
aria-pressed={active}
aria-label={`${option.label} — ${option.hint}`}
title={option.hint}
onClick={() => onFilter(option.id)}
className={cn(
'inline-flex h-7 flex-1 items-center justify-center gap-1.5 rounded-md px-3 text-xs font-medium whitespace-nowrap transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 lg:flex-none',
active
? 'bg-card text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground'
)}
>
{option.id === 'updates' && count > 0 && (
<span className="size-1.5 rounded-full bg-warning" aria-hidden="true" />
)}
{option.label}
<span
data-slot={`filter-count-${option.id}`}
className={cn(
'font-mono text-xs tabular-nums',
active ? 'text-muted-foreground' : 'text-muted-foreground/70'
)}
>
{count}
</span>
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,265 @@
import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Card, CardContent, CardFooter } from '@components/ui/card'
import { statusMeta } from '@components/container/status'
import { toast } from 'sonner'
import { installContainer, startContainer, stopContainer, uninstallContainer } from '@lib/api/containers'
import type { Service } from '@lib/types'
import { cn } from '@lib/utils'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
interface ServiceCardProps {
service: Service
}
function withV(s?: string): string | null {
if (!s) return null
return s.startsWith('v') || /^\d/.test(s) ? s : `v${s}`
}
function formatVersion(s?: string): string | null {
const v = withV(s)
return v ? `v${v.replace(/^v/, '')}` : null
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
export function ServiceCard({ service }: ServiceCardProps) {
const meta = statusMeta(service.status)
const installed = service.origin !== 'remote'
const running =
service.status.toLowerCase() === 'running' ||
service.status.toLowerCase() === 'starting' ||
service.status.toLowerCase() === 'restarting'
const current = formatVersion(service.version)
const latest = formatVersion(service.latestVersion)
const shortContainerId = service.id.slice(0, 12)
const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name
const copyId = useAsyncAction(async () => {
try {
await navigator.clipboard.writeText(service.id)
toast.success(`Copied ${service.name} container ID`, { description: service.id })
} catch (error) {
toast.error(`Failed to copy ${service.name} container ID`, { description: errorMessage(error) })
}
})
const copyImage = useAsyncAction(async () => {
if (!service.image) return
try {
await navigator.clipboard.writeText(service.image)
toast.success(`Copied ${service.name} image ref`, { description: service.image })
} catch (error) {
toast.error(`Failed to copy ${service.name} image ref`, { description: errorMessage(error) })
}
})
const install = useAsyncAction(async () => {
try {
await installContainer(service)
toast.success(`Installed ${service.name}`, { description: latest ? `v${latest.replace(/^v/, '')} from the dcntrlized registry` : undefined })
} catch (error) {
toast.error(`Failed to install ${service.name}`, { description: errorMessage(error) })
}
})
const update = useAsyncAction(async () => {
try {
await uninstallContainer(service)
await installContainer(service)
toast.success(`Updated ${service.name}`, { description: latest ? `v${current ?? ''} → ${latest}`.trim() : undefined })
} catch (error) {
toast.error(`Failed to update ${service.name}`, { description: errorMessage(error) })
}
})
const start = useAsyncAction(async () => {
try {
await startContainer(service)
toast.success(`Started ${service.name}`)
} catch (error) {
toast.error(`Failed to start ${service.name}`, { description: errorMessage(error) })
}
})
const stop = useAsyncAction(async () => {
try {
await stopContainer(service)
toast.success(`Stopped ${service.name}`)
} catch (error) {
toast.error(`Failed to stop ${service.name}`, { description: errorMessage(error) })
}
})
const uninstall = useAsyncAction(async () => {
try {
await uninstallContainer(service)
toast.success(`Uninstalled ${service.name}`)
} catch (error) {
toast.error(`Failed to uninstall ${service.name}`, { description: errorMessage(error) })
}
})
const busy = install.pending || update.pending || start.pending || stop.pending || uninstall.pending
return (
<Card data-slot="service-card" className="relative overflow-hidden transition-[border-color,box-shadow] duration-200 hover:border-border/90 hover:shadow-card-hover">
<CardContent className="flex flex-col gap-4 pt-5">
<div className="flex items-start gap-3">
<span
data-slot="card-tile"
aria-hidden="true"
className={cn('grid size-9 shrink-0 place-items-center rounded-lg border text-sm font-semibold', meta.tile)}
>
{service.name.charAt(0).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 data-slot="card-title" className="truncate text-sm font-semibold tracking-tight text-foreground">
{service.title || service.name}
</h3>
{service.vendor && (
<span data-slot="card-vendor" className="truncate font-mono text-xs text-muted-foreground">{service.vendor}</span>
)}
</div>
{service.image ? (
<button
data-slot="card-image"
type="button"
onClick={copyImage.run}
title={`Copy image ref — ${service.image}`}
aria-label={`Copy ${service.name} image ref`}
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground transition-colors hover:border-border/90 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="truncate">{shortImage}</span>
<Copy aria-hidden="true" className="size-3 shrink-0" />
</button>
) : (
<p data-slot="card-name" className="truncate font-mono text-xs text-muted-foreground">{service.name}</p>
)}
</div>
<span data-slot="card-status" className={cn('inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium', meta.chip)}>
{meta.ping && (
<span className="relative inline-flex size-1.5" aria-hidden="true">
<span className={cn('absolute inline-flex size-1.5 animate-status-ping rounded-full opacity-70', meta.dot)} />
<span className={cn('relative inline-flex size-1.5 rounded-full', meta.dot)} />
</span>
)}
{meta.label}
</span>
</div>
{service.description && (
<p data-slot="card-description" className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{service.description}
</p>
)}
<div className="flex min-w-0 items-center gap-2.5">
<span data-slot="card-version" className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground">
{installed ? current ?? '—' : latest ?? '—'}
</span>
{service.hasUpdate && latest && (
<span data-slot="card-update" className="inline-flex shrink-0 items-center gap-1 rounded-md border border-warning/25 bg-warning/10 px-1.5 py-0.5 font-mono text-xs tabular-nums text-warning">
<ArrowUp aria-hidden="true" className="size-3" />
{latest}
</span>
)}
{running && installed && (
<button
data-slot="card-container-id"
type="button"
onClick={copyId.run}
title={`Copy container ID — ${service.id}`}
aria-label={`Copy container ID — ${shortContainerId}`}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground transition-colors hover:border-border/90 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
{shortContainerId}
<Copy aria-hidden="true" className="size-3" />
</button>
)}
{service.source && (
<a
data-slot="card-source"
href={service.source}
target="_blank"
rel="noreferrer"
title={`Source — ${service.source}`}
className="ml-auto inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
Source
<ExternalLink aria-hidden="true" className="size-3" />
</a>
)}
</div>
</CardContent>
<CardFooter className="justify-end pt-4">
{installed && (
<Button
data-slot="card-uninstall"
variant="ghost"
size="icon-sm"
isDisabled={busy}
onPress={uninstall.run}
aria-label={`Uninstall ${service.name}`}
className="mr-auto text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
{uninstall.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Trash2 aria-hidden="true" className="size-3" />}
</Button>
)}
{installed && service.hasUpdate && (
<Button
data-slot="card-update-action"
size="sm"
isDisabled={busy}
onPress={update.run}
className={cn(
'border border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 focus-visible:border-warning/40 focus-visible:ring-warning/20'
)}
>
{update.pending ? (
<LoaderCircle aria-hidden="true" className="size-3 animate-spin" />
) : (
<ArrowUp aria-hidden="true" className="size-3" />
)}
Update
</Button>
)}
{running ? (
<Button data-slot="card-stop" variant="outline" size="sm" className="bg-destructive/10 text-destructive/80 hover:bg-destructive/20 hover:text-destructive" isDisabled={busy} onPress={stop.run}>
{stop.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Stop
</Button>
) : installed ? (
<Button
data-slot="card-start"
size="sm"
isDisabled={busy}
onPress={start.run}
className={cn(
'border border-success/30 bg-success/10 text-success hover:bg-success/20 focus-visible:border-success/40 focus-visible:ring-success/20'
)}
>
{start.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Start
</Button>
) : (
<Button data-slot="card-install" className="" size="sm" isDisabled={busy} onPress={install.run}>
{install.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Download aria-hidden="true" className="size-3" />}
Install
</Button>
)}
</CardFooter>
</Card>
)
}
@@ -0,0 +1,16 @@
import type { Service } from '@lib/types'
import { ServiceCard } from './service-card'
interface ServiceGridProps {
services: Service[]
}
export function ServiceGrid({ services }: ServiceGridProps) {
return (
<div data-slot="service-grid" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{services.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive/10 text-destructive",
outline: "border-border text-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & {
render?: (props: React.HTMLAttributes<HTMLElement>) => React.ReactNode
}) {
if (render) {
const renderProps = {
"data-slot": "badge",
"data-variant": variant,
className: cn(badgeVariants({ variant }), className),
...props,
}
return render(renderProps)
}
return (
<span
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -7,22 +7,20 @@ import {
type LinkProps as LinkPrimitiveProps, type LinkProps as LinkPrimitiveProps,
} from "react-aria-components" } from "react-aria-components"
import { cn } from "@lib/shadcn/lib/utils" import { cn } from "@lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", "border-input bg-transparent text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
+89
View File
@@ -0,0 +1,89 @@
import * as React from "react"
import { cn } from "@lib/utils"
function Card({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col rounded-xl border border-border bg-card text-card-foreground shadow-card",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("grid gap-1.5 px-5 pt-4 pb-2", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-sm font-semibold tracking-tight text-foreground", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("self-start justify-self-end", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-5 pb-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("mt-auto flex items-center gap-2 border-t border-border px-5 pt-3 pb-4", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import {
composeRenderProps,
Input as InputPrimitive,
} from "react-aria-components"
import { cn } from "@lib/utils"
function Input({
className,
type,
...props
}: React.ComponentProps<typeof InputPrimitive>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={composeRenderProps(className, (className) =>
cn(
"h-9 w-full min-w-0 rounded-lg border border-input bg-muted/40 px-3 py-1.5 text-sm transition-[color,box-shadow] duration-150 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50",
className
)
)}
{...props}
/>
)
}
export { Input }
+49
View File
@@ -0,0 +1,49 @@
import { CheckCircle2, XCircle } from 'lucide-react'
import { Toaster as Sonner } from 'sonner'
import type * as React from 'react'
type ToasterProps = React.ComponentProps<typeof Sonner>
function Toaster({ ...props }: ToasterProps) {
return (
<Sonner
theme="dark"
richColors
position="bottom-right"
gap={12}
duration={6000}
closeButton
icons={{
success: <CheckCircle2 aria-hidden="true" className="size-3.5 text-success" />,
error: <XCircle aria-hidden="true" className="size-3.5 text-destructive" />,
}}
toastOptions={{
classNames: {
toast: 'font-sans text-sm!',
description: 'text-xs !text-muted-foreground',
},
style: {
boxShadow: 'var(--toast-shadow)',
},
}}
style={
{
'--width': '20rem',
'--border-radius': 'var(--radius)',
'--normal-bg': 'var(--popover)',
'--normal-border': 'var(--border)',
'--normal-text': 'var(--popover-foreground)',
'--success-bg': 'var(--popover)',
'--success-border': 'color-mix(in oklab, var(--success) 25%, transparent)',
'--success-text': 'var(--popover-foreground)',
'--error-bg': 'var(--popover)',
'--error-border': 'color-mix(in oklab, var(--destructive) 30%, transparent)',
'--error-text': 'var(--popover-foreground)',
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,16 @@
import { cn } from "@lib/utils"
import { LoaderCircle } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<LoaderCircle
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+216 -175
View File
@@ -1,205 +1,246 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "@fontsource-variable/inter"; @import "@fontsource-variable/ibm-plex-sans";
@import "@fontsource-variable/jetbrains-mono";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* ---------------------------------------------------------------------------
Maestro console — dark ops world.
Scene: an operator watches their self-hosted node at night. Near-black
blue-zinc ink, hairline borders, raspberry primary, warm status lights.
Baseline: desktop-first at 150% density. Achieved with font-size: 150% on
<html> (below) — every rem-based utility (text-sm, h-8, gap-4, max-w-xl)
scales 1.5× while keeping its standard name, so no theme values are
rewritten. Px-based values (focus rings, shadows, borders) stay hairline,
and viewport units (min-h-screen, dvh) stay true. Breakpoints are pinned
to CSS px so the responsive grid stays tied to the real viewport and keeps
3 columns on desktop. The only density token is --radius (the design's
11px corner base, authored unscaled and scaled by the root font-size).
--------------------------------------------------------------------------- */
:root { :root {
--text: #6b6375; color-scheme: dark;
--text-h: #08060d;
--bg: #fff;
--border: oklch(0.92 0.004 286.32);
--code-bg: #f4f3ec;
--accent: oklch(0.967 0.001 286.375);
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; /* Surfaces — blue-tinted ink, stacked by elevation */
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --background: oklch(0.14 0.015 265);
--mono: ui-monospace, Consolas, monospace; --card: oklch(0.173 0.017 265);
--popover: oklch(0.19 0.018 265);
--secondary: oklch(0.218 0.026 354);
--muted: oklch(0.205 0.018 265);
--accent: oklch(0.245 0.021 265);
font: 18px/145% var(--sans); /* Ink */
letter-spacing: 0.18px; --foreground: oklch(0.955 0.004 265);
color-scheme: light dark; --muted-foreground: oklch(0.7 0.02 265);
color: var(--text); --card-foreground: oklch(0.955 0.004 265);
background: var(--bg); --popover-foreground: oklch(0.955 0.004 265);
--secondary-foreground: oklch(0.93 0.008 354);
--accent-foreground: oklch(0.955 0.004 265);
/* Structure */
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.58 0.15 354.66 / 0.5);
/* Brand + status — raspberry primary, warm status lights */
--primary: oklch(0.4981 0.1996 354.66);
--primary-foreground: oklch(0.97 0.012 354);
--secondary-accent: oklch(0.63 0.15 354.66);
--tertiary: oklch(0.39 0.14 354.66);
--destructive: oklch(0.66 0.2 24);
--success: oklch(0.74 0.13 160);
--warning: oklch(0.78 0.15 55);
--chart-1: oklch(0.4981 0.1996 354.66);
--chart-2: oklch(0.74 0.13 160);
--chart-3: oklch(0.78 0.15 55);
--chart-4: oklch(0.66 0.2 24);
--chart-5: oklch(0.63 0.15 354.66);
--radius: 0.6875rem;
/* Elevation — soft black drop shadows, tuned for the dark scene */
--card-shadow: 0 1px 0 rgba(0, 0, 0, 0.4), 0 10px 28px -14px rgba(0, 0, 0, 0.6);
--card-shadow-hover: 0 1px 0 rgba(0, 0, 0, 0.4), 0 16px 36px -16px rgba(0, 0, 0, 0.75);
--toast-shadow: 0 8px 30px -8px rgba(0, 0, 0, 0.7);
/* Sidebar tokens kept for shadcn parity */
--sidebar: oklch(0.173 0.017 265);
--sidebar-foreground: oklch(0.955 0.004 265);
--sidebar-primary: oklch(0.4981 0.1996 354.66);
--sidebar-primary-foreground: oklch(0.97 0.012 354);
--sidebar-accent: oklch(0.245 0.021 265);
--sidebar-accent-foreground: oklch(0.955 0.004 265);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.58 0.15 354.66 / 0.5);
font-family: "IBM Plex Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067)
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
} }
@theme inline { @theme inline {
--font-heading: var(--font-sans); --font-sans: "IBM Plex Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-sans: 'Inter Variable', sans-serif; --font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --breakpoint-sm: 640px;
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --breakpoint-md: 768px;
--color-sidebar-accent: var(--sidebar-accent); --breakpoint-lg: 1024px;
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --breakpoint-xl: 1280px;
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --container-console: 75rem;
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-tertiary: var(--tertiary);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.85);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.35);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
--shadow-card: var(--card-shadow);
--shadow-card-hover: var(--card-shadow-hover);
--shadow-toast: var(--toast-shadow);
} }
/* Dark is the console's canonical theme (html carries class="dark").
The block below is an alias so shadcn `dark:` variants stay consistent. */
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.14 0.015 265);
--foreground: oklch(0.985 0 0); --card: oklch(0.173 0.017 265);
--card: oklch(0.21 0.006 285.885); --popover: oklch(0.19 0.018 265);
--card-foreground: oklch(0.985 0 0); --secondary: oklch(0.218 0.026 354);
--popover: oklch(0.21 0.006 285.885); --muted: oklch(0.205 0.018 265);
--popover-foreground: oklch(0.985 0 0); --accent: oklch(0.245 0.021 265);
--primary: oklch(0.424 0.199 265.638); --foreground: oklch(0.955 0.004 265);
--primary-foreground: oklch(0.97 0.014 254.604); --muted-foreground: oklch(0.7 0.02 265);
--secondary: oklch(0.274 0.006 286.033); --card-foreground: oklch(0.955 0.004 265);
--secondary-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.955 0.004 265);
--muted: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.93 0.008 354);
--muted-foreground: oklch(0.705 0.015 286.067); --accent-foreground: oklch(0.955 0.004 265);
--accent: oklch(0.274 0.006 286.033); --border: oklch(1 0 0 / 8%);
--accent-foreground: oklch(0.985 0 0); --input: oklch(1 0 0 / 12%);
--destructive: oklch(0.704 0.191 22.216); --ring: oklch(0.58 0.15 354.66 / 0.5);
--border: oklch(1 0 0 / 10%); --primary: oklch(0.4981 0.1996 354.66);
--input: oklch(1 0 0 / 15%); --primary-foreground: oklch(0.97 0.012 354);
--ring: oklch(0.552 0.016 285.938); --secondary-accent: oklch(0.63 0.15 354.66);
--chart-1: oklch(0.809 0.105 251.813); --tertiary: oklch(0.39 0.14 354.66);
--chart-2: oklch(0.623 0.214 259.815); --destructive: oklch(0.66 0.2 24);
--chart-3: oklch(0.546 0.245 262.881); --success: oklch(0.74 0.13 160);
--chart-4: oklch(0.488 0.243 264.376); --warning: oklch(0.78 0.15 55);
--chart-5: oklch(0.424 0.199 265.638); --sidebar: oklch(0.173 0.017 265);
--sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.955 0.004 265);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.4981 0.1996 354.66);
--sidebar-primary: oklch(0.623 0.214 259.815); --sidebar-primary-foreground: oklch(0.97 0.012 354);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604); --sidebar-accent: oklch(0.245 0.021 265);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.955 0.004 265);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 8%);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.58 0.15 354.66 / 0.5);
--sidebar-ring: oklch(0.552 0.016 285.938);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50;} @apply border-border outline-ring/50;
body { }
@apply bg-background text-foreground;}
button:not(:disabled), [role="button"]:not(:disabled) {
cursor: pointer;}
html { html {
@apply font-sans;}} @apply font-sans;
font-size: 150%;
}
body {
@apply bg-background font-sans text-foreground antialiased;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/* Ambient console glow — a raspberry wash above the header and a faint warm
ember low on the page. Both sit near the edge of perception. */
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
}
body::before {
background: radial-gradient(
56rem 22rem at 50% -9rem,
oklch(0.6 0.15 354.66 / 0.1),
transparent 66%
);
}
body::after {
background: radial-gradient(
42rem 22rem at 88% 112%,
oklch(0.78 0.15 55 / 0.05),
transparent 66%
);
}
/* Status light — a soft expanding ring behind a solid core */
@keyframes status-ping {
0% {
transform: scale(1);
opacity: 0.55;
}
75%,
100% {
transform: scale(2.4);
opacity: 0;
}
}
.animate-status-ping {
animation: status-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-status-ping {
animation: none;
}
}
@@ -1,48 +0,0 @@
// src/lib/components/connectionBadge.tsx
import { useState, useEffect } from 'preact/hooks';
import { registry } from '../ws/registry';
import { connectWebSocket } from '../ws/client';
import { Badge } from '@shadcn/components/ui/badge';
import { BadgeCheck } from 'lucide-react';
export function ConnectionBadge() {
const [status, setStatus] = useState(() => registry.getState().status);
useEffect(() => {
// 1. Kick off the connection on initial component mount
connectWebSocket();
// 2. Subscribe to status changes
const unsubscribe = registry.subscribe(() => {
setStatus(registry.getState().status);
});
return unsubscribe;
}, []);
switch (status) {
case 'open':
return (
<Badge variant="default" className="flex items-center gap-1">
<BadgeCheck className="h-3 w-3 text-green-500" />
Connected
</Badge>
);
case 'connecting':
return (
<Badge variant="secondary" className="flex items-center gap-1">
Connecting...
</Badge>
);
case 'closed':
return (
<Badge variant="destructive" className="flex items-center gap-1">
Disconnected
</Badge>
);
default:
return null;
}
}
@@ -1,52 +0,0 @@
import { useState } from "react"
import { uninstallContainer, installContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
import { Download } from "lucide-react"
interface InstallProps {
container: RegistryItem,
type?: "install" | "update"
}
export function InstallButton({ container, type = 'install' }: InstallProps) {
const [isInstalling, setIsInstalling] = useState(false)
const handleInstall = async () => {
if(type === 'update') uninstallContainer(container)
setIsInstalling(true)
try {
await installContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsInstalling(false)
}
}
const buttonString = () => {
if(type === 'install') return isInstalling ? "Installing..." : "Install"
return isInstalling ? "Updating..." : "Update"
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
isDisabled={isInstalling} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleInstall} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isInstalling ? (
<Spinner />
) : (
<Download />
)}
<span>{buttonString()}</span>
</>
</Button>
)
}
@@ -1,44 +0,0 @@
import { useState } from "react"
import { startContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Play } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface StartProps {
container: RegistryItem,
}
export function StartButton({ container }: StartProps) {
const [isStarting, setIsStarting] = useState(false)
const handleStart = async () => {
setIsStarting(true)
try {
await startContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsStarting(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5 bg-emerald-600 hover:bg-emerald-700 text-emerald-100 hover:text-emerald-300"
isDisabled={isStarting} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleStart} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isStarting ? (
<Spinner />
) : (
<Play />
)}
<span>{isStarting ? "Starting..." : "Start" }</span>
</>
</Button>
)
}
@@ -1,45 +0,0 @@
import { useState } from "react"
import { stopContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Square } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface StopProps {
container: RegistryItem,
}
export function StopButton({ container }: StopProps) {
const [isStopping, setIsStopping] = useState(false)
const handleStop = async () => {
setIsStopping(true)
try {
await stopContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsStopping(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
variant="destructive"
isDisabled={isStopping} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleStop} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isStopping ? (
<Spinner />
) : (
<Square />
)}
<span>{isStopping ? "Stopping..." : "Stop"}</span>
</>
</Button>
)
}
@@ -1,45 +0,0 @@
import { useState } from "react"
import { uninstallContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Trash2 } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface UninstallProps {
container: RegistryItem,
}
export function UninstallButton({ container }: UninstallProps) {
const [isUninstalling, setIsUninstalling] = useState(false)
const handleInstall = async () => {
setIsUninstalling(true)
try {
await uninstallContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsUninstalling(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
variant='destructive'
isDisabled={isUninstalling} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleInstall} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isUninstalling ? (
<Spinner />
) : (
<Trash2 />
)}
<span>{isUninstalling ? "Uninstalling..." : "Uninstall"}</span>
</>
</Button>
)
}
@@ -1,56 +0,0 @@
import type { RegistryItem } from "@lib/types";
export function installContainer(remoteItem: RegistryItem): Promise<void> {
// For now simply call /api/containers/install with body { item: RemoteItem } to install the container
return fetch('/api/containers/install', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to install container ${remoteItem.name}: ${response.statusText}`);
}
});
}
export function uninstallContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/uninstall", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to uninstall container ${remoteItem.name}: ${response.statusText}`)
}
})
}
export function startContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/start", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to start container ${remoteItem.name}: ${response.statusText}`)
}
})}
export function stopContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/stop", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to stop container ${remoteItem.name}: ${response.statusText}`)
}
})}
@@ -1,58 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@shadcn/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & {
render?: (props: React.HTMLAttributes<HTMLElement>) => React.ReactNode
}) {
if (render) {
const renderProps = {
"data-slot": "badge",
"data-variant": variant,
className: cn(badgeVariants({ variant }), className),
...props,
}
return render(renderProps)
}
return (
<span
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -1,100 +0,0 @@
import * as React from "react"
import { cn } from "@shadcn/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -1,29 +0,0 @@
import * as React from "react"
import {
composeRenderProps,
Input as InputPrimitive,
} from "react-aria-components"
import { cn } from "@shadcn/lib/utils"
function Input({
className,
type,
...props
}: React.ComponentProps<typeof InputPrimitive>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={composeRenderProps(className, (className) =>
cn(
"h-8 w-full min-w-0 rounded-2xl border border-transparent bg-input/50 px-2.5 py-1 text-base transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)
)}
{...props}
/>
)
}
export { Input }
@@ -1,10 +0,0 @@
import { cn } from "@shadcn/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
@@ -1,91 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import {
TabList as TabListPrimitive,
TabPanel as TabPanelPrimitive,
Tab as TabPrimitive,
Tabs as TabsPrimitive,
} from "react-aria-components"
import { cn } from "@shadcn/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive>) {
return (
<TabsPrimitive
data-slot="tabs"
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-2xl p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:p-1 data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabListPrimitive> &
VariantProps<typeof tabsListVariants>) {
return (
<TabListPrimitive
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabPrimitive>) {
return (
<TabPrimitive
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-default items-center justify-center gap-1.5 rounded-2xl border border-transparent! px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-0.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-selected:bg-transparent dark:group-data-[variant=line]/tabs-list:data-selected:border-transparent dark:group-data-[variant=line]/tabs-list:data-selected:bg-transparent",
"data-selected:bg-background data-selected:text-foreground dark:data-selected:border-input dark:data-selected:bg-input/30 dark:data-selected:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-end-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-selected:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabPanelPrimitive>) {
return (
<TabPanelPrimitive
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -2,4 +2,4 @@ import { render } from 'preact'
import './index.css' import './index.css'
import { App } from './app.tsx' import { App } from './app.tsx'
render(<App />, document.querySelector("body")!) render(<App />, document.getElementById('root')!)
+1 -1
View File
@@ -13,7 +13,7 @@
"react/jsx-runtime": ["./node_modules/preact/jsx-runtime"], "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
"react/jsx-dev-runtime": ["./node_modules/preact/jsx-dev-runtime"], "react/jsx-dev-runtime": ["./node_modules/preact/jsx-dev-runtime"],
"@lib/*": ["./src/lib/*"], "@lib/*": ["./src/lib/*"],
"@shadcn/*": ["./src/lib/shadcn/*"] "@components/*": ["./src/components/*"]
}, },
/* Bundler mode */ /* Bundler mode */
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig(({ command }): UserConfig => {
resolve: { resolve: {
alias: { alias: {
"@lib": path.resolve(__dirname, "./src/lib"), "@lib": path.resolve(__dirname, "./src/lib"),
"@shadcn": path.resolve(__dirname, "./src/lib/shadcn"), "@components": path.resolve(__dirname, "./src/components"),
"react": path.resolve(__dirname, "./node_modules/preact/compat"), "react": path.resolve(__dirname, "./node_modules/preact/compat"),
}, },
}, },