From 08372febfe8d672391d46fa7f4a41f9a0fd941cf 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 +- package.json | 3 +- scripts/setup-appwrite.mjs | 100 +--- src/App.tsx | 958 ++++++++++++++++++++++++++++++++----- src/data/themes.ts | 200 ++++---- src/index.css | 660 ++++++++++++++++++++++--- src/lib/appwrite.ts | 1 + src/lib/encryptedChats.ts | 178 +++++++ src/types.ts | 20 + src/vite-env.d.ts | 2 + vite.config.ts | 50 +- 12 files changed, 1845 insertions(+), 388 deletions(-) create mode 100644 src/lib/encryptedChats.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/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 index 036e48c..cc11891 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -1,7 +1,6 @@ /* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; -import { URL } from "node:url"; const env = loadEnvFiles([".env", ".env.local"]); @@ -10,11 +9,7 @@ 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 barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products"); const apiKey = readEnv("APPWRITE_API_KEY", ""); -const verifiedBarcodeProducts = JSON.parse( - readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"), -); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); @@ -49,49 +44,16 @@ await ensureTable({ name: "Coach chats", columns: [ { kind: "string", key: "userId", size: 64, required: true }, - { kind: "string", key: "title", size: 512, required: true }, - { kind: "longtext", key: "messages", 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] }], }); -await retireLegacyChatColumns(chatTableId, [ - "encryptedTitle", - "encryptedMessages", - "titleIv", - "messagesIv", - "salt", - "version", -]); -await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]); -await ensureTable({ - tableId: barcodeTableId, - name: "Barcode products", - // Schema notes: - // - scope="verified" rows are seeded by this admin script and readable by signed-in users. - // - scope="user" rows are created by the browser SDK with per-user row permissions. - columns: [ - { kind: "string", key: "scope", size: 16, required: true }, - { kind: "string", key: "ownerUserId", size: 64, required: false }, - { kind: "string", key: "barcode", size: 32, required: true }, - { kind: "string", key: "flavourName", size: 128, required: true }, - { kind: "integer", key: "sizeMl", required: true }, - { kind: "float", key: "pricePerCan", required: true }, - { kind: "boolean", key: "sugarFree", required: true }, - { kind: "float", key: "caffeineMgPerCan", required: false }, - { kind: "string", key: "verifiedBy", size: 512, required: false }, - { kind: "string", key: "sourceName", size: 512, required: false }, - { kind: "string", key: "sourceUrl", size: 2048, required: false }, - { kind: "string", key: "variant", size: 64, required: false }, - { kind: "string", key: "notes", size: 2000, required: false }, - ], - indexes: [ - { key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] }, - { key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] }, - { key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] }, - ], -}); -await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts); console.log("Appwrite database and tables ready."); @@ -160,19 +122,6 @@ async function ensureColumn(tableId, column) { console.log(`Column ${tableId}.${column.key} created.`); } -async function retireLegacyChatColumns(tableId, keys) { - for (const key of keys) { - const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); - if (existing.status === 404) { - console.log(`Legacy column ${tableId}.${key} already removed.`); - continue; - } - - await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]); - console.log(`Legacy column ${tableId}.${key} removed.`); - } -} - async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { @@ -189,43 +138,6 @@ async function ensureIndex(tableId, index) { console.log(`Index ${tableId}.${index.key} created.`); } -async function seedVerifiedBarcodeProducts(tableId, products) { - for (const [barcode, product] of Object.entries(products)) { - const rowId = `verified_${barcode}`; - const data = { - scope: "verified", - ownerUserId: "", - barcode, - flavourName: product.flavourName, - sizeMl: product.sizeMl, - pricePerCan: product.pricePerCan, - sugarFree: Boolean(product.sugarFree), - caffeineMgPerCan: product.caffeineMgPerCan, - verifiedBy: product.verifiedBy ?? "", - sourceName: product.sourceName ?? "", - sourceUrl: product.sourceUrl ?? "", - variant: product.variant ?? "", - notes: product.notes ?? "", - }; - const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`; - const existing = await request("GET", path, undefined, [200, 404]); - - if (existing.status === 404) { - await request( - "POST", - `/tablesdb/${databaseId}/tables/${tableId}/rows`, - { rowId, data, permissions: ['read("users")'] }, - [201], - ); - console.log(`Verified barcode ${barcode} seeded.`); - continue; - } - - await request("PUT", path, { data, permissions: ['read("users")'] }, [200]); - console.log(`Verified barcode ${barcode} updated.`); - } -} - async function waitForColumns(tableId, keys) { const pending = new Set(keys); for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { diff --git a/src/App.tsx b/src/App.tsx index 0afdf62..502c1e2 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,16 +152,14 @@ 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: "blue", label: "Baby blue" }, - { id: "pink", label: "Pastel pink" }, + { id: "coach", label: "Coach", icon: MessageCircle }, + { id: "settings", label: "Settings", icon: Settings2 }, ]; 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(""); @@ -160,8 +182,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); @@ -485,17 +507,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)} /> @@ -572,8 +597,10 @@ function App() { recentEntries={recentEntries} chartData={chartData} flavourData={flavourData} + user={user} onQuickAdd={(item) => void quickAdd(item)} onAdd={openNewEntry} + onOpenCoach={() => setActiveView("coach")} onOpenLogbook={() => setActiveView("logbook")} /> )} @@ -607,16 +634,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} /> )} @@ -666,9 +703,17 @@ function ShellBackdrop() { ); } -function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; themeAccent: AccentTheme }) { +function LoadingScreen({ + setupStatus, + shellStyle, + themeId, +}: { + setupStatus: SetupStatus; + shellStyle: CSSProperties; + themeId: string; +}) { return ( -
+
@@ -684,20 +729,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; @@ -717,7 +762,7 @@ function AuthView({ } return ( -
+
@@ -741,9 +786,6 @@ function AuthView({ {setupStatus.message}
)} -
- -
@@ -824,50 +866,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 ( ); @@ -943,28 +1025,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 title = NAV_ITEMS.find((item) => item.id === activeView)?.label ?? "Overview"; @@ -985,8 +1067,14 @@ function TopBar({

{title}

-
- +
+ {user.email || "Synced user"} + +
+
+ +
+
+ + +
+
+ ); +} + +function WellnessPill({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + function TodayPanel({ dashboard, entries, @@ -1160,8 +1325,7 @@ function TodayPanel({ onAdd: () => void; }) { return ( -
-
+

Today

@@ -1361,68 +1525,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

+
+ + + + + +
+
+ + + +
@@ -2293,6 +2924,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"; @@ -2305,9 +3020,4 @@ function actionLabel(value: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function readStoredAccent(): AccentTheme { - const value = localStorage.getItem(ACCENT_STORAGE_KEY); - return value === "pink" ? "pink" : "blue"; -} - export default App; diff --git a/src/data/themes.ts b/src/data/themes.ts index 4aed0d7..fda4a06 100644 --- a/src/data/themes.ts +++ b/src/data/themes.ts @@ -51,149 +51,147 @@ export const APP_THEMES: AppTheme[] = [ tertiary: "#ffd8e7", }), - theme("original", "Original", "flavour", "#282874", { - primary: "#282874", - secondary: "#efefef", - tertiary: "#d4af37", - tokens: { - chartSecondary: "#e6301f", - }, + theme("original", "Original", "flavour", "#00a7ff", { + primary: "#0077c8", + secondary: "#00a7ff", + tertiary: "#1e3264", }), - theme("zero", "Zero", "flavour", "#b1d0ee", { - primary: "#b1d0ee", - secondary: "#efefef", - tertiary: "#e6301f", + theme("zero", "Zero", "flavour", "#2a2a2a", { + primary: "#2a2a2a", + secondary: "#5c5c5c", + tertiary: "#8a8a8a", + dark: true, }), theme("summer", "Summer Edition", "flavour", "#f0e53b", { - primary: "#f2e853", - secondary: "#efefef", - tertiary: "#8a8f98", + primary: "#d4c400", + secondary: "#f0e53b", + tertiary: "#ffc247", }), - theme("cherry", "Cherry Edition", "flavour", "#d81b60", { - primary: "#d81b60", - secondary: "#efefef", - tertiary: "#b50045", + 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", "#bf1431", { - primary: "#bf1431", - secondary: "#f6c300", - tertiary: "#f3911b", + theme("apple", "Apple Edition", "flavour", "#78be20", { + primary: "#5a9a12", + secondary: "#78be20", + tertiary: "#a8d84a", }), - theme("peach", "Peach Edition", "flavour", "#e24585", { - primary: "#e24585", - secondary: "#efefef", - tertiary: "#d6417e", + theme("peach", "Peach Edition", "flavour", "#ff9b63", { + primary: "#e87a3a", + secondary: "#ff9b63", + tertiary: "#ffc9a3", }), theme("ice", "Ice Edition", "flavour", "#49adbe", { - primary: "#53b2c2", - secondary: "#efefef", - tertiary: "#49adbe", + primary: "#2d8a9a", + secondary: "#49adbe", + tertiary: "#7ce7ff", }), - theme("blue-edition", "Blue Edition", "flavour", "#0085c8", { - primary: "#0085c8", - secondary: "#efefef", - tertiary: "#ff73d1", + theme("blue-edition", "Blue Edition", "flavour", "#496dff", { + primary: "#3a52cc", + secondary: "#496dff", + tertiary: "#9c73ff", }), - theme("red-edition", "Red Edition", "flavour", "#e6301f", { - primary: "#e6301f", - secondary: "#efefef", - tertiary: "#78b941", + theme("red-edition", "Red Edition", "flavour", "#ff355e", { + primary: "#e02045", + secondary: "#ff355e", + tertiary: "#ff6b8a", }), - theme("tropical", "Tropical Edition", "flavour", "#ffcb04", { - primary: "#ffcb04", - secondary: "#efefef", - tertiary: "#f6c300", + theme("tropical", "Tropical Edition", "flavour", "#ffc247", { + primary: "#e0a820", + secondary: "#ffc247", + tertiary: "#ff9b63", }), - theme("coconut", "Coconut Edition", "flavour", "#0070b8", { - primary: "#0070b8", - secondary: "#efefef", - tertiary: "#8a8f98", + theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", { + primary: "#4ec4e0", + secondary: "#7ce7ff", + tertiary: "#d8f9ff", }), - theme("green-edition", "Green Edition", "flavour", "#78b941", { - primary: "#78b941", - secondary: "#efefef", - tertiary: "#f3911b", + theme("green-edition", "Green Edition", "flavour", "#b7ff4a", { + primary: "#7acc20", + secondary: "#b7ff4a", + tertiary: "#d4ff8a", }), - theme("apricot", "Apricot Edition", "flavour", "#f3911b", { - primary: "#f3911b", - secondary: "#efefef", - tertiary: "#d6417e", + theme("apricot", "Apricot Edition", "flavour", "#ff8c42", { + primary: "#e06a20", + secondary: "#ff8c42", + tertiary: "#ffb87a", }), - theme("ruby", "Ruby Edition", "flavour", "#b50045", { - primary: "#b50045", - secondary: "#efefef", - tertiary: "#a3e635", + theme("ruby", "Ruby Edition", "flavour", "#c3093b", { + primary: "#a00730", + secondary: "#c3093b", + tertiary: "#e04060", }), - theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", { - primary: "#009edf", - secondary: "#efefef", - tertiary: "#e6301f", + theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", { + primary: "#8a9bb0", + secondary: "#c8d4e0", + tertiary: "#e7eef8", sugarFree: true, }), - theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", { - primary: "#f2e853", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", { + primary: "#c4c020", + secondary: "#e8e4a0", + tertiary: "#f0e53b", sugarFree: true, }), - theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", { - primary: "#bf1431", - secondary: "#f6c300", - tertiary: "#009edf", + theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", { + primary: "#6a9a30", + secondary: "#b8d4a0", + tertiary: "#78be20", sugarFree: true, }), - theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", { - primary: "#e24585", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", { + primary: "#d08050", + secondary: "#f0d0b8", + tertiary: "#ff9b63", sugarFree: true, }), - theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", { - primary: "#53b2c2", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", { + primary: "#4a9aaa", + secondary: "#b8e0e8", + tertiary: "#49adbe", sugarFree: true, }), - theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", { - primary: "#7d62ce", - secondary: "#44c7b7", - tertiary: "#009edf", + theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", { + primary: "#9070c0", + secondary: "#d8c8f0", + tertiary: "#b898e0", sugarFree: true, }), - theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", { - primary: "#e77bab", - secondary: "#8a1f3d", - tertiary: "#009edf", + theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", { + primary: "#d06090", + secondary: "#f0c8d8", + tertiary: "#ffb7d9", sugarFree: true, }), - theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", { - primary: "#0085c8", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", { + primary: "#5060c0", + secondary: "#c8d0f8", + tertiary: "#496dff", sugarFree: true, }), - theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", { - primary: "#0070b8", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", { + primary: "#60b8d0", + secondary: "#d0f0f8", + tertiary: "#7ce7ff", sugarFree: true, }), - theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", { - primary: "#78b941", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", { + primary: "#70a830", + secondary: "#d8f0b8", + tertiary: "#b7ff4a", sugarFree: true, }), - theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", { - primary: "#b50045", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", { + primary: "#a03050", + secondary: "#f0c0c8", + tertiary: "#c3093b", sugarFree: true, }), theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", { diff --git a/src/index.css b/src/index.css index 40e3692..ef7e85f 100644 --- a/src/index.css +++ b/src/index.css @@ -4,8 +4,8 @@ :root { color-scheme: light; - font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; - background: #f5fbff; + font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; + background: #f8fbff; } * { @@ -14,16 +14,16 @@ html { min-width: 320px; - background: #f5fbff; + background: #f8fbff; } body { min-width: 320px; min-height: 100vh; margin: 0; - background: #f5fbff; - color: #193042; - font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; + 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: geometricPrecision; } @@ -62,17 +62,489 @@ textarea:focus-visible { } @layer components { + .auth-layout { + @apply mx-auto grid min-h-screen w-full max-w-6xl gap-6 px-4 py-8 lg:grid-cols-[1.05fr_0.95fr]; + align-items: center; + } + + .auth-hero { + @apply min-w-0; + } + + .auth-signal-grid { + @apply mt-6 grid gap-3 sm:grid-cols-3; + } + + .auth-panel { + @apply border p-5 shadow-fridge sm:p-6; + background: color-mix(in srgb, var(--surface-container) 88%, white); + border-color: var(--outline-variant); + border-radius: 28px; + } + + .state-chip { + @apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-semibold; + background: var(--primary-container); + border-radius: 999px; + color: var(--on-primary-container); + } + + .segmented-control { + @apply grid grid-cols-2 gap-1 border p-1; + background: var(--surface-container-high); + border-color: var(--outline-variant); + border-radius: 999px; + } + + .segmented-control button { + @apply min-h-10 px-3 text-sm font-semibold transition; + border-radius: 999px; + color: var(--muted); + } + + .segmented-control-active { + background: var(--primary-container); + color: var(--on-primary-container) !important; + } + + .app-layout { + @apply mx-auto grid w-full gap-4 px-3 pb-28 pt-3; + max-width: 1720px; + } + + .app-content { + @apply min-w-0; + } + + .app-main { + @apply mt-4; + } + + .material-drawer { + @apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex; + 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); + } + + .drawer-brand { + @apply mb-5 flex items-center gap-3 px-1; + } + + .drawer-primary-action { + @apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-semibold shadow-can transition active:scale-[0.99]; + background: var(--primary-container); + border-radius: 18px; + color: var(--on-primary-container); + } + + .drawer-nav { + @apply grid gap-2; + } + + .drawer-footer { + @apply mt-auto grid gap-3; + } + + .drawer-info-card { + @apply border p-4; + background: var(--surface-container-high); + border-color: var(--outline-variant); + border-radius: 22px; + } + + .top-app-bar { + @apply border p-4 sm:p-5; + 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); + } + + .top-app-bar-main { + @apply flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between; + } + + .top-title-cluster { + @apply flex min-w-0 items-start gap-3; + } + + .top-app-icon { + @apply mt-1 flex h-12 w-12 shrink-0 items-center justify-center; + background: var(--primary-container); + border-radius: 16px; + color: var(--on-primary-container); + } + + .top-kicker { + @apply text-sm font-medium; + color: var(--primary); + } + + .top-title { + @apply mt-1 break-words text-4xl font-semibold sm:text-5xl; + color: var(--text); + } + + .top-meta-row { + @apply flex flex-wrap items-center gap-2; + } + + .account-chip { + @apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-semibold; + background: var(--surface-container-high); + color: var(--muted); + } + + .top-action-row { + @apply mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between; + } + + .top-action-primary, + .top-action-secondary { + @apply flex flex-wrap gap-2; + } + + .mobile-nav-bar { + @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; + } + + .mobile-nav-item { + @apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-semibold transition; + border-radius: 22px; + color: var(--muted); + } + + .mobile-nav-item-active { + background: var(--primary-container); + color: var(--on-primary-container) !important; + } + + @media (min-width: 1024px) { + .app-layout { + grid-template-columns: 300px minmax(0, 1fr); + gap: 24px; + padding: 24px; + } + + .app-main { + margin-top: 24px; + } + } + .glass-panel { - @apply rounded-lg border shadow-fridge backdrop-blur-2xl; - background: color-mix(in srgb, var(--panel) 86%, white); - border-color: var(--border); + @apply rounded-lg border shadow-fridge; + 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-cyan backdrop-blur-2xl; + @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: 36px; + } + + .today-panel { background: - linear-gradient(135deg, color-mix(in srgb, var(--accent) 42%, white), rgba(255, 255, 255, 0.96) 52%, color-mix(in srgb, var(--accent-soft) 76%, white)); - border-color: var(--border); + 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 { @@ -163,75 +635,147 @@ textarea:focus-visible { @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl; } - .accent-picker { - @apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm; - border-color: var(--border); + .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); + 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); } - .accent-picker button:hover, - .accent-picker-active { - background: var(--accent-soft); - color: var(--text) !important; + .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: #bdeeff; + .theme-preview-chip { + color: var(--text); } - .accent-swatch-pink { - background: #ffd6e8; + .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 { - --accent: #bdeeff; - --accent-soft: #e7f8ff; - --accent-strong: #4aa8d6; - --accent-warm: #ffe2ef; - --bg: #f5fbff; - --panel: #f8fcff; - --panel-strong: #ffffff; - --border: rgba(104, 164, 198, 0.24); - --text: #193042; - --muted: #607587; - --subtle: #7e93a3; + --primary: #4b86ad; + --on-primary: #ffffff; + --primary-container: #dff2ff; + --on-primary-container: #10283a; + --secondary: #647887; + --on-secondary: #ffffff; + --secondary-container: #ecf3f7; + --on-secondary-container: #1f2d35; + --tertiary: #9b7b51; + --on-tertiary: #ffffff; + --tertiary-container: #f4eadb; + --on-tertiary-container: #332313; + --error: #ba1a1a; + --on-error: #ffffff; + --error-container: #ffdad6; + --on-error-container: #410002; + --bg: #f8fbff; + --surface: #f8fbff; + --surface-container-lowest: #ffffff; + --surface-container-low: #f1f7fb; + --surface-container: #edf3f7; + --surface-container-high: #e7eef3; + --panel: var(--surface-container); + --panel-strong: var(--surface-container-lowest); + --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: #eef4f7; + --chart-primary: #4b86ad; + --chart-secondary: #6f8f7c; + --chart-tertiary: #9b7b51; + --chart-error: #ba1a1a; + --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; -} - -.app-shell[data-accent="pink"] { - --accent: #ffd6e8; - --accent-soft: #fff0f7; - --accent-strong: #d46c9d; - --accent-warm: #dff6ff; - --bg: #fff8fc; - --panel: #fffbfd; - --border: rgba(210, 108, 157, 0.22); + font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; } .backdrop-wash { background: - radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%), - radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%), - linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%); + 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 { background-image: - linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px), - linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px); - background-size: 42px 42px; - opacity: 0.5; + linear-gradient(var(--chart-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px); + background-size: 48px 48px; + opacity: 0; } .backdrop-rail { @@ -336,3 +880,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/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"], + }, }, }, }, - }, + }; });