intial commit
This commit is contained in:
+5
-13
@@ -1,17 +1,9 @@
|
|||||||
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
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,
|
||||||
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
|
# including fallback ports like http://127.0.0.1:5174.
|
||||||
OLLAMA_API_KEY=
|
VITE_APPWRITE_OAUTH_SUCCESS_URL=
|
||||||
OLLAMA_MODEL=deepseek-v4-pro:cloud
|
VITE_APPWRITE_OAUTH_FAILURE_URL=
|
||||||
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: userId, title, messages, updatedAt.
|
|
||||||
# Enable row security and Users -> Create at table level.
|
|
||||||
|
|||||||
+1
-46
@@ -21,14 +21,6 @@ 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`
|
||||||
@@ -36,8 +28,6 @@ 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`
|
|
||||||
- Barcode collection ID: `barcode_products`
|
|
||||||
|
|
||||||
`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`.
|
||||||
|
|
||||||
@@ -87,8 +77,6 @@ So if the Console asks you to create a **table**, that is the same resource as t
|
|||||||
|
|
||||||
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
||||||
|
|
||||||
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
|
|
||||||
|
|
||||||
Create a database with ID:
|
Create a database with ID:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -166,44 +154,11 @@ 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 stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
|
|
||||||
|
|
||||||
Create these chat columns:
|
|
||||||
|
|
||||||
| Key | Type | Required | Notes |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `userId` | String, 64 | Yes | Current Appwrite user ID |
|
|
||||||
| `title` | String, 512 | Yes | Chat title |
|
|
||||||
| `messages` | Longtext | Yes | JSON array of coach messages |
|
|
||||||
| `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/coach/data views, modals, and action state.
|
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/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/coachChats.ts`: Appwrite-backed coach chat storage.
|
|
||||||
- `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.
|
||||||
|
|||||||
Generated
-46
@@ -9,7 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
@@ -1899,41 +1898,6 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@zxing/browser": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@zxing/text-encoding": "^0.9.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@zxing/library": "^0.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@zxing/library": {
|
|
||||||
"version": "0.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
|
|
||||||
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ts-custom-error": "^3.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 24.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@zxing/text-encoding": "~0.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@zxing/text-encoding": {
|
|
||||||
"version": "0.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
|
||||||
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
|
||||||
"license": "(Unlicense OR Apache-2.0)",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -5115,16 +5079,6 @@
|
|||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-custom-error": {
|
|
||||||
"version": "3.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
|
||||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|||||||
+1
-3
@@ -7,12 +7,10 @@
|
|||||||
"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",
|
||||||
"@zxing/browser": "^0.2.0",
|
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
|||||||
+295
-1003
File diff suppressed because it is too large
Load Diff
+12
-17
@@ -1,25 +1,20 @@
|
|||||||
import type { Flavour } from "../types";
|
import type { Flavour } from "../types";
|
||||||
|
|
||||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||||
{ name: "Original", accent: "#282874" },
|
{ name: "Original", accent: "#00A7FF" },
|
||||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
{ name: "Ruby", accent: "#C3093B" },
|
||||||
{ name: "Ruby", accent: "#B50045" },
|
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
{ name: "Tropical", accent: "#FFC247" },
|
||||||
{ name: "Tropical", accent: "#FFCB04" },
|
{ name: "Watermelon", accent: "#FF355E" },
|
||||||
{ name: "Cherry Edition", accent: "#D81B60" },
|
|
||||||
{ name: "Apricot Edition", accent: "#F3911B" },
|
|
||||||
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
|
|
||||||
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
|
|
||||||
{ name: "Watermelon", accent: "#E6301F" },
|
|
||||||
{ name: "Blueberry", accent: "#496DFF" },
|
{ name: "Blueberry", accent: "#496DFF" },
|
||||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||||
{ name: "Peach", accent: "#E24585" },
|
{ name: "Peach", accent: "#FF9B63" },
|
||||||
{ name: "Juneberry", accent: "#0085C8" },
|
{ name: "Juneberry", accent: "#9C73FF" },
|
||||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||||
{ name: "Winter Edition", accent: "#BF1431" },
|
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||||
{ name: "Summer Edition", accent: "#F2E853" },
|
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||||
{ name: "Other", accent: "#AEB9C7" },
|
{ name: "Other", accent: "#AEB9C7" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+128
-1002
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -1,16 +1,15 @@
|
|||||||
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
|
import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite";
|
||||||
|
|
||||||
const env = import.meta.env;
|
const env = import.meta.env;
|
||||||
const currentOrigin = window.location.origin;
|
const currentOrigin = window.location.origin;
|
||||||
|
|
||||||
export const appwriteConfig = {
|
export const appwriteConfig = {
|
||||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
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),
|
||||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new Client()
|
const client = new Client()
|
||||||
@@ -24,6 +23,18 @@ export async function pingAppwrite() {
|
|||||||
return client.ping();
|
return client.ping();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB };
|
||||||
|
|
||||||
|
function resolveOAuthUrl(value?: string) {
|
||||||
|
if (!value) return currentOrigin;
|
||||||
|
|
||||||
|
const configured = new URL(value, currentOrigin);
|
||||||
|
const current = new URL(currentOrigin);
|
||||||
|
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||||
|
|
||||||
|
if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) {
|
||||||
|
return currentOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configured.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,57 +34,6 @@ export type EntryDraft = Omit<
|
|||||||
source?: RedBullEntry["source"];
|
source?: RedBullEntry["source"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown";
|
|
||||||
|
|
||||||
export type BarcodeProductDraft = {
|
|
||||||
flavourName: string;
|
|
||||||
sizeMl: number;
|
|
||||||
pricePerCan: number;
|
|
||||||
sugarFree?: boolean;
|
|
||||||
caffeineMgPerCan?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolvedBarcodeProduct = BarcodeProductDraft & {
|
|
||||||
flavourAccent: string;
|
|
||||||
source: "built-in" | "user";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BarcodeSeedProduct = BarcodeProductDraft & {
|
|
||||||
verifiedBy: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
notes?: string;
|
|
||||||
variant?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserBarcodeMapping = BarcodeProductDraft & {
|
|
||||||
barcode: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BarcodeLookupCatalog = {
|
|
||||||
verifiedProducts?: Record<string, BarcodeSeedProduct>;
|
|
||||||
userMappings?: UserBarcodeMapping[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BarcodeLookupResult =
|
|
||||||
| {
|
|
||||||
status: "known" | "user";
|
|
||||||
barcode: string;
|
|
||||||
product: ResolvedBarcodeProduct;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
status: "partial";
|
|
||||||
barcode: string;
|
|
||||||
product: BarcodeProductDraft;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
status: "unknown";
|
|
||||||
barcode: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Filters = {
|
export type Filters = {
|
||||||
flavour: string;
|
flavour: string;
|
||||||
dateRange: DateFilter;
|
dateRange: DateFilter;
|
||||||
@@ -105,40 +54,3 @@ 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserLimits = {
|
|
||||||
dailyCanLimit?: number;
|
|
||||||
dailySpendLimit?: number;
|
|
||||||
stopTime?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
|
||||||
|
|
||||||
export type LimitCheckResult = {
|
|
||||||
violations: LimitViolation[];
|
|
||||||
projectedCans: number;
|
|
||||||
projectedSpend: number;
|
|
||||||
todayCans: number;
|
|
||||||
todaySpend: number;
|
|
||||||
pastStopTime: boolean;
|
|
||||||
};
|
|
||||||
|
|||||||
Vendored
-3
@@ -5,11 +5,8 @@ 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_BARCODE_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 {
|
||||||
|
|||||||
+12
-13
@@ -6,21 +6,20 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
display: [
|
display: [
|
||||||
"Google Sans",
|
"SF Pro Display",
|
||||||
"Google Sans Text",
|
"SF Pro Text",
|
||||||
"Product Sans",
|
|
||||||
"Roboto",
|
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
body: [
|
body: [
|
||||||
"Google Sans",
|
"SF Pro Text",
|
||||||
"Google Sans Text",
|
|
||||||
"Product Sans",
|
|
||||||
"Roboto",
|
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -39,11 +38,11 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
||||||
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
||||||
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
||||||
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
||||||
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"carbon-grid":
|
"carbon-grid":
|
||||||
|
|||||||
+3
-125
@@ -1,37 +1,8 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
||||||
import type { Plugin } from "vite";
|
|
||||||
import { defineConfig, loadEnv } from "vite";
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
|
||||||
const ollamaProxy = {
|
|
||||||
target: "https://ollama.com",
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: () => "/api/chat",
|
|
||||||
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
|
|
||||||
proxy.on("proxyReq", (proxyReq) => {
|
|
||||||
if (env.OLLAMA_API_KEY) {
|
|
||||||
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [react(), ollamaProxyPlugin(env)],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api/ollama-chat": ollamaProxy,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
proxy: {
|
|
||||||
"/api/ollama-chat": ollamaProxy,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
@@ -44,97 +15,4 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
|
|
||||||
return {
|
|
||||||
name: "ollama-proxy",
|
|
||||||
configureServer(server) {
|
|
||||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
|
||||||
},
|
|
||||||
configurePreviewServer(server) {
|
|
||||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOllamaHandler(env: Record<string, string>) {
|
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
|
||||||
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.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("Method not allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleOllamaProxy(req, res, env);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record<string, string>) {
|
|
||||||
const apiKey = env.OLLAMA_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("OLLAMA_API_KEY is not configured on the server.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await readJsonBody(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 || 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.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonBody(req: IncomingMessage) {
|
|
||||||
let raw = "";
|
|
||||||
for await (const chunk of req) raw += chunk;
|
|
||||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user