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:
+834
-124
File diff suppressed because it is too large
Load Diff
+99
-101
@@ -51,149 +51,147 @@ export const APP_THEMES: AppTheme[] = [
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
|
||||
theme("original", "Original", "flavour", "#282874", {
|
||||
primary: "#282874",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d4af37",
|
||||
tokens: {
|
||||
chartSecondary: "#e6301f",
|
||||
},
|
||||
theme("original", "Original", "flavour", "#00a7ff", {
|
||||
primary: "#0077c8",
|
||||
secondary: "#00a7ff",
|
||||
tertiary: "#1e3264",
|
||||
}),
|
||||
theme("zero", "Zero", "flavour", "#b1d0ee", {
|
||||
primary: "#b1d0ee",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
theme("zero", "Zero", "flavour", "#2a2a2a", {
|
||||
primary: "#2a2a2a",
|
||||
secondary: "#5c5c5c",
|
||||
tertiary: "#8a8a8a",
|
||||
dark: true,
|
||||
}),
|
||||
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
primary: "#d4c400",
|
||||
secondary: "#f0e53b",
|
||||
tertiary: "#ffc247",
|
||||
}),
|
||||
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
|
||||
primary: "#d81b60",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#b50045",
|
||||
theme("cherry", "Cherry Edition", "flavour", "#e40046", {
|
||||
primary: "#c3093b",
|
||||
secondary: "#e40046",
|
||||
tertiary: "#ff6b8a",
|
||||
}),
|
||||
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||
primary: "#e85d8a",
|
||||
secondary: "#ffb3c6",
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
theme("apple", "Apple Edition", "flavour", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#f3911b",
|
||||
theme("apple", "Apple Edition", "flavour", "#78be20", {
|
||||
primary: "#5a9a12",
|
||||
secondary: "#78be20",
|
||||
tertiary: "#a8d84a",
|
||||
}),
|
||||
theme("peach", "Peach Edition", "flavour", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
theme("peach", "Peach Edition", "flavour", "#ff9b63", {
|
||||
primary: "#e87a3a",
|
||||
secondary: "#ff9b63",
|
||||
tertiary: "#ffc9a3",
|
||||
}),
|
||||
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#49adbe",
|
||||
primary: "#2d8a9a",
|
||||
secondary: "#49adbe",
|
||||
tertiary: "#7ce7ff",
|
||||
}),
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#ff73d1",
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
|
||||
primary: "#3a52cc",
|
||||
secondary: "#496dff",
|
||||
tertiary: "#9c73ff",
|
||||
}),
|
||||
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
|
||||
primary: "#e6301f",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#78b941",
|
||||
theme("red-edition", "Red Edition", "flavour", "#ff355e", {
|
||||
primary: "#e02045",
|
||||
secondary: "#ff355e",
|
||||
tertiary: "#ff6b8a",
|
||||
}),
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
|
||||
primary: "#ffcb04",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f6c300",
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
|
||||
primary: "#e0a820",
|
||||
secondary: "#ffc247",
|
||||
tertiary: "#ff9b63",
|
||||
}),
|
||||
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
|
||||
primary: "#4ec4e0",
|
||||
secondary: "#7ce7ff",
|
||||
tertiary: "#d8f9ff",
|
||||
}),
|
||||
theme("green-edition", "Green Edition", "flavour", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f3911b",
|
||||
theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
|
||||
primary: "#7acc20",
|
||||
secondary: "#b7ff4a",
|
||||
tertiary: "#d4ff8a",
|
||||
}),
|
||||
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
|
||||
primary: "#f3911b",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
|
||||
primary: "#e06a20",
|
||||
secondary: "#ff8c42",
|
||||
tertiary: "#ffb87a",
|
||||
}),
|
||||
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#a3e635",
|
||||
theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
|
||||
primary: "#a00730",
|
||||
secondary: "#c3093b",
|
||||
tertiary: "#e04060",
|
||||
}),
|
||||
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
|
||||
primary: "#009edf",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
|
||||
primary: "#8a9bb0",
|
||||
secondary: "#c8d4e0",
|
||||
tertiary: "#e7eef8",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
|
||||
primary: "#c4c020",
|
||||
secondary: "#e8e4a0",
|
||||
tertiary: "#f0e53b",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
|
||||
primary: "#6a9a30",
|
||||
secondary: "#b8d4a0",
|
||||
tertiary: "#78be20",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
|
||||
primary: "#d08050",
|
||||
secondary: "#f0d0b8",
|
||||
tertiary: "#ff9b63",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
|
||||
primary: "#4a9aaa",
|
||||
secondary: "#b8e0e8",
|
||||
tertiary: "#49adbe",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
|
||||
primary: "#7d62ce",
|
||||
secondary: "#44c7b7",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
|
||||
primary: "#9070c0",
|
||||
secondary: "#d8c8f0",
|
||||
tertiary: "#b898e0",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
|
||||
primary: "#e77bab",
|
||||
secondary: "#8a1f3d",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
|
||||
primary: "#d06090",
|
||||
secondary: "#f0c8d8",
|
||||
tertiary: "#ffb7d9",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
|
||||
primary: "#5060c0",
|
||||
secondary: "#c8d0f8",
|
||||
tertiary: "#496dff",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
|
||||
primary: "#60b8d0",
|
||||
secondary: "#d0f0f8",
|
||||
tertiary: "#7ce7ff",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
|
||||
primary: "#70a830",
|
||||
secondary: "#d8f0b8",
|
||||
tertiary: "#b7ff4a",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
|
||||
primary: "#a03050",
|
||||
secondary: "#f0c0c8",
|
||||
tertiary: "#c3093b",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||
|
||||
+605
-55
@@ -4,8 +4,8 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||
background: #f5fbff;
|
||||
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -14,16 +14,16 @@
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: #f5fbff;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5fbff;
|
||||
color: #193042;
|
||||
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||
background: #f8fbff;
|
||||
color: #1f252a;
|
||||
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
@@ -62,17 +62,489 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.auth-layout {
|
||||
@apply mx-auto grid min-h-screen w-full max-w-6xl gap-6 px-4 py-8 lg:grid-cols-[1.05fr_0.95fr];
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-hero {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.auth-signal-grid {
|
||||
@apply mt-6 grid gap-3 sm:grid-cols-3;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
@apply border p-5 shadow-fridge sm:p-6;
|
||||
background: color-mix(in srgb, var(--surface-container) 88%, white);
|
||||
border-color: var(--outline-variant);
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
@apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-semibold;
|
||||
background: var(--primary-container);
|
||||
border-radius: 999px;
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
@apply grid grid-cols-2 gap-1 border p-1;
|
||||
background: var(--surface-container-high);
|
||||
border-color: var(--outline-variant);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.segmented-control button {
|
||||
@apply min-h-10 px-3 text-sm font-semibold transition;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.segmented-control-active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container) !important;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
@apply mx-auto grid w-full gap-4 px-3 pb-28 pt-3;
|
||||
max-width: 1720px;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.material-drawer {
|
||||
@apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex;
|
||||
background: color-mix(in srgb, var(--surface-container-lowest) 84%, transparent);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent);
|
||||
border-radius: 32px;
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.drawer-brand {
|
||||
@apply mb-5 flex items-center gap-3 px-1;
|
||||
}
|
||||
|
||||
.drawer-primary-action {
|
||||
@apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-semibold shadow-can transition active:scale-[0.99];
|
||||
background: var(--primary-container);
|
||||
border-radius: 18px;
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.drawer-nav {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
@apply mt-auto grid gap-3;
|
||||
}
|
||||
|
||||
.drawer-info-card {
|
||||
@apply border p-4;
|
||||
background: var(--surface-container-high);
|
||||
border-color: var(--outline-variant);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.top-app-bar {
|
||||
@apply border p-4 sm:p-5;
|
||||
background: color-mix(in srgb, var(--surface-container-lowest) 86%, transparent);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent);
|
||||
border-radius: 34px;
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.top-app-bar-main {
|
||||
@apply flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between;
|
||||
}
|
||||
|
||||
.top-title-cluster {
|
||||
@apply flex min-w-0 items-start gap-3;
|
||||
}
|
||||
|
||||
.top-app-icon {
|
||||
@apply mt-1 flex h-12 w-12 shrink-0 items-center justify-center;
|
||||
background: var(--primary-container);
|
||||
border-radius: 16px;
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.top-kicker {
|
||||
@apply text-sm font-medium;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.top-title {
|
||||
@apply mt-1 break-words text-4xl font-semibold sm:text-5xl;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.top-meta-row {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.account-chip {
|
||||
@apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-semibold;
|
||||
background: var(--surface-container-high);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.top-action-row {
|
||||
@apply mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between;
|
||||
}
|
||||
|
||||
.top-action-primary,
|
||||
.top-action-secondary {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.mobile-nav-bar {
|
||||
@apply fixed inset-x-3 bottom-3 z-40 grid grid-cols-5 gap-1 border p-1 shadow-fridge;
|
||||
background: color-mix(in srgb, var(--surface-container-high) 92%, white);
|
||||
border-color: var(--outline-variant);
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
@apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-semibold transition;
|
||||
border-radius: 22px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-nav-item-active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
@apply rounded-lg border shadow-fridge backdrop-blur-2xl;
|
||||
background: color-mix(in srgb, var(--panel) 86%, white);
|
||||
border-color: var(--border);
|
||||
@apply rounded-lg border shadow-fridge;
|
||||
background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent);
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.can-panel {
|
||||
@apply rounded-lg border shadow-cyan backdrop-blur-2xl;
|
||||
@apply rounded-lg border shadow-can;
|
||||
background: linear-gradient(135deg, var(--primary-container), var(--surface-container-high) 58%, var(--tertiary-container));
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--outline-variant));
|
||||
border-radius: 36px;
|
||||
}
|
||||
|
||||
.today-panel {
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--accent) 42%, white), rgba(255, 255, 255, 0.96) 52%, color-mix(in srgb, var(--accent-soft) 76%, white));
|
||||
border-color: var(--border);
|
||||
radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--primary-container) 82%, white) 0 22%, transparent 44%),
|
||||
linear-gradient(145deg, var(--surface-container-lowest), var(--surface-container) 54%, var(--tertiary-container));
|
||||
}
|
||||
|
||||
.oura-hero {
|
||||
background:
|
||||
radial-gradient(circle at 14% 28%, color-mix(in srgb, var(--primary-container) 72%, white) 0 18%, transparent 42%),
|
||||
radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--secondary-container) 70%, white) 0 18%, transparent 38%),
|
||||
var(--surface-container-lowest);
|
||||
}
|
||||
|
||||
.oura-ring {
|
||||
@apply grid h-32 w-32 shrink-0 place-items-center p-2 shadow-can;
|
||||
background: conic-gradient(var(--primary) var(--progress), var(--surface-container-high) 0);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.oura-ring > div {
|
||||
@apply flex h-full w-full flex-col items-center justify-center;
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--outline-variant) 72%, transparent);
|
||||
}
|
||||
|
||||
.oura-ring span {
|
||||
@apply text-4xl font-semibold leading-none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.oura-ring small {
|
||||
@apply mt-1 text-xs font-semibold uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wellness-pill {
|
||||
@apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm;
|
||||
background: color-mix(in srgb, var(--surface-container-high) 78%, white);
|
||||
border-color: var(--outline-variant);
|
||||
}
|
||||
|
||||
.wellness-pill span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wellness-pill strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
@apply min-h-11 rounded-full border px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed;
|
||||
background: color-mix(in srgb, var(--surface-container-high) 72%, white);
|
||||
border-color: var(--outline-variant);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.suggestion-chip:hover:not(:disabled) {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.coach-gemini-shell {
|
||||
@apply grid min-h-[760px] gap-4;
|
||||
}
|
||||
|
||||
.coach-locked-shell {
|
||||
@apply place-items-center;
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.coach-chat-sidebar {
|
||||
@apply hidden min-h-[760px] flex-col border p-3 xl:flex;
|
||||
background: color-mix(in srgb, var(--surface-container-lowest) 80%, transparent);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent);
|
||||
border-radius: 34px;
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.coach-sidebar-brand {
|
||||
@apply flex items-center gap-3 px-2 py-2;
|
||||
}
|
||||
|
||||
.coach-brand-orb {
|
||||
@apply grid h-16 w-16 place-items-center rounded-full shadow-sm;
|
||||
background: radial-gradient(circle at 30% 26%, #ffffff 0 12%, #d7ecff 13% 48%, #7fb6df 72%, #5d8fb3 100%);
|
||||
color: #163247;
|
||||
}
|
||||
|
||||
.coach-brand-orb-small {
|
||||
@apply h-10 w-10;
|
||||
}
|
||||
|
||||
.coach-new-chat {
|
||||
@apply mt-4 inline-flex min-h-12 items-center gap-3 rounded-full px-4 text-sm font-semibold transition disabled:cursor-not-allowed;
|
||||
background: var(--surface-container-high);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-new-chat:hover:not(:disabled) {
|
||||
background: var(--primary-container);
|
||||
}
|
||||
|
||||
.coach-chat-list {
|
||||
@apply mt-4 grid flex-1 content-start gap-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.coach-chat-row {
|
||||
@apply grid grid-cols-[1fr_auto] items-center rounded-3xl transition;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-chat-row > button:first-child {
|
||||
@apply grid min-w-0 gap-1 px-4 py-3 text-left;
|
||||
}
|
||||
|
||||
.coach-chat-row > button:last-child {
|
||||
@apply mr-2 grid h-8 w-8 place-items-center rounded-full opacity-0 transition;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-chat-row:hover > button:last-child,
|
||||
.coach-chat-row-active > button:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.coach-chat-row span {
|
||||
@apply truncate text-sm font-medium;
|
||||
}
|
||||
|
||||
.coach-chat-row small {
|
||||
@apply text-xs;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-chat-row-active,
|
||||
.coach-chat-row:hover {
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.coach-context-card {
|
||||
@apply mt-4 rounded-[28px] border p-4;
|
||||
background: color-mix(in srgb, var(--surface-container-low) 76%, white);
|
||||
border-color: var(--outline-variant);
|
||||
}
|
||||
|
||||
.coach-stage {
|
||||
@apply relative flex min-h-[760px] flex-col overflow-hidden border;
|
||||
background:
|
||||
radial-gradient(circle at 50% 64%, rgba(192, 225, 250, 0.78) 0 18%, transparent 42%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.94), rgba(248,251,255,0.96));
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent);
|
||||
border-radius: 38px;
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.coach-stage-topbar {
|
||||
@apply flex items-center justify-between px-5 py-4 text-xs font-semibold;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-stage-messages {
|
||||
@apply flex-1 space-y-5 overflow-y-auto px-4 pb-36 pt-8 sm:px-8 lg:px-16;
|
||||
}
|
||||
|
||||
.coach-empty-state {
|
||||
@apply mx-auto flex min-h-[520px] max-w-3xl flex-col items-center justify-center text-center;
|
||||
}
|
||||
|
||||
.coach-empty-state h2 {
|
||||
@apply mt-6 text-5xl font-normal tracking-tight sm:text-6xl;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-empty-state p {
|
||||
@apply mt-4 max-w-xl text-base leading-7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-prompt-grid {
|
||||
@apply mt-7 grid gap-2 sm:grid-cols-3;
|
||||
}
|
||||
|
||||
.coach-message {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.coach-message-user {
|
||||
@apply justify-end;
|
||||
}
|
||||
|
||||
.coach-message-assistant {
|
||||
@apply justify-start;
|
||||
}
|
||||
|
||||
.coach-message-bubble {
|
||||
@apply max-w-[840px] rounded-[34px] border px-5 py-4 shadow-sm;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.coach-message-user .coach-message-bubble {
|
||||
background: #ececec;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.coach-message-user .coach-message-bubble,
|
||||
.coach-message-user .coach-message-bubble * {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.thinking-slider {
|
||||
@apply w-full overflow-hidden rounded-full border px-3 py-2 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-color: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.thinking-slider-active {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--outline-variant));
|
||||
}
|
||||
|
||||
.thinking-slider-track {
|
||||
@apply block overflow-hidden whitespace-nowrap;
|
||||
mask-image: linear-gradient(90deg, transparent, black 18%, black 82%, transparent);
|
||||
}
|
||||
|
||||
.thinking-slider-track span {
|
||||
@apply inline-block;
|
||||
padding-left: 100%;
|
||||
animation: thinking-slide 3.2s linear infinite;
|
||||
}
|
||||
|
||||
.thinking-trace {
|
||||
@apply mt-2 max-h-56 overflow-auto rounded-3xl border p-4 text-xs leading-5 whitespace-pre-wrap;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-composer {
|
||||
@apply absolute inset-x-4 bottom-4 z-10 mx-auto flex max-w-4xl items-center gap-3 rounded-full border p-3 sm:bottom-7;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent);
|
||||
box-shadow: 0 18px 50px rgba(74, 102, 122, 0.18);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.coach-input {
|
||||
@apply min-h-12 flex-1 rounded-full border-0 px-2 text-lg shadow-none transition;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer-icon-button,
|
||||
.composer-send-button {
|
||||
@apply grid h-12 w-12 shrink-0 place-items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-45;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.composer-icon-button:hover {
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.composer-send-button {
|
||||
background: #97cbf5;
|
||||
color: #10283a;
|
||||
}
|
||||
|
||||
.composer-send-button:hover:not(:disabled) {
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.composer-stop-button {
|
||||
background: var(--surface-container-high);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-unlock-card {
|
||||
@apply mt-8 flex w-full max-w-xl flex-col gap-3 rounded-full border p-3 sm:flex-row;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent);
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.coach-gemini-shell {
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.can-emblem {
|
||||
@@ -163,75 +635,147 @@ textarea:focus-visible {
|
||||
@apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl;
|
||||
}
|
||||
|
||||
.accent-picker {
|
||||
@apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm;
|
||||
border-color: var(--border);
|
||||
.theme-indicator {
|
||||
@apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-semibold transition;
|
||||
background: var(--surface-container-high);
|
||||
border-color: var(--outline-variant);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.accent-picker button {
|
||||
@apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition;
|
||||
.theme-indicator:hover {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.theme-indicator-swatch {
|
||||
@apply h-4 w-4 rounded-full border border-white shadow-sm;
|
||||
}
|
||||
|
||||
.theme-indicator-label {
|
||||
@apply max-w-[9rem] truncate;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
@apply inline-flex flex-wrap gap-1 rounded-full border p-1;
|
||||
background: var(--surface-container-high);
|
||||
border-color: var(--outline-variant);
|
||||
}
|
||||
|
||||
.settings-tabs button {
|
||||
@apply rounded-full px-4 py-2 text-sm font-semibold transition;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.accent-picker button:hover,
|
||||
.accent-picker-active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text) !important;
|
||||
.settings-tabs button:hover,
|
||||
.settings-tab-active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container) !important;
|
||||
}
|
||||
|
||||
.accent-swatch {
|
||||
@apply h-3 w-3 rounded-full border border-white shadow-sm;
|
||||
.theme-preview-strip {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.accent-swatch-blue {
|
||||
background: #bdeeff;
|
||||
.theme-preview-chip {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.accent-swatch-pink {
|
||||
background: #ffd6e8;
|
||||
.theme-picker-grid {
|
||||
@apply grid gap-3 sm:grid-cols-2 lg:grid-cols-3;
|
||||
}
|
||||
|
||||
.theme-tile {
|
||||
@apply flex min-h-[4.5rem] items-center gap-3 rounded-xl border px-3 py-3 text-left transition;
|
||||
background: var(--surface-container);
|
||||
border-color: var(--outline-variant);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-tile:hover {
|
||||
box-shadow: var(--elevation-1);
|
||||
border-color: var(--outline);
|
||||
}
|
||||
|
||||
.theme-tile-active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--elevation-1);
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.theme-tile-swatch {
|
||||
@apply h-10 w-10 shrink-0 rounded-full border border-white shadow-sm;
|
||||
}
|
||||
|
||||
.theme-tile-label {
|
||||
@apply text-sm font-semibold leading-5;
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--accent: #bdeeff;
|
||||
--accent-soft: #e7f8ff;
|
||||
--accent-strong: #4aa8d6;
|
||||
--accent-warm: #ffe2ef;
|
||||
--bg: #f5fbff;
|
||||
--panel: #f8fcff;
|
||||
--panel-strong: #ffffff;
|
||||
--border: rgba(104, 164, 198, 0.24);
|
||||
--text: #193042;
|
||||
--muted: #607587;
|
||||
--subtle: #7e93a3;
|
||||
--primary: #4b86ad;
|
||||
--on-primary: #ffffff;
|
||||
--primary-container: #dff2ff;
|
||||
--on-primary-container: #10283a;
|
||||
--secondary: #647887;
|
||||
--on-secondary: #ffffff;
|
||||
--secondary-container: #ecf3f7;
|
||||
--on-secondary-container: #1f2d35;
|
||||
--tertiary: #9b7b51;
|
||||
--on-tertiary: #ffffff;
|
||||
--tertiary-container: #f4eadb;
|
||||
--on-tertiary-container: #332313;
|
||||
--error: #ba1a1a;
|
||||
--on-error: #ffffff;
|
||||
--error-container: #ffdad6;
|
||||
--on-error-container: #410002;
|
||||
--bg: #f8fbff;
|
||||
--surface: #f8fbff;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #f1f7fb;
|
||||
--surface-container: #edf3f7;
|
||||
--surface-container-high: #e7eef3;
|
||||
--panel: var(--surface-container);
|
||||
--panel-strong: var(--surface-container-lowest);
|
||||
--outline: #7c8992;
|
||||
--outline-variant: #dce5ea;
|
||||
--text: #1f252a;
|
||||
--muted: #68747c;
|
||||
--subtle: #839099;
|
||||
--accent: var(--primary-container);
|
||||
--accent-soft: var(--surface-container-low);
|
||||
--accent-strong: var(--primary);
|
||||
--accent-warm: #eef4f7;
|
||||
--chart-primary: #4b86ad;
|
||||
--chart-secondary: #6f8f7c;
|
||||
--chart-tertiary: #9b7b51;
|
||||
--chart-error: #ba1a1a;
|
||||
--chart-grid: rgba(124, 137, 146, 0.18);
|
||||
--elevation-1: 0 12px 34px rgba(69, 91, 108, 0.08), 0 1px 2px rgba(69, 91, 108, 0.06);
|
||||
--elevation-2: 0 18px 44px rgba(69, 91, 108, 0.12), 0 2px 8px rgba(69, 91, 108, 0.08);
|
||||
min-height: 100vh;
|
||||
background: var(--bg) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.app-shell[data-accent="pink"] {
|
||||
--accent: #ffd6e8;
|
||||
--accent-soft: #fff0f7;
|
||||
--accent-strong: #d46c9d;
|
||||
--accent-warm: #dff6ff;
|
||||
--bg: #fff8fc;
|
||||
--panel: #fffbfd;
|
||||
--border: rgba(210, 108, 157, 0.22);
|
||||
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.backdrop-wash {
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%),
|
||||
radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%),
|
||||
linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%);
|
||||
radial-gradient(circle at 70% 35%, var(--primary-container) 0 18%, transparent 42%),
|
||||
radial-gradient(circle at 12% 12%, var(--surface-container-lowest) 0 18%, transparent 38%),
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%);
|
||||
}
|
||||
|
||||
.backdrop-grid {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
opacity: 0.5;
|
||||
linear-gradient(var(--chart-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.backdrop-rail {
|
||||
@@ -336,3 +880,9 @@ textarea:focus-visible {
|
||||
.app-shell .modal-panel {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@keyframes thinking-slide {
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const appwriteConfig = {
|
||||
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
|
||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -54,3 +54,23 @@ export type ImportPreview = {
|
||||
fileName: string;
|
||||
rows: ImportPreviewRow[];
|
||||
};
|
||||
|
||||
export type ChatRole = "user" | "assistant";
|
||||
|
||||
export type CoachMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
pending?: boolean;
|
||||
stopped?: boolean;
|
||||
};
|
||||
|
||||
export type CoachChat = {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
messages: CoachMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -5,8 +5,10 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user