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.
This commit is contained in:
Ned Halksworth
2026-05-22 22:39:38 +01:00
parent 1c279ccb6c
commit 084acfa84a
14 changed files with 2294 additions and 226 deletions
+13
View File
@@ -2,8 +2,21 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id VITE_APPWRITE_PROJECT_ID=your-project-id
VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_DATABASE_ID=redbull_tracker
VITE_APPWRITE_COLLECTION_ID=intake_entries 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, # Optional. Leave blank in local dev so the app uses the current Vite origin,
# including fallback ports like http://127.0.0.1:5174. # including fallback ports like http://127.0.0.1:5174.
VITE_APPWRITE_OAUTH_SUCCESS_URL= VITE_APPWRITE_OAUTH_SUCCESS_URL=
VITE_APPWRITE_OAUTH_FAILURE_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.
+47 -1
View File
@@ -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. 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: Configured defaults:
- Endpoint: `https://fra.cloud.appwrite.io/v1` - Endpoint: `https://fra.cloud.appwrite.io/v1`
@@ -28,6 +36,7 @@ Configured defaults:
- Project name: `Red Bull Tracker App` - Project name: `Red Bull Tracker App`
- Database ID: `redbull_tracker` - Database ID: `redbull_tracker`
- Collection ID: `intake_entries` - 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`. `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` - `user_import_key`: key index on `userId`, `importKey`
- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it - 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 ## 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/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/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/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/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
- `src/lib/storage.ts`: JSON backup export/import parser. - `src/lib/storage.ts`: JSON backup export/import parser.
+77
View File
@@ -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) : {};
}
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "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": { "dependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
+205
View File
@@ -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;
}
+822 -119
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -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<string, string> = {
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;
}
+412 -93
View File
@@ -17,7 +17,7 @@
:root { :root {
color-scheme: light; color-scheme: light;
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
background: #fff8fb; background: #f8fbff;
} }
* { * {
@@ -26,15 +26,15 @@
html { html {
min-width: 320px; min-width: 320px;
background: #fff8fb; background: #f8fbff;
} }
body { body {
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: #fff8fb; background: #f8fbff;
color: #21191d; color: #1f252a;
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -135,9 +135,9 @@ textarea:focus-visible {
.material-drawer { .material-drawer {
@apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex; @apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex;
background: var(--surface-container); background: color-mix(in srgb, var(--surface-container-lowest) 84%, transparent);
border-color: var(--outline-variant); border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent);
border-radius: 28px; border-radius: 32px;
box-shadow: var(--elevation-1); box-shadow: var(--elevation-1);
} }
@@ -169,9 +169,9 @@ textarea:focus-visible {
.top-app-bar { .top-app-bar {
@apply border p-4 sm:p-5; @apply border p-4 sm:p-5;
background: color-mix(in srgb, var(--surface-container-low) 84%, white); background: color-mix(in srgb, var(--surface-container-lowest) 86%, transparent);
border-color: var(--outline-variant); border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent);
border-radius: 28px; border-radius: 34px;
box-shadow: var(--elevation-1); box-shadow: var(--elevation-1);
} }
@@ -220,7 +220,7 @@ textarea:focus-visible {
} }
.mobile-nav-bar { .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); background: color-mix(in srgb, var(--surface-container-high) 92%, white);
border-color: var(--outline-variant); border-color: var(--outline-variant);
border-radius: 28px; border-radius: 28px;
@@ -251,16 +251,313 @@ textarea:focus-visible {
.glass-panel { .glass-panel {
@apply rounded-lg border shadow-fridge; @apply rounded-lg border shadow-fridge;
background: var(--surface-container); background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent);
border-color: var(--outline-variant); border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent);
border-radius: 24px; border-radius: 34px;
} }
.can-panel { .can-panel {
@apply rounded-lg border shadow-can; @apply rounded-lg border shadow-can;
background: linear-gradient(135deg, var(--primary-container), var(--surface-container-high) 58%, var(--tertiary-container)); 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-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 { .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; @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm;
} }
.accent-picker { .theme-indicator {
@apply inline-flex min-h-11 items-center gap-1 rounded-md border p-1 shadow-sm; @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); background: var(--surface-container-high);
border-color: var(--outline-variant); border-color: var(--outline-variant);
border-radius: 999px; color: var(--text);
} }
.accent-picker button { .theme-indicator:hover {
@apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition; 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); color: var(--muted);
border-radius: 999px;
} }
.accent-picker button:hover, .settings-tabs button:hover,
.accent-picker-active { .settings-tab-active {
background: var(--primary-container); background: var(--primary-container);
color: var(--on-primary-container) !important; color: var(--on-primary-container) !important;
} }
.accent-swatch { .theme-preview-strip {
@apply h-3 w-3 rounded-full border border-white shadow-sm; @apply flex flex-wrap gap-2;
} }
.accent-swatch-blue { .theme-preview-chip {
background: #d8e7ff; color: var(--text);
} }
.accent-swatch-pink { .theme-picker-grid {
background: #ffd8e7; @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 { .app-shell {
--primary: #9c4168; --primary: #4b86ad;
--on-primary: #ffffff; --on-primary: #ffffff;
--primary-container: #ffd8e7; --primary-container: #dff2ff;
--on-primary-container: #3e001d; --on-primary-container: #10283a;
--secondary: #526354; --secondary: #647887;
--on-secondary: #ffffff; --on-secondary: #ffffff;
--secondary-container: #d7efe2; --secondary-container: #ecf3f7;
--on-secondary-container: #102116; --on-secondary-container: #1f2d35;
--tertiary: #765930; --tertiary: #9b7b51;
--on-tertiary: #ffffff; --on-tertiary: #ffffff;
--tertiary-container: #ffddb2; --tertiary-container: #f4eadb;
--on-tertiary-container: #2b1700; --on-tertiary-container: #332313;
--error: #ba1a1a; --error: #ba1a1a;
--on-error: #ffffff; --on-error: #ffffff;
--error-container: #ffdad6; --error-container: #ffdad6;
--on-error-container: #410002; --on-error-container: #410002;
--bg: #fff8fb; --bg: #f8fbff;
--surface: #fff8fb; --surface: #f8fbff;
--surface-container-lowest: #ffffff; --surface-container-lowest: #ffffff;
--surface-container-low: #fff0f5; --surface-container-low: #f1f7fb;
--surface-container: #faedf3; --surface-container: #edf3f7;
--surface-container-high: #f4e7ee; --surface-container-high: #e7eef3;
--panel: var(--surface-container); --panel: var(--surface-container);
--panel-strong: var(--surface-container-lowest); --panel-strong: var(--surface-container-lowest);
--outline: #85737a; --outline: #7c8992;
--outline-variant: #d8c2ca; --outline-variant: #dce5ea;
--text: #21191d; --text: #1f252a;
--muted: #655b60; --muted: #68747c;
--subtle: #83757c; --subtle: #839099;
--accent: var(--primary-container); --accent: var(--primary-container);
--accent-soft: var(--surface-container-low); --accent-soft: var(--surface-container-low);
--accent-strong: var(--primary); --accent-strong: var(--primary);
--accent-warm: #d7efe2; --accent-warm: #eef4f7;
--chart-primary: #b85d84; --chart-primary: #4b86ad;
--chart-secondary: #5f7f6f; --chart-secondary: #6f8f7c;
--chart-tertiary: #906d1d; --chart-tertiary: #9b7b51;
--chart-error: #ba1a1a; --chart-error: #ba1a1a;
--chart-grid: rgba(132, 115, 122, 0.24); --chart-grid: rgba(124, 137, 146, 0.18);
--elevation-1: 0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08); --elevation-1: 0 12px 34px rgba(69, 91, 108, 0.08), 0 1px 2px rgba(69, 91, 108, 0.06);
--elevation-2: 0 2px 6px rgba(69, 54, 62, 0.14), 0 8px 18px rgba(69, 54, 62, 0.08); --elevation-2: 0 18px 44px rgba(69, 91, 108, 0.12), 0 2px 8px rgba(69, 91, 108, 0.08);
min-height: 100vh; min-height: 100vh;
background: var(--bg) !important; background: var(--bg) !important;
color: var(--text) !important; color: var(--text) !important;
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; 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 { .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 { .backdrop-grid {
@@ -526,7 +839,7 @@ textarea:focus-visible {
linear-gradient(var(--chart-grid) 1px, transparent 1px), linear-gradient(var(--chart-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px); linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px);
background-size: 48px 48px; background-size: 48px 48px;
opacity: 0.22; opacity: 0;
} }
.backdrop-rail { .backdrop-rail {
@@ -675,3 +988,9 @@ textarea:focus-visible {
.app-shell .modal-panel { .app-shell .modal-panel {
color: var(--text); color: var(--text);
} }
@keyframes thinking-slide {
to {
transform: translateX(-100%);
}
}
+1
View File
@@ -8,6 +8,7 @@ export const appwriteConfig = {
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138", projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", 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), oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
}; };
+178
View File
@@ -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<EncryptedChatRow>({
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<EncryptedChatRow>({
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<EncryptedChatRow>({
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<CoachChat> {
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<EncryptedValue> {
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)];
}
+248
View File
@@ -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<ThemeTokens>;
};
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;
}
+20
View File
@@ -54,3 +54,23 @@ export type ImportPreview = {
fileName: string; fileName: string;
rows: ImportPreviewRow[]; 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;
};
+2
View File
@@ -5,8 +5,10 @@ interface ImportMetaEnv {
readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_PROJECT_ID?: string;
readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string;
readonly VITE_APPWRITE_COLLECTION_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_SUCCESS_URL?: string;
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
readonly VITE_OLLAMA_PROXY_URL?: string;
} }
interface ImportMeta { interface ImportMeta {
+38 -12
View File
@@ -1,18 +1,44 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), "");
build: { const ollamaProxy = {
chunkSizeWarningLimit: 700, target: "https://ollama.com",
rollupOptions: { changeOrigin: true,
output: { rewrite: () => "/api/chat",
manualChunks: { configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
charts: ["recharts"], proxy.on("proxyReq", (proxyReq) => {
motion: ["framer-motion"], if (env.OLLAMA_API_KEY) {
icons: ["lucide-react"], 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"],
},
}, },
}, },
}, },
}, };
}); });