feat: add react api and shadcn foundation
This commit is contained in:
parent
f6d6d39a7b
commit
a71d66b1b8
21 changed files with 1524 additions and 36 deletions
25
components.json
Normal file
25
components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"lib": "$lib/.",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
**Architecture:** The browser runtime becomes React mounted from `src/main.tsx`. A Bun server at `src/server/index.ts` serves the static React build and exposes JSON API routes for dashboard reads and agent dashboard mutations. Existing model, persistence, datasource, and agent-config modules remain framework-agnostic TypeScript with import path updates as needed.
|
||||
|
||||
**Tech Stack:** Bun, Vite, React, React DOM, TypeScript, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React.
|
||||
**Tech Stack:** Bun, Vite, React, React DOM, TypeScript, Tailwind CSS v4, shadcn/ui, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,13 +23,16 @@
|
|||
- Create `src/server/routes/agent-dashboard.test.ts`: agent API delegation/auth tests.
|
||||
- Create `src/lib/ui/components/*.tsx`: React ports of current reusable Svelte components.
|
||||
- Create `src/lib/ui/components/styles.css`: component CSS migrated from Svelte style blocks.
|
||||
- Create `components.json`: shadcn/ui configuration for Vite, Radix, Nova, Tailwind v4, and `$lib` aliases.
|
||||
- Create `src/lib/components/ui/*.tsx`: selected shadcn primitives, not the full registry.
|
||||
- Create `src/lib/utils.ts`: `cn()` helper for shadcn and dashboard components.
|
||||
- Replace `src/lib/ui/components/render.test.ts`: React `renderToString` component tests.
|
||||
- Replace `src/lib/ui/stories/*.stories.svelte`: React `.stories.tsx` stories.
|
||||
- Modify `.storybook/main.ts`: use `@storybook/react-vite` and React story globs.
|
||||
- Modify `.storybook/preview.ts`: use React Storybook types and keep MSW setup/global CSS.
|
||||
- Modify `vite.config.ts`: use React plugin, alias `$lib` to `src/lib`, build client, and keep Vitest config.
|
||||
- Modify `vite.config.ts`: use React and Tailwind plugins, alias `$lib` to `src/lib`, build client, and keep Vitest config.
|
||||
- Modify `tsconfig.json`: remove `.svelte-kit` inheritance, enable JSX, and define path aliases.
|
||||
- Modify `package.json`: swap Svelte/SvelteKit dependencies for React tooling and update scripts.
|
||||
- Modify `package.json`: swap Svelte/SvelteKit dependencies for React/Tailwind/shadcn tooling and update scripts.
|
||||
- Modify `playwright.config.ts`: build React client and Bun server before e2e.
|
||||
- Modify `Containerfile`: copy React/Bun build artifacts and keep `bun build/index.js` command.
|
||||
- Modify `README.md`: document React runtime, Bun server, scripts, QA gate, deployment.
|
||||
|
|
@ -104,11 +107,12 @@ Update `package.json` scripts and dependencies:
|
|||
}
|
||||
```
|
||||
|
||||
Install React packages with Bun so `bun.lock` updates:
|
||||
Install React, Tailwind, and shadcn packages with Bun so `bun.lock` updates:
|
||||
|
||||
```sh
|
||||
bun add @iconify/react @vitejs/plugin-react react react-dom
|
||||
bun add -d @storybook/react-vite @types/react @types/react-dom
|
||||
bun add class-variance-authority clsx lucide-react radix-ui tailwind-merge tw-animate-css @fontsource-variable/geist
|
||||
bun add -d @storybook/react-vite @tailwindcss/vite @types/react @types/react-dom shadcn tailwindcss
|
||||
```
|
||||
|
||||
Create `index.html`:
|
||||
|
|
@ -181,7 +185,16 @@ createRoot(root).render(
|
|||
|
||||
Update `tsconfig.json` with React JSX and path aliases.
|
||||
|
||||
Update `vite.config.ts` to use `@vitejs/plugin-react` and `$lib` alias.
|
||||
Update `vite.config.ts` to use `@vitejs/plugin-react`, `@tailwindcss/vite`,
|
||||
and the `$lib` alias. Initialize shadcn with:
|
||||
|
||||
```sh
|
||||
bunx --bun shadcn@latest init --template vite --base radix --preset nova --yes --css-variables
|
||||
bunx --bun shadcn@latest add badge card progress separator skeleton alert
|
||||
```
|
||||
|
||||
Do not run `bunx --bun shadcn@latest add --all`; the dashboard should only
|
||||
vendor primitives it actually uses.
|
||||
|
||||
- [ ] **Step 4: Run checks for the scaffold**
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ state is a React runtime and React component library.
|
|||
|
||||
Use Vite React for the browser app and a small Bun HTTP server for production
|
||||
runtime. This keeps the existing Bun SQLite persistence model and avoids adding
|
||||
a heavier React framework where the current route/API surface is small.
|
||||
a heavier React framework where the current route/API surface is small. Use
|
||||
Tailwind CSS v4 and shadcn/ui as the reusable primitive layer for React
|
||||
components, but do not vendor the full shadcn registry. Add only primitives
|
||||
that map to the dashboard surface.
|
||||
|
||||
The migrated app will have these boundaries:
|
||||
|
||||
|
|
@ -36,6 +39,10 @@ The migrated app will have these boundaries:
|
|||
TypeScript business logic with minimal import-path updates.
|
||||
- `src/lib/ui/components/*.tsx` contains the reusable React component library.
|
||||
- `src/lib/ui/stories/*.stories.tsx` contains React Storybook stories.
|
||||
- `src/lib/components/ui/*.tsx` contains shadcn/ui primitives used by the
|
||||
dashboard component library.
|
||||
- `components.json` records the shadcn configuration with Vite, Radix, the Nova
|
||||
preset, and `$lib` import aliases.
|
||||
|
||||
## Component Migration
|
||||
|
||||
|
|
@ -76,10 +83,12 @@ data-model identifiers, severity attributes, focusable links, reduced-motion
|
|||
behavior, and screenshot-tested dashboard layout must stay equivalent to the
|
||||
current Svelte implementation.
|
||||
|
||||
CSS will remain in `src/lib/ui/tokens.css` and component-specific CSS files.
|
||||
React components should import their own CSS modules or regular CSS files in a
|
||||
consistent local pattern. Shared UI types and `dashboardDocumentToUiDashboard`
|
||||
remain framework-agnostic TypeScript.
|
||||
CSS will keep the existing design tokens in `src/lib/ui/tokens.css`.
|
||||
`src/app.css` imports Tailwind, shadcn CSS, font assets, and the existing
|
||||
Dimension Lab token file. shadcn semantic variables must be mapped to the dark
|
||||
console palette so generated primitives fit the dashboard instead of resetting
|
||||
the app to a light generic theme. Shared UI types and
|
||||
`dashboardDocumentToUiDashboard` remain framework-agnostic TypeScript.
|
||||
|
||||
## Runtime And API Behavior
|
||||
|
||||
|
|
@ -101,8 +110,11 @@ owned by `src/lib/server/agent-config/index.ts`.
|
|||
|
||||
`package.json` will move from Svelte/SvelteKit dependencies to React tooling:
|
||||
|
||||
- Runtime dependencies include `@vitejs/plugin-react`, `react`, `react-dom`,
|
||||
`@iconify/react`, and existing non-Svelte libraries that still apply.
|
||||
- Runtime dependencies include `react`, `react-dom`, `@iconify/react`,
|
||||
selected shadcn primitive dependencies, Tailwind merge helpers, and existing
|
||||
non-Svelte libraries that still apply.
|
||||
- Dev dependencies include `@vitejs/plugin-react`, `@tailwindcss/vite`,
|
||||
`tailwindcss`, and the shadcn package needed by the generated CSS import.
|
||||
- Storybook moves from `@storybook/sveltekit` and Svelte CSF to
|
||||
`@storybook/react-vite`.
|
||||
- `svelte.config.js`, `src/app.html`, `src/routes/**`, and `.svelte` files are
|
||||
|
|
@ -139,6 +151,8 @@ The migration is complete only when current evidence proves all of these:
|
|||
|
||||
- There are no `.svelte` app, component, route, or story files left.
|
||||
- `package.json` has no Svelte, SvelteKit, or Svelte Storybook dependencies.
|
||||
- shadcn is configured for Vite/Radix with `$lib` aliases and only selected
|
||||
primitives, not the full registry.
|
||||
- `bun run check` passes.
|
||||
- `bun run test:unit` passes.
|
||||
- `bun run build` produces the React client build and Bun server entry.
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -5,8 +5,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
|
||||
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
||||
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
||||
"build-storybook": "storybook build",
|
||||
"check": "tsc --noEmit",
|
||||
|
|
@ -18,14 +18,21 @@
|
|||
"db:check": "drizzle-kit check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@iconify/svelte": "^5.2.2",
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.21.0",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -39,6 +46,7 @@
|
|||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.65.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
@ -46,9 +54,11 @@
|
|||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"msw": "^2.14.6",
|
||||
"shadcn": "^4.11.0",
|
||||
"storybook": "^10.4.6",
|
||||
"svelte": "^5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
|
|
|
|||
59
src/App.tsx
59
src/App.tsx
|
|
@ -1,5 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||
|
||||
const loadingDashboardState: DashboardRuntimeState = {
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
};
|
||||
|
||||
export function AppStateView({
|
||||
dashboard,
|
||||
}: {
|
||||
|
|
@ -19,14 +27,45 @@ export function AppStateView({
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const [dashboard, setDashboard] =
|
||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
async function loadDashboard() {
|
||||
const response = await fetch("/api/dashboard");
|
||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||
|
||||
if (cancelled) return;
|
||||
setDashboard(nextDashboard);
|
||||
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
|
||||
const refreshIntervalSeconds =
|
||||
nextDashboard.state === "ready"
|
||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||
: undefined;
|
||||
|
||||
if (refreshIntervalSeconds) {
|
||||
refreshTimer = window.setInterval(
|
||||
() => void loadDashboard(),
|
||||
refreshIntervalSeconds * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <AppStateView dashboard={dashboard} />;
|
||||
}
|
||||
|
|
|
|||
144
src/app.css
144
src/app.css
|
|
@ -1 +1,145 @@
|
|||
@import "tailwindcss";
|
||||
@import "./lib/ui/tokens.css";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root,
|
||||
.dark {
|
||||
--background: var(--ui-color-canvas);
|
||||
--foreground: var(--ui-color-text);
|
||||
--card: var(--ui-color-surface);
|
||||
--card-foreground: var(--ui-color-text);
|
||||
--popover: var(--ui-color-surface-raised);
|
||||
--popover-foreground: var(--ui-color-text);
|
||||
--primary: var(--ui-color-accent);
|
||||
--primary-foreground: var(--ui-color-canvas);
|
||||
--secondary: var(--ui-color-surface-raised);
|
||||
--secondary-foreground: var(--ui-color-text);
|
||||
--muted: var(--ui-color-surface-raised);
|
||||
--muted-foreground: var(--ui-color-muted);
|
||||
--accent: var(--ui-color-accent);
|
||||
--accent-foreground: var(--ui-color-canvas);
|
||||
--destructive: var(--ui-color-danger);
|
||||
--border: rgba(244, 244, 244, 0.16);
|
||||
--input: rgba(244, 244, 244, 0.18);
|
||||
--ring: var(--ui-color-accent);
|
||||
--chart-1: var(--ui-color-accent);
|
||||
--chart-2: var(--ui-color-ok);
|
||||
--chart-3: var(--ui-color-warning);
|
||||
--chart-4: var(--ui-color-danger);
|
||||
--chart-5: var(--ui-color-stale);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: var(--ui-color-surface);
|
||||
--sidebar-foreground: var(--ui-color-text);
|
||||
--sidebar-primary: var(--ui-color-accent);
|
||||
--sidebar-primary-foreground: var(--ui-color-canvas);
|
||||
--sidebar-accent: var(--ui-color-surface-raised);
|
||||
--sidebar-accent-foreground: var(--ui-color-text);
|
||||
--sidebar-border: rgba(244, 244, 244, 0.16);
|
||||
--sidebar-ring: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ui-color-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
var(--ui-color-canvas);
|
||||
background-size: 48px 48px, 48px 48px, auto;
|
||||
color: var(--ui-color-text);
|
||||
font-family: var(--ui-font-mono);
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: var(--ui-focus-ring);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/lib/components/ui/alert.tsx
Normal file
76
src/lib/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
49
src/lib/components/ui/badge.tsx
Normal file
49
src/lib/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
67
src/lib/components/ui/button.tsx
Normal file
67
src/lib/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
src/lib/components/ui/card.tsx
Normal file
103
src/lib/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
29
src/lib/components/ui/progress.tsx
Normal file
29
src/lib/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
28
src/lib/components/ui/separator.tsx
Normal file
28
src/lib/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
13
src/lib/components/ui/skeleton.tsx
Normal file
13
src/lib/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "$lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
84
src/server/index.ts
Normal file
84
src/server/index.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { extname, normalize } from "node:path";
|
||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
||||
import { handleDashboardRoute } from "./routes/dashboard";
|
||||
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const distRoot = `${process.cwd()}/dist`;
|
||||
|
||||
const contentTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
]);
|
||||
|
||||
export async function handleRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/api/dashboard") {
|
||||
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||
return handleDashboardRoute();
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/agent/dashboard") {
|
||||
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||
return handleAgentDashboardRoute(request);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return Response.json({ ok: false, message: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return serveStaticAsset(url.pathname);
|
||||
}
|
||||
|
||||
async function serveStaticAsset(pathname: string): Promise<Response> {
|
||||
const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const assetPath = safePath === "/" || safePath === "." ? "/index.html" : safePath;
|
||||
const file = Bun.file(`${distRoot}${assetPath}`);
|
||||
|
||||
if (await file.exists()) {
|
||||
return new Response(file, {
|
||||
headers: contentTypeHeaders(assetPath),
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = Bun.file(`${distRoot}/index.html`);
|
||||
if (await fallback.exists()) {
|
||||
return new Response(fallback, {
|
||||
headers: contentTypeHeaders(".html"),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Build output not found", { status: 404 });
|
||||
}
|
||||
|
||||
function methodNotAllowed(allowedMethods: string[]): Response {
|
||||
return Response.json(
|
||||
{ ok: false, message: "Method not allowed" },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: allowedMethods.join(", "),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function contentTypeHeaders(pathname: string): HeadersInit {
|
||||
const contentType = contentTypes.get(extname(pathname));
|
||||
return contentType ? { "Content-Type": contentType } : {};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
fetch: handleRequest,
|
||||
});
|
||||
|
||||
console.info(`Dimension Lab website listening on http://${host}:${port}`);
|
||||
}
|
||||
12
src/server/routes/agent-dashboard.test.ts
Normal file
12
src/server/routes/agent-dashboard.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { handleAgentDashboardRoute } from "./agent-dashboard";
|
||||
|
||||
describe("agent dashboard API route", () => {
|
||||
test("delegates unauthorized requests to the existing agent handler", async () => {
|
||||
const response = await handleAgentDashboardRoute(
|
||||
new Request("http://localhost/api/agent/dashboard", { method: "POST" }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
5
src/server/routes/agent-dashboard.ts
Normal file
5
src/server/routes/agent-dashboard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleAgentDashboardRequest } from "$lib/server/agent-config";
|
||||
|
||||
export function handleAgentDashboardRoute(request: Request): Promise<Response> {
|
||||
return handleAgentDashboardRequest(request);
|
||||
}
|
||||
19
src/server/routes/dashboard.test.ts
Normal file
19
src/server/routes/dashboard.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||
import { loadDashboardResponse } from "./dashboard";
|
||||
|
||||
describe("dashboard API route", () => {
|
||||
test("returns ready dashboard runtime state from the existing model loader", async () => {
|
||||
const response = await loadDashboardResponse({
|
||||
disableLiveDatasources: true,
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
});
|
||||
|
||||
expect(response.state).toBe("ready");
|
||||
if (response.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(response.document.metadata.title).toBe(
|
||||
dimensionLabDashboardFixture.metadata.title,
|
||||
);
|
||||
});
|
||||
});
|
||||
41
src/server/routes/dashboard.ts
Normal file
41
src/server/routes/dashboard.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
loadDashboardRuntime,
|
||||
type DashboardRuntimeOptions,
|
||||
type DashboardRuntimeState,
|
||||
} from "$lib/server/dashboard";
|
||||
import { resolveDashboardDatasources } from "$lib/server/datasources";
|
||||
|
||||
export interface LoadDashboardResponseOptions
|
||||
extends Pick<
|
||||
DashboardRuntimeOptions,
|
||||
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
||||
> {
|
||||
disableLiveDatasources?: boolean;
|
||||
}
|
||||
|
||||
export async function loadDashboardResponse(
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<DashboardRuntimeState> {
|
||||
const dashboard = loadDashboardRuntime(undefined, {
|
||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||
seedDocument: options.seedDocument,
|
||||
});
|
||||
|
||||
if (dashboard.state !== "ready") {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
document: await resolveDashboardDatasources(dashboard.document),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleDashboardRoute(): Promise<Response> {
|
||||
return Response.json(await loadDashboardResponse());
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { defineConfig, configDefaults } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), svelte()],
|
||||
plugins: [react(), tailwindcss(), svelte()],
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, "tests/e2e/**"],
|
||||
exclude: [...configDefaults.exclude, "tests/e2e/**", "src/routes/**"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue