feat(web): scaffold React + Vite + TypeScript frontend

Phase 1 of frontend migration (epic #6, issue #7).

Project setup (web/):
- React 19, React Router 7, Vite 6, TypeScript 5.7
- Catppuccin Mocha theme CSS variables matching existing Go templates
- Vite dev proxy to Go backend at :8080 for /api/*, /login, /logout,
  /auth/*, /health, /ready

Shared infrastructure:
- api/client.ts: typed fetch wrapper (get/post/put/del) with 401
  redirect and credentials:include for session cookies
- api/types.ts: TypeScript interfaces for all API response types
  (User, Item, Project, Schema, Revision, BOMEntry, Audit, Error)
- context/AuthContext.tsx: AuthProvider calling GET /api/auth/me
- hooks/useAuth.ts: useAuth() hook exposing user/loading/logout

UI shell:
- AppShell.tsx: header nav matching current Go template navbar
  (Items, Projects, Schemas, Audit, Settings) with role badges
  (admin=mauve, editor=blue, viewer=teal) and active tab highlighting
- LoginPage: redirects to Go-served /login during transition
- Placeholder pages: Items, Projects, Schemas fetch from API and
  display data in tables; Audit shows summary stats; Settings shows
  current user profile

Go server changes:
- routes.go: serve web/dist/ at /app/* with SPA index.html fallback
  (only activates when web/dist/ directory exists)
- .gitignore: web/node_modules/, web/dist/
- Makefile: web-install, web-dev, web-build targets
This commit is contained in:
Forbes
2026-02-06 16:19:52 -06:00
parent 5f46efc8ed
commit 118c32dc14
25 changed files with 2779 additions and 116 deletions

4
.gitignore vendored
View File

@@ -43,3 +43,7 @@ build/
# FreeCAD
*.FCStd1
*.FCBak
# Web frontend
web/node_modules/
web/dist/

137
Makefile
View File

@@ -1,7 +1,7 @@
.PHONY: build run test clean migrate fmt lint \
docker-build docker-up docker-down docker-logs docker-ps \
docker-clean docker-rebuild \
build-calc-oxt install-calc uninstall-calc install-calc-dev test-calc
web-install web-dev web-build
# =============================================================================
# Local Development
@@ -20,14 +20,13 @@ run:
cli:
go run ./cmd/silo $(ARGS)
# Run tests (Go + Python)
# Run tests
test:
go test -v ./...
python3 -m unittest pkg/calc/tests/test_basics.py -v
# Clean build artifacts
clean:
rm -f silo silod silo-calc.oxt
rm -f silo silod
rm -f *.out
# Format code
@@ -104,104 +103,6 @@ docker-rebuild: docker-down docker-build docker-up
docker-shell:
docker compose -f deployments/docker-compose.yaml exec silo /bin/sh
# =============================================================================
# FreeCAD Integration
# =============================================================================
# Detect FreeCAD Mod directory (Flatpak or native)
# Flatpak app ID can be org.freecad.FreeCAD or org.freecadweb.FreeCAD
FREECAD_MOD_DIR_FLATPAK := $(HOME)/.var/app/org.freecad.FreeCAD/data/FreeCAD/Mod
FREECAD_MOD_DIR_NATIVE := $(HOME)/.local/share/FreeCAD/Mod
FREECAD_MOD_DIR_LEGACY := $(HOME)/.FreeCAD/Mod
# Install FreeCAD workbench (auto-detect Flatpak or native)
install-freecad:
@if [ -d "$(HOME)/.var/app/org.freecad.FreeCAD" ]; then \
echo "Detected Flatpak FreeCAD (org.freecad.FreeCAD)"; \
mkdir -p $(FREECAD_MOD_DIR_FLATPAK); \
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo; \
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo; \
echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo"; \
else \
echo "Using native FreeCAD installation"; \
mkdir -p $(FREECAD_MOD_DIR_NATIVE); \
mkdir -p $(FREECAD_MOD_DIR_LEGACY); \
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo; \
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo; \
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo; \
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo; \
echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo"; \
fi
@echo ""
@echo "Restart FreeCAD to load the Silo workbench"
# Install for Flatpak FreeCAD explicitly
install-freecad-flatpak:
mkdir -p $(FREECAD_MOD_DIR_FLATPAK)
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo
@echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo"
@echo "Restart FreeCAD to load the Silo workbench"
# Install for native FreeCAD explicitly
install-freecad-native:
mkdir -p $(FREECAD_MOD_DIR_NATIVE)
mkdir -p $(FREECAD_MOD_DIR_LEGACY)
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo
ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo
@echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo"
# Uninstall FreeCAD workbench
uninstall-freecad:
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
@echo "Uninstalled Silo workbench"
# =============================================================================
# LibreOffice Calc Extension
# =============================================================================
# Build .oxt extension package
build-calc-oxt:
@echo "Building silo-calc.oxt..."
@cd pkg/calc && zip -r ../../silo-calc.oxt . \
-x '*.pyc' '*__pycache__/*' 'tests/*' '.gitignore'
@echo "Built silo-calc.oxt"
# Install extension system-wide (requires unopkg)
install-calc: build-calc-oxt
unopkg add --shared silo-calc.oxt 2>/dev/null || unopkg add silo-calc.oxt
@echo "Installed silo-calc extension. Restart LibreOffice to load."
# Uninstall extension
uninstall-calc:
unopkg remove io.kindredsystems.silo.calc 2>/dev/null || true
@echo "Uninstalled silo-calc extension."
# Development install: symlink into user extensions dir
install-calc-dev:
@CALC_EXT_DIR="$${HOME}/.config/libreoffice/4/user/extensions"; \
if [ -d "$$CALC_EXT_DIR" ]; then \
rm -rf "$$CALC_EXT_DIR/silo-calc"; \
ln -sf $(PWD)/pkg/calc "$$CALC_EXT_DIR/silo-calc"; \
echo "Symlinked to $$CALC_EXT_DIR/silo-calc"; \
else \
echo "LibreOffice extensions dir not found at $$CALC_EXT_DIR"; \
echo "Try: install-calc (uses unopkg)"; \
fi
@echo "Restart LibreOffice to load the Silo Calc extension"
# Run Python tests for the Calc extension
test-calc:
python3 -m unittest pkg/calc/tests/test_basics.py -v
# Clean extension package
clean-calc:
rm -f silo-calc.oxt
# =============================================================================
# API Testing
# =============================================================================
@@ -228,6 +129,19 @@ api-create-item:
-H "Content-Type: application/json" \
-d '{"schema":"kindred-rd","project":"CS100","category":"F01","material":"316","description":"Test screw"}' | jq .
# =============================================================================
# Web Frontend
# =============================================================================
web-install:
cd web && npm ci
web-dev:
cd web && npm run dev
web-build:
cd web && npm run build
# =============================================================================
# Help
# =============================================================================
@@ -257,22 +171,13 @@ help:
@echo " migrate - Run database migrations"
@echo " db-shell - Connect to database with psql"
@echo ""
@echo "FreeCAD:"
@echo " install-freecad - Install workbench (auto-detect Flatpak/native)"
@echo " install-freecad-flatpak - Install for Flatpak FreeCAD"
@echo " install-freecad-native - Install for native FreeCAD"
@echo " uninstall-freecad - Remove workbench symlinks"
@echo ""
@echo "LibreOffice Calc:"
@echo " build-calc-oxt - Build .oxt extension package"
@echo " install-calc - Install extension (uses unopkg)"
@echo " install-calc-dev - Symlink for development"
@echo " uninstall-calc - Remove extension"
@echo " test-calc - Run Python tests for extension"
@echo " clean-calc - Remove .oxt file"
@echo ""
@echo "API Testing:"
@echo " api-health - Test health endpoint"
@echo " api-schemas - List schemas"
@echo " api-items - List items"
@echo " api-create-item - Create a test item"
@echo ""
@echo "Web Frontend:"
@echo " web-install - Install npm dependencies"
@echo " web-dev - Start Vite dev server"
@echo " web-build - Build production bundle"

View File

@@ -1,7 +1,10 @@
package api
import (
"io/fs"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -198,5 +201,21 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
})
})
// React SPA (transition period — served at /app/)
if webDir, err := os.Stat("web/dist"); err == nil && webDir.IsDir() {
webFS := os.DirFS("web/dist")
r.Get("/app/*", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/app/")
if path == "" {
path = "index.html"
}
// Try to serve the requested file; fall back to index.html for SPA routing
if _, err := fs.Stat(webFS, path); err != nil {
path = "index.html"
}
http.ServeFileFS(w, r, webFS, path)
})
}
return r
}

1
web/.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1875
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "silo-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}

30
web/src/App.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';
import { AppShell } from './components/AppShell';
import { LoginPage } from './pages/LoginPage';
import { ItemsPage } from './pages/ItemsPage';
import { ProjectsPage } from './pages/ProjectsPage';
import { SchemasPage } from './pages/SchemasPage';
import { AuditPage } from './pages/AuditPage';
import { SettingsPage } from './pages/SettingsPage';
export function App() {
const { user, loading } = useAuth();
if (loading) return null;
if (!user) return <LoginPage />;
return (
<Routes>
<Route element={<AppShell />}>
<Route index element={<ItemsPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="schemas" element={<SchemasPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}

70
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { ErrorResponse } from './types';
export class ApiError extends Error {
constructor(
public status: number,
public error: string,
message?: string,
) {
super(message ?? error);
this.name = 'ApiError';
}
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (res.status === 401) {
window.location.href = '/login';
throw new ApiError(401, 'unauthorized');
}
if (!res.ok) {
let body: ErrorResponse | undefined;
try {
body = await res.json() as ErrorResponse;
} catch {
// non-JSON error response
}
throw new ApiError(
res.status,
body?.error ?? `HTTP ${res.status}`,
body?.message,
);
}
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
export function get<T>(url: string): Promise<T> {
return request<T>(url);
}
export function post<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'POST',
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function put<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'PUT',
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function del(url: string): Promise<void> {
return request<void>(url, { method: 'DELETE' });
}

129
web/src/api/types.ts Normal file
View File

@@ -0,0 +1,129 @@
export interface User {
id: string;
username: string;
display_name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
auth_source: string;
}
export interface Item {
id: string;
part_number: string;
item_type: string;
description: string;
current_revision: number;
created_at: string;
updated_at: string;
sourcing_type: string;
sourcing_link?: string;
long_description?: string;
standard_cost?: number;
properties?: Record<string, unknown>;
}
export interface Project {
id: string;
code: string;
name?: string;
description?: string;
created_at: string;
}
export interface Schema {
name: string;
version: number;
description: string;
separator: string;
format: string;
segments: SchemaSegment[];
examples?: string[];
}
export interface SchemaSegment {
name: string;
type: string;
description?: string;
required: boolean;
values?: Record<string, string>;
length?: number;
}
export interface Revision {
id: string;
revision_number: number;
properties: Record<string, unknown>;
file_key?: string;
file_checksum?: string;
file_size?: number;
created_at: string;
created_by?: string;
comment?: string;
status: string;
labels: string[];
}
export interface BOMEntry {
id: string;
parent_part_number: string;
child_part_number: string;
child_description: string;
rel_type: string;
quantity: number | null;
unit?: string;
reference_designators?: string[];
child_revision?: number;
effective_revision: number;
depth?: number;
metadata?: Record<string, unknown>;
}
export interface AuditFieldResult {
key: string;
source: string;
weight: number;
value: unknown;
filled: boolean;
}
export interface AuditItemResult {
part_number: string;
description: string;
category: string;
category_name: string;
sourcing_type: string;
projects: string[];
score: number;
tier: string;
weighted_filled: number;
weighted_total: number;
has_bom: boolean;
bom_children: number;
missing_critical: string[];
missing: string[];
updated_at: string;
fields?: AuditFieldResult[];
}
export interface CategorySummary {
count: number;
avg_score: number;
}
export interface AuditSummary {
total_items: number;
avg_score: number;
manufactured_without_bom: number;
by_tier: Record<string, number>;
by_category: Record<string, CategorySummary>;
}
export interface AuditCompletenessResponse {
items: AuditItemResult[];
summary: AuditSummary;
}
export interface ErrorResponse {
error: string;
message?: string;
}

View File

@@ -0,0 +1,104 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const navLinks = [
{ to: '/', label: 'Items' },
{ to: '/projects', label: 'Projects' },
{ to: '/schemas', label: 'Schemas' },
{ to: '/audit', label: 'Audit' },
{ to: '/settings', label: 'Settings' },
];
const roleBadgeStyle: Record<string, React.CSSProperties> = {
admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' },
editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' },
};
export function AppShell() {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div className="spinner" />
</div>
);
}
return (
<>
<header
style={{
backgroundColor: 'var(--ctp-mantle)',
borderBottom: '1px solid var(--ctp-surface0)',
padding: '1rem 2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, color: 'var(--ctp-mauve)' }}>Silo</h1>
<nav style={{ display: 'flex', gap: '1.5rem' }}>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === '/'}
style={({ isActive }) => ({
color: isActive ? 'var(--ctp-mauve)' : 'var(--ctp-subtext1)',
backgroundColor: isActive ? 'var(--ctp-surface1)' : 'transparent',
fontWeight: 500,
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
textDecoration: 'none',
transition: 'all 0.2s',
})}
>
{link.label}
</NavLink>
))}
</nav>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ color: 'var(--ctp-subtext1)', fontSize: '0.9rem' }}>
{user.display_name}
</span>
<span
style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '1rem',
fontSize: '0.75rem',
fontWeight: 600,
...roleBadgeStyle[user.role],
}}
>
{user.role}
</span>
<button
onClick={logout}
style={{
padding: '0.35rem 0.75rem',
fontSize: '0.8rem',
borderRadius: '0.4rem',
cursor: 'pointer',
border: 'none',
background: 'var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
}}
>
Logout
</button>
</div>
)}
</header>
<main style={{ maxWidth: 1400, margin: '0 auto', padding: '2rem' }}>
<Outlet />
</main>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { createContext, useEffect, useState, type ReactNode } from 'react';
import type { User } from '../api/types';
import { get } from '../api/client';
export interface AuthContextValue {
user: User | null;
loading: boolean;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
get<User>('/api/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const logout = async () => {
await fetch('/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
};
return (
<AuthContext.Provider value={{ user, loading, logout }}>
{children}
</AuthContext.Provider>
);
}

6
web/src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
export function useAuth() {
return useContext(AuthContext);
}

16
web/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { App } from './App';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { AuditCompletenessResponse } from '../api/types';
export function AuditPage() {
const [audit, setAudit] = useState<AuditCompletenessResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<AuditCompletenessResponse>('/api/audit/completeness')
.then(setAudit)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading audit data...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
if (!audit) return null;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1.5rem',
marginBottom: '1rem',
}}>
<p>Total items: <strong>{audit.summary.total_items}</strong></p>
<p>Average score: <strong>{(audit.summary.avg_score * 100).toFixed(1)}%</strong></p>
<p>Manufactured without BOM: <strong>{audit.summary.manufactured_without_bom}</strong></p>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Item } from '../api/types';
export function ItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Item[]>('/api/items')
.then(setItems)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading items...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Items ({items.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Part Number</th>
<th style={thStyle}>Type</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Rev</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{item.part_number}
</td>
<td style={tdStyle}>{item.item_type}</td>
<td style={tdStyle}>{item.description}</td>
<td style={tdStyle}>{item.current_revision}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
};

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
export function LoginPage() {
// During transition, redirect to the Go-served login page
useEffect(() => {
window.location.href = '/login';
}, []);
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: 'var(--ctp-base)',
}}
>
<p style={{ color: 'var(--ctp-subtext0)' }}>Redirecting to login...</p>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Project } from '../api/types';
export function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Project[]>('/api/projects')
.then(setProjects)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading projects...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Projects ({projects.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Name</th>
<th style={thStyle}>Description</th>
</tr>
</thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{p.code}
</td>
<td style={tdStyle}>{p.name}</td>
<td style={tdStyle}>{p.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
};

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Schema } from '../api/types';
export function SchemasPage() {
const [schemas, setSchemas] = useState<Schema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Schema[]>('/api/schemas')
.then(setSchemas)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading schemas...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Schemas ({schemas.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Format</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Segments</th>
</tr>
</thead>
<tbody>
{schemas.map((s) => (
<tr key={s.name}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{s.name}
</td>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace" }}>{s.format}</td>
<td style={tdStyle}>{s.description}</td>
<td style={tdStyle}>{s.segments.length}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
};

View File

@@ -0,0 +1,28 @@
import { useAuth } from '../hooks/useAuth';
export function SettingsPage() {
const { user } = useAuth();
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Settings</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1.5rem',
}}>
{user ? (
<>
<p>Username: <strong>{user.username}</strong></p>
<p>Display name: <strong>{user.display_name}</strong></p>
<p>Email: <strong>{user.email}</strong></p>
<p>Role: <strong>{user.role}</strong></p>
<p>Auth source: <strong>{user.auth_source}</strong></p>
</>
) : (
<p style={{ color: 'var(--ctp-subtext0)' }}>Not logged in</p>
)}
</div>
</div>
);
}

51
web/src/styles/global.css Normal file
View File

@@ -0,0 +1,51 @@
@import './theme.css';
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--ctp-base);
color: var(--ctp-text);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--ctp-sapphire);
text-decoration: none;
}
a:hover {
color: var(--ctp-sky);
text-decoration: underline;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--ctp-mantle);
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ctp-surface2);
}
/* Monospace */
code, pre, .mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

29
web/src/styles/theme.css Normal file
View File

@@ -0,0 +1,29 @@
/* Catppuccin Mocha Theme */
:root {
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}

22
web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

19
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["vite.config.ts"]
}

16
web/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080',
'/auth': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/ready': 'http://localhost:8080',
},
},
})