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
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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue