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
committed by Ned
parent bd3e970286
commit 08372febfe
12 changed files with 1845 additions and 388 deletions
+13
View File
@@ -2,8 +2,21 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138
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.
+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",
+6 -94
View File
@@ -1,7 +1,6 @@
/* global console, fetch, process, setTimeout */ /* global console, fetch, process, setTimeout */
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { URL } from "node:url";
const env = loadEnvFiles([".env", ".env.local"]); 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 databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); 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 apiKey = readEnv("APPWRITE_API_KEY", "");
const verifiedBarcodeProducts = JSON.parse(
readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"),
);
if (!apiKey) { if (!apiKey) {
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); 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", name: "Coach chats",
columns: [ columns: [
{ kind: "string", key: "userId", size: 64, required: true }, { kind: "string", key: "userId", size: 64, required: true },
{ kind: "string", key: "title", size: 512, required: true }, { kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true },
{ kind: "longtext", key: "messages", required: 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 }, { kind: "datetime", key: "updatedAt", required: true },
], ],
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], 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."); console.log("Appwrite database and tables ready.");
@@ -160,19 +122,6 @@ async function ensureColumn(tableId, column) {
console.log(`Column ${tableId}.${column.key} created.`); 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) { async function ensureIndex(tableId, index) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
if (existing.status === 200) { if (existing.status === 200) {
@@ -189,43 +138,6 @@ async function ensureIndex(tableId, index) {
console.log(`Index ${tableId}.${index.key} created.`); 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) { async function waitForColumns(tableId, keys) {
const pending = new Set(keys); const pending = new Set(keys);
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
+834 -124
View File
File diff suppressed because it is too large Load Diff
+99 -101
View File
@@ -51,149 +51,147 @@ export const APP_THEMES: AppTheme[] = [
tertiary: "#ffd8e7", tertiary: "#ffd8e7",
}), }),
theme("original", "Original", "flavour", "#282874", { theme("original", "Original", "flavour", "#00a7ff", {
primary: "#282874", primary: "#0077c8",
secondary: "#efefef", secondary: "#00a7ff",
tertiary: "#d4af37", tertiary: "#1e3264",
tokens: {
chartSecondary: "#e6301f",
},
}), }),
theme("zero", "Zero", "flavour", "#b1d0ee", { theme("zero", "Zero", "flavour", "#2a2a2a", {
primary: "#b1d0ee", primary: "#2a2a2a",
secondary: "#efefef", secondary: "#5c5c5c",
tertiary: "#e6301f", tertiary: "#8a8a8a",
dark: true,
}), }),
theme("summer", "Summer Edition", "flavour", "#f0e53b", { theme("summer", "Summer Edition", "flavour", "#f0e53b", {
primary: "#f2e853", primary: "#d4c400",
secondary: "#efefef", secondary: "#f0e53b",
tertiary: "#8a8f98", tertiary: "#ffc247",
}), }),
theme("cherry", "Cherry Edition", "flavour", "#d81b60", { theme("cherry", "Cherry Edition", "flavour", "#e40046", {
primary: "#d81b60", primary: "#c3093b",
secondary: "#efefef", secondary: "#e40046",
tertiary: "#b50045", tertiary: "#ff6b8a",
}), }),
theme("spring", "Spring Edition", "flavour", "#ff8fab", { theme("spring", "Spring Edition", "flavour", "#ff8fab", {
primary: "#e85d8a", primary: "#e85d8a",
secondary: "#ffb3c6", secondary: "#ffb3c6",
tertiary: "#ffd8e7", tertiary: "#ffd8e7",
}), }),
theme("apple", "Apple Edition", "flavour", "#bf1431", { theme("apple", "Apple Edition", "flavour", "#78be20", {
primary: "#bf1431", primary: "#5a9a12",
secondary: "#f6c300", secondary: "#78be20",
tertiary: "#f3911b", tertiary: "#a8d84a",
}), }),
theme("peach", "Peach Edition", "flavour", "#e24585", { theme("peach", "Peach Edition", "flavour", "#ff9b63", {
primary: "#e24585", primary: "#e87a3a",
secondary: "#efefef", secondary: "#ff9b63",
tertiary: "#d6417e", tertiary: "#ffc9a3",
}), }),
theme("ice", "Ice Edition", "flavour", "#49adbe", { theme("ice", "Ice Edition", "flavour", "#49adbe", {
primary: "#53b2c2", primary: "#2d8a9a",
secondary: "#efefef", secondary: "#49adbe",
tertiary: "#49adbe", tertiary: "#7ce7ff",
}), }),
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", { theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
primary: "#0085c8", primary: "#3a52cc",
secondary: "#efefef", secondary: "#496dff",
tertiary: "#ff73d1", tertiary: "#9c73ff",
}), }),
theme("red-edition", "Red Edition", "flavour", "#e6301f", { theme("red-edition", "Red Edition", "flavour", "#ff355e", {
primary: "#e6301f", primary: "#e02045",
secondary: "#efefef", secondary: "#ff355e",
tertiary: "#78b941", tertiary: "#ff6b8a",
}), }),
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", { theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
primary: "#ffcb04", primary: "#e0a820",
secondary: "#efefef", secondary: "#ffc247",
tertiary: "#f6c300", tertiary: "#ff9b63",
}), }),
theme("coconut", "Coconut Edition", "flavour", "#0070b8", { theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
primary: "#0070b8", primary: "#4ec4e0",
secondary: "#efefef", secondary: "#7ce7ff",
tertiary: "#8a8f98", tertiary: "#d8f9ff",
}), }),
theme("green-edition", "Green Edition", "flavour", "#78b941", { theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
primary: "#78b941", primary: "#7acc20",
secondary: "#efefef", secondary: "#b7ff4a",
tertiary: "#f3911b", tertiary: "#d4ff8a",
}), }),
theme("apricot", "Apricot Edition", "flavour", "#f3911b", { theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
primary: "#f3911b", primary: "#e06a20",
secondary: "#efefef", secondary: "#ff8c42",
tertiary: "#d6417e", tertiary: "#ffb87a",
}), }),
theme("ruby", "Ruby Edition", "flavour", "#b50045", { theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
primary: "#b50045", primary: "#a00730",
secondary: "#efefef", secondary: "#c3093b",
tertiary: "#a3e635", tertiary: "#e04060",
}), }),
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", { theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
primary: "#009edf", primary: "#8a9bb0",
secondary: "#efefef", secondary: "#c8d4e0",
tertiary: "#e6301f", tertiary: "#e7eef8",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", { theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
primary: "#f2e853", primary: "#c4c020",
secondary: "#efefef", secondary: "#e8e4a0",
tertiary: "#009edf", tertiary: "#f0e53b",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", { theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
primary: "#bf1431", primary: "#6a9a30",
secondary: "#f6c300", secondary: "#b8d4a0",
tertiary: "#009edf", tertiary: "#78be20",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", { theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
primary: "#e24585", primary: "#d08050",
secondary: "#efefef", secondary: "#f0d0b8",
tertiary: "#009edf", tertiary: "#ff9b63",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", { theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
primary: "#53b2c2", primary: "#4a9aaa",
secondary: "#efefef", secondary: "#b8e0e8",
tertiary: "#009edf", tertiary: "#49adbe",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", { theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
primary: "#7d62ce", primary: "#9070c0",
secondary: "#44c7b7", secondary: "#d8c8f0",
tertiary: "#009edf", tertiary: "#b898e0",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", { theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
primary: "#e77bab", primary: "#d06090",
secondary: "#8a1f3d", secondary: "#f0c8d8",
tertiary: "#009edf", tertiary: "#ffb7d9",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", { theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
primary: "#0085c8", primary: "#5060c0",
secondary: "#efefef", secondary: "#c8d0f8",
tertiary: "#009edf", tertiary: "#496dff",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", { theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
primary: "#0070b8", primary: "#60b8d0",
secondary: "#efefef", secondary: "#d0f0f8",
tertiary: "#009edf", tertiary: "#7ce7ff",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", { theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
primary: "#78b941", primary: "#70a830",
secondary: "#efefef", secondary: "#d8f0b8",
tertiary: "#009edf", tertiary: "#b7ff4a",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", { theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
primary: "#b50045", primary: "#a03050",
secondary: "#efefef", secondary: "#f0c0c8",
tertiary: "#009edf", tertiary: "#c3093b",
sugarFree: true, sugarFree: true,
}), }),
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", { theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
+605 -55
View File
@@ -4,8 +4,8 @@
:root { :root {
color-scheme: light; color-scheme: light;
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5fbff; background: #f8fbff;
} }
* { * {
@@ -14,16 +14,16 @@
html { html {
min-width: 320px; min-width: 320px;
background: #f5fbff; background: #f8fbff;
} }
body { body {
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: #f5fbff; background: #f8fbff;
color: #193042; color: #1f252a;
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", 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: geometricPrecision; text-rendering: geometricPrecision;
} }
@@ -62,17 +62,489 @@ textarea:focus-visible {
} }
@layer components { @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 { .glass-panel {
@apply rounded-lg border shadow-fridge backdrop-blur-2xl; @apply rounded-lg border shadow-fridge;
background: color-mix(in srgb, var(--panel) 86%, white); background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent);
border-color: var(--border); border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent);
border-radius: 34px;
} }
.can-panel { .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: 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)); radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--primary-container) 82%, white) 0 22%, transparent 44%),
border-color: var(--border); 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 {
@@ -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; @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl;
} }
.accent-picker { .theme-indicator {
@apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm; @apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-semibold transition;
border-color: var(--border); background: var(--surface-container-high);
border-color: var(--outline-variant);
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);
} }
.accent-picker button:hover, .settings-tabs button:hover,
.accent-picker-active { .settings-tab-active {
background: var(--accent-soft); background: var(--primary-container);
color: var(--text) !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: #bdeeff; color: var(--text);
} }
.accent-swatch-pink { .theme-picker-grid {
background: #ffd6e8; @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 {
--accent: #bdeeff; --primary: #4b86ad;
--accent-soft: #e7f8ff; --on-primary: #ffffff;
--accent-strong: #4aa8d6; --primary-container: #dff2ff;
--accent-warm: #ffe2ef; --on-primary-container: #10283a;
--bg: #f5fbff; --secondary: #647887;
--panel: #f8fcff; --on-secondary: #ffffff;
--panel-strong: #ffffff; --secondary-container: #ecf3f7;
--border: rgba(104, 164, 198, 0.24); --on-secondary-container: #1f2d35;
--text: #193042; --tertiary: #9b7b51;
--muted: #607587; --on-tertiary: #ffffff;
--subtle: #7e93a3; --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; 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;
.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);
} }
.backdrop-wash { .backdrop-wash {
background: background:
radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%), radial-gradient(circle at 70% 35%, var(--primary-container) 0 18%, transparent 42%),
radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%), radial-gradient(circle at 12% 12%, var(--surface-container-lowest) 0 18%, transparent 38%),
linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%); linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%);
} }
.backdrop-grid { .backdrop-grid {
background-image: background-image:
linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px), linear-gradient(var(--chart-grid) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px); linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px);
background-size: 42px 42px; background-size: 48px 48px;
opacity: 0.5; opacity: 0;
} }
.backdrop-rail { .backdrop-rail {
@@ -336,3 +880,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)];
}
+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"],
},
}, },
}, },
}, },
}, };
}); });