feat(web): SPA shell — gate, settings, today placeholder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
marcuspaico
2026-08-17 14:18:05 -07:00
parent f33d395b71
commit 5fbbf0676f
14 changed files with 411 additions and 3 deletions

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" />
<title>Helios</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1 +1,18 @@
{ "name": "@helios/web", "version": "0.0.1", "private": true }
{
"name": "@helios/web",
"version": "0.0.1",
"scripts": { "dev": "vite", "build": "vite build" },
"dependencies": {
"@helios/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.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 { useCallback, useEffect, useState } from "react";
import { Link, Route, Routes } from "react-router";
import type { MeResponse } from "@helios/shared";
import { api } from "./api";
import { Gate } from "./pages/Gate";
import { Settings } from "./pages/Settings";
import { Today } from "./pages/Today";
export function App() {
const [me, setMe] = useState<MeResponse | null>(null);
const refresh = useCallback(() => { api.me().then(setMe); }, []);
useEffect(refresh, [refresh]);
if (!me) return null;
if (!me.authenticated) return <Gate needsSetup={me.needsSetup} onDone={refresh} />;
return (
<div className="shell">
<nav>
<Link to="/">Today</Link>
<Link to="/settings">Settings</Link>
<button onClick={() => api.logout().then(refresh)}>Log out</button>
</nav>
<Routes>
<Route path="/" element={<Today />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
);
}

16
web/src/api.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared";
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText);
return res.status === 204 ? (undefined as T) : res.json();
}
export const api = {
me: () => req<MeResponse>("/api/me"),
setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }),
login: (password: string) => req<void>("/api/login", { method: "POST", body: JSON.stringify({ password }) }),
logout: () => req<void>("/api/logout", { method: "POST" }),
getSettings: () => req<SettingsResponse>("/api/settings"),
putSettings: (body: SettingsUpdate) => req<void>("/api/settings", { method: "PUT", body: JSON.stringify(body) }),
};

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

@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>,
);

35
web/src/pages/Gate.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useState } from "react";
import { api } from "../api";
export function Gate({ needsSetup, onDone }: { needsSetup: boolean; onDone: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (needsSetup) await api.setup(password);
await api.login(password);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "failed");
}
};
return (
<main className="gate">
<h1>Helios</h1>
<p>{needsSetup ? "Create the password for this instance." : "Enter your password."}</p>
<form onSubmit={submit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={needsSetup ? "New password (min 8 chars)" : "Password"}
autoFocus
/>
<button type="submit">{needsSetup ? "Create password" : "Log in"}</button>
</form>
{error && <p className="error">{error}</p>}
</main>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import type { SettingsResponse } from "@helios/shared";
import { api } from "../api";
export function Settings() {
const [current, setCurrent] = useState<SettingsResponse | null>(null);
const [llmBaseUrl, setLlmBaseUrl] = useState("");
const [llmModel, setLlmModel] = useState("");
const [llmKey, setLlmKey] = useState("");
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
api.getSettings().then((s) => {
setCurrent(s);
setLlmBaseUrl(s.llmBaseUrl);
setLlmModel(s.llmModel);
});
}, []);
const save = async (e: React.FormEvent) => {
e.preventDefault();
setStatus(null);
try {
await api.putSettings({ llmBaseUrl, llmModel, ...(llmKey ? { llmKey } : {}) });
setLlmKey("");
setCurrent(await api.getSettings());
setStatus("Saved");
} catch (err) {
setStatus(err instanceof Error ? err.message : "failed");
}
};
if (!current) return <p>Loading…</p>;
return (
<section>
<h2>AI provider</h2>
<p>Any OpenAI-compatible endpoint. OpenRouter by default; point at Ollama for a fully local setup.</p>
<form onSubmit={save}>
<label>Base URL <input value={llmBaseUrl} onChange={(e) => setLlmBaseUrl(e.target.value)} /></label>
<label>Model <input value={llmModel} onChange={(e) => setLlmModel(e.target.value)} /></label>
<label>
API key{current.llmKeyMasked ? ` (saved: ${current.llmKeyMasked})` : ""}
<input type="password" value={llmKey} onChange={(e) => setLlmKey(e.target.value)} placeholder="paste to replace" />
</label>
<button type="submit">Save</button>
</form>
{status && <p>{status}</p>}
</section>
);
}

8
web/src/pages/Today.tsx Normal file
View File

@@ -0,0 +1,8 @@
export function Today() {
return (
<section>
<h2>Today</h2>
<p>No data yet. Connect a wearable in Settings once connectors land (M2).</p>
</section>
);
}

7
web/src/styles.css Normal file
View File

@@ -0,0 +1,7 @@
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; }
.shell, .gate { max-width: 640px; margin: 0 auto; padding: 24px; }
nav { display: flex; gap: 16px; margin-bottom: 24px; }
form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
input { padding: 8px; }
.error { color: crimson; }

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
web/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

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

@@ -0,0 +1,7 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://localhost:3000" } },
});