improve web ui

This commit is contained in:
2026-06-26 05:43:34 +00:00
parent 00cf51d610
commit 803573b4ec
73 changed files with 3373 additions and 2847 deletions
+60 -44
View File
@@ -1,62 +1,78 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { AppShell } from '@/components/app-shell'
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { AppShell } from "@/components/app-shell";
// AppShell is built from TanStack Router <Link>s, so it needs a router context.
// We stand up a throwaway in-memory router whose routes mirror the nav targets
// (so links resolve + the active highlight works) and render the shell from the
// root route. No loaders/data — purely for designing the chrome offline.
function ShellHarness({ initialPath }: { initialPath: string }) {
const rootRoute = createRootRoute({
component: () => (
<AppShell>
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Placeholder content swap routes from the sidebar to preview the active state.
</p>
</div>
</AppShell>
),
})
const rootRoute = createRootRoute({
component: () => (
<AppShell>
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Placeholder content swap routes from the sidebar to preview the
active state.
</p>
</div>
</AppShell>
),
});
const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings']
const navRoutes = navPaths.map((path) =>
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
)
// Splat so any other <Link> target still resolves without throwing.
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null })
const navPaths = [
"/",
"/host",
"/library",
"/clients",
"/pairing",
"/settings",
];
const navRoutes = navPaths.map((path) =>
createRoute({
getParentRoute: () => rootRoute,
path,
component: () => null,
}),
);
// Splat so any other <Link> target still resolves without throwing.
const splat = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([...navRoutes, splat]),
history: createMemoryHistory({ initialEntries: [initialPath] }),
})
const router = createRouter({
routeTree: rootRoute.addChildren([...navRoutes, splat]),
history: createMemoryHistory({ initialEntries: [initialPath] }),
});
return <RouterProvider router={router} />
return <RouterProvider router={router} />;
}
const meta = {
title: 'Shell/AppShell',
component: AppShell,
parameters: { layout: 'fullscreen' },
// AppShell requires `children`; the harness supplies the real content, so this
// placeholder just satisfies the arg type.
args: { children: null },
} satisfies Meta<typeof AppShell>
title: "Shell/AppShell",
component: AppShell,
parameters: { layout: "fullscreen" },
// AppShell requires `children`; the harness supplies the real content, so this
// placeholder just satisfies the arg type.
args: { children: null },
} satisfies Meta<typeof AppShell>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Dashboard: Story = {
render: () => <ShellHarness initialPath="/" />,
}
render: () => <ShellHarness initialPath="/" />,
};
export const HostActive: Story = {
render: () => <ShellHarness initialPath="/host" />,
}
render: () => <ShellHarness initialPath="/host" />,
};
+29 -23
View File
@@ -1,30 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Badge } from '@/components/ui/badge'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from "@/components/ui/badge";
const VARIANTS = ['default', 'secondary', 'success', 'destructive', 'outline'] as const
const VARIANTS = [
"default",
"secondary",
"success",
"destructive",
"outline",
] as const;
const meta = {
title: 'UI/Badge',
component: Badge,
args: { children: 'badge' },
argTypes: {
variant: { control: 'select', options: VARIANTS },
},
} satisfies Meta<typeof Badge>
title: "UI/Badge",
component: Badge,
args: { children: "badge" },
argTypes: {
variant: { control: "select", options: VARIANTS },
},
} satisfies Meta<typeof Badge>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {}
export const Playground: Story = {};
export const All: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-2">
{VARIANTS.map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
),
}
render: () => (
<div className="flex flex-wrap items-center gap-2">
{VARIANTS.map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
),
};
+31 -31
View File
@@ -1,40 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { BrandMark } from '@/components/brand-mark'
import { Wordmark } from '@/components/wordmark'
import { Logo } from '@/components/logo'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BrandMark } from "@/components/brand-mark";
import { Logo } from "@/components/logo";
import { Wordmark } from "@/components/wordmark";
const meta = {
title: 'Brand/Marks',
component: BrandMark,
} satisfies Meta<typeof BrandMark>
title: "Brand/Marks",
component: BrandMark,
} satisfies Meta<typeof BrandMark>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Mark: Story = {
render: () => (
<div className="flex items-end gap-6">
<BrandMark className="size-8" />
<BrandMark className="size-12" />
<BrandMark className="size-20" />
</div>
),
}
render: () => (
<div className="flex items-end gap-6">
<BrandMark className="size-8" />
<BrandMark className="size-12" />
<BrandMark className="size-20" />
</div>
),
};
export const Word: Story = {
render: () => (
<div className="space-y-4">
<Wordmark className="h-4" />
<Wordmark className="h-6 text-foreground" />
<Wordmark className="h-8 text-primary" />
</div>
),
}
render: () => (
<div className="space-y-4">
<Wordmark className="h-4" />
<Wordmark className="h-6 text-foreground" />
<Wordmark className="h-8 text-primary" />
</div>
),
};
export const Lockup: Story = {
render: () => (
<div className="pl-8 pt-6">
<Logo className="w-48" />
</div>
),
}
render: () => (
<div className="pl-8 pt-6">
<Logo className="w-48" />
</div>
),
};
+45 -38
View File
@@ -1,48 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Play } from "lucide-react";
import { Button } from "@/components/ui/button";
const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const
const SIZES = ['default', 'sm', 'lg', 'icon'] as const
const VARIANTS = [
"default",
"secondary",
"outline",
"ghost",
"link",
"destructive",
] as const;
const SIZES = ["default", "sm", "lg", "icon"] as const;
const meta = {
title: 'UI/Button',
component: Button,
args: { children: 'Stream' },
argTypes: {
variant: { control: 'select', options: VARIANTS },
size: { control: 'select', options: SIZES },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>
title: "UI/Button",
component: Button,
args: { children: "Stream" },
argTypes: {
variant: { control: "select", options: VARIANTS },
size: { control: "select", options: SIZES },
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Button>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
/** Playground — drive variant/size/disabled from the Controls panel. */
export const Playground: Story = {}
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
{VARIANTS.map((variant) => (
<Button key={variant} variant={variant}>
{variant}
</Button>
))}
</div>
),
}
render: () => (
<div className="flex flex-wrap items-center gap-3">
{VARIANTS.map((variant) => (
<Button key={variant} variant={variant}>
{variant}
</Button>
))}
</div>
),
};
export const Sizes: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Play">
<Play className="size-4" />
</Button>
</div>
),
}
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Play">
<Play className="size-4" />
</Button>
</div>
),
};
+39 -39
View File
@@ -1,45 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const meta = {
title: 'UI/Card',
component: Card,
// Card requires `children`; every story supplies its own via `render`, so this
// is just a placeholder to satisfy the arg type.
args: { children: null },
} satisfies Meta<typeof Card>
title: "UI/Card",
component: Card,
// Card requires `children`; every story supplies its own via `render`, so this
// is just a placeholder to satisfy the arg type.
args: { children: null },
} satisfies Meta<typeof Card>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const HostCard: Story = {
render: () => (
<Card className="max-w-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>ENRICOS-DESKTOP</CardTitle>
<Badge variant="success">online</Badge>
</div>
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Paired 2 days ago. Last session 11 ms p50 capturepresent.
</CardContent>
<CardFooter className="gap-2">
<Button size="sm">Connect</Button>
<Button size="sm" variant="outline">
Details
</Button>
</CardFooter>
</Card>
),
}
render: () => (
<Card className="max-w-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>ENRICOS-DESKTOP</CardTitle>
<Badge variant="success">online</Badge>
</div>
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Paired 2 days ago. Last session 11 ms p50 capturepresent.
</CardContent>
<CardFooter className="gap-2">
<Button size="sm">Connect</Button>
<Button size="sm" variant="outline">
Details
</Button>
</CardFooter>
</Card>
),
};
+13 -21
View File
@@ -1,28 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ClientsPage } from '@/routes/clients'
import { MockApi } from './lib/mock-api'
import { pairedClients } from './lib/fixtures'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClientsView } from "@/sections/Clients/view";
import { pairedClients } from "./lib/fixtures";
const meta = {
title: 'Pages/Clients',
component: ClientsPage,
} satisfies Meta<typeof ClientsPage>
title: "Pages/Clients",
component: ClientsView,
args: { onUnpair: () => {}, isUnpairing: false },
} satisfies Meta<typeof ClientsView>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Paired: Story = {
render: () => (
<MockApi routes={{ '/api/v1/clients': pairedClients }}>
<ClientsPage />
</MockApi>
),
}
args: { clients: { data: pairedClients, isLoading: false, error: null } },
};
export const Empty: Story = {
render: () => (
<MockApi routes={{ '/api/v1/clients': [] }}>
<ClientsPage />
</MockApi>
),
}
args: { clients: { data: [], isLoading: false, error: null } },
};
+18 -21
View File
@@ -1,28 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Dashboard } from '@/routes/index'
import { MockApi } from './lib/mock-api'
import { statusActive, statusIdle } from './lib/fixtures'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardView } from "@/sections/Dashboard/view";
import { statusActive, statusIdle } from "./lib/fixtures";
const meta = {
title: 'Pages/Dashboard',
component: Dashboard,
} satisfies Meta<typeof Dashboard>
title: "Pages/Dashboard",
component: DashboardView,
args: {
onStopSession: () => {},
onRequestIdr: () => {},
isStopping: false,
isRequestingIdr: false,
},
} satisfies Meta<typeof DashboardView>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const ActiveSession: Story = {
render: () => (
<MockApi routes={{ '/api/v1/status': statusActive }}>
<Dashboard />
</MockApi>
),
}
args: { status: { data: statusActive, isLoading: false, error: null } },
};
export const Idle: Story = {
render: () => (
<MockApi routes={{ '/api/v1/status': statusIdle }}>
<Dashboard />
</MockApi>
),
}
args: { status: { data: statusIdle, isLoading: false, error: null } },
};
+20 -16
View File
@@ -1,20 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { HostPage } from '@/routes/host'
import { MockApi } from './lib/mock-api'
import { compositors, hostInfo } from './lib/fixtures'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { HostView } from "@/sections/Host/view";
import { compositors, hostInfo } from "./lib/fixtures";
const meta = {
title: 'Pages/Host',
component: HostPage,
} satisfies Meta<typeof HostPage>
title: "Pages/Host",
component: HostView,
args: {
host: { data: hostInfo, isLoading: false, error: null },
compositors: { data: compositors, isLoading: false, error: null },
},
} satisfies Meta<typeof HostView>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<MockApi routes={{ '/api/v1/host': hostInfo, '/api/v1/compositors': compositors }}>
<HostPage />
</MockApi>
),
}
export const Default: Story = {};
export const Loading: Story = {
args: {
host: { data: undefined, isLoading: true, error: null },
compositors: { data: undefined, isLoading: true, error: null },
},
};
+25 -25
View File
@@ -1,30 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const meta = {
title: 'UI/Inputs',
component: Input,
} satisfies Meta<typeof Input>
title: "UI/Inputs",
component: Input,
} satisfies Meta<typeof Input>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Form: Story = {
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="host">Host address</Label>
<Input id="host" placeholder="192.168.1.173" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pin">Pairing PIN</Label>
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
</div>
<div className="space-y-1.5">
<Label htmlFor="disabled">Disabled</Label>
<Input id="disabled" disabled placeholder="unavailable" />
</div>
</div>
),
}
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="host">Host address</Label>
<Input id="host" placeholder="192.168.1.173" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pin">Pairing PIN</Label>
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
</div>
<div className="space-y-1.5">
<Label htmlFor="disabled">Disabled</Label>
<Input id="disabled" disabled placeholder="unavailable" />
</div>
</div>
),
};
+19 -21
View File
@@ -1,28 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { LibraryPage } from '@/routes/library'
import { MockApi } from './lib/mock-api'
import { library } from './lib/fixtures'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryView } from "@/sections/Library/view";
import { library } from "./lib/fixtures";
const meta = {
title: 'Pages/Library',
component: LibraryPage,
} satisfies Meta<typeof LibraryPage>
title: "Pages/Library",
component: LibraryView,
args: {
onCreate: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
isSaving: false,
isDeleting: false,
},
} satisfies Meta<typeof LibraryView>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Populated: Story = {
render: () => (
<MockApi routes={{ '/api/v1/library': library }}>
<LibraryPage />
</MockApi>
),
}
args: { library: { data: library, isLoading: false, error: null } },
};
export const Empty: Story = {
render: () => (
<MockApi routes={{ '/api/v1/library': [] }}>
<LibraryPage />
</MockApi>
),
}
args: { library: { data: [], isLoading: false, error: null } },
};
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LoginView } from "@/sections/Login/view";
const meta = {
title: "Pages/Login",
component: LoginView,
parameters: { layout: "fullscreen" },
args: { onSubmit: () => {}, error: false, busy: false },
} satisfies Meta<typeof LoginView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Error: Story = { args: { error: true } };
+26 -20
View File
@@ -1,36 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { QueryState } from '@/components/query-state'
import { ApiError } from '@/api/fetcher'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ApiError } from "@/api/fetcher";
import { QueryState } from "@/components/query-state";
// QueryState is the uniform loading/error wrapper every data-backed route uses —
// the most useful thing to design WITHOUT a running host, since its three states
// (loading spinner / error / unauthorized) never appear together live.
const Loaded = () => (
<div className="rounded-lg border p-4 text-sm">Loaded content renders here.</div>
)
<div className="rounded-lg border p-4 text-sm">
Loaded content renders here.
</div>
);
const meta = {
title: 'Patterns/QueryState',
component: QueryState,
args: { children: <Loaded /> },
} satisfies Meta<typeof QueryState>
title: "Patterns/QueryState",
component: QueryState,
args: { children: <Loaded /> },
} satisfies Meta<typeof QueryState>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
args: { isLoading: true, error: null },
}
args: { isLoading: true, error: null },
};
export const ErrorWithRetry: Story = {
args: { isLoading: false, error: new Error('connection refused'), refetch: () => {} },
}
args: {
isLoading: false,
error: new Error("connection refused"),
refetch: () => {},
},
};
export const Unauthorized: Story = {
args: { isLoading: false, error: new ApiError(401, null) },
}
args: { isLoading: false, error: new ApiError(401, null) },
};
export const Loaded_: Story = {
name: 'Success',
args: { isLoading: false, error: null },
}
name: "Success",
args: { isLoading: false, error: null },
};
+8 -10
View File
@@ -1,14 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { SettingsPage } from '@/routes/settings'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SectionSettings } from "@/sections/Settings";
// Settings reads no API (just the locale + a logout button), so it renders
// directly — no mock needed.
const meta = {
title: 'Pages/Settings',
component: SettingsPage,
} satisfies Meta<typeof SettingsPage>
title: "Pages/Settings",
component: SectionSettings,
} satisfies Meta<typeof SectionSettings>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {}
export const Default: Story = {};
+23 -23
View File
@@ -1,31 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Spinner } from '@/components/ui/spinner'
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Spinner } from "@/components/ui/spinner";
const meta = {
title: 'UI/Spinner',
component: Spinner,
} satisfies Meta<typeof Spinner>
title: "UI/Spinner",
component: Spinner,
} satisfies Meta<typeof Spinner>;
export default meta
type Story = StoryObj<typeof meta>
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {}
export const Default: Story = {};
export const Large: Story = {
render: () => (
<div className="flex min-h-60 items-center justify-center">
<Spinner className="size-40" />
</div>
),
}
render: () => (
<div className="flex min-h-60 items-center justify-center">
<Spinner className="size-40" />
</div>
),
};
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Spinner className="size-4" />
<Spinner className="size-6" />
<Spinner className="size-10" />
<Spinner className="size-10 text-primary" />
</div>
),
}
render: () => (
<div className="flex items-center gap-4">
<Spinner className="size-4" />
<Spinner className="size-6" />
<Spinner className="size-10" />
<Spinner className="size-10 text-primary" />
</div>
),
};
+100 -73
View File
@@ -1,87 +1,114 @@
// Mock API payloads for the page stories — typed against the generated models so
// they stay honest if the OpenAPI schema changes.
import type { AvailableCompositor } from '@/api/gen/model/availableCompositor'
import type { GameEntry } from '@/api/gen/model/gameEntry'
import type { HostInfo } from '@/api/gen/model/hostInfo'
import type { PairedClient } from '@/api/gen/model/pairedClient'
import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus'
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
export const hostInfo: HostInfo = {
abi_version: 2,
app_version: '7.1.450.0',
codecs: ['h264', 'h265', 'av1'],
gfe_version: '3.23.0.74',
hostname: 'ENRICOS-DESKTOP',
local_ip: '192.168.1.173',
ports: {
audio: 48000,
control: 47999,
http: 47989,
https: 47984,
mgmt: 47990,
rtsp: 48010,
video: 47998,
},
uniqueid: '0f8a1c3e9b7d4a62',
version: '0.2.0',
}
abi_version: 2,
app_version: "7.1.450.0",
codecs: ["h264", "h265", "av1"],
gfe_version: "3.23.0.74",
hostname: "ENRICOS-DESKTOP",
local_ip: "192.168.1.173",
ports: {
audio: 48000,
control: 47999,
http: 47989,
https: 47984,
mgmt: 47990,
rtsp: 48010,
video: 47998,
},
uniqueid: "0f8a1c3e9b7d4a62",
version: "0.2.0",
};
export const compositors: AvailableCompositor[] = [
{ id: 'kwin', label: 'KWin (Plasma)', available: true, default: true },
{ id: 'gamescope', label: 'gamescope', available: true, default: false },
{ id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false },
{ id: 'wlroots', label: 'Sway / wlroots', available: false, default: false },
]
{ id: "kwin", label: "KWin (Plasma)", available: true, default: true },
{ id: "gamescope", label: "gamescope", available: true, default: false },
{ id: "mutter", label: "Mutter (GNOME)", available: false, default: false },
{ id: "wlroots", label: "Sway / wlroots", available: false, default: false },
];
export const statusActive: RuntimeStatus = {
video_streaming: true,
audio_streaming: true,
paired_clients: 3,
pin_pending: false,
session: { width: 5120, height: 1440, fps: 240 },
stream: {
codec: 'h265',
width: 5120,
height: 1440,
fps: 240,
bitrate_kbps: 150_000,
min_fec: 5,
packet_size: 1392,
},
}
video_streaming: true,
audio_streaming: true,
paired_clients: 3,
pin_pending: false,
session: { width: 5120, height: 1440, fps: 240 },
stream: {
codec: "h265",
width: 5120,
height: 1440,
fps: 240,
bitrate_kbps: 150_000,
min_fec: 5,
packet_size: 1392,
},
};
export const statusIdle: RuntimeStatus = {
video_streaming: false,
audio_streaming: false,
paired_clients: 1,
pin_pending: true,
session: null,
stream: null,
}
video_streaming: false,
audio_streaming: false,
paired_clients: 1,
pin_pending: true,
session: null,
stream: null,
};
export const pairedClients: PairedClient[] = [
{
fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00',
subject: 'enricos-macbook',
not_before_unix: 1_718_000_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1',
subject: 'living-room-tv',
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff',
subject: null,
},
]
{
fingerprint:
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
subject: "enricos-macbook",
not_before_unix: 1_718_000_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint:
"0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff",
subject: null,
},
];
const noArt = { header: null, hero: null, logo: null, portrait: null }
const noArt = { header: null, hero: null, logo: null, portrait: null };
export const library: GameEntry[] = [
{ id: 'steam:1245620', store: 'steam', title: 'Elden Ring', art: noArt, launch: null },
{ id: 'steam:1086940', store: 'steam', title: "Baldur's Gate 3", art: noArt, launch: null },
{ id: 'steam:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null },
{ id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null },
]
{
id: "steam:1245620",
store: "steam",
title: "Elden Ring",
art: noArt,
launch: null,
},
{
id: "steam:1086940",
store: "steam",
title: "Baldur's Gate 3",
art: noArt,
launch: null,
},
{
id: "steam:413150",
store: "steam",
title: "Stardew Valley",
art: noArt,
launch: null,
},
{
id: "custom:retroarch",
store: "custom",
title: "RetroArch",
art: noArt,
launch: null,
},
];
-49
View File
@@ -1,49 +0,0 @@
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
/** Map of API pathname (e.g. `/api/v1/host`) → JSON body to return for a GET. */
export type MockRoutes = Record<string, unknown>
/**
* Renders a data-backed page WITHOUT a running host by stubbing `window.fetch`
* for the lifetime of the story: matched pathnames return their mock JSON (200),
* everything else returns `{}` (200) so mutations + polling never error. The
* real orval/React-Query hooks run unchanged, so loading/success transitions and
* `refetchInterval` behave exactly as in the app. Each story gets a fresh,
* isolated QueryClient (retries off).
*/
export function MockApi({ routes, children }: { routes: MockRoutes; children: ReactNode }) {
// Read the latest routes inside the stub without re-installing it.
const routesRef = useRef(routes)
routesRef.current = routes
const [stubbed, setStubbed] = useState(false)
useEffect(() => {
const real = window.fetch
const stub = (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const path = new URL(url, window.location.origin).pathname
const data = path in routesRef.current ? routesRef.current[path] : {}
return Promise.resolve(
new Response(JSON.stringify(data ?? null), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
}
window.fetch = stub as typeof window.fetch
setStubbed(true)
return () => {
window.fetch = real
}
}, [])
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
// Hold the first render until the stub is installed, so the page's initial
// query resolves against the mock rather than racing a real (failing) request.
if (!stubbed) return null
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}