Template
Inital template
This commit is contained in:
commit
54faec3540
62 files changed
+6808
No files matched your search
@@ -0,0 +1,73 @@
|
||||
# .eventsourced/session.yaml for Circuit's frontend template (React + Vite +
|
||||
# Tailwind, with a mock API that vite.config.ts starts). Commit it at the
|
||||
# ROOT of the template project; every initiative created from the template
|
||||
# then has a ready dev server and a published preview when its session
|
||||
# starts, instead of waiting for the agent (or the preview tab) to start one.
|
||||
# See docs/circuit-session-startup.md for the design and how to verify it.
|
||||
#
|
||||
# It runs only where the deployment sets SESSION_STARTUP=true (Circuit's does),
|
||||
# and a session can opt out with the per-session `session_startup: false`.
|
||||
# Port exposure also needs the session to be allowed network.expose, which
|
||||
# every Circuit mode allows (TestNoCircuitModeDeniesNetworkExpose).
|
||||
#
|
||||
# Why each step is written the way it is:
|
||||
#
|
||||
# * Install only when node_modules is missing (a worker that kept its
|
||||
# workspace volume does not reinstall), with `npm ci` when there is a
|
||||
# lockfile and `npm install` when there is not.
|
||||
#
|
||||
# * The dev server binds 0.0.0.0 and uses --strictPort on 5173, the port the
|
||||
# template serves on (its README says so and vite.config.ts does not set
|
||||
# another). Circuit's own preview start takes whatever port is published
|
||||
# first, so the port declared here is the one the preview uses.
|
||||
#
|
||||
# * Vite refuses requests whose Host header it does not know ("Blocked
|
||||
# request. This host is not allowed"). The published host is
|
||||
# <label>.<PREVIEW_DOMAIN> and does not exist yet when the server starts
|
||||
# (the port is published after it). Circuit's own preview start tells Vite
|
||||
# the host through __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS; here the whole
|
||||
# suffix is allowed from PREVIEW_DOMAIN, which the orchestrator's
|
||||
# controller passes into every worker pod. A pod without the variable (one
|
||||
# that predates it) gets an empty value and Vite keeps its default; then
|
||||
# restart the workspace, or set server.allowedHosts in vite.config.ts.
|
||||
#
|
||||
# * A change conversation works in its own worktree under
|
||||
# .circuit/worktrees/<id>, on the same worker, and Circuit swaps the
|
||||
# running preview between the main checkout and a change when one is
|
||||
# opened. Starting a second server on the same port (--strictPort would
|
||||
# fail) or installing a second node_modules there would only produce a
|
||||
# failed startup in the change's prompt, so both run steps do nothing in a
|
||||
# worktree.
|
||||
#
|
||||
# * disable_in_subagent on every step: a spawned subagent shares its
|
||||
# parent's checkout and must not install or serve again.
|
||||
#
|
||||
# `exec` makes the named background process the dev server itself, so the
|
||||
# agent's process tool (list / read / write / kill / spawn) acts on it
|
||||
# directly.
|
||||
|
||||
notes: |
|
||||
The app's Vite dev server is already running as the background process
|
||||
"dev" on port 5173, and that port is published: the live preview works
|
||||
without you starting anything. Do not start a second dev server on that
|
||||
port.
|
||||
- Vite reloads on every file write and restarts itself when vite.config.ts
|
||||
changes; the mock API (mock/server.mjs) is started by vite.config.ts in
|
||||
the same process, so there is no separate step for it.
|
||||
- After you add or remove a dependency, run `npm install`, then restart
|
||||
"dev": kill "dev" with the process tool, then spawn "dev" again with
|
||||
exactly this command:
|
||||
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${PREVIEW_DOMAIN:+.$PREVIEW_DOMAIN} npm run dev -- --host 0.0.0.0 --port 5173 --strictPort
|
||||
- A change conversation works in its own worktree. Circuit starts the
|
||||
preview there when it is asked for, so none of these steps run in a
|
||||
worktree.
|
||||
startup:
|
||||
- run: 'case "$(pwd -P)" in */.circuit/worktrees/*) echo "change worktree: Circuit starts its own preview"; exit 0;; esac; test -d node_modules || { if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi; }'
|
||||
timeout_seconds: 900
|
||||
disable_in_subagent: true
|
||||
- run: 'case "$(pwd -P)" in */.circuit/worktrees/*) exit 0;; esac; __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${PREVIEW_DOMAIN:+.$PREVIEW_DOMAIN} exec npm run dev -- --host 0.0.0.0 --port 5173 --strictPort'
|
||||
background: dev
|
||||
disable_in_subagent: true
|
||||
- expose: 5173
|
||||
name: preview
|
||||
disable_in_subagent: true
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Circuit change worktrees
|
||||
.circuit/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.sw?
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"react",
|
||||
"typescript",
|
||||
"oxc"
|
||||
],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": [
|
||||
"warn",
|
||||
{
|
||||
"allowConstantExport": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
Greenfield: when you edit the page, expose the dev port so the user can collaborate on the
|
||||
design by opening the page in their own browser.
|
||||
|
||||
Guidance for AI coding agents working in this repo. The full conventions live in
|
||||
[README.md](./README.md) — read it before making changes.
|
||||
|
||||
Non-obvious facts:
|
||||
|
||||
- The dev server starts a mock API on port 8787 (`mock/server.mjs`) and proxies `/api` to it.
|
||||
There is no real backend; data comes from `useSWR` hooks in `src/api/hooks.ts`.
|
||||
- Both themes are driven by CSS variables at the top of `src/index.css`. Use semantic Tailwind
|
||||
tokens (`bg-card`, `text-muted-foreground`), never raw hex values, or dark mode breaks.
|
||||
- The look is Stackship: Poppins, blue `primary` + green `accent`, the blue→green gradient as
|
||||
an accent (`text-gradient`, `bg-brand`), large radii (`rounded-xl` = 20px), Lucide icons.
|
||||
No purple/pink SaaS gradients, no emoji.
|
||||
- Fonts are bundled with Fontsource on purpose — don't add Google Fonts or other CDN links;
|
||||
Stackship apps often run on clusters without internet egress.
|
||||
- React Compiler is enabled — keep components pure; don't add manual memoization.
|
||||
- `npm run build` type-checks; run it before declaring work done.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Stackship Circuit template
|
||||
|
||||
A React 19 + Vite starting point for apps that Circuit builds and Stackship runs. Routing,
|
||||
light/dark theming, a typed API layer and the Stackship components are already wired, so an
|
||||
agent (or you) starts with product work instead of plumbing.
|
||||
|
||||
It follows the same contract as Circuit's frontend template — dev server on `:5173`, mock API
|
||||
at `/api`, `.eventsourced/session.yaml` for the live preview — with the Stackship design system
|
||||
on top.
|
||||
|
||||
## Stack
|
||||
|
||||
- **React 19** with the React Compiler enabled
|
||||
- **Vite 8** dev server, with a mock API served at `/api`
|
||||
- **Tailwind v4** — Stackship tokens defined as CSS variables in `src/index.css`
|
||||
- **Radix UI** (unified `radix-ui` package) for accessible primitives
|
||||
- **SWR** for data fetching, **react-router** for routing, **lucide-react** for icons
|
||||
- **Poppins + JetBrains Mono** bundled through Fontsource — no CDN calls, works air-gapped
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
api/ # typed API layer — one SWR hook per endpoint
|
||||
types.ts # response types
|
||||
client.ts # fetch wrapper (all network calls go through here)
|
||||
hooks.ts # useStats(), useResources(), … consumed by pages
|
||||
components/
|
||||
ui/ # Stackship primitives: Button, Card, Badge, ServiceIcon, ProgressBar, Skeleton
|
||||
gallery/ # component-gallery demos, one primitive per file
|
||||
dashboard/ # dashboard sections (StatGrid, ResourcesTable, SentinelCard…)
|
||||
home/ # home page sections (Hero, EnvironmentCard, LoopSection, StackGrid)
|
||||
Layout.tsx # header + footer, wraps every route
|
||||
Logo.tsx # Stackship wordmark, light/dark aware
|
||||
ThemeProvider.tsx / ThemeToggle.tsx
|
||||
pages/ # one file per route, registered in App.tsx
|
||||
mock/server.mjs # zero-dependency mock API (started by the dev server)
|
||||
public/ # favicon and Stackship logos
|
||||
```
|
||||
|
||||
## Conventions (follow these when editing — applies to AI agents too)
|
||||
|
||||
- **Colours and fonts come from tokens only.** Use Tailwind classes like `bg-card`,
|
||||
`text-muted-foreground`, `border-border`, `bg-primary`. Never hardcode hex values; both modes
|
||||
read from the variables at the top of `src/index.css`. The one exception is the always-dark
|
||||
hero surface, which uses `bg-night` and white text in both themes.
|
||||
- **Brand utilities:** `text-gradient` (blue→green headline words), `bg-brand` (gradient CTAs
|
||||
and meters), `glass`, `glow-primary` / `glow-accent` (blurred backdrops on dark sections).
|
||||
Use the gradient sparingly — one highlight per screen reads as deliberate.
|
||||
- **Resource types** get their accent from `ServiceIcon` (`app`, `database`, `storage`, …).
|
||||
Don't invent new hues for them.
|
||||
- **Sentinel** (the AI/ops assistant) uses the rose `sentinel` colour and nothing else does.
|
||||
- **New route** = one file in `src/pages/` + one line in `src/App.tsx` (+ nav entry in
|
||||
`Layout.tsx`).
|
||||
- **New data** = type in `src/api/types.ts`, route in `mock/server.mjs`, hook in
|
||||
`src/api/hooks.ts`. Pages never call `fetch` directly.
|
||||
- **Loading** with `<Skeleton />`, **errors** inline with `<ErrorNote />` and a retry — see
|
||||
`src/pages/Dashboard.tsx`.
|
||||
- **Pages are compositions.** A page owns layout and data fetching, then renders sections from
|
||||
`src/components/<page>/`. If a page file grows past ~100 lines, extract sections.
|
||||
- **Copy:** sentence case for headings and buttons, no exclamation marks, no emoji. Calm and
|
||||
declarative — "You control where data lives", not "Unleash your data".
|
||||
- Radix provides behaviour, Tailwind provides looks. Copy styles from the gallery
|
||||
(`src/pages/Components.tsx`) rather than inventing new patterns.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # app on :5173, mock API proxied at /api
|
||||
npm run build # type-check + production build
|
||||
npm run lint # oxlint
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
Light and dark are two sets of CSS variables in `src/index.css` (`:root` and `.dark`). The
|
||||
header toggle offers light, dark and system, and persists to `localStorage`. To retheme, change
|
||||
the variable values — no component edits needed.
|
||||
|
||||
## Swapping in a real API
|
||||
|
||||
Point `server.proxy['/api']` in `vite.config.ts` at your backend and remove the `mockApi()`
|
||||
plugin. Endpoint shapes stay in `src/api/types.ts`.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<title>Stackship — Circuit starter</title>
|
||||
<script>
|
||||
// Apply the saved theme before first paint to avoid a flash of the wrong mode.
|
||||
try {
|
||||
const t = localStorage.getItem('theme')
|
||||
if (t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
document.documentElement.classList.add('dark')
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
export function startMockServer(port?: number): { close(): void }
|
||||
@@ -0,0 +1,67 @@
|
||||
// Zero-dependency mock API used by the Vite dev server via the /api proxy.
|
||||
// Add an endpoint by giving it a key in `routes`; response delay mimics a real network.
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const stats = [
|
||||
{ label: 'Running apps', value: '12', delta: '+2', up: true, icon: 'apps' },
|
||||
{ label: 'Monthly cost', value: '18 400 kr', delta: '-4%', up: true, icon: 'cost' },
|
||||
{ label: 'Deploys this week', value: '38', delta: '+12%', up: true, icon: 'deploys' },
|
||||
{ label: 'Open findings', value: '3', delta: '+1', up: false, icon: 'findings' },
|
||||
]
|
||||
|
||||
const resources = [
|
||||
{ name: 'case-intake-web', service: 'app', env: 'production', region: 'Borlänge DC', status: 'running' },
|
||||
{ name: 'case-intake-db', service: 'database', env: 'production', region: 'Borlänge DC', status: 'running' },
|
||||
{ name: 'documents', service: 'storage', env: 'production', region: 'Borlänge DC', status: 'running' },
|
||||
{ name: 'classify-case', service: 'function', env: 'staging', region: 'Falun edge', status: 'scaling' },
|
||||
{ name: 'session-cache', service: 'cache', env: 'staging', region: 'Falun edge', status: 'degraded' },
|
||||
]
|
||||
|
||||
const deploys = [
|
||||
{ project: 'case-intake-web', env: 'production', by: 'Circuit agent', time: '2 min ago', ok: true },
|
||||
{ project: 'classify-case', env: 'staging', by: 'Ana R.', time: '1 h ago', ok: true },
|
||||
{ project: 'permit-portal', env: 'preview', by: 'Circuit agent', time: '3 h ago', ok: false },
|
||||
{ project: 'case-intake-web', env: 'staging', by: 'Mats K.', time: 'yesterday', ok: true },
|
||||
]
|
||||
|
||||
const team = {
|
||||
members: ['MK', 'AR', 'JT', 'SB'],
|
||||
extraCount: 3,
|
||||
activity: 'Ana approved the merge request for classify-case an hour ago.',
|
||||
}
|
||||
|
||||
const budget = { used: 14_250, total: 20_000, currency: 'kr' }
|
||||
|
||||
const insight = {
|
||||
title: 'Memory pressure on session-cache',
|
||||
body: 'Two pods restarted after a memory spike at 09:42. Raising the limit to 512 Mi keeps headroom for the morning peak.',
|
||||
action: 'Raise limit to 512 Mi',
|
||||
}
|
||||
|
||||
const routes = {
|
||||
'/api/stats': stats,
|
||||
'/api/resources': resources,
|
||||
'/api/deploys': deploys,
|
||||
'/api/team': team,
|
||||
'/api/budget': budget,
|
||||
'/api/insight': insight,
|
||||
}
|
||||
|
||||
const PORT = 8787
|
||||
|
||||
export function startMockServer(port = PORT) {
|
||||
const server = createServer((req, res) => {
|
||||
const respond = (status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
setTimeout(() => {
|
||||
const body = routes[req.url]
|
||||
if (req.method === 'GET' && body !== undefined) respond(200, body)
|
||||
else respond(404, { error: 'Not found', method: req.method, path: req.url })
|
||||
}, 350)
|
||||
})
|
||||
server.listen(port)
|
||||
console.log(`[mock-api] listening on http://localhost:${port}`)
|
||||
return server
|
||||
}
|
||||
Generated
+4300
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "stackship-circuit-template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"lucide-react": "^1.44.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.1",
|
||||
"swr": "^2.5.1",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0"?><svg width="100%" height="100%" viewBox="0 0 1652 1652" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" xmlns:c2pa="http://c2pa.org/manifest">
|
||||
<g transform="matrix(1,0,0,1,-6170,-3298)">
|
||||
<g id="symb" transform="matrix(1,0,0,1,-24254.8,-0.575461)">
|
||||
<rect x="30424.8" y="3298.58" width="1651.64" height="1651.64" style="fill:none;"></rect>
|
||||
<clipPath id="_clip1">
|
||||
<rect x="30424.8" y="3298.58" width="1651.64" height="1651.64"></rect>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g transform="matrix(0.702634,0,0,0.702634,25202.6,-970.248)">
|
||||
<g transform="matrix(1,0,0,1,-623.937,-312.725)">
|
||||
<circle cx="9232.68" cy="7564.32" r="1175.32" style="fill:rgb(0,132,180);"></circle>
|
||||
</g>
|
||||
<g id="symbol" transform="matrix(-1.8149,0,0,1.8149,11114.7,5158.95)">
|
||||
<g id="Ship-on-clouds" transform="matrix(-1,0,0,1,4896.85,-2256.7)">
|
||||
<path d="M3346.33,3602.15C3325.77,3569.06 3289.09,3547 3247.3,3547C3189.86,3547 3142.06,3588.66 3132.53,3643.39C3130.66,3654.13 3120.43,3661.34 3109.69,3659.47C3098.95,3657.6 3091.74,3647.36 3093.61,3636.62C3100.89,3594.79 3124.86,3558.66 3158.38,3535.34L3111.39,3373.99C3110.43,3370.7 3111.08,3367.14 3113.14,3364.4C3115.19,3361.66 3118.42,3360.04 3121.85,3360.04L3284.41,3360.04C3286.19,3360.04 3287.75,3361.21 3288.25,3362.92L3302.11,3410.51C3303.46,3415.16 3307.73,3418.36 3312.57,3418.36L3854.29,3418.36C3859.13,3418.36 3863.4,3415.16 3864.75,3410.51L3878.61,3362.92C3879.11,3361.21 3880.67,3360.04 3882.45,3360.04L3910.28,3360.04C3913.71,3360.04 3916.94,3361.66 3919,3364.4C3921.06,3367.14 3921.71,3370.7 3920.75,3373.99L3866.9,3558.87C3897.55,3574.67 3922.3,3600.32 3936.95,3631.63C3941.57,3641.51 3937.31,3653.28 3927.43,3657.9C3917.56,3662.52 3905.79,3658.25 3901.17,3648.38C3882.59,3608.68 3842.28,3581.14 3795.58,3581.14C3770.32,3581.14 3746.93,3589.19 3727.84,3602.87C3719.4,3608.93 3707.68,3607.39 3701.08,3599.37C3684.48,3579.21 3659.33,3566.33 3631.2,3566.33C3596.76,3566.33 3566.8,3585.62 3551.5,3613.96C3551.47,3614.02 3551.44,3614.08 3551.41,3614.14C3550.66,3615.54 3549.76,3616.81 3548.76,3617.94C3547.75,3619.08 3546.59,3620.11 3545.29,3621.01C3545.02,3621.2 3544.75,3621.38 3544.48,3621.55C3541.66,3623.31 3538.45,3624.33 3535.14,3624.53C3531.84,3624.71 3528.53,3624.07 3525.53,3622.65C3525.24,3622.51 3524.95,3622.37 3524.66,3622.21C3523.26,3621.46 3522,3620.57 3520.86,3619.56C3519.73,3618.55 3518.7,3617.39 3517.79,3616.1C3517.75,3616.04 3517.71,3615.98 3517.67,3615.93C3501.31,3592.54 3474.18,3577.23 3443.49,3577.23C3417.71,3577.23 3394.43,3588.05 3377.94,3605.38C3377.86,3605.47 3377.78,3605.55 3377.7,3605.64C3377.1,3606.27 3376.47,3606.86 3375.81,3607.4C3375.15,3607.93 3374.45,3608.43 3373.7,3608.89C3372.78,3609.46 3371.82,3609.95 3370.84,3610.35C3367.82,3611.59 3364.54,3612.06 3361.32,3611.73C3358.11,3611.4 3354.99,3610.28 3352.28,3608.46C3351.4,3607.86 3350.56,3607.2 3349.77,3606.45C3349.14,3605.85 3348.55,3605.22 3348.01,3604.56C3347.48,3603.9 3346.98,3603.2 3346.52,3602.45C3346.46,3602.35 3346.39,3602.25 3346.33,3602.15Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-743.802)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM563.676,2073.39L323.914,2073.39C321.785,2073.39 319.743,2074.36 318.238,2076.08C316.732,2077.8 315.887,2080.14 315.887,2082.57L315.887,2103.21C315.887,2105.64 316.732,2107.98 318.238,2109.7C319.743,2111.42 321.785,2112.39 323.914,2112.39L563.676,2112.39C565.805,2112.39 567.847,2111.42 569.352,2109.7C570.857,2107.98 571.703,2105.64 571.703,2103.21L571.703,2082.57C571.703,2080.14 570.857,2077.8 569.352,2076.08C567.847,2074.36 565.805,2073.39 563.676,2073.39ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-no-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-872.891)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0"?><svg width="100%" height="100%" viewBox="0 0 5489 1436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" xmlns:c2pa="http://c2pa.org/manifest">
|
||||
<g transform="matrix(1,0,0,1,0,-15061)">
|
||||
<g id="horiz-on-dark" transform="matrix(0.953147,0,0,1,-23118.3,-0.631482)">
|
||||
<rect x="24254.8" y="15061.6" width="5758.4" height="1435.39" style="fill:none;"></rect>
|
||||
<clipPath id="_clip1">
|
||||
<rect x="24254.8" y="15061.6" width="5758.4" height="1435.39"></rect>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g transform="matrix(1.04916,0,0,1,15750.9,8365.39)">
|
||||
<g id="text--Funnel-Display-" transform="matrix(3.95063,0,0,3.95063,-11304.3,-9275.54)">
|
||||
<g id="logo">
|
||||
<g id="text--Funnel-Display-1" transform="matrix(202.499,0,0,202.499,5358.24,4293.01)">
|
||||
<path d="M0.334,0.01C0.245,0.01 0.174,-0.01 0.12,-0.049C0.067,-0.088 0.038,-0.142 0.033,-0.212L0.164,-0.212C0.169,-0.173 0.186,-0.144 0.215,-0.124C0.245,-0.104 0.286,-0.094 0.339,-0.094C0.386,-0.094 0.421,-0.101 0.445,-0.117C0.47,-0.133 0.482,-0.156 0.482,-0.187C0.482,-0.211 0.474,-0.23 0.458,-0.244C0.442,-0.259 0.417,-0.27 0.383,-0.276L0.247,-0.304C0.186,-0.317 0.139,-0.339 0.109,-0.369C0.078,-0.4 0.062,-0.439 0.062,-0.488C0.062,-0.549 0.085,-0.597 0.132,-0.632C0.178,-0.667 0.241,-0.685 0.321,-0.685C0.401,-0.685 0.467,-0.667 0.516,-0.63C0.566,-0.593 0.594,-0.542 0.601,-0.476L0.47,-0.476C0.453,-0.548 0.401,-0.584 0.314,-0.584C0.274,-0.584 0.243,-0.577 0.22,-0.562C0.198,-0.548 0.186,-0.526 0.186,-0.499C0.186,-0.456 0.217,-0.429 0.278,-0.416L0.42,-0.387C0.483,-0.375 0.53,-0.353 0.56,-0.321C0.591,-0.29 0.606,-0.249 0.606,-0.197C0.606,-0.131 0.582,-0.081 0.535,-0.044C0.487,-0.008 0.42,0.01 0.334,0.01Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5488.55,4293.01)">
|
||||
<path d="M0.207,-0C0.174,-0 0.149,-0.008 0.13,-0.025C0.112,-0.042 0.103,-0.067 0.103,-0.101L0.103,-0.403L0.015,-0.403L0.015,-0.5L0.105,-0.5L0.105,-0.65L0.226,-0.65L0.226,-0.5L0.346,-0.5L0.346,-0.403L0.227,-0.403L0.227,-0.097L0.359,-0.097L0.359,-0L0.207,-0Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5565.24,4293.01)">
|
||||
<path d="M0.217,0.009C0.158,0.009 0.113,-0.005 0.08,-0.033C0.046,-0.062 0.03,-0.099 0.03,-0.146C0.03,-0.195 0.047,-0.232 0.081,-0.259C0.114,-0.285 0.163,-0.299 0.226,-0.299L0.378,-0.299L0.378,-0.319C0.378,-0.38 0.342,-0.411 0.272,-0.411C0.211,-0.411 0.175,-0.389 0.162,-0.346L0.039,-0.346C0.049,-0.399 0.074,-0.44 0.114,-0.468C0.154,-0.496 0.207,-0.51 0.272,-0.51C0.345,-0.51 0.401,-0.493 0.439,-0.46C0.478,-0.427 0.497,-0.379 0.497,-0.316L0.497,-0.124L0.555,-0.124L0.555,-0L0.428,-0L0.428,-0.08L0.393,-0.08C0.376,-0.052 0.353,-0.03 0.323,-0.014C0.294,0.001 0.259,0.009 0.217,0.009ZM0.235,-0.086C0.262,-0.086 0.286,-0.092 0.308,-0.102C0.33,-0.112 0.347,-0.126 0.359,-0.144C0.372,-0.161 0.378,-0.181 0.378,-0.202L0.378,-0.214L0.232,-0.214C0.176,-0.214 0.148,-0.193 0.148,-0.152C0.148,-0.131 0.156,-0.115 0.171,-0.104C0.187,-0.092 0.208,-0.086 0.235,-0.086Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5683.46,4293.01)">
|
||||
<path d="M0.29,0.01C0.238,0.01 0.193,-0.001 0.154,-0.022C0.115,-0.043 0.085,-0.074 0.064,-0.113C0.042,-0.151 0.031,-0.197 0.031,-0.25C0.031,-0.303 0.042,-0.349 0.063,-0.388C0.085,-0.426 0.115,-0.457 0.154,-0.478C0.192,-0.499 0.238,-0.51 0.29,-0.51C0.354,-0.51 0.408,-0.494 0.45,-0.461C0.492,-0.429 0.519,-0.384 0.53,-0.326L0.406,-0.326C0.398,-0.351 0.384,-0.37 0.364,-0.383C0.345,-0.396 0.32,-0.402 0.29,-0.402C0.249,-0.402 0.216,-0.389 0.192,-0.362C0.167,-0.336 0.155,-0.298 0.155,-0.25C0.155,-0.202 0.168,-0.165 0.192,-0.138C0.216,-0.111 0.249,-0.098 0.29,-0.098C0.354,-0.098 0.394,-0.126 0.408,-0.182L0.533,-0.182C0.523,-0.121 0.496,-0.074 0.454,-0.04C0.412,-0.007 0.357,0.01 0.29,0.01Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5793.15,4293.01)">
|
||||
<path d="M0.058,-0L0.058,-0.7L0.183,-0.7L0.183,-0.306L0.186,-0.306L0.396,-0.5L0.545,-0.5L0.328,-0.304L0.562,-0L0.416,-0L0.241,-0.228L0.183,-0.18L0.183,-0L0.058,-0Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5902.27,4293.01)">
|
||||
<path d="M0.27,0.01C0.198,0.01 0.141,-0.005 0.099,-0.035C0.058,-0.066 0.035,-0.108 0.032,-0.162L0.152,-0.162C0.156,-0.136 0.169,-0.117 0.188,-0.104C0.208,-0.092 0.237,-0.086 0.275,-0.086C0.339,-0.086 0.371,-0.104 0.371,-0.141C0.371,-0.156 0.366,-0.168 0.356,-0.177C0.347,-0.185 0.33,-0.192 0.308,-0.196L0.197,-0.217C0.099,-0.236 0.051,-0.282 0.051,-0.355C0.051,-0.402 0.069,-0.44 0.106,-0.468C0.144,-0.496 0.195,-0.51 0.26,-0.51C0.326,-0.51 0.379,-0.496 0.417,-0.467C0.456,-0.438 0.477,-0.398 0.481,-0.346L0.362,-0.346C0.356,-0.371 0.344,-0.389 0.327,-0.4C0.31,-0.412 0.286,-0.418 0.254,-0.418C0.226,-0.418 0.204,-0.413 0.189,-0.404C0.174,-0.395 0.167,-0.382 0.167,-0.365C0.167,-0.352 0.171,-0.342 0.181,-0.334C0.19,-0.326 0.205,-0.32 0.226,-0.316L0.338,-0.295C0.389,-0.285 0.427,-0.268 0.451,-0.245C0.475,-0.222 0.486,-0.191 0.486,-0.151C0.486,-0.1 0.468,-0.061 0.431,-0.033C0.393,-0.004 0.34,0.01 0.27,0.01Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6005.96,4293.01)">
|
||||
<path d="M0.058,-0L0.058,-0.7L0.183,-0.7L0.183,-0.426L0.186,-0.426C0.202,-0.453 0.224,-0.474 0.252,-0.488C0.279,-0.502 0.312,-0.509 0.35,-0.509C0.413,-0.509 0.463,-0.49 0.5,-0.453C0.537,-0.415 0.555,-0.365 0.555,-0.301L0.555,-0L0.431,-0L0.431,-0.279C0.431,-0.318 0.42,-0.348 0.399,-0.368C0.378,-0.389 0.347,-0.399 0.307,-0.399C0.266,-0.399 0.236,-0.389 0.214,-0.368C0.193,-0.348 0.183,-0.318 0.183,-0.279L0.183,-0L0.058,-0Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6129.52,4293.01)">
|
||||
<path d="M0.063,-0L0.063,-0.5L0.187,-0.5L0.187,-0L0.063,-0ZM0.124,-0.555C0.105,-0.555 0.088,-0.562 0.075,-0.575C0.061,-0.589 0.054,-0.606 0.054,-0.625C0.054,-0.644 0.061,-0.661 0.075,-0.675C0.088,-0.688 0.105,-0.695 0.124,-0.695C0.144,-0.695 0.16,-0.688 0.174,-0.675C0.187,-0.661 0.194,-0.644 0.194,-0.625C0.194,-0.606 0.187,-0.589 0.174,-0.575C0.16,-0.562 0.144,-0.555 0.124,-0.555Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6179.86,4293.01)">
|
||||
<path d="M0.09,0.2L0.09,-0.376L0.028,-0.376L0.028,-0.5L0.156,-0.5L0.156,-0.42L0.191,-0.42C0.208,-0.449 0.232,-0.471 0.261,-0.487C0.291,-0.502 0.326,-0.51 0.367,-0.51C0.419,-0.51 0.462,-0.499 0.498,-0.478C0.533,-0.456 0.56,-0.426 0.578,-0.387C0.597,-0.348 0.606,-0.303 0.606,-0.25C0.606,-0.197 0.597,-0.152 0.579,-0.113C0.561,-0.074 0.535,-0.043 0.501,-0.022C0.467,-0.001 0.426,0.01 0.378,0.01C0.341,0.01 0.308,0.003 0.281,-0.012C0.254,-0.026 0.233,-0.046 0.218,-0.072L0.215,-0.072L0.215,0.2L0.09,0.2ZM0.345,-0.097C0.39,-0.097 0.424,-0.111 0.447,-0.138C0.47,-0.165 0.482,-0.202 0.482,-0.25C0.482,-0.298 0.47,-0.335 0.447,-0.362C0.424,-0.389 0.39,-0.403 0.345,-0.403C0.305,-0.403 0.274,-0.391 0.25,-0.369C0.226,-0.346 0.215,-0.316 0.215,-0.277L0.215,-0.223C0.215,-0.184 0.226,-0.154 0.25,-0.131C0.274,-0.109 0.305,-0.097 0.345,-0.097Z" style="fill:white;fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6308.86,4293.01)">
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.610638,0,0,0.610638,3567.05,2986.48)">
|
||||
<g transform="matrix(1,0,0,1,-623.937,-312.725)">
|
||||
<circle cx="9232.68" cy="7564.32" r="1175.32" style="fill:rgb(0,132,180);"></circle>
|
||||
</g>
|
||||
<g id="symbol" transform="matrix(-1.8149,0,0,1.8149,11114.7,5158.95)">
|
||||
<g id="Ship-on-clouds" transform="matrix(-1,0,0,1,4896.85,-2256.7)">
|
||||
<path d="M3346.33,3602.15C3325.77,3569.06 3289.09,3547 3247.3,3547C3189.86,3547 3142.06,3588.66 3132.53,3643.39C3130.66,3654.13 3120.43,3661.34 3109.69,3659.47C3098.95,3657.6 3091.74,3647.36 3093.61,3636.62C3100.89,3594.79 3124.86,3558.66 3158.38,3535.34L3111.39,3373.99C3110.43,3370.7 3111.08,3367.14 3113.14,3364.4C3115.19,3361.66 3118.42,3360.04 3121.85,3360.04L3284.41,3360.04C3286.19,3360.04 3287.75,3361.21 3288.25,3362.92L3302.11,3410.51C3303.46,3415.16 3307.73,3418.36 3312.57,3418.36L3854.29,3418.36C3859.13,3418.36 3863.4,3415.16 3864.75,3410.51L3878.61,3362.92C3879.11,3361.21 3880.67,3360.04 3882.45,3360.04L3910.28,3360.04C3913.71,3360.04 3916.94,3361.66 3919,3364.4C3921.06,3367.14 3921.71,3370.7 3920.75,3373.99L3866.9,3558.87C3897.55,3574.67 3922.3,3600.32 3936.95,3631.63C3941.57,3641.51 3937.31,3653.28 3927.43,3657.9C3917.56,3662.52 3905.79,3658.25 3901.17,3648.38C3882.59,3608.68 3842.28,3581.14 3795.58,3581.14C3770.32,3581.14 3746.93,3589.19 3727.84,3602.87C3719.4,3608.93 3707.68,3607.39 3701.08,3599.37C3684.48,3579.21 3659.33,3566.33 3631.2,3566.33C3596.76,3566.33 3566.8,3585.62 3551.5,3613.96C3551.47,3614.02 3551.44,3614.08 3551.41,3614.14C3550.66,3615.54 3549.76,3616.81 3548.76,3617.94C3547.75,3619.08 3546.59,3620.11 3545.29,3621.01C3545.02,3621.2 3544.75,3621.38 3544.48,3621.55C3541.66,3623.31 3538.45,3624.33 3535.14,3624.53C3531.84,3624.71 3528.53,3624.07 3525.53,3622.65C3525.24,3622.51 3524.95,3622.37 3524.66,3622.21C3523.26,3621.46 3522,3620.57 3520.86,3619.56C3519.73,3618.55 3518.7,3617.39 3517.79,3616.1C3517.75,3616.04 3517.71,3615.98 3517.67,3615.93C3501.31,3592.54 3474.18,3577.23 3443.49,3577.23C3417.71,3577.23 3394.43,3588.05 3377.94,3605.38C3377.86,3605.47 3377.78,3605.55 3377.7,3605.64C3377.1,3606.27 3376.47,3606.86 3375.81,3607.4C3375.15,3607.93 3374.45,3608.43 3373.7,3608.89C3372.78,3609.46 3371.82,3609.95 3370.84,3610.35C3367.82,3611.59 3364.54,3612.06 3361.32,3611.73C3358.11,3611.4 3354.99,3610.28 3352.28,3608.46C3351.4,3607.86 3350.56,3607.2 3349.77,3606.45C3349.14,3605.85 3348.55,3605.22 3348.01,3604.56C3347.48,3603.9 3346.98,3603.2 3346.52,3602.45C3346.46,3602.35 3346.39,3602.25 3346.33,3602.15Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-743.802)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM563.676,2073.39L323.914,2073.39C321.785,2073.39 319.743,2074.36 318.238,2076.08C316.732,2077.8 315.887,2080.14 315.887,2082.57L315.887,2103.21C315.887,2105.64 316.732,2107.98 318.238,2109.7C319.743,2111.42 321.785,2112.39 323.914,2112.39L563.676,2112.39C565.805,2112.39 567.847,2111.42 569.352,2109.7C570.857,2107.98 571.703,2105.64 571.703,2103.21L571.703,2082.57C571.703,2080.14 570.857,2077.8 569.352,2076.08C567.847,2074.36 565.805,2073.39 563.676,2073.39ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-no-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-872.891)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0"?><svg width="100%" height="100%" viewBox="0 0 5489 1436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" xmlns:c2pa="http://c2pa.org/manifest">
|
||||
<g transform="matrix(1,0,0,1,0,-16796)">
|
||||
<g id="horiz" transform="matrix(0.953147,0,0,1,-23118.3,-0.0215473)">
|
||||
<rect x="24254.8" y="16796" width="5758.4" height="1435.39" style="fill:none;"></rect>
|
||||
<clipPath id="_clip1">
|
||||
<rect x="24254.8" y="16796" width="5758.4" height="1435.39"></rect>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g transform="matrix(1.04916,0,0,1,15750.9,10099.2)">
|
||||
<g id="text--Funnel-Display-" transform="matrix(3.95063,0,0,3.95063,-11304.3,-9275.54)">
|
||||
<g id="logo">
|
||||
<g id="text--Funnel-Display-1" transform="matrix(202.499,0,0,202.499,5358.24,4293.01)">
|
||||
<path d="M0.334,0.01C0.245,0.01 0.174,-0.01 0.12,-0.049C0.067,-0.088 0.038,-0.142 0.033,-0.212L0.164,-0.212C0.169,-0.173 0.186,-0.144 0.215,-0.124C0.245,-0.104 0.286,-0.094 0.339,-0.094C0.386,-0.094 0.421,-0.101 0.445,-0.117C0.47,-0.133 0.482,-0.156 0.482,-0.187C0.482,-0.211 0.474,-0.23 0.458,-0.244C0.442,-0.259 0.417,-0.27 0.383,-0.276L0.247,-0.304C0.186,-0.317 0.139,-0.339 0.109,-0.369C0.078,-0.4 0.062,-0.439 0.062,-0.488C0.062,-0.549 0.085,-0.597 0.132,-0.632C0.178,-0.667 0.241,-0.685 0.321,-0.685C0.401,-0.685 0.467,-0.667 0.516,-0.63C0.566,-0.593 0.594,-0.542 0.601,-0.476L0.47,-0.476C0.453,-0.548 0.401,-0.584 0.314,-0.584C0.274,-0.584 0.243,-0.577 0.22,-0.562C0.198,-0.548 0.186,-0.526 0.186,-0.499C0.186,-0.456 0.217,-0.429 0.278,-0.416L0.42,-0.387C0.483,-0.375 0.53,-0.353 0.56,-0.321C0.591,-0.29 0.606,-0.249 0.606,-0.197C0.606,-0.131 0.582,-0.081 0.535,-0.044C0.487,-0.008 0.42,0.01 0.334,0.01Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5488.55,4293.01)">
|
||||
<path d="M0.207,-0C0.174,-0 0.149,-0.008 0.13,-0.025C0.112,-0.042 0.103,-0.067 0.103,-0.101L0.103,-0.403L0.015,-0.403L0.015,-0.5L0.105,-0.5L0.105,-0.65L0.226,-0.65L0.226,-0.5L0.346,-0.5L0.346,-0.403L0.227,-0.403L0.227,-0.097L0.359,-0.097L0.359,-0L0.207,-0Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5565.24,4293.01)">
|
||||
<path d="M0.217,0.009C0.158,0.009 0.113,-0.005 0.08,-0.033C0.046,-0.062 0.03,-0.099 0.03,-0.146C0.03,-0.195 0.047,-0.232 0.081,-0.259C0.114,-0.285 0.163,-0.299 0.226,-0.299L0.378,-0.299L0.378,-0.319C0.378,-0.38 0.342,-0.411 0.272,-0.411C0.211,-0.411 0.175,-0.389 0.162,-0.346L0.039,-0.346C0.049,-0.399 0.074,-0.44 0.114,-0.468C0.154,-0.496 0.207,-0.51 0.272,-0.51C0.345,-0.51 0.401,-0.493 0.439,-0.46C0.478,-0.427 0.497,-0.379 0.497,-0.316L0.497,-0.124L0.555,-0.124L0.555,-0L0.428,-0L0.428,-0.08L0.393,-0.08C0.376,-0.052 0.353,-0.03 0.323,-0.014C0.294,0.001 0.259,0.009 0.217,0.009ZM0.235,-0.086C0.262,-0.086 0.286,-0.092 0.308,-0.102C0.33,-0.112 0.347,-0.126 0.359,-0.144C0.372,-0.161 0.378,-0.181 0.378,-0.202L0.378,-0.214L0.232,-0.214C0.176,-0.214 0.148,-0.193 0.148,-0.152C0.148,-0.131 0.156,-0.115 0.171,-0.104C0.187,-0.092 0.208,-0.086 0.235,-0.086Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5683.46,4293.01)">
|
||||
<path d="M0.29,0.01C0.238,0.01 0.193,-0.001 0.154,-0.022C0.115,-0.043 0.085,-0.074 0.064,-0.113C0.042,-0.151 0.031,-0.197 0.031,-0.25C0.031,-0.303 0.042,-0.349 0.063,-0.388C0.085,-0.426 0.115,-0.457 0.154,-0.478C0.192,-0.499 0.238,-0.51 0.29,-0.51C0.354,-0.51 0.408,-0.494 0.45,-0.461C0.492,-0.429 0.519,-0.384 0.53,-0.326L0.406,-0.326C0.398,-0.351 0.384,-0.37 0.364,-0.383C0.345,-0.396 0.32,-0.402 0.29,-0.402C0.249,-0.402 0.216,-0.389 0.192,-0.362C0.167,-0.336 0.155,-0.298 0.155,-0.25C0.155,-0.202 0.168,-0.165 0.192,-0.138C0.216,-0.111 0.249,-0.098 0.29,-0.098C0.354,-0.098 0.394,-0.126 0.408,-0.182L0.533,-0.182C0.523,-0.121 0.496,-0.074 0.454,-0.04C0.412,-0.007 0.357,0.01 0.29,0.01Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5793.15,4293.01)">
|
||||
<path d="M0.058,-0L0.058,-0.7L0.183,-0.7L0.183,-0.306L0.186,-0.306L0.396,-0.5L0.545,-0.5L0.328,-0.304L0.562,-0L0.416,-0L0.241,-0.228L0.183,-0.18L0.183,-0L0.058,-0Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,5902.27,4293.01)">
|
||||
<path d="M0.27,0.01C0.198,0.01 0.141,-0.005 0.099,-0.035C0.058,-0.066 0.035,-0.108 0.032,-0.162L0.152,-0.162C0.156,-0.136 0.169,-0.117 0.188,-0.104C0.208,-0.092 0.237,-0.086 0.275,-0.086C0.339,-0.086 0.371,-0.104 0.371,-0.141C0.371,-0.156 0.366,-0.168 0.356,-0.177C0.347,-0.185 0.33,-0.192 0.308,-0.196L0.197,-0.217C0.099,-0.236 0.051,-0.282 0.051,-0.355C0.051,-0.402 0.069,-0.44 0.106,-0.468C0.144,-0.496 0.195,-0.51 0.26,-0.51C0.326,-0.51 0.379,-0.496 0.417,-0.467C0.456,-0.438 0.477,-0.398 0.481,-0.346L0.362,-0.346C0.356,-0.371 0.344,-0.389 0.327,-0.4C0.31,-0.412 0.286,-0.418 0.254,-0.418C0.226,-0.418 0.204,-0.413 0.189,-0.404C0.174,-0.395 0.167,-0.382 0.167,-0.365C0.167,-0.352 0.171,-0.342 0.181,-0.334C0.19,-0.326 0.205,-0.32 0.226,-0.316L0.338,-0.295C0.389,-0.285 0.427,-0.268 0.451,-0.245C0.475,-0.222 0.486,-0.191 0.486,-0.151C0.486,-0.1 0.468,-0.061 0.431,-0.033C0.393,-0.004 0.34,0.01 0.27,0.01Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6005.96,4293.01)">
|
||||
<path d="M0.058,-0L0.058,-0.7L0.183,-0.7L0.183,-0.426L0.186,-0.426C0.202,-0.453 0.224,-0.474 0.252,-0.488C0.279,-0.502 0.312,-0.509 0.35,-0.509C0.413,-0.509 0.463,-0.49 0.5,-0.453C0.537,-0.415 0.555,-0.365 0.555,-0.301L0.555,-0L0.431,-0L0.431,-0.279C0.431,-0.318 0.42,-0.348 0.399,-0.368C0.378,-0.389 0.347,-0.399 0.307,-0.399C0.266,-0.399 0.236,-0.389 0.214,-0.368C0.193,-0.348 0.183,-0.318 0.183,-0.279L0.183,-0L0.058,-0Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6129.52,4293.01)">
|
||||
<path d="M0.063,-0L0.063,-0.5L0.187,-0.5L0.187,-0L0.063,-0ZM0.124,-0.555C0.105,-0.555 0.088,-0.562 0.075,-0.575C0.061,-0.589 0.054,-0.606 0.054,-0.625C0.054,-0.644 0.061,-0.661 0.075,-0.675C0.088,-0.688 0.105,-0.695 0.124,-0.695C0.144,-0.695 0.16,-0.688 0.174,-0.675C0.187,-0.661 0.194,-0.644 0.194,-0.625C0.194,-0.606 0.187,-0.589 0.174,-0.575C0.16,-0.562 0.144,-0.555 0.124,-0.555Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6179.86,4293.01)">
|
||||
<path d="M0.09,0.2L0.09,-0.376L0.028,-0.376L0.028,-0.5L0.156,-0.5L0.156,-0.42L0.191,-0.42C0.208,-0.449 0.232,-0.471 0.261,-0.487C0.291,-0.502 0.326,-0.51 0.367,-0.51C0.419,-0.51 0.462,-0.499 0.498,-0.478C0.533,-0.456 0.56,-0.426 0.578,-0.387C0.597,-0.348 0.606,-0.303 0.606,-0.25C0.606,-0.197 0.597,-0.152 0.579,-0.113C0.561,-0.074 0.535,-0.043 0.501,-0.022C0.467,-0.001 0.426,0.01 0.378,0.01C0.341,0.01 0.308,0.003 0.281,-0.012C0.254,-0.026 0.233,-0.046 0.218,-0.072L0.215,-0.072L0.215,0.2L0.09,0.2ZM0.345,-0.097C0.39,-0.097 0.424,-0.111 0.447,-0.138C0.47,-0.165 0.482,-0.202 0.482,-0.25C0.482,-0.298 0.47,-0.335 0.447,-0.362C0.424,-0.389 0.39,-0.403 0.345,-0.403C0.305,-0.403 0.274,-0.391 0.25,-0.369C0.226,-0.346 0.215,-0.316 0.215,-0.277L0.215,-0.223C0.215,-0.184 0.226,-0.154 0.25,-0.131C0.274,-0.109 0.305,-0.097 0.345,-0.097Z" style="fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
<g transform="matrix(202.499,0,0,202.499,6308.86,4293.01)">
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.610638,0,0,0.610638,3567.05,2986.48)">
|
||||
<g transform="matrix(1,0,0,1,-623.937,-312.725)">
|
||||
<circle cx="9232.68" cy="7564.32" r="1175.32" style="fill:rgb(0,132,180);"></circle>
|
||||
</g>
|
||||
<g id="symbol" transform="matrix(-1.8149,0,0,1.8149,11114.7,5158.95)">
|
||||
<g id="Ship-on-clouds" transform="matrix(-1,0,0,1,4896.85,-2256.7)">
|
||||
<path d="M3346.33,3602.15C3325.77,3569.06 3289.09,3547 3247.3,3547C3189.86,3547 3142.06,3588.66 3132.53,3643.39C3130.66,3654.13 3120.43,3661.34 3109.69,3659.47C3098.95,3657.6 3091.74,3647.36 3093.61,3636.62C3100.89,3594.79 3124.86,3558.66 3158.38,3535.34L3111.39,3373.99C3110.43,3370.7 3111.08,3367.14 3113.14,3364.4C3115.19,3361.66 3118.42,3360.04 3121.85,3360.04L3284.41,3360.04C3286.19,3360.04 3287.75,3361.21 3288.25,3362.92L3302.11,3410.51C3303.46,3415.16 3307.73,3418.36 3312.57,3418.36L3854.29,3418.36C3859.13,3418.36 3863.4,3415.16 3864.75,3410.51L3878.61,3362.92C3879.11,3361.21 3880.67,3360.04 3882.45,3360.04L3910.28,3360.04C3913.71,3360.04 3916.94,3361.66 3919,3364.4C3921.06,3367.14 3921.71,3370.7 3920.75,3373.99L3866.9,3558.87C3897.55,3574.67 3922.3,3600.32 3936.95,3631.63C3941.57,3641.51 3937.31,3653.28 3927.43,3657.9C3917.56,3662.52 3905.79,3658.25 3901.17,3648.38C3882.59,3608.68 3842.28,3581.14 3795.58,3581.14C3770.32,3581.14 3746.93,3589.19 3727.84,3602.87C3719.4,3608.93 3707.68,3607.39 3701.08,3599.37C3684.48,3579.21 3659.33,3566.33 3631.2,3566.33C3596.76,3566.33 3566.8,3585.62 3551.5,3613.96C3551.47,3614.02 3551.44,3614.08 3551.41,3614.14C3550.66,3615.54 3549.76,3616.81 3548.76,3617.94C3547.75,3619.08 3546.59,3620.11 3545.29,3621.01C3545.02,3621.2 3544.75,3621.38 3544.48,3621.55C3541.66,3623.31 3538.45,3624.33 3535.14,3624.53C3531.84,3624.71 3528.53,3624.07 3525.53,3622.65C3525.24,3622.51 3524.95,3622.37 3524.66,3622.21C3523.26,3621.46 3522,3620.57 3520.86,3619.56C3519.73,3618.55 3518.7,3617.39 3517.79,3616.1C3517.75,3616.04 3517.71,3615.98 3517.67,3615.93C3501.31,3592.54 3474.18,3577.23 3443.49,3577.23C3417.71,3577.23 3394.43,3588.05 3377.94,3605.38C3377.86,3605.47 3377.78,3605.55 3377.7,3605.64C3377.1,3606.27 3376.47,3606.86 3375.81,3607.4C3375.15,3607.93 3374.45,3608.43 3373.7,3608.89C3372.78,3609.46 3371.82,3609.95 3370.84,3610.35C3367.82,3611.59 3364.54,3612.06 3361.32,3611.73C3358.11,3611.4 3354.99,3610.28 3352.28,3608.46C3351.4,3607.86 3350.56,3607.2 3349.77,3606.45C3349.14,3605.85 3348.55,3605.22 3348.01,3604.56C3347.48,3603.9 3346.98,3603.2 3346.52,3602.45C3346.46,3602.35 3346.39,3602.25 3346.33,3602.15Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-743.802)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM563.676,2073.39L323.914,2073.39C321.785,2073.39 319.743,2074.36 318.238,2076.08C316.732,2077.8 315.887,2080.14 315.887,2082.57L315.887,2103.21C315.887,2105.64 316.732,2107.98 318.238,2109.7C319.743,2111.42 321.785,2112.39 323.914,2112.39L563.676,2112.39C565.805,2112.39 567.847,2111.42 569.352,2109.7C570.857,2107.98 571.703,2105.64 571.703,2103.21L571.703,2082.57C571.703,2080.14 570.857,2077.8 569.352,2076.08C567.847,2074.36 565.805,2073.39 563.676,2073.39ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
<g id="server-no-disc" transform="matrix(-0.996651,0,0,0.871949,1841.66,-872.891)">
|
||||
<path d="M761.378,2036.69C769.136,2036.69 775.425,2043.88 775.425,2052.75L775.425,2131.99C775.425,2140.85 769.136,2148.04 761.378,2148.04L297.826,2148.04C290.068,2148.04 283.779,2140.85 283.779,2131.99L283.779,2052.75C283.779,2043.88 290.068,2036.69 297.826,2036.69L761.378,2036.69ZM676.076,2072.87C666.662,2072.87 659.019,2081.61 659.019,2092.37C659.019,2103.13 666.662,2111.87 676.076,2111.87C685.49,2111.87 693.133,2103.13 693.133,2092.37C693.133,2081.61 685.49,2072.87 676.076,2072.87ZM726.261,2072.87C716.847,2072.87 709.204,2081.61 709.204,2092.37C709.204,2103.13 716.847,2111.87 726.261,2111.87C735.675,2111.87 743.318,2103.13 743.318,2092.37C743.318,2081.61 735.675,2072.87 726.261,2072.87Z" style="fill:white;"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
+19
@@ -0,0 +1,19 @@
|
||||
import { Routes, Route } from 'react-router'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import Components from './pages/Components'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/components" element={<Components />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Thin fetch wrapper. Every API call goes through here so headers and error
|
||||
// handling stay in one place. SWR hooks in src/api/hooks.ts use this.
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||
throw new ApiError(res.status, body?.error ?? `Request failed: ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// POST/PUT/DELETE live here too when you need them (use useSWRMutation in hooks.ts):
|
||||
// export async function apiPost<T>(path: string, data: unknown): Promise<T> { ... }
|
||||
@@ -0,0 +1,29 @@
|
||||
import useSWR from 'swr'
|
||||
import { apiGet } from './client'
|
||||
import type { Budget, Deploy, Insight, Resource, Stat, Team } from './types'
|
||||
|
||||
// One hook per endpoint. Pages never call fetch directly — they consume these.
|
||||
|
||||
export function useStats() {
|
||||
return useSWR<Stat[]>('/api/stats', apiGet)
|
||||
}
|
||||
|
||||
export function useResources() {
|
||||
return useSWR<Resource[]>('/api/resources', apiGet)
|
||||
}
|
||||
|
||||
export function useDeploys() {
|
||||
return useSWR<Deploy[]>('/api/deploys', apiGet)
|
||||
}
|
||||
|
||||
export function useTeam() {
|
||||
return useSWR<Team>('/api/team', apiGet)
|
||||
}
|
||||
|
||||
export function useBudget() {
|
||||
return useSWR<Budget>('/api/budget', apiGet)
|
||||
}
|
||||
|
||||
export function useInsight() {
|
||||
return useSWR<Insight>('/api/insight', apiGet)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Types for the mock API. In a real app these come from codegen or the backend.
|
||||
|
||||
/** Stackship resource types — each has its own accent colour (see ServiceIcon). */
|
||||
export type Service =
|
||||
| 'app'
|
||||
| 'function'
|
||||
| 'database'
|
||||
| 'storage'
|
||||
| 'container'
|
||||
| 'secrets'
|
||||
| 'vm'
|
||||
| 'cache'
|
||||
| 'bus'
|
||||
| 'erp'
|
||||
|
||||
export type Env = 'production' | 'staging' | 'preview'
|
||||
|
||||
export type Stat = {
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
/** true when the change is good news (a falling cost is `up: true`). */
|
||||
up: boolean
|
||||
icon: 'apps' | 'cost' | 'deploys' | 'findings'
|
||||
}
|
||||
|
||||
export type Resource = {
|
||||
name: string
|
||||
service: Service
|
||||
env: Env
|
||||
region: string
|
||||
status: 'running' | 'scaling' | 'degraded' | 'stopped'
|
||||
}
|
||||
|
||||
export type Deploy = {
|
||||
project: string
|
||||
env: Env
|
||||
by: string
|
||||
time: string
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export type Team = {
|
||||
members: string[]
|
||||
extraCount: number
|
||||
activity: string
|
||||
}
|
||||
|
||||
export type Budget = {
|
||||
used: number
|
||||
total: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export type Insight = {
|
||||
title: string
|
||||
body: string
|
||||
action: string
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NavLink, Outlet, Link } from 'react-router'
|
||||
import { BookOpenIcon, ComponentIcon, LayoutDashboardIcon, SparklesIcon } from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Overview', icon: SparklesIcon },
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboardIcon },
|
||||
{ to: '/components', label: 'Components', icon: ComponentIcon },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/75 backdrop-blur-xl backdrop-saturate-150">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 md:gap-8 md:px-6">
|
||||
<Link to="/" className="flex shrink-0 items-center gap-3" aria-label="Home">
|
||||
<Logo className="h-6 md:h-7" />
|
||||
<span className="hidden h-5 w-px bg-border sm:block" aria-hidden />
|
||||
<span className="hidden text-sm font-medium text-muted-foreground sm:block">Circuit</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1">
|
||||
{nav.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1.5 rounded-md px-3 py-2 text-sm transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<a
|
||||
href="https://stackship.se/en"
|
||||
className="hidden items-center gap-1.5 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground sm:flex"
|
||||
>
|
||||
<BookOpenIcon className="size-4" />
|
||||
Docs
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border/70">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-8 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between md:px-6">
|
||||
<p className="max-w-xl">
|
||||
Built with Circuit. Runs on Stackship — on your own Kubernetes clusters, with your data
|
||||
where you decide.
|
||||
</p>
|
||||
<p className="font-mono text-xs">React 19 · Vite · Tailwind v4 · Radix UI</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Stackship wordmark. Picks the colour logo in light mode and the on-dark logo in dark mode;
|
||||
* pass `onDark` on surfaces that are always dark. Never recolour or add effects to the logo.
|
||||
*/
|
||||
export function Logo({ onDark = false, className = 'h-7' }: { onDark?: boolean; className?: string }) {
|
||||
if (onDark) return <img src="/logo-on-dark.svg" alt="Stackship" className={`w-auto ${className}`} />
|
||||
return (
|
||||
<>
|
||||
<img src="/logo.svg" alt="Stackship" className={`w-auto dark:hidden ${className}`} />
|
||||
<img src="/logo-on-dark.svg" alt="Stackship" className={`hidden w-auto dark:block ${className}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
/** Small eyebrow above a heading. */
|
||||
export default function SectionLabel({
|
||||
icon: Icon,
|
||||
onDark = false,
|
||||
children,
|
||||
}: {
|
||||
icon?: LucideIcon
|
||||
onDark?: boolean
|
||||
children: string
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${
|
||||
onDark ? 'bg-white/5 text-white/80 ring-1 ring-white/10' : 'bg-primary/10 text-primary'
|
||||
}`}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5" />}
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
|
||||
theme: 'system',
|
||||
setTheme: () => {},
|
||||
})
|
||||
|
||||
function savedTheme(): Theme {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'light' || saved === 'dark') return saved
|
||||
} catch {
|
||||
// Storage can be blocked (private mode, sandboxed previews); fall back to the OS.
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(savedTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(prefers-color-scheme: dark)')
|
||||
const apply = () =>
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
theme === 'dark' || (theme === 'system' && media.matches),
|
||||
)
|
||||
apply()
|
||||
try {
|
||||
if (theme === 'system') localStorage.removeItem('theme')
|
||||
else localStorage.setItem('theme', theme)
|
||||
} catch {
|
||||
// Not persisted; the choice still applies for this visit.
|
||||
}
|
||||
if (theme !== 'system') return
|
||||
media.addEventListener('change', apply)
|
||||
return () => media.removeEventListener('change', apply)
|
||||
}, [theme])
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DropdownMenu } from 'radix-ui'
|
||||
import { LaptopIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useTheme, type Theme } from './ThemeProvider'
|
||||
|
||||
const options: { value: Theme; label: string; icon: typeof SunIcon }[] = [
|
||||
{ value: 'light', label: 'Light', icon: SunIcon },
|
||||
{ value: 'dark', label: 'Dark', icon: MoonIcon },
|
||||
{ value: 'system', label: 'System', icon: LaptopIcon },
|
||||
]
|
||||
|
||||
/** Light / dark / system picker. Persists to localStorage via ThemeProvider. */
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
aria-label="Change theme"
|
||||
className="grid size-9 place-items-center rounded-md text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<SunIcon className="size-4 dark:hidden" />
|
||||
<MoonIcon className="hidden size-4 dark:block" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="z-50 w-40 rounded-lg border border-border bg-popover p-1.5 shadow-xl focus:outline-none"
|
||||
>
|
||||
{options.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() => setTheme(value)}
|
||||
className="flex cursor-default items-center gap-2.5 rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted"
|
||||
>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
{label}
|
||||
{theme === value && <span className="ml-auto size-1.5 rounded-full bg-brand" />}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card } from '../ui/Card'
|
||||
import { ProgressBar } from '../ui/ProgressBar'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Budget } from '../../api/types'
|
||||
|
||||
const kr = (n: number) => n.toLocaleString('sv-SE')
|
||||
|
||||
/** Monthly budget meter; takes the useBudget() result. */
|
||||
export default function BudgetCard({ data }: { data?: Budget }) {
|
||||
const pct = data ? Math.round((data.used / data.total) * 100) : null
|
||||
return (
|
||||
<Card variant="gradient" className="p-6">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="font-semibold">Monthly budget</h2>
|
||||
<span className="font-mono text-xs text-muted-foreground">{pct ?? '—'}%</span>
|
||||
</div>
|
||||
{data ? (
|
||||
<>
|
||||
<p className="mt-3 text-3xl font-semibold tabular-nums">
|
||||
{kr(data.used)}
|
||||
<span className="ml-1.5 text-base font-normal text-muted-foreground">
|
||||
/ {kr(data.total)} {data.currency}
|
||||
</span>
|
||||
</p>
|
||||
<ProgressBar value={pct ?? 0} label="Budget used" className="mt-4" />
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
On track. Prices exclude VAT; compute is billed by your own cluster.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="mt-3 h-9 w-40" />
|
||||
<Skeleton className="mt-4 h-2 w-full" />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BotIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import { Card } from '../ui/Card'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Deploy } from '../../api/types'
|
||||
|
||||
/** Recent deploys; takes the useDeploys() state and handles loading itself. */
|
||||
export default function DeploysTable({ data, isLoading }: { data?: Deploy[]; isLoading: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="font-semibold">Recent deploys</h2>
|
||||
<span className="text-xs text-muted-foreground">Last 24 h</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3 p-6">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border/70">
|
||||
{(data ?? []).map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-3 pr-2 pl-6">
|
||||
{d.ok ? (
|
||||
<CheckCircle2Icon className="size-4 text-success-foreground" aria-label="Succeeded" />
|
||||
) : (
|
||||
<XCircleIcon className="size-4 text-destructive-foreground" aria-label="Failed" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-3 font-mono text-[13px]">{d.project}</td>
|
||||
<td className="px-2 py-3">
|
||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{d.env}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-2 py-3 text-muted-foreground sm:table-cell">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.by === 'Circuit agent' && <BotIcon className="size-3.5 text-primary" />}
|
||||
{d.by}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-6 pl-2 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{d.time}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TriangleAlertIcon } from 'lucide-react'
|
||||
import { Button } from '../ui/Button'
|
||||
|
||||
/** Inline error with a retry — the pattern for any failed request. */
|
||||
export default function ErrorNote({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-warning-foreground/30 bg-warning px-4 py-3 text-sm text-warning-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
Couldn't load data — {error.message}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChevronRightIcon } from 'lucide-react'
|
||||
import { Badge, type Tone } from '../ui/Badge'
|
||||
import { Card } from '../ui/Card'
|
||||
import { ServiceIcon } from '../ui/ServiceIcon'
|
||||
import { SERVICES } from '../ui/services'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Resource } from '../../api/types'
|
||||
|
||||
const statusTone: Record<Resource['status'], Tone> = {
|
||||
running: 'success',
|
||||
scaling: 'info',
|
||||
degraded: 'warning',
|
||||
stopped: 'neutral',
|
||||
}
|
||||
|
||||
/** Resource list with per-service icons; takes the useResources() state. */
|
||||
export default function ResourcesTable({ data, isLoading }: { data?: Resource[]; isLoading: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="font-semibold">Resources</h2>
|
||||
<span className="text-xs text-muted-foreground">{data ? `${data.length} total` : ''}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3 p-6">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/70">
|
||||
{(data ?? []).map((r) => (
|
||||
<li key={r.name}>
|
||||
<a
|
||||
href="#"
|
||||
className="group flex items-center gap-4 px-6 py-3.5 transition-colors duration-200 hover:bg-muted/50"
|
||||
>
|
||||
<ServiceIcon service={r.service} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-mono text-[13px] font-medium">{r.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{SERVICES[r.service].label} · {r.region}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden text-xs text-muted-foreground sm:block">{r.env}</span>
|
||||
<Badge tone={statusTone[r.status]} dot className="capitalize">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { WandSparklesIcon } from 'lucide-react'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Insight } from '../../api/types'
|
||||
|
||||
/**
|
||||
* Stackship Sentinel insight. Sentinel is the AI/ops sub-brand and deliberately uses the rose
|
||||
* accent instead of blue/green — keep it for Sentinel surfaces only.
|
||||
*/
|
||||
export default function SentinelCard({ data }: { data?: Insight }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-sentinel/30 bg-sentinel/[0.06] p-6 shadow-lg shadow-sentinel/10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="grid size-8 place-items-center rounded-md bg-sentinel text-white">
|
||||
<WandSparklesIcon className="size-4" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold">Stackship Sentinel</p>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">runs locally*</span>
|
||||
</div>
|
||||
{data ? (
|
||||
<>
|
||||
<p className="mt-4 font-medium">{data.title}</p>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">{data.body}</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-md bg-sentinel px-3 py-1.5 text-sm font-medium text-white transition-opacity duration-200 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sentinel">
|
||||
{data.action}
|
||||
</button>
|
||||
<button className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="mt-4 h-5 w-3/4" />
|
||||
<Skeleton className="mt-2 h-12 w-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ArrowDownRightIcon, ArrowUpRightIcon, BoxesIcon, CoinsIcon, RocketIcon, ShieldAlertIcon, type LucideIcon } from 'lucide-react'
|
||||
import { Card } from '../ui/Card'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Stat } from '../../api/types'
|
||||
|
||||
const icons: Record<Stat['icon'], LucideIcon> = {
|
||||
apps: BoxesIcon,
|
||||
cost: CoinsIcon,
|
||||
deploys: RocketIcon,
|
||||
findings: ShieldAlertIcon,
|
||||
}
|
||||
|
||||
/** Four summary tiles; takes the useStats() state and handles loading itself. */
|
||||
export default function StatGrid({ data, isLoading }: { data?: Stat[]; isLoading: boolean }) {
|
||||
if (isLoading) {
|
||||
return Array.from({ length: 4 }, (_, i) => (
|
||||
<Card key={i} className="p-5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="mt-4 h-8 w-20" />
|
||||
<Skeleton className="mt-3 h-3 w-28" />
|
||||
</Card>
|
||||
))
|
||||
}
|
||||
return (data ?? []).map(({ label, value, delta, up, icon }) => {
|
||||
const Icon = icons[icon]
|
||||
return (
|
||||
<Card key={label} className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<span className="grid size-8 place-items-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-tight tabular-nums">{value}</p>
|
||||
<p
|
||||
className={`mt-1.5 flex items-center gap-1 text-xs font-medium ${
|
||||
up ? 'text-success-foreground' : 'text-destructive-foreground'
|
||||
}`}
|
||||
>
|
||||
{delta.startsWith('-') ? (
|
||||
<ArrowDownRightIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpRightIcon className="size-3.5" />
|
||||
)}
|
||||
{delta} vs last month
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DropdownMenu } from 'radix-ui'
|
||||
import { MoreHorizontalIcon } from 'lucide-react'
|
||||
import { Card } from '../ui/Card'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import type { Team } from '../../api/types'
|
||||
|
||||
/** Team roster + latest activity; takes the useTeam() state and handles loading itself. */
|
||||
export default function TeamCard({ data, isLoading }: { data?: Team; isLoading: boolean }) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Team</h2>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
aria-label="Team actions"
|
||||
className="grid size-8 place-items-center rounded-md text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="z-50 w-44 rounded-lg border border-border bg-popover p-1.5 shadow-xl focus:outline-none"
|
||||
>
|
||||
{['Invite teammate', 'Manage roles', 'Leave team'].map((label) => (
|
||||
<DropdownMenu.Item
|
||||
key={label}
|
||||
onSelect={() => {}}
|
||||
className="cursor-default rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted"
|
||||
>
|
||||
{label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
<div className="mt-4 flex -space-x-2">
|
||||
{isLoading ? (
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
) : (
|
||||
(data?.members ?? []).map((initials, i) => (
|
||||
<span
|
||||
key={initials}
|
||||
className={`grid size-9 place-items-center rounded-full border-2 border-card text-xs font-semibold text-white ${
|
||||
['bg-svc-app', 'bg-svc-function', 'bg-svc-database', 'bg-svc-storage'][i % 4]
|
||||
}`}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{data && data.extraCount > 0 && (
|
||||
<span className="grid size-9 place-items-center rounded-full border-2 border-card bg-muted text-xs font-medium text-muted-foreground">
|
||||
+{data.extraCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{isLoading ? 'Loading activity…' : data?.activity}
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Accordion } from 'radix-ui'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'Where does the app run?',
|
||||
a: 'On Stackship — on your own Kubernetes clusters, on‑prem, at the edge or in your cloud account.',
|
||||
},
|
||||
{
|
||||
q: 'How is the theme defined?',
|
||||
a: 'Stackship tokens as CSS variables at the top of src/index.css. Change a value and both modes follow.',
|
||||
},
|
||||
{
|
||||
q: 'Can I remove pages I don’t need?',
|
||||
a: 'Yes. Each route is one file in src/pages, registered in src/App.tsx, with a nav entry in Layout.tsx.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function AccordionDemo() {
|
||||
return (
|
||||
<Accordion.Root type="single" collapsible className="divide-y divide-border rounded-lg border border-border">
|
||||
{faqs.map(({ q, a }) => (
|
||||
<Accordion.Item key={q} value={q}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger className="group flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring">
|
||||
{q}
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content className="overflow-hidden px-5 pb-4 text-sm leading-6 text-muted-foreground">
|
||||
{a}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Badge } from '../ui/Badge'
|
||||
|
||||
export default function BadgesDemo() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone="success" dot>Running</Badge>
|
||||
<Badge tone="info" dot>Scaling</Badge>
|
||||
<Badge tone="warning" dot>Degraded</Badge>
|
||||
<Badge tone="error" dot>Failed</Badge>
|
||||
<Badge tone="neutral" dot>Stopped</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone="gradient">Pilot</Badge>
|
||||
<Badge>GDPR</Badge>
|
||||
<Badge>NIS2</Badge>
|
||||
<Badge>ISO27001</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ArrowRightIcon, PlusIcon } from 'lucide-react'
|
||||
import { Button } from '../ui/Button'
|
||||
|
||||
export default function ButtonsDemo() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="gradient">
|
||||
Apply now
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
<Button>
|
||||
<PlusIcon />
|
||||
Create resource
|
||||
</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Learn more</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md" variant="outline">Medium</Button>
|
||||
<Button size="lg" variant="outline">Large</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-night p-4">
|
||||
<Button variant="gradient" size="sm">On dark</Button>
|
||||
<Button variant="outline" size="sm" onDark>Outline</Button>
|
||||
<Button variant="ghost" size="sm" onDark>Ghost</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Dialog } from 'radix-ui'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import { Button } from '../ui/Button'
|
||||
|
||||
export default function DialogDemo() {
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trash2Icon />
|
||||
Delete environment
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-night/50 backdrop-blur-sm" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-popover p-6 shadow-2xl focus:outline-none">
|
||||
<Dialog.Title className="text-lg font-semibold">Delete this environment?</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
“staging” and its 4 resources will be removed for everyone. Backups are kept for 30
|
||||
days.
|
||||
</Dialog.Description>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="outline">Keep it</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close className="inline-flex h-10 items-center rounded-md bg-destructive-foreground px-4 text-sm font-medium text-white transition-opacity duration-200 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-foreground">
|
||||
Delete environment
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Label, RadioGroup } from 'radix-ui'
|
||||
import { Button } from '../ui/Button'
|
||||
|
||||
const envs = [
|
||||
{ value: 'production', note: 'Borlänge DC' },
|
||||
{ value: 'staging', note: 'Falun edge' },
|
||||
]
|
||||
|
||||
const field =
|
||||
'h-10 w-full rounded-md border border-border bg-input px-3 text-sm transition-colors duration-200 placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/15'
|
||||
|
||||
export default function FormDemo() {
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Label.Root htmlFor="app-name" className="text-sm font-medium">
|
||||
App name
|
||||
</Label.Root>
|
||||
<input id="app-name" className={`${field} mt-1.5 font-mono`} placeholder="case-intake-web" />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">Lowercase letters, digits and dashes.</p>
|
||||
</div>
|
||||
<RadioGroup.Root defaultValue="production" aria-label="Environment" className="grid grid-cols-2 gap-3">
|
||||
{envs.map(({ value, note }) => (
|
||||
<RadioGroup.Item
|
||||
key={value}
|
||||
value={value}
|
||||
className="rounded-lg border border-border p-3 text-left transition-colors duration-200 hover:border-foreground/25 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<span className="block text-sm font-medium capitalize">{value}</span>
|
||||
<span className="block text-xs text-muted-foreground">{note}</span>
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
<Button type="submit" variant="gradient" className="w-full">
|
||||
Create app
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Checkbox, Popover } from 'radix-ui'
|
||||
import { BellIcon, CheckIcon } from 'lucide-react'
|
||||
import { Button } from '../ui/Button'
|
||||
|
||||
export default function NotificationsDemo() {
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="outline">
|
||||
<BellIcon />
|
||||
Notifications
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
className="glass z-50 w-72 rounded-lg p-4 shadow-xl focus:outline-none"
|
||||
>
|
||||
<p className="text-sm font-semibold">Notify me when</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">For resources you own.</p>
|
||||
<div className="mt-3 space-y-2.5 text-sm">
|
||||
{['A deploy fails', 'A review is requested', 'Sentinel finds something'].map(
|
||||
(label, i) => (
|
||||
<label key={label} className="flex items-center gap-2.5">
|
||||
<Checkbox.Root
|
||||
defaultChecked={i !== 2}
|
||||
className="grid size-4.5 place-items-center rounded-[5px] border border-foreground/25 bg-card transition-colors duration-200 data-[state=checked]:border-primary data-[state=checked]:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<Checkbox.Indicator>
|
||||
<CheckIcon className="size-3 text-white" strokeWidth={3} />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Popover.Close asChild>
|
||||
<Button size="sm" className="mt-4 w-full">
|
||||
Save preferences
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card } from '../ui/Card'
|
||||
|
||||
/** Framed section used across the component gallery. */
|
||||
export default function Section({
|
||||
title,
|
||||
hint,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
hint: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{hint}</span>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ServiceIcon } from '../ui/ServiceIcon'
|
||||
import { SERVICES } from '../ui/services'
|
||||
import type { Service } from '../../api/types'
|
||||
|
||||
export default function ServicesDemo() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-5">
|
||||
{(Object.keys(SERVICES) as Service[]).map((service) => (
|
||||
<div key={service} className="flex items-center gap-2.5 sm:flex-col sm:text-center">
|
||||
<ServiceIcon service={service} size="lg" />
|
||||
<span className="text-xs text-muted-foreground">{SERVICES[service].label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Tabs } from 'radix-ui'
|
||||
|
||||
const tabs = [
|
||||
{ value: 'dev', label: 'Develop', cmd: 'npm run dev', note: 'app on :5173, mock API at /api' },
|
||||
{ value: 'build', label: 'Build', cmd: 'npm run build', note: 'type-check + production build' },
|
||||
{ value: 'lint', label: 'Lint', cmd: 'npm run lint', note: 'oxlint, in milliseconds' },
|
||||
]
|
||||
|
||||
export default function TabsDemo() {
|
||||
return (
|
||||
<Tabs.Root defaultValue="dev" className="w-full">
|
||||
<Tabs.List className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabs.map(({ value, label }) => (
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="rounded-md px-4 py-1.5 text-sm text-muted-foreground transition-colors duration-200 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
{label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{tabs.map(({ value, cmd, note }) => (
|
||||
<Tabs.Content
|
||||
key={value}
|
||||
value={value}
|
||||
className="mt-4 rounded-lg bg-night p-4 font-mono text-sm text-white/80 focus:outline-none"
|
||||
>
|
||||
<span className="text-accent">$</span> {cmd}
|
||||
<span className="mt-1 block text-xs text-white/40"># {note}</span>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Tooltip } from 'radix-ui'
|
||||
import { CopyIcon, DownloadIcon, RotateCwIcon } from 'lucide-react'
|
||||
import { ProgressBar } from '../ui/ProgressBar'
|
||||
|
||||
const buttons = [
|
||||
{ label: 'Copy', tip: 'Copy connection string', icon: CopyIcon },
|
||||
{ label: 'Restart', tip: 'Restart all replicas', icon: RotateCwIcon },
|
||||
{ label: 'Download', tip: 'Download logs', icon: DownloadIcon },
|
||||
]
|
||||
|
||||
export default function TooltipProgressDemo() {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{buttons.map(({ label, tip, icon: Icon }) => (
|
||||
<Tooltip.Root key={label}>
|
||||
<Tooltip.Trigger
|
||||
aria-label={label}
|
||||
className="grid size-10 place-items-center rounded-md border border-border transition-colors duration-200 hover:border-foreground/25 hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
className="z-50 rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-lg"
|
||||
>
|
||||
{tip}
|
||||
<Tooltip.Arrow className="fill-foreground" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{[
|
||||
['CPU quota', '6.2 / 8 cores', 77],
|
||||
['Storage', '41 / 100 GB', 41],
|
||||
].map(([label, value, pct]) => (
|
||||
<div key={label}>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
<ProgressBar value={pct as number} label={label as string} className="mt-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Avatar, DropdownMenu } from 'radix-ui'
|
||||
import { ChevronDownIcon, LogOutIcon, SettingsIcon, UserIcon } from 'lucide-react'
|
||||
|
||||
export default function UserMenuDemo() {
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="flex items-center gap-2 rounded-md border border-border py-1.5 pr-3 pl-1.5 text-sm transition-colors duration-200 hover:border-foreground/25 data-[state=open]:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
|
||||
<Avatar.Root className="size-7 overflow-hidden rounded-md bg-brand">
|
||||
<Avatar.Fallback className="grid size-full place-items-center text-xs font-semibold text-white">
|
||||
MK
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
Mats K.
|
||||
<ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="z-50 w-52 rounded-lg border border-border bg-popover p-1.5 shadow-xl focus:outline-none"
|
||||
>
|
||||
<DropdownMenu.Label className="px-3 py-2 text-xs text-muted-foreground">
|
||||
mats@kommunen.se
|
||||
</DropdownMenu.Label>
|
||||
{[
|
||||
{ icon: UserIcon, label: 'Profile' },
|
||||
{ icon: SettingsIcon, label: 'Settings' },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<DropdownMenu.Item
|
||||
key={label}
|
||||
className="flex cursor-default items-center gap-2.5 rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted"
|
||||
>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
{label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Separator className="my-1.5 h-px bg-border" />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {}}
|
||||
className="flex cursor-default items-center gap-2.5 rounded-md px-3 py-2 text-sm text-destructive-foreground outline-none data-[highlighted]:bg-destructive"
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
Sign out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { Slider, Switch } from 'radix-ui'
|
||||
import { ServiceIcon } from '../ui/ServiceIcon'
|
||||
import type { Service } from '../../api/types'
|
||||
|
||||
const stack: { service: Service; name: string; detail: string }[] = [
|
||||
{ service: 'app', name: 'case-intake-web', detail: 'App Service · Node 22' },
|
||||
{ service: 'database', name: 'case-intake-db', detail: 'PostgreSQL 17 · 20 GB' },
|
||||
{ service: 'storage', name: 'documents', detail: 'Object storage · S3 API' },
|
||||
]
|
||||
|
||||
// Illustrative pricing for the demo: kr per replica per month, plus the fixed services.
|
||||
const PER_REPLICA = 420
|
||||
const FIXED = 1_150
|
||||
|
||||
/** Interactive glass card in the hero — Radix Slider + Switch driving a live cost estimate. */
|
||||
export default function EnvironmentCard() {
|
||||
const [replicas, setReplicas] = useState(3)
|
||||
const [scaleToZero, setScaleToZero] = useState(true)
|
||||
const monthly = FIXED + replicas * PER_REPLICA * (scaleToZero ? 0.6 : 1)
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md animate-rise rounded-2xl bg-linear-to-tr from-primary/40 to-accent/40 p-px shadow-2xl shadow-primary/20 [animation-delay:120ms]">
|
||||
<div className="rounded-[calc(var(--radius-2xl)-1px)] bg-[#111821]/90 p-6 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/50">Environment</p>
|
||||
<p className="mt-0.5 text-lg font-semibold">production</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-accent/15 px-2.5 py-1 text-xs font-medium text-accent">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-accent" />
|
||||
Healthy
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-5 space-y-2">
|
||||
{stack.map(({ service, name, detail }) => (
|
||||
<li
|
||||
key={name}
|
||||
className="flex items-center gap-3 rounded-lg bg-white/[0.04] p-2.5 ring-1 ring-white/5"
|
||||
>
|
||||
<ServiceIcon service={service} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-[13px] text-white">{name}</p>
|
||||
<p className="text-xs text-white/50">{detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 flex items-baseline justify-between text-sm">
|
||||
<span className="text-white/70">Replicas</span>
|
||||
<span className="font-mono text-white">{replicas}</span>
|
||||
</div>
|
||||
<Slider.Root
|
||||
value={[replicas]}
|
||||
onValueChange={([value]) => setReplicas(value)}
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
aria-label="Replicas"
|
||||
className="relative mt-3 flex h-5 w-full touch-none items-center select-none"
|
||||
>
|
||||
<Slider.Track className="relative h-1.5 grow rounded-full bg-white/10">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-brand" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block size-4 rounded-full bg-white shadow-md shadow-black/40 transition-transform duration-200 hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" />
|
||||
</Slider.Root>
|
||||
|
||||
<label className="mt-5 flex cursor-pointer items-center justify-between gap-4 text-sm text-white/70">
|
||||
Scale to zero outside office hours
|
||||
<Switch.Root
|
||||
checked={scaleToZero}
|
||||
onCheckedChange={setScaleToZero}
|
||||
className="relative h-6 w-10 shrink-0 rounded-full bg-white/15 transition-colors duration-200 data-[state=checked]:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Switch.Thumb className="block size-4 translate-x-1 rounded-full bg-white transition-transform duration-200 data-[state=checked]:translate-x-5" />
|
||||
</Switch.Root>
|
||||
</label>
|
||||
|
||||
<div className="mt-6 flex items-end justify-between border-t border-white/10 pt-5">
|
||||
<div>
|
||||
<p className="text-xs text-white/50">Estimated monthly cost*</p>
|
||||
<p className="mt-1 text-3xl font-semibold tabular-nums">
|
||||
{Math.round(monthly).toLocaleString('sv-SE')}
|
||||
<span className="ml-1.5 text-base font-normal text-white/50">kr</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-[9rem] text-right text-[11px] leading-snug text-white/40">
|
||||
* Management layer only. Compute billed by your own cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ArrowRightIcon, SparklesIcon } from 'lucide-react'
|
||||
import { Button } from '../ui/Button'
|
||||
import SectionLabel from '../SectionLabel'
|
||||
import EnvironmentCard from './EnvironmentCard'
|
||||
|
||||
/** Always-dark hero with the brand glows. Sits flush under the header in both themes. */
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section className="relative isolate overflow-hidden bg-night text-white">
|
||||
<div className="grid-lines absolute inset-0 -z-10" aria-hidden />
|
||||
<div className="glow-primary absolute -bottom-40 -left-32 -z-10 size-[32rem]" aria-hidden />
|
||||
<div className="glow-accent absolute -top-24 right-[-8rem] -z-10 size-[36rem]" aria-hidden />
|
||||
|
||||
<div className="mx-auto grid max-w-7xl items-center gap-14 px-4 pt-16 pb-20 md:px-6 lg:grid-cols-[1.15fr_1fr] lg:pt-24 lg:pb-28">
|
||||
<div className="animate-rise">
|
||||
<SectionLabel icon={SparklesIcon} onDark>
|
||||
Circuit starter for Stackship
|
||||
</SectionLabel>
|
||||
<h1 className="mt-6 text-4xl leading-[1.1] font-bold sm:text-5xl lg:text-6xl">
|
||||
Your next system,
|
||||
<br />
|
||||
<span className="text-gradient">built on your terms.</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/70">
|
||||
A React and Vite starting point that Circuit builds on. Routing, theming, a typed API
|
||||
layer and the Stackship components are already wired — so the first prompt goes to
|
||||
product work, not plumbing.
|
||||
</p>
|
||||
<div className="mt-9 flex flex-wrap items-center gap-3">
|
||||
<Button to="/dashboard" variant="gradient" size="lg" className="group">
|
||||
Open the dashboard
|
||||
<ArrowRightIcon className="transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</Button>
|
||||
<Button to="/components" variant="outline" size="lg" onDark>
|
||||
Browse components
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="mt-12 grid max-w-md grid-cols-3 gap-6 border-t border-white/10 pt-6">
|
||||
{[
|
||||
['5173', 'preview port'],
|
||||
['/api', 'mock backend'],
|
||||
['2', 'themes'],
|
||||
].map(([value, label]) => (
|
||||
<div key={label}>
|
||||
<dt className="sr-only">{label}</dt>
|
||||
<dd className="font-mono text-xl font-medium text-white">{value}</dd>
|
||||
<dd className="mt-1 text-xs text-white/50">{label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<EnvironmentCard />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { HammerIcon, RocketIcon, ShieldCheckIcon, type LucideIcon } from 'lucide-react'
|
||||
import { Card } from '../ui/Card'
|
||||
|
||||
const steps: { icon: LucideIcon; step: string; title: string; body: string }[] = [
|
||||
{
|
||||
icon: HammerIcon,
|
||||
step: '01',
|
||||
title: 'Build',
|
||||
body: 'Describe the need. The agent shapes this template into a working app, one small, reviewable change at a time.',
|
||||
},
|
||||
{
|
||||
icon: RocketIcon,
|
||||
step: '02',
|
||||
title: 'Deploy',
|
||||
body: 'Review the change, approve the merge, and it runs on Stackship — on your clusters, behind your firewall.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheckIcon,
|
||||
step: '03',
|
||||
title: 'Operate',
|
||||
body: 'Patches, dependency updates and vulnerability fixes keep landing for as long as the system runs.',
|
||||
},
|
||||
]
|
||||
|
||||
/** The Circuit loop: Build → Deploy → Operate. */
|
||||
export default function LoopSection() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 py-20 md:px-6 md:py-24">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">How Circuit uses this template</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Every initiative starts from this repository. You own the code — it lands in your own
|
||||
source control, not with us.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-12 grid gap-5 md:grid-cols-3">
|
||||
{steps.map(({ icon: Icon, step, title, body }, i) => (
|
||||
<Card key={step} variant={i === 0 ? 'gradient' : 'default'} className="h-full p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="grid size-11 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{step}</span>
|
||||
</div>
|
||||
<h3 className="mt-8 text-xl font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
const stack = [
|
||||
['React 19', 'with the React Compiler'],
|
||||
['Vite 8', 'instant HMR, rolldown core'],
|
||||
['Tailwind v4', 'Stackship tokens in CSS'],
|
||||
['Radix UI', 'accessible primitives'],
|
||||
['SWR', 'typed data hooks'],
|
||||
['Lucide', 'the Stackship icon set'],
|
||||
]
|
||||
|
||||
const wired = [
|
||||
'Light and dark themes',
|
||||
'Self-hosted Poppins — works air-gapped',
|
||||
'Mock API with loading and error states',
|
||||
'Live preview on port 5173',
|
||||
]
|
||||
|
||||
export default function StackGrid() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 pb-24 md:px-6">
|
||||
<div className="grid gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2 lg:grid-cols-3">
|
||||
{stack.map(([name, note]) => (
|
||||
<div key={name} className="bg-card p-6">
|
||||
<p className="font-semibold">{name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-muted-foreground">
|
||||
{wired.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="grid size-5 place-items-center rounded-full bg-accent/15 text-accent">
|
||||
<CheckIcon className="size-3" strokeWidth={3} />
|
||||
</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Tone = 'success' | 'info' | 'warning' | 'error' | 'neutral' | 'gradient'
|
||||
|
||||
const tones: Record<Tone, string> = {
|
||||
success: 'bg-success text-success-foreground',
|
||||
info: 'bg-info text-info-foreground',
|
||||
warning: 'bg-warning text-warning-foreground',
|
||||
error: 'bg-destructive text-destructive-foreground',
|
||||
neutral: 'bg-muted text-muted-foreground',
|
||||
gradient: 'bg-brand text-white',
|
||||
}
|
||||
|
||||
/** Status pill. Set `dot` for a leading status dot (e.g. "Running"). */
|
||||
export function Badge({
|
||||
tone = 'neutral',
|
||||
dot = false,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone
|
||||
dot?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${tones[tone]} ${className}`}
|
||||
>
|
||||
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
type Variant = 'primary' | 'gradient' | 'outline' | 'ghost'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition duration-200 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||
gradient: 'bg-brand text-white shadow-lg shadow-primary/20 hover:opacity-90',
|
||||
outline: 'border border-border bg-card/40 hover:border-foreground/25 hover:bg-muted',
|
||||
ghost: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
}
|
||||
|
||||
// On the always-dark surfaces (hero, callouts) outline and ghost need light borders/hovers.
|
||||
const onDarkVariants: Partial<Record<Variant, string>> = {
|
||||
outline: 'border border-white/15 text-white hover:border-white/30 hover:bg-white/10',
|
||||
ghost: 'text-white/70 hover:bg-white/10 hover:text-white',
|
||||
}
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
}
|
||||
|
||||
type ButtonProps = { variant?: Variant; size?: Size; onDark?: boolean } & (
|
||||
| ({ to: string } & Omit<ComponentProps<typeof Link>, 'to'>)
|
||||
| ({ to?: undefined } & ComponentProps<'button'>)
|
||||
)
|
||||
|
||||
/** The one button for the whole app. Renders a router Link when `to` is set. */
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
onDark = false,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const look = (onDark && onDarkVariants[variant]) || variants[variant]
|
||||
const classes = `${base} ${look} ${sizes[size]} ${className}`
|
||||
if ('to' in props && props.to) return <Link className={classes} {...props} />
|
||||
return <button className={classes} {...(props as ComponentProps<'button'>)} />
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type Variant = 'default' | 'glass' | 'gradient'
|
||||
|
||||
const variants: Record<Exclude<Variant, 'gradient'>, string> = {
|
||||
default: 'border border-border bg-card text-card-foreground shadow-sm',
|
||||
glass: 'glass shadow-xl shadow-black/5',
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface for grouped content. `gradient` wraps the card in a 1px blue→green halo —
|
||||
* use it for the one card on a screen that should draw the eye.
|
||||
* Set `interactive` for cards that are clickable (lighter border + lift on hover).
|
||||
*/
|
||||
export function Card({
|
||||
variant = 'default',
|
||||
interactive = false,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
variant?: Variant
|
||||
interactive?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const hover = interactive
|
||||
? 'transition duration-200 ease-out hover:border-foreground/25 hover:shadow-md'
|
||||
: ''
|
||||
if (variant === 'gradient') {
|
||||
return (
|
||||
<div className="rounded-xl bg-linear-to-tr from-primary/50 to-accent/50 p-px shadow-lg shadow-primary/10">
|
||||
<div className={`h-full rounded-[calc(var(--radius-xl)-1px)] bg-card ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className={`rounded-xl ${variants[variant]} ${hover} ${className}`}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Theme-aware progress bar filled with the brand gradient; value is 0–100. */
|
||||
export function ProgressBar({
|
||||
value,
|
||||
label,
|
||||
className = '',
|
||||
}: {
|
||||
value: number
|
||||
label?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={`h-2 overflow-hidden rounded-full bg-muted ${className}`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Service } from '../../api/types'
|
||||
import { SERVICES } from './services'
|
||||
|
||||
const sizes = {
|
||||
sm: 'size-8 rounded-md [&_svg]:size-4',
|
||||
md: 'size-10 rounded-md [&_svg]:size-5',
|
||||
lg: 'size-12 rounded-lg [&_svg]:size-6',
|
||||
}
|
||||
|
||||
/** Coloured tile with a white icon — the canonical marker for a Stackship resource. */
|
||||
export function ServiceIcon({
|
||||
service,
|
||||
size = 'md',
|
||||
}: {
|
||||
service: Service
|
||||
size?: keyof typeof sizes
|
||||
}) {
|
||||
const { icon: Icon, bg, label } = SERVICES[service]
|
||||
return (
|
||||
<span
|
||||
className={`grid shrink-0 place-items-center text-white shadow-sm ${bg} ${sizes[size]}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon strokeWidth={1.75} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Loading placeholder — pairs with the .skeleton class in index.css. */
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} aria-hidden />
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
CloudIcon,
|
||||
ContainerIcon,
|
||||
DatabaseIcon,
|
||||
DatabaseZapIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MonitorIcon,
|
||||
SquareFunctionIcon,
|
||||
WebhookIcon,
|
||||
WorkflowIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { Service } from '../../api/types'
|
||||
|
||||
/** Accent colour and Lucide icon per Stackship resource type (mirrors the Stackship portal). */
|
||||
export const SERVICES: Record<Service, { label: string; icon: LucideIcon; bg: string }> = {
|
||||
app: { label: 'App Service', icon: GlobeIcon, bg: 'bg-svc-app' },
|
||||
function: { label: 'Function', icon: SquareFunctionIcon, bg: 'bg-svc-function' },
|
||||
database: { label: 'Database', icon: DatabaseIcon, bg: 'bg-svc-database' },
|
||||
storage: { label: 'Storage', icon: CloudIcon, bg: 'bg-svc-storage' },
|
||||
container: { label: 'Container', icon: ContainerIcon, bg: 'bg-svc-container' },
|
||||
secrets: { label: 'Secrets', icon: LockIcon, bg: 'bg-svc-secrets' },
|
||||
vm: { label: 'Virtual machine', icon: MonitorIcon, bg: 'bg-svc-vm' },
|
||||
cache: { label: 'Cache', icon: DatabaseZapIcon, bg: 'bg-svc-cache' },
|
||||
bus: { label: 'Message bus', icon: WebhookIcon, bg: 'bg-svc-bus' },
|
||||
erp: { label: 'ERP', icon: WorkflowIcon, bg: 'bg-svc-erp' },
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Stackship theme tokens — lifted from the Stackship portal (portal-v2/src/index.css).
|
||||
Everything in the app reads these through Tailwind classes (bg-card, text-muted-foreground,
|
||||
border-border, text-primary…). Change a value here and both modes follow. */
|
||||
:root {
|
||||
--radius: 1rem;
|
||||
|
||||
/* Brand — the two hero hues and the signature gradient */
|
||||
--primary: hsl(196 100% 35%); /* #0085b8 Stackship blue */
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--accent: hsl(164 100% 38%); /* #00c28e Stackship green */
|
||||
--accent-foreground: hsl(222 84% 5%);
|
||||
--brand-gradient: linear-gradient(90deg, var(--primary), var(--accent));
|
||||
|
||||
/* Sentinel — the AI/ops sub-brand. Rose, used only for Sentinel surfaces. */
|
||||
--sentinel: oklch(64.5% 0.246 16.439);
|
||||
|
||||
/* The always-dark marketing surface (hero, callouts) */
|
||||
--night: hsl(216 28% 7%); /* #0d1117 */
|
||||
|
||||
--background: hsl(0 0% 98%);
|
||||
--background-end: hsl(0 0% 94%);
|
||||
--foreground: hsl(210 6% 15%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(210 6% 15%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--secondary: hsl(217 15% 94%);
|
||||
--secondary-foreground: hsl(216 28% 7%);
|
||||
--muted: hsl(217 15% 94%);
|
||||
--muted-foreground: hsl(215 14% 40%);
|
||||
--border: hsl(210 10% 90%);
|
||||
--input: hsl(210 10% 95%);
|
||||
--ring: hsl(202 100% 29%);
|
||||
|
||||
--success: hsl(138 77% 95%);
|
||||
--success-foreground: hsl(145 100% 26%);
|
||||
--info: hsl(206 100% 96%);
|
||||
--info-foreground: hsl(206 100% 36%);
|
||||
--warning: hsl(55 91% 93%);
|
||||
--warning-foreground: hsl(33 100% 27%);
|
||||
--destructive: hsl(360 88% 96%);
|
||||
--destructive-foreground: hsl(358 100% 38%);
|
||||
|
||||
--glass-bg: rgb(255 255 255 / 0.6);
|
||||
--glass-border: rgb(0 0 0 / 0.08);
|
||||
|
||||
/* Service accents — one hue per Stackship resource type */
|
||||
--svc-app: hsl(196 100% 35%);
|
||||
--svc-function: hsl(164 100% 38%);
|
||||
--svc-database: oklch(62.7% 0.265 303.9);
|
||||
--svc-storage: oklch(70.5% 0.213 47.604);
|
||||
--svc-container: oklch(60.6% 0.25 292.717);
|
||||
--svc-secrets: oklch(76.8% 0.233 130.85);
|
||||
--svc-vm: oklch(65.6% 0.241 354.308);
|
||||
--svc-cache: oklch(79.5% 0.184 86.047);
|
||||
--svc-bus: oklch(70.4% 0.14 182.503);
|
||||
--svc-erp: oklch(68.5% 0.169 237.323);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(216 28% 7%);
|
||||
--background-end: hsl(218 39% 11%);
|
||||
--foreground: hsl(210 20% 98%);
|
||||
--card: hsl(220 17% 10%);
|
||||
--card-foreground: hsl(210 20% 98%);
|
||||
--popover: hsl(216 17% 12%);
|
||||
--secondary: hsl(217 15% 18%);
|
||||
--secondary-foreground: hsl(210 20% 98%);
|
||||
--muted: hsl(217 15% 15%);
|
||||
--muted-foreground: hsl(215 20% 65%);
|
||||
--border: hsl(210 8% 20%);
|
||||
--input: hsl(210 10% 15%);
|
||||
--ring: hsl(202 100% 40%);
|
||||
|
||||
--success: hsl(150 96% 45% / 0.1);
|
||||
--success-foreground: hsl(150 96% 45%);
|
||||
--info: hsl(206 100% 40% / 0.12);
|
||||
--info-foreground: hsl(206 100% 60%);
|
||||
--warning: hsl(48 100% 49% / 0.1);
|
||||
--warning-foreground: hsl(44 100% 47%);
|
||||
--destructive: hsl(330 23% 12%);
|
||||
--destructive-foreground: hsl(359 100% 70%);
|
||||
|
||||
--glass-bg: rgb(255 255 255 / 0.04);
|
||||
--glass-border: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
--radius-md: calc(var(--radius) - 4px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-sentinel: var(--sentinel);
|
||||
--color-night: var(--night);
|
||||
|
||||
--color-svc-app: var(--svc-app);
|
||||
--color-svc-function: var(--svc-function);
|
||||
--color-svc-database: var(--svc-database);
|
||||
--color-svc-storage: var(--svc-storage);
|
||||
--color-svc-container: var(--svc-container);
|
||||
--color-svc-secrets: var(--svc-secrets);
|
||||
--color-svc-vm: var(--svc-vm);
|
||||
--color-svc-cache: var(--svc-cache);
|
||||
--color-svc-bus: var(--svc-bus);
|
||||
--color-svc-erp: var(--svc-erp);
|
||||
|
||||
--animate-rise: rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
--animate-aurora: aurora 60s linear infinite;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(to bottom, var(--background), var(--background-end)) fixed;
|
||||
color: var(--foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Anything 24px and up gets the brand's tightened tracking. */
|
||||
h1,
|
||||
h2 {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ---- Brand utilities ----------------------------------------------------- */
|
||||
|
||||
/* Blue→green gradient text, for the highlighted words in a headline. */
|
||||
@utility text-gradient {
|
||||
background-image: var(--brand-gradient);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Blue→green gradient fill, for CTAs, meters and dividers. */
|
||||
@utility bg-brand {
|
||||
background-image: var(--brand-gradient);
|
||||
}
|
||||
|
||||
/* Translucent glass surface — sticky headers, popovers, cards over glows. */
|
||||
@utility glass {
|
||||
background-color: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
|
||||
/* Soft brand-coloured glow blobs behind hero sections. Position with absolute + inset classes. */
|
||||
@utility glow-primary {
|
||||
background: var(--primary);
|
||||
opacity: 0.18;
|
||||
filter: blur(96px);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
@utility glow-accent {
|
||||
background: var(--accent);
|
||||
opacity: 0.14;
|
||||
filter: blur(96px);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Faint grid that fades out from the top — the only texture, used on dark heroes. */
|
||||
@utility grid-lines {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.04) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
mask-image: radial-gradient(ellipse 80% 70% at 50% 0%, black 30%, transparent 100%);
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
from {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
to {
|
||||
background-position: 400% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--muted);
|
||||
}
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--card) 60%, transparent), transparent);
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import { SWRConfig } from 'swr'
|
||||
// Fonts are bundled, not fetched from a CDN, so the app also works on air-gapped clusters.
|
||||
import '@fontsource/poppins/latin-300.css'
|
||||
import '@fontsource/poppins/latin-400.css'
|
||||
import '@fontsource/poppins/latin-500.css'
|
||||
import '@fontsource/poppins/latin-600.css'
|
||||
import '@fontsource/poppins/latin-700.css'
|
||||
import '@fontsource/jetbrains-mono/latin-400.css'
|
||||
import '@fontsource/jetbrains-mono/latin-500.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10_000,
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</SWRConfig>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
import AccordionDemo from '../components/gallery/AccordionDemo'
|
||||
import BadgesDemo from '../components/gallery/BadgesDemo'
|
||||
import ButtonsDemo from '../components/gallery/ButtonsDemo'
|
||||
import DialogDemo from '../components/gallery/DialogDemo'
|
||||
import FormDemo from '../components/gallery/FormDemo'
|
||||
import NotificationsDemo from '../components/gallery/NotificationsDemo'
|
||||
import Section from '../components/gallery/Section'
|
||||
import ServicesDemo from '../components/gallery/ServicesDemo'
|
||||
import TabsDemo from '../components/gallery/TabsDemo'
|
||||
import TooltipProgressDemo from '../components/gallery/TooltipProgressDemo'
|
||||
import UserMenuDemo from '../components/gallery/UserMenuDemo'
|
||||
|
||||
export default function Components() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 md:px-6 md:py-14">
|
||||
<p className="text-sm font-medium text-primary">Component gallery</p>
|
||||
<h1 className="mt-2 max-w-2xl text-3xl leading-tight font-bold sm:text-4xl">
|
||||
Radix behaviour, <span className="text-gradient">Stackship look</span>
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-muted-foreground">
|
||||
Keyboard navigation, focus management and ARIA come from Radix. The look is plain
|
||||
Tailwind classes on Stackship tokens — copy them straight into new screens.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-2">
|
||||
<Section title="Buttons" hint="ui/Button">
|
||||
<ButtonsDemo />
|
||||
</Section>
|
||||
<Section title="Badges" hint="ui/Badge">
|
||||
<BadgesDemo />
|
||||
</Section>
|
||||
<Section title="Service icons" hint="ui/ServiceIcon" className="lg:col-span-2">
|
||||
<ServicesDemo />
|
||||
</Section>
|
||||
<Section title="Form" hint="radix label · radio-group">
|
||||
<FormDemo />
|
||||
</Section>
|
||||
<Section title="Tabs" hint="radix tabs">
|
||||
<TabsDemo />
|
||||
</Section>
|
||||
<Section title="Dropdown menu" hint="radix dropdown-menu">
|
||||
<UserMenuDemo />
|
||||
</Section>
|
||||
<Section title="Dialog & popover" hint="radix dialog · popover">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<DialogDemo />
|
||||
<NotificationsDemo />
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Accordion" hint="radix accordion">
|
||||
<AccordionDemo />
|
||||
</Section>
|
||||
<Section title="Tooltip & progress" hint="radix tooltip">
|
||||
<TooltipProgressDemo />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-center text-sm text-muted-foreground">
|
||||
Every demo is one self-contained file in{' '}
|
||||
<span className="font-mono text-xs">src/components/gallery/</span>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import BudgetCard from '../components/dashboard/BudgetCard'
|
||||
import DeploysTable from '../components/dashboard/DeploysTable'
|
||||
import ErrorNote from '../components/dashboard/ErrorNote'
|
||||
import ResourcesTable from '../components/dashboard/ResourcesTable'
|
||||
import SentinelCard from '../components/dashboard/SentinelCard'
|
||||
import StatGrid from '../components/dashboard/StatGrid'
|
||||
import TeamCard from '../components/dashboard/TeamCard'
|
||||
import { useBudget, useDeploys, useInsight, useResources, useStats, useTeam } from '../api/hooks'
|
||||
|
||||
export default function Dashboard() {
|
||||
const stats = useStats()
|
||||
const resources = useResources()
|
||||
const deploys = useDeploys()
|
||||
const team = useTeam()
|
||||
const budget = useBudget()
|
||||
const insight = useInsight()
|
||||
const all = [stats, resources, deploys, team, budget, insight]
|
||||
const failed = all.find((r) => r.error)?.error as Error | undefined
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 md:px-6 md:py-14">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Kommunen · production</p>
|
||||
<h1 className="mt-1 text-3xl font-bold sm:text-4xl">
|
||||
All systems <span className="text-gradient">running</span>
|
||||
</h1>
|
||||
</div>
|
||||
<Button variant="gradient">
|
||||
<PlusIcon />
|
||||
Create resource
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<div className="mt-8">
|
||||
<ErrorNote error={failed} onRetry={() => all.forEach((r) => r.mutate())} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatGrid data={stats.data} isLoading={stats.isLoading} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
<ResourcesTable data={resources.data} isLoading={resources.isLoading} />
|
||||
<DeploysTable data={deploys.data} isLoading={deploys.isLoading} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<BudgetCard data={budget.data} />
|
||||
<SentinelCard data={insight.data} />
|
||||
<TeamCard data={team.data} isLoading={team.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Hero from '../components/home/Hero'
|
||||
import LoopSection from '../components/home/LoopSection'
|
||||
import StackGrid from '../components/home/StackGrid'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<LoopSection />
|
||||
<StackGrid />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center px-4 py-32 text-center">
|
||||
<p className="text-gradient font-mono text-sm font-medium">404</p>
|
||||
<h1 className="mt-3 text-4xl font-bold">This page does not exist</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
The address may be mistyped, or the page was removed in a later change.
|
||||
</p>
|
||||
<Button to="/" variant="outline" className="mt-8">
|
||||
<ArrowLeftIcon />
|
||||
Back to overview
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import { startMockServer } from './mock/server.mjs'
|
||||
|
||||
// Runs the mock API (mock/server.mjs) during `vite dev` and kills it on exit.
|
||||
function mockApi(): Plugin {
|
||||
let server: ReturnType<typeof startMockServer> | undefined
|
||||
return {
|
||||
name: 'mock-api',
|
||||
configureServer(devServer) {
|
||||
server = startMockServer()
|
||||
devServer.httpServer?.once('close', () => server?.close())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), babel({ presets: [reactCompilerPreset()] }), mockApi()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8787',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user