feat(web): SPA shell — gate, settings, today placeholder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
30
web/src/App.tsx
Normal file
30
web/src/App.tsx
Normal 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
16
web/src/api.ts
Normal 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
10
web/src/main.tsx
Normal 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
35
web/src/pages/Gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
web/src/pages/Settings.tsx
Normal file
50
web/src/pages/Settings.tsx
Normal 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
8
web/src/pages/Today.tsx
Normal 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
7
web/src/styles.css
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user