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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,3 +43,7 @@ build/
|
||||
# FreeCAD
|
||||
*.FCStd1
|
||||
*.FCBak
|
||||
|
||||
# Web frontend
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
137
Makefile
137
Makefile
@@ -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"
|
||||
|
||||
@@ -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
1
web/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
20
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal 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
1875
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
web/package.json
Normal file
23
web/package.json
Normal 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
30
web/src/App.tsx
Normal 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
70
web/src/api/client.ts
Normal 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
129
web/src/api/types.ts
Normal 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;
|
||||
}
|
||||
104
web/src/components/AppShell.tsx
Normal file
104
web/src/components/AppShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
web/src/context/AuthContext.tsx
Normal file
38
web/src/context/AuthContext.tsx
Normal 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
6
web/src/hooks/useAuth.ts
Normal 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
16
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
36
web/src/pages/AuditPage.tsx
Normal file
36
web/src/pages/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/pages/ItemsPage.tsx
Normal file
70
web/src/pages/ItemsPage.tsx
Normal 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)',
|
||||
};
|
||||
22
web/src/pages/LoginPage.tsx
Normal file
22
web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
web/src/pages/ProjectsPage.tsx
Normal file
68
web/src/pages/ProjectsPage.tsx
Normal 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)',
|
||||
};
|
||||
70
web/src/pages/SchemasPage.tsx
Normal file
70
web/src/pages/SchemasPage.tsx
Normal 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)',
|
||||
};
|
||||
28
web/src/pages/SettingsPage.tsx
Normal file
28
web/src/pages/SettingsPage.tsx
Normal 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
51
web/src/styles/global.css
Normal 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
29
web/src/styles/theme.css
Normal 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
22
web/tsconfig.json
Normal 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
19
web/tsconfig.node.json
Normal 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
16
web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user