mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(web): scaffold browser-based Sprout web client (#474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
target/
|
||||
desktop/
|
||||
web/
|
||||
.git/
|
||||
.scratch/
|
||||
*.md
|
||||
|
||||
45
justfile
45
justfile
@@ -4,6 +4,7 @@ set dotenv-load := true
|
||||
|
||||
desktop_dir := "desktop"
|
||||
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
|
||||
web_dir := "web"
|
||||
|
||||
# List all available tasks
|
||||
default:
|
||||
@@ -43,7 +44,7 @@ build-release:
|
||||
cargo build --workspace --release
|
||||
|
||||
# Run repo lint and formatting checks
|
||||
check: fmt-check clippy desktop-check desktop-tauri-fmt-check mobile-check
|
||||
check: fmt-check clippy desktop-check desktop-tauri-fmt-check web-check mobile-check
|
||||
|
||||
# Format all Rust code
|
||||
fmt:
|
||||
@@ -126,7 +127,7 @@ desktop-e2e-integration:
|
||||
cd {{desktop_dir}} && pnpm test:e2e:integration
|
||||
|
||||
# Run all checks suitable for CI / pre-push (no infra needed)
|
||||
ci: check test-unit desktop-build desktop-tauri-check mobile-test
|
||||
ci: check test-unit desktop-build desktop-tauri-check web-build mobile-test
|
||||
|
||||
# ─── Test ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -198,6 +199,46 @@ desktop-dev:
|
||||
desktop-app *ARGS:
|
||||
just dev {{ARGS}}
|
||||
|
||||
# ─── Web ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run the web frontend dev server (port derived from worktree to avoid collisions)
|
||||
web:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{web_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
cd ..
|
||||
source scripts/instance-env.sh
|
||||
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
|
||||
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
|
||||
echo "Starting web dev server on port ${VITE_PORT}, relay ${SPROUT_RELAY_URL}"
|
||||
cd {{web_dir}}
|
||||
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
||||
|
||||
# Install web JS dependencies
|
||||
web-install:
|
||||
cd {{web_dir}} && pnpm install
|
||||
|
||||
# Install web JS dependencies reproducibly for CI
|
||||
web-install-ci:
|
||||
cd {{web_dir}} && pnpm install --frozen-lockfile
|
||||
|
||||
# Run web lint and format checks
|
||||
web-check:
|
||||
cd {{web_dir}} && pnpm check
|
||||
|
||||
# Run web TypeScript checks
|
||||
web-typecheck:
|
||||
cd {{web_dir}} && pnpm typecheck
|
||||
|
||||
# Build web frontend assets
|
||||
web-build:
|
||||
cd {{web_dir}} && pnpm build
|
||||
|
||||
# Run web browser smoke tests
|
||||
web-e2e-smoke:
|
||||
cd {{web_dir}} && pnpm test:e2e:smoke
|
||||
|
||||
# ─── Mobile ──────────────────────────────────────────────────────────────────
|
||||
|
||||
mobile_dir := "mobile"
|
||||
|
||||
@@ -6,6 +6,8 @@ pre-commit:
|
||||
run: just desktop-tauri-fmt-check
|
||||
desktop-check:
|
||||
run: just desktop-check
|
||||
web-check:
|
||||
run: just web-check
|
||||
mobile-check:
|
||||
run: just mobile-check
|
||||
|
||||
@@ -26,6 +28,10 @@ pre-push:
|
||||
run: just desktop-build
|
||||
desktop-tauri-check:
|
||||
run: just desktop-tauri-check
|
||||
web-check:
|
||||
run: just web-check
|
||||
web-build:
|
||||
run: just web-build
|
||||
mobile-check:
|
||||
run: just mobile-check
|
||||
mobile-test:
|
||||
|
||||
@@ -193,6 +193,23 @@ else
|
||||
warn "Desktop directory not found at ${DESKTOP_DIR} — skipping."
|
||||
fi
|
||||
|
||||
# ---- Install web dependencies -----------------------------------------------
|
||||
|
||||
WEB_DIR="${REPO_ROOT}/web"
|
||||
|
||||
if [[ -d "${WEB_DIR}" ]]; then
|
||||
if command -v pnpm &>/dev/null; then
|
||||
log "Installing web dependencies (pnpm install)..."
|
||||
(cd "${WEB_DIR}" && pnpm install)
|
||||
success "Web dependencies installed"
|
||||
else
|
||||
warn "pnpm not found — skipping web dependency install."
|
||||
warn "Run '. ./bin/activate-hermit' to get pnpm, then 'just web-install'."
|
||||
fi
|
||||
else
|
||||
warn "Web directory not found at ${WEB_DIR} — skipping."
|
||||
fi
|
||||
|
||||
# ---- Print connection info --------------------------------------------------
|
||||
|
||||
echo ""
|
||||
|
||||
12
web/.gitignore
vendored
Normal file
12
web/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
37
web/biome.json
Normal file
37
web/biome.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
19
web/components.json
Normal file
19
web/components.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/shared/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/ui",
|
||||
"utils": "@/shared/lib/cn",
|
||||
"ui": "@/shared/ui",
|
||||
"lib": "@/shared/lib"
|
||||
}
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sprout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
web/package.json
Normal file
47
web/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "sprout-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm check:file-sizes",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "pnpm build && playwright test",
|
||||
"test:e2e:smoke": "pnpm build && playwright test --project=smoke"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
33
web/playwright.config.ts
Normal file
33
web/playwright.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "playwright-report" }],
|
||||
],
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
testMatch: ["**/smoke.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm exec vite preview --port 4173 --strictPort --host 127.0.0.1",
|
||||
cwd: ".",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: "http://127.0.0.1:4173",
|
||||
},
|
||||
});
|
||||
2586
web/pnpm-lock.yaml
generated
Normal file
2586
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
108
web/scripts/check-file-sizes.mjs
Normal file
108
web/scripts/check-file-sizes.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const rules = [
|
||||
{
|
||||
root: "src/app",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
{
|
||||
root: "src/features",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
{
|
||||
root: "src/shared/api",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
];
|
||||
|
||||
const overrides = new Map([]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walkFiles(fullPath);
|
||||
}
|
||||
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
function findRule(relativePath) {
|
||||
return rules.find((rule) => {
|
||||
const normalizedRoot = `${rule.root}${path.sep}`;
|
||||
return relativePath.startsWith(normalizedRoot);
|
||||
});
|
||||
}
|
||||
|
||||
function countLines(content) {
|
||||
if (content.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return content.split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
const candidateFiles = (
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
const dir = path.join(projectRoot, rule.root);
|
||||
return fs
|
||||
.access(dir)
|
||||
.then(() => walkFiles(dir))
|
||||
.catch(() => []);
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
const rule = findRule(relativePath);
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = path.extname(relativePath);
|
||||
if (!rule.extensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const limit = overrides.get(relativePath) ?? rule.maxLines;
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lineCount = countLines(content);
|
||||
if (lineCount > limit) {
|
||||
violations.push({
|
||||
limit,
|
||||
lineCount,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Web file size check failed:");
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- ${violation.relativePath}: ${violation.lineCount} lines (limit ${violation.limit})`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"Split the file or add a narrowly scoped exception in `web/scripts/check-file-sizes.mjs`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
7
web/src/app/App.tsx
Normal file
7
web/src/app/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
55
web/src/app/routeTree.gen.ts
Normal file
55
web/src/app/routeTree.gen.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// biome-ignore-all lint: generated by TanStack Router
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/root";
|
||||
import { Route as indexRouteImport } from "./routes/index";
|
||||
|
||||
const indexRoute = indexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof indexRoute;
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof indexRoute;
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof indexRoute;
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fullPaths: "/";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
to: "/";
|
||||
id: "__root__" | "/";
|
||||
fileRoutesById: FileRoutesById;
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
indexRoute: typeof indexRoute;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/": {
|
||||
id: "/";
|
||||
path: "/";
|
||||
fullPath: "/";
|
||||
preLoaderRoute: typeof indexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
indexRoute: indexRoute,
|
||||
};
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>();
|
||||
17
web/src/app/router.tsx
Normal file
17
web/src/app/router.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createBrowserHistory, createRouter } from "@tanstack/react-router";
|
||||
|
||||
import { routeTree } from "@/app/routeTree.gen";
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
history: createBrowserHistory(),
|
||||
scrollRestoration: true,
|
||||
getScrollRestorationKey: (location: { pathname: string }) =>
|
||||
location.pathname,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
3
web/src/app/routes.ts
Normal file
3
web/src/app/routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { index, rootRoute } from "@tanstack/virtual-file-routes";
|
||||
|
||||
export const routes = rootRoute("root.tsx", [index("index.tsx")]);
|
||||
36
web/src/app/routes/index.tsx
Normal file
36
web/src/app/routes/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomeRoute,
|
||||
});
|
||||
|
||||
function HomeRoute() {
|
||||
const relayUrl = import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Relay</CardTitle>
|
||||
<CardDescription>Connected relay endpoint</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code
|
||||
className="text-sm font-mono text-muted-foreground"
|
||||
data-testid="relay-url"
|
||||
>
|
||||
{relayUrl}
|
||||
</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
web/src/app/routes/root.tsx
Normal file
20
web/src/app/routes/root.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="flex h-12 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold tracking-tight">Sprout</span>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
web/src/features/.gitkeep
Normal file
0
web/src/features/.gitkeep
Normal file
35
web/src/main.tsx
Normal file
35
web/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "@/app/App";
|
||||
import "@/shared/styles/globals.css";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "always",
|
||||
gcTime: 5 * 60 * 1_000,
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
6
web/src/shared/lib/cn.ts
Normal file
6
web/src/shared/lib/cn.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));
|
||||
}
|
||||
99
web/src/shared/styles/globals.css
Normal file
99
web/src/shared/styles/globals.css
Normal file
@@ -0,0 +1,99 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Catppuccin Latte (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
--background: 220 23.08% 94.9%;
|
||||
--foreground: 234 16.02% 35.49%;
|
||||
--card: 220 23.08% 94.9%;
|
||||
--card-foreground: 234 16.02% 35.49%;
|
||||
--popover: 220 23.08% 94.9%;
|
||||
--popover-foreground: 234 16.02% 35.49%;
|
||||
--primary: 266 85.05% 58.04%;
|
||||
--primary-foreground: 220 23.08% 94.9%;
|
||||
--secondary: 223 15.91% 82.75%;
|
||||
--secondary-foreground: 234 16.02% 35.49%;
|
||||
--muted: 223 15.91% 82.75%;
|
||||
--muted-foreground: 233 12.8% 41.37%;
|
||||
--accent: 223 15.91% 82.75%;
|
||||
--accent-foreground: 234 16.02% 35.49%;
|
||||
--destructive: 347 86.67% 44.12%;
|
||||
--destructive-foreground: 220 23.08% 94.9%;
|
||||
--border: 225 13.56% 76.86%;
|
||||
--input: 225 13.56% 76.86%;
|
||||
--ring: 234 16.02% 35.49%;
|
||||
--chart-1: 345 57.65% 74.12%;
|
||||
--chart-2: 185 37.25% 70.59%;
|
||||
--chart-3: 219 81.96% 78.43%;
|
||||
--chart-4: 35 61.18% 76.86%;
|
||||
--chart-5: 113 32.94% 73.33%;
|
||||
--sidebar: 218 25.1% 93.73%;
|
||||
--sidebar-background: 218 25.1% 93.73%;
|
||||
--sidebar-foreground: 234 16.02% 35.49%;
|
||||
--sidebar-primary: 266 85.05% 58.04%;
|
||||
--sidebar-primary-foreground: 220 23.08% 94.9%;
|
||||
--sidebar-accent: 220 23.08% 94.9%;
|
||||
--sidebar-accent-foreground: 234 16.02% 35.49%;
|
||||
--sidebar-border: 225 13.56% 76.86%;
|
||||
--sidebar-ring: 225 13.56% 76.86%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Catppuccin Macchiato (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
--background: 232 23.4% 18.43%;
|
||||
--foreground: 227 68.25% 87.65%;
|
||||
--card: 232 23.4% 18.43%;
|
||||
--card-foreground: 227 68.25% 87.65%;
|
||||
--popover: 232 23.4% 18.43%;
|
||||
--popover-foreground: 227 68.25% 87.65%;
|
||||
--primary: 267 82.69% 79.61%;
|
||||
--primary-foreground: 232 23.4% 18.43%;
|
||||
--secondary: 230 18.8% 26.08%;
|
||||
--secondary-foreground: 227 68.25% 87.65%;
|
||||
--muted: 230 18.8% 26.08%;
|
||||
--muted-foreground: 228 39.22% 80%;
|
||||
--accent: 230 18.8% 26.08%;
|
||||
--accent-foreground: 227 68.25% 87.65%;
|
||||
--destructive: 351 73.91% 72.94%;
|
||||
--destructive-foreground: 232 23.4% 18.43%;
|
||||
--border: 231 15.61% 33.92%;
|
||||
--input: 231 15.61% 33.92%;
|
||||
--ring: 227 68.25% 87.65%;
|
||||
--chart-1: 333 20.78% 38.04%;
|
||||
--chart-2: 193 19.61% 38.04%;
|
||||
--chart-3: 224 26.67% 41.18%;
|
||||
--chart-4: 30 8.24% 42.35%;
|
||||
--chart-5: 139 10.98% 38.82%;
|
||||
--sidebar: 234 22.75% 17.25%;
|
||||
--sidebar-background: 234 22.75% 17.25%;
|
||||
--sidebar-foreground: 227 68.25% 87.65%;
|
||||
--sidebar-primary: 267 82.69% 79.61%;
|
||||
--sidebar-primary-foreground: 232 23.4% 18.43%;
|
||||
--sidebar-accent: 232 23.4% 18.43%;
|
||||
--sidebar-accent-foreground: 227 68.25% 87.65%;
|
||||
--sidebar-border: 231 15.61% 33.92%;
|
||||
--sidebar-ring: 231 15.61% 33.92%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: "Geist", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
}
|
||||
85
web/src/shared/theme/ThemeProvider.tsx
Normal file
85
web/src/shared/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "sprout-web-theme";
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function getSystemDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function applyClass(isDark: boolean) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
const t = readStoredTheme();
|
||||
const dark = t === "system" ? getSystemDark() : t === "dark";
|
||||
applyClass(dark);
|
||||
return dark;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const dark = theme === "system" ? getSystemDark() : theme === "dark";
|
||||
applyClass(dark);
|
||||
setIsDark(dark);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
applyClass(e.matches);
|
||||
setIsDark(e.matches);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
window.localStorage.setItem(STORAGE_KEY, t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, isDark, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
32
web/src/shared/theme/ThemeToggle.tsx
Normal file
32
web/src/shared/theme/ThemeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const icons = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
const next: Record<string, "dark" | "system" | "light"> = {
|
||||
light: "dark",
|
||||
dark: "system",
|
||||
system: "light",
|
||||
};
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const Icon = icons[theme];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setTheme(next[theme])}
|
||||
aria-label={`Theme: ${theme}. Click to switch.`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
57
web/src/shared/ui/button.tsx
Normal file
57
web/src/shared/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
83
web/src/shared/ui/card.tsx
Normal file
83
web/src/shared/ui/card.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border/70 bg-card/80 text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
29
web/src/shared/ui/sonner.tsx
Normal file
29
web/src/shared/ui/sonner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={isDark ? "dark" : "light"}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
30
web/src/shared/ui/tooltip.tsx
Normal file
30
web/src/shared/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
71
web/tailwind.config.js
Normal file
71
web/tailwind.config.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
status: {
|
||||
added: "var(--status-added)",
|
||||
deleted: "var(--status-deleted)",
|
||||
modified: "var(--status-modified)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "var(--ui-warning)",
|
||||
bg: "var(--ui-warning-bg)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcssAnimate],
|
||||
};
|
||||
13
web/tests/e2e/smoke.spec.ts
Normal file
13
web/tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("home page loads with Sprout heading", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("header")).toContainText("Sprout");
|
||||
});
|
||||
|
||||
test("relay URL is visible", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const relayUrl = page.getByTestId("relay-url");
|
||||
await expect(relayUrl).toBeVisible();
|
||||
await expect(relayUrl).toContainText("ws://");
|
||||
});
|
||||
29
web/tsconfig.json
Normal file
29
web/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
web/vite.config.ts
Normal file
30
web/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
routesDirectory: "./src/app/routes",
|
||||
generatedRouteTree: "./src/app/routeTree.gen.ts",
|
||||
virtualRouteConfig: "./src/app/routes.ts",
|
||||
quoteStyle: "double",
|
||||
semicolons: true,
|
||||
routeTreeFileHeader: [
|
||||
"// biome-ignore-all lint: generated by TanStack Router",
|
||||
],
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.VITE_PORT || "5173", 10),
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user