From de6ce0c350bd5db81248214627f1b7ed1fdb0c80 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Fri, 22 May 2026 22:39:38 +0100 Subject: [PATCH] feat: enhance Appwrite integration and chat functionality - Added support for encrypted coach chats with a new `coach_chats` collection in the Appwrite database. - Updated `.env.example` to include `OLLAMA_API_KEY`, `OLLAMA_MODEL`, and `APPWRITE_API_KEY` for server-side configurations. - Introduced a setup script in `package.json` for initializing Appwrite database tables. - Enhanced the Vite configuration to proxy requests to the Ollama API. - Updated the main application structure to accommodate new chat features and improved theme management. - Refined CSS styles for better UI consistency and added new components for chat functionality. --- .env.example | 13 + APPWRITE_SETUP.md | 48 +- api/ollama-chat.js | 77 +++ package.json | 3 +- scripts/setup-appwrite.mjs | 205 ++++++++ src/App.tsx | 941 ++++++++++++++++++++++++++++++++----- src/data/themes.ts | 229 +++++++++ src/index.css | 505 ++++++++++++++++---- src/lib/appwrite.ts | 1 + src/lib/encryptedChats.ts | 178 +++++++ src/lib/themeTokens.ts | 248 ++++++++++ src/types.ts | 20 + src/vite-env.d.ts | 2 + vite.config.ts | 50 +- 14 files changed, 2294 insertions(+), 226 deletions(-) create mode 100644 api/ollama-chat.js create mode 100644 scripts/setup-appwrite.mjs create mode 100644 src/data/themes.ts create mode 100644 src/lib/encryptedChats.ts create mode 100644 src/lib/themeTokens.ts diff --git a/.env.example b/.env.example index d1b772e..8705f05 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,21 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_COLLECTION_ID=intake_entries +VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats # Optional. Leave blank in local dev so the app uses the current Vite origin, # including fallback ports like http://127.0.0.1:5174. VITE_APPWRITE_OAUTH_SUCCESS_URL= VITE_APPWRITE_OAUTH_FAILURE_URL= + +# Server-only. Do not prefix with VITE_ or it will be exposed to the browser. +OLLAMA_API_KEY= +OLLAMA_MODEL=deepseek-v4-pro:cloud +VITE_OLLAMA_PROXY_URL=/api/ollama-chat + +# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. +APPWRITE_API_KEY= + +# Appwrite chat table columns needed for encrypted coach chats: +# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings +# version as integer. Enable row security and Users -> Create at table level. diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index 152ed1c..3ed9557 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -21,6 +21,14 @@ cp .env.example .env.local This app uses only the Appwrite browser SDK. Do not add an API key to the frontend. +To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run: + +```bash +npm run setup:appwrite +``` + +The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code. + Configured defaults: - Endpoint: `https://fra.cloud.appwrite.io/v1` @@ -28,6 +36,7 @@ Configured defaults: - Project name: `Red Bull Tracker App` - Database ID: `redbull_tracker` - Collection ID: `intake_entries` +- Chat collection ID: `coach_chats` `client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. @@ -154,11 +163,48 @@ Recommended indexes: - `user_import_key`: key index on `userId`, `importKey` - Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it +## Encrypted Coach Chats + +Create a second table with ID: + +```text +coach_chats +``` + +Enable row security on `coach_chats`. + +Recommended table-level permissions: + +- Create: `users` +- Read: none +- Update: none +- Delete: none + +The app encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. + +Create these chat columns: + +| Key | Type | Required | Notes | +| --- | --- | --- | --- | +| `userId` | String, 64 | Yes | Current Appwrite user ID | +| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | +| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | +| `titleIv` | String, 128 | Yes | Base64 IV | +| `messagesIv` | String, 128 | Yes | Base64 IV | +| `salt` | String, 128 | Yes | Base64 PBKDF2 salt | +| `version` | Integer | Yes | Crypto version | +| `updatedAt` | DateTime | Yes | Sort key | + +Recommended chat index: + +- `user_chat_updated`: key index on `userId`, `updatedAt` + ## Component Structure -- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/data views, modals, and action state. +- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state. - `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper. - `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures. +- `src/lib/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. - `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview. - `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks. - `src/lib/storage.ts`: JSON backup export/import parser. diff --git a/api/ollama-chat.js b/api/ollama-chat.js new file mode 100644 index 0000000..5ed710b --- /dev/null +++ b/api/ollama-chat.js @@ -0,0 +1,77 @@ +/* global Buffer, fetch, process */ + +const DEFAULT_MODEL = "deepseek-v4-pro:cloud"; + +export default async function handler(req, res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.end(); + return; + } + + if (req.method !== "POST") { + res.statusCode = 405; + res.end("Method not allowed"); + return; + } + + const apiKey = process.env.OLLAMA_API_KEY; + if (!apiKey) { + res.statusCode = 500; + res.end("OLLAMA_API_KEY is not configured on the server."); + return; + } + + try { + const payload = await readJson(req); + const upstream = await fetch("https://ollama.com/api/chat", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...payload, + model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL, + stream: payload.stream !== false, + }), + }); + + res.statusCode = upstream.status; + res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson"); + + if (!upstream.ok) { + res.end(await upstream.text()); + return; + } + + if (!upstream.body) { + res.end(); + return; + } + + const reader = upstream.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + res.end(); + } catch (error) { + res.statusCode = 500; + res.end(error instanceof Error ? error.message : "Ollama proxy failed."); + } +} + +async function readJson(req) { + if (req.body && typeof req.body === "object") return req.body; + if (typeof req.body === "string") return JSON.parse(req.body || "{}"); + + let raw = ""; + for await (const chunk of req) raw += chunk; + return raw ? JSON.parse(raw) : {}; +} diff --git a/package.json b/package.json index e492d48..7fec1a0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "setup:appwrite": "node scripts/setup-appwrite.mjs" }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs new file mode 100644 index 0000000..cc11891 --- /dev/null +++ b/scripts/setup-appwrite.mjs @@ -0,0 +1,205 @@ +/* global console, fetch, process, setTimeout */ + +import { existsSync, readFileSync } from "node:fs"; + +const env = loadEnvFiles([".env", ".env.local"]); + +const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.io/v1").replace(/\/$/, ""); +const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138"); +const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker"); +const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); +const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); +const apiKey = readEnv("APPWRITE_API_KEY", ""); + +if (!apiKey) { + throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); +} + +await ensureDatabase(databaseId, "Red Bull Tracker"); +await ensureTable({ + tableId: intakeTableId, + name: "Intake entries", + columns: [ + { kind: "string", key: "userId", size: 64, required: true }, + { kind: "float", key: "cans", required: true }, + { kind: "string", key: "flavour", size: 128, required: true }, + { kind: "string", key: "flavourAccent", size: 32, required: true }, + { kind: "integer", key: "sizeMl", required: true }, + { kind: "float", key: "pricePerCan", required: true }, + { kind: "datetime", key: "dateTime", required: true }, + { kind: "string", key: "notes", size: 2000, required: false }, + { kind: "string", key: "store", size: 256, required: false }, + { kind: "boolean", key: "sugarFree", required: true }, + { kind: "float", key: "caffeineMgPerCan", required: false }, + { kind: "string", key: "importKey", size: 512, required: true }, + { kind: "string", key: "source", size: 32, required: true }, + ], + indexes: [ + { key: "user_date_desc", type: "key", columns: ["userId", "dateTime"], orders: ["ASC", "DESC"], lengths: [32] }, + { key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] }, + ], +}); +await ensureTable({ + tableId: chatTableId, + name: "Coach chats", + columns: [ + { kind: "string", key: "userId", size: 64, required: true }, + { kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true }, + { kind: "longtext", key: "encryptedMessages", required: true, encrypt: true }, + { kind: "string", key: "titleIv", size: 128, required: true }, + { kind: "string", key: "messagesIv", size: 128, required: true }, + { kind: "string", key: "salt", size: 128, required: true }, + { kind: "integer", key: "version", required: true }, + { kind: "datetime", key: "updatedAt", required: true }, + ], + indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], +}); + +console.log("Appwrite database and tables ready."); + +async function ensureDatabase(id, name) { + const existing = await request("GET", `/tablesdb/${id}`, undefined, [200, 404]); + if (existing.status === 200) { + console.log(`Database ${id} exists.`); + return; + } + + await request("POST", "/tablesdb", { databaseId: id, name, enabled: true }, [201]); + console.log(`Database ${id} created.`); +} + +async function ensureTable({ tableId, name, columns, indexes }) { + const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}`, undefined, [200, 404]); + if (existing.status === 404) { + await request( + "POST", + `/tablesdb/${databaseId}/tables`, + { + tableId, + name, + permissions: ['create("users")'], + rowSecurity: true, + enabled: true, + }, + [201], + ); + console.log(`Table ${tableId} created.`); + } else { + await request( + "PUT", + `/tablesdb/${databaseId}/tables/${tableId}`, + { name, permissions: ['create("users")'], rowSecurity: true, enabled: true, purge: true }, + [200], + ); + console.log(`Table ${tableId} exists and permissions updated.`); + } + + for (const column of columns) { + await ensureColumn(tableId, column); + } + await waitForColumns(tableId, columns.map((column) => column.key)); + for (const index of indexes) { + await ensureIndex(tableId, index); + } +} + +async function ensureColumn(tableId, column) { + const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.key}`, undefined, [200, 404]); + if (existing.status === 200) { + console.log(`Column ${tableId}.${column.key} exists.`); + return; + } + + const body = { + key: column.key, + required: column.required, + array: false, + }; + if (column.size) body.size = column.size; + if (column.encrypt) body.encrypt = true; + + await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]); + console.log(`Column ${tableId}.${column.key} created.`); +} + +async function ensureIndex(tableId, index) { + const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); + if (existing.status === 200) { + console.log(`Index ${tableId}.${index.key} exists.`); + return; + } + + await request( + "POST", + `/tablesdb/${databaseId}/tables/${tableId}/indexes`, + { key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths }, + [202, 201], + ); + console.log(`Index ${tableId}.${index.key} created.`); +} + +async function waitForColumns(tableId, keys) { + const pending = new Set(keys); + for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { + for (const key of [...pending]) { + const response = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); + if (response.status === 200 && ["available", "failed"].includes(response.body.status)) { + if (response.body.status === "failed") throw new Error(`Column ${tableId}.${key} failed: ${response.body.error || "unknown error"}`); + pending.delete(key); + } + } + if (pending.size) await delay(1_000); + } + if (pending.size) throw new Error(`Timed out waiting for columns: ${[...pending].join(", ")}`); +} + +async function request(method, path, body, okStatuses) { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: { + "Content-Type": "application/json", + "X-Appwrite-Key": apiKey, + "X-Appwrite-Project": projectId, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await response.text(); + const parsed = text ? parseJson(text) : null; + if (!okStatuses.includes(response.status)) { + const message = parsed?.message || text || `${method} ${path} failed with status ${response.status}`; + throw new Error(message); + } + + return { status: response.status, body: parsed }; +} + +function parseJson(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function readEnv(name, fallback) { + return process.env[name] || env[name] || fallback; +} + +function loadEnvFiles(paths) { + const values = {}; + for (const path of paths) { + if (!existsSync(path)) continue; + const lines = readFileSync(path, "utf8").split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + values[match[1]] = match[2].trim().replace(/^(["'])(.*)\1$/, "$2"); + } + } + return values; +} diff --git a/src/App.tsx b/src/App.tsx index 80128c7..e69866c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import type { Models } from "appwrite"; import { Activity, AlertTriangle, + Brain, CalendarDays, CheckCircle2, ChevronRight, @@ -16,15 +17,21 @@ import { Home, LineChart, Loader2, + Lock, LogIn, LogOut, + MessageCircle, + MessageSquarePlus, Plus, PoundSterling, RefreshCcw, RotateCcw, + Send, Search, Settings2, ShieldCheck, + Sparkles, + Square, TimerReset, Trash2, Upload, @@ -61,6 +68,16 @@ import { YAxis, } from "recharts"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours"; +import { + APP_THEMES, + THEME_CATEGORIES, + THEME_STORAGE_KEY, + getThemeById, + readStoredThemeId, + type AppTheme, + type ThemeCategory, +} from "./data/themes"; +import { themeTokensToStyle } from "./lib/themeTokens"; import { account, appwriteConfig, Channel, client, OAuthProvider, pingAppwrite } from "./lib/appwrite"; import { appwriteErrorMessage, @@ -71,6 +88,13 @@ import { listEntries, updateEntry, } from "./lib/appwriteEntries"; +import { + chatStorageErrorMessage, + createEncryptedChat, + deleteEncryptedChat, + listEncryptedChats, + updateEncryptedChat, +} from "./lib/encryptedChats"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, @@ -99,15 +123,15 @@ import { wholeNumber, } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; -import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; +import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; -type AppView = "overview" | "logbook" | "trends" | "data"; +type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; type AuthMode = "login" | "signup"; -type AccentTheme = "blue" | "pink"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; - -const ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1"; +type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } }; +const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; +const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; const DEFAULT_FILTERS: Filters = { flavour: "all", @@ -128,11 +152,8 @@ const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [ { id: "overview", label: "Overview", icon: Home }, { id: "logbook", label: "Logbook", icon: CalendarDays }, { id: "trends", label: "Trends", icon: LineChart }, - { id: "data", label: "Data", icon: Settings2 }, -]; - -const ACCENT_OPTIONS: Array<{ id: AccentTheme; label: string }> = [ - { id: "pink", label: "Pastel pink" }, + { id: "coach", label: "Coach", icon: MessageCircle }, + { id: "settings", label: "Settings", icon: Settings2 }, ]; const MATERIAL_ACCENTS = { @@ -144,7 +165,9 @@ const MATERIAL_ACCENTS = { }; function App() { - const [themeAccent, setThemeAccent] = useState(() => readStoredAccent()); + const [themeId, setThemeId] = useState(() => readStoredThemeId()); + const activeTheme = useMemo(() => getThemeById(themeId), [themeId]); + const shellStyle = useMemo(() => themeTokensToStyle(activeTheme.tokens), [activeTheme]); const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authError, setAuthError] = useState(""); @@ -167,8 +190,8 @@ function App() { const jsonFileInputRef = useRef(null); useEffect(() => { - localStorage.setItem(ACCENT_STORAGE_KEY, themeAccent); - }, [themeAccent]); + localStorage.setItem(THEME_STORAGE_KEY, themeId); + }, [themeId]); const refreshEntries = useCallback(async (userId: string, showLoader = true) => { if (showLoader) setDataLoading(true); @@ -492,17 +515,17 @@ function App() { } if (authLoading) { - return ; + return ; } if (!user) { return ( +
void logout()} + onOpenSettings={() => setActiveView("settings")} />
void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} + onOpenSettings={() => setActiveView("settings")} onRefresh={() => void refreshEntries(user.$id)} /> @@ -580,8 +605,10 @@ function App() { recentEntries={recentEntries} chartData={chartData} flavourData={flavourData} + user={user} onQuickAdd={(item) => void quickAdd(item)} onAdd={openNewEntry} + onOpenCoach={() => setActiveView("coach")} onOpenLogbook={() => setActiveView("logbook")} /> )} @@ -615,16 +642,26 @@ function App() { /> )} - {activeView === "data" && ( - } + + {activeView === "settings" && ( + void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} onExportJson={exportJson} onImportJson={() => jsonFileInputRef.current?.click()} + onLogout={() => void logout()} onReset={() => setIsResetOpen(true)} + onThemeChange={setThemeId} /> )} @@ -674,9 +711,17 @@ function ShellBackdrop() { ); } -function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; themeAccent: AccentTheme }) { +function LoadingScreen({ + setupStatus, + shellStyle, + themeId, +}: { + setupStatus: SetupStatus; + shellStyle: CSSProperties; + themeId: string; +}) { return ( -
+
@@ -692,20 +737,20 @@ function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; } function AuthView({ - accent, authError, busy, setupStatus, - onAccentChange, + shellStyle, + themeId, onLogin, onOAuth, onSignup, }: { - accent: AccentTheme; authError: string; busy: boolean; setupStatus: SetupStatus; - onAccentChange: (accent: AccentTheme) => void; + shellStyle: CSSProperties; + themeId: string; onLogin: (email: string, password: string) => Promise; onOAuth: (provider: "github" | "google") => void; onSignup: (name: string, email: string, password: string) => Promise; @@ -725,7 +770,7 @@ function AuthView({ } return ( -
+
@@ -749,9 +794,6 @@ function AuthView({ {setupStatus.message}
)} -
- -
@@ -832,52 +874,98 @@ function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: str ); } -function AccentPicker({ - accent, - onChange, +function CurrentThemeIndicator({ + theme, + onClick, }: { - accent: AccentTheme; - onChange: (accent: AccentTheme) => void; + theme: AppTheme; + onClick: () => void; }) { return ( -
- {ACCENT_OPTIONS.map((option) => ( - - ))} + + ); +} + +function ThemePicker({ + themeId, + onChange, +}: { + themeId: string; + onChange: (id: string) => void; +}) { + const [category, setCategory] = useState("vocaloid"); + const activeTheme = getThemeById(themeId); + const visibleThemes = APP_THEMES.filter((theme) => theme.category === category); + + return ( +
+
+ {THEME_CATEGORIES.map((entry) => ( + + ))} +
+ +
+
Primary
+
Surface
+
+ Chart +
+
+ +
+ {visibleThemes.map((theme) => ( + + ))} +
+ +

+ Current theme: {activeTheme.label} +

); } function Sidebar({ - accent, activeView, dataLoading, notice, setupStatus, user, - onAccentChange, onAdd, onChange, - onLogout, + onOpenSettings, }: { - accent: AccentTheme; activeView: AppView; dataLoading: boolean; notice: string; setupStatus: SetupStatus; user: AuthUser; - onAccentChange: (accent: AccentTheme) => void; onAdd: () => void; onChange: (view: AppView) => void; - onLogout: () => void; + onOpenSettings: () => void; }) { return ( ); @@ -956,28 +1036,28 @@ function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (v } function TopBar({ - accent, + activeTheme, activeView, actionLoading, dataLoading, entries, user, - onAccentChange, onAdd, onExportExcel, onImportExcel, + onOpenSettings, onRefresh, }: { - accent: AccentTheme; + activeTheme: AppTheme; activeView: AppView; actionLoading: string | null; dataLoading: boolean; entries: RedBullEntry[]; user: AuthUser; - onAccentChange: (accent: AccentTheme) => void; onAdd: () => void; onExportExcel: () => void; onImportExcel: () => void; + onOpenSettings: () => void; onRefresh: () => void; }) { const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0]; @@ -1004,7 +1084,7 @@ function TopBar({
{user.email || "Synced user"} - +
@@ -1076,8 +1156,10 @@ function OverviewView({ recentEntries, chartData, flavourData, + user, onQuickAdd, onAdd, + onOpenCoach, onOpenLogbook, }: { dashboard: Dashboard; @@ -1087,12 +1169,16 @@ function OverviewView({ recentEntries: RedBullEntry[]; chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>; flavourData: Array<{ name: string; value: number; spend: number; accent: string }>; + user: AuthUser; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void; onAdd: () => void; + onOpenCoach: () => void; onOpenLogbook: () => void; }) { return (
+ +
@@ -1175,6 +1261,77 @@ function OverviewView({ ); } +function GreetingPanel({ + dashboard, + entries, + user, + onOpenCoach, +}: { + dashboard: Dashboard; + entries: RedBullEntry[]; + user: AuthUser; + onOpenCoach: () => void; +}) { + const todayNumber = Number.parseFloat(dashboard.todayCans) || 0; + const progress = Math.min(100, Math.round((todayNumber / 4) * 100)); + const name = firstName(user); + const favourite = dashboard.favouriteFlavour === "None yet" ? "still forming" : dashboard.favouriteFlavour; + const redBullLabel = todayNumber === 1 ? "Red Bull" : "Red Bulls"; + + return ( +
+
+
+
+ {dashboard.todayCans} + today +
+
+ +
+
+
+

+ Hey {name}, you've had {dashboard.todayCans} {redBullLabel} today and your favourite flavour is {favourite}. +

+

+ Clean caffeine, sugar, spend, and streak signals in one glance. +

+
+ +
+ + + +
+
+ +
+ + + +
+
+ ); +} + +function WellnessPill({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + function TodayPanel({ dashboard, entries, @@ -1185,8 +1342,7 @@ function TodayPanel({ onAdd: () => void; }) { return ( -
-
+

Today

@@ -1386,68 +1542,535 @@ function TrendsView({ ); } -function DataView({ +function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries: RedBullEntry[]; user: AuthUser }) { + const [chats, setChats] = useState([]); + const [activeChatId, setActiveChatId] = useState(null); + const [savedChatIds, setSavedChatIds] = useState>(() => new Set()); + const [chatKey, setChatKey] = useState(""); + const [chatKeyInput, setChatKeyInput] = useState(""); + const [chatStorageStatus, setChatStorageStatus] = useState("unlock encrypted chat storage"); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [openThinkingIds, setOpenThinkingIds] = useState([]); + const abortRef = useRef(null); + const messagesEndRef = useRef(null); + const activeChat = chats.find((chat) => chat.id === activeChatId) ?? null; + const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]); + const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); + }, [activeChatId, messages]); + + const quickPrompts = [ + "what does my red bull pattern say about today?", + "give me one lower-sugar swap based on my favourite flavour.", + "how should i pace caffeine for the rest of the day?", + ]; + + async function unlockChats(event: FormEvent) { + event.preventDefault(); + const passphrase = chatKeyInput.trim(); + if (!passphrase) return; + + setBusy(true); + setError(""); + setChatStorageStatus("opening encrypted appwrite chats..."); + try { + const savedChats = await listEncryptedChats(user.$id, passphrase); + const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user)]; + setChatKey(passphrase); + setChats(initialChats); + setSavedChatIds(new Set(savedChats.map((chat) => chat.id))); + setActiveChatId(initialChats[0].id); + setChatStorageStatus(savedChats.length ? `${savedChats.length} encrypted chat${savedChats.length === 1 ? "" : "s"} loaded` : "new encrypted chat ready"); + } catch (caught) { + const message = chatStorageErrorMessage(caught); + setError(message); + setChatKey(""); + setChatStorageStatus("encrypted chat unlock failed"); + } finally { + setBusy(false); + } + } + + function startNewChat() { + if (!chatKey) return; + const chat = buildNewCoachChat(user); + setChats((current) => [chat, ...current]); + setActiveChatId(chat.id); + setInput(""); + setError(""); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + await sendPrompt(input); + } + + async function sendPrompt(prompt: string) { + const trimmed = prompt.trim(); + if (!trimmed || busy) return; + if (!chatKey) { + setError("unlock encrypted chat storage first."); + return; + } + + const currentChat = activeChat ?? buildNewCoachChat(user); + const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed }; + const assistantId = makeId(); + const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true }; + const conversation = [...currentChat.messages, userMessage]; + const now = new Date().toISOString(); + const draftChat: CoachChat = { + ...currentChat, + title: titleForChat(currentChat.title, trimmed), + messages: [...conversation, assistantMessage], + updatedAt: now, + }; + + upsertChatState(draftChat); + setActiveChatId(draftChat.id); + setInput(""); + setBusy(true); + setError(""); + + let streamedContent = ""; + let streamedThinking = ""; + const abortController = new AbortController(); + abortRef.current = abortController; + + try { + const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [ + { role: "system", content: buildCoachSystemPrompt(user, dashboard, entries) }, + ...conversation + .filter((message) => message.content.trim().length > 0) + .map((message) => ({ + role: message.role, + content: message.content, + ...(message.thinking ? { thinking: message.thinking } : {}), + })), + ]; + + const response = await fetch(OLLAMA_PROXY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + messages: requestMessages, + stream: true, + think: true, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `Ollama request failed with status ${response.status}.`); + } + if (!response.body) { + throw new Error("Streaming response was empty."); + } + + await readOllamaStream(response.body, (chunk) => { + if (chunk.error) throw new Error(chunk.error); + if (chunk.message?.thinking) streamedThinking += chunk.message.thinking; + if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase(); + + patchAssistantMessage(draftChat.id, assistantId, { + content: streamedContent, + thinking: streamedThinking, + pending: true, + }); + }); + + const finalChat = withAssistantMessage(draftChat, assistantId, { + content: streamedContent || "no answer returned.", + thinking: streamedThinking, + pending: false, + }); + upsertChatState(finalChat); + await persistChat(finalChat); + } catch (caught) { + const aborted = abortController.signal.aborted; + const message = caught instanceof Error ? caught.message : "Coach request failed."; + const finalChat = withAssistantMessage(draftChat, assistantId, { + content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(), + thinking: streamedThinking, + pending: false, + stopped: aborted, + }); + upsertChatState(finalChat); + await persistChat(finalChat); + if (!aborted) setError(message); + } finally { + abortRef.current = null; + setBusy(false); + } + } + + function stopThinking() { + abortRef.current?.abort(); + } + + function toggleThinking(id: string) { + setOpenThinkingIds((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id])); + } + + function upsertChatState(chat: CoachChat) { + setChats((current) => { + const exists = current.some((item) => item.id === chat.id); + return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current]; + }); + } + + function patchAssistantMessage(chatId: string, messageId: string, patch: Partial) { + setChats((current) => + current.map((chat) => + chat.id === chatId + ? { + ...chat, + updatedAt: new Date().toISOString(), + messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), + } + : chat, + ), + ); + } + + function withAssistantMessage(chat: CoachChat, messageId: string, patch: Partial): CoachChat { + return { + ...chat, + updatedAt: new Date().toISOString(), + messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), + }; + } + + async function persistChat(chat: CoachChat) { + if (!chatKey) return; + try { + const saved = savedChatIds.has(chat.id) + ? await updateEncryptedChat(user.$id, chatKey, chat) + : await createEncryptedChat(user.$id, chatKey, chat); + setSavedChatIds((current) => new Set(current).add(saved.id)); + upsertChatState(saved); + setChatStorageStatus("encrypted chat saved to appwrite"); + } catch (caught) { + setChatStorageStatus("encrypted chat save failed"); + setError(chatStorageErrorMessage(caught)); + } + } + + async function removeChat(chatId: string) { + if (busy) return; + try { + if (savedChatIds.has(chatId)) await deleteEncryptedChat(chatId); + setSavedChatIds((current) => { + const next = new Set(current); + next.delete(chatId); + return next; + }); + setChats((current) => { + const next = current.filter((chat) => chat.id !== chatId); + const fallback = buildNewCoachChat(user); + setActiveChatId(next[0]?.id ?? fallback.id); + return next.length ? next : [fallback]; + }); + setChatStorageStatus("encrypted chat deleted"); + } catch (caught) { + setError(chatStorageErrorMessage(caught)); + } + } + + if (!chatKey) { + return ( +
+
+
+
+

unlock encrypted coach chats

+

+ messages encrypt in this browser before appwrite stores them. the passphrase is never saved, so use the same one on every device. +

+
+ setChatKeyInput(event.target.value)} + placeholder="encryption passphrase" + autoComplete="current-password" + /> + +
+ {error &&

{error}

} +
+
+ ); + } + + return ( +
+ + +
+
+ {OLLAMA_MODEL} + {busy ? "thinking" : "ready"} +
+ +
+ {!visibleMessages.length ? ( +
+
+
+

ready when you are

+

ask about caffeine pace, sugar, spend, or your flavour pattern. answers stay lower case.

+
+ {quickPrompts.map((prompt) => ( + + ))} +
+
+ ) : ( + visibleMessages.map((message) => ( + toggleThinking(message.id)} + /> + )) + )} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setInput(event.target.value)} + placeholder="ask coach" + disabled={busy} + /> + {busy ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function CoachMessageBubble({ + message, + thinkingOpen, + onToggleThinking, +}: { + message: CoachMessage; + thinkingOpen: boolean; + onToggleThinking: () => void; +}) { + const isAssistant = message.role === "assistant"; + const canShowThinking = isAssistant && (message.pending || Boolean(message.thinking)); + const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "thinking"; + + return ( +
+
+

{isAssistant ? "coach" : "you"}

+
+ {message.content || (message.pending ? "streaming response..." : "")} +
+ + {canShowThinking && ( +
+ + + {thinkingOpen && ( + + {message.thinking || "waiting for reasoning trace..."} + + )} + +
+ )} +
+
+ ); +} + +function SettingsView({ + activeTheme, dashboard, + dataLoading, entries, + notice, + setupStatus, + themeId, + user, actionLoading, onExportExcel, onImportExcel, onExportJson, onImportJson, + onLogout, onReset, + onThemeChange, }: { + activeTheme: AppTheme; dashboard: Dashboard; + dataLoading: boolean; entries: RedBullEntry[]; + notice: string; + setupStatus: SetupStatus; + themeId: string; + user: AuthUser; actionLoading: string | null; onExportExcel: () => void; onImportExcel: () => void; onExportJson: () => void; onImportJson: () => void; + onLogout: () => void; onReset: () => void; + onThemeChange: (id: string) => void; }) { return (
- -
- - - -
+
+ +
+

{user.name || "Appwrite user"}

+

{user.email}

+
+ {dataLoading ?
+

{setupStatus.message}

+ +
+
-
- - - - -
+ + + -
-

Configured Appwrite IDs

-
- - - - -
-
+ +
+ + + +
- -
+
+ + + + +
+ +
+

Configured Appwrite IDs

+
+ + + + + +
+
+ + + +
@@ -2318,6 +2941,90 @@ function formatMetricValue(name: string, value: number) { return oneDecimal.format(value); } +async function readOllamaStream(stream: ReadableStream, onChunk: (chunk: OllamaStreamChunk) => void) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + function processLine(line: string) { + const chunk = parseOllamaLine(line); + if (chunk) onChunk(chunk); + } + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + lines.forEach(processLine); + } + + buffer += decoder.decode(); + if (buffer.trim()) processLine(buffer); +} + +function parseOllamaLine(line: string): OllamaStreamChunk | null { + const trimmed = line.trim().replace(/^data:\s*/, ""); + if (!trimmed || trimmed === "[DONE]") return null; + try { + return JSON.parse(trimmed) as OllamaStreamChunk; + } catch { + return null; + } +} + +function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) { + const recent = entries + .slice(0, 12) + .map( + (entry) => + `- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`, + ) + .join("\n"); + + return [ + "You are an upbeat Red Bull intake coach inside a tracking app.", + "Respond entirely in lower case, including headings and short labels.", + "Give concise, practical suggestions based only on the logged data provided.", + "Do not give medical advice; suggest checking labels and using personal judgement for caffeine tolerance.", + `User: ${user.name || user.email || "Appwrite user"}`, + `Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`, + `Favourite flavour: ${dashboard.favouriteFlavour}. Current streak: ${dashboard.currentStreak} day(s). Total spend: ${dashboard.totalSpend}.`, + `Recent entries:\n${recent || "No entries logged yet."}`, + ].join("\n"); +} + +function buildNewCoachChat(user: AuthUser): CoachChat { + const now = new Date().toISOString(); + return { + id: makeId(), + userId: user.$id, + title: "new chat", + createdAt: now, + updatedAt: now, + messages: [ + { + id: "coach-welcome", + role: "assistant", + content: `hey ${firstName(user).toLocaleLowerCase()}, i can help with caffeine pace, sugar swaps, spend trends, and smarter quick-add choices.`, + }, + ], + }; +} + +function titleForChat(currentTitle: string, prompt: string) { + if (currentTitle !== "new chat") return currentTitle; + const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase(); + return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "new chat"; +} + +function firstName(user: AuthUser) { + const fallback = user.email?.split("@")[0] ?? "there"; + const value = (user.name || fallback).trim(); + return value.split(/\s+/)[0] || "there"; +} + function sizeToPreset(size: number) { if (size === 250 || size === 355 || size === 473) return size.toString(); return "custom"; @@ -2330,8 +3037,4 @@ function actionLabel(value: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function readStoredAccent(): AccentTheme { - return "pink"; -} - export default App; diff --git a/src/data/themes.ts b/src/data/themes.ts new file mode 100644 index 0000000..fda4a06 --- /dev/null +++ b/src/data/themes.ts @@ -0,0 +1,229 @@ +import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens"; + +export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree"; + +export type AppTheme = { + id: string; + label: string; + category: ThemeCategory; + swatch: string; + tokens: ThemeTokens; +}; + +export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1"; +export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1"; +export const DEFAULT_THEME_ID = "oura-mist"; + +const LEGACY_ACCENT_MAP: Record = { + pink: "oura-mist", + blue: "oura-mist", +}; + +function theme(id: string, label: string, category: ThemeCategory, swatch: string, seed: ThemeSeed): AppTheme { + return { id, label, category, swatch, tokens: buildThemeTokens(seed) }; +} + +export const APP_THEMES: AppTheme[] = [ + theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", { + primary: "#4b86ad", + tokens: { + primary: "#4b86ad", + primaryContainer: "#dff2ff", + onPrimaryContainer: "#10283a", + chartPrimary: "#4b86ad", + chartSecondary: "#6f8f7c", + chartTertiary: "#9b7b51", + }, + }), + theme("miku-blue", "Miku Blue", "vocaloid", "#39c5bb", { + primary: "#39c5bb", + secondary: "#39d5ff", + tertiary: "#7ce7ff", + }), + theme("teto-red", "Teto Red", "vocaloid", "#fe0404", { + primary: "#fe0404", + secondary: "#ff3448", + tertiary: "#ff6b6b", + }), + theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", { + primary: "#e07aa8", + secondary: "#ffb7d9", + tertiary: "#ffd8e7", + }), + + theme("original", "Original", "flavour", "#00a7ff", { + primary: "#0077c8", + secondary: "#00a7ff", + tertiary: "#1e3264", + }), + theme("zero", "Zero", "flavour", "#2a2a2a", { + primary: "#2a2a2a", + secondary: "#5c5c5c", + tertiary: "#8a8a8a", + dark: true, + }), + theme("summer", "Summer Edition", "flavour", "#f0e53b", { + primary: "#d4c400", + secondary: "#f0e53b", + tertiary: "#ffc247", + }), + theme("cherry", "Cherry Edition", "flavour", "#e40046", { + primary: "#c3093b", + secondary: "#e40046", + tertiary: "#ff6b8a", + }), + theme("spring", "Spring Edition", "flavour", "#ff8fab", { + primary: "#e85d8a", + secondary: "#ffb3c6", + tertiary: "#ffd8e7", + }), + theme("apple", "Apple Edition", "flavour", "#78be20", { + primary: "#5a9a12", + secondary: "#78be20", + tertiary: "#a8d84a", + }), + theme("peach", "Peach Edition", "flavour", "#ff9b63", { + primary: "#e87a3a", + secondary: "#ff9b63", + tertiary: "#ffc9a3", + }), + theme("ice", "Ice Edition", "flavour", "#49adbe", { + primary: "#2d8a9a", + secondary: "#49adbe", + tertiary: "#7ce7ff", + }), + theme("blue-edition", "Blue Edition", "flavour", "#496dff", { + primary: "#3a52cc", + secondary: "#496dff", + tertiary: "#9c73ff", + }), + theme("red-edition", "Red Edition", "flavour", "#ff355e", { + primary: "#e02045", + secondary: "#ff355e", + tertiary: "#ff6b8a", + }), + theme("tropical", "Tropical Edition", "flavour", "#ffc247", { + primary: "#e0a820", + secondary: "#ffc247", + tertiary: "#ff9b63", + }), + theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", { + primary: "#4ec4e0", + secondary: "#7ce7ff", + tertiary: "#d8f9ff", + }), + theme("green-edition", "Green Edition", "flavour", "#b7ff4a", { + primary: "#7acc20", + secondary: "#b7ff4a", + tertiary: "#d4ff8a", + }), + theme("apricot", "Apricot Edition", "flavour", "#ff8c42", { + primary: "#e06a20", + secondary: "#ff8c42", + tertiary: "#ffb87a", + }), + theme("ruby", "Ruby Edition", "flavour", "#c3093b", { + primary: "#a00730", + secondary: "#c3093b", + tertiary: "#e04060", + }), + + theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", { + primary: "#8a9bb0", + secondary: "#c8d4e0", + tertiary: "#e7eef8", + sugarFree: true, + }), + theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", { + primary: "#c4c020", + secondary: "#e8e4a0", + tertiary: "#f0e53b", + sugarFree: true, + }), + theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", { + primary: "#6a9a30", + secondary: "#b8d4a0", + tertiary: "#78be20", + sugarFree: true, + }), + theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", { + primary: "#d08050", + secondary: "#f0d0b8", + tertiary: "#ff9b63", + sugarFree: true, + }), + theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", { + primary: "#4a9aaa", + secondary: "#b8e0e8", + tertiary: "#49adbe", + sugarFree: true, + }), + theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", { + primary: "#9070c0", + secondary: "#d8c8f0", + tertiary: "#b898e0", + sugarFree: true, + }), + theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", { + primary: "#d06090", + secondary: "#f0c8d8", + tertiary: "#ffb7d9", + sugarFree: true, + }), + theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", { + primary: "#5060c0", + secondary: "#c8d0f8", + tertiary: "#496dff", + sugarFree: true, + }), + theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", { + primary: "#60b8d0", + secondary: "#d0f0f8", + tertiary: "#7ce7ff", + sugarFree: true, + }), + theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", { + primary: "#70a830", + secondary: "#d8f0b8", + tertiary: "#b7ff4a", + sugarFree: true, + }), + theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", { + primary: "#a03050", + secondary: "#f0c0c8", + tertiary: "#c3093b", + sugarFree: true, + }), + theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", { + primary: "#d07090", + secondary: "#f8d0e0", + tertiary: "#ffb3c6", + sugarFree: true, + }), +]; + +export const THEME_CATEGORIES: Array<{ id: ThemeCategory; label: string }> = [ + { id: "vocaloid", label: "Vocaloid & Pink" }, + { id: "flavour", label: "Flavours" }, + { id: "sugarfree", label: "Sugarfree" }, +]; + +export function getThemeById(id: string): AppTheme { + return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0]; +} + +export function readStoredThemeId(): string { + if (typeof window === "undefined") return DEFAULT_THEME_ID; + + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (stored && APP_THEMES.some((entry) => entry.id === stored)) { + return stored; + } + + const legacy = localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY); + if (legacy && LEGACY_ACCENT_MAP[legacy]) { + return LEGACY_ACCENT_MAP[legacy]; + } + + return DEFAULT_THEME_ID; +} diff --git a/src/index.css b/src/index.css index 651e2d6..b5a2008 100644 --- a/src/index.css +++ b/src/index.css @@ -17,7 +17,7 @@ :root { color-scheme: light; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; - background: #fff8fb; + background: #f8fbff; } * { @@ -26,15 +26,15 @@ html { min-width: 320px; - background: #fff8fb; + background: #f8fbff; } body { min-width: 320px; min-height: 100vh; margin: 0; - background: #fff8fb; - color: #21191d; + background: #f8fbff; + color: #1f252a; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; @@ -135,9 +135,9 @@ textarea:focus-visible { .material-drawer { @apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex; - background: var(--surface-container); - border-color: var(--outline-variant); - border-radius: 28px; + background: color-mix(in srgb, var(--surface-container-lowest) 84%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + border-radius: 32px; box-shadow: var(--elevation-1); } @@ -169,9 +169,9 @@ textarea:focus-visible { .top-app-bar { @apply border p-4 sm:p-5; - background: color-mix(in srgb, var(--surface-container-low) 84%, white); - border-color: var(--outline-variant); - border-radius: 28px; + background: color-mix(in srgb, var(--surface-container-lowest) 86%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); + border-radius: 34px; box-shadow: var(--elevation-1); } @@ -220,7 +220,7 @@ textarea:focus-visible { } .mobile-nav-bar { - @apply fixed inset-x-3 bottom-3 z-40 grid grid-cols-4 gap-1 border p-1 shadow-fridge; + @apply fixed inset-x-3 bottom-3 z-40 grid grid-cols-5 gap-1 border p-1 shadow-fridge; background: color-mix(in srgb, var(--surface-container-high) 92%, white); border-color: var(--outline-variant); border-radius: 28px; @@ -251,16 +251,313 @@ textarea:focus-visible { .glass-panel { @apply rounded-lg border shadow-fridge; - background: var(--surface-container); - border-color: var(--outline-variant); - border-radius: 24px; + background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); + border-radius: 34px; } .can-panel { @apply rounded-lg border shadow-can; background: linear-gradient(135deg, var(--primary-container), var(--surface-container-high) 58%, var(--tertiary-container)); border-color: color-mix(in srgb, var(--primary) 20%, var(--outline-variant)); - border-radius: 28px; + border-radius: 36px; + } + + .today-panel { + background: + radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--primary-container) 82%, white) 0 22%, transparent 44%), + linear-gradient(145deg, var(--surface-container-lowest), var(--surface-container) 54%, var(--tertiary-container)); + } + + .oura-hero { + background: + radial-gradient(circle at 14% 28%, color-mix(in srgb, var(--primary-container) 72%, white) 0 18%, transparent 42%), + radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--secondary-container) 70%, white) 0 18%, transparent 38%), + var(--surface-container-lowest); + } + + .oura-ring { + @apply grid h-32 w-32 shrink-0 place-items-center p-2 shadow-can; + background: conic-gradient(var(--primary) var(--progress), var(--surface-container-high) 0); + border-radius: 999px; + } + + .oura-ring > div { + @apply flex h-full w-full flex-col items-center justify-center; + background: var(--surface-container-lowest); + border-radius: inherit; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--outline-variant) 72%, transparent); + } + + .oura-ring span { + @apply text-4xl font-semibold leading-none; + color: var(--text); + } + + .oura-ring small { + @apply mt-1 text-xs font-semibold uppercase; + color: var(--muted); + } + + .wellness-pill { + @apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm; + background: color-mix(in srgb, var(--surface-container-high) 78%, white); + border-color: var(--outline-variant); + } + + .wellness-pill span { + color: var(--muted); + } + + .wellness-pill strong { + color: var(--text); + } + + .suggestion-chip { + @apply min-h-11 rounded-full border px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed; + background: color-mix(in srgb, var(--surface-container-high) 72%, white); + border-color: var(--outline-variant); + color: var(--text); + } + + .suggestion-chip:hover:not(:disabled) { + background: var(--primary-container); + color: var(--on-primary-container); + box-shadow: var(--elevation-1); + } + + .coach-gemini-shell { + @apply grid min-h-[760px] gap-4; + } + + .coach-locked-shell { + @apply place-items-center; + grid-template-columns: 1fr !important; + } + + .coach-chat-sidebar { + @apply hidden min-h-[760px] flex-col border p-3 xl:flex; + background: color-mix(in srgb, var(--surface-container-lowest) 80%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent); + border-radius: 34px; + box-shadow: var(--elevation-1); + } + + .coach-sidebar-brand { + @apply flex items-center gap-3 px-2 py-2; + } + + .coach-brand-orb { + @apply grid h-16 w-16 place-items-center rounded-full shadow-sm; + background: radial-gradient(circle at 30% 26%, #ffffff 0 12%, #d7ecff 13% 48%, #7fb6df 72%, #5d8fb3 100%); + color: #163247; + } + + .coach-brand-orb-small { + @apply h-10 w-10; + } + + .coach-new-chat { + @apply mt-4 inline-flex min-h-12 items-center gap-3 rounded-full px-4 text-sm font-semibold transition disabled:cursor-not-allowed; + background: var(--surface-container-high); + color: var(--text); + } + + .coach-new-chat:hover:not(:disabled) { + background: var(--primary-container); + } + + .coach-chat-list { + @apply mt-4 grid flex-1 content-start gap-1 overflow-y-auto; + } + + .coach-chat-row { + @apply grid grid-cols-[1fr_auto] items-center rounded-3xl transition; + color: var(--text); + } + + .coach-chat-row > button:first-child { + @apply grid min-w-0 gap-1 px-4 py-3 text-left; + } + + .coach-chat-row > button:last-child { + @apply mr-2 grid h-8 w-8 place-items-center rounded-full opacity-0 transition; + color: var(--muted); + } + + .coach-chat-row:hover > button:last-child, + .coach-chat-row-active > button:last-child { + opacity: 1; + } + + .coach-chat-row span { + @apply truncate text-sm font-medium; + } + + .coach-chat-row small { + @apply text-xs; + color: var(--muted); + } + + .coach-chat-row-active, + .coach-chat-row:hover { + background: var(--surface-container-high); + } + + .coach-context-card { + @apply mt-4 rounded-[28px] border p-4; + background: color-mix(in srgb, var(--surface-container-low) 76%, white); + border-color: var(--outline-variant); + } + + .coach-stage { + @apply relative flex min-h-[760px] flex-col overflow-hidden border; + background: + radial-gradient(circle at 50% 64%, rgba(192, 225, 250, 0.78) 0 18%, transparent 42%), + linear-gradient(180deg, rgba(255,255,255,0.94), rgba(248,251,255,0.96)); + border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent); + border-radius: 38px; + box-shadow: var(--elevation-1); + } + + .coach-stage-topbar { + @apply flex items-center justify-between px-5 py-4 text-xs font-semibold; + color: var(--muted); + } + + .coach-stage-messages { + @apply flex-1 space-y-5 overflow-y-auto px-4 pb-36 pt-8 sm:px-8 lg:px-16; + } + + .coach-empty-state { + @apply mx-auto flex min-h-[520px] max-w-3xl flex-col items-center justify-center text-center; + } + + .coach-empty-state h2 { + @apply mt-6 text-5xl font-normal tracking-tight sm:text-6xl; + color: var(--text); + } + + .coach-empty-state p { + @apply mt-4 max-w-xl text-base leading-7; + color: var(--muted); + } + + .coach-prompt-grid { + @apply mt-7 grid gap-2 sm:grid-cols-3; + } + + .coach-message { + @apply flex; + } + + .coach-message-user { + @apply justify-end; + } + + .coach-message-assistant { + @apply justify-start; + } + + .coach-message-bubble { + @apply max-w-[840px] rounded-[34px] border px-5 py-4 shadow-sm; + background: rgba(255, 255, 255, 0.82); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + backdrop-filter: blur(18px); + } + + .coach-message-user .coach-message-bubble { + background: #ececec; + border-color: transparent; + } + + .coach-message-user .coach-message-bubble, + .coach-message-user .coach-message-bubble * { + color: var(--text) !important; + } + + .thinking-slider { + @apply w-full overflow-hidden rounded-full border px-3 py-2 text-sm font-medium; + background: rgba(255, 255, 255, 0.72); + border-color: transparent; + color: var(--muted); + } + + .thinking-slider-active { + border-color: color-mix(in srgb, var(--primary) 42%, var(--outline-variant)); + } + + .thinking-slider-track { + @apply block overflow-hidden whitespace-nowrap; + mask-image: linear-gradient(90deg, transparent, black 18%, black 82%, transparent); + } + + .thinking-slider-track span { + @apply inline-block; + padding-left: 100%; + animation: thinking-slide 3.2s linear infinite; + } + + .thinking-trace { + @apply mt-2 max-h-56 overflow-auto rounded-3xl border p-4 text-xs leading-5 whitespace-pre-wrap; + background: rgba(255, 255, 255, 0.72); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + color: var(--muted); + } + + .coach-composer { + @apply absolute inset-x-4 bottom-4 z-10 mx-auto flex max-w-4xl items-center gap-3 rounded-full border p-3 sm:bottom-7; + background: rgba(255, 255, 255, 0.94); + border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent); + box-shadow: 0 18px 50px rgba(74, 102, 122, 0.18); + backdrop-filter: blur(18px); + } + + .coach-input { + @apply min-h-12 flex-1 rounded-full border-0 px-2 text-lg shadow-none transition; + background: transparent; + color: var(--text); + } + + .coach-input:focus { + box-shadow: none; + } + + .composer-icon-button, + .composer-send-button { + @apply grid h-12 w-12 shrink-0 place-items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-45; + color: var(--text); + } + + .composer-icon-button:hover { + background: var(--surface-container-high); + } + + .composer-send-button { + background: #97cbf5; + color: #10283a; + } + + .composer-send-button:hover:not(:disabled) { + filter: brightness(0.98); + } + + .composer-stop-button { + background: var(--surface-container-high); + color: var(--text); + } + + .coach-unlock-card { + @apply mt-8 flex w-full max-w-xl flex-col gap-3 rounded-full border p-3 sm:flex-row; + background: rgba(255, 255, 255, 0.94); + border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent); + box-shadow: var(--elevation-2); + } + + @media (min-width: 1280px) { + .coach-gemini-shell { + grid-template-columns: 340px minmax(0, 1fr); + } } .can-emblem { @@ -402,123 +699,139 @@ textarea:focus-visible { @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm; } - .accent-picker { - @apply inline-flex min-h-11 items-center gap-1 rounded-md border p-1 shadow-sm; + .theme-indicator { + @apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-semibold transition; background: var(--surface-container-high); border-color: var(--outline-variant); - border-radius: 999px; + color: var(--text); } - .accent-picker button { - @apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition; + .theme-indicator:hover { + background: var(--primary-container); + color: var(--on-primary-container); + } + + .theme-indicator-swatch { + @apply h-4 w-4 rounded-full border border-white shadow-sm; + } + + .theme-indicator-label { + @apply max-w-[9rem] truncate; + } + + .settings-section { + @apply grid gap-4; + } + + .settings-tabs { + @apply inline-flex flex-wrap gap-1 rounded-full border p-1; + background: var(--surface-container-high); + border-color: var(--outline-variant); + } + + .settings-tabs button { + @apply rounded-full px-4 py-2 text-sm font-semibold transition; color: var(--muted); - border-radius: 999px; } - .accent-picker button:hover, - .accent-picker-active { + .settings-tabs button:hover, + .settings-tab-active { background: var(--primary-container); color: var(--on-primary-container) !important; } - .accent-swatch { - @apply h-3 w-3 rounded-full border border-white shadow-sm; + .theme-preview-strip { + @apply flex flex-wrap gap-2; } - .accent-swatch-blue { - background: #d8e7ff; + .theme-preview-chip { + color: var(--text); } - .accent-swatch-pink { - background: #ffd8e7; + .theme-picker-grid { + @apply grid gap-3 sm:grid-cols-2 lg:grid-cols-3; + } + + .theme-tile { + @apply flex min-h-[4.5rem] items-center gap-3 rounded-xl border px-3 py-3 text-left transition; + background: var(--surface-container); + border-color: var(--outline-variant); + color: var(--text); + } + + .theme-tile:hover { + box-shadow: var(--elevation-1); + border-color: var(--outline); + } + + .theme-tile-active { + border-color: var(--primary); + box-shadow: var(--elevation-1); + background: var(--primary-container); + color: var(--on-primary-container); + } + + .theme-tile-swatch { + @apply h-10 w-10 shrink-0 rounded-full border border-white shadow-sm; + } + + .theme-tile-label { + @apply text-sm font-semibold leading-5; } } .app-shell { - --primary: #9c4168; + --primary: #4b86ad; --on-primary: #ffffff; - --primary-container: #ffd8e7; - --on-primary-container: #3e001d; - --secondary: #526354; + --primary-container: #dff2ff; + --on-primary-container: #10283a; + --secondary: #647887; --on-secondary: #ffffff; - --secondary-container: #d7efe2; - --on-secondary-container: #102116; - --tertiary: #765930; + --secondary-container: #ecf3f7; + --on-secondary-container: #1f2d35; + --tertiary: #9b7b51; --on-tertiary: #ffffff; - --tertiary-container: #ffddb2; - --on-tertiary-container: #2b1700; + --tertiary-container: #f4eadb; + --on-tertiary-container: #332313; --error: #ba1a1a; --on-error: #ffffff; --error-container: #ffdad6; --on-error-container: #410002; - --bg: #fff8fb; - --surface: #fff8fb; + --bg: #f8fbff; + --surface: #f8fbff; --surface-container-lowest: #ffffff; - --surface-container-low: #fff0f5; - --surface-container: #faedf3; - --surface-container-high: #f4e7ee; + --surface-container-low: #f1f7fb; + --surface-container: #edf3f7; + --surface-container-high: #e7eef3; --panel: var(--surface-container); --panel-strong: var(--surface-container-lowest); - --outline: #85737a; - --outline-variant: #d8c2ca; - --text: #21191d; - --muted: #655b60; - --subtle: #83757c; + --outline: #7c8992; + --outline-variant: #dce5ea; + --text: #1f252a; + --muted: #68747c; + --subtle: #839099; --accent: var(--primary-container); --accent-soft: var(--surface-container-low); --accent-strong: var(--primary); - --accent-warm: #d7efe2; - --chart-primary: #b85d84; - --chart-secondary: #5f7f6f; - --chart-tertiary: #906d1d; + --accent-warm: #eef4f7; + --chart-primary: #4b86ad; + --chart-secondary: #6f8f7c; + --chart-tertiary: #9b7b51; --chart-error: #ba1a1a; - --chart-grid: rgba(132, 115, 122, 0.24); - --elevation-1: 0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08); - --elevation-2: 0 2px 6px rgba(69, 54, 62, 0.14), 0 8px 18px rgba(69, 54, 62, 0.08); + --chart-grid: rgba(124, 137, 146, 0.18); + --elevation-1: 0 12px 34px rgba(69, 91, 108, 0.08), 0 1px 2px rgba(69, 91, 108, 0.06); + --elevation-2: 0 18px 44px rgba(69, 91, 108, 0.12), 0 2px 8px rgba(69, 91, 108, 0.08); min-height: 100vh; background: var(--bg) !important; color: var(--text) !important; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; } -.app-shell[data-accent="blue"] { - --primary: #49617d; - --on-primary: #ffffff; - --primary-container: #d8e7ff; - --on-primary-container: #061d35; - --secondary: #5f5d72; - --secondary-container: #e5dff9; - --on-secondary-container: #1c1a2c; - --tertiary: #765930; - --tertiary-container: #ffddb2; - --bg: #f8fbff; - --surface: #f8fbff; - --surface-container-lowest: #ffffff; - --surface-container-low: #eef4fb; - --surface-container: #e8eef6; - --surface-container-high: #e1e9f2; - --outline: #72777f; - --outline-variant: #c2c7cf; - --text: #191c20; - --muted: #5d6269; - --subtle: #777c83; - --accent-warm: #ffd8e7; - --chart-primary: #49617d; - --chart-secondary: #9c4168; - --chart-tertiary: #906d1d; - --chart-error: #ba1a1a; - --chart-grid: rgba(114, 119, 127, 0.24); -} - -.app-shell[data-accent="pink"] { - --primary: #9c4168; - --on-primary: #ffffff; - --primary-container: #ffd8e7; - --on-primary-container: #3e001d; -} - .backdrop-wash { - background: linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%); + background: + radial-gradient(circle at 70% 35%, var(--primary-container) 0 18%, transparent 42%), + radial-gradient(circle at 12% 12%, var(--surface-container-lowest) 0 18%, transparent 38%), + linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%); } .backdrop-grid { @@ -526,7 +839,7 @@ textarea:focus-visible { linear-gradient(var(--chart-grid) 1px, transparent 1px), linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px); background-size: 48px 48px; - opacity: 0.22; + opacity: 0; } .backdrop-rail { @@ -675,3 +988,9 @@ textarea:focus-visible { .app-shell .modal-panel { color: var(--text); } + +@keyframes thinking-slide { + to { + transform: translateX(-100%); + } +} diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index ef7ed74..30cd0c0 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -8,6 +8,7 @@ export const appwriteConfig = { projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", + chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats", oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL), oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), }; diff --git a/src/lib/encryptedChats.ts b/src/lib/encryptedChats.ts new file mode 100644 index 0000000..a12f5ad --- /dev/null +++ b/src/lib/encryptedChats.ts @@ -0,0 +1,178 @@ +import type { Models } from "appwrite"; +import type { CoachChat } from "../types"; +import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite"; + +const CHAT_CRYPTO_VERSION = 1; +const KEY_ITERATIONS = 210_000; + +type EncryptedChatRow = Models.Row & { + userId: string; + encryptedTitle: string; + encryptedMessages: string; + titleIv: string; + messagesIv: string; + salt: string; + version: number; + updatedAt: string; +}; + +type EncryptedValue = { + ciphertext: string; + iv: string; +}; + +export async function listEncryptedChats(userId: string, passphrase: string) { + const response = await tablesDB.listRows({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)], + }); + + const chats: CoachChat[] = []; + for (const row of response.rows) { + chats.push(await decryptChatRow(row, passphrase)); + } + + return chats; +} + +export async function createEncryptedChat(userId: string, passphrase: string, chat: CoachChat) { + const row = await tablesDB.createRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: ID.custom(chat.id), + data: await toEncryptedRowData(userId, passphrase, chat), + permissions: userRowPermissions(userId), + }); + + return decryptChatRow(row, passphrase); +} + +export async function updateEncryptedChat(userId: string, passphrase: string, chat: CoachChat) { + const row = await tablesDB.updateRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: chat.id, + data: await toEncryptedRowData(userId, passphrase, chat), + permissions: userRowPermissions(userId), + }); + + return decryptChatRow(row, passphrase); +} + +export async function deleteEncryptedChat(id: string) { + await tablesDB.deleteRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: id, + }); +} + +export function chatStorageErrorMessage(error: unknown) { + if (error instanceof Error) { + if (/decrypt|operation failed|unable to decrypt/i.test(error.message)) { + return "Encrypted chat key could not unlock saved chats."; + } + if (/not found|404/i.test(error.message)) { + return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found.`; + } + if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) { + return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`; + } + return error.message; + } + return "Encrypted chat storage failed."; +} + +async function toEncryptedRowData(userId: string, passphrase: string, chat: CoachChat) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await deriveKey(passphrase, userId, salt); + const title = await encryptText(chat.title, key); + const messages = await encryptText(JSON.stringify(chat.messages), key); + + return { + userId, + encryptedTitle: title.ciphertext, + encryptedMessages: messages.ciphertext, + titleIv: title.iv, + messagesIv: messages.iv, + salt: bytesToBase64(salt), + version: CHAT_CRYPTO_VERSION, + updatedAt: chat.updatedAt, + }; +} + +async function decryptChatRow(row: EncryptedChatRow, passphrase: string): Promise { + const salt = base64ToBytes(row.salt); + const key = await deriveKey(passphrase, row.userId, salt); + const title = await decryptText({ ciphertext: row.encryptedTitle, iv: row.titleIv }, key); + const messages = JSON.parse(await decryptText({ ciphertext: row.encryptedMessages, iv: row.messagesIv }, key)); + + return { + id: row.$id, + userId: row.userId, + title, + messages, + createdAt: row.$createdAt, + updatedAt: row.updatedAt || row.$updatedAt, + }; +} + +async function deriveKey(passphrase: string, userId: string, salt: Uint8Array) { + const material = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(`${userId}:${passphrase}`), + "PBKDF2", + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: bytesToArrayBuffer(salt), iterations: KEY_ITERATIONS, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function bytesToArrayBuffer(bytes: Uint8Array) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +async function encryptText(value: string, key: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value)); + return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) }; +} + +async function decryptText(value: EncryptedValue, key: CryptoKey) { + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(value.iv) }, + key, + base64ToBytes(value.ciphertext), + ); + return new TextDecoder().decode(decrypted); +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function userRowPermissions(userId: string) { + const role = Role.user(userId); + return [Permission.read(role), Permission.update(role), Permission.delete(role)]; +} diff --git a/src/lib/themeTokens.ts b/src/lib/themeTokens.ts new file mode 100644 index 0000000..6c69468 --- /dev/null +++ b/src/lib/themeTokens.ts @@ -0,0 +1,248 @@ +import type { CSSProperties } from "react"; + +export type ThemeTokens = { + primary: string; + onPrimary: string; + primaryContainer: string; + onPrimaryContainer: string; + secondary: string; + onSecondary: string; + secondaryContainer: string; + onSecondaryContainer: string; + tertiary: string; + onTertiary: string; + tertiaryContainer: string; + onTertiaryContainer: string; + error: string; + onError: string; + errorContainer: string; + onErrorContainer: string; + bg: string; + surface: string; + surfaceContainerLowest: string; + surfaceContainerLow: string; + surfaceContainer: string; + surfaceContainerHigh: string; + outline: string; + outlineVariant: string; + text: string; + muted: string; + subtle: string; + accentWarm: string; + chartPrimary: string; + chartSecondary: string; + chartTertiary: string; + chartError: string; + chartGrid: string; + elevation1: string; + elevation2: string; +}; + +export type ThemeSeed = { + primary: string; + secondary?: string; + tertiary?: string; + sugarFree?: boolean; + dark?: boolean; + tokens?: Partial; +}; + +type Rgb = { r: number; g: number; b: number }; + +function clamp(value: number, min = 0, max = 255) { + return Math.min(max, Math.max(min, value)); +} + +function parseHex(hex: string): Rgb { + const normalized = hex.replace("#", ""); + const value = + normalized.length === 3 + ? normalized + .split("") + .map((part) => part + part) + .join("") + : normalized; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function toHex({ r, g, b }: Rgb) { + return `#${[r, g, b] + .map((channel) => clamp(Math.round(channel)).toString(16).padStart(2, "0")) + .join("")}`; +} + +function mix(a: string, b: string, weight: number) { + const left = parseHex(a); + const right = parseHex(b); + return toHex({ + r: left.r * (1 - weight) + right.r * weight, + g: left.g * (1 - weight) + right.g * weight, + b: left.b * (1 - weight) + right.b * weight, + }); +} + +function luminance(hex: string) { + const { r, g, b } = parseHex(hex); + const channels = [r, g, b].map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function onColor(background: string) { + return luminance(background) > 0.58 ? "#1f252a" : "#ffffff"; +} + +function containerColor(primary: string) { + return mix(primary, "#ffffff", 0.82); +} + +function surfaceStack(primary: string, sugarFree: boolean, dark: boolean) { + if (dark) { + return { + bg: mix(primary, "#000000", 0.88), + surface: mix(primary, "#000000", 0.86), + surfaceContainerLowest: mix(primary, "#000000", 0.78), + surfaceContainerLow: mix(primary, "#000000", 0.82), + surfaceContainer: mix(primary, "#000000", 0.84), + surfaceContainerHigh: mix(primary, "#000000", 0.8), + outline: mix(primary, "#ffffff", 0.35), + outlineVariant: mix(primary, "#ffffff", 0.18), + text: "#f5f7fa", + muted: mix("#ffffff", primary, 0.45), + subtle: mix("#ffffff", primary, 0.55), + accentWarm: mix(primary, "#ffffff", 0.12), + }; + } + + const tint = sugarFree ? mix(primary, "#e8ecf4", 0.72) : mix(primary, "#ffffff", 0.94); + return { + bg: tint, + surface: tint, + surfaceContainerLowest: "#ffffff", + surfaceContainerLow: mix(primary, "#ffffff", sugarFree ? 0.9 : 0.92), + surfaceContainer: mix(primary, "#ffffff", sugarFree ? 0.86 : 0.88), + surfaceContainerHigh: mix(primary, "#ffffff", sugarFree ? 0.8 : 0.82), + outline: mix(primary, "#68747c", 0.55), + outlineVariant: mix(primary, "#dce5ea", 0.72), + text: "#1f252a", + muted: mix("#68747c", primary, 0.25), + subtle: mix("#839099", primary, 0.2), + accentWarm: mix(primary, "#ffffff", sugarFree ? 0.78 : 0.84), + }; +} + +function chartSecondary(primary: string) { + return mix(primary, "#9c4168", 0.45); +} + +function chartTertiary(primary: string) { + return mix(primary, "#906d1d", 0.35); +} + +function rgbaFromHex(hex: string, alpha: number) { + const { r, g, b } = parseHex(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export function buildThemeTokens(seed: ThemeSeed): ThemeTokens { + const { primary, sugarFree = false, dark = false } = seed; + const secondary = seed.secondary ?? mix(primary, "#647887", 0.35); + const tertiary = seed.tertiary ?? mix(primary, "#9b7b51", 0.3); + const surfaces = surfaceStack(primary, sugarFree, dark); + const primaryContainer = containerColor(primary); + const secondaryContainer = containerColor(secondary); + const tertiaryContainer = containerColor(tertiary); + const error = "#ba1a1a"; + const errorContainer = "#ffdad6"; + + const tokens: ThemeTokens = { + primary, + onPrimary: onColor(primary), + primaryContainer, + onPrimaryContainer: onColor(primaryContainer), + secondary, + onSecondary: onColor(secondary), + secondaryContainer, + onSecondaryContainer: onColor(secondaryContainer), + tertiary, + onTertiary: onColor(tertiary), + tertiaryContainer, + onTertiaryContainer: onColor(tertiaryContainer), + error, + onError: "#ffffff", + errorContainer, + onErrorContainer: "#410002", + bg: surfaces.bg, + surface: surfaces.surface, + surfaceContainerLowest: surfaces.surfaceContainerLowest, + surfaceContainerLow: surfaces.surfaceContainerLow, + surfaceContainer: surfaces.surfaceContainer, + surfaceContainerHigh: surfaces.surfaceContainerHigh, + outline: surfaces.outline, + outlineVariant: surfaces.outlineVariant, + text: surfaces.text, + muted: surfaces.muted, + subtle: surfaces.subtle, + accentWarm: surfaces.accentWarm, + chartPrimary: primary, + chartSecondary: chartSecondary(primary), + chartTertiary: chartTertiary(primary), + chartError: error, + chartGrid: rgbaFromHex(surfaces.outline, 0.24), + elevation1: `0 12px 34px ${rgbaFromHex(primary, 0.08)}, 0 1px 2px ${rgbaFromHex(primary, 0.06)}`, + elevation2: `0 18px 44px ${rgbaFromHex(primary, 0.12)}, 0 2px 8px ${rgbaFromHex(primary, 0.08)}`, + }; + + return { ...tokens, ...seed.tokens }; +} + +export function themeTokensToStyle(tokens: ThemeTokens): CSSProperties { + return { + "--primary": tokens.primary, + "--on-primary": tokens.onPrimary, + "--primary-container": tokens.primaryContainer, + "--on-primary-container": tokens.onPrimaryContainer, + "--secondary": tokens.secondary, + "--on-secondary": tokens.onSecondary, + "--secondary-container": tokens.secondaryContainer, + "--on-secondary-container": tokens.onSecondaryContainer, + "--tertiary": tokens.tertiary, + "--on-tertiary": tokens.onTertiary, + "--tertiary-container": tokens.tertiaryContainer, + "--on-tertiary-container": tokens.onTertiaryContainer, + "--error": tokens.error, + "--on-error": tokens.onError, + "--error-container": tokens.errorContainer, + "--on-error-container": tokens.onErrorContainer, + "--bg": tokens.bg, + "--surface": tokens.surface, + "--surface-container-lowest": tokens.surfaceContainerLowest, + "--surface-container-low": tokens.surfaceContainerLow, + "--surface-container": tokens.surfaceContainer, + "--surface-container-high": tokens.surfaceContainerHigh, + "--panel": tokens.surfaceContainer, + "--panel-strong": tokens.surfaceContainerLowest, + "--outline": tokens.outline, + "--outline-variant": tokens.outlineVariant, + "--text": tokens.text, + "--muted": tokens.muted, + "--subtle": tokens.subtle, + "--accent": tokens.primaryContainer, + "--accent-soft": tokens.surfaceContainerLow, + "--accent-strong": tokens.primary, + "--accent-warm": tokens.accentWarm, + "--chart-primary": tokens.chartPrimary, + "--chart-secondary": tokens.chartSecondary, + "--chart-tertiary": tokens.chartTertiary, + "--chart-error": tokens.chartError, + "--chart-grid": tokens.chartGrid, + "--elevation-1": tokens.elevation1, + "--elevation-2": tokens.elevation2, + } as CSSProperties; +} diff --git a/src/types.ts b/src/types.ts index 4bc3f58..a52d322 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,3 +54,23 @@ export type ImportPreview = { fileName: string; rows: ImportPreviewRow[]; }; + +export type ChatRole = "user" | "assistant"; + +export type CoachMessage = { + id: string; + role: ChatRole; + content: string; + thinking?: string; + pending?: boolean; + stopped?: boolean; +}; + +export type CoachChat = { + id: string; + userId: string; + title: string; + messages: CoachMessage[]; + createdAt: string; + updatedAt: string; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 419d521..b6746b5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,8 +5,10 @@ interface ImportMetaEnv { readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string; + readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; + readonly VITE_OLLAMA_PROXY_URL?: string; } interface ImportMeta { diff --git a/vite.config.ts b/vite.config.ts index 83a6d9d..e45d38c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,18 +1,44 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; -export default defineConfig({ - plugins: [react()], - build: { - chunkSizeWarningLimit: 700, - rollupOptions: { - output: { - manualChunks: { - charts: ["recharts"], - motion: ["framer-motion"], - icons: ["lucide-react"], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const ollamaProxy = { + target: "https://ollama.com", + changeOrigin: true, + rewrite: () => "/api/chat", + configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) { + proxy.on("proxyReq", (proxyReq) => { + if (env.OLLAMA_API_KEY) { + proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`); + } + }); + }, + }; + + return { + plugins: [react()], + server: { + proxy: { + "/api/ollama-chat": ollamaProxy, + }, + }, + preview: { + proxy: { + "/api/ollama-chat": ollamaProxy, + }, + }, + build: { + chunkSizeWarningLimit: 700, + rollupOptions: { + output: { + manualChunks: { + charts: ["recharts"], + motion: ["framer-motion"], + icons: ["lucide-react"], + }, }, }, }, - }, + }; });