feat(frontend): redesign console UI with new component tree and design system
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -5,17 +5,16 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"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",
|
||||
"@types/semver": "^7.7.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"preact": "^10.29.3",
|
||||
"react-aria-components": "^1.19.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"semver": "^7.8.5",
|
||||
"shadcn": "^4.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -227,8 +228,6 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -729,6 +726,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
|
||||
"dot-prop/is-obj": ["[email protected]", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="theme-color" content="#101116" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Maestro · dcntrlized console</title>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,17 +9,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"@types/semver": "^7.7.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"preact": "^10.29.3",
|
||||
"react-aria-components": "^1.19.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"semver": "^7.8.5",
|
||||
"shadcn": "^4.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
+66
-323
@@ -1,341 +1,84 @@
|
||||
import './app.css'
|
||||
import { ConnectionBadge } from '@lib/components/connectionBadge'
|
||||
import { registry } from '@lib/ws/registry'
|
||||
import { useEffect, useState, useMemo } from 'preact/hooks'
|
||||
import {
|
||||
type Container,
|
||||
type RegistryItem,
|
||||
STATUS_PRIORITY
|
||||
} from '@lib/types'
|
||||
import semver from 'semver'
|
||||
|
||||
// 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))
|
||||
}
|
||||
import { useMemo, useState } from 'preact/hooks'
|
||||
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
||||
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
||||
import { mergeServices } from '@lib/domain/merge'
|
||||
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
||||
import type { ServiceFilter } from '@lib/types'
|
||||
import { Header } from '@components/layout/header'
|
||||
import { Toolbar } from '@components/layout/toolbar'
|
||||
import { ServiceGrid } from '@components/service/service-grid'
|
||||
import { Toaster } from '@components/ui/sonner'
|
||||
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
|
||||
|
||||
export function App() {
|
||||
const [rawContainers, setRawContainers] = useState<Container[]>(() => registry.getState().containers)
|
||||
const [masterRegistry, setMasterRegistry] = useState<RegistryItem[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'running' | 'updates' | 'missing'>('all')
|
||||
const { containers, status } = useContainerStore()
|
||||
const registry = useMasterRegistry()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = registry.subscribe(() => {
|
||||
setRawContainers(registry.getState().containers)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
const services = useMemo(() => {
|
||||
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
||||
return mergeServices(containers, items)
|
||||
}, [containers, registry.state])
|
||||
|
||||
useEffect(() => {
|
||||
const getRegistryItems = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/registry')
|
||||
const data: Array<RegistryItem> = await response.json()
|
||||
setMasterRegistry(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch remote registry:", err)
|
||||
const counts = useMemo(() => serviceCounts(services), [services])
|
||||
const visible = useMemo(
|
||||
() => filterServices(services, query, filter),
|
||||
[services, query, filter]
|
||||
)
|
||||
|
||||
const loading = registry.state.kind === 'loading' && containers.length === 0
|
||||
const registryError = registry.state.kind === 'error'
|
||||
|
||||
const showUpdates = () => setFilter('updates')
|
||||
const clearFilters = () => {
|
||||
setQuery('')
|
||||
setFilter('all')
|
||||
}
|
||||
}
|
||||
getRegistryItems()
|
||||
}, [])
|
||||
|
||||
// Hydrate into a single clean RegistryItem list
|
||||
const containers = useMemo(() => {
|
||||
const map = new Map<string, RegistryItem>()
|
||||
|
||||
// 1. Process local containers from Websocket
|
||||
for (const c of rawContainers) {
|
||||
if (c.name.toUpperCase().includes("MAESTRO")) continue
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans 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">
|
||||
<div className="max-w-7xl mx-auto flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground m-0">Container Manager</h1>
|
||||
<Badge variant="secondary" className="px-2.5 py-0.5 text-xs font-semibold">
|
||||
{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">
|
||||
<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">
|
||||
<div className="relative flex-1">
|
||||
<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>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search containers, images, or repositories..."
|
||||
value={searchQuery}
|
||||
onInput={(e: Event) => setSearchQuery((e.target as HTMLInputElement).value)}
|
||||
className="pl-9"
|
||||
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
|
||||
<Header
|
||||
installed={counts.installed}
|
||||
updates={counts.updates}
|
||||
connection={status}
|
||||
onShowUpdates={showUpdates}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
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>
|
||||
<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">
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{registryError && <RegistryErrorState onRetry={registry.retry} />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filteredContainers.map((container) => {
|
||||
const isRemoteOnly = container.origin === 'remote'
|
||||
const isInGitRegistry = container.origin === 'both' || isRemoteOnly
|
||||
const isRunning = (container.status || '').toLowerCase() === 'running'
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
counts={{
|
||||
total: counts.total,
|
||||
running: counts.running,
|
||||
updates: counts.updates,
|
||||
available: counts.available,
|
||||
}}
|
||||
/>
|
||||
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground" role="status">
|
||||
Showing {visible.length} of {counts.total} services
|
||||
</p>
|
||||
|
||||
<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>
|
||||
{visible.length === 0 ? (
|
||||
<EmptyState query={query} filter={filter} total={counts.total} onClear={clearFilters} />
|
||||
) : (
|
||||
<ServiceGrid services={visible} />
|
||||
)}
|
||||
{(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 →
|
||||
</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>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
+6
-8
@@ -7,22 +7,20 @@ import {
|
||||
type LinkProps as LinkPrimitiveProps,
|
||||
} from "react-aria-components"
|
||||
|
||||
import { cn } from "@lib/shadcn/lib/utils"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
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",
|
||||
secondary:
|
||||
"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 aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
"border-input bg-transparent text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
|
||||
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted",
|
||||
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",
|
||||
},
|
||||
size: {
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -1,205 +1,246 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-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 *));
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
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 {
|
||||
--text: #6b6375;
|
||||
--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;
|
||||
color-scheme: dark;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
/* Surfaces — blue-tinted ink, stacked by elevation */
|
||||
--background: oklch(0.14 0.015 265);
|
||||
--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);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
/* Ink */
|
||||
--foreground: oklch(0.955 0.004 265);
|
||||
--muted-foreground: oklch(0.7 0.02 265);
|
||||
--card-foreground: oklch(0.955 0.004 265);
|
||||
--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;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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 {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Inter Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--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);
|
||||
--font-sans: "IBM Plex Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
|
||||
--container-console: 75rem;
|
||||
|
||||
--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-md: calc(var(--radius) * 0.8);
|
||||
--radius-md: calc(var(--radius) * 0.85);
|
||||
--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-3xl: calc(var(--radius) * 2.2);
|
||||
--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 {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.424 0.199 265.638);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--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);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--background: oklch(0.14 0.015 265);
|
||||
--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);
|
||||
--foreground: oklch(0.955 0.004 265);
|
||||
--muted-foreground: oklch(0.7 0.02 265);
|
||||
--card-foreground: oklch(0.955 0.004 265);
|
||||
--popover-foreground: oklch(0.955 0.004 265);
|
||||
--secondary-foreground: oklch(0.93 0.008 354);
|
||||
--accent-foreground: oklch(0.955 0.004 265);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 12%);
|
||||
--ring: oklch(0.58 0.15 354.66 / 0.5);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;}
|
||||
body {
|
||||
@apply bg-background text-foreground;}
|
||||
button:not(:disabled), [role="button"]:not(:disabled) {
|
||||
cursor: pointer;}
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
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 }
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { render } from 'preact'
|
||||
import './index.css'
|
||||
import { App } from './app.tsx'
|
||||
|
||||
render(<App />, document.querySelector("body")!)
|
||||
render(<App />, document.getElementById('root')!)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
|
||||
"react/jsx-dev-runtime": ["./node_modules/preact/jsx-dev-runtime"],
|
||||
"@lib/*": ["./src/lib/*"],
|
||||
"@shadcn/*": ["./src/lib/shadcn/*"]
|
||||
"@components/*": ["./src/components/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig(({ command }): UserConfig => {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@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"),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user