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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Vendored
+2
@@ -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
@@ -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"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user