style: format codebase with prettier
- commit
- 267d526093abb2135d74dd07e5768edf3ee4ea62
- author
- Kit · · 2025-12-15 06:38 UTC
- parent
- 497fca5
- signature
- SSH Unverified
SHA256:3RGv7q1rcg0JUU2CjMAhwZcyGvcCDmFuxQQT8Z18wLA· arch-ssh This key is not trusted for the committer email
70 files · +9931 -9503
M .github/dependabot.yml +37 -37
| @@ -1,67 +1,67 @@ | |||
| 1 | 1 | version: 2 | |
| 2 | 2 | updates: | |
| 3 | 3 | # npm dependencies via pnpm | |
| 4 | - | - package-ecosystem: "npm" | |
| 5 | - | directory: "/" | |
| 4 | + | - package-ecosystem: 'npm' | |
| 5 | + | directory: '/' | |
| 6 | 6 | schedule: | |
| 7 | - | interval: "weekly" | |
| 8 | - | day: "monday" | |
| 9 | - | time: "09:00" | |
| 10 | - | timezone: "Europe/Paris" | |
| 7 | + | interval: 'weekly' | |
| 8 | + | day: 'monday' | |
| 9 | + | time: '09:00' | |
| 10 | + | timezone: 'Europe/Paris' | |
| 11 | 11 | # Group updates to reduce PR noise | |
| 12 | 12 | groups: | |
| 13 | 13 | # Group SvelteKit and related core framework updates | |
| 14 | 14 | sveltekit: | |
| 15 | 15 | patterns: | |
| 16 | - | - "@sveltejs/*" | |
| 17 | - | - "svelte*" | |
| 16 | + | - '@sveltejs/*' | |
| 17 | + | - 'svelte*' | |
| 18 | 18 | update-types: | |
| 19 | - | - "minor" | |
| 20 | - | - "patch" | |
| 19 | + | - 'minor' | |
| 20 | + | - 'patch' | |
| 21 | 21 | # Group development tooling updates | |
| 22 | 22 | dev-tools: | |
| 23 | 23 | patterns: | |
| 24 | - | - "eslint*" | |
| 25 | - | - "prettier*" | |
| 26 | - | - "@eslint/*" | |
| 27 | - | - "typescript*" | |
| 28 | - | - "@types/*" | |
| 29 | - | - "vite*" | |
| 30 | - | - "vitest*" | |
| 24 | + | - 'eslint*' | |
| 25 | + | - 'prettier*' | |
| 26 | + | - '@eslint/*' | |
| 27 | + | - 'typescript*' | |
| 28 | + | - '@types/*' | |
| 29 | + | - 'vite*' | |
| 30 | + | - 'vitest*' | |
| 31 | 31 | update-types: | |
| 32 | - | - "minor" | |
| 33 | - | - "patch" | |
| 32 | + | - 'minor' | |
| 33 | + | - 'patch' | |
| 34 | 34 | # Group Tailwind CSS related packages | |
| 35 | 35 | tailwind: | |
| 36 | 36 | patterns: | |
| 37 | - | - "tailwindcss*" | |
| 38 | - | - "@tailwindcss/*" | |
| 39 | - | - "autoprefixer" | |
| 37 | + | - 'tailwindcss*' | |
| 38 | + | - '@tailwindcss/*' | |
| 39 | + | - 'autoprefixer' | |
| 40 | 40 | update-types: | |
| 41 | - | - "minor" | |
| 42 | - | - "patch" | |
| 41 | + | - 'minor' | |
| 42 | + | - 'patch' | |
| 43 | 43 | # Group Drizzle ORM related packages | |
| 44 | 44 | drizzle: | |
| 45 | 45 | patterns: | |
| 46 | - | - "drizzle-*" | |
| 47 | - | - "@libsql/*" | |
| 46 | + | - 'drizzle-*' | |
| 47 | + | - '@libsql/*' | |
| 48 | 48 | update-types: | |
| 49 | - | - "minor" | |
| 50 | - | - "patch" | |
| 49 | + | - 'minor' | |
| 50 | + | - 'patch' | |
| 51 | 51 | # Set PR limits to avoid overwhelming the team | |
| 52 | 52 | open-pull-requests-limit: 8 | |
| 53 | 53 | # Ignore packages that require manual updates or are pinned for specific reasons | |
| 54 | 54 | ignore: | |
| 55 | 55 | # Node.js version is managed via engines field and should be manually updated | |
| 56 | - | - dependency-name: "node" | |
| 56 | + | - dependency-name: 'node' | |
| 57 | 57 | # Major framework versions should be manually reviewed due to breaking changes | |
| 58 | - | - dependency-name: "@sveltejs/kit" | |
| 59 | - | update-types: ["version-update:semver-major"] | |
| 60 | - | - dependency-name: "svelte" | |
| 61 | - | update-types: ["version-update:semver-major"] | |
| 62 | - | - dependency-name: "typescript" | |
| 63 | - | update-types: ["version-update:semver-major"] | |
| 58 | + | - dependency-name: '@sveltejs/kit' | |
| 59 | + | update-types: ['version-update:semver-major'] | |
| 60 | + | - dependency-name: 'svelte' | |
| 61 | + | update-types: ['version-update:semver-major'] | |
| 62 | + | - dependency-name: 'typescript' | |
| 63 | + | update-types: ['version-update:semver-major'] | |
| 64 | 64 | # Configure commit message format | |
| 65 | 65 | commit-message: | |
| 66 | - | prefix: "deps" | |
| 67 | - | include: "scope"⏎̸ | |
| 66 | + | prefix: 'deps' | |
| 67 | + | include: 'scope' | |
M .github/workflows/ci.yml +5 -5
| @@ -8,11 +8,11 @@ on: | |||
| 8 | 8 | ||
| 9 | 9 | env: | |
| 10 | 10 | # Dummy values for svelte-check type generation | |
| 11 | - | TURSO_DATABASE_URL: "libsql://dummy.turso.io" | |
| 12 | - | TURSO_AUTH_TOKEN: "dummy-token" | |
| 13 | - | TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA" | |
| 14 | - | PUBLIC_TURNSTILE_SITE_KEY: "1x00000000000000000000AA" | |
| 15 | - | PUBLIC_DEFAULT_CHAT_ROOM_ID: "00000000-0000-0000-0000-000000000001" | |
| 11 | + | TURSO_DATABASE_URL: 'libsql://dummy.turso.io' | |
| 12 | + | TURSO_AUTH_TOKEN: 'dummy-token' | |
| 13 | + | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | |
| 14 | + | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | |
| 15 | + | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | |
| 16 | 16 | ||
| 17 | 17 | jobs: | |
| 18 | 18 | check: | |
M .github/workflows/dependabot-auto-merge.yml +1 -1
| @@ -16,7 +16,7 @@ jobs: | |||
| 16 | 16 | id: metadata | |
| 17 | 17 | uses: dependabot/fetch-metadata@v2 | |
| 18 | 18 | with: | |
| 19 | - | github-token: "${{ secrets.GITHUB_TOKEN }}" | |
| 19 | + | github-token: '${{ secrets.GITHUB_TOKEN }}' | |
| 20 | 20 | - name: Enable auto-merge for minor/patch | |
| 21 | 21 | if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' | |
| 22 | 22 | run: gh pr merge --auto --squash "$PR_URL" | |
M README.md +10 -3
| @@ -1,20 +1,22 @@ | |||
| 1 | 1 | # Bienvenue | |
| 2 | 2 | ||
| 3 | - | Ce projet *sandbox*, à but non lucratif, recrée l'ambiance des salons de discussion de l'époque AOL/AIM ; il est accessible à l'adresse : https://pdraim.org. | |
| 3 | + | Ce projet _sandbox_, à but non lucratif, recrée l'ambiance des salons de discussion de l'époque AOL/AIM ; il est accessible à l'adresse : https://pdraim.org. | |
| 4 | 4 | ||
| 5 | 5 | ## Technologies utilisées | |
| 6 | + | ||
| 6 | 7 | - SvelteKit (version 5) | |
| 7 | 8 | - Node.js | |
| 8 | 9 | - TypeScript | |
| 9 | 10 | - XP.css | |
| 10 | 11 | - Tailwind CSS | |
| 11 | 12 | ||
| 12 | - | ||
| 13 | 13 | ## Prérequis | |
| 14 | + | ||
| 14 | 15 | - Node.js (>=22.13.0) | |
| 15 | 16 | - npm ou pnpm pour la gestion des packages | |
| 16 | 17 | ||
| 17 | 18 | ## Installation | |
| 19 | + | ||
| 18 | 20 | Clonez le dépôt et installez les dépendances : | |
| 19 | 21 | ||
| 20 | 22 | ```bash | |
| @@ -24,6 +26,7 @@ pnpm install | |||
| 24 | 26 | ``` | |
| 25 | 27 | ||
| 26 | 28 | ## Lancement du projet | |
| 29 | + | ||
| 27 | 30 | Lancez le projet en mode développement : | |
| 28 | 31 | ||
| 29 | 32 | ```bash | |
| @@ -35,8 +38,10 @@ pnpm dev | |||
| 35 | 38 | Le site sera accessible via http://localhost:5173 par défaut. | |
| 36 | 39 | ||
| 37 | 40 | ## Contribution | |
| 41 | + | ||
| 38 | 42 | Toute contribution visant à améliorer le réalisme et la qualité du projet est la bienvenue ! | |
| 39 | 43 | Pour contribuer : | |
| 44 | + | ||
| 40 | 45 | - Forkez ce dépôt | |
| 41 | 46 | - Créez une branche pour vos modifications | |
| 42 | 47 | - Envoyez une pull request en décrivant clairement vos changements | |
| @@ -60,6 +65,7 @@ npm run db:push | |||
| 60 | 65 | # ou | |
| 61 | 66 | pnpm db:push | |
| 62 | 67 | ``` | |
| 68 | + | ||
| 63 | 69 | Vous pouvez avoir un aperçu du contenu de la base en utilisant : | |
| 64 | 70 | ||
| 65 | 71 | ```bash | |
| @@ -67,7 +73,8 @@ npx drizzle-kit studio | |||
| 67 | 73 | ``` | |
| 68 | 74 | ||
| 69 | 75 | ## Documentation et Ressources | |
| 76 | + | ||
| 70 | 77 | Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles : | |
| 78 | + | ||
| 71 | 79 | - [Documentation de Svelte](https://svelte.dev/docs/) | |
| 72 | 80 | - [Documentation de SvelteKit](https://svelte.dev/docs/kit/@sveltejs-kit) | |
| 73 | - | ||
M drizzle.config.ts +8 -8
| @@ -2,11 +2,11 @@ import 'dotenv/config'; | |||
| 2 | 2 | import { defineConfig } from 'drizzle-kit'; | |
| 3 | 3 | ||
| 4 | 4 | export default defineConfig({ | |
| 5 | - | out: './drizzle', | |
| 6 | - | schema: './src/lib/db/schema.ts', | |
| 7 | - | dialect: 'turso', | |
| 8 | - | dbCredentials: { | |
| 9 | - | url: process.env.TURSO_DATABASE_URL as string, | |
| 10 | - | authToken: process.env.TURSO_AUTH_TOKEN as string, | |
| 11 | - | }, | |
| 12 | - | });⏎̸ | |
| 5 | + | out: './drizzle', | |
| 6 | + | schema: './src/lib/db/schema.ts', | |
| 7 | + | dialect: 'turso', | |
| 8 | + | dbCredentials: { | |
| 9 | + | url: process.env.TURSO_DATABASE_URL as string, | |
| 10 | + | authToken: process.env.TURSO_AUTH_TOKEN as string | |
| 11 | + | } | |
| 12 | + | }); | |
M drizzle/meta/0000_snapshot.json +331 -341
| @@ -1,342 +1,332 @@ | |||
| 1 | 1 | { | |
| 2 | - | "version": "6", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 5 | - | "prevId": "00000000-0000-0000-0000-000000000000", | |
| 6 | - | "tables": { | |
| 7 | - | "chat_rooms": { | |
| 8 | - | "name": "chat_rooms", | |
| 9 | - | "columns": { | |
| 10 | - | "id": { | |
| 11 | - | "name": "id", | |
| 12 | - | "type": "text", | |
| 13 | - | "primaryKey": true, | |
| 14 | - | "notNull": true, | |
| 15 | - | "autoincrement": false | |
| 16 | - | }, | |
| 17 | - | "name": { | |
| 18 | - | "name": "name", | |
| 19 | - | "type": "text", | |
| 20 | - | "primaryKey": false, | |
| 21 | - | "notNull": false, | |
| 22 | - | "autoincrement": false | |
| 23 | - | }, | |
| 24 | - | "type": { | |
| 25 | - | "name": "type", | |
| 26 | - | "type": "text", | |
| 27 | - | "primaryKey": false, | |
| 28 | - | "notNull": true, | |
| 29 | - | "autoincrement": false, | |
| 30 | - | "default": "'direct'" | |
| 31 | - | }, | |
| 32 | - | "created_at": { | |
| 33 | - | "name": "created_at", | |
| 34 | - | "type": "integer", | |
| 35 | - | "primaryKey": false, | |
| 36 | - | "notNull": true, | |
| 37 | - | "autoincrement": false | |
| 38 | - | } | |
| 39 | - | }, | |
| 40 | - | "indexes": {}, | |
| 41 | - | "foreignKeys": {}, | |
| 42 | - | "compositePrimaryKeys": {}, | |
| 43 | - | "uniqueConstraints": {}, | |
| 44 | - | "checkConstraints": {} | |
| 45 | - | }, | |
| 46 | - | "messages": { | |
| 47 | - | "name": "messages", | |
| 48 | - | "columns": { | |
| 49 | - | "id": { | |
| 50 | - | "name": "id", | |
| 51 | - | "type": "text", | |
| 52 | - | "primaryKey": true, | |
| 53 | - | "notNull": true, | |
| 54 | - | "autoincrement": false | |
| 55 | - | }, | |
| 56 | - | "chat_room_id": { | |
| 57 | - | "name": "chat_room_id", | |
| 58 | - | "type": "text", | |
| 59 | - | "primaryKey": false, | |
| 60 | - | "notNull": true, | |
| 61 | - | "autoincrement": false | |
| 62 | - | }, | |
| 63 | - | "sender_id": { | |
| 64 | - | "name": "sender_id", | |
| 65 | - | "type": "text", | |
| 66 | - | "primaryKey": false, | |
| 67 | - | "notNull": true, | |
| 68 | - | "autoincrement": false | |
| 69 | - | }, | |
| 70 | - | "content": { | |
| 71 | - | "name": "content", | |
| 72 | - | "type": "text", | |
| 73 | - | "primaryKey": false, | |
| 74 | - | "notNull": true, | |
| 75 | - | "autoincrement": false | |
| 76 | - | }, | |
| 77 | - | "type": { | |
| 78 | - | "name": "type", | |
| 79 | - | "type": "text", | |
| 80 | - | "primaryKey": false, | |
| 81 | - | "notNull": true, | |
| 82 | - | "autoincrement": false, | |
| 83 | - | "default": "'chat'" | |
| 84 | - | }, | |
| 85 | - | "timestamp": { | |
| 86 | - | "name": "timestamp", | |
| 87 | - | "type": "integer", | |
| 88 | - | "primaryKey": false, | |
| 89 | - | "notNull": true, | |
| 90 | - | "autoincrement": false | |
| 91 | - | } | |
| 92 | - | }, | |
| 93 | - | "indexes": {}, | |
| 94 | - | "foreignKeys": { | |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | - | "tableFrom": "messages", | |
| 98 | - | "tableTo": "chat_rooms", | |
| 99 | - | "columnsFrom": [ | |
| 100 | - | "chat_room_id" | |
| 101 | - | ], | |
| 102 | - | "columnsTo": [ | |
| 103 | - | "id" | |
| 104 | - | ], | |
| 105 | - | "onDelete": "no action", | |
| 106 | - | "onUpdate": "no action" | |
| 107 | - | }, | |
| 108 | - | "messages_sender_id_users_id_fk": { | |
| 109 | - | "name": "messages_sender_id_users_id_fk", | |
| 110 | - | "tableFrom": "messages", | |
| 111 | - | "tableTo": "users", | |
| 112 | - | "columnsFrom": [ | |
| 113 | - | "sender_id" | |
| 114 | - | ], | |
| 115 | - | "columnsTo": [ | |
| 116 | - | "id" | |
| 117 | - | ], | |
| 118 | - | "onDelete": "no action", | |
| 119 | - | "onUpdate": "no action" | |
| 120 | - | } | |
| 121 | - | }, | |
| 122 | - | "compositePrimaryKeys": {}, | |
| 123 | - | "uniqueConstraints": {}, | |
| 124 | - | "checkConstraints": {} | |
| 125 | - | }, | |
| 126 | - | "users": { | |
| 127 | - | "name": "users", | |
| 128 | - | "columns": { | |
| 129 | - | "id": { | |
| 130 | - | "name": "id", | |
| 131 | - | "type": "text", | |
| 132 | - | "primaryKey": true, | |
| 133 | - | "notNull": true, | |
| 134 | - | "autoincrement": false | |
| 135 | - | }, | |
| 136 | - | "email": { | |
| 137 | - | "name": "email", | |
| 138 | - | "type": "text", | |
| 139 | - | "primaryKey": false, | |
| 140 | - | "notNull": true, | |
| 141 | - | "autoincrement": false | |
| 142 | - | }, | |
| 143 | - | "password": { | |
| 144 | - | "name": "password", | |
| 145 | - | "type": "text", | |
| 146 | - | "primaryKey": false, | |
| 147 | - | "notNull": true, | |
| 148 | - | "autoincrement": false | |
| 149 | - | }, | |
| 150 | - | "nickname": { | |
| 151 | - | "name": "nickname", | |
| 152 | - | "type": "text", | |
| 153 | - | "primaryKey": false, | |
| 154 | - | "notNull": true, | |
| 155 | - | "autoincrement": false | |
| 156 | - | }, | |
| 157 | - | "status": { | |
| 158 | - | "name": "status", | |
| 159 | - | "type": "text", | |
| 160 | - | "primaryKey": false, | |
| 161 | - | "notNull": false, | |
| 162 | - | "autoincrement": false, | |
| 163 | - | "default": "'offline'" | |
| 164 | - | }, | |
| 165 | - | "avatar_url": { | |
| 166 | - | "name": "avatar_url", | |
| 167 | - | "type": "text", | |
| 168 | - | "primaryKey": false, | |
| 169 | - | "notNull": false, | |
| 170 | - | "autoincrement": false | |
| 171 | - | }, | |
| 172 | - | "created_at": { | |
| 173 | - | "name": "created_at", | |
| 174 | - | "type": "integer", | |
| 175 | - | "primaryKey": false, | |
| 176 | - | "notNull": true, | |
| 177 | - | "autoincrement": false | |
| 178 | - | }, | |
| 179 | - | "last_seen": { | |
| 180 | - | "name": "last_seen", | |
| 181 | - | "type": "integer", | |
| 182 | - | "primaryKey": false, | |
| 183 | - | "notNull": false, | |
| 184 | - | "autoincrement": false | |
| 185 | - | } | |
| 186 | - | }, | |
| 187 | - | "indexes": { | |
| 188 | - | "users_email_unique": { | |
| 189 | - | "name": "users_email_unique", | |
| 190 | - | "columns": [ | |
| 191 | - | "email" | |
| 192 | - | ], | |
| 193 | - | "isUnique": true | |
| 194 | - | } | |
| 195 | - | }, | |
| 196 | - | "foreignKeys": {}, | |
| 197 | - | "compositePrimaryKeys": {}, | |
| 198 | - | "uniqueConstraints": {}, | |
| 199 | - | "checkConstraints": {} | |
| 200 | - | } | |
| 201 | - | }, | |
| 202 | - | "views": { | |
| 203 | - | "chat_room_view": { | |
| 204 | - | "columns": { | |
| 205 | - | "id": { | |
| 206 | - | "name": "id", | |
| 207 | - | "type": "text", | |
| 208 | - | "primaryKey": true, | |
| 209 | - | "notNull": true, | |
| 210 | - | "autoincrement": false | |
| 211 | - | }, | |
| 212 | - | "name": { | |
| 213 | - | "name": "name", | |
| 214 | - | "type": "text", | |
| 215 | - | "primaryKey": false, | |
| 216 | - | "notNull": false, | |
| 217 | - | "autoincrement": false | |
| 218 | - | }, | |
| 219 | - | "type": { | |
| 220 | - | "name": "type", | |
| 221 | - | "type": "text", | |
| 222 | - | "primaryKey": false, | |
| 223 | - | "notNull": true, | |
| 224 | - | "autoincrement": false, | |
| 225 | - | "default": "'direct'" | |
| 226 | - | }, | |
| 227 | - | "created_at": { | |
| 228 | - | "name": "created_at", | |
| 229 | - | "type": "integer", | |
| 230 | - | "primaryKey": false, | |
| 231 | - | "notNull": true, | |
| 232 | - | "autoincrement": false | |
| 233 | - | } | |
| 234 | - | }, | |
| 235 | - | "name": "chat_room_view", | |
| 236 | - | "isExisting": false, | |
| 237 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 238 | - | }, | |
| 239 | - | "message_view": { | |
| 240 | - | "columns": { | |
| 241 | - | "id": { | |
| 242 | - | "name": "id", | |
| 243 | - | "type": "text", | |
| 244 | - | "primaryKey": true, | |
| 245 | - | "notNull": true, | |
| 246 | - | "autoincrement": false | |
| 247 | - | }, | |
| 248 | - | "content": { | |
| 249 | - | "name": "content", | |
| 250 | - | "type": "text", | |
| 251 | - | "primaryKey": false, | |
| 252 | - | "notNull": true, | |
| 253 | - | "autoincrement": false | |
| 254 | - | }, | |
| 255 | - | "type": { | |
| 256 | - | "name": "type", | |
| 257 | - | "type": "text", | |
| 258 | - | "primaryKey": false, | |
| 259 | - | "notNull": true, | |
| 260 | - | "autoincrement": false, | |
| 261 | - | "default": "'chat'" | |
| 262 | - | }, | |
| 263 | - | "timestamp": { | |
| 264 | - | "name": "timestamp", | |
| 265 | - | "type": "integer", | |
| 266 | - | "primaryKey": false, | |
| 267 | - | "notNull": true, | |
| 268 | - | "autoincrement": false | |
| 269 | - | }, | |
| 270 | - | "nickname": { | |
| 271 | - | "name": "nickname", | |
| 272 | - | "type": "text", | |
| 273 | - | "primaryKey": false, | |
| 274 | - | "notNull": true, | |
| 275 | - | "autoincrement": false | |
| 276 | - | }, | |
| 277 | - | "name": { | |
| 278 | - | "name": "name", | |
| 279 | - | "type": "text", | |
| 280 | - | "primaryKey": false, | |
| 281 | - | "notNull": false, | |
| 282 | - | "autoincrement": false | |
| 283 | - | } | |
| 284 | - | }, | |
| 285 | - | "name": "message_view", | |
| 286 | - | "isExisting": false, | |
| 287 | - | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 288 | - | }, | |
| 289 | - | "user_view": { | |
| 290 | - | "columns": { | |
| 291 | - | "id": { | |
| 292 | - | "name": "id", | |
| 293 | - | "type": "text", | |
| 294 | - | "primaryKey": true, | |
| 295 | - | "notNull": true, | |
| 296 | - | "autoincrement": false | |
| 297 | - | }, | |
| 298 | - | "nickname": { | |
| 299 | - | "name": "nickname", | |
| 300 | - | "type": "text", | |
| 301 | - | "primaryKey": false, | |
| 302 | - | "notNull": true, | |
| 303 | - | "autoincrement": false | |
| 304 | - | }, | |
| 305 | - | "status": { | |
| 306 | - | "name": "status", | |
| 307 | - | "type": "text", | |
| 308 | - | "primaryKey": false, | |
| 309 | - | "notNull": false, | |
| 310 | - | "autoincrement": false, | |
| 311 | - | "default": "'offline'" | |
| 312 | - | }, | |
| 313 | - | "avatar_url": { | |
| 314 | - | "name": "avatar_url", | |
| 315 | - | "type": "text", | |
| 316 | - | "primaryKey": false, | |
| 317 | - | "notNull": false, | |
| 318 | - | "autoincrement": false | |
| 319 | - | }, | |
| 320 | - | "last_seen": { | |
| 321 | - | "name": "last_seen", | |
| 322 | - | "type": "integer", | |
| 323 | - | "primaryKey": false, | |
| 324 | - | "notNull": false, | |
| 325 | - | "autoincrement": false | |
| 326 | - | } | |
| 327 | - | }, | |
| 328 | - | "name": "user_view", | |
| 329 | - | "isExisting": false, | |
| 330 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 331 | - | } | |
| 332 | - | }, | |
| 333 | - | "enums": {}, | |
| 334 | - | "_meta": { | |
| 335 | - | "schemas": {}, | |
| 336 | - | "tables": {}, | |
| 337 | - | "columns": {} | |
| 338 | - | }, | |
| 339 | - | "internal": { | |
| 340 | - | "indexes": {} | |
| 341 | - | } | |
| 342 | - | }⏎̸ | |
| 2 | + | "version": "6", | |
| 3 | + | "dialect": "sqlite", | |
| 4 | + | "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 5 | + | "prevId": "00000000-0000-0000-0000-000000000000", | |
| 6 | + | "tables": { | |
| 7 | + | "chat_rooms": { | |
| 8 | + | "name": "chat_rooms", | |
| 9 | + | "columns": { | |
| 10 | + | "id": { | |
| 11 | + | "name": "id", | |
| 12 | + | "type": "text", | |
| 13 | + | "primaryKey": true, | |
| 14 | + | "notNull": true, | |
| 15 | + | "autoincrement": false | |
| 16 | + | }, | |
| 17 | + | "name": { | |
| 18 | + | "name": "name", | |
| 19 | + | "type": "text", | |
| 20 | + | "primaryKey": false, | |
| 21 | + | "notNull": false, | |
| 22 | + | "autoincrement": false | |
| 23 | + | }, | |
| 24 | + | "type": { | |
| 25 | + | "name": "type", | |
| 26 | + | "type": "text", | |
| 27 | + | "primaryKey": false, | |
| 28 | + | "notNull": true, | |
| 29 | + | "autoincrement": false, | |
| 30 | + | "default": "'direct'" | |
| 31 | + | }, | |
| 32 | + | "created_at": { | |
| 33 | + | "name": "created_at", | |
| 34 | + | "type": "integer", | |
| 35 | + | "primaryKey": false, | |
| 36 | + | "notNull": true, | |
| 37 | + | "autoincrement": false | |
| 38 | + | } | |
| 39 | + | }, | |
| 40 | + | "indexes": {}, | |
| 41 | + | "foreignKeys": {}, | |
| 42 | + | "compositePrimaryKeys": {}, | |
| 43 | + | "uniqueConstraints": {}, | |
| 44 | + | "checkConstraints": {} | |
| 45 | + | }, | |
| 46 | + | "messages": { | |
| 47 | + | "name": "messages", | |
| 48 | + | "columns": { | |
| 49 | + | "id": { | |
| 50 | + | "name": "id", | |
| 51 | + | "type": "text", | |
| 52 | + | "primaryKey": true, | |
| 53 | + | "notNull": true, | |
| 54 | + | "autoincrement": false | |
| 55 | + | }, | |
| 56 | + | "chat_room_id": { | |
| 57 | + | "name": "chat_room_id", | |
| 58 | + | "type": "text", | |
| 59 | + | "primaryKey": false, | |
| 60 | + | "notNull": true, | |
| 61 | + | "autoincrement": false | |
| 62 | + | }, | |
| 63 | + | "sender_id": { | |
| 64 | + | "name": "sender_id", | |
| 65 | + | "type": "text", | |
| 66 | + | "primaryKey": false, | |
| 67 | + | "notNull": true, | |
| 68 | + | "autoincrement": false | |
| 69 | + | }, | |
| 70 | + | "content": { | |
| 71 | + | "name": "content", | |
| 72 | + | "type": "text", | |
| 73 | + | "primaryKey": false, | |
| 74 | + | "notNull": true, | |
| 75 | + | "autoincrement": false | |
| 76 | + | }, | |
| 77 | + | "type": { | |
| 78 | + | "name": "type", | |
| 79 | + | "type": "text", | |
| 80 | + | "primaryKey": false, | |
| 81 | + | "notNull": true, | |
| 82 | + | "autoincrement": false, | |
| 83 | + | "default": "'chat'" | |
| 84 | + | }, | |
| 85 | + | "timestamp": { | |
| 86 | + | "name": "timestamp", | |
| 87 | + | "type": "integer", | |
| 88 | + | "primaryKey": false, | |
| 89 | + | "notNull": true, | |
| 90 | + | "autoincrement": false | |
| 91 | + | } | |
| 92 | + | }, | |
| 93 | + | "indexes": {}, | |
| 94 | + | "foreignKeys": { | |
| 95 | + | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | + | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | + | "tableFrom": "messages", | |
| 98 | + | "tableTo": "chat_rooms", | |
| 99 | + | "columnsFrom": ["chat_room_id"], | |
| 100 | + | "columnsTo": ["id"], | |
| 101 | + | "onDelete": "no action", | |
| 102 | + | "onUpdate": "no action" | |
| 103 | + | }, | |
| 104 | + | "messages_sender_id_users_id_fk": { | |
| 105 | + | "name": "messages_sender_id_users_id_fk", | |
| 106 | + | "tableFrom": "messages", | |
| 107 | + | "tableTo": "users", | |
| 108 | + | "columnsFrom": ["sender_id"], | |
| 109 | + | "columnsTo": ["id"], | |
| 110 | + | "onDelete": "no action", | |
| 111 | + | "onUpdate": "no action" | |
| 112 | + | } | |
| 113 | + | }, | |
| 114 | + | "compositePrimaryKeys": {}, | |
| 115 | + | "uniqueConstraints": {}, | |
| 116 | + | "checkConstraints": {} | |
| 117 | + | }, | |
| 118 | + | "users": { | |
| 119 | + | "name": "users", | |
| 120 | + | "columns": { | |
| 121 | + | "id": { | |
| 122 | + | "name": "id", | |
| 123 | + | "type": "text", | |
| 124 | + | "primaryKey": true, | |
| 125 | + | "notNull": true, | |
| 126 | + | "autoincrement": false | |
| 127 | + | }, | |
| 128 | + | "email": { | |
| 129 | + | "name": "email", | |
| 130 | + | "type": "text", | |
| 131 | + | "primaryKey": false, | |
| 132 | + | "notNull": true, | |
| 133 | + | "autoincrement": false | |
| 134 | + | }, | |
| 135 | + | "password": { | |
| 136 | + | "name": "password", | |
| 137 | + | "type": "text", | |
| 138 | + | "primaryKey": false, | |
| 139 | + | "notNull": true, | |
| 140 | + | "autoincrement": false | |
| 141 | + | }, | |
| 142 | + | "nickname": { | |
| 143 | + | "name": "nickname", | |
| 144 | + | "type": "text", | |
| 145 | + | "primaryKey": false, | |
| 146 | + | "notNull": true, | |
| 147 | + | "autoincrement": false | |
| 148 | + | }, | |
| 149 | + | "status": { | |
| 150 | + | "name": "status", | |
| 151 | + | "type": "text", | |
| 152 | + | "primaryKey": false, | |
| 153 | + | "notNull": false, | |
| 154 | + | "autoincrement": false, | |
| 155 | + | "default": "'offline'" | |
| 156 | + | }, | |
| 157 | + | "avatar_url": { | |
| 158 | + | "name": "avatar_url", | |
| 159 | + | "type": "text", | |
| 160 | + | "primaryKey": false, | |
| 161 | + | "notNull": false, | |
| 162 | + | "autoincrement": false | |
| 163 | + | }, | |
| 164 | + | "created_at": { | |
| 165 | + | "name": "created_at", | |
| 166 | + | "type": "integer", | |
| 167 | + | "primaryKey": false, | |
| 168 | + | "notNull": true, | |
| 169 | + | "autoincrement": false | |
| 170 | + | }, | |
| 171 | + | "last_seen": { | |
| 172 | + | "name": "last_seen", | |
| 173 | + | "type": "integer", | |
| 174 | + | "primaryKey": false, | |
| 175 | + | "notNull": false, | |
| 176 | + | "autoincrement": false | |
| 177 | + | } | |
| 178 | + | }, | |
| 179 | + | "indexes": { | |
| 180 | + | "users_email_unique": { | |
| 181 | + | "name": "users_email_unique", | |
| 182 | + | "columns": ["email"], | |
| 183 | + | "isUnique": true | |
| 184 | + | } | |
| 185 | + | }, | |
| 186 | + | "foreignKeys": {}, | |
| 187 | + | "compositePrimaryKeys": {}, | |
| 188 | + | "uniqueConstraints": {}, | |
| 189 | + | "checkConstraints": {} | |
| 190 | + | } | |
| 191 | + | }, | |
| 192 | + | "views": { | |
| 193 | + | "chat_room_view": { | |
| 194 | + | "columns": { | |
| 195 | + | "id": { | |
| 196 | + | "name": "id", | |
| 197 | + | "type": "text", | |
| 198 | + | "primaryKey": true, | |
| 199 | + | "notNull": true, | |
| 200 | + | "autoincrement": false | |
| 201 | + | }, | |
| 202 | + | "name": { | |
| 203 | + | "name": "name", | |
| 204 | + | "type": "text", | |
| 205 | + | "primaryKey": false, | |
| 206 | + | "notNull": false, | |
| 207 | + | "autoincrement": false | |
| 208 | + | }, | |
| 209 | + | "type": { | |
| 210 | + | "name": "type", | |
| 211 | + | "type": "text", | |
| 212 | + | "primaryKey": false, | |
| 213 | + | "notNull": true, | |
| 214 | + | "autoincrement": false, | |
| 215 | + | "default": "'direct'" | |
| 216 | + | }, | |
| 217 | + | "created_at": { | |
| 218 | + | "name": "created_at", | |
| 219 | + | "type": "integer", | |
| 220 | + | "primaryKey": false, | |
| 221 | + | "notNull": true, | |
| 222 | + | "autoincrement": false | |
| 223 | + | } | |
| 224 | + | }, | |
| 225 | + | "name": "chat_room_view", | |
| 226 | + | "isExisting": false, | |
| 227 | + | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 228 | + | }, | |
| 229 | + | "message_view": { | |
| 230 | + | "columns": { | |
| 231 | + | "id": { | |
| 232 | + | "name": "id", | |
| 233 | + | "type": "text", | |
| 234 | + | "primaryKey": true, | |
| 235 | + | "notNull": true, | |
| 236 | + | "autoincrement": false | |
| 237 | + | }, | |
| 238 | + | "content": { | |
| 239 | + | "name": "content", | |
| 240 | + | "type": "text", | |
| 241 | + | "primaryKey": false, | |
| 242 | + | "notNull": true, | |
| 243 | + | "autoincrement": false | |
| 244 | + | }, | |
| 245 | + | "type": { | |
| 246 | + | "name": "type", | |
| 247 | + | "type": "text", | |
| 248 | + | "primaryKey": false, | |
| 249 | + | "notNull": true, | |
| 250 | + | "autoincrement": false, | |
| 251 | + | "default": "'chat'" | |
| 252 | + | }, | |
| 253 | + | "timestamp": { | |
| 254 | + | "name": "timestamp", | |
| 255 | + | "type": "integer", | |
| 256 | + | "primaryKey": false, | |
| 257 | + | "notNull": true, | |
| 258 | + | "autoincrement": false | |
| 259 | + | }, | |
| 260 | + | "nickname": { | |
| 261 | + | "name": "nickname", | |
| 262 | + | "type": "text", | |
| 263 | + | "primaryKey": false, | |
| 264 | + | "notNull": true, | |
| 265 | + | "autoincrement": false | |
| 266 | + | }, | |
| 267 | + | "name": { | |
| 268 | + | "name": "name", | |
| 269 | + | "type": "text", | |
| 270 | + | "primaryKey": false, | |
| 271 | + | "notNull": false, | |
| 272 | + | "autoincrement": false | |
| 273 | + | } | |
| 274 | + | }, | |
| 275 | + | "name": "message_view", | |
| 276 | + | "isExisting": false, | |
| 277 | + | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 278 | + | }, | |
| 279 | + | "user_view": { | |
| 280 | + | "columns": { | |
| 281 | + | "id": { | |
| 282 | + | "name": "id", | |
| 283 | + | "type": "text", | |
| 284 | + | "primaryKey": true, | |
| 285 | + | "notNull": true, | |
| 286 | + | "autoincrement": false | |
| 287 | + | }, | |
| 288 | + | "nickname": { | |
| 289 | + | "name": "nickname", | |
| 290 | + | "type": "text", | |
| 291 | + | "primaryKey": false, | |
| 292 | + | "notNull": true, | |
| 293 | + | "autoincrement": false | |
| 294 | + | }, | |
| 295 | + | "status": { | |
| 296 | + | "name": "status", | |
| 297 | + | "type": "text", | |
| 298 | + | "primaryKey": false, | |
| 299 | + | "notNull": false, | |
| 300 | + | "autoincrement": false, | |
| 301 | + | "default": "'offline'" | |
| 302 | + | }, | |
| 303 | + | "avatar_url": { | |
| 304 | + | "name": "avatar_url", | |
| 305 | + | "type": "text", | |
| 306 | + | "primaryKey": false, | |
| 307 | + | "notNull": false, | |
| 308 | + | "autoincrement": false | |
| 309 | + | }, | |
| 310 | + | "last_seen": { | |
| 311 | + | "name": "last_seen", | |
| 312 | + | "type": "integer", | |
| 313 | + | "primaryKey": false, | |
| 314 | + | "notNull": false, | |
| 315 | + | "autoincrement": false | |
| 316 | + | } | |
| 317 | + | }, | |
| 318 | + | "name": "user_view", | |
| 319 | + | "isExisting": false, | |
| 320 | + | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 321 | + | } | |
| 322 | + | }, | |
| 323 | + | "enums": {}, | |
| 324 | + | "_meta": { | |
| 325 | + | "schemas": {}, | |
| 326 | + | "tables": {}, | |
| 327 | + | "columns": {} | |
| 328 | + | }, | |
| 329 | + | "internal": { | |
| 330 | + | "indexes": {} | |
| 331 | + | } | |
| 332 | + | } | |
M drizzle/meta/0001_snapshot.json +366 -378
| @@ -1,379 +1,367 @@ | |||
| 1 | 1 | { | |
| 2 | - | "version": "6", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6", | |
| 5 | - | "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 6 | - | "tables": { | |
| 7 | - | "chat_rooms": { | |
| 8 | - | "name": "chat_rooms", | |
| 9 | - | "columns": { | |
| 10 | - | "id": { | |
| 11 | - | "name": "id", | |
| 12 | - | "type": "text", | |
| 13 | - | "primaryKey": true, | |
| 14 | - | "notNull": true, | |
| 15 | - | "autoincrement": false | |
| 16 | - | }, | |
| 17 | - | "name": { | |
| 18 | - | "name": "name", | |
| 19 | - | "type": "text", | |
| 20 | - | "primaryKey": false, | |
| 21 | - | "notNull": false, | |
| 22 | - | "autoincrement": false | |
| 23 | - | }, | |
| 24 | - | "type": { | |
| 25 | - | "name": "type", | |
| 26 | - | "type": "text", | |
| 27 | - | "primaryKey": false, | |
| 28 | - | "notNull": true, | |
| 29 | - | "autoincrement": false, | |
| 30 | - | "default": "'direct'" | |
| 31 | - | }, | |
| 32 | - | "created_at": { | |
| 33 | - | "name": "created_at", | |
| 34 | - | "type": "integer", | |
| 35 | - | "primaryKey": false, | |
| 36 | - | "notNull": true, | |
| 37 | - | "autoincrement": false | |
| 38 | - | } | |
| 39 | - | }, | |
| 40 | - | "indexes": {}, | |
| 41 | - | "foreignKeys": {}, | |
| 42 | - | "compositePrimaryKeys": {}, | |
| 43 | - | "uniqueConstraints": {}, | |
| 44 | - | "checkConstraints": {} | |
| 45 | - | }, | |
| 46 | - | "messages": { | |
| 47 | - | "name": "messages", | |
| 48 | - | "columns": { | |
| 49 | - | "id": { | |
| 50 | - | "name": "id", | |
| 51 | - | "type": "text", | |
| 52 | - | "primaryKey": true, | |
| 53 | - | "notNull": true, | |
| 54 | - | "autoincrement": false | |
| 55 | - | }, | |
| 56 | - | "chat_room_id": { | |
| 57 | - | "name": "chat_room_id", | |
| 58 | - | "type": "text", | |
| 59 | - | "primaryKey": false, | |
| 60 | - | "notNull": true, | |
| 61 | - | "autoincrement": false | |
| 62 | - | }, | |
| 63 | - | "sender_id": { | |
| 64 | - | "name": "sender_id", | |
| 65 | - | "type": "text", | |
| 66 | - | "primaryKey": false, | |
| 67 | - | "notNull": true, | |
| 68 | - | "autoincrement": false | |
| 69 | - | }, | |
| 70 | - | "content": { | |
| 71 | - | "name": "content", | |
| 72 | - | "type": "text", | |
| 73 | - | "primaryKey": false, | |
| 74 | - | "notNull": true, | |
| 75 | - | "autoincrement": false | |
| 76 | - | }, | |
| 77 | - | "type": { | |
| 78 | - | "name": "type", | |
| 79 | - | "type": "text", | |
| 80 | - | "primaryKey": false, | |
| 81 | - | "notNull": true, | |
| 82 | - | "autoincrement": false, | |
| 83 | - | "default": "'chat'" | |
| 84 | - | }, | |
| 85 | - | "timestamp": { | |
| 86 | - | "name": "timestamp", | |
| 87 | - | "type": "integer", | |
| 88 | - | "primaryKey": false, | |
| 89 | - | "notNull": true, | |
| 90 | - | "autoincrement": false | |
| 91 | - | } | |
| 92 | - | }, | |
| 93 | - | "indexes": {}, | |
| 94 | - | "foreignKeys": { | |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | - | "tableFrom": "messages", | |
| 98 | - | "tableTo": "chat_rooms", | |
| 99 | - | "columnsFrom": [ | |
| 100 | - | "chat_room_id" | |
| 101 | - | ], | |
| 102 | - | "columnsTo": [ | |
| 103 | - | "id" | |
| 104 | - | ], | |
| 105 | - | "onDelete": "no action", | |
| 106 | - | "onUpdate": "no action" | |
| 107 | - | }, | |
| 108 | - | "messages_sender_id_users_id_fk": { | |
| 109 | - | "name": "messages_sender_id_users_id_fk", | |
| 110 | - | "tableFrom": "messages", | |
| 111 | - | "tableTo": "users", | |
| 112 | - | "columnsFrom": [ | |
| 113 | - | "sender_id" | |
| 114 | - | ], | |
| 115 | - | "columnsTo": [ | |
| 116 | - | "id" | |
| 117 | - | ], | |
| 118 | - | "onDelete": "no action", | |
| 119 | - | "onUpdate": "no action" | |
| 120 | - | } | |
| 121 | - | }, | |
| 122 | - | "compositePrimaryKeys": {}, | |
| 123 | - | "uniqueConstraints": {}, | |
| 124 | - | "checkConstraints": {} | |
| 125 | - | }, | |
| 126 | - | "sessions": { | |
| 127 | - | "name": "sessions", | |
| 128 | - | "columns": { | |
| 129 | - | "id": { | |
| 130 | - | "name": "id", | |
| 131 | - | "type": "text", | |
| 132 | - | "primaryKey": true, | |
| 133 | - | "notNull": true, | |
| 134 | - | "autoincrement": false | |
| 135 | - | }, | |
| 136 | - | "user_id": { | |
| 137 | - | "name": "user_id", | |
| 138 | - | "type": "text", | |
| 139 | - | "primaryKey": false, | |
| 140 | - | "notNull": true, | |
| 141 | - | "autoincrement": false | |
| 142 | - | }, | |
| 143 | - | "expires_at": { | |
| 144 | - | "name": "expires_at", | |
| 145 | - | "type": "integer", | |
| 146 | - | "primaryKey": false, | |
| 147 | - | "notNull": true, | |
| 148 | - | "autoincrement": false | |
| 149 | - | }, | |
| 150 | - | "created_at": { | |
| 151 | - | "name": "created_at", | |
| 152 | - | "type": "integer", | |
| 153 | - | "primaryKey": false, | |
| 154 | - | "notNull": true, | |
| 155 | - | "autoincrement": false | |
| 156 | - | } | |
| 157 | - | }, | |
| 158 | - | "indexes": {}, | |
| 159 | - | "foreignKeys": { | |
| 160 | - | "sessions_user_id_users_id_fk": { | |
| 161 | - | "name": "sessions_user_id_users_id_fk", | |
| 162 | - | "tableFrom": "sessions", | |
| 163 | - | "tableTo": "users", | |
| 164 | - | "columnsFrom": [ | |
| 165 | - | "user_id" | |
| 166 | - | ], | |
| 167 | - | "columnsTo": [ | |
| 168 | - | "id" | |
| 169 | - | ], | |
| 170 | - | "onDelete": "no action", | |
| 171 | - | "onUpdate": "no action" | |
| 172 | - | } | |
| 173 | - | }, | |
| 174 | - | "compositePrimaryKeys": {}, | |
| 175 | - | "uniqueConstraints": {}, | |
| 176 | - | "checkConstraints": {} | |
| 177 | - | }, | |
| 178 | - | "users": { | |
| 179 | - | "name": "users", | |
| 180 | - | "columns": { | |
| 181 | - | "id": { | |
| 182 | - | "name": "id", | |
| 183 | - | "type": "text", | |
| 184 | - | "primaryKey": true, | |
| 185 | - | "notNull": true, | |
| 186 | - | "autoincrement": false | |
| 187 | - | }, | |
| 188 | - | "password": { | |
| 189 | - | "name": "password", | |
| 190 | - | "type": "text", | |
| 191 | - | "primaryKey": false, | |
| 192 | - | "notNull": true, | |
| 193 | - | "autoincrement": false | |
| 194 | - | }, | |
| 195 | - | "nickname": { | |
| 196 | - | "name": "nickname", | |
| 197 | - | "type": "text", | |
| 198 | - | "primaryKey": false, | |
| 199 | - | "notNull": true, | |
| 200 | - | "autoincrement": false | |
| 201 | - | }, | |
| 202 | - | "status": { | |
| 203 | - | "name": "status", | |
| 204 | - | "type": "text", | |
| 205 | - | "primaryKey": false, | |
| 206 | - | "notNull": true, | |
| 207 | - | "autoincrement": false, | |
| 208 | - | "default": "'offline'" | |
| 209 | - | }, | |
| 210 | - | "avatar_url": { | |
| 211 | - | "name": "avatar_url", | |
| 212 | - | "type": "text", | |
| 213 | - | "primaryKey": false, | |
| 214 | - | "notNull": false, | |
| 215 | - | "autoincrement": false | |
| 216 | - | }, | |
| 217 | - | "created_at": { | |
| 218 | - | "name": "created_at", | |
| 219 | - | "type": "integer", | |
| 220 | - | "primaryKey": false, | |
| 221 | - | "notNull": true, | |
| 222 | - | "autoincrement": false | |
| 223 | - | }, | |
| 224 | - | "last_seen": { | |
| 225 | - | "name": "last_seen", | |
| 226 | - | "type": "integer", | |
| 227 | - | "primaryKey": false, | |
| 228 | - | "notNull": false, | |
| 229 | - | "autoincrement": false | |
| 230 | - | } | |
| 231 | - | }, | |
| 232 | - | "indexes": {}, | |
| 233 | - | "foreignKeys": {}, | |
| 234 | - | "compositePrimaryKeys": {}, | |
| 235 | - | "uniqueConstraints": {}, | |
| 236 | - | "checkConstraints": {} | |
| 237 | - | } | |
| 238 | - | }, | |
| 239 | - | "views": { | |
| 240 | - | "chat_room_view": { | |
| 241 | - | "columns": { | |
| 242 | - | "id": { | |
| 243 | - | "name": "id", | |
| 244 | - | "type": "text", | |
| 245 | - | "primaryKey": true, | |
| 246 | - | "notNull": true, | |
| 247 | - | "autoincrement": false | |
| 248 | - | }, | |
| 249 | - | "name": { | |
| 250 | - | "name": "name", | |
| 251 | - | "type": "text", | |
| 252 | - | "primaryKey": false, | |
| 253 | - | "notNull": false, | |
| 254 | - | "autoincrement": false | |
| 255 | - | }, | |
| 256 | - | "type": { | |
| 257 | - | "name": "type", | |
| 258 | - | "type": "text", | |
| 259 | - | "primaryKey": false, | |
| 260 | - | "notNull": true, | |
| 261 | - | "autoincrement": false, | |
| 262 | - | "default": "'direct'" | |
| 263 | - | }, | |
| 264 | - | "created_at": { | |
| 265 | - | "name": "created_at", | |
| 266 | - | "type": "integer", | |
| 267 | - | "primaryKey": false, | |
| 268 | - | "notNull": true, | |
| 269 | - | "autoincrement": false | |
| 270 | - | } | |
| 271 | - | }, | |
| 272 | - | "name": "chat_room_view", | |
| 273 | - | "isExisting": false, | |
| 274 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 275 | - | }, | |
| 276 | - | "message_view": { | |
| 277 | - | "columns": { | |
| 278 | - | "id": { | |
| 279 | - | "name": "id", | |
| 280 | - | "type": "text", | |
| 281 | - | "primaryKey": true, | |
| 282 | - | "notNull": true, | |
| 283 | - | "autoincrement": false | |
| 284 | - | }, | |
| 285 | - | "content": { | |
| 286 | - | "name": "content", | |
| 287 | - | "type": "text", | |
| 288 | - | "primaryKey": false, | |
| 289 | - | "notNull": true, | |
| 290 | - | "autoincrement": false | |
| 291 | - | }, | |
| 292 | - | "type": { | |
| 293 | - | "name": "type", | |
| 294 | - | "type": "text", | |
| 295 | - | "primaryKey": false, | |
| 296 | - | "notNull": true, | |
| 297 | - | "autoincrement": false, | |
| 298 | - | "default": "'chat'" | |
| 299 | - | }, | |
| 300 | - | "timestamp": { | |
| 301 | - | "name": "timestamp", | |
| 302 | - | "type": "integer", | |
| 303 | - | "primaryKey": false, | |
| 304 | - | "notNull": true, | |
| 305 | - | "autoincrement": false | |
| 306 | - | }, | |
| 307 | - | "nickname": { | |
| 308 | - | "name": "nickname", | |
| 309 | - | "type": "text", | |
| 310 | - | "primaryKey": false, | |
| 311 | - | "notNull": true, | |
| 312 | - | "autoincrement": false | |
| 313 | - | }, | |
| 314 | - | "name": { | |
| 315 | - | "name": "name", | |
| 316 | - | "type": "text", | |
| 317 | - | "primaryKey": false, | |
| 318 | - | "notNull": false, | |
| 319 | - | "autoincrement": false | |
| 320 | - | } | |
| 321 | - | }, | |
| 322 | - | "name": "message_view", | |
| 323 | - | "isExisting": false, | |
| 324 | - | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 325 | - | }, | |
| 326 | - | "user_view": { | |
| 327 | - | "columns": { | |
| 328 | - | "id": { | |
| 329 | - | "name": "id", | |
| 330 | - | "type": "text", | |
| 331 | - | "primaryKey": true, | |
| 332 | - | "notNull": true, | |
| 333 | - | "autoincrement": false | |
| 334 | - | }, | |
| 335 | - | "nickname": { | |
| 336 | - | "name": "nickname", | |
| 337 | - | "type": "text", | |
| 338 | - | "primaryKey": false, | |
| 339 | - | "notNull": true, | |
| 340 | - | "autoincrement": false | |
| 341 | - | }, | |
| 342 | - | "status": { | |
| 343 | - | "name": "status", | |
| 344 | - | "type": "text", | |
| 345 | - | "primaryKey": false, | |
| 346 | - | "notNull": true, | |
| 347 | - | "autoincrement": false, | |
| 348 | - | "default": "'offline'" | |
| 349 | - | }, | |
| 350 | - | "avatar_url": { | |
| 351 | - | "name": "avatar_url", | |
| 352 | - | "type": "text", | |
| 353 | - | "primaryKey": false, | |
| 354 | - | "notNull": false, | |
| 355 | - | "autoincrement": false | |
| 356 | - | }, | |
| 357 | - | "last_seen": { | |
| 358 | - | "name": "last_seen", | |
| 359 | - | "type": "integer", | |
| 360 | - | "primaryKey": false, | |
| 361 | - | "notNull": false, | |
| 362 | - | "autoincrement": false | |
| 363 | - | } | |
| 364 | - | }, | |
| 365 | - | "name": "user_view", | |
| 366 | - | "isExisting": false, | |
| 367 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 368 | - | } | |
| 369 | - | }, | |
| 370 | - | "enums": {}, | |
| 371 | - | "_meta": { | |
| 372 | - | "schemas": {}, | |
| 373 | - | "tables": {}, | |
| 374 | - | "columns": {} | |
| 375 | - | }, | |
| 376 | - | "internal": { | |
| 377 | - | "indexes": {} | |
| 378 | - | } | |
| 379 | - | }⏎̸ | |
| 2 | + | "version": "6", | |
| 3 | + | "dialect": "sqlite", | |
| 4 | + | "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6", | |
| 5 | + | "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 6 | + | "tables": { | |
| 7 | + | "chat_rooms": { | |
| 8 | + | "name": "chat_rooms", | |
| 9 | + | "columns": { | |
| 10 | + | "id": { | |
| 11 | + | "name": "id", | |
| 12 | + | "type": "text", | |
| 13 | + | "primaryKey": true, | |
| 14 | + | "notNull": true, | |
| 15 | + | "autoincrement": false | |
| 16 | + | }, | |
| 17 | + | "name": { | |
| 18 | + | "name": "name", | |
| 19 | + | "type": "text", | |
| 20 | + | "primaryKey": false, | |
| 21 | + | "notNull": false, | |
| 22 | + | "autoincrement": false | |
| 23 | + | }, | |
| 24 | + | "type": { | |
| 25 | + | "name": "type", | |
| 26 | + | "type": "text", | |
| 27 | + | "primaryKey": false, | |
| 28 | + | "notNull": true, | |
| 29 | + | "autoincrement": false, | |
| 30 | + | "default": "'direct'" | |
| 31 | + | }, | |
| 32 | + | "created_at": { | |
| 33 | + | "name": "created_at", | |
| 34 | + | "type": "integer", | |
| 35 | + | "primaryKey": false, | |
| 36 | + | "notNull": true, | |
| 37 | + | "autoincrement": false | |
| 38 | + | } | |
| 39 | + | }, | |
| 40 | + | "indexes": {}, | |
| 41 | + | "foreignKeys": {}, | |
| 42 | + | "compositePrimaryKeys": {}, | |
| 43 | + | "uniqueConstraints": {}, | |
| 44 | + | "checkConstraints": {} | |
| 45 | + | }, | |
| 46 | + | "messages": { | |
| 47 | + | "name": "messages", | |
| 48 | + | "columns": { | |
| 49 | + | "id": { | |
| 50 | + | "name": "id", | |
| 51 | + | "type": "text", | |
| 52 | + | "primaryKey": true, | |
| 53 | + | "notNull": true, | |
| 54 | + | "autoincrement": false | |
| 55 | + | }, | |
| 56 | + | "chat_room_id": { | |
| 57 | + | "name": "chat_room_id", | |
| 58 | + | "type": "text", | |
| 59 | + | "primaryKey": false, | |
| 60 | + | "notNull": true, | |
| 61 | + | "autoincrement": false | |
| 62 | + | }, | |
| 63 | + | "sender_id": { | |
| 64 | + | "name": "sender_id", | |
| 65 | + | "type": "text", | |
| 66 | + | "primaryKey": false, | |
| 67 | + | "notNull": true, | |
| 68 | + | "autoincrement": false | |
| 69 | + | }, | |
| 70 | + | "content": { | |
| 71 | + | "name": "content", | |
| 72 | + | "type": "text", | |
| 73 | + | "primaryKey": false, | |
| 74 | + | "notNull": true, | |
| 75 | + | "autoincrement": false | |
| 76 | + | }, | |
| 77 | + | "type": { | |
| 78 | + | "name": "type", | |
| 79 | + | "type": "text", | |
| 80 | + | "primaryKey": false, | |
| 81 | + | "notNull": true, | |
| 82 | + | "autoincrement": false, | |
| 83 | + | "default": "'chat'" | |
| 84 | + | }, | |
| 85 | + | "timestamp": { | |
| 86 | + | "name": "timestamp", | |
| 87 | + | "type": "integer", | |
| 88 | + | "primaryKey": false, | |
| 89 | + | "notNull": true, | |
| 90 | + | "autoincrement": false | |
| 91 | + | } | |
| 92 | + | }, | |
| 93 | + | "indexes": {}, | |
| 94 | + | "foreignKeys": { | |
| 95 | + | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | + | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | + | "tableFrom": "messages", | |
| 98 | + | "tableTo": "chat_rooms", | |
| 99 | + | "columnsFrom": ["chat_room_id"], | |
| 100 | + | "columnsTo": ["id"], | |
| 101 | + | "onDelete": "no action", | |
| 102 | + | "onUpdate": "no action" | |
| 103 | + | }, | |
| 104 | + | "messages_sender_id_users_id_fk": { | |
| 105 | + | "name": "messages_sender_id_users_id_fk", | |
| 106 | + | "tableFrom": "messages", | |
| 107 | + | "tableTo": "users", | |
| 108 | + | "columnsFrom": ["sender_id"], | |
| 109 | + | "columnsTo": ["id"], | |
| 110 | + | "onDelete": "no action", | |
| 111 | + | "onUpdate": "no action" | |
| 112 | + | } | |
| 113 | + | }, | |
| 114 | + | "compositePrimaryKeys": {}, | |
| 115 | + | "uniqueConstraints": {}, | |
| 116 | + | "checkConstraints": {} | |
| 117 | + | }, | |
| 118 | + | "sessions": { | |
| 119 | + | "name": "sessions", | |
| 120 | + | "columns": { | |
| 121 | + | "id": { | |
| 122 | + | "name": "id", | |
| 123 | + | "type": "text", | |
| 124 | + | "primaryKey": true, | |
| 125 | + | "notNull": true, | |
| 126 | + | "autoincrement": false | |
| 127 | + | }, | |
| 128 | + | "user_id": { | |
| 129 | + | "name": "user_id", | |
| 130 | + | "type": "text", | |
| 131 | + | "primaryKey": false, | |
| 132 | + | "notNull": true, | |
| 133 | + | "autoincrement": false | |
| 134 | + | }, | |
| 135 | + | "expires_at": { | |
| 136 | + | "name": "expires_at", | |
| 137 | + | "type": "integer", | |
| 138 | + | "primaryKey": false, | |
| 139 | + | "notNull": true, | |
| 140 | + | "autoincrement": false | |
| 141 | + | }, | |
| 142 | + | "created_at": { | |
| 143 | + | "name": "created_at", | |
| 144 | + | "type": "integer", | |
| 145 | + | "primaryKey": false, | |
| 146 | + | "notNull": true, | |
| 147 | + | "autoincrement": false | |
| 148 | + | } | |
| 149 | + | }, | |
| 150 | + | "indexes": {}, | |
| 151 | + | "foreignKeys": { | |
| 152 | + | "sessions_user_id_users_id_fk": { | |
| 153 | + | "name": "sessions_user_id_users_id_fk", | |
| 154 | + | "tableFrom": "sessions", | |
| 155 | + | "tableTo": "users", | |
| 156 | + | "columnsFrom": ["user_id"], | |
| 157 | + | "columnsTo": ["id"], | |
| 158 | + | "onDelete": "no action", | |
| 159 | + | "onUpdate": "no action" | |
| 160 | + | } | |
| 161 | + | }, | |
| 162 | + | "compositePrimaryKeys": {}, | |
| 163 | + | "uniqueConstraints": {}, | |
| 164 | + | "checkConstraints": {} | |
| 165 | + | }, | |
| 166 | + | "users": { | |
| 167 | + | "name": "users", | |
| 168 | + | "columns": { | |
| 169 | + | "id": { | |
| 170 | + | "name": "id", | |
| 171 | + | "type": "text", | |
| 172 | + | "primaryKey": true, | |
| 173 | + | "notNull": true, | |
| 174 | + | "autoincrement": false | |
| 175 | + | }, | |
| 176 | + | "password": { | |
| 177 | + | "name": "password", | |
| 178 | + | "type": "text", | |
| 179 | + | "primaryKey": false, | |
| 180 | + | "notNull": true, | |
| 181 | + | "autoincrement": false | |
| 182 | + | }, | |
| 183 | + | "nickname": { | |
| 184 | + | "name": "nickname", | |
| 185 | + | "type": "text", | |
| 186 | + | "primaryKey": false, | |
| 187 | + | "notNull": true, | |
| 188 | + | "autoincrement": false | |
| 189 | + | }, | |
| 190 | + | "status": { | |
| 191 | + | "name": "status", | |
| 192 | + | "type": "text", | |
| 193 | + | "primaryKey": false, | |
| 194 | + | "notNull": true, | |
| 195 | + | "autoincrement": false, | |
| 196 | + | "default": "'offline'" | |
| 197 | + | }, | |
| 198 | + | "avatar_url": { | |
| 199 | + | "name": "avatar_url", | |
| 200 | + | "type": "text", | |
| 201 | + | "primaryKey": false, | |
| 202 | + | "notNull": false, | |
| 203 | + | "autoincrement": false | |
| 204 | + | }, | |
| 205 | + | "created_at": { | |
| 206 | + | "name": "created_at", | |
| 207 | + | "type": "integer", | |
| 208 | + | "primaryKey": false, | |
| 209 | + | "notNull": true, | |
| 210 | + | "autoincrement": false | |
| 211 | + | }, | |
| 212 | + | "last_seen": { | |
| 213 | + | "name": "last_seen", | |
| 214 | + | "type": "integer", | |
| 215 | + | "primaryKey": false, | |
| 216 | + | "notNull": false, | |
| 217 | + | "autoincrement": false | |
| 218 | + | } | |
| 219 | + | }, | |
| 220 | + | "indexes": {}, | |
| 221 | + | "foreignKeys": {}, | |
| 222 | + | "compositePrimaryKeys": {}, | |
| 223 | + | "uniqueConstraints": {}, | |
| 224 | + | "checkConstraints": {} | |
| 225 | + | } | |
| 226 | + | }, | |
| 227 | + | "views": { | |
| 228 | + | "chat_room_view": { | |
| 229 | + | "columns": { | |
| 230 | + | "id": { | |
| 231 | + | "name": "id", | |
| 232 | + | "type": "text", | |
| 233 | + | "primaryKey": true, | |
| 234 | + | "notNull": true, | |
| 235 | + | "autoincrement": false | |
| 236 | + | }, | |
| 237 | + | "name": { | |
| 238 | + | "name": "name", | |
| 239 | + | "type": "text", | |
| 240 | + | "primaryKey": false, | |
| 241 | + | "notNull": false, | |
| 242 | + | "autoincrement": false | |
| 243 | + | }, | |
| 244 | + | "type": { | |
| 245 | + | "name": "type", | |
| 246 | + | "type": "text", | |
| 247 | + | "primaryKey": false, | |
| 248 | + | "notNull": true, | |
| 249 | + | "autoincrement": false, | |
| 250 | + | "default": "'direct'" | |
| 251 | + | }, | |
| 252 | + | "created_at": { | |
| 253 | + | "name": "created_at", | |
| 254 | + | "type": "integer", | |
| 255 | + | "primaryKey": false, | |
| 256 | + | "notNull": true, | |
| 257 | + | "autoincrement": false | |
| 258 | + | } | |
| 259 | + | }, | |
| 260 | + | "name": "chat_room_view", | |
| 261 | + | "isExisting": false, | |
| 262 | + | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 263 | + | }, | |
| 264 | + | "message_view": { | |
| 265 | + | "columns": { | |
| 266 | + | "id": { | |
| 267 | + | "name": "id", | |
| 268 | + | "type": "text", | |
| 269 | + | "primaryKey": true, | |
| 270 | + | "notNull": true, | |
| 271 | + | "autoincrement": false | |
| 272 | + | }, | |
| 273 | + | "content": { | |
| 274 | + | "name": "content", | |
| 275 | + | "type": "text", | |
| 276 | + | "primaryKey": false, | |
| 277 | + | "notNull": true, | |
| 278 | + | "autoincrement": false | |
| 279 | + | }, | |
| 280 | + | "type": { | |
| 281 | + | "name": "type", | |
| 282 | + | "type": "text", | |
| 283 | + | "primaryKey": false, | |
| 284 | + | "notNull": true, | |
| 285 | + | "autoincrement": false, | |
| 286 | + | "default": "'chat'" | |
| 287 | + | }, | |
| 288 | + | "timestamp": { | |
| 289 | + | "name": "timestamp", | |
| 290 | + | "type": "integer", | |
| 291 | + | "primaryKey": false, | |
| 292 | + | "notNull": true, | |
| 293 | + | "autoincrement": false | |
| 294 | + | }, | |
| 295 | + | "nickname": { | |
| 296 | + | "name": "nickname", | |
| 297 | + | "type": "text", | |
| 298 | + | "primaryKey": false, | |
| 299 | + | "notNull": true, | |
| 300 | + | "autoincrement": false | |
| 301 | + | }, | |
| 302 | + | "name": { | |
| 303 | + | "name": "name", | |
| 304 | + | "type": "text", | |
| 305 | + | "primaryKey": false, | |
| 306 | + | "notNull": false, | |
| 307 | + | "autoincrement": false | |
| 308 | + | } | |
| 309 | + | }, | |
| 310 | + | "name": "message_view", | |
| 311 | + | "isExisting": false, | |
| 312 | + | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 313 | + | }, | |
| 314 | + | "user_view": { | |
| 315 | + | "columns": { | |
| 316 | + | "id": { | |
| 317 | + | "name": "id", | |
| 318 | + | "type": "text", | |
| 319 | + | "primaryKey": true, | |
| 320 | + | "notNull": true, | |
| 321 | + | "autoincrement": false | |
| 322 | + | }, | |
| 323 | + | "nickname": { | |
| 324 | + | "name": "nickname", | |
| 325 | + | "type": "text", | |
| 326 | + | "primaryKey": false, | |
| 327 | + | "notNull": true, | |
| 328 | + | "autoincrement": false | |
| 329 | + | }, | |
| 330 | + | "status": { | |
| 331 | + | "name": "status", | |
| 332 | + | "type": "text", | |
| 333 | + | "primaryKey": false, | |
| 334 | + | "notNull": true, | |
| 335 | + | "autoincrement": false, | |
| 336 | + | "default": "'offline'" | |
| 337 | + | }, | |
| 338 | + | "avatar_url": { | |
| 339 | + | "name": "avatar_url", | |
| 340 | + | "type": "text", | |
| 341 | + | "primaryKey": false, | |
| 342 | + | "notNull": false, | |
| 343 | + | "autoincrement": false | |
| 344 | + | }, | |
| 345 | + | "last_seen": { | |
| 346 | + | "name": "last_seen", | |
| 347 | + | "type": "integer", | |
| 348 | + | "primaryKey": false, | |
| 349 | + | "notNull": false, | |
| 350 | + | "autoincrement": false | |
| 351 | + | } | |
| 352 | + | }, | |
| 353 | + | "name": "user_view", | |
| 354 | + | "isExisting": false, | |
| 355 | + | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 356 | + | } | |
| 357 | + | }, | |
| 358 | + | "enums": {}, | |
| 359 | + | "_meta": { | |
| 360 | + | "schemas": {}, | |
| 361 | + | "tables": {}, | |
| 362 | + | "columns": {} | |
| 363 | + | }, | |
| 364 | + | "internal": { | |
| 365 | + | "indexes": {} | |
| 366 | + | } | |
| 367 | + | } | |
M drizzle/meta/_journal.json +19 -19
| @@ -1,20 +1,20 @@ | |||
| 1 | 1 | { | |
| 2 | - | "version": "7", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "entries": [ | |
| 5 | - | { | |
| 6 | - | "idx": 0, | |
| 7 | - | "version": "6", | |
| 8 | - | "when": 1739208055141, | |
| 9 | - | "tag": "0000_shallow_ghost_rider", | |
| 10 | - | "breakpoints": true | |
| 11 | - | }, | |
| 12 | - | { | |
| 13 | - | "idx": 1, | |
| 14 | - | "version": "6", | |
| 15 | - | "when": 1739432549447, | |
| 16 | - | "tag": "0001_blushing_jean_grey", | |
| 17 | - | "breakpoints": true | |
| 18 | - | } | |
| 19 | - | ] | |
| 20 | - | }⏎̸ | |
| 2 | + | "version": "7", | |
| 3 | + | "dialect": "sqlite", | |
| 4 | + | "entries": [ | |
| 5 | + | { | |
| 6 | + | "idx": 0, | |
| 7 | + | "version": "6", | |
| 8 | + | "when": 1739208055141, | |
| 9 | + | "tag": "0000_shallow_ghost_rider", | |
| 10 | + | "breakpoints": true | |
| 11 | + | }, | |
| 12 | + | { | |
| 13 | + | "idx": 1, | |
| 14 | + | "version": "6", | |
| 15 | + | "when": 1739432549447, | |
| 16 | + | "tag": "0001_blushing_jean_grey", | |
| 17 | + | "breakpoints": true | |
| 18 | + | } | |
| 19 | + | ] | |
| 20 | + | } | |
M src/app.css +342 -325
| @@ -1,116 +1,116 @@ | |||
| 1 | - | @import "tailwindcss"; | |
| 2 | - | @import "tw-animate-css"; | |
| 1 | + | @import 'tailwindcss'; | |
| 2 | + | @import 'tw-animate-css'; | |
| 3 | 3 | ||
| 4 | 4 | @custom-variant dark (&:is(.dark *)); | |
| 5 | 5 | ||
| 6 | 6 | :root { | |
| 7 | - | --background: hsl(0 0% 100%); | |
| 8 | - | --foreground: hsl(240 10% 3.9%); | |
| 9 | - | --muted: hsl(240 4.8% 95.9%); | |
| 10 | - | --muted-foreground: hsl(240 3.8% 46.1%); | |
| 11 | - | --popover: hsl(0 0% 100%); | |
| 12 | - | --popover-foreground: hsl(240 10% 3.9%); | |
| 13 | - | --card: hsl(0 0% 100%); | |
| 14 | - | --card-foreground: hsl(240 10% 3.9%); | |
| 15 | - | --border: hsl(240 5.9% 90%); | |
| 16 | - | --input: hsl(240 5.9% 90%); | |
| 17 | - | --primary: hsl(240 5.9% 10%); | |
| 18 | - | --primary-foreground: hsl(0 0% 98%); | |
| 19 | - | --secondary: hsl(240 4.8% 95.9%); | |
| 20 | - | --secondary-foreground: hsl(240 5.9% 10%); | |
| 21 | - | --accent: hsl(240 4.8% 95.9%); | |
| 22 | - | --accent-foreground: hsl(240 5.9% 10%); | |
| 23 | - | --destructive: hsl(0 72.2% 50.6%); | |
| 24 | - | --destructive-foreground: hsl(0 0% 98%); | |
| 25 | - | --ring: hsl(240 10% 3.9%); | |
| 26 | - | --sidebar: hsl(0 0% 98%); | |
| 27 | - | --sidebar-foreground: hsl(240 5.3% 26.1%); | |
| 28 | - | --sidebar-primary: hsl(240 5.9% 10%); | |
| 29 | - | --sidebar-primary-foreground: hsl(0 0% 98%); | |
| 30 | - | --sidebar-accent: hsl(240 4.8% 95.9%); | |
| 31 | - | --sidebar-accent-foreground: hsl(240 5.9% 10%); | |
| 32 | - | --sidebar-border: hsl(220 13% 91%); | |
| 33 | - | --sidebar-ring: hsl(217.2 91.2% 59.8%); | |
| 34 | - | ||
| 35 | - | --radius: 0.5rem; | |
| 7 | + | --background: hsl(0 0% 100%); | |
| 8 | + | --foreground: hsl(240 10% 3.9%); | |
| 9 | + | --muted: hsl(240 4.8% 95.9%); | |
| 10 | + | --muted-foreground: hsl(240 3.8% 46.1%); | |
| 11 | + | --popover: hsl(0 0% 100%); | |
| 12 | + | --popover-foreground: hsl(240 10% 3.9%); | |
| 13 | + | --card: hsl(0 0% 100%); | |
| 14 | + | --card-foreground: hsl(240 10% 3.9%); | |
| 15 | + | --border: hsl(240 5.9% 90%); | |
| 16 | + | --input: hsl(240 5.9% 90%); | |
| 17 | + | --primary: hsl(240 5.9% 10%); | |
| 18 | + | --primary-foreground: hsl(0 0% 98%); | |
| 19 | + | --secondary: hsl(240 4.8% 95.9%); | |
| 20 | + | --secondary-foreground: hsl(240 5.9% 10%); | |
| 21 | + | --accent: hsl(240 4.8% 95.9%); | |
| 22 | + | --accent-foreground: hsl(240 5.9% 10%); | |
| 23 | + | --destructive: hsl(0 72.2% 50.6%); | |
| 24 | + | --destructive-foreground: hsl(0 0% 98%); | |
| 25 | + | --ring: hsl(240 10% 3.9%); | |
| 26 | + | --sidebar: hsl(0 0% 98%); | |
| 27 | + | --sidebar-foreground: hsl(240 5.3% 26.1%); | |
| 28 | + | --sidebar-primary: hsl(240 5.9% 10%); | |
| 29 | + | --sidebar-primary-foreground: hsl(0 0% 98%); | |
| 30 | + | --sidebar-accent: hsl(240 4.8% 95.9%); | |
| 31 | + | --sidebar-accent-foreground: hsl(240 5.9% 10%); | |
| 32 | + | --sidebar-border: hsl(220 13% 91%); | |
| 33 | + | --sidebar-ring: hsl(217.2 91.2% 59.8%); | |
| 34 | + | ||
| 35 | + | --radius: 0.5rem; | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | .dark { | |
| 39 | - | --background: hsl(240 10% 3.9%); | |
| 40 | - | --foreground: hsl(0 0% 98%); | |
| 41 | - | --muted: hsl(240 3.7% 15.9%); | |
| 42 | - | --muted-foreground: hsl(240 5% 64.9%); | |
| 43 | - | --popover: hsl(240 10% 3.9%); | |
| 44 | - | --popover-foreground: hsl(0 0% 98%); | |
| 45 | - | --card: hsl(240 10% 3.9%); | |
| 46 | - | --card-foreground: hsl(0 0% 98%); | |
| 47 | - | --border: hsl(240 3.7% 15.9%); | |
| 48 | - | --input: hsl(240 3.7% 15.9%); | |
| 49 | - | --primary: hsl(0 0% 98%); | |
| 50 | - | --primary-foreground: hsl(240 5.9% 10%); | |
| 51 | - | --secondary: hsl(240 3.7% 15.9%); | |
| 52 | - | --secondary-foreground: hsl(0 0% 98%); | |
| 53 | - | --accent: hsl(240 3.7% 15.9%); | |
| 54 | - | --accent-foreground: hsl(0 0% 98%); | |
| 55 | - | --destructive: hsl(0 62.8% 30.6%); | |
| 56 | - | --destructive-foreground: hsl(0 0% 98%); | |
| 57 | - | --ring: hsl(240 4.9% 83.9%); | |
| 58 | - | --sidebar: hsl(240 5.9% 10%); | |
| 59 | - | --sidebar-foreground: hsl(240 4.8% 95.9%); | |
| 60 | - | --sidebar-primary: hsl(224.3 76.3% 48%); | |
| 61 | - | --sidebar-primary-foreground: hsl(0 0% 100%); | |
| 62 | - | --sidebar-accent: hsl(240 3.7% 15.9%); | |
| 63 | - | --sidebar-accent-foreground: hsl(240 4.8% 95.9%); | |
| 64 | - | --sidebar-border: hsl(240 3.7% 15.9%); | |
| 65 | - | --sidebar-ring: hsl(217.2 91.2% 59.8%); | |
| 39 | + | --background: hsl(240 10% 3.9%); | |
| 40 | + | --foreground: hsl(0 0% 98%); | |
| 41 | + | --muted: hsl(240 3.7% 15.9%); | |
| 42 | + | --muted-foreground: hsl(240 5% 64.9%); | |
| 43 | + | --popover: hsl(240 10% 3.9%); | |
| 44 | + | --popover-foreground: hsl(0 0% 98%); | |
| 45 | + | --card: hsl(240 10% 3.9%); | |
| 46 | + | --card-foreground: hsl(0 0% 98%); | |
| 47 | + | --border: hsl(240 3.7% 15.9%); | |
| 48 | + | --input: hsl(240 3.7% 15.9%); | |
| 49 | + | --primary: hsl(0 0% 98%); | |
| 50 | + | --primary-foreground: hsl(240 5.9% 10%); | |
| 51 | + | --secondary: hsl(240 3.7% 15.9%); | |
| 52 | + | --secondary-foreground: hsl(0 0% 98%); | |
| 53 | + | --accent: hsl(240 3.7% 15.9%); | |
| 54 | + | --accent-foreground: hsl(0 0% 98%); | |
| 55 | + | --destructive: hsl(0 62.8% 30.6%); | |
| 56 | + | --destructive-foreground: hsl(0 0% 98%); | |
| 57 | + | --ring: hsl(240 4.9% 83.9%); | |
| 58 | + | --sidebar: hsl(240 5.9% 10%); | |
| 59 | + | --sidebar-foreground: hsl(240 4.8% 95.9%); | |
| 60 | + | --sidebar-primary: hsl(224.3 76.3% 48%); | |
| 61 | + | --sidebar-primary-foreground: hsl(0 0% 100%); | |
| 62 | + | --sidebar-accent: hsl(240 3.7% 15.9%); | |
| 63 | + | --sidebar-accent-foreground: hsl(240 4.8% 95.9%); | |
| 64 | + | --sidebar-border: hsl(240 3.7% 15.9%); | |
| 65 | + | --sidebar-ring: hsl(217.2 91.2% 59.8%); | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | @theme inline { | |
| 69 | - | /* Radius (for rounded-*) */ | |
| 70 | - | --radius-sm: calc(var(--radius) - 4px); | |
| 71 | - | --radius-md: calc(var(--radius) - 2px); | |
| 72 | - | --radius-lg: var(--radius); | |
| 73 | - | --radius-xl: calc(var(--radius) + 4px); | |
| 74 | - | ||
| 75 | - | /* Colors */ | |
| 76 | - | --color-background: var(--background); | |
| 77 | - | --color-foreground: var(--foreground); | |
| 78 | - | --color-muted: var(--muted); | |
| 79 | - | --color-muted-foreground: var(--muted-foreground); | |
| 80 | - | --color-popover: var(--popover); | |
| 81 | - | --color-popover-foreground: var(--popover-foreground); | |
| 82 | - | --color-card: var(--card); | |
| 83 | - | --color-card-foreground: var(--card-foreground); | |
| 84 | - | --color-border: var(--border); | |
| 85 | - | --color-input: var(--input); | |
| 86 | - | --color-primary: var(--primary); | |
| 87 | - | --color-primary-foreground: var(--primary-foreground); | |
| 88 | - | --color-secondary: var(--secondary); | |
| 89 | - | --color-secondary-foreground: var(--secondary-foreground); | |
| 90 | - | --color-accent: var(--accent); | |
| 91 | - | --color-accent-foreground: var(--accent-foreground); | |
| 92 | - | --color-destructive: var(--destructive); | |
| 93 | - | --color-destructive-foreground: var(--destructive-foreground); | |
| 94 | - | --color-ring: var(--ring); | |
| 95 | - | --color-radius: var(--radius); | |
| 96 | - | --color-sidebar: var(--sidebar); | |
| 97 | - | --color-sidebar-foreground: var(--sidebar-foreground); | |
| 98 | - | --color-sidebar-primary: var(--sidebar-primary); | |
| 99 | - | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); | |
| 100 | - | --color-sidebar-accent: var(--sidebar-accent); | |
| 101 | - | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); | |
| 102 | - | --color-sidebar-border: var(--sidebar-border); | |
| 103 | - | --color-sidebar-ring: var(--sidebar-ring); | |
| 69 | + | /* Radius (for rounded-*) */ | |
| 70 | + | --radius-sm: calc(var(--radius) - 4px); | |
| 71 | + | --radius-md: calc(var(--radius) - 2px); | |
| 72 | + | --radius-lg: var(--radius); | |
| 73 | + | --radius-xl: calc(var(--radius) + 4px); | |
| 74 | + | ||
| 75 | + | /* Colors */ | |
| 76 | + | --color-background: var(--background); | |
| 77 | + | --color-foreground: var(--foreground); | |
| 78 | + | --color-muted: var(--muted); | |
| 79 | + | --color-muted-foreground: var(--muted-foreground); | |
| 80 | + | --color-popover: var(--popover); | |
| 81 | + | --color-popover-foreground: var(--popover-foreground); | |
| 82 | + | --color-card: var(--card); | |
| 83 | + | --color-card-foreground: var(--card-foreground); | |
| 84 | + | --color-border: var(--border); | |
| 85 | + | --color-input: var(--input); | |
| 86 | + | --color-primary: var(--primary); | |
| 87 | + | --color-primary-foreground: var(--primary-foreground); | |
| 88 | + | --color-secondary: var(--secondary); | |
| 89 | + | --color-secondary-foreground: var(--secondary-foreground); | |
| 90 | + | --color-accent: var(--accent); | |
| 91 | + | --color-accent-foreground: var(--accent-foreground); | |
| 92 | + | --color-destructive: var(--destructive); | |
| 93 | + | --color-destructive-foreground: var(--destructive-foreground); | |
| 94 | + | --color-ring: var(--ring); | |
| 95 | + | --color-radius: var(--radius); | |
| 96 | + | --color-sidebar: var(--sidebar); | |
| 97 | + | --color-sidebar-foreground: var(--sidebar-foreground); | |
| 98 | + | --color-sidebar-primary: var(--sidebar-primary); | |
| 99 | + | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); | |
| 100 | + | --color-sidebar-accent: var(--sidebar-accent); | |
| 101 | + | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); | |
| 102 | + | --color-sidebar-border: var(--sidebar-border); | |
| 103 | + | --color-sidebar-ring: var(--sidebar-ring); | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | @layer base { | |
| 107 | - | * { | |
| 108 | - | @apply border-border; | |
| 109 | - | } | |
| 107 | + | * { | |
| 108 | + | @apply border-border; | |
| 109 | + | } | |
| 110 | 110 | ||
| 111 | - | body { | |
| 112 | - | @apply bg-background text-foreground; | |
| 113 | - | } | |
| 111 | + | body { | |
| 112 | + | @apply bg-background text-foreground; | |
| 113 | + | } | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | @import 'xp.css/dist/XP.css'; | |
| @@ -118,445 +118,462 @@ | |||
| 118 | 118 | ||
| 119 | 119 | /* Local font faces with 30-day cache */ | |
| 120 | 120 | @font-face { | |
| 121 | - | font-family: 'Arial'; | |
| 122 | - | src: url('/fonts/Arial.TTF') format('truetype'); | |
| 123 | - | font-display: swap; | |
| 124 | - | font-weight: normal; | |
| 125 | - | font-style: normal; | |
| 121 | + | font-family: 'Arial'; | |
| 122 | + | src: url('/fonts/Arial.TTF') format('truetype'); | |
| 123 | + | font-display: swap; | |
| 124 | + | font-weight: normal; | |
| 125 | + | font-style: normal; | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | 128 | @font-face { | |
| 129 | - | font-family: 'Arial'; | |
| 130 | - | src: url('/fonts/Arialbd.TTF') format('truetype'); | |
| 131 | - | font-display: swap; | |
| 132 | - | font-weight: bold; | |
| 133 | - | font-style: normal; | |
| 129 | + | font-family: 'Arial'; | |
| 130 | + | src: url('/fonts/Arialbd.TTF') format('truetype'); | |
| 131 | + | font-display: swap; | |
| 132 | + | font-weight: bold; | |
| 133 | + | font-style: normal; | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | @font-face { | |
| 137 | - | font-family: 'Arial'; | |
| 138 | - | src: url('/fonts/Ariali.TTF') format('truetype'); | |
| 139 | - | font-display: swap; | |
| 140 | - | font-weight: normal; | |
| 141 | - | font-style: italic; | |
| 137 | + | font-family: 'Arial'; | |
| 138 | + | src: url('/fonts/Ariali.TTF') format('truetype'); | |
| 139 | + | font-display: swap; | |
| 140 | + | font-weight: normal; | |
| 141 | + | font-style: italic; | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | @font-face { | |
| 145 | - | font-family: 'Arial'; | |
| 146 | - | src: url('/fonts/Arialbi.TTF') format('truetype'); | |
| 147 | - | font-display: swap; | |
| 148 | - | font-weight: bold; | |
| 149 | - | font-style: italic; | |
| 145 | + | font-family: 'Arial'; | |
| 146 | + | src: url('/fonts/Arialbi.TTF') format('truetype'); | |
| 147 | + | font-display: swap; | |
| 148 | + | font-weight: bold; | |
| 149 | + | font-style: italic; | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | 152 | @font-face { | |
| 153 | - | font-family: 'Tahoma'; | |
| 154 | - | src: url('/fonts/tahoma.ttf') format('truetype'); | |
| 155 | - | font-display: swap; | |
| 156 | - | font-weight: 80; | |
| 157 | - | font-style: normal; | |
| 153 | + | font-family: 'Tahoma'; | |
| 154 | + | src: url('/fonts/tahoma.ttf') format('truetype'); | |
| 155 | + | font-display: swap; | |
| 156 | + | font-weight: 80; | |
| 157 | + | font-style: normal; | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | 160 | @font-face { | |
| 161 | - | font-family: 'Tahoma'; | |
| 162 | - | src: url('/fonts/tahomabd.ttf') format('truetype'); | |
| 163 | - | font-display: swap; | |
| 164 | - | font-weight: 200; | |
| 165 | - | font-style: normal; | |
| 161 | + | font-family: 'Tahoma'; | |
| 162 | + | src: url('/fonts/tahomabd.ttf') format('truetype'); | |
| 163 | + | font-display: swap; | |
| 164 | + | font-weight: 200; | |
| 165 | + | font-style: normal; | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | @font-face { | |
| 169 | - | font-family: 'Verdana'; | |
| 170 | - | src: url('/fonts/Verdana.TTF') format('truetype'); | |
| 171 | - | font-display: swap; | |
| 172 | - | font-weight: normal; | |
| 173 | - | font-style: normal; | |
| 169 | + | font-family: 'Verdana'; | |
| 170 | + | src: url('/fonts/Verdana.TTF') format('truetype'); | |
| 171 | + | font-display: swap; | |
| 172 | + | font-weight: normal; | |
| 173 | + | font-style: normal; | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | 176 | @font-face { | |
| 177 | - | font-family: 'Verdana'; | |
| 178 | - | src: url('/fonts/Verdanab.TTF') format('truetype'); | |
| 179 | - | font-display: swap; | |
| 180 | - | font-weight: bold; | |
| 181 | - | font-style: normal; | |
| 177 | + | font-family: 'Verdana'; | |
| 178 | + | src: url('/fonts/Verdanab.TTF') format('truetype'); | |
| 179 | + | font-display: swap; | |
| 180 | + | font-weight: bold; | |
| 181 | + | font-style: normal; | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | 184 | @font-face { | |
| 185 | - | font-family: 'Verdana'; | |
| 186 | - | src: url('/fonts/Verdanai.TTF') format('truetype'); | |
| 187 | - | font-display: swap; | |
| 188 | - | font-weight: normal; | |
| 189 | - | font-style: italic; | |
| 185 | + | font-family: 'Verdana'; | |
| 186 | + | src: url('/fonts/Verdanai.TTF') format('truetype'); | |
| 187 | + | font-display: swap; | |
| 188 | + | font-weight: normal; | |
| 189 | + | font-style: italic; | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | 192 | @font-face { | |
| 193 | - | font-family: 'Verdana'; | |
| 194 | - | src: url('/fonts/Verdanaz.TTF') format('truetype'); | |
| 195 | - | font-display: swap; | |
| 196 | - | font-weight: bold; | |
| 197 | - | font-style: italic; | |
| 193 | + | font-family: 'Verdana'; | |
| 194 | + | src: url('/fonts/Verdanaz.TTF') format('truetype'); | |
| 195 | + | font-display: swap; | |
| 196 | + | font-weight: bold; | |
| 197 | + | font-style: italic; | |
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 200 | @font-face { | |
| 201 | - | font-family: 'Comic Sans MS'; | |
| 202 | - | src: url('/fonts/Comic.TTF') format('truetype'); | |
| 203 | - | font-display: swap; | |
| 204 | - | font-weight: normal; | |
| 205 | - | font-style: normal; | |
| 201 | + | font-family: 'Comic Sans MS'; | |
| 202 | + | src: url('/fonts/Comic.TTF') format('truetype'); | |
| 203 | + | font-display: swap; | |
| 204 | + | font-weight: normal; | |
| 205 | + | font-style: normal; | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | 208 | @font-face { | |
| 209 | - | font-family: 'Comic Sans MS'; | |
| 210 | - | src: url('/fonts/Comicbd.TTF') format('truetype'); | |
| 211 | - | font-display: swap; | |
| 212 | - | font-weight: bold; | |
| 213 | - | font-style: normal; | |
| 209 | + | font-family: 'Comic Sans MS'; | |
| 210 | + | src: url('/fonts/Comicbd.TTF') format('truetype'); | |
| 211 | + | font-display: swap; | |
| 212 | + | font-weight: bold; | |
| 213 | + | font-style: normal; | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | @font-face { | |
| 217 | - | font-family: 'Times New Roman'; | |
| 218 | - | src: url('/fonts/Times.TTF') format('truetype'); | |
| 219 | - | font-display: swap; | |
| 220 | - | font-weight: normal; | |
| 221 | - | font-style: normal; | |
| 217 | + | font-family: 'Times New Roman'; | |
| 218 | + | src: url('/fonts/Times.TTF') format('truetype'); | |
| 219 | + | font-display: swap; | |
| 220 | + | font-weight: normal; | |
| 221 | + | font-style: normal; | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | 224 | @font-face { | |
| 225 | - | font-family: 'Times New Roman'; | |
| 226 | - | src: url('/fonts/Timesbd.TTF') format('truetype'); | |
| 227 | - | font-display: swap; | |
| 228 | - | font-weight: bold; | |
| 229 | - | font-style: normal; | |
| 225 | + | font-family: 'Times New Roman'; | |
| 226 | + | src: url('/fonts/Timesbd.TTF') format('truetype'); | |
| 227 | + | font-display: swap; | |
| 228 | + | font-weight: bold; | |
| 229 | + | font-style: normal; | |
| 230 | 230 | } | |
| 231 | 231 | ||
| 232 | 232 | @font-face { | |
| 233 | - | font-family: 'Times New Roman'; | |
| 234 | - | src: url('/fonts/Timesi.TTF') format('truetype'); | |
| 235 | - | font-display: swap; | |
| 236 | - | font-weight: normal; | |
| 237 | - | font-style: italic; | |
| 233 | + | font-family: 'Times New Roman'; | |
| 234 | + | src: url('/fonts/Timesi.TTF') format('truetype'); | |
| 235 | + | font-display: swap; | |
| 236 | + | font-weight: normal; | |
| 237 | + | font-style: italic; | |
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | @font-face { | |
| 241 | - | font-family: 'Times New Roman'; | |
| 242 | - | src: url('/fonts/Timesbi.TTF') format('truetype'); | |
| 243 | - | font-display: swap; | |
| 244 | - | font-weight: bold; | |
| 245 | - | font-style: italic; | |
| 241 | + | font-family: 'Times New Roman'; | |
| 242 | + | src: url('/fonts/Timesbi.TTF') format('truetype'); | |
| 243 | + | font-display: swap; | |
| 244 | + | font-weight: bold; | |
| 245 | + | font-style: italic; | |
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | 248 | @font-face { | |
| 249 | - | font-family: 'Courier New'; | |
| 250 | - | src: url('/fonts/LiberationMono-Regular.ttf') format('truetype'); | |
| 251 | - | font-display: swap; | |
| 252 | - | font-weight: normal; | |
| 253 | - | font-style: normal; | |
| 249 | + | font-family: 'Courier New'; | |
| 250 | + | src: url('/fonts/LiberationMono-Regular.ttf') format('truetype'); | |
| 251 | + | font-display: swap; | |
| 252 | + | font-weight: normal; | |
| 253 | + | font-style: normal; | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | 256 | @font-face { | |
| 257 | - | font-family: 'Courier New'; | |
| 258 | - | src: url('/fonts/LiberationMono-Bold.ttf') format('truetype'); | |
| 259 | - | font-display: swap; | |
| 260 | - | font-weight: bold; | |
| 261 | - | font-style: normal; | |
| 257 | + | font-family: 'Courier New'; | |
| 258 | + | src: url('/fonts/LiberationMono-Bold.ttf') format('truetype'); | |
| 259 | + | font-display: swap; | |
| 260 | + | font-weight: bold; | |
| 261 | + | font-style: normal; | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | 264 | @font-face { | |
| 265 | - | font-family: 'Courier New'; | |
| 266 | - | src: url('/fonts/LiberationMono-Italic.ttf') format('truetype'); | |
| 267 | - | font-display: swap; | |
| 268 | - | font-weight: normal; | |
| 269 | - | font-style: italic; | |
| 265 | + | font-family: 'Courier New'; | |
| 266 | + | src: url('/fonts/LiberationMono-Italic.ttf') format('truetype'); | |
| 267 | + | font-display: swap; | |
| 268 | + | font-weight: normal; | |
| 269 | + | font-style: italic; | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | 272 | @font-face { | |
| 273 | - | font-family: 'Courier New'; | |
| 274 | - | src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype'); | |
| 275 | - | font-display: swap; | |
| 276 | - | font-weight: bold; | |
| 277 | - | font-style: italic; | |
| 273 | + | font-family: 'Courier New'; | |
| 274 | + | src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype'); | |
| 275 | + | font-display: swap; | |
| 276 | + | font-weight: bold; | |
| 277 | + | font-style: italic; | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | 280 | /* ===== RETRO FONT STYLING ===== */ | |
| 281 | 281 | /* Retro Windows fonts with proper fallbacks */ | |
| 282 | 282 | .retro-font-pixelated { | |
| 283 | - | font-family: "Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif; | |
| 283 | + | font-family: 'Pixelated MS Sans Serif', 'Tahoma', Geneva, sans-serif; | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | - | .retro-font-tahoma { | |
| 287 | - | font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif; | |
| 286 | + | .retro-font-tahoma { | |
| 287 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', Geneva, sans-serif; | |
| 288 | 288 | } | |
| 289 | 289 | ||
| 290 | - | .retro-font-verdana { | |
| 291 | - | font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif; | |
| 290 | + | .retro-font-verdana { | |
| 291 | + | font-family: Verdana, 'Pixelated MS Sans Serif', Geneva, sans-serif; | |
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | - | .retro-font-comicSans { | |
| 295 | - | font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif; | |
| 294 | + | .retro-font-comicSans { | |
| 295 | + | font-family: 'Comic Sans MS', 'Pixelated MS Sans Serif', cursive, sans-serif; | |
| 296 | 296 | } | |
| 297 | 297 | ||
| 298 | - | .retro-font-timesNewRoman { | |
| 299 | - | font-family: "Times New Roman", Times, serif; | |
| 298 | + | .retro-font-timesNewRoman { | |
| 299 | + | font-family: 'Times New Roman', Times, serif; | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | .retro-font-arial { | |
| 303 | - | font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif; | |
| 302 | + | .retro-font-arial { | |
| 303 | + | font-family: Arial, 'Pixelated MS Sans Serif', Helvetica, sans-serif; | |
| 304 | 304 | } | |
| 305 | 305 | ||
| 306 | - | .retro-font-courierNew { | |
| 307 | - | font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace; | |
| 306 | + | .retro-font-courierNew { | |
| 307 | + | font-family: 'Courier New', 'Perfect DOS VGA 437 Win', Courier, monospace; | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | 310 | .retro-font-dos { | |
| 311 | - | font-family: "Perfect DOS VGA 437 Win", "Courier New", Courier, monospace; | |
| 311 | + | font-family: 'Perfect DOS VGA 437 Win', 'Courier New', Courier, monospace; | |
| 312 | 312 | } | |
| 313 | 313 | ||
| 314 | 314 | /* ===== GRADIENT TEXT EFFECTS ===== */ | |
| 315 | 315 | .gradient-text { | |
| 316 | - | background-size: 200% 200%; | |
| 317 | - | -webkit-background-clip: text; | |
| 318 | - | -webkit-text-fill-color: transparent; | |
| 319 | - | background-clip: text; | |
| 320 | - | animation: gradientShift 3s ease infinite; | |
| 316 | + | background-size: 200% 200%; | |
| 317 | + | -webkit-background-clip: text; | |
| 318 | + | -webkit-text-fill-color: transparent; | |
| 319 | + | background-clip: text; | |
| 320 | + | animation: gradientShift 3s ease infinite; | |
| 321 | 321 | } | |
| 322 | 322 | ||
| 323 | 323 | .gradient-text-static { | |
| 324 | - | /* Remove text-fill-color to allow inline color styles to work */ | |
| 325 | - | /* Individual spans will have their own colors set inline */ | |
| 326 | - | letter-spacing: normal; | |
| 327 | - | word-spacing: normal; | |
| 324 | + | /* Remove text-fill-color to allow inline color styles to work */ | |
| 325 | + | /* Individual spans will have their own colors set inline */ | |
| 326 | + | letter-spacing: normal; | |
| 327 | + | word-spacing: normal; | |
| 328 | 328 | } | |
| 329 | 329 | ||
| 330 | 330 | /* Gradient text container uses flexbox */ | |
| 331 | 331 | .gradient-text-static { | |
| 332 | - | display: inline-flex !important; | |
| 333 | - | flex-wrap: wrap !important; | |
| 334 | - | align-items: baseline !important; | |
| 332 | + | display: inline-flex !important; | |
| 333 | + | flex-wrap: wrap !important; | |
| 334 | + | align-items: baseline !important; | |
| 335 | 335 | } | |
| 336 | 336 | ||
| 337 | 337 | /* Ensure gradient character spans don't add spacing */ | |
| 338 | 338 | .gradient-text-static > span { | |
| 339 | - | display: inline !important; | |
| 340 | - | margin: 0 !important; | |
| 341 | - | padding: 0 !important; | |
| 342 | - | letter-spacing: 0 !important; | |
| 343 | - | line-height: inherit !important; | |
| 344 | - | font-size: inherit !important; | |
| 339 | + | display: inline !important; | |
| 340 | + | margin: 0 !important; | |
| 341 | + | padding: 0 !important; | |
| 342 | + | letter-spacing: 0 !important; | |
| 343 | + | line-height: inherit !important; | |
| 344 | + | font-size: inherit !important; | |
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | 347 | /* Ensure gradient character colors from inline styles are respected */ | |
| 348 | 348 | .gradient-text-static .gradient-char { | |
| 349 | - | -webkit-text-fill-color: initial !important; | |
| 349 | + | -webkit-text-fill-color: initial !important; | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | 352 | /* Gradient text in input fields - preserve normal input background */ | |
| 353 | - | input[style*="background-clip: text"] { | |
| 354 | - | /* Keep default input background while allowing gradient text */ | |
| 353 | + | input[style*='background-clip: text'] { | |
| 354 | + | /* Keep default input background while allowing gradient text */ | |
| 355 | 355 | } | |
| 356 | 356 | ||
| 357 | 357 | /* Gradient animation for AOL-style effects */ | |
| 358 | 358 | @keyframes gradientShift { | |
| 359 | - | 0% { background-position: 0% 50%; } | |
| 360 | - | 50% { background-position: 100% 50%; } | |
| 361 | - | 100% { background-position: 0% 50%; } | |
| 359 | + | 0% { | |
| 360 | + | background-position: 0% 50%; | |
| 361 | + | } | |
| 362 | + | 50% { | |
| 363 | + | background-position: 100% 50%; | |
| 364 | + | } | |
| 365 | + | 100% { | |
| 366 | + | background-position: 0% 50%; | |
| 367 | + | } | |
| 362 | 368 | } | |
| 363 | 369 | ||
| 364 | 370 | /* Specific gradient presets */ | |
| 365 | 371 | .gradient-rainbow { | |
| 366 | - | background: linear-gradient(45deg, | |
| 367 | - | #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80, | |
| 368 | - | #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080); | |
| 372 | + | background: linear-gradient( | |
| 373 | + | 45deg, | |
| 374 | + | #ff0000, | |
| 375 | + | #ff8000, | |
| 376 | + | #ffff00, | |
| 377 | + | #80ff00, | |
| 378 | + | #00ff00, | |
| 379 | + | #00ff80, | |
| 380 | + | #00ffff, | |
| 381 | + | #0080ff, | |
| 382 | + | #0000ff, | |
| 383 | + | #8000ff, | |
| 384 | + | #ff00ff, | |
| 385 | + | #ff0080 | |
| 386 | + | ); | |
| 369 | 387 | } | |
| 370 | 388 | ||
| 371 | 389 | .gradient-fire { | |
| 372 | - | background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00); | |
| 390 | + | background: linear-gradient(45deg, #ff0000, #ff4000, #ff8000, #ffbf00, #ffff00); | |
| 373 | 391 | } | |
| 374 | 392 | ||
| 375 | 393 | .gradient-ocean { | |
| 376 | - | background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF); | |
| 394 | + | background: linear-gradient(45deg, #000080, #0040ff, #0080ff, #00bfff, #00ffff); | |
| 377 | 395 | } | |
| 378 | 396 | ||
| 379 | 397 | .gradient-sunset { | |
| 380 | - | background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00); | |
| 398 | + | background: linear-gradient(45deg, #ff4500, #ff6347, #ffd700, #ffff00); | |
| 381 | 399 | } | |
| 382 | 400 | ||
| 383 | 401 | .gradient-neon { | |
| 384 | - | background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF); | |
| 402 | + | background: linear-gradient(45deg, #ff00ff, #8000ff, #0080ff, #00ffff); | |
| 385 | 403 | } | |
| 386 | 404 | ||
| 387 | 405 | .gradient-forest { | |
| 388 | - | background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90); | |
| 406 | + | background: linear-gradient(45deg, #006400, #228b22, #32cd32, #90ee90); | |
| 389 | 407 | } | |
| 390 | 408 | ||
| 391 | 409 | .gradient-royal { | |
| 392 | - | background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD); | |
| 410 | + | background: linear-gradient(45deg, #4b0082, #6a5acd, #9370db, #dda0dd); | |
| 393 | 411 | } | |
| 394 | 412 | ||
| 395 | 413 | /* ===== CHAT MESSAGE FORMATTING ===== */ | |
| 396 | 414 | .formatted-message { | |
| 397 | - | word-break: break-word; | |
| 398 | - | line-height: 1.4; | |
| 399 | - | max-width: 100%; | |
| 415 | + | word-break: break-word; | |
| 416 | + | line-height: 1.4; | |
| 417 | + | max-width: 100%; | |
| 400 | 418 | } | |
| 401 | 419 | ||
| 402 | 420 | .formatted-message strong { | |
| 403 | - | font-weight: bold; | |
| 421 | + | font-weight: bold; | |
| 404 | 422 | } | |
| 405 | 423 | ||
| 406 | 424 | .formatted-message em { | |
| 407 | - | font-style: italic; | |
| 425 | + | font-style: italic; | |
| 408 | 426 | } | |
| 409 | 427 | ||
| 410 | 428 | .formatted-message u { | |
| 411 | - | text-decoration: underline; | |
| 429 | + | text-decoration: underline; | |
| 412 | 430 | } | |
| 413 | 431 | ||
| 414 | 432 | .formatted-message s { | |
| 415 | - | text-decoration: line-through; | |
| 433 | + | text-decoration: line-through; | |
| 416 | 434 | } | |
| 417 | 435 | ||
| 418 | 436 | .formatted-message code { | |
| 419 | - | background: #f0f0f0; | |
| 420 | - | padding: 1px 3px; | |
| 421 | - | border-radius: 2px; | |
| 422 | - | font-family: 'Courier New', Courier, monospace; | |
| 423 | - | font-size: 0.9em; | |
| 437 | + | background: #f0f0f0; | |
| 438 | + | padding: 1px 3px; | |
| 439 | + | border-radius: 2px; | |
| 440 | + | font-family: 'Courier New', Courier, monospace; | |
| 441 | + | font-size: 0.9em; | |
| 424 | 442 | } | |
| 425 | 443 | ||
| 426 | 444 | /* ===== INPUT STYLING FIXES ===== */ | |
| 427 | 445 | /* Fix letter spacing issues with gradient text in inputs */ | |
| 428 | - | input[style*="background-clip: text"] { | |
| 429 | - | letter-spacing: normal !important; | |
| 430 | - | word-spacing: normal !important; | |
| 446 | + | input[style*='background-clip: text'] { | |
| 447 | + | letter-spacing: normal !important; | |
| 448 | + | word-spacing: normal !important; | |
| 431 | 449 | } | |
| 432 | 450 | ||
| 433 | 451 | /* Ensure gradient inputs don't have extra padding */ | |
| 434 | 452 | input.styled-input { | |
| 435 | - | letter-spacing: 0; | |
| 436 | - | word-spacing: 0; | |
| 453 | + | letter-spacing: 0; | |
| 454 | + | word-spacing: 0; | |
| 437 | 455 | } | |
| 438 | 456 | ||
| 439 | 457 | /* Ensure proper font weights are used */ | |
| 440 | 458 | .retro-font-tahoma { | |
| 441 | - | font-weight: 80; | |
| 459 | + | font-weight: 80; | |
| 442 | 460 | } | |
| 443 | 461 | ||
| 444 | 462 | /* ===== TEXT STYLE SELECTOR STYLING ===== */ | |
| 445 | 463 | .text-formatting-toolbar { | |
| 446 | - | background: #f0f0f0; | |
| 447 | - | border: 2px inset #dfdfdf; | |
| 448 | - | padding: 4px; | |
| 449 | - | margin-bottom: 8px; | |
| 450 | - | display: flex; | |
| 451 | - | flex-wrap: wrap; | |
| 452 | - | gap: 4px; | |
| 453 | - | align-items: center; | |
| 464 | + | background: #f0f0f0; | |
| 465 | + | border: 2px inset #dfdfdf; | |
| 466 | + | padding: 4px; | |
| 467 | + | margin-bottom: 8px; | |
| 468 | + | display: flex; | |
| 469 | + | flex-wrap: wrap; | |
| 470 | + | gap: 4px; | |
| 471 | + | align-items: center; | |
| 454 | 472 | } | |
| 455 | 473 | ||
| 456 | 474 | .formatting-button { | |
| 457 | - | width: 24px; | |
| 458 | - | height: 24px; | |
| 459 | - | border: 1px outset #dfdfdf; | |
| 460 | - | background: #f0f0f0; | |
| 461 | - | cursor: pointer; | |
| 462 | - | display: flex; | |
| 463 | - | align-items: center; | |
| 464 | - | justify-content: center; | |
| 465 | - | font-size: 12px; | |
| 466 | - | font-weight: bold; | |
| 467 | - | transition: all 0.1s ease; | |
| 475 | + | width: 24px; | |
| 476 | + | height: 24px; | |
| 477 | + | border: 1px outset #dfdfdf; | |
| 478 | + | background: #f0f0f0; | |
| 479 | + | cursor: pointer; | |
| 480 | + | display: flex; | |
| 481 | + | align-items: center; | |
| 482 | + | justify-content: center; | |
| 483 | + | font-size: 12px; | |
| 484 | + | font-weight: bold; | |
| 485 | + | transition: all 0.1s ease; | |
| 468 | 486 | } | |
| 469 | 487 | ||
| 470 | 488 | .formatting-button:hover { | |
| 471 | - | background: #e0e0e0; | |
| 489 | + | background: #e0e0e0; | |
| 472 | 490 | } | |
| 473 | 491 | ||
| 474 | 492 | .formatting-button:active, | |
| 475 | 493 | .formatting-button.active { | |
| 476 | - | border: 1px inset #dfdfdf; | |
| 477 | - | background: #d0d0d0; | |
| 494 | + | border: 1px inset #dfdfdf; | |
| 495 | + | background: #d0d0d0; | |
| 478 | 496 | } | |
| 479 | 497 | ||
| 480 | 498 | .font-selector { | |
| 481 | - | padding: 2px 4px; | |
| 482 | - | border: 1px inset #dfdfdf; | |
| 483 | - | background: white; | |
| 484 | - | font-size: 11px; | |
| 485 | - | min-width: 80px; | |
| 499 | + | padding: 2px 4px; | |
| 500 | + | border: 1px inset #dfdfdf; | |
| 501 | + | background: white; | |
| 502 | + | font-size: 11px; | |
| 503 | + | min-width: 80px; | |
| 486 | 504 | } | |
| 487 | 505 | ||
| 488 | 506 | .size-slider { | |
| 489 | - | width: 60px; | |
| 507 | + | width: 60px; | |
| 490 | 508 | } | |
| 491 | 509 | ||
| 492 | 510 | .color-indicator { | |
| 493 | - | width: 16px; | |
| 494 | - | height: 16px; | |
| 495 | - | border: 1px solid #000; | |
| 496 | - | cursor: pointer; | |
| 497 | - | display: inline-block; | |
| 511 | + | width: 16px; | |
| 512 | + | height: 16px; | |
| 513 | + | border: 1px solid #000; | |
| 514 | + | cursor: pointer; | |
| 515 | + | display: inline-block; | |
| 498 | 516 | } | |
| 499 | 517 | ||
| 500 | 518 | /* ===== ACCESSIBILITY IMPROVEMENTS ===== */ | |
| 501 | 519 | @media (prefers-reduced-motion: reduce) { | |
| 502 | - | .gradient-text { | |
| 503 | - | animation: none; | |
| 504 | - | } | |
| 520 | + | .gradient-text { | |
| 521 | + | animation: none; | |
| 522 | + | } | |
| 505 | 523 | } | |
| 506 | 524 | ||
| 507 | 525 | /* High contrast mode support */ | |
| 508 | 526 | @media (prefers-contrast: high) { | |
| 509 | - | .gradient-text { | |
| 510 | - | -webkit-text-fill-color: unset; | |
| 511 | - | background: none; | |
| 512 | - | color: inherit; | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | .gradient-text-static { | |
| 516 | - | -webkit-text-fill-color: unset; | |
| 517 | - | background: none; | |
| 518 | - | color: inherit; | |
| 519 | - | } | |
| 527 | + | .gradient-text { | |
| 528 | + | -webkit-text-fill-color: unset; | |
| 529 | + | background: none; | |
| 530 | + | color: inherit; | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | .gradient-text-static { | |
| 534 | + | -webkit-text-fill-color: unset; | |
| 535 | + | background: none; | |
| 536 | + | color: inherit; | |
| 537 | + | } | |
| 520 | 538 | } | |
| 521 | 539 | ||
| 522 | 540 | /* ===== RETRO STYLING ENHANCEMENTS ===== */ | |
| 523 | 541 | .retro-message { | |
| 524 | - | font-family: inherit; | |
| 525 | - | line-height: 1.3; | |
| 526 | - | word-wrap: break-word; | |
| 542 | + | font-family: inherit; | |
| 543 | + | line-height: 1.3; | |
| 544 | + | word-wrap: break-word; | |
| 527 | 545 | } | |
| 528 | 546 | ||
| 529 | 547 | .retro-message.style-bold { | |
| 530 | - | font-weight: bold; | |
| 548 | + | font-weight: bold; | |
| 531 | 549 | } | |
| 532 | 550 | ||
| 533 | 551 | .retro-message.style-italic { | |
| 534 | - | font-style: italic; | |
| 552 | + | font-style: italic; | |
| 535 | 553 | } | |
| 536 | 554 | ||
| 537 | 555 | .retro-message.style-underline { | |
| 538 | - | text-decoration: underline; | |
| 556 | + | text-decoration: underline; | |
| 539 | 557 | } | |
| 540 | 558 | ||
| 541 | 559 | .retro-message.style-strikethrough { | |
| 542 | - | text-decoration: line-through; | |
| 560 | + | text-decoration: line-through; | |
| 543 | 561 | } | |
| 544 | 562 | ||
| 545 | 563 | .retro-message.style-underline.style-strikethrough { | |
| 546 | - | text-decoration: underline line-through; | |
| 564 | + | text-decoration: underline line-through; | |
| 547 | 565 | } | |
| 548 | 566 | ||
| 549 | 567 | /* ===== PERFORMANCE OPTIMIZATIONS ===== */ | |
| 550 | 568 | .gradient-text, | |
| 551 | 569 | .gradient-text-static { | |
| 552 | - | will-change: background-position; | |
| 553 | - | contain: layout style paint; | |
| 570 | + | will-change: background-position; | |
| 571 | + | contain: layout style paint; | |
| 554 | 572 | } | |
| 555 | 573 | ||
| 556 | 574 | /* Limit gradient text length for performance */ | |
| 557 | 575 | .gradient-text-long { | |
| 558 | - | max-width: 500px; | |
| 559 | - | overflow: hidden; | |
| 560 | - | text-overflow: ellipsis; | |
| 576 | + | max-width: 500px; | |
| 577 | + | overflow: hidden; | |
| 578 | + | text-overflow: ellipsis; | |
| 561 | 579 | } | |
| 562 | - | ||
M src/app.d.ts +2 -2
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | |
| 2 | 2 | // for information about these interfaces | |
| 3 | - | import type { User, Session } from "$lib/types/chat"; | |
| 3 | + | import type { User, Session } from '$lib/types/chat'; | |
| 4 | 4 | ||
| 5 | 5 | declare global { | |
| 6 | 6 | namespace App { | |
| @@ -13,7 +13,7 @@ declare global { | |||
| 13 | 13 | // interface PageState {} | |
| 14 | 14 | // interface Platform {} | |
| 15 | 15 | } | |
| 16 | - | type Window = object | |
| 16 | + | type Window = object; | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | export {}; | |
M src/hooks.server.ts +88 -87
| @@ -1,105 +1,106 @@ | |||
| 1 | - | import type { Handle } from "@sveltejs/kit"; | |
| 2 | - | import db from "$lib/db/db.server"; | |
| 3 | - | import { handleRateLimit } from "$lib/api/rate-limiter"; | |
| 4 | - | import { users } from "$lib/db/schema"; | |
| 5 | - | import { createLogger } from "$lib/utils/logger.server"; | |
| 6 | - | import { ensureDefaultChatRoom } from "$lib/utils/chat.server"; | |
| 7 | - | import { validateSessionToken } from "$lib/api/session.server"; | |
| 8 | - | import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie"; | |
| 1 | + | import type { Handle } from '@sveltejs/kit'; | |
| 2 | + | import db from '$lib/db/db.server'; | |
| 3 | + | import { handleRateLimit } from '$lib/api/rate-limiter'; | |
| 4 | + | import { users } from '$lib/db/schema'; | |
| 5 | + | import { createLogger } from '$lib/utils/logger.server'; | |
| 6 | + | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | |
| 7 | + | import { validateSessionToken } from '$lib/api/session.server'; | |
| 8 | + | import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 9 | 9 | ||
| 10 | 10 | const log = createLogger('hooks-server'); | |
| 11 | 11 | ||
| 12 | 12 | // Set all users to offline on server start | |
| 13 | 13 | async function setAllUsersOffline() { | |
| 14 | - | try { | |
| 15 | - | await db.update(users) | |
| 16 | - | .set({ status: 'offline'}); | |
| 17 | - | log.info('All users set to offline on server start'); | |
| 18 | - | } catch (error: unknown) { | |
| 19 | - | log.error('Failed to set users offline on server start:', { error }); | |
| 20 | - | } | |
| 14 | + | try { | |
| 15 | + | await db.update(users).set({ status: 'offline' }); | |
| 16 | + | log.info('All users set to offline on server start'); | |
| 17 | + | } catch (error: unknown) { | |
| 18 | + | log.error('Failed to set users offline on server start:', { error }); | |
| 19 | + | } | |
| 21 | 20 | } | |
| 22 | 21 | ||
| 23 | 22 | // Initialize server | |
| 24 | 23 | Promise.resolve() | |
| 25 | - | .then(async () => { | |
| 26 | - | log.info('Initializing server...'); | |
| 27 | - | await ensureDefaultChatRoom(db); | |
| 28 | - | await setAllUsersOffline(); | |
| 29 | - | log.info('Server initialized successfully'); | |
| 30 | - | }) | |
| 31 | - | .catch((error: unknown) => { | |
| 32 | - | log.error('Failed to initialize server:', { error }); | |
| 33 | - | process.exit(1); | |
| 34 | - | }); | |
| 24 | + | .then(async () => { | |
| 25 | + | log.info('Initializing server...'); | |
| 26 | + | await ensureDefaultChatRoom(db); | |
| 27 | + | await setAllUsersOffline(); | |
| 28 | + | log.info('Server initialized successfully'); | |
| 29 | + | }) | |
| 30 | + | .catch((error: unknown) => { | |
| 31 | + | log.error('Failed to initialize server:', { error }); | |
| 32 | + | process.exit(1); | |
| 33 | + | }); | |
| 35 | 34 | ||
| 36 | 35 | export const handle: Handle = async ({ event, resolve }) => { | |
| 37 | - | log.debug("Handling request", { path: event.url.pathname }); | |
| 38 | - | ||
| 39 | - | // Get session token from cookies | |
| 40 | - | const token = event.cookies.get("session") ?? null; | |
| 36 | + | log.debug('Handling request', { path: event.url.pathname }); | |
| 41 | 37 | ||
| 42 | - | // Validate session token if token exists | |
| 43 | - | if (token) { | |
| 44 | - | const { session, user } = await validateSessionToken(token); | |
| 45 | - | if (session) { | |
| 46 | - | log.debug("Valid session found", { user: user?.nickname }); | |
| 47 | - | setSessionTokenCookie(event, token, session.expiresAt); | |
| 48 | - | event.locals.session = session; | |
| 49 | - | event.locals.user = user; | |
| 50 | - | } else { | |
| 51 | - | log.debug("Invalid session token"); | |
| 52 | - | deleteSessionTokenCookie(event); | |
| 53 | - | event.locals.session = null; | |
| 54 | - | event.locals.user = null; | |
| 55 | - | } | |
| 56 | - | } | |
| 38 | + | // Get session token from cookies | |
| 39 | + | const token = event.cookies.get('session') ?? null; | |
| 57 | 40 | ||
| 58 | - | // Identify public chat and room requests if there is no valid session | |
| 59 | - | const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | |
| 60 | - | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; | |
| 41 | + | // Validate session token if token exists | |
| 42 | + | if (token) { | |
| 43 | + | const { session, user } = await validateSessionToken(token); | |
| 44 | + | if (session) { | |
| 45 | + | log.debug('Valid session found', { user: user?.nickname }); | |
| 46 | + | setSessionTokenCookie(event, token, session.expiresAt); | |
| 47 | + | event.locals.session = session; | |
| 48 | + | event.locals.user = user; | |
| 49 | + | } else { | |
| 50 | + | log.debug('Invalid session token'); | |
| 51 | + | deleteSessionTokenCookie(event); | |
| 52 | + | event.locals.session = null; | |
| 53 | + | event.locals.user = null; | |
| 54 | + | } | |
| 55 | + | } | |
| 61 | 56 | ||
| 62 | - | if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) { | |
| 63 | - | log.debug("Public request accessed", { path: event.url.pathname }); | |
| 64 | - | const response = await resolve(event); | |
| 65 | - | return response; | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Public routes that don't require authentication | |
| 69 | - | const publicRoutes = [ | |
| 70 | - | "/", | |
| 71 | - | "/api/session/login", | |
| 72 | - | "/api/session/validate", | |
| 73 | - | "/api/register", | |
| 74 | - | "/login", | |
| 75 | - | "/register" | |
| 76 | - | ]; | |
| 57 | + | // Identify public chat and room requests if there is no valid session | |
| 58 | + | const isPublicChatRequest = | |
| 59 | + | event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | |
| 60 | + | const isPublicRoomRequest = | |
| 61 | + | event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; | |
| 77 | 62 | ||
| 78 | - | // Handle public routes | |
| 79 | - | if (publicRoutes.includes(event.url.pathname)) { | |
| 80 | - | log.debug("Public route accessed", { path: event.url.pathname }); | |
| 81 | - | const response = await resolve(event); | |
| 82 | - | return response; | |
| 83 | - | } | |
| 63 | + | if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) { | |
| 64 | + | log.debug('Public request accessed', { path: event.url.pathname }); | |
| 65 | + | const response = await resolve(event); | |
| 66 | + | return response; | |
| 67 | + | } | |
| 84 | 68 | ||
| 85 | - | // Skip rate limiting for SSE endpoint when authenticated | |
| 86 | - | if (event.url.pathname.startsWith('/api/sse')) { | |
| 87 | - | return await resolve(event); | |
| 88 | - | } | |
| 69 | + | // Public routes that don't require authentication | |
| 70 | + | const publicRoutes = [ | |
| 71 | + | '/', | |
| 72 | + | '/api/session/login', | |
| 73 | + | '/api/session/validate', | |
| 74 | + | '/api/register', | |
| 75 | + | '/login', | |
| 76 | + | '/register' | |
| 77 | + | ]; | |
| 89 | 78 | ||
| 90 | - | // Apply rate limiting for non-public endpoints | |
| 91 | - | if (!isPublicChatRequest && !isPublicRoomRequest) { | |
| 92 | - | const rateLimitResult = await handleRateLimit(event); | |
| 93 | - | if (rateLimitResult?.status === 429) { | |
| 94 | - | return new Response('Too Many Requests', { | |
| 95 | - | status: 429, | |
| 96 | - | headers: { | |
| 97 | - | 'Retry-After': '60' | |
| 98 | - | } | |
| 99 | - | }); | |
| 100 | - | } | |
| 101 | - | } | |
| 79 | + | // Handle public routes | |
| 80 | + | if (publicRoutes.includes(event.url.pathname)) { | |
| 81 | + | log.debug('Public route accessed', { path: event.url.pathname }); | |
| 82 | + | const response = await resolve(event); | |
| 83 | + | return response; | |
| 84 | + | } | |
| 102 | 85 | ||
| 103 | - | const response = await resolve(event); | |
| 104 | - | return response; | |
| 105 | - | }; ⏎̸ | |
| 86 | + | // Skip rate limiting for SSE endpoint when authenticated | |
| 87 | + | if (event.url.pathname.startsWith('/api/sse')) { | |
| 88 | + | return await resolve(event); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Apply rate limiting for non-public endpoints | |
| 92 | + | if (!isPublicChatRequest && !isPublicRoomRequest) { | |
| 93 | + | const rateLimitResult = await handleRateLimit(event); | |
| 94 | + | if (rateLimitResult?.status === 429) { | |
| 95 | + | return new Response('Too Many Requests', { | |
| 96 | + | status: 429, | |
| 97 | + | headers: { | |
| 98 | + | 'Retry-After': '60' | |
| 99 | + | } | |
| 100 | + | }); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | const response = await resolve(event); | |
| 105 | + | return response; | |
| 106 | + | }; | |
M src/lib/actions/draggable.ts +133 -124
| @@ -1,132 +1,141 @@ | |||
| 1 | 1 | import type { Action } from 'svelte/action'; | |
| 2 | 2 | ||
| 3 | 3 | interface DraggableParameters { | |
| 4 | - | handle?: string; | |
| 5 | - | bounds?: 'parent' | 'window'; | |
| 6 | - | enabled?: boolean; | |
| 4 | + | handle?: string; | |
| 5 | + | bounds?: 'parent' | 'window'; | |
| 6 | + | enabled?: boolean; | |
| 7 | 7 | } | |
| 8 | 8 | ||
| 9 | 9 | interface DraggableAttributes { | |
| 10 | - | ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 11 | - | ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 12 | - | ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 10 | + | ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 11 | + | ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 12 | + | ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void; | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | - | export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (node, params = {}) => { | |
| 16 | - | let x = 0; | |
| 17 | - | let y = 0; | |
| 18 | - | let startX = 0; | |
| 19 | - | let startY = 0; | |
| 20 | - | let isDragging = false; | |
| 21 | - | ||
| 22 | - | let isEnabled = params.enabled !== undefined ? params.enabled : true; | |
| 23 | - | const handleSelector = params.handle ?? null; | |
| 24 | - | const bounds = params.bounds ?? 'window'; | |
| 25 | - | ||
| 26 | - | const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node; | |
| 27 | - | if (!handleElement) { | |
| 28 | - | console.warn('Draggable handle element not found'); | |
| 29 | - | return; | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Ensure proper positioning | |
| 33 | - | if (getComputedStyle(node).position === 'static') { | |
| 34 | - | node.style.position = 'fixed'; | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | function getNodePosition() { | |
| 38 | - | const rect = node.getBoundingClientRect(); | |
| 39 | - | return { | |
| 40 | - | x: rect.left, | |
| 41 | - | y: rect.top | |
| 42 | - | }; | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | function updatePosition(newX: number, newY: number) { | |
| 46 | - | x = newX; | |
| 47 | - | y = newY; | |
| 48 | - | node.style.left = `${x}px`; | |
| 49 | - | node.style.top = `${y}px`; | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | function onMouseDown(event: MouseEvent) { | |
| 53 | - | if (!isEnabled || event.button !== 0) return; | |
| 54 | - | ||
| 55 | - | // Check if we're clicking a button in the title bar | |
| 56 | - | const target = event.target as HTMLElement; | |
| 57 | - | if (target.tagName === 'BUTTON' || target.closest('button')) { | |
| 58 | - | return; | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | isDragging = true; | |
| 62 | - | ||
| 63 | - | // Get current position | |
| 64 | - | const pos = getNodePosition(); | |
| 65 | - | x = pos.x; | |
| 66 | - | y = pos.y; | |
| 67 | - | ||
| 68 | - | startX = event.clientX - x; | |
| 69 | - | startY = event.clientY - y; | |
| 70 | - | ||
| 71 | - | node.dispatchEvent(new CustomEvent('dragstart', { | |
| 72 | - | detail: { x, y } | |
| 73 | - | })); | |
| 74 | - | ||
| 75 | - | // Add dragging class for styling | |
| 76 | - | node.classList.add('dragging'); | |
| 77 | - | ||
| 78 | - | event.preventDefault(); | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | function onMouseMove(event: MouseEvent) { | |
| 82 | - | if (!isDragging || !isEnabled) return; | |
| 83 | - | ||
| 84 | - | let newX = event.clientX - startX; | |
| 85 | - | let newY = event.clientY - startY; | |
| 86 | - | ||
| 87 | - | if (bounds === 'window') { | |
| 88 | - | newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth)); | |
| 89 | - | newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight)); | |
| 90 | - | } else if (bounds === 'parent' && node.parentElement) { | |
| 91 | - | const parent = node.parentElement; | |
| 92 | - | newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth)); | |
| 93 | - | newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight)); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | requestAnimationFrame(() => { | |
| 97 | - | updatePosition(newX, newY); | |
| 98 | - | node.dispatchEvent(new CustomEvent('dragmove', { | |
| 99 | - | detail: { x: newX, y: newY } | |
| 100 | - | })); | |
| 101 | - | }); | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | function onMouseUp() { | |
| 105 | - | if (!isDragging) return; | |
| 106 | - | isDragging = false; | |
| 107 | - | ||
| 108 | - | node.classList.remove('dragging'); | |
| 109 | - | ||
| 110 | - | node.dispatchEvent(new CustomEvent('dragend', { | |
| 111 | - | detail: { x, y } | |
| 112 | - | })); | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | |
| 116 | - | handleElement.addEventListener('mousedown', onMouseDown); | |
| 117 | - | window.addEventListener('mousemove', onMouseMove, { passive: true }); | |
| 118 | - | window.addEventListener('mouseup', onMouseUp); | |
| 119 | - | ||
| 120 | - | return { | |
| 121 | - | update(newParams: DraggableParameters) { | |
| 122 | - | isEnabled = newParams.enabled !== undefined ? newParams.enabled : true; | |
| 123 | - | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | |
| 124 | - | Object.assign(params, newParams); | |
| 125 | - | }, | |
| 126 | - | destroy() { | |
| 127 | - | handleElement.removeEventListener('mousedown', onMouseDown); | |
| 128 | - | window.removeEventListener('mousemove', onMouseMove); | |
| 129 | - | window.removeEventListener('mouseup', onMouseUp); | |
| 130 | - | } | |
| 131 | - | }; | |
| 132 | - | }; ⏎̸ | |
| 15 | + | export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = ( | |
| 16 | + | node, | |
| 17 | + | params = {} | |
| 18 | + | ) => { | |
| 19 | + | let x = 0; | |
| 20 | + | let y = 0; | |
| 21 | + | let startX = 0; | |
| 22 | + | let startY = 0; | |
| 23 | + | let isDragging = false; | |
| 24 | + | ||
| 25 | + | let isEnabled = params.enabled !== undefined ? params.enabled : true; | |
| 26 | + | const handleSelector = params.handle ?? null; | |
| 27 | + | const bounds = params.bounds ?? 'window'; | |
| 28 | + | ||
| 29 | + | const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node; | |
| 30 | + | if (!handleElement) { | |
| 31 | + | console.warn('Draggable handle element not found'); | |
| 32 | + | return; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Ensure proper positioning | |
| 36 | + | if (getComputedStyle(node).position === 'static') { | |
| 37 | + | node.style.position = 'fixed'; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function getNodePosition() { | |
| 41 | + | const rect = node.getBoundingClientRect(); | |
| 42 | + | return { | |
| 43 | + | x: rect.left, | |
| 44 | + | y: rect.top | |
| 45 | + | }; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function updatePosition(newX: number, newY: number) { | |
| 49 | + | x = newX; | |
| 50 | + | y = newY; | |
| 51 | + | node.style.left = `${x}px`; | |
| 52 | + | node.style.top = `${y}px`; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function onMouseDown(event: MouseEvent) { | |
| 56 | + | if (!isEnabled || event.button !== 0) return; | |
| 57 | + | ||
| 58 | + | // Check if we're clicking a button in the title bar | |
| 59 | + | const target = event.target as HTMLElement; | |
| 60 | + | if (target.tagName === 'BUTTON' || target.closest('button')) { | |
| 61 | + | return; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | isDragging = true; | |
| 65 | + | ||
| 66 | + | // Get current position | |
| 67 | + | const pos = getNodePosition(); | |
| 68 | + | x = pos.x; | |
| 69 | + | y = pos.y; | |
| 70 | + | ||
| 71 | + | startX = event.clientX - x; | |
| 72 | + | startY = event.clientY - y; | |
| 73 | + | ||
| 74 | + | node.dispatchEvent( | |
| 75 | + | new CustomEvent('dragstart', { | |
| 76 | + | detail: { x, y } | |
| 77 | + | }) | |
| 78 | + | ); | |
| 79 | + | ||
| 80 | + | // Add dragging class for styling | |
| 81 | + | node.classList.add('dragging'); | |
| 82 | + | ||
| 83 | + | event.preventDefault(); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | function onMouseMove(event: MouseEvent) { | |
| 87 | + | if (!isDragging || !isEnabled) return; | |
| 88 | + | ||
| 89 | + | let newX = event.clientX - startX; | |
| 90 | + | let newY = event.clientY - startY; | |
| 91 | + | ||
| 92 | + | if (bounds === 'window') { | |
| 93 | + | newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth)); | |
| 94 | + | newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight)); | |
| 95 | + | } else if (bounds === 'parent' && node.parentElement) { | |
| 96 | + | const parent = node.parentElement; | |
| 97 | + | newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth)); | |
| 98 | + | newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight)); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | requestAnimationFrame(() => { | |
| 102 | + | updatePosition(newX, newY); | |
| 103 | + | node.dispatchEvent( | |
| 104 | + | new CustomEvent('dragmove', { | |
| 105 | + | detail: { x: newX, y: newY } | |
| 106 | + | }) | |
| 107 | + | ); | |
| 108 | + | }); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | function onMouseUp() { | |
| 112 | + | if (!isDragging) return; | |
| 113 | + | isDragging = false; | |
| 114 | + | ||
| 115 | + | node.classList.remove('dragging'); | |
| 116 | + | ||
| 117 | + | node.dispatchEvent( | |
| 118 | + | new CustomEvent('dragend', { | |
| 119 | + | detail: { x, y } | |
| 120 | + | }) | |
| 121 | + | ); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | |
| 125 | + | handleElement.addEventListener('mousedown', onMouseDown); | |
| 126 | + | window.addEventListener('mousemove', onMouseMove, { passive: true }); | |
| 127 | + | window.addEventListener('mouseup', onMouseUp); | |
| 128 | + | ||
| 129 | + | return { | |
| 130 | + | update(newParams: DraggableParameters) { | |
| 131 | + | isEnabled = newParams.enabled !== undefined ? newParams.enabled : true; | |
| 132 | + | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | |
| 133 | + | Object.assign(params, newParams); | |
| 134 | + | }, | |
| 135 | + | destroy() { | |
| 136 | + | handleElement.removeEventListener('mousedown', onMouseDown); | |
| 137 | + | window.removeEventListener('mousemove', onMouseMove); | |
| 138 | + | window.removeEventListener('mouseup', onMouseUp); | |
| 139 | + | } | |
| 140 | + | }; | |
| 141 | + | }; | |
M src/lib/actions/maximizable.ts +105 -99
| @@ -1,109 +1,115 @@ | |||
| 1 | 1 | import type { Action } from 'svelte/action'; | |
| 2 | 2 | interface MaximizableParameters { | |
| 3 | - | enabled?: boolean; | |
| 4 | - | padding?: number; | |
| 3 | + | enabled?: boolean; | |
| 4 | + | padding?: number; | |
| 5 | 5 | } | |
| 6 | 6 | ||
| 7 | 7 | interface MaximizableAttributes { | |
| 8 | - | onmaximize?: (e: CustomEvent<{ | |
| 9 | - | isMaximized: boolean; | |
| 10 | - | width: number; | |
| 11 | - | height: number; | |
| 12 | - | x: number; | |
| 13 | - | y: number; | |
| 14 | - | }>) => void; | |
| 8 | + | onmaximize?: ( | |
| 9 | + | e: CustomEvent<{ | |
| 10 | + | isMaximized: boolean; | |
| 11 | + | width: number; | |
| 12 | + | height: number; | |
| 13 | + | x: number; | |
| 14 | + | y: number; | |
| 15 | + | }> | |
| 16 | + | ) => void; | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | 19 | interface StoredState { | |
| 18 | - | width: number; | |
| 19 | - | height: number; | |
| 20 | - | x: number; | |
| 21 | - | y: number; | |
| 20 | + | width: number; | |
| 21 | + | height: number; | |
| 22 | + | x: number; | |
| 23 | + | y: number; | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | - | export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (node, params = {}) => { | |
| 25 | - | const { | |
| 26 | - | enabled = true, | |
| 27 | - | padding = 0 | |
| 28 | - | } = params; | |
| 29 | - | ||
| 30 | - | let isMaximized = false; | |
| 31 | - | let storedState: StoredState | null = null; | |
| 32 | - | ||
| 33 | - | function maximize() { | |
| 34 | - | if (!enabled || isMaximized) return; | |
| 35 | - | ||
| 36 | - | // Store current state | |
| 37 | - | storedState = { | |
| 38 | - | width: node.offsetWidth, | |
| 39 | - | height: node.offsetHeight, | |
| 40 | - | x: parseInt(node.style.left) || 0, | |
| 41 | - | y: parseInt(node.style.top) || 0 | |
| 42 | - | }; | |
| 43 | - | ||
| 44 | - | // Set maximized state | |
| 45 | - | const maxWidth = window.innerWidth - (padding * 2); | |
| 46 | - | const maxHeight = window.innerHeight - (padding * 2); | |
| 47 | - | ||
| 48 | - | node.style.width = `${maxWidth}px`; | |
| 49 | - | node.style.height = `${maxHeight}px`; | |
| 50 | - | node.style.left = `${padding}px`; | |
| 51 | - | node.style.top = `${padding}px`; | |
| 52 | - | ||
| 53 | - | isMaximized = true; | |
| 54 | - | ||
| 55 | - | node.dispatchEvent(new CustomEvent('maximize', { | |
| 56 | - | detail: { | |
| 57 | - | isMaximized: true, | |
| 58 | - | width: maxWidth, | |
| 59 | - | height: maxHeight, | |
| 60 | - | x: padding, | |
| 61 | - | y: padding | |
| 62 | - | } | |
| 63 | - | })); | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | function restore() { | |
| 67 | - | if (!enabled || !isMaximized || !storedState) return; | |
| 68 | - | ||
| 69 | - | // Restore previous state | |
| 70 | - | node.style.width = `${storedState.width}px`; | |
| 71 | - | node.style.height = `${storedState.height}px`; | |
| 72 | - | node.style.left = `${storedState.x}px`; | |
| 73 | - | node.style.top = `${storedState.y}px`; | |
| 74 | - | ||
| 75 | - | isMaximized = false; | |
| 76 | - | ||
| 77 | - | node.dispatchEvent(new CustomEvent('maximize', { | |
| 78 | - | detail: { | |
| 79 | - | isMaximized: false, | |
| 80 | - | ...storedState | |
| 81 | - | } | |
| 82 | - | })); | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | function toggle() { | |
| 86 | - | if (isMaximized) { | |
| 87 | - | restore(); | |
| 88 | - | } else { | |
| 89 | - | maximize(); | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Expose methods to the node | |
| 94 | - | Object.assign(node, { | |
| 95 | - | maximize, | |
| 96 | - | restore, | |
| 97 | - | toggleMaximize: toggle, | |
| 98 | - | isMaximized: () => isMaximized | |
| 99 | - | }); | |
| 100 | - | ||
| 101 | - | return { | |
| 102 | - | update(newParams: MaximizableParameters) { | |
| 103 | - | Object.assign(params, newParams); | |
| 104 | - | }, | |
| 105 | - | destroy() { | |
| 106 | - | // Clean up if needed | |
| 107 | - | } | |
| 108 | - | }; | |
| 109 | - | }; ⏎̸ | |
| 26 | + | export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = ( | |
| 27 | + | node, | |
| 28 | + | params = {} | |
| 29 | + | ) => { | |
| 30 | + | const { enabled = true, padding = 0 } = params; | |
| 31 | + | ||
| 32 | + | let isMaximized = false; | |
| 33 | + | let storedState: StoredState | null = null; | |
| 34 | + | ||
| 35 | + | function maximize() { | |
| 36 | + | if (!enabled || isMaximized) return; | |
| 37 | + | ||
| 38 | + | // Store current state | |
| 39 | + | storedState = { | |
| 40 | + | width: node.offsetWidth, | |
| 41 | + | height: node.offsetHeight, | |
| 42 | + | x: parseInt(node.style.left) || 0, | |
| 43 | + | y: parseInt(node.style.top) || 0 | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | // Set maximized state | |
| 47 | + | const maxWidth = window.innerWidth - padding * 2; | |
| 48 | + | const maxHeight = window.innerHeight - padding * 2; | |
| 49 | + | ||
| 50 | + | node.style.width = `${maxWidth}px`; | |
| 51 | + | node.style.height = `${maxHeight}px`; | |
| 52 | + | node.style.left = `${padding}px`; | |
| 53 | + | node.style.top = `${padding}px`; | |
| 54 | + | ||
| 55 | + | isMaximized = true; | |
| 56 | + | ||
| 57 | + | node.dispatchEvent( | |
| 58 | + | new CustomEvent('maximize', { | |
| 59 | + | detail: { | |
| 60 | + | isMaximized: true, | |
| 61 | + | width: maxWidth, | |
| 62 | + | height: maxHeight, | |
| 63 | + | x: padding, | |
| 64 | + | y: padding | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | ); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | function restore() { | |
| 71 | + | if (!enabled || !isMaximized || !storedState) return; | |
| 72 | + | ||
| 73 | + | // Restore previous state | |
| 74 | + | node.style.width = `${storedState.width}px`; | |
| 75 | + | node.style.height = `${storedState.height}px`; | |
| 76 | + | node.style.left = `${storedState.x}px`; | |
| 77 | + | node.style.top = `${storedState.y}px`; | |
| 78 | + | ||
| 79 | + | isMaximized = false; | |
| 80 | + | ||
| 81 | + | node.dispatchEvent( | |
| 82 | + | new CustomEvent('maximize', { | |
| 83 | + | detail: { | |
| 84 | + | isMaximized: false, | |
| 85 | + | ...storedState | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | ); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | function toggle() { | |
| 92 | + | if (isMaximized) { | |
| 93 | + | restore(); | |
| 94 | + | } else { | |
| 95 | + | maximize(); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Expose methods to the node | |
| 100 | + | Object.assign(node, { | |
| 101 | + | maximize, | |
| 102 | + | restore, | |
| 103 | + | toggleMaximize: toggle, | |
| 104 | + | isMaximized: () => isMaximized | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | return { | |
| 108 | + | update(newParams: MaximizableParameters) { | |
| 109 | + | Object.assign(params, newParams); | |
| 110 | + | }, | |
| 111 | + | destroy() { | |
| 112 | + | // Clean up if needed | |
| 113 | + | } | |
| 114 | + | }; | |
| 115 | + | }; | |
M src/lib/actions/minimizable.ts +161 -157
| @@ -1,168 +1,172 @@ | |||
| 1 | 1 | import type { Action } from 'svelte/action'; | |
| 2 | 2 | ||
| 3 | 3 | interface MinimizableParameters { | |
| 4 | - | enabled?: boolean; | |
| 5 | - | onMinimize?: (isMinimized: boolean) => void; | |
| 4 | + | enabled?: boolean; | |
| 5 | + | onMinimize?: (isMinimized: boolean) => void; | |
| 6 | 6 | } | |
| 7 | 7 | ||
| 8 | 8 | interface MinimizableAttributes { | |
| 9 | - | onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void; | |
| 9 | + | onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void; | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | interface StoredState { | |
| 13 | - | width: number; | |
| 14 | - | height: number; | |
| 15 | - | x: number; | |
| 16 | - | y: number; | |
| 13 | + | width: number; | |
| 14 | + | height: number; | |
| 15 | + | x: number; | |
| 16 | + | y: number; | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | - | export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (node, params = {}) => { | |
| 20 | - | const { | |
| 21 | - | enabled = true, | |
| 22 | - | onMinimize | |
| 23 | - | } = params; | |
| 24 | - | ||
| 25 | - | let isMinimized = false; | |
| 26 | - | let storedState: StoredState | null = null; | |
| 27 | - | ||
| 28 | - | function calculateMinimizedWidth() { | |
| 29 | - | const titleBar = node.querySelector('.title-bar-text'); | |
| 30 | - | if (!titleBar) return 200; | |
| 31 | - | ||
| 32 | - | // Create a temporary element to measure text width | |
| 33 | - | const temp = document.createElement('div'); | |
| 34 | - | temp.style.position = 'absolute'; | |
| 35 | - | temp.style.visibility = 'hidden'; | |
| 36 | - | temp.style.whiteSpace = 'nowrap'; | |
| 37 | - | temp.style.font = window.getComputedStyle(titleBar).font; | |
| 38 | - | temp.textContent = titleBar.textContent; | |
| 39 | - | document.body.appendChild(temp); | |
| 40 | - | ||
| 41 | - | // Get the width and add some padding | |
| 42 | - | const width = temp.offsetWidth + 100; // Add 100px for buttons and padding | |
| 43 | - | document.body.removeChild(temp); | |
| 44 | - | ||
| 45 | - | return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | function minimize() { | |
| 49 | - | if (!enabled || isMinimized) return; | |
| 50 | - | ||
| 51 | - | // Store current state | |
| 52 | - | storedState = { | |
| 53 | - | width: node.offsetWidth, | |
| 54 | - | height: node.offsetHeight, | |
| 55 | - | x: parseInt(node.style.left) || 0, | |
| 56 | - | y: parseInt(node.style.top) || 0 | |
| 57 | - | }; | |
| 58 | - | ||
| 59 | - | // Check if we're in mobile mode | |
| 60 | - | const isMobile = window.innerWidth <= 768; | |
| 61 | - | ||
| 62 | - | if (!isMobile) { | |
| 63 | - | // Desktop minimization | |
| 64 | - | const minimizedWidth = calculateMinimizedWidth(); | |
| 65 | - | const minimizedHeight = 32; // Height of title bar | |
| 66 | - | const minimizedX = 10; | |
| 67 | - | const minimizedY = window.innerHeight - minimizedHeight - 10; | |
| 68 | - | ||
| 69 | - | node.style.width = `${minimizedWidth}px`; | |
| 70 | - | node.style.height = `${minimizedHeight}px`; | |
| 71 | - | node.style.left = `${minimizedX}px`; | |
| 72 | - | node.style.top = `${minimizedY}px`; | |
| 73 | - | } else { | |
| 74 | - | // Mobile minimization - position at bottom of screen | |
| 75 | - | node.style.width = '100%'; | |
| 76 | - | node.style.height = '100%'; | |
| 77 | - | node.style.left = '0'; | |
| 78 | - | node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | isMinimized = true; | |
| 82 | - | node.classList.add('minimized'); | |
| 83 | - | ||
| 84 | - | // Hide resize handle | |
| 85 | - | const resizeHandle = node.querySelector('.resize-handle'); | |
| 86 | - | if (resizeHandle) { | |
| 87 | - | (resizeHandle as HTMLElement).style.display = 'none'; | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | onMinimize?.(true); | |
| 91 | - | node.dispatchEvent(new CustomEvent('minimize', { | |
| 92 | - | detail: { isMinimized: true } | |
| 93 | - | })); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | function restore() { | |
| 97 | - | if (!enabled || !isMinimized || !storedState) return; | |
| 98 | - | ||
| 99 | - | const isMobile = window.innerWidth <= 768; | |
| 100 | - | ||
| 101 | - | if (!isMobile) { | |
| 102 | - | // Desktop restoration | |
| 103 | - | node.style.width = `${storedState.width}px`; | |
| 104 | - | node.style.height = `${storedState.height}px`; | |
| 105 | - | node.style.left = `${storedState.x}px`; | |
| 106 | - | node.style.top = `${storedState.y}px`; | |
| 107 | - | } else { | |
| 108 | - | // Mobile restoration | |
| 109 | - | node.style.width = '100%'; | |
| 110 | - | node.style.height = '100%'; | |
| 111 | - | node.style.left = '0'; | |
| 112 | - | node.style.top = '0'; | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | isMinimized = false; | |
| 116 | - | node.classList.remove('minimized'); | |
| 117 | - | ||
| 118 | - | // Show resize handle if not in mobile mode | |
| 119 | - | const resizeHandle = node.querySelector('.resize-handle'); | |
| 120 | - | if (resizeHandle) { | |
| 121 | - | (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none'; | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // Ensure window body is visible | |
| 125 | - | const windowBody = node.querySelector('.window-body'); | |
| 126 | - | if (windowBody) { | |
| 127 | - | (windowBody as HTMLElement).style.display = 'flex'; | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | onMinimize?.(false); | |
| 131 | - | node.dispatchEvent(new CustomEvent('minimize', { | |
| 132 | - | detail: { isMinimized: false } | |
| 133 | - | })); | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | function toggle() { | |
| 137 | - | if (isMinimized) { | |
| 138 | - | restore(); | |
| 139 | - | } else { | |
| 140 | - | minimize(); | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // Handle double click on title bar | |
| 145 | - | const titleBar = node.querySelector('.title-bar'); | |
| 146 | - | if (titleBar) { | |
| 147 | - | titleBar.addEventListener('dblclick', toggle); | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // Expose methods to the node | |
| 151 | - | Object.assign(node, { | |
| 152 | - | minimize, | |
| 153 | - | restore, | |
| 154 | - | toggleMinimize: toggle, | |
| 155 | - | isMinimized: () => isMinimized | |
| 156 | - | }); | |
| 157 | - | ||
| 158 | - | return { | |
| 159 | - | update(newParams: MinimizableParameters) { | |
| 160 | - | Object.assign(params, newParams); | |
| 161 | - | }, | |
| 162 | - | destroy() { | |
| 163 | - | if (titleBar) { | |
| 164 | - | titleBar.removeEventListener('dblclick', toggle); | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | }; | |
| 168 | - | }; ⏎̸ | |
| 19 | + | export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = ( | |
| 20 | + | node, | |
| 21 | + | params = {} | |
| 22 | + | ) => { | |
| 23 | + | const { enabled = true, onMinimize } = params; | |
| 24 | + | ||
| 25 | + | let isMinimized = false; | |
| 26 | + | let storedState: StoredState | null = null; | |
| 27 | + | ||
| 28 | + | function calculateMinimizedWidth() { | |
| 29 | + | const titleBar = node.querySelector('.title-bar-text'); | |
| 30 | + | if (!titleBar) return 200; | |
| 31 | + | ||
| 32 | + | // Create a temporary element to measure text width | |
| 33 | + | const temp = document.createElement('div'); | |
| 34 | + | temp.style.position = 'absolute'; | |
| 35 | + | temp.style.visibility = 'hidden'; | |
| 36 | + | temp.style.whiteSpace = 'nowrap'; | |
| 37 | + | temp.style.font = window.getComputedStyle(titleBar).font; | |
| 38 | + | temp.textContent = titleBar.textContent; | |
| 39 | + | document.body.appendChild(temp); | |
| 40 | + | ||
| 41 | + | // Get the width and add some padding | |
| 42 | + | const width = temp.offsetWidth + 100; // Add 100px for buttons and padding | |
| 43 | + | document.body.removeChild(temp); | |
| 44 | + | ||
| 45 | + | return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function minimize() { | |
| 49 | + | if (!enabled || isMinimized) return; | |
| 50 | + | ||
| 51 | + | // Store current state | |
| 52 | + | storedState = { | |
| 53 | + | width: node.offsetWidth, | |
| 54 | + | height: node.offsetHeight, | |
| 55 | + | x: parseInt(node.style.left) || 0, | |
| 56 | + | y: parseInt(node.style.top) || 0 | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | // Check if we're in mobile mode | |
| 60 | + | const isMobile = window.innerWidth <= 768; | |
| 61 | + | ||
| 62 | + | if (!isMobile) { | |
| 63 | + | // Desktop minimization | |
| 64 | + | const minimizedWidth = calculateMinimizedWidth(); | |
| 65 | + | const minimizedHeight = 32; // Height of title bar | |
| 66 | + | const minimizedX = 10; | |
| 67 | + | const minimizedY = window.innerHeight - minimizedHeight - 10; | |
| 68 | + | ||
| 69 | + | node.style.width = `${minimizedWidth}px`; | |
| 70 | + | node.style.height = `${minimizedHeight}px`; | |
| 71 | + | node.style.left = `${minimizedX}px`; | |
| 72 | + | node.style.top = `${minimizedY}px`; | |
| 73 | + | } else { | |
| 74 | + | // Mobile minimization - position at bottom of screen | |
| 75 | + | node.style.width = '100%'; | |
| 76 | + | node.style.height = '100%'; | |
| 77 | + | node.style.left = '0'; | |
| 78 | + | node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | isMinimized = true; | |
| 82 | + | node.classList.add('minimized'); | |
| 83 | + | ||
| 84 | + | // Hide resize handle | |
| 85 | + | const resizeHandle = node.querySelector('.resize-handle'); | |
| 86 | + | if (resizeHandle) { | |
| 87 | + | (resizeHandle as HTMLElement).style.display = 'none'; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | onMinimize?.(true); | |
| 91 | + | node.dispatchEvent( | |
| 92 | + | new CustomEvent('minimize', { | |
| 93 | + | detail: { isMinimized: true } | |
| 94 | + | }) | |
| 95 | + | ); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | function restore() { | |
| 99 | + | if (!enabled || !isMinimized || !storedState) return; | |
| 100 | + | ||
| 101 | + | const isMobile = window.innerWidth <= 768; | |
| 102 | + | ||
| 103 | + | if (!isMobile) { | |
| 104 | + | // Desktop restoration | |
| 105 | + | node.style.width = `${storedState.width}px`; | |
| 106 | + | node.style.height = `${storedState.height}px`; | |
| 107 | + | node.style.left = `${storedState.x}px`; | |
| 108 | + | node.style.top = `${storedState.y}px`; | |
| 109 | + | } else { | |
| 110 | + | // Mobile restoration | |
| 111 | + | node.style.width = '100%'; | |
| 112 | + | node.style.height = '100%'; | |
| 113 | + | node.style.left = '0'; | |
| 114 | + | node.style.top = '0'; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | isMinimized = false; | |
| 118 | + | node.classList.remove('minimized'); | |
| 119 | + | ||
| 120 | + | // Show resize handle if not in mobile mode | |
| 121 | + | const resizeHandle = node.querySelector('.resize-handle'); | |
| 122 | + | if (resizeHandle) { | |
| 123 | + | (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none'; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Ensure window body is visible | |
| 127 | + | const windowBody = node.querySelector('.window-body'); | |
| 128 | + | if (windowBody) { | |
| 129 | + | (windowBody as HTMLElement).style.display = 'flex'; | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | onMinimize?.(false); | |
| 133 | + | node.dispatchEvent( | |
| 134 | + | new CustomEvent('minimize', { | |
| 135 | + | detail: { isMinimized: false } | |
| 136 | + | }) | |
| 137 | + | ); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | function toggle() { | |
| 141 | + | if (isMinimized) { | |
| 142 | + | restore(); | |
| 143 | + | } else { | |
| 144 | + | minimize(); | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Handle double click on title bar | |
| 149 | + | const titleBar = node.querySelector('.title-bar'); | |
| 150 | + | if (titleBar) { | |
| 151 | + | titleBar.addEventListener('dblclick', toggle); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Expose methods to the node | |
| 155 | + | Object.assign(node, { | |
| 156 | + | minimize, | |
| 157 | + | restore, | |
| 158 | + | toggleMinimize: toggle, | |
| 159 | + | isMinimized: () => isMinimized | |
| 160 | + | }); | |
| 161 | + | ||
| 162 | + | return { | |
| 163 | + | update(newParams: MinimizableParameters) { | |
| 164 | + | Object.assign(params, newParams); | |
| 165 | + | }, | |
| 166 | + | destroy() { | |
| 167 | + | if (titleBar) { | |
| 168 | + | titleBar.removeEventListener('dblclick', toggle); | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | }; | |
| 172 | + | }; | |
M src/lib/actions/resizable.ts +112 -103
| @@ -1,111 +1,120 @@ | |||
| 1 | 1 | import type { Action } from 'svelte/action'; | |
| 2 | 2 | ||
| 3 | 3 | interface ResizableParameters { | |
| 4 | - | enabled?: boolean; | |
| 5 | - | minWidth?: number; | |
| 6 | - | minHeight?: number; | |
| 7 | - | maxWidth?: number; | |
| 8 | - | maxHeight?: number; | |
| 4 | + | enabled?: boolean; | |
| 5 | + | minWidth?: number; | |
| 6 | + | minHeight?: number; | |
| 7 | + | maxWidth?: number; | |
| 8 | + | maxHeight?: number; | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | interface ResizableAttributes { | |
| 12 | - | onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 13 | - | onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 14 | - | onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 12 | + | onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 13 | + | onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 14 | + | onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void; | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = (node, params = {}) => { | |
| 18 | - | let isResizing = false; | |
| 19 | - | let startWidth = 0; | |
| 20 | - | let startHeight = 0; | |
| 21 | - | let startX = 0; | |
| 22 | - | let startY = 0; | |
| 23 | - | ||
| 24 | - | const { | |
| 25 | - | enabled = true, | |
| 26 | - | minWidth = 200, | |
| 27 | - | minHeight = 200, | |
| 28 | - | maxWidth = window.innerWidth, | |
| 29 | - | maxHeight = window.innerHeight | |
| 30 | - | } = params; | |
| 31 | - | ||
| 32 | - | // Create and append resize handle | |
| 33 | - | const handle = document.createElement('div'); | |
| 34 | - | handle.className = 'resize-handle'; | |
| 35 | - | Object.assign(handle.style, { | |
| 36 | - | position: 'absolute', | |
| 37 | - | bottom: '0', | |
| 38 | - | right: '0', | |
| 39 | - | width: '15px', | |
| 40 | - | height: '15px', | |
| 41 | - | cursor: 'se-resize', | |
| 42 | - | zIndex: '1000' | |
| 43 | - | }); | |
| 44 | - | node.appendChild(handle); | |
| 45 | - | ||
| 46 | - | // Ensure parent element has position | |
| 47 | - | if (getComputedStyle(node).position === 'static') { | |
| 48 | - | node.style.position = 'relative'; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | function onMouseDown(event: MouseEvent) { | |
| 52 | - | if (!enabled) return; | |
| 53 | - | ||
| 54 | - | isResizing = true; | |
| 55 | - | startX = event.clientX; | |
| 56 | - | startY = event.clientY; | |
| 57 | - | startWidth = node.offsetWidth; | |
| 58 | - | startHeight = node.offsetHeight; | |
| 59 | - | ||
| 60 | - | node.dispatchEvent(new CustomEvent('resizestart', { | |
| 61 | - | detail: { width: startWidth, height: startHeight } | |
| 62 | - | })); | |
| 63 | - | ||
| 64 | - | event.preventDefault(); | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | function onMouseMove(event: MouseEvent) { | |
| 68 | - | if (!isResizing || !enabled) return; | |
| 69 | - | ||
| 70 | - | const deltaX = event.clientX - startX; | |
| 71 | - | const deltaY = event.clientY - startY; | |
| 72 | - | ||
| 73 | - | const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX)); | |
| 74 | - | const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY)); | |
| 75 | - | ||
| 76 | - | node.style.width = `${newWidth}px`; | |
| 77 | - | node.style.height = `${newHeight}px`; | |
| 78 | - | ||
| 79 | - | node.dispatchEvent(new CustomEvent('resizemove', { | |
| 80 | - | detail: { width: newWidth, height: newHeight } | |
| 81 | - | })); | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | function onMouseUp() { | |
| 85 | - | if (!isResizing) return; | |
| 86 | - | ||
| 87 | - | isResizing = false; | |
| 88 | - | node.dispatchEvent(new CustomEvent('resizeend', { | |
| 89 | - | detail: { | |
| 90 | - | width: node.offsetWidth, | |
| 91 | - | height: node.offsetHeight | |
| 92 | - | } | |
| 93 | - | })); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | handle.addEventListener('mousedown', onMouseDown); | |
| 97 | - | window.addEventListener('mousemove', onMouseMove); | |
| 98 | - | window.addEventListener('mouseup', onMouseUp); | |
| 99 | - | ||
| 100 | - | return { | |
| 101 | - | update(newParams: ResizableParameters) { | |
| 102 | - | Object.assign(params, newParams); | |
| 103 | - | }, | |
| 104 | - | destroy() { | |
| 105 | - | handle.removeEventListener('mousedown', onMouseDown); | |
| 106 | - | window.removeEventListener('mousemove', onMouseMove); | |
| 107 | - | window.removeEventListener('mouseup', onMouseUp); | |
| 108 | - | node.removeChild(handle); | |
| 109 | - | } | |
| 110 | - | }; | |
| 111 | - | }; ⏎̸ | |
| 17 | + | export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = ( | |
| 18 | + | node, | |
| 19 | + | params = {} | |
| 20 | + | ) => { | |
| 21 | + | let isResizing = false; | |
| 22 | + | let startWidth = 0; | |
| 23 | + | let startHeight = 0; | |
| 24 | + | let startX = 0; | |
| 25 | + | let startY = 0; | |
| 26 | + | ||
| 27 | + | const { | |
| 28 | + | enabled = true, | |
| 29 | + | minWidth = 200, | |
| 30 | + | minHeight = 200, | |
| 31 | + | maxWidth = window.innerWidth, | |
| 32 | + | maxHeight = window.innerHeight | |
| 33 | + | } = params; | |
| 34 | + | ||
| 35 | + | // Create and append resize handle | |
| 36 | + | const handle = document.createElement('div'); | |
| 37 | + | handle.className = 'resize-handle'; | |
| 38 | + | Object.assign(handle.style, { | |
| 39 | + | position: 'absolute', | |
| 40 | + | bottom: '0', | |
| 41 | + | right: '0', | |
| 42 | + | width: '15px', | |
| 43 | + | height: '15px', | |
| 44 | + | cursor: 'se-resize', | |
| 45 | + | zIndex: '1000' | |
| 46 | + | }); | |
| 47 | + | node.appendChild(handle); | |
| 48 | + | ||
| 49 | + | // Ensure parent element has position | |
| 50 | + | if (getComputedStyle(node).position === 'static') { | |
| 51 | + | node.style.position = 'relative'; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function onMouseDown(event: MouseEvent) { | |
| 55 | + | if (!enabled) return; | |
| 56 | + | ||
| 57 | + | isResizing = true; | |
| 58 | + | startX = event.clientX; | |
| 59 | + | startY = event.clientY; | |
| 60 | + | startWidth = node.offsetWidth; | |
| 61 | + | startHeight = node.offsetHeight; | |
| 62 | + | ||
| 63 | + | node.dispatchEvent( | |
| 64 | + | new CustomEvent('resizestart', { | |
| 65 | + | detail: { width: startWidth, height: startHeight } | |
| 66 | + | }) | |
| 67 | + | ); | |
| 68 | + | ||
| 69 | + | event.preventDefault(); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | function onMouseMove(event: MouseEvent) { | |
| 73 | + | if (!isResizing || !enabled) return; | |
| 74 | + | ||
| 75 | + | const deltaX = event.clientX - startX; | |
| 76 | + | const deltaY = event.clientY - startY; | |
| 77 | + | ||
| 78 | + | const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX)); | |
| 79 | + | const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY)); | |
| 80 | + | ||
| 81 | + | node.style.width = `${newWidth}px`; | |
| 82 | + | node.style.height = `${newHeight}px`; | |
| 83 | + | ||
| 84 | + | node.dispatchEvent( | |
| 85 | + | new CustomEvent('resizemove', { | |
| 86 | + | detail: { width: newWidth, height: newHeight } | |
| 87 | + | }) | |
| 88 | + | ); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | function onMouseUp() { | |
| 92 | + | if (!isResizing) return; | |
| 93 | + | ||
| 94 | + | isResizing = false; | |
| 95 | + | node.dispatchEvent( | |
| 96 | + | new CustomEvent('resizeend', { | |
| 97 | + | detail: { | |
| 98 | + | width: node.offsetWidth, | |
| 99 | + | height: node.offsetHeight | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | ); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | handle.addEventListener('mousedown', onMouseDown); | |
| 106 | + | window.addEventListener('mousemove', onMouseMove); | |
| 107 | + | window.addEventListener('mouseup', onMouseUp); | |
| 108 | + | ||
| 109 | + | return { | |
| 110 | + | update(newParams: ResizableParameters) { | |
| 111 | + | Object.assign(params, newParams); | |
| 112 | + | }, | |
| 113 | + | destroy() { | |
| 114 | + | handle.removeEventListener('mousedown', onMouseDown); | |
| 115 | + | window.removeEventListener('mousemove', onMouseMove); | |
| 116 | + | window.removeEventListener('mouseup', onMouseUp); | |
| 117 | + | node.removeChild(handle); | |
| 118 | + | } | |
| 119 | + | }; | |
| 120 | + | }; | |
M src/lib/api/rate-limiter.ts +181 -165
| @@ -7,181 +7,197 @@ const isDev = import.meta.env.DEV; | |||
| 7 | 7 | ||
| 8 | 8 | // Rate limit configuration by endpoint type | |
| 9 | 9 | const RATE_LIMITS = { | |
| 10 | - | // Authentication endpoints (login, register) | |
| 11 | - | auth: { | |
| 12 | - | points: isDev ? 1000 : 20, | |
| 13 | - | durationMs: 5 * 60 * 1000 | |
| 14 | - | }, | |
| 15 | - | ||
| 16 | - | // Public endpoints (public chat, rooms) | |
| 17 | - | public: { | |
| 18 | - | points: isDev ? 1000 : 30, | |
| 19 | - | durationMs: 60 * 1000 | |
| 20 | - | }, | |
| 21 | - | ||
| 22 | - | // Protected endpoints (authenticated API calls) | |
| 23 | - | protected: { | |
| 24 | - | points: isDev ? 1000 : 100, | |
| 25 | - | durationMs: 60 * 1000 | |
| 26 | - | }, | |
| 27 | - | ||
| 28 | - | // SSE endpoints - separate limits for authenticated and unauthenticated users | |
| 29 | - | sse: { | |
| 30 | - | authenticated: { | |
| 31 | - | points: isDev ? 1000 : 500, | |
| 32 | - | durationMs: 60 * 1000 | |
| 33 | - | }, | |
| 34 | - | unauthenticated: { | |
| 35 | - | points: 0, | |
| 36 | - | durationMs: 60 * 1000 | |
| 37 | - | } | |
| 38 | - | } | |
| 10 | + | // Authentication endpoints (login, register) | |
| 11 | + | auth: { | |
| 12 | + | points: isDev ? 1000 : 20, | |
| 13 | + | durationMs: 5 * 60 * 1000 | |
| 14 | + | }, | |
| 15 | + | ||
| 16 | + | // Public endpoints (public chat, rooms) | |
| 17 | + | public: { | |
| 18 | + | points: isDev ? 1000 : 30, | |
| 19 | + | durationMs: 60 * 1000 | |
| 20 | + | }, | |
| 21 | + | ||
| 22 | + | // Protected endpoints (authenticated API calls) | |
| 23 | + | protected: { | |
| 24 | + | points: isDev ? 1000 : 100, | |
| 25 | + | durationMs: 60 * 1000 | |
| 26 | + | }, | |
| 27 | + | ||
| 28 | + | // SSE endpoints - separate limits for authenticated and unauthenticated users | |
| 29 | + | sse: { | |
| 30 | + | authenticated: { | |
| 31 | + | points: isDev ? 1000 : 500, | |
| 32 | + | durationMs: 60 * 1000 | |
| 33 | + | }, | |
| 34 | + | unauthenticated: { | |
| 35 | + | points: 0, | |
| 36 | + | durationMs: 60 * 1000 | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | 39 | } as const; | |
| 40 | 40 | ||
| 41 | 41 | // In-memory store for rate limiting | |
| 42 | 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | |
| 43 | 43 | ||
| 44 | 44 | // Cleanup old entries every 5 minutes | |
| 45 | - | setInterval(() => { | |
| 46 | - | const now = Date.now(); | |
| 47 | - | for (const [ip, timestamps] of ipRequests.entries()) { | |
| 48 | - | // Remove entries older than the maximum duration we care about (5 minutes) | |
| 49 | - | const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000); | |
| 50 | - | if (filtered.length === 0) { | |
| 51 | - | ipRequests.delete(ip); | |
| 52 | - | } else { | |
| 53 | - | ipRequests.set(ip, filtered); | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | }, 5 * 60 * 1000); | |
| 45 | + | setInterval( | |
| 46 | + | () => { | |
| 47 | + | const now = Date.now(); | |
| 48 | + | for (const [ip, timestamps] of ipRequests.entries()) { | |
| 49 | + | // Remove entries older than the maximum duration we care about (5 minutes) | |
| 50 | + | const filtered = timestamps.filter((t) => now - t.timestamp < 5 * 60 * 1000); | |
| 51 | + | if (filtered.length === 0) { | |
| 52 | + | ipRequests.delete(ip); | |
| 53 | + | } else { | |
| 54 | + | ipRequests.set(ip, filtered); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | }, | |
| 58 | + | 5 * 60 * 1000 | |
| 59 | + | ); | |
| 57 | 60 | ||
| 58 | 61 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | |
| 59 | - | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | |
| 60 | - | return 'auth'; | |
| 61 | - | } | |
| 62 | - | if (pathname.startsWith('/api/sse')) { | |
| 63 | - | return 'sse'; | |
| 64 | - | } | |
| 65 | - | if (isPublic) { | |
| 66 | - | return 'public'; | |
| 67 | - | } | |
| 68 | - | return 'protected'; | |
| 62 | + | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | |
| 63 | + | return 'auth'; | |
| 64 | + | } | |
| 65 | + | if (pathname.startsWith('/api/sse')) { | |
| 66 | + | return 'sse'; | |
| 67 | + | } | |
| 68 | + | if (isPublic) { | |
| 69 | + | return 'public'; | |
| 70 | + | } | |
| 71 | + | return 'protected'; | |
| 69 | 72 | } | |
| 70 | 73 | ||
| 71 | - | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | |
| 72 | - | // Skip rate limiting completely in development for localhost | |
| 73 | - | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | |
| 74 | - | return { limited: false }; | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | const now = Date.now(); | |
| 78 | - | ||
| 79 | - | // Handle SSE endpoints differently based on authentication status | |
| 80 | - | if (endpointType === 'sse') { | |
| 81 | - | const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated; | |
| 82 | - | const { points, durationMs } = config; | |
| 83 | - | ||
| 84 | - | // Get or initialize request history for this IP | |
| 85 | - | const requests = ipRequests.get(ip) || []; | |
| 86 | - | ||
| 87 | - | // Clean old requests outside the window | |
| 88 | - | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | |
| 89 | - | ||
| 90 | - | // Check if we're over the limit | |
| 91 | - | if (validRequests.length >= points) { | |
| 92 | - | log.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`); | |
| 93 | - | const oldestRequest = validRequests[0]; | |
| 94 | - | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 95 | - | return { limited: true, retryAfter }; | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // Add this request | |
| 99 | - | validRequests.push({ timestamp: now }); | |
| 100 | - | ipRequests.set(ip, validRequests); | |
| 101 | - | ||
| 102 | - | return { limited: false }; | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Handle other endpoints | |
| 106 | - | const { points, durationMs } = RATE_LIMITS[endpointType]; | |
| 107 | - | const requests = ipRequests.get(ip) || []; | |
| 108 | - | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | |
| 109 | - | ||
| 110 | - | log.debug(`Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs/1000}s`); | |
| 111 | - | ||
| 112 | - | if (validRequests.length >= points) { | |
| 113 | - | const oldestRequest = validRequests[0]; | |
| 114 | - | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 115 | - | return { limited: true, retryAfter }; | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | validRequests.push({ timestamp: now }); | |
| 119 | - | ipRequests.set(ip, validRequests); | |
| 120 | - | ||
| 121 | - | return { limited: false }; | |
| 74 | + | function isRateLimited( | |
| 75 | + | ip: string, | |
| 76 | + | endpointType: keyof typeof RATE_LIMITS, | |
| 77 | + | isAuthenticated: boolean = false | |
| 78 | + | ): { limited: boolean; retryAfter?: number } { | |
| 79 | + | // Skip rate limiting completely in development for localhost | |
| 80 | + | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | |
| 81 | + | return { limited: false }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | const now = Date.now(); | |
| 85 | + | ||
| 86 | + | // Handle SSE endpoints differently based on authentication status | |
| 87 | + | if (endpointType === 'sse') { | |
| 88 | + | const config = isAuthenticated | |
| 89 | + | ? RATE_LIMITS.sse.authenticated | |
| 90 | + | : RATE_LIMITS.sse.unauthenticated; | |
| 91 | + | const { points, durationMs } = config; | |
| 92 | + | ||
| 93 | + | // Get or initialize request history for this IP | |
| 94 | + | const requests = ipRequests.get(ip) || []; | |
| 95 | + | ||
| 96 | + | // Clean old requests outside the window | |
| 97 | + | const validRequests = requests.filter((req) => now - req.timestamp < durationMs); | |
| 98 | + | ||
| 99 | + | // Check if we're over the limit | |
| 100 | + | if (validRequests.length >= points) { | |
| 101 | + | log.debug( | |
| 102 | + | `Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})` | |
| 103 | + | ); | |
| 104 | + | const oldestRequest = validRequests[0]; | |
| 105 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 106 | + | return { limited: true, retryAfter }; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Add this request | |
| 110 | + | validRequests.push({ timestamp: now }); | |
| 111 | + | ipRequests.set(ip, validRequests); | |
| 112 | + | ||
| 113 | + | return { limited: false }; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Handle other endpoints | |
| 117 | + | const { points, durationMs } = RATE_LIMITS[endpointType]; | |
| 118 | + | const requests = ipRequests.get(ip) || []; | |
| 119 | + | const validRequests = requests.filter((req) => now - req.timestamp < durationMs); | |
| 120 | + | ||
| 121 | + | log.debug( | |
| 122 | + | `Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs / 1000}s` | |
| 123 | + | ); | |
| 124 | + | ||
| 125 | + | if (validRequests.length >= points) { | |
| 126 | + | const oldestRequest = validRequests[0]; | |
| 127 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 128 | + | return { limited: true, retryAfter }; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | validRequests.push({ timestamp: now }); | |
| 132 | + | ipRequests.set(ip, validRequests); | |
| 133 | + | ||
| 134 | + | return { limited: false }; | |
| 122 | 135 | } | |
| 123 | 136 | ||
| 124 | 137 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | |
| 125 | - | const ip = event.getClientAddress(); | |
| 126 | - | const url = new URL(event.request.url); | |
| 127 | - | const isPublic = url.searchParams.get('public') === 'true'; | |
| 128 | - | const isAuthenticated = Boolean(event.locals.user); | |
| 129 | - | ||
| 130 | - | // Skip rate limiting for non-API routes | |
| 131 | - | if (!url.pathname.startsWith('/api')) { | |
| 132 | - | return null; | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | const endpointType = getEndpointType(url.pathname, isPublic); | |
| 136 | - | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); | |
| 137 | - | ||
| 138 | - | if (limited) { | |
| 139 | - | // Create debug headers | |
| 140 | - | const debugHeaders = { | |
| 141 | - | 'X-Debug-Is-Authenticated': isAuthenticated.toString(), | |
| 142 | - | 'X-Debug-Has-User': Boolean(event.locals.user).toString(), | |
| 143 | - | 'X-Debug-Has-Session': Boolean(event.locals.session).toString(), | |
| 144 | - | 'X-Debug-Endpoint-Type': endpointType, | |
| 145 | - | 'X-Debug-Rate-Limit-Points': isAuthenticated | |
| 146 | - | ? RATE_LIMITS.sse.authenticated.points.toString() | |
| 147 | - | : RATE_LIMITS.sse.unauthenticated.points.toString(), | |
| 148 | - | }; | |
| 149 | - | ||
| 150 | - | // Construct a user-friendly error message | |
| 151 | - | let errorMessage = 'Too many requests. '; | |
| 152 | - | if (endpointType === 'sse') { | |
| 153 | - | errorMessage += isAuthenticated | |
| 154 | - | ? 'Please wait before attempting to reconnect to the chat.' | |
| 155 | - | : 'Please wait before attempting to view the public chat again.'; | |
| 156 | - | } else if (endpointType === 'auth') { | |
| 157 | - | errorMessage += 'Please wait before attempting to log in again.'; | |
| 158 | - | } else { | |
| 159 | - | errorMessage += 'Please try again later.'; | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | return new Response(JSON.stringify({ | |
| 163 | - | error: errorMessage, | |
| 164 | - | retryAfter: Math.ceil((retryAfter || 0) / 1000), | |
| 165 | - | endpointType, | |
| 166 | - | isAuthenticated, | |
| 167 | - | debug: { | |
| 168 | - | isAuthenticated, | |
| 169 | - | hasUser: Boolean(event.locals.user), | |
| 170 | - | hasSession: Boolean(event.locals.session), | |
| 171 | - | endpointType, | |
| 172 | - | rateLimitPoints: isAuthenticated | |
| 173 | - | ? RATE_LIMITS.sse.authenticated.points | |
| 174 | - | : RATE_LIMITS.sse.unauthenticated.points | |
| 175 | - | } | |
| 176 | - | }), { | |
| 177 | - | status: 429, | |
| 178 | - | headers: { | |
| 179 | - | 'Content-Type': 'application/json', | |
| 180 | - | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(), | |
| 181 | - | ...debugHeaders | |
| 182 | - | } | |
| 183 | - | }); | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | return null; | |
| 187 | - | } ⏎̸ | |
| 138 | + | const ip = event.getClientAddress(); | |
| 139 | + | const url = new URL(event.request.url); | |
| 140 | + | const isPublic = url.searchParams.get('public') === 'true'; | |
| 141 | + | const isAuthenticated = Boolean(event.locals.user); | |
| 142 | + | ||
| 143 | + | // Skip rate limiting for non-API routes | |
| 144 | + | if (!url.pathname.startsWith('/api')) { | |
| 145 | + | return null; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | const endpointType = getEndpointType(url.pathname, isPublic); | |
| 149 | + | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); | |
| 150 | + | ||
| 151 | + | if (limited) { | |
| 152 | + | // Create debug headers | |
| 153 | + | const debugHeaders = { | |
| 154 | + | 'X-Debug-Is-Authenticated': isAuthenticated.toString(), | |
| 155 | + | 'X-Debug-Has-User': Boolean(event.locals.user).toString(), | |
| 156 | + | 'X-Debug-Has-Session': Boolean(event.locals.session).toString(), | |
| 157 | + | 'X-Debug-Endpoint-Type': endpointType, | |
| 158 | + | 'X-Debug-Rate-Limit-Points': isAuthenticated | |
| 159 | + | ? RATE_LIMITS.sse.authenticated.points.toString() | |
| 160 | + | : RATE_LIMITS.sse.unauthenticated.points.toString() | |
| 161 | + | }; | |
| 162 | + | ||
| 163 | + | // Construct a user-friendly error message | |
| 164 | + | let errorMessage = 'Too many requests. '; | |
| 165 | + | if (endpointType === 'sse') { | |
| 166 | + | errorMessage += isAuthenticated | |
| 167 | + | ? 'Please wait before attempting to reconnect to the chat.' | |
| 168 | + | : 'Please wait before attempting to view the public chat again.'; | |
| 169 | + | } else if (endpointType === 'auth') { | |
| 170 | + | errorMessage += 'Please wait before attempting to log in again.'; | |
| 171 | + | } else { | |
| 172 | + | errorMessage += 'Please try again later.'; | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | return new Response( | |
| 176 | + | JSON.stringify({ | |
| 177 | + | error: errorMessage, | |
| 178 | + | retryAfter: Math.ceil((retryAfter || 0) / 1000), | |
| 179 | + | endpointType, | |
| 180 | + | isAuthenticated, | |
| 181 | + | debug: { | |
| 182 | + | isAuthenticated, | |
| 183 | + | hasUser: Boolean(event.locals.user), | |
| 184 | + | hasSession: Boolean(event.locals.session), | |
| 185 | + | endpointType, | |
| 186 | + | rateLimitPoints: isAuthenticated | |
| 187 | + | ? RATE_LIMITS.sse.authenticated.points | |
| 188 | + | : RATE_LIMITS.sse.unauthenticated.points | |
| 189 | + | } | |
| 190 | + | }), | |
| 191 | + | { | |
| 192 | + | status: 429, | |
| 193 | + | headers: { | |
| 194 | + | 'Content-Type': 'application/json', | |
| 195 | + | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(), | |
| 196 | + | ...debugHeaders | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | return null; | |
| 203 | + | } | |
M src/lib/api/session.cookie.ts +52 -48
| @@ -1,61 +1,65 @@ | |||
| 1 | - | import type { Cookies } from "@sveltejs/kit"; | |
| 2 | - | import { createLogger } from "$lib/utils/logger.server"; | |
| 1 | + | import type { Cookies } from '@sveltejs/kit'; | |
| 2 | + | import { createLogger } from '$lib/utils/logger.server'; | |
| 3 | 3 | ||
| 4 | 4 | const log = createLogger('session-cookie'); | |
| 5 | 5 | ||
| 6 | 6 | // Helper function to determine cookie domain | |
| 7 | 7 | function getCookieDomain(): string | undefined { | |
| 8 | - | if (process.env.NODE_ENV === 'production') { | |
| 9 | - | // Check for Cloudflare Pages domain | |
| 10 | - | if (process.env.CF_PAGES_URL) { | |
| 11 | - | const url = process.env.CF_PAGES_URL; | |
| 12 | - | log.debug("Cloudflare Pages URL:", { url }); | |
| 13 | - | // For preview deployments, use host-only cookie | |
| 14 | - | if (process.env.CF_PAGES_BRANCH !== 'main') { | |
| 15 | - | return undefined; | |
| 16 | - | } | |
| 17 | - | // For production, use the main domain | |
| 18 | - | return '.pdraim.pages.dev'; | |
| 19 | - | } | |
| 20 | - | // Fallback for custom domain | |
| 21 | - | return '.pdraim.org'; | |
| 22 | - | } | |
| 23 | - | return undefined; | |
| 8 | + | if (process.env.NODE_ENV === 'production') { | |
| 9 | + | // Check for Cloudflare Pages domain | |
| 10 | + | if (process.env.CF_PAGES_URL) { | |
| 11 | + | const url = process.env.CF_PAGES_URL; | |
| 12 | + | log.debug('Cloudflare Pages URL:', { url }); | |
| 13 | + | // For preview deployments, use host-only cookie | |
| 14 | + | if (process.env.CF_PAGES_BRANCH !== 'main') { | |
| 15 | + | return undefined; | |
| 16 | + | } | |
| 17 | + | // For production, use the main domain | |
| 18 | + | return '.pdraim.pages.dev'; | |
| 19 | + | } | |
| 20 | + | // Fallback for custom domain | |
| 21 | + | return '.pdraim.org'; | |
| 22 | + | } | |
| 23 | + | return undefined; | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void { | |
| 27 | - | log.debug("Setting session cookie with expiry:", new Date(expiresAt)); | |
| 28 | - | ||
| 29 | - | const domain = getCookieDomain(); | |
| 30 | - | log.debug("Setting cookie for domain:", { domain }); | |
| 31 | - | ||
| 32 | - | cookies.set("session", token, { | |
| 33 | - | httpOnly: true, | |
| 34 | - | sameSite: "lax", | |
| 35 | - | secure: process.env.NODE_ENV === 'production', | |
| 36 | - | domain, | |
| 37 | - | expires: new Date(expiresAt), | |
| 38 | - | path: "/" | |
| 39 | - | }); | |
| 26 | + | export function setSessionTokenCookie( | |
| 27 | + | { cookies }: { cookies: Cookies }, | |
| 28 | + | token: string, | |
| 29 | + | expiresAt: number | |
| 30 | + | ): void { | |
| 31 | + | log.debug('Setting session cookie with expiry:', new Date(expiresAt)); | |
| 32 | + | ||
| 33 | + | const domain = getCookieDomain(); | |
| 34 | + | log.debug('Setting cookie for domain:', { domain }); | |
| 35 | + | ||
| 36 | + | cookies.set('session', token, { | |
| 37 | + | httpOnly: true, | |
| 38 | + | sameSite: 'lax', | |
| 39 | + | secure: process.env.NODE_ENV === 'production', | |
| 40 | + | domain, | |
| 41 | + | expires: new Date(expiresAt), | |
| 42 | + | path: '/' | |
| 43 | + | }); | |
| 40 | 44 | } | |
| 41 | 45 | ||
| 42 | 46 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | |
| 43 | - | log.debug("Deleting session cookie"); | |
| 44 | - | ||
| 45 | - | const domain = getCookieDomain(); | |
| 46 | - | log.debug("Deleting cookie for domain:", { domain }); | |
| 47 | - | ||
| 48 | - | cookies.set("session", "", { | |
| 49 | - | httpOnly: true, | |
| 50 | - | sameSite: "lax", | |
| 51 | - | secure: process.env.NODE_ENV === 'production', | |
| 52 | - | domain, | |
| 53 | - | maxAge: 0, | |
| 54 | - | path: "/" | |
| 55 | - | }); | |
| 47 | + | log.debug('Deleting session cookie'); | |
| 48 | + | ||
| 49 | + | const domain = getCookieDomain(); | |
| 50 | + | log.debug('Deleting cookie for domain:', { domain }); | |
| 51 | + | ||
| 52 | + | cookies.set('session', '', { | |
| 53 | + | httpOnly: true, | |
| 54 | + | sameSite: 'lax', | |
| 55 | + | secure: process.env.NODE_ENV === 'production', | |
| 56 | + | domain, | |
| 57 | + | maxAge: 0, | |
| 58 | + | path: '/' | |
| 59 | + | }); | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | 62 | export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined { | |
| 59 | - | log.debug("Getting session cookie"); | |
| 60 | - | return cookies.get("session"); | |
| 61 | - | } ⏎̸ | |
| 63 | + | log.debug('Getting session cookie'); | |
| 64 | + | return cookies.get('session'); | |
| 65 | + | } | |
M src/lib/api/session.server.ts +73 -73
| @@ -8,61 +8,61 @@ const log = createLogger('session-server'); | |||
| 8 | 8 | ||
| 9 | 9 | // Helper: Compute SHA-256 hash of a message and encode it as a hex string. | |
| 10 | 10 | export async function sha256(message: string): Promise<string> { | |
| 11 | - | const encoder = new TextEncoder(); | |
| 12 | - | const data = encoder.encode(message); | |
| 13 | - | const hashBuffer = await crypto.subtle.digest('SHA-256', data); | |
| 14 | - | const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| 15 | - | const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |
| 16 | - | return hashHex; | |
| 11 | + | const encoder = new TextEncoder(); | |
| 12 | + | const data = encoder.encode(message); | |
| 13 | + | const hashBuffer = await crypto.subtle.digest('SHA-256', data); | |
| 14 | + | const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| 15 | + | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); | |
| 16 | + | return hashHex; | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | // Helper: Custom Base32 encoder using the standard alphabet. | |
| 20 | 20 | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |
| 21 | - | const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; | |
| 21 | + | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | |
| 22 | 22 | function base32Encode(data: Uint8Array): string { | |
| 23 | - | let bits = 0; | |
| 24 | - | let value = 0; | |
| 25 | - | let output = ""; | |
| 26 | - | for (let i = 0; i < data.length; i++) { | |
| 27 | - | value = (value << 8) | data[i]; | |
| 28 | - | bits += 8; | |
| 29 | - | while (bits >= 5) { | |
| 30 | - | const index = (value >>> (bits - 5)) & 0x1f; | |
| 31 | - | output += base32Alphabet[index]; | |
| 32 | - | bits -= 5; | |
| 33 | - | } | |
| 34 | - | } | |
| 35 | - | if (bits > 0) { | |
| 36 | - | const index = (value << (5 - bits)) & 0x1f; | |
| 37 | - | output += base32Alphabet[index]; | |
| 38 | - | } | |
| 39 | - | return output; | |
| 23 | + | let bits = 0; | |
| 24 | + | let value = 0; | |
| 25 | + | let output = ''; | |
| 26 | + | for (let i = 0; i < data.length; i++) { | |
| 27 | + | value = (value << 8) | data[i]; | |
| 28 | + | bits += 8; | |
| 29 | + | while (bits >= 5) { | |
| 30 | + | const index = (value >>> (bits - 5)) & 0x1f; | |
| 31 | + | output += base32Alphabet[index]; | |
| 32 | + | bits -= 5; | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | if (bits > 0) { | |
| 36 | + | const index = (value << (5 - bits)) & 0x1f; | |
| 37 | + | output += base32Alphabet[index]; | |
| 38 | + | } | |
| 39 | + | return output; | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | // Generate a secure session token by generating 20 random bytes and Base32 encoding them. | |
| 43 | 43 | export function generateSessionToken(): string { | |
| 44 | - | const randomBytesArray = new Uint8Array(20); | |
| 45 | - | crypto.getRandomValues(randomBytesArray); | |
| 46 | - | const token = base32Encode(randomBytesArray); | |
| 47 | - | log.debug("Generated session token", { token }); | |
| 48 | - | return token; | |
| 44 | + | const randomBytesArray = new Uint8Array(20); | |
| 45 | + | crypto.getRandomValues(randomBytesArray); | |
| 46 | + | const token = base32Encode(randomBytesArray); | |
| 47 | + | log.debug('Generated session token', { token }); | |
| 48 | + | return token; | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | // Create a session record once a valid token is available. | |
| 52 | 52 | // The session ID is the SHA-256 hash of the token. | |
| 53 | 53 | export async function createSession(token: string, userId: string): Promise<Session> { | |
| 54 | - | log.debug("Creating session", { userId }); | |
| 55 | - | const sessionId = await sha256(token); | |
| 56 | - | const createdAt = Date.now(); | |
| 57 | - | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | |
| 58 | - | await db.insert(sessions).values({ | |
| 59 | - | id: sessionId, | |
| 60 | - | userId, | |
| 61 | - | createdAt, | |
| 62 | - | expiresAt, | |
| 63 | - | }); | |
| 64 | - | log.debug("Session created", { sessionId }); | |
| 65 | - | return { id: sessionId, userId, createdAt, expiresAt }; | |
| 54 | + | log.debug('Creating session', { userId }); | |
| 55 | + | const sessionId = await sha256(token); | |
| 56 | + | const createdAt = Date.now(); | |
| 57 | + | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | |
| 58 | + | await db.insert(sessions).values({ | |
| 59 | + | id: sessionId, | |
| 60 | + | userId, | |
| 61 | + | createdAt, | |
| 62 | + | expiresAt | |
| 63 | + | }); | |
| 64 | + | log.debug('Session created', { sessionId }); | |
| 65 | + | return { id: sessionId, userId, createdAt, expiresAt }; | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | /** | |
| @@ -71,46 +71,46 @@ export async function createSession(token: string, userId: string): Promise<Sess | |||
| 71 | 71 | * Note: This returns the full User type since it's used server-side in hooks. | |
| 72 | 72 | */ | |
| 73 | 73 | export type SessionValidationResult = | |
| 74 | - | | { session: Session; user: User } | |
| 75 | - | | { session: null; user: null }; | |
| 74 | + | | { session: Session; user: User } | |
| 75 | + | | { session: null; user: null }; | |
| 76 | 76 | ||
| 77 | 77 | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | |
| 78 | 78 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | |
| 79 | - | log.debug("Validating session token"); | |
| 80 | - | const sessionId = await sha256(token); | |
| 81 | - | const session = await db.query.sessions.findFirst({ | |
| 82 | - | where: eq(sessions.id, sessionId) | |
| 83 | - | }); | |
| 84 | - | if (!session) { | |
| 85 | - | log.debug("Session not found", { sessionId }); | |
| 86 | - | return { session: null, user: null }; | |
| 87 | - | } | |
| 88 | - | const now = Date.now(); | |
| 89 | - | if (session.expiresAt < now) { | |
| 90 | - | log.debug("Session expired", { sessionId }); | |
| 91 | - | return { session: null, user: null }; | |
| 92 | - | } | |
| 93 | - | const user = await db.query.users.findFirst({ | |
| 94 | - | where: eq(users.id, session.userId) | |
| 95 | - | }); | |
| 96 | - | if (!user) { | |
| 97 | - | log.debug("User not found for session", { sessionId }); | |
| 98 | - | return { session: null, user: null }; | |
| 99 | - | } | |
| 100 | - | log.debug("Session validated", { sessionId, userId: user.id }); | |
| 101 | - | return { session, user }; | |
| 79 | + | log.debug('Validating session token'); | |
| 80 | + | const sessionId = await sha256(token); | |
| 81 | + | const session = await db.query.sessions.findFirst({ | |
| 82 | + | where: eq(sessions.id, sessionId) | |
| 83 | + | }); | |
| 84 | + | if (!session) { | |
| 85 | + | log.debug('Session not found', { sessionId }); | |
| 86 | + | return { session: null, user: null }; | |
| 87 | + | } | |
| 88 | + | const now = Date.now(); | |
| 89 | + | if (session.expiresAt < now) { | |
| 90 | + | log.debug('Session expired', { sessionId }); | |
| 91 | + | return { session: null, user: null }; | |
| 92 | + | } | |
| 93 | + | const user = await db.query.users.findFirst({ | |
| 94 | + | where: eq(users.id, session.userId) | |
| 95 | + | }); | |
| 96 | + | if (!user) { | |
| 97 | + | log.debug('User not found for session', { sessionId }); | |
| 98 | + | return { session: null, user: null }; | |
| 99 | + | } | |
| 100 | + | log.debug('Session validated', { sessionId, userId: user.id }); | |
| 101 | + | return { session, user }; | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | // Invalidate a specific session by deleting it from the database. | |
| 105 | 105 | export async function invalidateSession(sessionId: string): Promise<void> { | |
| 106 | - | log.debug("Invalidating session", { sessionId }); | |
| 107 | - | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 108 | - | log.debug("Session invalidated", { sessionId }); | |
| 106 | + | log.debug('Invalidating session', { sessionId }); | |
| 107 | + | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 108 | + | log.debug('Session invalidated', { sessionId }); | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | 111 | // Invalidate all sessions for a given user. | |
| 112 | 112 | export async function invalidateAllSessions(userId: string): Promise<void> { | |
| 113 | - | log.debug("Invalidating all sessions for user", { userId }); | |
| 114 | - | await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 115 | - | log.debug("All sessions invalidated", { userId }); | |
| 116 | - | } ⏎̸ | |
| 113 | + | log.debug('Invalidating all sessions for user', { userId }); | |
| 114 | + | await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 115 | + | log.debug('All sessions invalidated', { userId }); | |
| 116 | + | } | |
M src/lib/buddyListCache.ts +46 -46
| @@ -4,52 +4,52 @@ import type { SafeUser } from './types/chat'; | |||
| 4 | 4 | const log = createLogger('buddy-list-cache'); | |
| 5 | 5 | ||
| 6 | 6 | class BuddyListCache { | |
| 7 | - | private cache: SafeUser[] | null = null; | |
| 8 | - | private lastHash: string = ''; | |
| 9 | - | private lastFetch: number = 0; | |
| 10 | - | private isDirty: boolean = true; // Force initial fetch | |
| 11 | - | ||
| 12 | - | // Mark cache as needing refresh | |
| 13 | - | invalidate(): void { | |
| 14 | - | this.isDirty = true; | |
| 15 | - | log.debug('Buddy list cache invalidated'); | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // Check if we need to fetch new data | |
| 19 | - | needsRefresh(): boolean { | |
| 20 | - | return this.isDirty || this.cache === null; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // Update cache with new data | |
| 24 | - | update(users: SafeUser[]): boolean { | |
| 25 | - | const newHash = JSON.stringify(users); | |
| 26 | - | const hasChanged = newHash !== this.lastHash; | |
| 27 | - | ||
| 28 | - | if (hasChanged) { | |
| 29 | - | this.cache = users; | |
| 30 | - | this.lastHash = newHash; | |
| 31 | - | this.lastFetch = Date.now(); | |
| 32 | - | this.isDirty = false; | |
| 33 | - | ||
| 34 | - | log.debug('Buddy list cache updated', { | |
| 35 | - | userCount: users.length, | |
| 36 | - | changed: hasChanged | |
| 37 | - | }); | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return hasChanged; | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // Get cached data | |
| 44 | - | get(): SafeUser[] | null { | |
| 45 | - | return this.cache; | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Get cache age in milliseconds | |
| 49 | - | getAge(): number { | |
| 50 | - | return this.cache ? Date.now() - this.lastFetch : Infinity; | |
| 51 | - | } | |
| 7 | + | private cache: SafeUser[] | null = null; | |
| 8 | + | private lastHash: string = ''; | |
| 9 | + | private lastFetch: number = 0; | |
| 10 | + | private isDirty: boolean = true; // Force initial fetch | |
| 11 | + | ||
| 12 | + | // Mark cache as needing refresh | |
| 13 | + | invalidate(): void { | |
| 14 | + | this.isDirty = true; | |
| 15 | + | log.debug('Buddy list cache invalidated'); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Check if we need to fetch new data | |
| 19 | + | needsRefresh(): boolean { | |
| 20 | + | return this.isDirty || this.cache === null; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Update cache with new data | |
| 24 | + | update(users: SafeUser[]): boolean { | |
| 25 | + | const newHash = JSON.stringify(users); | |
| 26 | + | const hasChanged = newHash !== this.lastHash; | |
| 27 | + | ||
| 28 | + | if (hasChanged) { | |
| 29 | + | this.cache = users; | |
| 30 | + | this.lastHash = newHash; | |
| 31 | + | this.lastFetch = Date.now(); | |
| 32 | + | this.isDirty = false; | |
| 33 | + | ||
| 34 | + | log.debug('Buddy list cache updated', { | |
| 35 | + | userCount: users.length, | |
| 36 | + | changed: hasChanged | |
| 37 | + | }); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return hasChanged; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Get cached data | |
| 44 | + | get(): SafeUser[] | null { | |
| 45 | + | return this.cache; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Get cache age in milliseconds | |
| 49 | + | getAge(): number { | |
| 50 | + | return this.cache ? Date.now() - this.lastFetch : Infinity; | |
| 51 | + | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | // Export singleton instance | |
| 55 | - | export const buddyListCache = new BuddyListCache();⏎̸ | |
| 55 | + | export const buddyListCache = new BuddyListCache(); | |
M src/lib/components/aim-login.svelte +607 -468
| @@ -1,478 +1,617 @@ | |||
| 1 | 1 | <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs --> | |
| 2 | 2 | <script lang="ts"> | |
| 3 | - | import { draggable } from '$lib/actions/draggable'; | |
| 4 | - | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | - | import type { User, SafeUser } from '$lib/types/chat'; | |
| 6 | - | import { invalidateAll } from '$app/navigation'; | |
| 7 | - | import { chatState } from '$lib/states/chat.svelte'; | |
| 8 | - | import { createSafeUser } from '$lib/types/chat'; | |
| 9 | - | import { onMount } from 'svelte'; | |
| 10 | - | import PasswordStrengthIndicator from './password-strength-indicator.svelte'; | |
| 11 | - | import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password'; | |
| 12 | - | ||
| 13 | - | interface $$Props { | |
| 14 | - | showAuth: boolean; | |
| 15 | - | onLoginSuccess?: (user: SafeUser | null) => void; | |
| 16 | - | activeTab?: 'signin' | 'signup'; | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | |
| 20 | - | ||
| 21 | - | let activeTab = $state(initialTab); | |
| 22 | - | ||
| 23 | - | // Sign In state | |
| 24 | - | let siUsername = $state(''); | |
| 25 | - | let siPassword = $state(''); | |
| 26 | - | let error = $state(''); | |
| 27 | - | let loginStatus = $state('idle'); | |
| 28 | - | ||
| 29 | - | // Sign Up state | |
| 30 | - | let suUsername = $state(''); | |
| 31 | - | let suPassword = $state(''); | |
| 32 | - | let suConfirmPassword = $state(''); | |
| 33 | - | let captchaAnswer = $state(''); | |
| 34 | - | let signupError = $state(''); | |
| 35 | - | let signupStatus = $state('idle'); | |
| 36 | - | let suTurnstileToken = $state(''); | |
| 37 | - | ||
| 38 | - | // Password validation state | |
| 39 | - | let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS; | |
| 40 | - | let showPasswordStrength = $state(false); | |
| 41 | - | ||
| 42 | - | // Added dragging state and handler similar to chat-room | |
| 43 | - | let windowX = $state(0); | |
| 44 | - | let windowY = $state(0); | |
| 45 | - | ||
| 46 | - | // Initialize window position on mount | |
| 47 | - | onMount(() => { | |
| 48 | - | if (window.innerWidth > 768) { // Desktop mode | |
| 49 | - | windowX = window.innerWidth * 0.2; | |
| 50 | - | windowY = window.innerHeight * 0.2; | |
| 51 | - | } | |
| 52 | - | }); | |
| 53 | - | ||
| 54 | - | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |
| 55 | - | windowX = event.detail.x; | |
| 56 | - | windowY = event.detail.y; | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | function handleClose() { | |
| 60 | - | console.debug("Closing AIM Login component."); | |
| 61 | - | showAuth = false; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | async function handleSigninSubmit() { | |
| 65 | - | error = ''; | |
| 66 | - | loginStatus = 'loading'; | |
| 67 | - | ||
| 68 | - | // Validate login data | |
| 69 | - | const validationError = validateLoginData(); | |
| 70 | - | if (validationError) { | |
| 71 | - | error = validationError; | |
| 72 | - | loginStatus = 'error'; | |
| 73 | - | return; | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | console.debug("Attempting login for", siUsername); | |
| 77 | - | try { | |
| 78 | - | const res = await fetch('/api/session/login', { | |
| 79 | - | method: 'POST', | |
| 80 | - | headers: { 'Content-Type': 'application/json' }, | |
| 81 | - | body: JSON.stringify({ | |
| 82 | - | username: siUsername, | |
| 83 | - | password: siPassword | |
| 84 | - | }) | |
| 85 | - | }); | |
| 86 | - | ||
| 87 | - | const data = await res.json(); | |
| 88 | - | if (!res.ok) { | |
| 89 | - | error = data.error || "Login failed."; | |
| 90 | - | console.debug("Login failed:", data.error); | |
| 91 | - | loginStatus = 'error'; | |
| 92 | - | return; | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null }); | |
| 96 | - | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 97 | - | loginStatus = 'success'; | |
| 98 | - | ||
| 99 | - | // Call the callback prop if provided | |
| 100 | - | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 101 | - | // Auto close login window after 3 seconds on successful login | |
| 102 | - | setTimeout(() => { | |
| 103 | - | console.debug("Auto closing AIM Login component after successful login"); | |
| 104 | - | invalidateAll(); | |
| 105 | - | handleClose(); | |
| 106 | - | }, 3000); | |
| 107 | - | ||
| 108 | - | return; | |
| 109 | - | } catch (err) { | |
| 110 | - | console.debug("Login error:", err); | |
| 111 | - | error = "Login error. Please try again."; | |
| 112 | - | loginStatus = 'error'; | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // Validation using Zod schemas | |
| 117 | - | function validateLoginData() { | |
| 118 | - | try { | |
| 119 | - | loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() }); | |
| 120 | - | return null; | |
| 121 | - | } catch (err: any) { | |
| 122 | - | return err.errors?.[0]?.message || 'Invalid input'; | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | function validateSignupData() { | |
| 127 | - | try { | |
| 128 | - | const registrationSchema = createRegistrationSchema(passwordConstraints); | |
| 129 | - | registrationSchema.parse({ | |
| 130 | - | suUsername: suUsername.trim(), | |
| 131 | - | suPassword: suPassword.trim(), | |
| 132 | - | suConfirmPassword: suConfirmPassword.trim(), | |
| 133 | - | captchaAnswer: captchaAnswer.trim(), | |
| 134 | - | turnstileToken: suTurnstileToken | |
| 135 | - | }); | |
| 136 | - | return null; | |
| 137 | - | } catch (err: any) { | |
| 138 | - | return err.errors?.[0]?.message || 'Invalid input'; | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | async function handleSignupSubmit() { | |
| 143 | - | signupError = ''; | |
| 144 | - | signupStatus = 'loading'; | |
| 145 | - | ||
| 146 | - | // Client-side validations before submitting the form. | |
| 147 | - | const validationError = validateSignupData(); | |
| 148 | - | if (validationError) { | |
| 149 | - | signupError = validationError; | |
| 150 | - | signupStatus = 'error'; | |
| 151 | - | return; | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // Additional check for captcha | |
| 155 | - | if (!captchaAnswer) { | |
| 156 | - | signupError = 'Please complete all fields including the PDR question'; | |
| 157 | - | signupStatus = 'error'; | |
| 158 | - | return; | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | console.debug("Attempting registration for", suUsername); | |
| 162 | - | try { | |
| 163 | - | const res = await fetch('/api/register', { | |
| 164 | - | method: 'POST', | |
| 165 | - | headers: { 'Content-Type': 'application/json' }, | |
| 166 | - | body: JSON.stringify({ | |
| 167 | - | suUsername, | |
| 168 | - | suPassword, | |
| 169 | - | suConfirmPassword, | |
| 170 | - | captchaAnswer, | |
| 171 | - | turnstileToken: suTurnstileToken | |
| 172 | - | }) | |
| 173 | - | }); | |
| 174 | - | ||
| 175 | - | const data = await res.json() as RegisterResponse; | |
| 176 | - | if (!res.ok) { | |
| 177 | - | const errorData = data as RegisterResponseError; | |
| 178 | - | signupError = errorData.error || "Registration failed."; | |
| 179 | - | signupStatus = 'error'; | |
| 180 | - | return; | |
| 181 | - | } | |
| 182 | - | console.debug("Registration succeeded:", data); | |
| 183 | - | signupStatus = 'success'; | |
| 184 | - | ||
| 185 | - | // Auto login after registration. | |
| 186 | - | await handleAutoLoginSignup(); | |
| 187 | - | return; | |
| 188 | - | } catch (err) { | |
| 189 | - | console.debug("Registration error", err); | |
| 190 | - | signupError = "Registration error. Please try again."; | |
| 191 | - | signupStatus = 'error'; | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | async function handleAutoLoginSignup() { | |
| 196 | - | try { | |
| 197 | - | const res = await fetch('/api/session/login', { | |
| 198 | - | method: 'POST', | |
| 199 | - | headers: { 'Content-Type': 'application/json' }, | |
| 200 | - | body: JSON.stringify({ username: suUsername, password: suPassword }) | |
| 201 | - | }); | |
| 202 | - | const data = await res.json(); | |
| 203 | - | if (!res.ok) { | |
| 204 | - | signupError = data.error || "Login failed after registration."; | |
| 205 | - | signupStatus = 'error'; | |
| 206 | - | return; | |
| 207 | - | } | |
| 208 | - | console.debug("Auto login after registration succeeded:", data); | |
| 209 | - | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 210 | - | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 211 | - | setTimeout(() => { | |
| 212 | - | console.debug("Auto closing AIM Login component after auto login on registration"); | |
| 213 | - | invalidateAll(); | |
| 214 | - | handleClose(); | |
| 215 | - | }, 3000); | |
| 216 | - | ||
| 217 | - | } catch (err) { | |
| 218 | - | console.debug("Auto login error:", err); | |
| 219 | - | signupError = "Auto login error. Please try to login manually."; | |
| 220 | - | signupStatus = 'error'; | |
| 221 | - | } | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | function switchTab(tab: string) { | |
| 225 | - | activeTab = tab; | |
| 226 | - | error = ''; | |
| 227 | - | signupError = ''; | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | function handleKeydown(event: KeyboardEvent) { | |
| 231 | - | if (event.key === 'Escape') { | |
| 232 | - | console.debug("Escape key pressed, closing AIM Login component."); | |
| 233 | - | handleClose(); | |
| 234 | - | } | |
| 235 | - | } | |
| 3 | + | import { draggable } from '$lib/actions/draggable'; | |
| 4 | + | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | + | import type { User, SafeUser } from '$lib/types/chat'; | |
| 6 | + | import { invalidateAll } from '$app/navigation'; | |
| 7 | + | import { chatState } from '$lib/states/chat.svelte'; | |
| 8 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 9 | + | import { onMount } from 'svelte'; | |
| 10 | + | import PasswordStrengthIndicator from './password-strength-indicator.svelte'; | |
| 11 | + | import { | |
| 12 | + | loginSchema, | |
| 13 | + | createRegistrationSchema, | |
| 14 | + | validatePasswordStrength, | |
| 15 | + | DEFAULT_PASSWORD_CONSTRAINTS, | |
| 16 | + | type PasswordConstraints | |
| 17 | + | } from '../validation/password'; | |
| 18 | + | ||
| 19 | + | interface $$Props { | |
| 20 | + | showAuth: boolean; | |
| 21 | + | onLoginSuccess?: (user: SafeUser | null) => void; | |
| 22 | + | activeTab?: 'signin' | 'signup'; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | |
| 26 | + | ||
| 27 | + | let activeTab = $state(initialTab); | |
| 28 | + | ||
| 29 | + | // Sign In state | |
| 30 | + | let siUsername = $state(''); | |
| 31 | + | let siPassword = $state(''); | |
| 32 | + | let error = $state(''); | |
| 33 | + | let loginStatus = $state('idle'); | |
| 34 | + | ||
| 35 | + | // Sign Up state | |
| 36 | + | let suUsername = $state(''); | |
| 37 | + | let suPassword = $state(''); | |
| 38 | + | let suConfirmPassword = $state(''); | |
| 39 | + | let captchaAnswer = $state(''); | |
| 40 | + | let signupError = $state(''); | |
| 41 | + | let signupStatus = $state('idle'); | |
| 42 | + | let suTurnstileToken = $state(''); | |
| 43 | + | ||
| 44 | + | // Password validation state | |
| 45 | + | let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS; | |
| 46 | + | let showPasswordStrength = $state(false); | |
| 47 | + | ||
| 48 | + | // Added dragging state and handler similar to chat-room | |
| 49 | + | let windowX = $state(0); | |
| 50 | + | let windowY = $state(0); | |
| 51 | + | ||
| 52 | + | // Initialize window position on mount | |
| 53 | + | onMount(() => { | |
| 54 | + | if (window.innerWidth > 768) { | |
| 55 | + | // Desktop mode | |
| 56 | + | windowX = window.innerWidth * 0.2; | |
| 57 | + | windowY = window.innerHeight * 0.2; | |
| 58 | + | } | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |
| 62 | + | windowX = event.detail.x; | |
| 63 | + | windowY = event.detail.y; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | function handleClose() { | |
| 67 | + | console.debug('Closing AIM Login component.'); | |
| 68 | + | showAuth = false; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | async function handleSigninSubmit() { | |
| 72 | + | error = ''; | |
| 73 | + | loginStatus = 'loading'; | |
| 74 | + | ||
| 75 | + | // Validate login data | |
| 76 | + | const validationError = validateLoginData(); | |
| 77 | + | if (validationError) { | |
| 78 | + | error = validationError; | |
| 79 | + | loginStatus = 'error'; | |
| 80 | + | return; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | console.debug('Attempting login for', siUsername); | |
| 84 | + | try { | |
| 85 | + | const res = await fetch('/api/session/login', { | |
| 86 | + | method: 'POST', | |
| 87 | + | headers: { 'Content-Type': 'application/json' }, | |
| 88 | + | body: JSON.stringify({ | |
| 89 | + | username: siUsername, | |
| 90 | + | password: siPassword | |
| 91 | + | }) | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | const data = await res.json(); | |
| 95 | + | if (!res.ok) { | |
| 96 | + | error = data.error || 'Login failed.'; | |
| 97 | + | console.debug('Login failed:', data.error); | |
| 98 | + | loginStatus = 'error'; | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | console.debug('Login succeeded:', { | |
| 103 | + | ...data, | |
| 104 | + | user: data.user ? { ...data.user, password: '[REDACTED]' } : null | |
| 105 | + | }); | |
| 106 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 107 | + | loginStatus = 'success'; | |
| 108 | + | ||
| 109 | + | // Call the callback prop if provided | |
| 110 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 111 | + | // Auto close login window after 3 seconds on successful login | |
| 112 | + | setTimeout(() => { | |
| 113 | + | console.debug('Auto closing AIM Login component after successful login'); | |
| 114 | + | invalidateAll(); | |
| 115 | + | handleClose(); | |
| 116 | + | }, 3000); | |
| 117 | + | ||
| 118 | + | return; | |
| 119 | + | } catch (err) { | |
| 120 | + | console.debug('Login error:', err); | |
| 121 | + | error = 'Login error. Please try again.'; | |
| 122 | + | loginStatus = 'error'; | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Validation using Zod schemas | |
| 127 | + | function validateLoginData() { | |
| 128 | + | try { | |
| 129 | + | loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() }); | |
| 130 | + | return null; | |
| 131 | + | } catch (err: any) { | |
| 132 | + | return err.errors?.[0]?.message || 'Invalid input'; | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | function validateSignupData() { | |
| 137 | + | try { | |
| 138 | + | const registrationSchema = createRegistrationSchema(passwordConstraints); | |
| 139 | + | registrationSchema.parse({ | |
| 140 | + | suUsername: suUsername.trim(), | |
| 141 | + | suPassword: suPassword.trim(), | |
| 142 | + | suConfirmPassword: suConfirmPassword.trim(), | |
| 143 | + | captchaAnswer: captchaAnswer.trim(), | |
| 144 | + | turnstileToken: suTurnstileToken | |
| 145 | + | }); | |
| 146 | + | return null; | |
| 147 | + | } catch (err: any) { | |
| 148 | + | return err.errors?.[0]?.message || 'Invalid input'; | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | async function handleSignupSubmit() { | |
| 153 | + | signupError = ''; | |
| 154 | + | signupStatus = 'loading'; | |
| 155 | + | ||
| 156 | + | // Client-side validations before submitting the form. | |
| 157 | + | const validationError = validateSignupData(); | |
| 158 | + | if (validationError) { | |
| 159 | + | signupError = validationError; | |
| 160 | + | signupStatus = 'error'; | |
| 161 | + | return; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Additional check for captcha | |
| 165 | + | if (!captchaAnswer) { | |
| 166 | + | signupError = 'Please complete all fields including the PDR question'; | |
| 167 | + | signupStatus = 'error'; | |
| 168 | + | return; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | console.debug('Attempting registration for', suUsername); | |
| 172 | + | try { | |
| 173 | + | const res = await fetch('/api/register', { | |
| 174 | + | method: 'POST', | |
| 175 | + | headers: { 'Content-Type': 'application/json' }, | |
| 176 | + | body: JSON.stringify({ | |
| 177 | + | suUsername, | |
| 178 | + | suPassword, | |
| 179 | + | suConfirmPassword, | |
| 180 | + | captchaAnswer, | |
| 181 | + | turnstileToken: suTurnstileToken | |
| 182 | + | }) | |
| 183 | + | }); | |
| 184 | + | ||
| 185 | + | const data = (await res.json()) as RegisterResponse; | |
| 186 | + | if (!res.ok) { | |
| 187 | + | const errorData = data as RegisterResponseError; | |
| 188 | + | signupError = errorData.error || 'Registration failed.'; | |
| 189 | + | signupStatus = 'error'; | |
| 190 | + | return; | |
| 191 | + | } | |
| 192 | + | console.debug('Registration succeeded:', data); | |
| 193 | + | signupStatus = 'success'; | |
| 194 | + | ||
| 195 | + | // Auto login after registration. | |
| 196 | + | await handleAutoLoginSignup(); | |
| 197 | + | return; | |
| 198 | + | } catch (err) { | |
| 199 | + | console.debug('Registration error', err); | |
| 200 | + | signupError = 'Registration error. Please try again.'; | |
| 201 | + | signupStatus = 'error'; | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | async function handleAutoLoginSignup() { | |
| 206 | + | try { | |
| 207 | + | const res = await fetch('/api/session/login', { | |
| 208 | + | method: 'POST', | |
| 209 | + | headers: { 'Content-Type': 'application/json' }, | |
| 210 | + | body: JSON.stringify({ username: suUsername, password: suPassword }) | |
| 211 | + | }); | |
| 212 | + | const data = await res.json(); | |
| 213 | + | if (!res.ok) { | |
| 214 | + | signupError = data.error || 'Login failed after registration.'; | |
| 215 | + | signupStatus = 'error'; | |
| 216 | + | return; | |
| 217 | + | } | |
| 218 | + | console.debug('Auto login after registration succeeded:', data); | |
| 219 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 220 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 221 | + | setTimeout(() => { | |
| 222 | + | console.debug('Auto closing AIM Login component after auto login on registration'); | |
| 223 | + | invalidateAll(); | |
| 224 | + | handleClose(); | |
| 225 | + | }, 3000); | |
| 226 | + | } catch (err) { | |
| 227 | + | console.debug('Auto login error:', err); | |
| 228 | + | signupError = 'Auto login error. Please try to login manually.'; | |
| 229 | + | signupStatus = 'error'; | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | function switchTab(tab: string) { | |
| 234 | + | activeTab = tab; | |
| 235 | + | error = ''; | |
| 236 | + | signupError = ''; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | function handleKeydown(event: KeyboardEvent) { | |
| 240 | + | if (event.key === 'Escape') { | |
| 241 | + | console.debug('Escape key pressed, closing AIM Login component.'); | |
| 242 | + | handleClose(); | |
| 243 | + | } | |
| 244 | + | } | |
| 236 | 245 | </script> | |
| 237 | 246 | ||
| 238 | 247 | <svelte:window onkeydown={handleKeydown} /> | |
| 239 | 248 | ||
| 240 | 249 | <!-- Updated outer div to handle dragging with inline style and drag event --> | |
| 241 | - | <div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;"> | |
| 242 | - | <div class="title-bar"> | |
| 243 | - | <div class="title-bar-text"> | |
| 244 | - | <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" /> | |
| 245 | - | {activeTab === 'signin' ? 'Connexion' : 'Inscription'} | |
| 246 | - | </div> | |
| 247 | - | <div class="title-bar-controls"> | |
| 248 | - | <button aria-label="Close" onclick={handleClose}>×</button> | |
| 249 | - | </div> | |
| 250 | - | </div> | |
| 251 | - | ||
| 252 | - | <div class="tabs"> | |
| 253 | - | <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Connexion</button> | |
| 254 | - | <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Inscription</button> | |
| 255 | - | </div> | |
| 256 | - | ||
| 257 | - | <div class="window-content"> | |
| 258 | - | {#if activeTab === 'signin'} | |
| 259 | - | <form onsubmit={handleSigninSubmit}> | |
| 260 | - | <div class="form-group"> | |
| 261 | - | <label for="si-username">Nom d'utilisateur</label> | |
| 262 | - | <input type="text" id="si-username" bind:value={siUsername} placeholder="Nom d'utilisateur" /> | |
| 263 | - | </div> | |
| 264 | - | <div class="form-group"> | |
| 265 | - | <label for="si-password">Mot de passe</label> | |
| 266 | - | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | |
| 267 | - | </div> | |
| 268 | - | {#if error} | |
| 269 | - | <div class="error">{error}</div> | |
| 270 | - | {/if} | |
| 271 | - | <div class="button-bar"> | |
| 272 | - | <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}"> | |
| 273 | - | {#if loginStatus === 'loading'} | |
| 274 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 275 | - | <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle> | |
| 276 | - | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | |
| 277 | - | <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/> | |
| 278 | - | </path> | |
| 279 | - | </svg> | |
| 280 | - | {:else if loginStatus === 'success'} | |
| 281 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 282 | - | <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/> | |
| 283 | - | </svg> | |
| 284 | - | {:else if loginStatus === 'error'} | |
| 285 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 286 | - | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/> | |
| 287 | - | <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/> | |
| 288 | - | <circle cx="12" cy="17" r="1" fill="red"/> | |
| 289 | - | </svg> | |
| 290 | - | {:else} | |
| 291 | - | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 292 | - | <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/> | |
| 293 | - | </svg> | |
| 294 | - | {/if} | |
| 295 | - | Se connecter | |
| 296 | - | </button> | |
| 297 | - | </div> | |
| 298 | - | </form> | |
| 299 | - | {:else} | |
| 300 | - | <form onsubmit={handleSignupSubmit}> | |
| 301 | - | <div class="form-group"> | |
| 302 | - | <label for="su-username">Nom d'utilisateur</label> | |
| 303 | - | <input type="text" id="su-username" bind:value={suUsername} placeholder="Choisissez un nom d'utilisateur" /> | |
| 304 | - | </div> | |
| 305 | - | <div class="form-group"> | |
| 306 | - | <label for="su-password">Mot de passe</label> | |
| 307 | - | <input | |
| 308 | - | type="password" | |
| 309 | - | id="su-password" | |
| 310 | - | bind:value={suPassword} | |
| 311 | - | placeholder="Mot de passe" | |
| 312 | - | onfocus={() => showPasswordStrength = true} | |
| 313 | - | /> | |
| 314 | - | {#if showPasswordStrength} | |
| 315 | - | <PasswordStrengthIndicator | |
| 316 | - | password={suPassword} | |
| 317 | - | constraints={passwordConstraints} | |
| 318 | - | showDetails={true} | |
| 319 | - | /> | |
| 320 | - | {/if} | |
| 321 | - | </div> | |
| 322 | - | <div class="form-group"> | |
| 323 | - | <label for="su-confirm-password">Confirmer le mot de passe</label> | |
| 324 | - | <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirmer le mot de passe" /> | |
| 325 | - | </div> | |
| 326 | - | <div class="form-group"> | |
| 327 | - | <label for="captcha">Quelle est la signification de PDR ?</label> | |
| 328 | - | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | |
| 329 | - | </div> | |
| 330 | - | {#if signupError} | |
| 331 | - | <div class="error">{signupError}</div> | |
| 332 | - | {/if} | |
| 333 | - | <div class="button-bar"> | |
| 334 | - | <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}"> | |
| 335 | - | {#if signupStatus === 'loading'} | |
| 336 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 337 | - | <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle> | |
| 338 | - | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | |
| 339 | - | <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform> | |
| 340 | - | </path> | |
| 341 | - | </svg> | |
| 342 | - | {:else if signupStatus === 'success'} | |
| 343 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 344 | - | <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path> | |
| 345 | - | </svg> | |
| 346 | - | {:else if signupStatus === 'error'} | |
| 347 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 348 | - | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle> | |
| 349 | - | <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line> | |
| 350 | - | <circle cx="12" cy="17" r="1" fill="red"></circle> | |
| 351 | - | </svg> | |
| 352 | - | {:else} | |
| 353 | - | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 354 | - | <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path> | |
| 355 | - | </svg> | |
| 356 | - | {/if} | |
| 357 | - | S'inscrire | |
| 358 | - | </button> | |
| 359 | - | </div> | |
| 360 | - | </form> | |
| 361 | - | {/if} | |
| 362 | - | </div> | |
| 250 | + | <div | |
| 251 | + | class="login-window window" | |
| 252 | + | use:draggable={{ handle: '.title-bar' }} | |
| 253 | + | ondragmove={handleDragMove} | |
| 254 | + | style="left: {windowX}px; top: {windowY}px;" | |
| 255 | + | > | |
| 256 | + | <div class="title-bar"> | |
| 257 | + | <div class="title-bar-text"> | |
| 258 | + | <img | |
| 259 | + | src="/desktop/aim-desktop-icon.png" | |
| 260 | + | alt="AIM" | |
| 261 | + | class="title-icon" | |
| 262 | + | width="16" | |
| 263 | + | height="16" | |
| 264 | + | /> | |
| 265 | + | {activeTab === 'signin' ? 'Connexion' : 'Inscription'} | |
| 266 | + | </div> | |
| 267 | + | <div class="title-bar-controls"> | |
| 268 | + | <button aria-label="Close" onclick={handleClose}>×</button> | |
| 269 | + | </div> | |
| 270 | + | </div> | |
| 271 | + | ||
| 272 | + | <div class="tabs"> | |
| 273 | + | <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')} | |
| 274 | + | >Connexion</button | |
| 275 | + | > | |
| 276 | + | <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')} | |
| 277 | + | >Inscription</button | |
| 278 | + | > | |
| 279 | + | </div> | |
| 280 | + | ||
| 281 | + | <div class="window-content"> | |
| 282 | + | {#if activeTab === 'signin'} | |
| 283 | + | <form onsubmit={handleSigninSubmit}> | |
| 284 | + | <div class="form-group"> | |
| 285 | + | <label for="si-username">Nom d'utilisateur</label> | |
| 286 | + | <input | |
| 287 | + | type="text" | |
| 288 | + | id="si-username" | |
| 289 | + | bind:value={siUsername} | |
| 290 | + | placeholder="Nom d'utilisateur" | |
| 291 | + | /> | |
| 292 | + | </div> | |
| 293 | + | <div class="form-group"> | |
| 294 | + | <label for="si-password">Mot de passe</label> | |
| 295 | + | <input | |
| 296 | + | type="password" | |
| 297 | + | id="si-password" | |
| 298 | + | bind:value={siPassword} | |
| 299 | + | placeholder="Mot de passe" | |
| 300 | + | /> | |
| 301 | + | </div> | |
| 302 | + | {#if error} | |
| 303 | + | <div class="error">{error}</div> | |
| 304 | + | {/if} | |
| 305 | + | <div class="button-bar"> | |
| 306 | + | <button | |
| 307 | + | type="submit" | |
| 308 | + | class="icon-button {loginStatus === 'success' | |
| 309 | + | ? 'success-border' | |
| 310 | + | : loginStatus === 'error' | |
| 311 | + | ? 'error-border' | |
| 312 | + | : ''}" | |
| 313 | + | > | |
| 314 | + | {#if loginStatus === 'loading'} | |
| 315 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 316 | + | <circle | |
| 317 | + | cx="12" | |
| 318 | + | cy="12" | |
| 319 | + | r="10" | |
| 320 | + | stroke="currentColor" | |
| 321 | + | stroke-width="2" | |
| 322 | + | fill="none" | |
| 323 | + | opacity="0.25" | |
| 324 | + | ></circle> | |
| 325 | + | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | |
| 326 | + | <animateTransform | |
| 327 | + | attributeName="transform" | |
| 328 | + | type="rotate" | |
| 329 | + | values="0 12 12;360 12 12" | |
| 330 | + | dur="1s" | |
| 331 | + | repeatCount="indefinite" | |
| 332 | + | /> | |
| 333 | + | </path> | |
| 334 | + | </svg> | |
| 335 | + | {:else if loginStatus === 'success'} | |
| 336 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 337 | + | <path | |
| 338 | + | d="M20 6L9 17l-5-5" | |
| 339 | + | stroke="green" | |
| 340 | + | stroke-width="2" | |
| 341 | + | fill="none" | |
| 342 | + | stroke-linecap="round" | |
| 343 | + | stroke-linejoin="round" | |
| 344 | + | /> | |
| 345 | + | </svg> | |
| 346 | + | {:else if loginStatus === 'error'} | |
| 347 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 348 | + | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none" /> | |
| 349 | + | <line | |
| 350 | + | x1="12" | |
| 351 | + | y1="8" | |
| 352 | + | x2="12" | |
| 353 | + | y2="13" | |
| 354 | + | stroke="red" | |
| 355 | + | stroke-width="2" | |
| 356 | + | stroke-linecap="round" | |
| 357 | + | /> | |
| 358 | + | <circle cx="12" cy="17" r="1" fill="red" /> | |
| 359 | + | </svg> | |
| 360 | + | {:else} | |
| 361 | + | <svg | |
| 362 | + | width="24" | |
| 363 | + | height="24" | |
| 364 | + | viewBox="0 0 24 24" | |
| 365 | + | fill="none" | |
| 366 | + | xmlns="http://www.w3.org/2000/svg" | |
| 367 | + | > | |
| 368 | + | <path | |
| 369 | + | d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" | |
| 370 | + | fill="currentColor" | |
| 371 | + | /> | |
| 372 | + | </svg> | |
| 373 | + | {/if} | |
| 374 | + | Se connecter | |
| 375 | + | </button> | |
| 376 | + | </div> | |
| 377 | + | </form> | |
| 378 | + | {:else} | |
| 379 | + | <form onsubmit={handleSignupSubmit}> | |
| 380 | + | <div class="form-group"> | |
| 381 | + | <label for="su-username">Nom d'utilisateur</label> | |
| 382 | + | <input | |
| 383 | + | type="text" | |
| 384 | + | id="su-username" | |
| 385 | + | bind:value={suUsername} | |
| 386 | + | placeholder="Choisissez un nom d'utilisateur" | |
| 387 | + | /> | |
| 388 | + | </div> | |
| 389 | + | <div class="form-group"> | |
| 390 | + | <label for="su-password">Mot de passe</label> | |
| 391 | + | <input | |
| 392 | + | type="password" | |
| 393 | + | id="su-password" | |
| 394 | + | bind:value={suPassword} | |
| 395 | + | placeholder="Mot de passe" | |
| 396 | + | onfocus={() => (showPasswordStrength = true)} | |
| 397 | + | /> | |
| 398 | + | {#if showPasswordStrength} | |
| 399 | + | <PasswordStrengthIndicator | |
| 400 | + | password={suPassword} | |
| 401 | + | constraints={passwordConstraints} | |
| 402 | + | showDetails={true} | |
| 403 | + | /> | |
| 404 | + | {/if} | |
| 405 | + | </div> | |
| 406 | + | <div class="form-group"> | |
| 407 | + | <label for="su-confirm-password">Confirmer le mot de passe</label> | |
| 408 | + | <input | |
| 409 | + | type="password" | |
| 410 | + | id="su-confirm-password" | |
| 411 | + | bind:value={suConfirmPassword} | |
| 412 | + | placeholder="Confirmer le mot de passe" | |
| 413 | + | /> | |
| 414 | + | </div> | |
| 415 | + | <div class="form-group"> | |
| 416 | + | <label for="captcha">Quelle est la signification de PDR ?</label> | |
| 417 | + | <input | |
| 418 | + | type="text" | |
| 419 | + | id="captcha" | |
| 420 | + | bind:value={captchaAnswer} | |
| 421 | + | placeholder="Entrez votre réponse" | |
| 422 | + | /> | |
| 423 | + | </div> | |
| 424 | + | {#if signupError} | |
| 425 | + | <div class="error">{signupError}</div> | |
| 426 | + | {/if} | |
| 427 | + | <div class="button-bar"> | |
| 428 | + | <button | |
| 429 | + | type="submit" | |
| 430 | + | class="icon-button {signupStatus === 'success' | |
| 431 | + | ? 'success-border' | |
| 432 | + | : signupStatus === 'error' | |
| 433 | + | ? 'error-border' | |
| 434 | + | : ''}" | |
| 435 | + | > | |
| 436 | + | {#if signupStatus === 'loading'} | |
| 437 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 438 | + | <circle | |
| 439 | + | cx="12" | |
| 440 | + | cy="12" | |
| 441 | + | r="10" | |
| 442 | + | stroke="currentColor" | |
| 443 | + | stroke-width="2" | |
| 444 | + | fill="none" | |
| 445 | + | opacity="0.25" | |
| 446 | + | ></circle> | |
| 447 | + | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | |
| 448 | + | <animateTransform | |
| 449 | + | attributeName="transform" | |
| 450 | + | type="rotate" | |
| 451 | + | values="0 12 12;360 12 12" | |
| 452 | + | dur="1s" | |
| 453 | + | repeatCount="indefinite" | |
| 454 | + | ></animateTransform> | |
| 455 | + | </path> | |
| 456 | + | </svg> | |
| 457 | + | {:else if signupStatus === 'success'} | |
| 458 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 459 | + | <path | |
| 460 | + | d="M20 6L9 17l-5-5" | |
| 461 | + | stroke="green" | |
| 462 | + | stroke-width="2" | |
| 463 | + | fill="none" | |
| 464 | + | stroke-linecap="round" | |
| 465 | + | stroke-linejoin="round" | |
| 466 | + | ></path> | |
| 467 | + | </svg> | |
| 468 | + | {:else if signupStatus === 'error'} | |
| 469 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |
| 470 | + | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle> | |
| 471 | + | <line | |
| 472 | + | x1="12" | |
| 473 | + | y1="8" | |
| 474 | + | x2="12" | |
| 475 | + | y2="13" | |
| 476 | + | stroke="red" | |
| 477 | + | stroke-width="2" | |
| 478 | + | stroke-linecap="round" | |
| 479 | + | ></line> | |
| 480 | + | <circle cx="12" cy="17" r="1" fill="red"></circle> | |
| 481 | + | </svg> | |
| 482 | + | {:else} | |
| 483 | + | <svg | |
| 484 | + | width="24" | |
| 485 | + | height="24" | |
| 486 | + | viewBox="0 0 24 24" | |
| 487 | + | fill="none" | |
| 488 | + | xmlns="http://www.w3.org/2000/svg" | |
| 489 | + | > | |
| 490 | + | <path | |
| 491 | + | d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" | |
| 492 | + | fill="currentColor" | |
| 493 | + | ></path> | |
| 494 | + | </svg> | |
| 495 | + | {/if} | |
| 496 | + | S'inscrire | |
| 497 | + | </button> | |
| 498 | + | </div> | |
| 499 | + | </form> | |
| 500 | + | {/if} | |
| 501 | + | </div> | |
| 363 | 502 | </div> | |
| 364 | 503 | ||
| 365 | 504 | <style> | |
| 366 | - | .login-window { | |
| 367 | - | width: 300px; | |
| 368 | - | background: #ECE9D8; | |
| 369 | - | /* Changed to fixed positioning for correct dragging */ | |
| 370 | - | position: fixed; | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | @media (max-width: 768px) { | |
| 374 | - | .login-window { | |
| 375 | - | width: 100% !important; | |
| 376 | - | height: 100% !important; | |
| 377 | - | top: 0 !important; | |
| 378 | - | left: 0 !important; | |
| 379 | - | right: 0 !important; | |
| 380 | - | bottom: 0 !important; | |
| 381 | - | margin: 0 !important; | |
| 382 | - | border-radius: 0 !important; | |
| 383 | - | transform: none !important; | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | .window-content { | |
| 387 | - | height: calc(100% - 80px); /* Account for title bar and tabs */ | |
| 388 | - | overflow-y: auto; | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | form { | |
| 392 | - | max-width: 400px; | |
| 393 | - | margin: 0 auto; | |
| 394 | - | padding: 1rem; | |
| 395 | - | } | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | .title-bar { | |
| 399 | - | background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%); | |
| 400 | - | padding: 4px 6px; | |
| 401 | - | display: flex; | |
| 402 | - | justify-content: space-between; | |
| 403 | - | align-items: center; | |
| 404 | - | color: white; | |
| 405 | - | font-weight: bold; | |
| 406 | - | user-select: none; | |
| 407 | - | position: relative; | |
| 408 | - | } | |
| 409 | - | .title-bar-text { | |
| 410 | - | display: flex; | |
| 411 | - | align-items: center; | |
| 412 | - | gap: 4px; | |
| 413 | - | font-size: 0.875rem; | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | .tabs { | |
| 417 | - | padding: 0 3px; | |
| 418 | - | display: flex; | |
| 419 | - | border-bottom: 1px solid #0054E3; | |
| 420 | - | } | |
| 421 | - | .tabs button { | |
| 422 | - | flex: 1; | |
| 423 | - | padding: 8px; | |
| 424 | - | border: none; | |
| 425 | - | background: #ECE9D8; | |
| 426 | - | font-size: 1rem; | |
| 427 | - | cursor: pointer; | |
| 428 | - | } | |
| 429 | - | .tabs button.active { | |
| 430 | - | background: #fff; | |
| 431 | - | border-bottom: 2px solid #0054E3; | |
| 432 | - | font-weight: bold; | |
| 433 | - | } | |
| 434 | - | .window-content { | |
| 435 | - | padding: 16px; | |
| 436 | - | } | |
| 437 | - | .form-group { | |
| 438 | - | margin-bottom: 16px; | |
| 439 | - | } | |
| 440 | - | .form-group label { | |
| 441 | - | display: block; | |
| 442 | - | margin-bottom: 4px; | |
| 443 | - | font-size: 1rem; | |
| 444 | - | } | |
| 445 | - | .form-group input { | |
| 446 | - | width: 100%; | |
| 447 | - | padding: 4px; | |
| 448 | - | font-size: 1rem; | |
| 449 | - | font-family: Arial, Verdana, Tahoma, sans-serif; | |
| 450 | - | } | |
| 451 | - | .button-bar { | |
| 452 | - | display: flex; | |
| 453 | - | justify-content: center; | |
| 454 | - | margin-top: 20px; | |
| 455 | - | } | |
| 456 | - | .icon-button { | |
| 457 | - | display: flex; | |
| 458 | - | flex-direction: column; | |
| 459 | - | align-items: center; | |
| 460 | - | gap: 4px; | |
| 461 | - | background: none; | |
| 462 | - | border: none; | |
| 463 | - | cursor: pointer; | |
| 464 | - | font-size: 1rem; | |
| 465 | - | color: black; | |
| 466 | - | } | |
| 467 | - | .error { | |
| 468 | - | color: red; | |
| 469 | - | font-size: 1rem; | |
| 470 | - | margin-top: 8px; | |
| 471 | - | } | |
| 472 | - | .success-border { | |
| 473 | - | border: 2px solid green; | |
| 474 | - | } | |
| 475 | - | .error-border { | |
| 476 | - | border: 2px solid red; | |
| 477 | - | } | |
| 478 | - | </style> ⏎̸ | |
| 505 | + | .login-window { | |
| 506 | + | width: 300px; | |
| 507 | + | background: #ece9d8; | |
| 508 | + | /* Changed to fixed positioning for correct dragging */ | |
| 509 | + | position: fixed; | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | @media (max-width: 768px) { | |
| 513 | + | .login-window { | |
| 514 | + | width: 100% !important; | |
| 515 | + | height: 100% !important; | |
| 516 | + | top: 0 !important; | |
| 517 | + | left: 0 !important; | |
| 518 | + | right: 0 !important; | |
| 519 | + | bottom: 0 !important; | |
| 520 | + | margin: 0 !important; | |
| 521 | + | border-radius: 0 !important; | |
| 522 | + | transform: none !important; | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | .window-content { | |
| 526 | + | height: calc(100% - 80px); /* Account for title bar and tabs */ | |
| 527 | + | overflow-y: auto; | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | form { | |
| 531 | + | max-width: 400px; | |
| 532 | + | margin: 0 auto; | |
| 533 | + | padding: 1rem; | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | .title-bar { | |
| 538 | + | background: linear-gradient(180deg, #0054e3 0%, #0047ab 100%); | |
| 539 | + | padding: 4px 6px; | |
| 540 | + | display: flex; | |
| 541 | + | justify-content: space-between; | |
| 542 | + | align-items: center; | |
| 543 | + | color: white; | |
| 544 | + | font-weight: bold; | |
| 545 | + | user-select: none; | |
| 546 | + | position: relative; | |
| 547 | + | } | |
| 548 | + | .title-bar-text { | |
| 549 | + | display: flex; | |
| 550 | + | align-items: center; | |
| 551 | + | gap: 4px; | |
| 552 | + | font-size: 0.875rem; | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | .tabs { | |
| 556 | + | padding: 0 3px; | |
| 557 | + | display: flex; | |
| 558 | + | border-bottom: 1px solid #0054e3; | |
| 559 | + | } | |
| 560 | + | .tabs button { | |
| 561 | + | flex: 1; | |
| 562 | + | padding: 8px; | |
| 563 | + | border: none; | |
| 564 | + | background: #ece9d8; | |
| 565 | + | font-size: 1rem; | |
| 566 | + | cursor: pointer; | |
| 567 | + | } | |
| 568 | + | .tabs button.active { | |
| 569 | + | background: #fff; | |
| 570 | + | border-bottom: 2px solid #0054e3; | |
| 571 | + | font-weight: bold; | |
| 572 | + | } | |
| 573 | + | .window-content { | |
| 574 | + | padding: 16px; | |
| 575 | + | } | |
| 576 | + | .form-group { | |
| 577 | + | margin-bottom: 16px; | |
| 578 | + | } | |
| 579 | + | .form-group label { | |
| 580 | + | display: block; | |
| 581 | + | margin-bottom: 4px; | |
| 582 | + | font-size: 1rem; | |
| 583 | + | } | |
| 584 | + | .form-group input { | |
| 585 | + | width: 100%; | |
| 586 | + | padding: 4px; | |
| 587 | + | font-size: 1rem; | |
| 588 | + | font-family: Arial, Verdana, Tahoma, sans-serif; | |
| 589 | + | } | |
| 590 | + | .button-bar { | |
| 591 | + | display: flex; | |
| 592 | + | justify-content: center; | |
| 593 | + | margin-top: 20px; | |
| 594 | + | } | |
| 595 | + | .icon-button { | |
| 596 | + | display: flex; | |
| 597 | + | flex-direction: column; | |
| 598 | + | align-items: center; | |
| 599 | + | gap: 4px; | |
| 600 | + | background: none; | |
| 601 | + | border: none; | |
| 602 | + | cursor: pointer; | |
| 603 | + | font-size: 1rem; | |
| 604 | + | color: black; | |
| 605 | + | } | |
| 606 | + | .error { | |
| 607 | + | color: red; | |
| 608 | + | font-size: 1rem; | |
| 609 | + | margin-top: 8px; | |
| 610 | + | } | |
| 611 | + | .success-border { | |
| 612 | + | border: 2px solid green; | |
| 613 | + | } | |
| 614 | + | .error-border { | |
| 615 | + | border: 2px solid red; | |
| 616 | + | } | |
| 617 | + | </style> | |
M src/lib/components/chat-room.svelte +1153 -1096 large diff (2249 lines changed): collapsed
M src/lib/components/color-picker-256.svelte +449 -446
| @@ -1,453 +1,456 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | |
| 3 | - | ||
| 4 | - | // Props | |
| 5 | - | let { | |
| 6 | - | selectedColor = $bindable('#000000'), | |
| 7 | - | selectedGradient = $bindable(undefined), | |
| 8 | - | onColorChange, | |
| 9 | - | onGradientChange, | |
| 10 | - | compact = true | |
| 11 | - | } = $props<{ | |
| 12 | - | selectedColor?: string; | |
| 13 | - | selectedGradient?: string[]; | |
| 14 | - | onColorChange?: (color: string) => void; | |
| 15 | - | onGradientChange?: (gradient: string[]) => void; | |
| 16 | - | compact?: boolean; | |
| 17 | - | }>(); | |
| 18 | - | ||
| 19 | - | // State | |
| 20 | - | let showPicker = $state(false); | |
| 21 | - | let gradientColors = $state<string[]>([selectedColor || '#000000']); | |
| 22 | - | let activeGradientSlot = $state(0); | |
| 23 | - | ||
| 24 | - | // Generate color palette | |
| 25 | - | const colors256 = generate256Colors(); | |
| 26 | - | const classicColors = Object.values(CLASSIC_COLORS); | |
| 27 | - | ||
| 28 | - | // Initialize gradient colors from props | |
| 29 | - | $effect(() => { | |
| 30 | - | if (selectedGradient && selectedGradient.length > 0) { | |
| 31 | - | gradientColors = [...selectedGradient]; | |
| 32 | - | } else if (selectedColor) { | |
| 33 | - | gradientColors = [selectedColor]; | |
| 34 | - | } | |
| 35 | - | }); | |
| 36 | - | ||
| 37 | - | // Get current display color/gradient | |
| 38 | - | let displayStyle = $derived(() => { | |
| 39 | - | if (gradientColors.length > 1) { | |
| 40 | - | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |
| 41 | - | } | |
| 42 | - | return `background: ${gradientColors[0] || '#000000'}`; | |
| 43 | - | }); | |
| 44 | - | ||
| 45 | - | // Handle color selection | |
| 46 | - | function selectColor(color: string) { | |
| 47 | - | if (activeGradientSlot < gradientColors.length) { | |
| 48 | - | gradientColors[activeGradientSlot] = color; | |
| 49 | - | gradientColors = [...gradientColors]; // Trigger reactivity | |
| 50 | - | ||
| 51 | - | if (gradientColors.length === 1) { | |
| 52 | - | // Single color mode | |
| 53 | - | selectedColor = color; | |
| 54 | - | selectedGradient = undefined; | |
| 55 | - | onColorChange?.(color); | |
| 56 | - | } else { | |
| 57 | - | // Gradient mode | |
| 58 | - | selectedGradient = [...gradientColors]; | |
| 59 | - | selectedColor = gradientColors[0]; | |
| 60 | - | onGradientChange?.(gradientColors); | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Gradient management | |
| 66 | - | function addGradientColor() { | |
| 67 | - | if (gradientColors.length < 3) { | |
| 68 | - | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | |
| 69 | - | gradientColors = [...gradientColors, newColor]; | |
| 70 | - | activeGradientSlot = gradientColors.length - 1; | |
| 71 | - | selectedGradient = [...gradientColors]; | |
| 72 | - | onGradientChange?.(gradientColors); | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | function removeGradientColor(index: number) { | |
| 77 | - | if (gradientColors.length > 1) { | |
| 78 | - | gradientColors = gradientColors.filter((_, i) => i !== index); | |
| 79 | - | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |
| 80 | - | ||
| 81 | - | if (gradientColors.length === 1) { | |
| 82 | - | // Back to single color mode | |
| 83 | - | selectedColor = gradientColors[0]; | |
| 84 | - | selectedGradient = undefined; | |
| 85 | - | onColorChange?.(gradientColors[0]); | |
| 86 | - | } else { | |
| 87 | - | selectedGradient = [...gradientColors]; | |
| 88 | - | onGradientChange?.(gradientColors); | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | function setActiveSlot(index: number) { | |
| 94 | - | activeGradientSlot = index; | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Close picker | |
| 98 | - | function closePicker() { | |
| 99 | - | showPicker = false; | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // Handle click outside to close | |
| 103 | - | function handleClickOutside(event: MouseEvent) { | |
| 104 | - | const target = event.target as HTMLElement; | |
| 105 | - | const colorPickerContainer = document.querySelector('.color-picker-container'); | |
| 106 | - | ||
| 107 | - | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | |
| 108 | - | closePicker(); | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Add/remove click outside listener | |
| 113 | - | $effect(() => { | |
| 114 | - | if (showPicker) { | |
| 115 | - | document.addEventListener('click', handleClickOutside); | |
| 116 | - | return () => { | |
| 117 | - | document.removeEventListener('click', handleClickOutside); | |
| 118 | - | }; | |
| 119 | - | } | |
| 120 | - | }); | |
| 2 | + | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | |
| 3 | + | ||
| 4 | + | // Props | |
| 5 | + | let { | |
| 6 | + | selectedColor = $bindable('#000000'), | |
| 7 | + | selectedGradient = $bindable(undefined), | |
| 8 | + | onColorChange, | |
| 9 | + | onGradientChange, | |
| 10 | + | compact = true | |
| 11 | + | } = $props<{ | |
| 12 | + | selectedColor?: string; | |
| 13 | + | selectedGradient?: string[]; | |
| 14 | + | onColorChange?: (color: string) => void; | |
| 15 | + | onGradientChange?: (gradient: string[]) => void; | |
| 16 | + | compact?: boolean; | |
| 17 | + | }>(); | |
| 18 | + | ||
| 19 | + | // State | |
| 20 | + | let showPicker = $state(false); | |
| 21 | + | let gradientColors = $state<string[]>([selectedColor || '#000000']); | |
| 22 | + | let activeGradientSlot = $state(0); | |
| 23 | + | ||
| 24 | + | // Generate color palette | |
| 25 | + | const colors256 = generate256Colors(); | |
| 26 | + | const classicColors = Object.values(CLASSIC_COLORS); | |
| 27 | + | ||
| 28 | + | // Initialize gradient colors from props | |
| 29 | + | $effect(() => { | |
| 30 | + | if (selectedGradient && selectedGradient.length > 0) { | |
| 31 | + | gradientColors = [...selectedGradient]; | |
| 32 | + | } else if (selectedColor) { | |
| 33 | + | gradientColors = [selectedColor]; | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // Get current display color/gradient | |
| 38 | + | let displayStyle = $derived(() => { | |
| 39 | + | if (gradientColors.length > 1) { | |
| 40 | + | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |
| 41 | + | } | |
| 42 | + | return `background: ${gradientColors[0] || '#000000'}`; | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | // Handle color selection | |
| 46 | + | function selectColor(color: string) { | |
| 47 | + | if (activeGradientSlot < gradientColors.length) { | |
| 48 | + | gradientColors[activeGradientSlot] = color; | |
| 49 | + | gradientColors = [...gradientColors]; // Trigger reactivity | |
| 50 | + | ||
| 51 | + | if (gradientColors.length === 1) { | |
| 52 | + | // Single color mode | |
| 53 | + | selectedColor = color; | |
| 54 | + | selectedGradient = undefined; | |
| 55 | + | onColorChange?.(color); | |
| 56 | + | } else { | |
| 57 | + | // Gradient mode | |
| 58 | + | selectedGradient = [...gradientColors]; | |
| 59 | + | selectedColor = gradientColors[0]; | |
| 60 | + | onGradientChange?.(gradientColors); | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Gradient management | |
| 66 | + | function addGradientColor() { | |
| 67 | + | if (gradientColors.length < 3) { | |
| 68 | + | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | |
| 69 | + | gradientColors = [...gradientColors, newColor]; | |
| 70 | + | activeGradientSlot = gradientColors.length - 1; | |
| 71 | + | selectedGradient = [...gradientColors]; | |
| 72 | + | onGradientChange?.(gradientColors); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function removeGradientColor(index: number) { | |
| 77 | + | if (gradientColors.length > 1) { | |
| 78 | + | gradientColors = gradientColors.filter((_, i) => i !== index); | |
| 79 | + | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |
| 80 | + | ||
| 81 | + | if (gradientColors.length === 1) { | |
| 82 | + | // Back to single color mode | |
| 83 | + | selectedColor = gradientColors[0]; | |
| 84 | + | selectedGradient = undefined; | |
| 85 | + | onColorChange?.(gradientColors[0]); | |
| 86 | + | } else { | |
| 87 | + | selectedGradient = [...gradientColors]; | |
| 88 | + | onGradientChange?.(gradientColors); | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function setActiveSlot(index: number) { | |
| 94 | + | activeGradientSlot = index; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Close picker | |
| 98 | + | function closePicker() { | |
| 99 | + | showPicker = false; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Handle click outside to close | |
| 103 | + | function handleClickOutside(event: MouseEvent) { | |
| 104 | + | const target = event.target as HTMLElement; | |
| 105 | + | const colorPickerContainer = document.querySelector('.color-picker-container'); | |
| 106 | + | ||
| 107 | + | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | |
| 108 | + | closePicker(); | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Add/remove click outside listener | |
| 113 | + | $effect(() => { | |
| 114 | + | if (showPicker) { | |
| 115 | + | document.addEventListener('click', handleClickOutside); | |
| 116 | + | return () => { | |
| 117 | + | document.removeEventListener('click', handleClickOutside); | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | }); | |
| 121 | 121 | </script> | |
| 122 | 122 | ||
| 123 | 123 | <div class="color-picker-container"> | |
| 124 | - | <!-- Color picker button --> | |
| 125 | - | <button | |
| 126 | - | onclick={(e) => { | |
| 127 | - | e.stopPropagation(); | |
| 128 | - | showPicker = !showPicker; | |
| 129 | - | }} | |
| 130 | - | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | |
| 131 | - | style="width: 20px; height: 20px; padding: 0;" | |
| 132 | - | > | |
| 133 | - | <div | |
| 134 | - | class="color-indicator" | |
| 135 | - | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | |
| 136 | - | ></div> | |
| 137 | - | </button> | |
| 138 | - | ||
| 139 | - | <!-- Color picker dropdown --> | |
| 140 | - | {#if showPicker} | |
| 141 | - | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | |
| 142 | - | <!-- Gradient controls --> | |
| 143 | - | <div class="gradient-controls"> | |
| 144 | - | <div class="gradient-slots"> | |
| 145 | - | {#each gradientColors as color, index} | |
| 146 | - | <button | |
| 147 | - | class="gradient-slot" | |
| 148 | - | class:active={activeGradientSlot === index} | |
| 149 | - | style="background-color: {color} !important;" | |
| 150 | - | onclick={() => setActiveSlot(index)} | |
| 151 | - | title="Color {index + 1} - Click to select this color slot" | |
| 152 | - | > | |
| 153 | - | {#if gradientColors.length > 1} | |
| 154 | - | <span | |
| 155 | - | class="remove-slot" | |
| 156 | - | onclick={(e) => { | |
| 157 | - | e.stopPropagation(); | |
| 158 | - | removeGradientColor(index); | |
| 159 | - | }} | |
| 160 | - | title="Remove this color" | |
| 161 | - | role="button" | |
| 162 | - | tabindex="0" | |
| 163 | - | >×</span> | |
| 164 | - | {/if} | |
| 165 | - | </button> | |
| 166 | - | {/each} | |
| 167 | - | ||
| 168 | - | {#if gradientColors.length < 3} | |
| 169 | - | <button | |
| 170 | - | class="add-slot" | |
| 171 | - | onclick={addGradientColor} | |
| 172 | - | title="Add another color to create gradient" | |
| 173 | - | >+</button> | |
| 174 | - | {/if} | |
| 175 | - | </div> | |
| 176 | - | ||
| 177 | - | {#if gradientColors.length > 1} | |
| 178 | - | <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div> | |
| 179 | - | {/if} | |
| 180 | - | </div> | |
| 181 | - | ||
| 182 | - | <!-- Classic colors (first row) --> | |
| 183 | - | <div class="color-section"> | |
| 184 | - | <div class="section-title">Classic Colors</div> | |
| 185 | - | <div class="color-row"> | |
| 186 | - | {#each classicColors as color} | |
| 187 | - | <button | |
| 188 | - | class="color-swatch classic-swatch" | |
| 189 | - | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 190 | - | onclick={() => selectColor(color)} | |
| 191 | - | title={color} | |
| 192 | - | ></button> | |
| 193 | - | {/each} | |
| 194 | - | </div> | |
| 195 | - | </div> | |
| 196 | - | ||
| 197 | - | <!-- 256-color palette --> | |
| 198 | - | <div class="color-section"> | |
| 199 | - | <div class="section-title">Full Palette</div> | |
| 200 | - | <div class="color-grid"> | |
| 201 | - | {#each colors256 as color} | |
| 202 | - | <button | |
| 203 | - | class="color-swatch" | |
| 204 | - | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 205 | - | onclick={() => selectColor(color)} | |
| 206 | - | title={color} | |
| 207 | - | ></button> | |
| 208 | - | {/each} | |
| 209 | - | </div> | |
| 210 | - | </div> | |
| 211 | - | ||
| 212 | - | <!-- Close button --> | |
| 213 | - | <div class="picker-footer"> | |
| 214 | - | <button onclick={closePicker}>Close</button> | |
| 215 | - | </div> | |
| 216 | - | </div> | |
| 217 | - | {/if} | |
| 124 | + | <!-- Color picker button --> | |
| 125 | + | <button | |
| 126 | + | onclick={(e) => { | |
| 127 | + | e.stopPropagation(); | |
| 128 | + | showPicker = !showPicker; | |
| 129 | + | }} | |
| 130 | + | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | |
| 131 | + | style="width: 20px; height: 20px; padding: 0;" | |
| 132 | + | > | |
| 133 | + | <div | |
| 134 | + | class="color-indicator" | |
| 135 | + | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | |
| 136 | + | ></div> | |
| 137 | + | </button> | |
| 138 | + | ||
| 139 | + | <!-- Color picker dropdown --> | |
| 140 | + | {#if showPicker} | |
| 141 | + | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | |
| 142 | + | <!-- Gradient controls --> | |
| 143 | + | <div class="gradient-controls"> | |
| 144 | + | <div class="gradient-slots"> | |
| 145 | + | {#each gradientColors as color, index} | |
| 146 | + | <button | |
| 147 | + | class="gradient-slot" | |
| 148 | + | class:active={activeGradientSlot === index} | |
| 149 | + | style="background-color: {color} !important;" | |
| 150 | + | onclick={() => setActiveSlot(index)} | |
| 151 | + | title="Color {index + 1} - Click to select this color slot" | |
| 152 | + | > | |
| 153 | + | {#if gradientColors.length > 1} | |
| 154 | + | <span | |
| 155 | + | class="remove-slot" | |
| 156 | + | onclick={(e) => { | |
| 157 | + | e.stopPropagation(); | |
| 158 | + | removeGradientColor(index); | |
| 159 | + | }} | |
| 160 | + | title="Remove this color" | |
| 161 | + | role="button" | |
| 162 | + | tabindex="0">×</span | |
| 163 | + | > | |
| 164 | + | {/if} | |
| 165 | + | </button> | |
| 166 | + | {/each} | |
| 167 | + | ||
| 168 | + | {#if gradientColors.length < 3} | |
| 169 | + | <button | |
| 170 | + | class="add-slot" | |
| 171 | + | onclick={addGradientColor} | |
| 172 | + | title="Add another color to create gradient">+</button | |
| 173 | + | > | |
| 174 | + | {/if} | |
| 175 | + | </div> | |
| 176 | + | ||
| 177 | + | {#if gradientColors.length > 1} | |
| 178 | + | <div | |
| 179 | + | class="gradient-preview" | |
| 180 | + | style="background: linear-gradient(90deg, {gradientColors.join(', ')});" | |
| 181 | + | ></div> | |
| 182 | + | {/if} | |
| 183 | + | </div> | |
| 184 | + | ||
| 185 | + | <!-- Classic colors (first row) --> | |
| 186 | + | <div class="color-section"> | |
| 187 | + | <div class="section-title">Classic Colors</div> | |
| 188 | + | <div class="color-row"> | |
| 189 | + | {#each classicColors as color} | |
| 190 | + | <button | |
| 191 | + | class="color-swatch classic-swatch" | |
| 192 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 193 | + | onclick={() => selectColor(color)} | |
| 194 | + | title={color} | |
| 195 | + | ></button> | |
| 196 | + | {/each} | |
| 197 | + | </div> | |
| 198 | + | </div> | |
| 199 | + | ||
| 200 | + | <!-- 256-color palette --> | |
| 201 | + | <div class="color-section"> | |
| 202 | + | <div class="section-title">Full Palette</div> | |
| 203 | + | <div class="color-grid"> | |
| 204 | + | {#each colors256 as color} | |
| 205 | + | <button | |
| 206 | + | class="color-swatch" | |
| 207 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 208 | + | onclick={() => selectColor(color)} | |
| 209 | + | title={color} | |
| 210 | + | ></button> | |
| 211 | + | {/each} | |
| 212 | + | </div> | |
| 213 | + | </div> | |
| 214 | + | ||
| 215 | + | <!-- Close button --> | |
| 216 | + | <div class="picker-footer"> | |
| 217 | + | <button onclick={closePicker}>Close</button> | |
| 218 | + | </div> | |
| 219 | + | </div> | |
| 220 | + | {/if} | |
| 218 | 221 | </div> | |
| 219 | 222 | ||
| 220 | 223 | <style> | |
| 221 | - | .color-picker-container { | |
| 222 | - | position: relative; | |
| 223 | - | display: inline-block; | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | .picker-dropdown { | |
| 227 | - | position: absolute; | |
| 228 | - | top: 100%; | |
| 229 | - | right: 0; /* Changed from left: 0 to prevent cutoff */ | |
| 230 | - | z-index: 1000; | |
| 231 | - | padding: 4px; | |
| 232 | - | margin-top: 2px; | |
| 233 | - | background: #ece9d8; /* Authentic XP dialog background */ | |
| 234 | - | width: 420px; | |
| 235 | - | max-height: 380px; | |
| 236 | - | overflow-y: auto; | |
| 237 | - | border: 2px outset #c0c0c0; | |
| 238 | - | box-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| 239 | - | box-sizing: border-box; | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | /* Responsive positioning for mobile/small screens */ | |
| 243 | - | @media (max-width: 768px) { | |
| 244 | - | .picker-dropdown { | |
| 245 | - | position: fixed; | |
| 246 | - | top: 50%; | |
| 247 | - | left: 50%; | |
| 248 | - | transform: translate(-50%, -50%); | |
| 249 | - | right: auto; | |
| 250 | - | width: 90vw; | |
| 251 | - | max-width: 420px; | |
| 252 | - | max-height: 85vh; | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | .gradient-controls { | |
| 257 | - | margin-bottom: 6px; | |
| 258 | - | padding: 4px; | |
| 259 | - | border: 1px inset #dfdfdf; | |
| 260 | - | background: #ece9d8; | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | .gradient-slots { | |
| 264 | - | display: flex; | |
| 265 | - | gap: 4px; | |
| 266 | - | margin-bottom: 4px; | |
| 267 | - | align-items: center; | |
| 268 | - | flex-wrap: wrap; | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | .gradient-slot { | |
| 272 | - | width: 24px; | |
| 273 | - | height: 24px; | |
| 274 | - | border: 2px outset #c0c0c0 !important; | |
| 275 | - | cursor: pointer; | |
| 276 | - | position: relative; | |
| 277 | - | padding: 0 !important; | |
| 278 | - | margin: 0 !important; | |
| 279 | - | background-image: none !important; | |
| 280 | - | min-width: 24px !important; | |
| 281 | - | min-height: 24px !important; | |
| 282 | - | max-width: 24px !important; | |
| 283 | - | max-height: 24px !important; | |
| 284 | - | box-sizing: border-box !important; | |
| 285 | - | flex-shrink: 0; | |
| 286 | - | transition: all 0.2s ease; | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | .gradient-slot.active { | |
| 290 | - | border: 2px inset #c0c0c0 !important; | |
| 291 | - | box-shadow: inset 0 0 0 1px #000 !important; | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | .gradient-slot:hover { | |
| 295 | - | transform: scale(1.1); | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | .gradient-slot .remove-slot { | |
| 299 | - | position: absolute; | |
| 300 | - | top: -4px; | |
| 301 | - | right: -4px; | |
| 302 | - | width: 12px !important; | |
| 303 | - | height: 12px !important; | |
| 304 | - | min-width: 12px !important; | |
| 305 | - | min-height: 12px !important; | |
| 306 | - | max-width: 12px !important; | |
| 307 | - | max-height: 12px !important; | |
| 308 | - | background-color: #808080 !important; | |
| 309 | - | color: white !important; | |
| 310 | - | border: 1px solid #000 !important; | |
| 311 | - | font-size: 9px !important; | |
| 312 | - | line-height: 1 !important; | |
| 313 | - | cursor: pointer !important; | |
| 314 | - | display: inline-flex !important; | |
| 315 | - | align-items: center !important; | |
| 316 | - | justify-content: center !important; | |
| 317 | - | border-radius: 0 !important; | |
| 318 | - | padding: 0 !important; | |
| 319 | - | margin: 0 !important; | |
| 320 | - | font-weight: normal !important; | |
| 321 | - | font-family: 'MS Sans Serif', sans-serif !important; | |
| 322 | - | z-index: 10; | |
| 323 | - | opacity: 0; | |
| 324 | - | transition: opacity 0.15s ease; | |
| 325 | - | text-align: center !important; | |
| 326 | - | box-sizing: border-box !important; | |
| 327 | - | user-select: none; | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | .gradient-slot:hover .remove-slot { | |
| 331 | - | opacity: 0.8; | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | .gradient-slot .remove-slot:hover { | |
| 335 | - | background-color: #000 !important; | |
| 336 | - | opacity: 1; | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | .add-slot { | |
| 340 | - | width: 24px; | |
| 341 | - | height: 24px; | |
| 342 | - | border: 2px dashed #666 !important; | |
| 343 | - | background: #f0f0f0 !important; | |
| 344 | - | cursor: pointer; | |
| 345 | - | font-weight: bold; | |
| 346 | - | padding: 0 !important; | |
| 347 | - | margin: 0 !important; | |
| 348 | - | min-width: 24px !important; | |
| 349 | - | min-height: 24px !important; | |
| 350 | - | max-width: 24px !important; | |
| 351 | - | max-height: 24px !important; | |
| 352 | - | background-image: none !important; | |
| 353 | - | box-sizing: border-box !important; | |
| 354 | - | flex-shrink: 0; | |
| 355 | - | display: flex !important; | |
| 356 | - | align-items: center !important; | |
| 357 | - | justify-content: center !important; | |
| 358 | - | font-size: 14px !important; | |
| 359 | - | color: #666 !important; | |
| 360 | - | transition: all 0.2s ease; | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | .add-slot:hover { | |
| 364 | - | background: #e0e0e0 !important; | |
| 365 | - | border-color: #333 !important; | |
| 366 | - | transform: scale(1.05); | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | .gradient-preview { | |
| 370 | - | height: 12px; | |
| 371 | - | border: 1px inset #dfdfdf; | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | .color-section { | |
| 375 | - | margin-bottom: 6px; | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | .section-title { | |
| 379 | - | font-size: 10px; | |
| 380 | - | font-weight: bold; | |
| 381 | - | margin-bottom: 2px; | |
| 382 | - | color: #000; | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | .color-row { | |
| 386 | - | display: flex; | |
| 387 | - | flex-wrap: wrap; | |
| 388 | - | gap: 2px; | |
| 389 | - | margin-bottom: 4px; | |
| 390 | - | padding: 3px; | |
| 391 | - | background: #ece9d8; | |
| 392 | - | border: 1px inset #dfdfdf; | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | .classic-swatch { | |
| 396 | - | width: 18px !important; | |
| 397 | - | height: 18px !important; | |
| 398 | - | min-width: 18px !important; | |
| 399 | - | min-height: 18px !important; | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | .color-grid { | |
| 403 | - | display: grid; | |
| 404 | - | grid-template-columns: repeat(16, 1fr); | |
| 405 | - | gap: 2px; | |
| 406 | - | width: 100%; | |
| 407 | - | padding: 3px; | |
| 408 | - | background: #ece9d8; | |
| 409 | - | border: 1px inset #dfdfdf; | |
| 410 | - | box-sizing: border-box; | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | .color-swatch { | |
| 414 | - | width: 18px !important; | |
| 415 | - | height: 18px !important; | |
| 416 | - | min-width: 18px !important; | |
| 417 | - | min-height: 18px !important; | |
| 418 | - | border: 1px solid #808080 !important; /* Override xp.css */ | |
| 419 | - | cursor: pointer; | |
| 420 | - | padding: 0 !important; | |
| 421 | - | margin: 0 !important; | |
| 422 | - | box-sizing: border-box !important; | |
| 423 | - | outline: none !important; | |
| 424 | - | /* Remove any xp.css button styling */ | |
| 425 | - | background-image: none !important; | |
| 426 | - | background-color: var(--swatch-color, #000000) !important; | |
| 427 | - | font-size: 0 !important; /* Hide any text */ | |
| 428 | - | line-height: 0 !important; | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | .color-swatch:hover { | |
| 432 | - | border: 2px solid #000000 !important; | |
| 433 | - | transform: scale(1.1) !important; | |
| 434 | - | z-index: 5; | |
| 435 | - | position: relative; | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | .color-swatch:active { | |
| 439 | - | border: 2px solid #ffffff !important; | |
| 440 | - | transform: scale(0.95) !important; | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | .picker-footer { | |
| 444 | - | text-align: center; | |
| 445 | - | padding-top: 4px; | |
| 446 | - | border-top: 1px solid #808080; | |
| 447 | - | margin-top: 4px; | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | .color-indicator { | |
| 451 | - | border-radius: 1px; | |
| 452 | - | } | |
| 453 | - | </style>⏎̸ | |
| 224 | + | .color-picker-container { | |
| 225 | + | position: relative; | |
| 226 | + | display: inline-block; | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | .picker-dropdown { | |
| 230 | + | position: absolute; | |
| 231 | + | top: 100%; | |
| 232 | + | right: 0; /* Changed from left: 0 to prevent cutoff */ | |
| 233 | + | z-index: 1000; | |
| 234 | + | padding: 4px; | |
| 235 | + | margin-top: 2px; | |
| 236 | + | background: #ece9d8; /* Authentic XP dialog background */ | |
| 237 | + | width: 420px; | |
| 238 | + | max-height: 380px; | |
| 239 | + | overflow-y: auto; | |
| 240 | + | border: 2px outset #c0c0c0; | |
| 241 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); | |
| 242 | + | box-sizing: border-box; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /* Responsive positioning for mobile/small screens */ | |
| 246 | + | @media (max-width: 768px) { | |
| 247 | + | .picker-dropdown { | |
| 248 | + | position: fixed; | |
| 249 | + | top: 50%; | |
| 250 | + | left: 50%; | |
| 251 | + | transform: translate(-50%, -50%); | |
| 252 | + | right: auto; | |
| 253 | + | width: 90vw; | |
| 254 | + | max-width: 420px; | |
| 255 | + | max-height: 85vh; | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | .gradient-controls { | |
| 260 | + | margin-bottom: 6px; | |
| 261 | + | padding: 4px; | |
| 262 | + | border: 1px inset #dfdfdf; | |
| 263 | + | background: #ece9d8; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | .gradient-slots { | |
| 267 | + | display: flex; | |
| 268 | + | gap: 4px; | |
| 269 | + | margin-bottom: 4px; | |
| 270 | + | align-items: center; | |
| 271 | + | flex-wrap: wrap; | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | .gradient-slot { | |
| 275 | + | width: 24px; | |
| 276 | + | height: 24px; | |
| 277 | + | border: 2px outset #c0c0c0 !important; | |
| 278 | + | cursor: pointer; | |
| 279 | + | position: relative; | |
| 280 | + | padding: 0 !important; | |
| 281 | + | margin: 0 !important; | |
| 282 | + | background-image: none !important; | |
| 283 | + | min-width: 24px !important; | |
| 284 | + | min-height: 24px !important; | |
| 285 | + | max-width: 24px !important; | |
| 286 | + | max-height: 24px !important; | |
| 287 | + | box-sizing: border-box !important; | |
| 288 | + | flex-shrink: 0; | |
| 289 | + | transition: all 0.2s ease; | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | .gradient-slot.active { | |
| 293 | + | border: 2px inset #c0c0c0 !important; | |
| 294 | + | box-shadow: inset 0 0 0 1px #000 !important; | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | .gradient-slot:hover { | |
| 298 | + | transform: scale(1.1); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | .gradient-slot .remove-slot { | |
| 302 | + | position: absolute; | |
| 303 | + | top: -4px; | |
| 304 | + | right: -4px; | |
| 305 | + | width: 12px !important; | |
| 306 | + | height: 12px !important; | |
| 307 | + | min-width: 12px !important; | |
| 308 | + | min-height: 12px !important; | |
| 309 | + | max-width: 12px !important; | |
| 310 | + | max-height: 12px !important; | |
| 311 | + | background-color: #808080 !important; | |
| 312 | + | color: white !important; | |
| 313 | + | border: 1px solid #000 !important; | |
| 314 | + | font-size: 9px !important; | |
| 315 | + | line-height: 1 !important; | |
| 316 | + | cursor: pointer !important; | |
| 317 | + | display: inline-flex !important; | |
| 318 | + | align-items: center !important; | |
| 319 | + | justify-content: center !important; | |
| 320 | + | border-radius: 0 !important; | |
| 321 | + | padding: 0 !important; | |
| 322 | + | margin: 0 !important; | |
| 323 | + | font-weight: normal !important; | |
| 324 | + | font-family: 'MS Sans Serif', sans-serif !important; | |
| 325 | + | z-index: 10; | |
| 326 | + | opacity: 0; | |
| 327 | + | transition: opacity 0.15s ease; | |
| 328 | + | text-align: center !important; | |
| 329 | + | box-sizing: border-box !important; | |
| 330 | + | user-select: none; | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | .gradient-slot:hover .remove-slot { | |
| 334 | + | opacity: 0.8; | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | .gradient-slot .remove-slot:hover { | |
| 338 | + | background-color: #000 !important; | |
| 339 | + | opacity: 1; | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | .add-slot { | |
| 343 | + | width: 24px; | |
| 344 | + | height: 24px; | |
| 345 | + | border: 2px dashed #666 !important; | |
| 346 | + | background: #f0f0f0 !important; | |
| 347 | + | cursor: pointer; | |
| 348 | + | font-weight: bold; | |
| 349 | + | padding: 0 !important; | |
| 350 | + | margin: 0 !important; | |
| 351 | + | min-width: 24px !important; | |
| 352 | + | min-height: 24px !important; | |
| 353 | + | max-width: 24px !important; | |
| 354 | + | max-height: 24px !important; | |
| 355 | + | background-image: none !important; | |
| 356 | + | box-sizing: border-box !important; | |
| 357 | + | flex-shrink: 0; | |
| 358 | + | display: flex !important; | |
| 359 | + | align-items: center !important; | |
| 360 | + | justify-content: center !important; | |
| 361 | + | font-size: 14px !important; | |
| 362 | + | color: #666 !important; | |
| 363 | + | transition: all 0.2s ease; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | .add-slot:hover { | |
| 367 | + | background: #e0e0e0 !important; | |
| 368 | + | border-color: #333 !important; | |
| 369 | + | transform: scale(1.05); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | .gradient-preview { | |
| 373 | + | height: 12px; | |
| 374 | + | border: 1px inset #dfdfdf; | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | .color-section { | |
| 378 | + | margin-bottom: 6px; | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | .section-title { | |
| 382 | + | font-size: 10px; | |
| 383 | + | font-weight: bold; | |
| 384 | + | margin-bottom: 2px; | |
| 385 | + | color: #000; | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | .color-row { | |
| 389 | + | display: flex; | |
| 390 | + | flex-wrap: wrap; | |
| 391 | + | gap: 2px; | |
| 392 | + | margin-bottom: 4px; | |
| 393 | + | padding: 3px; | |
| 394 | + | background: #ece9d8; | |
| 395 | + | border: 1px inset #dfdfdf; | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | .classic-swatch { | |
| 399 | + | width: 18px !important; | |
| 400 | + | height: 18px !important; | |
| 401 | + | min-width: 18px !important; | |
| 402 | + | min-height: 18px !important; | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | .color-grid { | |
| 406 | + | display: grid; | |
| 407 | + | grid-template-columns: repeat(16, 1fr); | |
| 408 | + | gap: 2px; | |
| 409 | + | width: 100%; | |
| 410 | + | padding: 3px; | |
| 411 | + | background: #ece9d8; | |
| 412 | + | border: 1px inset #dfdfdf; | |
| 413 | + | box-sizing: border-box; | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | .color-swatch { | |
| 417 | + | width: 18px !important; | |
| 418 | + | height: 18px !important; | |
| 419 | + | min-width: 18px !important; | |
| 420 | + | min-height: 18px !important; | |
| 421 | + | border: 1px solid #808080 !important; /* Override xp.css */ | |
| 422 | + | cursor: pointer; | |
| 423 | + | padding: 0 !important; | |
| 424 | + | margin: 0 !important; | |
| 425 | + | box-sizing: border-box !important; | |
| 426 | + | outline: none !important; | |
| 427 | + | /* Remove any xp.css button styling */ | |
| 428 | + | background-image: none !important; | |
| 429 | + | background-color: var(--swatch-color, #000000) !important; | |
| 430 | + | font-size: 0 !important; /* Hide any text */ | |
| 431 | + | line-height: 0 !important; | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | .color-swatch:hover { | |
| 435 | + | border: 2px solid #000000 !important; | |
| 436 | + | transform: scale(1.1) !important; | |
| 437 | + | z-index: 5; | |
| 438 | + | position: relative; | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | .color-swatch:active { | |
| 442 | + | border: 2px solid #ffffff !important; | |
| 443 | + | transform: scale(0.95) !important; | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | .picker-footer { | |
| 447 | + | text-align: center; | |
| 448 | + | padding-top: 4px; | |
| 449 | + | border-top: 1px solid #808080; | |
| 450 | + | margin-top: 4px; | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | .color-indicator { | |
| 454 | + | border-radius: 1px; | |
| 455 | + | } | |
| 456 | + | </style> | |
M src/lib/components/desktop-icons.svelte +80 -79
| @@ -1,94 +1,95 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | let { showChatRoom = $bindable(), showAuth = $bindable() } = $props(); | |
| 2 | + | let { showChatRoom = $bindable(), showAuth = $bindable() } = $props(); | |
| 3 | 3 | ||
| 4 | - | function handleKeydown(event: KeyboardEvent, action: () => void) { | |
| 5 | - | if (event.key === 'Enter' || event.key === ' ') { | |
| 6 | - | event.preventDefault(); | |
| 7 | - | action(); | |
| 8 | - | } | |
| 9 | - | } | |
| 4 | + | function handleKeydown(event: KeyboardEvent, action: () => void) { | |
| 5 | + | if (event.key === 'Enter' || event.key === ' ') { | |
| 6 | + | event.preventDefault(); | |
| 7 | + | action(); | |
| 8 | + | } | |
| 9 | + | } | |
| 10 | 10 | ||
| 11 | - | function handleChatRoomClick() { | |
| 12 | - | showChatRoom = true | |
| 13 | - | } | |
| 11 | + | function handleChatRoomClick() { | |
| 12 | + | showChatRoom = true; | |
| 13 | + | } | |
| 14 | 14 | ||
| 15 | - | function handleAuthClick() { | |
| 16 | - | showAuth = true; | |
| 17 | - | } | |
| 15 | + | function handleAuthClick() { | |
| 16 | + | showAuth = true; | |
| 17 | + | } | |
| 18 | 18 | </script> | |
| 19 | 19 | ||
| 20 | 20 | <div class="desktop-icons"> | |
| 21 | - | <div | |
| 22 | - | class="desktop-icon" | |
| 23 | - | ondblclick={handleChatRoomClick} | |
| 24 | - | onkeydown={(e) => handleKeydown(e, handleChatRoomClick)} | |
| 25 | - | role="button" | |
| 26 | - | tabindex="0" | |
| 27 | - | > | |
| 28 | - | <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" /> | |
| 29 | - | <span class="icon-label">Pdr Aim</span> | |
| 30 | - | </div> | |
| 31 | - | ||
| 32 | - | <div | |
| 33 | - | class="desktop-icon" | |
| 34 | - | ondblclick={handleAuthClick} | |
| 35 | - | onkeydown={(e) => handleKeydown(e, handleAuthClick)} | |
| 36 | - | role="button" | |
| 37 | - | tabindex="0" | |
| 38 | - | > | |
| 39 | - | <img src="/desktop/application-x-shellscript.png" alt="Connexion" /> | |
| 40 | - | <span class="icon-label">Connexion</span> | |
| 41 | - | </div> | |
| 21 | + | <div | |
| 22 | + | class="desktop-icon" | |
| 23 | + | ondblclick={handleChatRoomClick} | |
| 24 | + | onkeydown={(e) => handleKeydown(e, handleChatRoomClick)} | |
| 25 | + | role="button" | |
| 26 | + | tabindex="0" | |
| 27 | + | > | |
| 28 | + | <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" /> | |
| 29 | + | <span class="icon-label">Pdr Aim</span> | |
| 30 | + | </div> | |
| 31 | + | ||
| 32 | + | <div | |
| 33 | + | class="desktop-icon" | |
| 34 | + | ondblclick={handleAuthClick} | |
| 35 | + | onkeydown={(e) => handleKeydown(e, handleAuthClick)} | |
| 36 | + | role="button" | |
| 37 | + | tabindex="0" | |
| 38 | + | > | |
| 39 | + | <img src="/desktop/application-x-shellscript.png" alt="Connexion" /> | |
| 40 | + | <span class="icon-label">Connexion</span> | |
| 41 | + | </div> | |
| 42 | 42 | </div> | |
| 43 | 43 | ||
| 44 | 44 | <style> | |
| 45 | - | .desktop-icons { | |
| 46 | - | position: fixed; | |
| 47 | - | top: 20px; | |
| 48 | - | left: 20px; | |
| 49 | - | display: flex; | |
| 50 | - | flex-direction: column; | |
| 51 | - | gap: 20px; | |
| 52 | - | } | |
| 45 | + | .desktop-icons { | |
| 46 | + | position: fixed; | |
| 47 | + | top: 20px; | |
| 48 | + | left: 20px; | |
| 49 | + | display: flex; | |
| 50 | + | flex-direction: column; | |
| 51 | + | gap: 20px; | |
| 52 | + | } | |
| 53 | 53 | ||
| 54 | - | .desktop-icon { | |
| 55 | - | display: flex; | |
| 56 | - | flex-direction: column; | |
| 57 | - | align-items: center; | |
| 58 | - | cursor: pointer; | |
| 59 | - | width: 75px; | |
| 60 | - | text-align: center; | |
| 61 | - | padding: 8px; | |
| 62 | - | border: 1px dotted transparent; | |
| 63 | - | border-radius: 2px; | |
| 64 | - | margin: 2px; | |
| 65 | - | } | |
| 54 | + | .desktop-icon { | |
| 55 | + | display: flex; | |
| 56 | + | flex-direction: column; | |
| 57 | + | align-items: center; | |
| 58 | + | cursor: pointer; | |
| 59 | + | width: 75px; | |
| 60 | + | text-align: center; | |
| 61 | + | padding: 8px; | |
| 62 | + | border: 1px dotted transparent; | |
| 63 | + | border-radius: 2px; | |
| 64 | + | margin: 2px; | |
| 65 | + | } | |
| 66 | 66 | ||
| 67 | - | .desktop-icon:hover, .desktop-icon:focus { | |
| 68 | - | background: rgba(49, 106, 197, 0.2); | |
| 69 | - | border: 1px dotted rgba(255, 255, 255, 0.8); | |
| 70 | - | outline: 1px solid rgba(49, 106, 197, 0.6); | |
| 71 | - | outline-offset: -1px; | |
| 72 | - | } | |
| 67 | + | .desktop-icon:hover, | |
| 68 | + | .desktop-icon:focus { | |
| 69 | + | background: rgba(49, 106, 197, 0.2); | |
| 70 | + | border: 1px dotted rgba(255, 255, 255, 0.8); | |
| 71 | + | outline: 1px solid rgba(49, 106, 197, 0.6); | |
| 72 | + | outline-offset: -1px; | |
| 73 | + | } | |
| 73 | 74 | ||
| 74 | - | .desktop-icon:active { | |
| 75 | - | background: rgba(49, 106, 197, 0.4); | |
| 76 | - | } | |
| 75 | + | .desktop-icon:active { | |
| 76 | + | background: rgba(49, 106, 197, 0.4); | |
| 77 | + | } | |
| 77 | 78 | ||
| 78 | - | .desktop-icon img { | |
| 79 | - | width: 48px; | |
| 80 | - | height: 48px; | |
| 81 | - | margin-bottom: 5px; | |
| 82 | - | pointer-events: none; /* Prevents image from interfering with hover */ | |
| 83 | - | } | |
| 79 | + | .desktop-icon img { | |
| 80 | + | width: 48px; | |
| 81 | + | height: 48px; | |
| 82 | + | margin-bottom: 5px; | |
| 83 | + | pointer-events: none; /* Prevents image from interfering with hover */ | |
| 84 | + | } | |
| 84 | 85 | ||
| 85 | - | .icon-label { | |
| 86 | - | color: white; | |
| 87 | - | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); | |
| 88 | - | font-size: 12px; | |
| 89 | - | user-select: none; | |
| 90 | - | pointer-events: none; /* Prevents text from interfering with hover */ | |
| 91 | - | max-width: 100%; | |
| 92 | - | overflow-wrap: break-word; | |
| 93 | - | } | |
| 94 | - | </style> ⏎̸ | |
| 86 | + | .icon-label { | |
| 87 | + | color: white; | |
| 88 | + | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); | |
| 89 | + | font-size: 12px; | |
| 90 | + | user-select: none; | |
| 91 | + | pointer-events: none; /* Prevents text from interfering with hover */ | |
| 92 | + | max-width: 100%; | |
| 93 | + | overflow-wrap: break-word; | |
| 94 | + | } | |
| 95 | + | </style> | |
M src/lib/components/formatted-message.svelte +250 -240
| @@ -1,249 +1,259 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { onMount } from 'svelte'; | |
| 3 | - | import type { EnrichedMessage } from '../types/chat'; | |
| 4 | - | import type { TextStyle } from '../types/text-formatting'; | |
| 5 | - | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 6 | - | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | |
| 7 | - | ||
| 8 | - | // Props | |
| 9 | - | let { | |
| 10 | - | message, | |
| 11 | - | allowFormatting = true | |
| 12 | - | } = $props<{ | |
| 13 | - | message: EnrichedMessage; | |
| 14 | - | allowFormatting?: boolean; | |
| 15 | - | }>(); | |
| 16 | - | ||
| 17 | - | // State | |
| 18 | - | let formattedContent = $state(''); | |
| 19 | - | let isProcessing = $state(false); | |
| 20 | - | let hasError = $state(false); | |
| 21 | - | ||
| 22 | - | // Process message formatting | |
| 23 | - | async function processMessage() { | |
| 24 | - | if (!allowFormatting || !message.hasFormatting) { | |
| 25 | - | formattedContent = escapeHtml(message.content); | |
| 26 | - | return; | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | isProcessing = true; | |
| 30 | - | hasError = false; | |
| 31 | - | ||
| 32 | - | try { | |
| 33 | - | // Parse style data if available | |
| 34 | - | let style: TextStyle = DEFAULT_TEXT_STYLE; | |
| 35 | - | if (message.styleData) { | |
| 36 | - | try { | |
| 37 | - | style = JSON.parse(message.styleData); | |
| 38 | - | console.debug('Parsed message style:', style, 'for message:', message.content); | |
| 39 | - | } catch (parseError) { | |
| 40 | - | console.warn('Failed to parse message style data:', parseError); | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Check if this is a gradient message | |
| 45 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 46 | - | // Use character-by-character gradient for better compatibility | |
| 47 | - | console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content); | |
| 48 | - | formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style); | |
| 49 | - | console.debug('Generated gradient HTML:', formattedContent); | |
| 50 | - | return; | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // Regular formatted text (no gradient) | |
| 54 | - | const styleWithoutGradient = { ...style, gradient: undefined }; | |
| 55 | - | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |
| 56 | - | ||
| 57 | - | } catch (error) { | |
| 58 | - | console.error('Message formatting error:', error); | |
| 59 | - | hasError = true; | |
| 60 | - | formattedContent = escapeHtml(message.content); | |
| 61 | - | } finally { | |
| 62 | - | isProcessing = false; | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Process message when it changes | |
| 67 | - | $effect(() => { | |
| 68 | - | processMessage(); | |
| 69 | - | }); | |
| 70 | - | ||
| 71 | - | // Utility function to escape HTML | |
| 72 | - | function escapeHtml(text: string): string { | |
| 73 | - | const div = document.createElement('div'); | |
| 74 | - | div.textContent = text; | |
| 75 | - | return div.innerHTML; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Generate inline styles for the message | |
| 79 | - | function getMessageStyles(): string { | |
| 80 | - | if (!message.hasFormatting || !message.styleData) { | |
| 81 | - | return ''; | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | try { | |
| 85 | - | const style: TextStyle = JSON.parse(message.styleData); | |
| 86 | - | // For gradient messages, we still need some styles but not font-family (use class instead) | |
| 87 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 88 | - | // Return only non-color and non-font-family styles for gradient messages | |
| 89 | - | const styles: string[] = []; | |
| 90 | - | styles.push(`font-size: ${style.fontSize}px`); | |
| 91 | - | if (style.bold) styles.push(`font-weight: bold`); | |
| 92 | - | if (style.italic) styles.push(`font-style: italic`); | |
| 93 | - | const decorations: string[] = []; | |
| 94 | - | if (style.underline) decorations.push('underline'); | |
| 95 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 96 | - | if (decorations.length > 0) { | |
| 97 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 98 | - | } | |
| 99 | - | return styles.join('; '); | |
| 100 | - | } | |
| 101 | - | // For non-gradient messages, exclude font-family (use class instead) | |
| 102 | - | const styles: string[] = []; | |
| 103 | - | styles.push(`font-size: ${style.fontSize}px`); | |
| 104 | - | if (style.color) styles.push(`color: ${style.color}`); | |
| 105 | - | if (style.bold) styles.push(`font-weight: bold`); | |
| 106 | - | if (style.italic) styles.push(`font-style: italic`); | |
| 107 | - | const decorations: string[] = []; | |
| 108 | - | if (style.underline) decorations.push('underline'); | |
| 109 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 110 | - | if (decorations.length > 0) { | |
| 111 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 112 | - | } | |
| 113 | - | return styles.join('; '); | |
| 114 | - | } catch (error) { | |
| 115 | - | console.warn('Failed to generate message styles:', error); | |
| 116 | - | return ''; | |
| 117 | - | } | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // Get CSS classes for the message | |
| 121 | - | function getMessageClasses(): string { | |
| 122 | - | const classes = ['formatted-message']; | |
| 123 | - | ||
| 124 | - | if (!message.hasFormatting || !message.styleData) { | |
| 125 | - | return classes.join(' '); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | try { | |
| 129 | - | const style: TextStyle = JSON.parse(message.styleData); | |
| 130 | - | ||
| 131 | - | // Add font family class | |
| 132 | - | classes.push(`retro-font-${style.fontFamily}`); | |
| 133 | - | ||
| 134 | - | // Add style classes | |
| 135 | - | if (style.bold) classes.push('style-bold'); | |
| 136 | - | if (style.italic) classes.push('style-italic'); | |
| 137 | - | if (style.underline) classes.push('style-underline'); | |
| 138 | - | if (style.strikethrough) classes.push('style-strikethrough'); | |
| 139 | - | ||
| 140 | - | // Add gradient class if applicable | |
| 141 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 142 | - | classes.push('gradient-text-static'); | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | } catch (error) { | |
| 146 | - | console.warn('Failed to parse message style for classes:', error); | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | return classes.join(' '); | |
| 150 | - | } | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import type { EnrichedMessage } from '../types/chat'; | |
| 4 | + | import type { TextStyle } from '../types/text-formatting'; | |
| 5 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 6 | + | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | |
| 7 | + | ||
| 8 | + | // Props | |
| 9 | + | let { message, allowFormatting = true } = $props<{ | |
| 10 | + | message: EnrichedMessage; | |
| 11 | + | allowFormatting?: boolean; | |
| 12 | + | }>(); | |
| 13 | + | ||
| 14 | + | // State | |
| 15 | + | let formattedContent = $state(''); | |
| 16 | + | let isProcessing = $state(false); | |
| 17 | + | let hasError = $state(false); | |
| 18 | + | ||
| 19 | + | // Process message formatting | |
| 20 | + | async function processMessage() { | |
| 21 | + | if (!allowFormatting || !message.hasFormatting) { | |
| 22 | + | formattedContent = escapeHtml(message.content); | |
| 23 | + | return; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | isProcessing = true; | |
| 27 | + | hasError = false; | |
| 28 | + | ||
| 29 | + | try { | |
| 30 | + | // Parse style data if available | |
| 31 | + | let style: TextStyle = DEFAULT_TEXT_STYLE; | |
| 32 | + | if (message.styleData) { | |
| 33 | + | try { | |
| 34 | + | style = JSON.parse(message.styleData); | |
| 35 | + | console.debug('Parsed message style:', style, 'for message:', message.content); | |
| 36 | + | } catch (parseError) { | |
| 37 | + | console.warn('Failed to parse message style data:', parseError); | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Check if this is a gradient message | |
| 42 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 43 | + | // Use character-by-character gradient for better compatibility | |
| 44 | + | console.debug( | |
| 45 | + | 'Creating gradient text with colors:', | |
| 46 | + | style.gradient, | |
| 47 | + | 'for text:', | |
| 48 | + | message.content | |
| 49 | + | ); | |
| 50 | + | formattedContent = createGradientText( | |
| 51 | + | message.content, | |
| 52 | + | style.gradient, | |
| 53 | + | 'gradient-text-static', | |
| 54 | + | style | |
| 55 | + | ); | |
| 56 | + | console.debug('Generated gradient HTML:', formattedContent); | |
| 57 | + | return; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Regular formatted text (no gradient) | |
| 61 | + | const styleWithoutGradient = { ...style, gradient: undefined }; | |
| 62 | + | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |
| 63 | + | } catch (error) { | |
| 64 | + | console.error('Message formatting error:', error); | |
| 65 | + | hasError = true; | |
| 66 | + | formattedContent = escapeHtml(message.content); | |
| 67 | + | } finally { | |
| 68 | + | isProcessing = false; | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Process message when it changes | |
| 73 | + | $effect(() => { | |
| 74 | + | processMessage(); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | // Utility function to escape HTML | |
| 78 | + | function escapeHtml(text: string): string { | |
| 79 | + | const div = document.createElement('div'); | |
| 80 | + | div.textContent = text; | |
| 81 | + | return div.innerHTML; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Generate inline styles for the message | |
| 85 | + | function getMessageStyles(): string { | |
| 86 | + | if (!message.hasFormatting || !message.styleData) { | |
| 87 | + | return ''; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | try { | |
| 91 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 92 | + | // For gradient messages, we still need some styles but not font-family (use class instead) | |
| 93 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 94 | + | // Return only non-color and non-font-family styles for gradient messages | |
| 95 | + | const styles: string[] = []; | |
| 96 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 97 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 98 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 99 | + | const decorations: string[] = []; | |
| 100 | + | if (style.underline) decorations.push('underline'); | |
| 101 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 102 | + | if (decorations.length > 0) { | |
| 103 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 104 | + | } | |
| 105 | + | return styles.join('; '); | |
| 106 | + | } | |
| 107 | + | // For non-gradient messages, exclude font-family (use class instead) | |
| 108 | + | const styles: string[] = []; | |
| 109 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 110 | + | if (style.color) styles.push(`color: ${style.color}`); | |
| 111 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 112 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 113 | + | const decorations: string[] = []; | |
| 114 | + | if (style.underline) decorations.push('underline'); | |
| 115 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 116 | + | if (decorations.length > 0) { | |
| 117 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 118 | + | } | |
| 119 | + | return styles.join('; '); | |
| 120 | + | } catch (error) { | |
| 121 | + | console.warn('Failed to generate message styles:', error); | |
| 122 | + | return ''; | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Get CSS classes for the message | |
| 127 | + | function getMessageClasses(): string { | |
| 128 | + | const classes = ['formatted-message']; | |
| 129 | + | ||
| 130 | + | if (!message.hasFormatting || !message.styleData) { | |
| 131 | + | return classes.join(' '); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | try { | |
| 135 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 136 | + | ||
| 137 | + | // Add font family class | |
| 138 | + | classes.push(`retro-font-${style.fontFamily}`); | |
| 139 | + | ||
| 140 | + | // Add style classes | |
| 141 | + | if (style.bold) classes.push('style-bold'); | |
| 142 | + | if (style.italic) classes.push('style-italic'); | |
| 143 | + | if (style.underline) classes.push('style-underline'); | |
| 144 | + | if (style.strikethrough) classes.push('style-strikethrough'); | |
| 145 | + | ||
| 146 | + | // Add gradient class if applicable | |
| 147 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 148 | + | classes.push('gradient-text-static'); | |
| 149 | + | } | |
| 150 | + | } catch (error) { | |
| 151 | + | console.warn('Failed to parse message style for classes:', error); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return classes.join(' '); | |
| 155 | + | } | |
| 151 | 156 | </script> | |
| 152 | 157 | ||
| 153 | 158 | {#if isProcessing} | |
| 154 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 155 | - | <span class="processing">...</span> | |
| 156 | - | </span> | |
| 159 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 160 | + | <span class="processing">...</span> | |
| 161 | + | </span> | |
| 157 | 162 | {:else if hasError} | |
| 158 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 159 | - | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | |
| 160 | - | </span> | |
| 163 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 164 | + | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | |
| 165 | + | </span> | |
| 161 | 166 | {:else} | |
| 162 | - | <!-- For gradient messages, render without wrapper to avoid conflicts --> | |
| 163 | - | {#if message.hasFormatting && message.styleData} | |
| 164 | - | {@const parsedStyle = (() => { | |
| 165 | - | try { | |
| 166 | - | return JSON.parse(message.styleData); | |
| 167 | - | } catch { | |
| 168 | - | return null; | |
| 169 | - | } | |
| 170 | - | })()} | |
| 171 | - | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} | |
| 172 | - | <!-- Gradient messages are already wrapped properly --> | |
| 173 | - | {@html formattedContent} | |
| 174 | - | {:else} | |
| 175 | - | <!-- Regular formatted messages --> | |
| 176 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 177 | - | {@html formattedContent} | |
| 178 | - | </span> | |
| 179 | - | {/if} | |
| 180 | - | {:else} | |
| 181 | - | <!-- Plain messages --> | |
| 182 | - | <span class={getMessageClasses()}> | |
| 183 | - | {@html formattedContent} | |
| 184 | - | </span> | |
| 185 | - | {/if} | |
| 167 | + | <!-- For gradient messages, render without wrapper to avoid conflicts --> | |
| 168 | + | {#if message.hasFormatting && message.styleData} | |
| 169 | + | {@const parsedStyle = (() => { | |
| 170 | + | try { | |
| 171 | + | return JSON.parse(message.styleData); | |
| 172 | + | } catch { | |
| 173 | + | return null; | |
| 174 | + | } | |
| 175 | + | })()} | |
| 176 | + | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} | |
| 177 | + | <!-- Gradient messages are already wrapped properly --> | |
| 178 | + | {@html formattedContent} | |
| 179 | + | {:else} | |
| 180 | + | <!-- Regular formatted messages --> | |
| 181 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 182 | + | {@html formattedContent} | |
| 183 | + | </span> | |
| 184 | + | {/if} | |
| 185 | + | {:else} | |
| 186 | + | <!-- Plain messages --> | |
| 187 | + | <span class={getMessageClasses()}> | |
| 188 | + | {@html formattedContent} | |
| 189 | + | </span> | |
| 190 | + | {/if} | |
| 186 | 191 | {/if} | |
| 187 | 192 | ||
| 188 | 193 | <style> | |
| 189 | - | .processing { | |
| 190 | - | color: #666; | |
| 191 | - | font-style: italic; | |
| 192 | - | animation: pulse 1s infinite; | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | .error { | |
| 196 | - | color: #cc0000; | |
| 197 | - | border-bottom: 1px dotted #cc0000; | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | @keyframes pulse { | |
| 201 | - | 0%, 100% { opacity: 1; } | |
| 202 | - | 50% { opacity: 0.5; } | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | /* Ensure formatted messages don't break layout */ | |
| 206 | - | :global(.formatted-message) { | |
| 207 | - | word-break: break-word; | |
| 208 | - | overflow-wrap: break-word; | |
| 209 | - | hyphens: auto; | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | /* Improve gradient text readability */ | |
| 213 | - | :global(.formatted-message.gradient-text-static) { | |
| 214 | - | font-weight: bold; | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | /* Ensure gradient spans are visible and preserve their colors */ | |
| 218 | - | :global(.gradient-text-static .gradient-char) { | |
| 219 | - | display: inline; | |
| 220 | - | opacity: 1 !important; | |
| 221 | - | margin: 0 !important; | |
| 222 | - | padding: 0 !important; | |
| 223 | - | letter-spacing: 0 !important; | |
| 224 | - | -webkit-text-fill-color: initial !important; | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | /* Don't override inline styles for gradient chars - removed to let inline styles work */ | |
| 228 | - | ||
| 229 | - | /* Remove any blur or gray effects */ | |
| 230 | - | :global(.formatted-message) { | |
| 231 | - | opacity: 1 !important; | |
| 232 | - | filter: none !important; | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | /* Don't inherit color for gradient text spans */ | |
| 236 | - | :global(.formatted-message:not(.gradient-text-static)) { | |
| 237 | - | color: inherit; | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | /* Ensure accessibility for screen readers */ | |
| 241 | - | :global(.formatted-message[aria-label]) { | |
| 242 | - | position: relative; | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | /* Performance optimization for complex messages */ | |
| 246 | - | :global(.formatted-message) { | |
| 247 | - | contain: layout style; | |
| 248 | - | } | |
| 249 | - | </style>⏎̸ | |
| 194 | + | .processing { | |
| 195 | + | color: #666; | |
| 196 | + | font-style: italic; | |
| 197 | + | animation: pulse 1s infinite; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | .error { | |
| 201 | + | color: #cc0000; | |
| 202 | + | border-bottom: 1px dotted #cc0000; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | @keyframes pulse { | |
| 206 | + | 0%, | |
| 207 | + | 100% { | |
| 208 | + | opacity: 1; | |
| 209 | + | } | |
| 210 | + | 50% { | |
| 211 | + | opacity: 0.5; | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /* Ensure formatted messages don't break layout */ | |
| 216 | + | :global(.formatted-message) { | |
| 217 | + | word-break: break-word; | |
| 218 | + | overflow-wrap: break-word; | |
| 219 | + | hyphens: auto; | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /* Improve gradient text readability */ | |
| 223 | + | :global(.formatted-message.gradient-text-static) { | |
| 224 | + | font-weight: bold; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /* Ensure gradient spans are visible and preserve their colors */ | |
| 228 | + | :global(.gradient-text-static .gradient-char) { | |
| 229 | + | display: inline; | |
| 230 | + | opacity: 1 !important; | |
| 231 | + | margin: 0 !important; | |
| 232 | + | padding: 0 !important; | |
| 233 | + | letter-spacing: 0 !important; | |
| 234 | + | -webkit-text-fill-color: initial !important; | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /* Don't override inline styles for gradient chars - removed to let inline styles work */ | |
| 238 | + | ||
| 239 | + | /* Remove any blur or gray effects */ | |
| 240 | + | :global(.formatted-message) { | |
| 241 | + | opacity: 1 !important; | |
| 242 | + | filter: none !important; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /* Don't inherit color for gradient text spans */ | |
| 246 | + | :global(.formatted-message:not(.gradient-text-static)) { | |
| 247 | + | color: inherit; | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /* Ensure accessibility for screen readers */ | |
| 251 | + | :global(.formatted-message[aria-label]) { | |
| 252 | + | position: relative; | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /* Performance optimization for complex messages */ | |
| 256 | + | :global(.formatted-message) { | |
| 257 | + | contain: layout style; | |
| 258 | + | } | |
| 259 | + | </style> | |
M src/lib/components/password-strength-indicator.svelte +168 -158
| @@ -1,165 +1,175 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { validatePasswordStrength, type PasswordConstraints, DEFAULT_PASSWORD_CONSTRAINTS } from '../validation/password'; | |
| 3 | - | ||
| 4 | - | interface $$Props { | |
| 5 | - | password: string; | |
| 6 | - | constraints?: PasswordConstraints; | |
| 7 | - | showDetails?: boolean; | |
| 8 | - | } | |
| 9 | - | ||
| 10 | - | let { | |
| 11 | - | password = '', | |
| 12 | - | constraints = DEFAULT_PASSWORD_CONSTRAINTS, | |
| 13 | - | showDetails = true | |
| 14 | - | } = $props(); | |
| 15 | - | ||
| 16 | - | // Reactive validation result | |
| 17 | - | let validation = $derived(validatePasswordStrength(password, constraints)); | |
| 18 | - | ||
| 19 | - | // Get strength color and label | |
| 20 | - | function getStrengthInfo(strength: number) { | |
| 21 | - | if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' }; | |
| 22 | - | if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' }; | |
| 23 | - | if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' }; | |
| 24 | - | return { color: '#00aa00', label: 'Strong', class: 'strong' }; | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | let strengthInfo = $derived(getStrengthInfo(validation.strength)); | |
| 2 | + | import { | |
| 3 | + | validatePasswordStrength, | |
| 4 | + | type PasswordConstraints, | |
| 5 | + | DEFAULT_PASSWORD_CONSTRAINTS | |
| 6 | + | } from '../validation/password'; | |
| 7 | + | ||
| 8 | + | interface $$Props { | |
| 9 | + | password: string; | |
| 10 | + | constraints?: PasswordConstraints; | |
| 11 | + | showDetails?: boolean; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | let { password = '', constraints = DEFAULT_PASSWORD_CONSTRAINTS, showDetails = true } = $props(); | |
| 15 | + | ||
| 16 | + | // Reactive validation result | |
| 17 | + | let validation = $derived(validatePasswordStrength(password, constraints)); | |
| 18 | + | ||
| 19 | + | // Get strength color and label | |
| 20 | + | function getStrengthInfo(strength: number) { | |
| 21 | + | if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' }; | |
| 22 | + | if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' }; | |
| 23 | + | if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' }; | |
| 24 | + | return { color: '#00aa00', label: 'Strong', class: 'strong' }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | let strengthInfo = $derived(getStrengthInfo(validation.strength)); | |
| 28 | 28 | </script> | |
| 29 | 29 | ||
| 30 | 30 | <div class="password-strength-indicator"> | |
| 31 | - | {#if password.length > 0} | |
| 32 | - | <!-- Strength bar --> | |
| 33 | - | <div class="strength-bar"> | |
| 34 | - | <div class="strength-bar-bg"> | |
| 35 | - | <div | |
| 36 | - | class="strength-bar-fill {strengthInfo.class}" | |
| 37 | - | style="width: {validation.strength}%; background-color: {strengthInfo.color};" | |
| 38 | - | ></div> | |
| 39 | - | </div> | |
| 40 | - | <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span> | |
| 41 | - | </div> | |
| 42 | - | ||
| 43 | - | {#if showDetails} | |
| 44 | - | <!-- Requirements checklist --> | |
| 45 | - | <div class="requirements-list"> | |
| 46 | - | {#each validation.requirements as requirement} | |
| 47 | - | <div class="requirement {requirement.met ? 'met' : 'unmet'}"> | |
| 48 | - | <span class="requirement-icon"> | |
| 49 | - | {requirement.met ? '✓' : '✗'} | |
| 50 | - | </span> | |
| 51 | - | <span class="requirement-text">{requirement.message}</span> | |
| 52 | - | </div> | |
| 53 | - | {/each} | |
| 54 | - | </div> | |
| 55 | - | ||
| 56 | - | <!-- Error messages --> | |
| 57 | - | {#if validation.errors.length > 0} | |
| 58 | - | <div class="error-messages"> | |
| 59 | - | {#each validation.errors as error} | |
| 60 | - | <div class="error-message">{error}</div> | |
| 61 | - | {/each} | |
| 62 | - | </div> | |
| 63 | - | {/if} | |
| 64 | - | {/if} | |
| 65 | - | {/if} | |
| 31 | + | {#if password.length > 0} | |
| 32 | + | <!-- Strength bar --> | |
| 33 | + | <div class="strength-bar"> | |
| 34 | + | <div class="strength-bar-bg"> | |
| 35 | + | <div | |
| 36 | + | class="strength-bar-fill {strengthInfo.class}" | |
| 37 | + | style="width: {validation.strength}%; background-color: {strengthInfo.color};" | |
| 38 | + | ></div> | |
| 39 | + | </div> | |
| 40 | + | <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span> | |
| 41 | + | </div> | |
| 42 | + | ||
| 43 | + | {#if showDetails} | |
| 44 | + | <!-- Requirements checklist --> | |
| 45 | + | <div class="requirements-list"> | |
| 46 | + | {#each validation.requirements as requirement} | |
| 47 | + | <div class="requirement {requirement.met ? 'met' : 'unmet'}"> | |
| 48 | + | <span class="requirement-icon"> | |
| 49 | + | {requirement.met ? '✓' : '✗'} | |
| 50 | + | </span> | |
| 51 | + | <span class="requirement-text">{requirement.message}</span> | |
| 52 | + | </div> | |
| 53 | + | {/each} | |
| 54 | + | </div> | |
| 55 | + | ||
| 56 | + | <!-- Error messages --> | |
| 57 | + | {#if validation.errors.length > 0} | |
| 58 | + | <div class="error-messages"> | |
| 59 | + | {#each validation.errors as error} | |
| 60 | + | <div class="error-message">{error}</div> | |
| 61 | + | {/each} | |
| 62 | + | </div> | |
| 63 | + | {/if} | |
| 64 | + | {/if} | |
| 65 | + | {/if} | |
| 66 | 66 | </div> | |
| 67 | 67 | ||
| 68 | 68 | <style> | |
| 69 | - | .password-strength-indicator { | |
| 70 | - | margin-top: 8px; | |
| 71 | - | font-size: 0.875rem; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | .strength-bar { | |
| 75 | - | display: flex; | |
| 76 | - | align-items: center; | |
| 77 | - | gap: 8px; | |
| 78 | - | margin-bottom: 8px; | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | .strength-bar-bg { | |
| 82 | - | flex: 1; | |
| 83 | - | height: 6px; | |
| 84 | - | background-color: #ddd; | |
| 85 | - | border-radius: 3px; | |
| 86 | - | overflow: hidden; | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | .strength-bar-fill { | |
| 90 | - | height: 100%; | |
| 91 | - | transition: width 0.3s ease, background-color 0.3s ease; | |
| 92 | - | border-radius: 3px; | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | .strength-label { | |
| 96 | - | min-width: 50px; | |
| 97 | - | font-weight: bold; | |
| 98 | - | font-size: 0.75rem; | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | .strength-label.weak { color: #ff4444; } | |
| 102 | - | .strength-label.fair { color: #ffaa00; } | |
| 103 | - | .strength-label.good { color: #44aa44; } | |
| 104 | - | .strength-label.strong { color: #00aa00; } | |
| 105 | - | ||
| 106 | - | .requirements-list { | |
| 107 | - | display: flex; | |
| 108 | - | flex-direction: column; | |
| 109 | - | gap: 4px; | |
| 110 | - | margin-bottom: 8px; | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | .requirement { | |
| 114 | - | display: flex; | |
| 115 | - | align-items: center; | |
| 116 | - | gap: 6px; | |
| 117 | - | font-size: 0.75rem; | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | .requirement-icon { | |
| 121 | - | width: 12px; | |
| 122 | - | text-align: center; | |
| 123 | - | font-weight: bold; | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | .requirement.met { | |
| 127 | - | color: #00aa00; | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | .requirement.met .requirement-icon { | |
| 131 | - | color: #00aa00; | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | .requirement.unmet { | |
| 135 | - | color: #888; | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | .requirement.unmet .requirement-icon { | |
| 139 | - | color: #ff4444; | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | .error-messages { | |
| 143 | - | display: flex; | |
| 144 | - | flex-direction: column; | |
| 145 | - | gap: 4px; | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | .error-message { | |
| 149 | - | color: #ff4444; | |
| 150 | - | font-size: 0.75rem; | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | /* Compact mode styles */ | |
| 154 | - | :global(.compact) .password-strength-indicator { | |
| 155 | - | font-size: 0.75rem; | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | :global(.compact) .requirements-list { | |
| 159 | - | display: none; | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | :global(.compact) .strength-bar { | |
| 163 | - | margin-bottom: 4px; | |
| 164 | - | } | |
| 165 | - | </style>⏎̸ | |
| 69 | + | .password-strength-indicator { | |
| 70 | + | margin-top: 8px; | |
| 71 | + | font-size: 0.875rem; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .strength-bar { | |
| 75 | + | display: flex; | |
| 76 | + | align-items: center; | |
| 77 | + | gap: 8px; | |
| 78 | + | margin-bottom: 8px; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | .strength-bar-bg { | |
| 82 | + | flex: 1; | |
| 83 | + | height: 6px; | |
| 84 | + | background-color: #ddd; | |
| 85 | + | border-radius: 3px; | |
| 86 | + | overflow: hidden; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | .strength-bar-fill { | |
| 90 | + | height: 100%; | |
| 91 | + | transition: | |
| 92 | + | width 0.3s ease, | |
| 93 | + | background-color 0.3s ease; | |
| 94 | + | border-radius: 3px; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | .strength-label { | |
| 98 | + | min-width: 50px; | |
| 99 | + | font-weight: bold; | |
| 100 | + | font-size: 0.75rem; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | .strength-label.weak { | |
| 104 | + | color: #ff4444; | |
| 105 | + | } | |
| 106 | + | .strength-label.fair { | |
| 107 | + | color: #ffaa00; | |
| 108 | + | } | |
| 109 | + | .strength-label.good { | |
| 110 | + | color: #44aa44; | |
| 111 | + | } | |
| 112 | + | .strength-label.strong { | |
| 113 | + | color: #00aa00; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | .requirements-list { | |
| 117 | + | display: flex; | |
| 118 | + | flex-direction: column; | |
| 119 | + | gap: 4px; | |
| 120 | + | margin-bottom: 8px; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | .requirement { | |
| 124 | + | display: flex; | |
| 125 | + | align-items: center; | |
| 126 | + | gap: 6px; | |
| 127 | + | font-size: 0.75rem; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | .requirement-icon { | |
| 131 | + | width: 12px; | |
| 132 | + | text-align: center; | |
| 133 | + | font-weight: bold; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | .requirement.met { | |
| 137 | + | color: #00aa00; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | .requirement.met .requirement-icon { | |
| 141 | + | color: #00aa00; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | .requirement.unmet { | |
| 145 | + | color: #888; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | .requirement.unmet .requirement-icon { | |
| 149 | + | color: #ff4444; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | .error-messages { | |
| 153 | + | display: flex; | |
| 154 | + | flex-direction: column; | |
| 155 | + | gap: 4px; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | .error-message { | |
| 159 | + | color: #ff4444; | |
| 160 | + | font-size: 0.75rem; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /* Compact mode styles */ | |
| 164 | + | :global(.compact) .password-strength-indicator { | |
| 165 | + | font-size: 0.75rem; | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | :global(.compact) .requirements-list { | |
| 169 | + | display: none; | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | :global(.compact) .strength-bar { | |
| 173 | + | margin-bottom: 4px; | |
| 174 | + | } | |
| 175 | + | </style> | |
M src/lib/components/text-formatting-toolbar.svelte +203 -207
| @@ -1,216 +1,212 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { | |
| 3 | - | RETRO_FONTS, | |
| 4 | - | CLASSIC_COLORS, | |
| 5 | - | GRADIENT_PRESETS, | |
| 6 | - | DEFAULT_TEXT_STYLE, | |
| 7 | - | type TextStyle, | |
| 8 | - | type FontFamily | |
| 9 | - | } from '../types/text-formatting'; | |
| 10 | - | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | - | ||
| 12 | - | // Props | |
| 13 | - | let { | |
| 14 | - | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 15 | - | onPreviewText, | |
| 16 | - | compact = true, | |
| 17 | - | showFontSelector = false, | |
| 18 | - | showGradients = true | |
| 19 | - | } = $props<{ | |
| 20 | - | style?: TextStyle; | |
| 21 | - | onPreviewText?: (text: string) => void; | |
| 22 | - | compact?: boolean; | |
| 23 | - | showFontSelector?: boolean; | |
| 24 | - | showGradients?: boolean; | |
| 25 | - | }>(); | |
| 26 | - | ||
| 27 | - | // State | |
| 28 | - | let currentColor = $state(style.color || '#000000'); | |
| 29 | - | let currentGradient = $state(style.gradient); | |
| 30 | - | ||
| 31 | - | // Toggle functions | |
| 32 | - | function toggleBold() { | |
| 33 | - | style = { ...style, bold: !style.bold }; | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | function toggleItalic() { | |
| 37 | - | style = { ...style, italic: !style.italic }; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | function toggleUnderline() { | |
| 41 | - | style = { ...style, underline: !style.underline }; | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | function handleColorChange(color: string) { | |
| 45 | - | currentColor = color; | |
| 46 | - | style = { ...style, color, gradient: undefined }; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | function handleGradientChange(gradient: string[]) { | |
| 50 | - | currentGradient = gradient; | |
| 51 | - | style = { ...style, gradient, color: gradient[0] }; | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | function selectFont(fontFamily: FontFamily) { | |
| 55 | - | style = { ...style, fontFamily }; | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // Keyboard shortcuts handler | |
| 59 | - | function handleKeydown(event: KeyboardEvent) { | |
| 60 | - | if (event.ctrlKey || event.metaKey) { | |
| 61 | - | switch (event.key.toLowerCase()) { | |
| 62 | - | case 'b': | |
| 63 | - | event.preventDefault(); | |
| 64 | - | toggleBold(); | |
| 65 | - | break; | |
| 66 | - | case 'i': | |
| 67 | - | event.preventDefault(); | |
| 68 | - | toggleItalic(); | |
| 69 | - | break; | |
| 70 | - | case 'u': | |
| 71 | - | event.preventDefault(); | |
| 72 | - | toggleUnderline(); | |
| 73 | - | break; | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Preview text change | |
| 79 | - | function previewTextChanged() { | |
| 80 | - | if (onPreviewText) { | |
| 81 | - | onPreviewText('Sample formatted text'); | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Update internal state when style prop changes | |
| 86 | - | $effect(() => { | |
| 87 | - | currentColor = style.color || '#000000'; | |
| 88 | - | currentGradient = style.gradient; | |
| 89 | - | }); | |
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | GRADIENT_PRESETS, | |
| 6 | + | DEFAULT_TEXT_STYLE, | |
| 7 | + | type TextStyle, | |
| 8 | + | type FontFamily | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | ||
| 12 | + | // Props | |
| 13 | + | let { | |
| 14 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 15 | + | onPreviewText, | |
| 16 | + | compact = true, | |
| 17 | + | showFontSelector = false, | |
| 18 | + | showGradients = true | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | onPreviewText?: (text: string) => void; | |
| 22 | + | compact?: boolean; | |
| 23 | + | showFontSelector?: boolean; | |
| 24 | + | showGradients?: boolean; | |
| 25 | + | }>(); | |
| 26 | + | ||
| 27 | + | // State | |
| 28 | + | let currentColor = $state(style.color || '#000000'); | |
| 29 | + | let currentGradient = $state(style.gradient); | |
| 30 | + | ||
| 31 | + | // Toggle functions | |
| 32 | + | function toggleBold() { | |
| 33 | + | style = { ...style, bold: !style.bold }; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function toggleItalic() { | |
| 37 | + | style = { ...style, italic: !style.italic }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function toggleUnderline() { | |
| 41 | + | style = { ...style, underline: !style.underline }; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function handleColorChange(color: string) { | |
| 45 | + | currentColor = color; | |
| 46 | + | style = { ...style, color, gradient: undefined }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | function handleGradientChange(gradient: string[]) { | |
| 50 | + | currentGradient = gradient; | |
| 51 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function selectFont(fontFamily: FontFamily) { | |
| 55 | + | style = { ...style, fontFamily }; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Keyboard shortcuts handler | |
| 59 | + | function handleKeydown(event: KeyboardEvent) { | |
| 60 | + | if (event.ctrlKey || event.metaKey) { | |
| 61 | + | switch (event.key.toLowerCase()) { | |
| 62 | + | case 'b': | |
| 63 | + | event.preventDefault(); | |
| 64 | + | toggleBold(); | |
| 65 | + | break; | |
| 66 | + | case 'i': | |
| 67 | + | event.preventDefault(); | |
| 68 | + | toggleItalic(); | |
| 69 | + | break; | |
| 70 | + | case 'u': | |
| 71 | + | event.preventDefault(); | |
| 72 | + | toggleUnderline(); | |
| 73 | + | break; | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Preview text change | |
| 79 | + | function previewTextChanged() { | |
| 80 | + | if (onPreviewText) { | |
| 81 | + | onPreviewText('Sample formatted text'); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Update internal state when style prop changes | |
| 86 | + | $effect(() => { | |
| 87 | + | currentColor = style.color || '#000000'; | |
| 88 | + | currentGradient = style.gradient; | |
| 89 | + | }); | |
| 90 | 90 | </script> | |
| 91 | 91 | ||
| 92 | 92 | <svelte:window onkeydown={handleKeydown} /> | |
| 93 | 93 | ||
| 94 | 94 | <div class="text-formatting-toolbar sunken-panel" class:compact> | |
| 95 | - | ||
| 96 | - | <!-- Font selector (if enabled) --> | |
| 97 | - | {#if showFontSelector} | |
| 98 | - | <select | |
| 99 | - | value={style.fontFamily} | |
| 100 | - | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 101 | - | title="Font Family" | |
| 102 | - | > | |
| 103 | - | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 104 | - | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |
| 105 | - | {/each} | |
| 106 | - | </select> | |
| 107 | - | {/if} | |
| 108 | - | ||
| 109 | - | <!-- Size slider --> | |
| 110 | - | <div class="size-control"> | |
| 111 | - | <input | |
| 112 | - | type="range" | |
| 113 | - | min="8" | |
| 114 | - | max="18" | |
| 115 | - | bind:value={style.fontSize} | |
| 116 | - | class="size-slider" | |
| 117 | - | title="Font Size: {style.fontSize}px" | |
| 118 | - | /> | |
| 119 | - | <span class="size-display">{style.fontSize}</span> | |
| 120 | - | </div> | |
| 121 | - | ||
| 122 | - | <!-- Style toggle buttons --> | |
| 123 | - | <div class="style-toggles"> | |
| 124 | - | <button | |
| 125 | - | onclick={toggleBold} | |
| 126 | - | title="Bold (Ctrl+B)" | |
| 127 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 128 | - | > | |
| 129 | - | <strong>B</strong> | |
| 130 | - | </button> | |
| 131 | - | ||
| 132 | - | <button | |
| 133 | - | onclick={toggleItalic} | |
| 134 | - | title="Italic (Ctrl+I)" | |
| 135 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 136 | - | > | |
| 137 | - | <em>I</em> | |
| 138 | - | </button> | |
| 139 | - | ||
| 140 | - | <button | |
| 141 | - | onclick={toggleUnderline} | |
| 142 | - | title="Underline (Ctrl+U)" | |
| 143 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 144 | - | > | |
| 145 | - | <u>U</u> | |
| 146 | - | </button> | |
| 147 | - | </div> | |
| 148 | - | ||
| 149 | - | <!-- Color controls --> | |
| 150 | - | <div class="color-controls"> | |
| 151 | - | <ColorPicker256 | |
| 152 | - | bind:selectedColor={currentColor} | |
| 153 | - | bind:selectedGradient={currentGradient} | |
| 154 | - | onColorChange={handleColorChange} | |
| 155 | - | onGradientChange={handleGradientChange} | |
| 156 | - | {compact} | |
| 157 | - | /> | |
| 158 | - | </div> | |
| 159 | - | ||
| 160 | - | ||
| 95 | + | <!-- Font selector (if enabled) --> | |
| 96 | + | {#if showFontSelector} | |
| 97 | + | <select | |
| 98 | + | value={style.fontFamily} | |
| 99 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 100 | + | title="Font Family" | |
| 101 | + | > | |
| 102 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 103 | + | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |
| 104 | + | {/each} | |
| 105 | + | </select> | |
| 106 | + | {/if} | |
| 107 | + | ||
| 108 | + | <!-- Size slider --> | |
| 109 | + | <div class="size-control"> | |
| 110 | + | <input | |
| 111 | + | type="range" | |
| 112 | + | min="8" | |
| 113 | + | max="18" | |
| 114 | + | bind:value={style.fontSize} | |
| 115 | + | class="size-slider" | |
| 116 | + | title="Font Size: {style.fontSize}px" | |
| 117 | + | /> | |
| 118 | + | <span class="size-display">{style.fontSize}</span> | |
| 119 | + | </div> | |
| 120 | + | ||
| 121 | + | <!-- Style toggle buttons --> | |
| 122 | + | <div class="style-toggles"> | |
| 123 | + | <button | |
| 124 | + | onclick={toggleBold} | |
| 125 | + | title="Bold (Ctrl+B)" | |
| 126 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 127 | + | > | |
| 128 | + | <strong>B</strong> | |
| 129 | + | </button> | |
| 130 | + | ||
| 131 | + | <button | |
| 132 | + | onclick={toggleItalic} | |
| 133 | + | title="Italic (Ctrl+I)" | |
| 134 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 135 | + | > | |
| 136 | + | <em>I</em> | |
| 137 | + | </button> | |
| 138 | + | ||
| 139 | + | <button | |
| 140 | + | onclick={toggleUnderline} | |
| 141 | + | title="Underline (Ctrl+U)" | |
| 142 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 143 | + | > | |
| 144 | + | <u>U</u> | |
| 145 | + | </button> | |
| 146 | + | </div> | |
| 147 | + | ||
| 148 | + | <!-- Color controls --> | |
| 149 | + | <div class="color-controls"> | |
| 150 | + | <ColorPicker256 | |
| 151 | + | bind:selectedColor={currentColor} | |
| 152 | + | bind:selectedGradient={currentGradient} | |
| 153 | + | onColorChange={handleColorChange} | |
| 154 | + | onGradientChange={handleGradientChange} | |
| 155 | + | {compact} | |
| 156 | + | /> | |
| 157 | + | </div> | |
| 161 | 158 | </div> | |
| 162 | 159 | ||
| 163 | 160 | <style> | |
| 164 | - | .text-formatting-toolbar { | |
| 165 | - | display: flex; | |
| 166 | - | align-items: center; | |
| 167 | - | gap: 4px; | |
| 168 | - | padding: 2px; | |
| 169 | - | font-size: 11px; | |
| 170 | - | position: relative; | |
| 171 | - | flex-wrap: wrap; | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | .size-control { | |
| 175 | - | display: flex; | |
| 176 | - | align-items: center; | |
| 177 | - | gap: 2px; | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | .size-slider { | |
| 181 | - | width: 50px; | |
| 182 | - | height: 12px; | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | .size-display { | |
| 186 | - | font-size: 9px; | |
| 187 | - | color: #666; | |
| 188 | - | min-width: 18px; | |
| 189 | - | text-align: center; | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | .style-toggles { | |
| 193 | - | display: flex; | |
| 194 | - | gap: 1px; | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | .color-controls { | |
| 198 | - | display: flex; | |
| 199 | - | gap: 1px; | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | /* Fix font dropdown text alignment */ | |
| 203 | - | select { | |
| 204 | - | height: 20px; | |
| 205 | - | line-height: 1; | |
| 206 | - | vertical-align: middle; | |
| 207 | - | padding: 1px 4px; | |
| 208 | - | font-size: 11px; | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | select option { | |
| 212 | - | padding: 2px 4px; | |
| 213 | - | line-height: normal; | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | </style>⏎̸ | |
| 161 | + | .text-formatting-toolbar { | |
| 162 | + | display: flex; | |
| 163 | + | align-items: center; | |
| 164 | + | gap: 4px; | |
| 165 | + | padding: 2px; | |
| 166 | + | font-size: 11px; | |
| 167 | + | position: relative; | |
| 168 | + | flex-wrap: wrap; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | .size-control { | |
| 172 | + | display: flex; | |
| 173 | + | align-items: center; | |
| 174 | + | gap: 2px; | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | .size-slider { | |
| 178 | + | width: 50px; | |
| 179 | + | height: 12px; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | .size-display { | |
| 183 | + | font-size: 9px; | |
| 184 | + | color: #666; | |
| 185 | + | min-width: 18px; | |
| 186 | + | text-align: center; | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | .style-toggles { | |
| 190 | + | display: flex; | |
| 191 | + | gap: 1px; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | .color-controls { | |
| 195 | + | display: flex; | |
| 196 | + | gap: 1px; | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /* Fix font dropdown text alignment */ | |
| 200 | + | select { | |
| 201 | + | height: 20px; | |
| 202 | + | line-height: 1; | |
| 203 | + | vertical-align: middle; | |
| 204 | + | padding: 1px 4px; | |
| 205 | + | font-size: 11px; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | select option { | |
| 209 | + | padding: 2px 4px; | |
| 210 | + | line-height: normal; | |
| 211 | + | } | |
| 212 | + | </style> | |
M src/lib/components/text-style-selector.svelte +312 -316
| @@ -1,323 +1,319 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { | |
| 3 | - | RETRO_FONTS, | |
| 4 | - | CLASSIC_COLORS, | |
| 5 | - | DEFAULT_TEXT_STYLE, | |
| 6 | - | type TextStyle, | |
| 7 | - | type FontFamily, | |
| 8 | - | generateCSSStyle | |
| 9 | - | } from '../types/text-formatting'; | |
| 10 | - | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | - | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 12 | - | ||
| 13 | - | // Props | |
| 14 | - | let { | |
| 15 | - | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 16 | - | previewText = "Sample text preview", | |
| 17 | - | showPreview = true, | |
| 18 | - | compact = false | |
| 19 | - | } = $props<{ | |
| 20 | - | style?: TextStyle; | |
| 21 | - | previewText?: string; | |
| 22 | - | showPreview?: boolean; | |
| 23 | - | compact?: boolean; | |
| 24 | - | }>(); | |
| 25 | - | ||
| 26 | - | // Local state | |
| 27 | - | let currentColor = $state(style.color || '#000000'); | |
| 28 | - | let currentGradient = $state(style.gradient); | |
| 29 | - | let formattedPreview = $state(''); | |
| 30 | - | let isProcessing = $state(false); | |
| 31 | - | ||
| 32 | - | // Update preview when style changes | |
| 33 | - | $effect(() => { | |
| 34 | - | if (!showPreview) return; | |
| 35 | - | ||
| 36 | - | async function updatePreview() { | |
| 37 | - | isProcessing = true; | |
| 38 | - | try { | |
| 39 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 40 | - | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | |
| 41 | - | } else { | |
| 42 | - | formattedPreview = await formatText(previewText, style, false); | |
| 43 | - | } | |
| 44 | - | } catch (error) { | |
| 45 | - | console.error('Preview formatting error:', error); | |
| 46 | - | formattedPreview = previewText; | |
| 47 | - | } finally { | |
| 48 | - | isProcessing = false; | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | updatePreview(); | |
| 53 | - | }); | |
| 54 | - | ||
| 55 | - | // Font family selection | |
| 56 | - | function selectFont(fontFamily: FontFamily) { | |
| 57 | - | style = { ...style, fontFamily }; | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Color selection | |
| 61 | - | function handleColorChange(color: string) { | |
| 62 | - | currentColor = color; | |
| 63 | - | style = { ...style, color, gradient: undefined }; | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Gradient selection | |
| 67 | - | function handleGradientChange(gradient: string[]) { | |
| 68 | - | currentGradient = gradient; | |
| 69 | - | style = { ...style, gradient, color: gradient[0] }; | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // Update internal state when style prop changes | |
| 73 | - | $effect(() => { | |
| 74 | - | currentColor = style.color || '#000000'; | |
| 75 | - | currentGradient = style.gradient; | |
| 76 | - | }); | |
| 77 | - | ||
| 78 | - | // Toggle style options | |
| 79 | - | function toggleBold() { | |
| 80 | - | style = { ...style, bold: !style.bold }; | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | function toggleItalic() { | |
| 84 | - | style = { ...style, italic: !style.italic }; | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | function toggleUnderline() { | |
| 88 | - | style = { ...style, underline: !style.underline }; | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | function toggleStrikethrough() { | |
| 92 | - | style = { ...style, strikethrough: !style.strikethrough }; | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Font size adjustment | |
| 96 | - | function updateFontSize(size: number) { | |
| 97 | - | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | |
| 98 | - | } | |
| 99 | - | ||
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | DEFAULT_TEXT_STYLE, | |
| 6 | + | type TextStyle, | |
| 7 | + | type FontFamily, | |
| 8 | + | generateCSSStyle | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 12 | + | ||
| 13 | + | // Props | |
| 14 | + | let { | |
| 15 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 16 | + | previewText = 'Sample text preview', | |
| 17 | + | showPreview = true, | |
| 18 | + | compact = false | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | previewText?: string; | |
| 22 | + | showPreview?: boolean; | |
| 23 | + | compact?: boolean; | |
| 24 | + | }>(); | |
| 25 | + | ||
| 26 | + | // Local state | |
| 27 | + | let currentColor = $state(style.color || '#000000'); | |
| 28 | + | let currentGradient = $state(style.gradient); | |
| 29 | + | let formattedPreview = $state(''); | |
| 30 | + | let isProcessing = $state(false); | |
| 31 | + | ||
| 32 | + | // Update preview when style changes | |
| 33 | + | $effect(() => { | |
| 34 | + | if (!showPreview) return; | |
| 35 | + | ||
| 36 | + | async function updatePreview() { | |
| 37 | + | isProcessing = true; | |
| 38 | + | try { | |
| 39 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 40 | + | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | |
| 41 | + | } else { | |
| 42 | + | formattedPreview = await formatText(previewText, style, false); | |
| 43 | + | } | |
| 44 | + | } catch (error) { | |
| 45 | + | console.error('Preview formatting error:', error); | |
| 46 | + | formattedPreview = previewText; | |
| 47 | + | } finally { | |
| 48 | + | isProcessing = false; | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | updatePreview(); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | // Font family selection | |
| 56 | + | function selectFont(fontFamily: FontFamily) { | |
| 57 | + | style = { ...style, fontFamily }; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Color selection | |
| 61 | + | function handleColorChange(color: string) { | |
| 62 | + | currentColor = color; | |
| 63 | + | style = { ...style, color, gradient: undefined }; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Gradient selection | |
| 67 | + | function handleGradientChange(gradient: string[]) { | |
| 68 | + | currentGradient = gradient; | |
| 69 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Update internal state when style prop changes | |
| 73 | + | $effect(() => { | |
| 74 | + | currentColor = style.color || '#000000'; | |
| 75 | + | currentGradient = style.gradient; | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | // Toggle style options | |
| 79 | + | function toggleBold() { | |
| 80 | + | style = { ...style, bold: !style.bold }; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | function toggleItalic() { | |
| 84 | + | style = { ...style, italic: !style.italic }; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function toggleUnderline() { | |
| 88 | + | style = { ...style, underline: !style.underline }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | function toggleStrikethrough() { | |
| 92 | + | style = { ...style, strikethrough: !style.strikethrough }; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Font size adjustment | |
| 96 | + | function updateFontSize(size: number) { | |
| 97 | + | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | |
| 98 | + | } | |
| 100 | 99 | </script> | |
| 101 | 100 | ||
| 102 | 101 | <div class="text-style-selector" class:compact> | |
| 103 | - | <!-- Font Family Selection --> | |
| 104 | - | <div class="style-group"> | |
| 105 | - | <label>Font:</label> | |
| 106 | - | <select | |
| 107 | - | bind:value={style.fontFamily} | |
| 108 | - | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 109 | - | > | |
| 110 | - | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 111 | - | <option value={key} style="font-family: {font.stack}"> | |
| 112 | - | {font.name} | |
| 113 | - | </option> | |
| 114 | - | {/each} | |
| 115 | - | </select> | |
| 116 | - | </div> | |
| 117 | - | ||
| 118 | - | <!-- Font Size --> | |
| 119 | - | <div class="style-group"> | |
| 120 | - | <label>Size:</label> | |
| 121 | - | <input | |
| 122 | - | type="range" | |
| 123 | - | min="8" | |
| 124 | - | max="18" | |
| 125 | - | bind:value={style.fontSize} | |
| 126 | - | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} | |
| 127 | - | /> | |
| 128 | - | <span class="size-display">{style.fontSize}px</span> | |
| 129 | - | </div> | |
| 130 | - | ||
| 131 | - | <!-- Style Toggles --> | |
| 132 | - | <div class="style-group toggles"> | |
| 133 | - | <button | |
| 134 | - | class="style-toggle" | |
| 135 | - | class:active={style.bold} | |
| 136 | - | onclick={toggleBold} | |
| 137 | - | title="Bold (Ctrl+B)" | |
| 138 | - | > | |
| 139 | - | <strong>B</strong> | |
| 140 | - | </button> | |
| 141 | - | <button | |
| 142 | - | class="style-toggle" | |
| 143 | - | class:active={style.italic} | |
| 144 | - | onclick={toggleItalic} | |
| 145 | - | title="Italic (Ctrl+I)" | |
| 146 | - | > | |
| 147 | - | <em>I</em> | |
| 148 | - | </button> | |
| 149 | - | <button | |
| 150 | - | class="style-toggle" | |
| 151 | - | class:active={style.underline} | |
| 152 | - | onclick={toggleUnderline} | |
| 153 | - | title="Underline (Ctrl+U)" | |
| 154 | - | > | |
| 155 | - | <u>U</u> | |
| 156 | - | </button> | |
| 157 | - | <button | |
| 158 | - | class="style-toggle" | |
| 159 | - | class:active={style.strikethrough} | |
| 160 | - | onclick={toggleStrikethrough} | |
| 161 | - | title="Strikethrough" | |
| 162 | - | > | |
| 163 | - | <s>S</s> | |
| 164 | - | </button> | |
| 165 | - | </div> | |
| 166 | - | ||
| 167 | - | <!-- Color & Gradient Selection --> | |
| 168 | - | <div class="style-group"> | |
| 169 | - | <label>Color:</label> | |
| 170 | - | <div class="color-controls"> | |
| 171 | - | <ColorPicker256 | |
| 172 | - | bind:selectedColor={currentColor} | |
| 173 | - | bind:selectedGradient={currentGradient} | |
| 174 | - | onColorChange={handleColorChange} | |
| 175 | - | onGradientChange={handleGradientChange} | |
| 176 | - | compact={false} | |
| 177 | - | /> | |
| 178 | - | </div> | |
| 179 | - | </div> | |
| 180 | - | ||
| 181 | - | ||
| 182 | - | <!-- Preview --> | |
| 183 | - | {#if showPreview} | |
| 184 | - | <div class="preview-section"> | |
| 185 | - | <label>Preview:</label> | |
| 186 | - | <div | |
| 187 | - | class="preview-text" | |
| 188 | - | style={generateCSSStyle(style)} | |
| 189 | - | > | |
| 190 | - | {#if isProcessing} | |
| 191 | - | <span class="loading">Processing...</span> | |
| 192 | - | {:else} | |
| 193 | - | {@html formattedPreview} | |
| 194 | - | {/if} | |
| 195 | - | </div> | |
| 196 | - | </div> | |
| 197 | - | {/if} | |
| 102 | + | <!-- Font Family Selection --> | |
| 103 | + | <div class="style-group"> | |
| 104 | + | <label>Font:</label> | |
| 105 | + | <select | |
| 106 | + | bind:value={style.fontFamily} | |
| 107 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 108 | + | > | |
| 109 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 110 | + | <option value={key} style="font-family: {font.stack}"> | |
| 111 | + | {font.name} | |
| 112 | + | </option> | |
| 113 | + | {/each} | |
| 114 | + | </select> | |
| 115 | + | </div> | |
| 116 | + | ||
| 117 | + | <!-- Font Size --> | |
| 118 | + | <div class="style-group"> | |
| 119 | + | <label>Size:</label> | |
| 120 | + | <input | |
| 121 | + | type="range" | |
| 122 | + | min="8" | |
| 123 | + | max="18" | |
| 124 | + | bind:value={style.fontSize} | |
| 125 | + | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} | |
| 126 | + | /> | |
| 127 | + | <span class="size-display">{style.fontSize}px</span> | |
| 128 | + | </div> | |
| 129 | + | ||
| 130 | + | <!-- Style Toggles --> | |
| 131 | + | <div class="style-group toggles"> | |
| 132 | + | <button | |
| 133 | + | class="style-toggle" | |
| 134 | + | class:active={style.bold} | |
| 135 | + | onclick={toggleBold} | |
| 136 | + | title="Bold (Ctrl+B)" | |
| 137 | + | > | |
| 138 | + | <strong>B</strong> | |
| 139 | + | </button> | |
| 140 | + | <button | |
| 141 | + | class="style-toggle" | |
| 142 | + | class:active={style.italic} | |
| 143 | + | onclick={toggleItalic} | |
| 144 | + | title="Italic (Ctrl+I)" | |
| 145 | + | > | |
| 146 | + | <em>I</em> | |
| 147 | + | </button> | |
| 148 | + | <button | |
| 149 | + | class="style-toggle" | |
| 150 | + | class:active={style.underline} | |
| 151 | + | onclick={toggleUnderline} | |
| 152 | + | title="Underline (Ctrl+U)" | |
| 153 | + | > | |
| 154 | + | <u>U</u> | |
| 155 | + | </button> | |
| 156 | + | <button | |
| 157 | + | class="style-toggle" | |
| 158 | + | class:active={style.strikethrough} | |
| 159 | + | onclick={toggleStrikethrough} | |
| 160 | + | title="Strikethrough" | |
| 161 | + | > | |
| 162 | + | <s>S</s> | |
| 163 | + | </button> | |
| 164 | + | </div> | |
| 165 | + | ||
| 166 | + | <!-- Color & Gradient Selection --> | |
| 167 | + | <div class="style-group"> | |
| 168 | + | <label>Color:</label> | |
| 169 | + | <div class="color-controls"> | |
| 170 | + | <ColorPicker256 | |
| 171 | + | bind:selectedColor={currentColor} | |
| 172 | + | bind:selectedGradient={currentGradient} | |
| 173 | + | onColorChange={handleColorChange} | |
| 174 | + | onGradientChange={handleGradientChange} | |
| 175 | + | compact={false} | |
| 176 | + | /> | |
| 177 | + | </div> | |
| 178 | + | </div> | |
| 179 | + | ||
| 180 | + | <!-- Preview --> | |
| 181 | + | {#if showPreview} | |
| 182 | + | <div class="preview-section"> | |
| 183 | + | <label>Preview:</label> | |
| 184 | + | <div class="preview-text" style={generateCSSStyle(style)}> | |
| 185 | + | {#if isProcessing} | |
| 186 | + | <span class="loading">Processing...</span> | |
| 187 | + | {:else} | |
| 188 | + | {@html formattedPreview} | |
| 189 | + | {/if} | |
| 190 | + | </div> | |
| 191 | + | </div> | |
| 192 | + | {/if} | |
| 198 | 193 | </div> | |
| 199 | 194 | ||
| 200 | 195 | <style> | |
| 201 | - | .text-style-selector { | |
| 202 | - | background: #f0f0f0; | |
| 203 | - | border: 2px inset #dfdfdf; | |
| 204 | - | padding: 8px; | |
| 205 | - | font-family: 'MS Sans Serif', sans-serif; | |
| 206 | - | font-size: 11px; | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | .text-style-selector.compact { | |
| 210 | - | padding: 4px; | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | .style-group { | |
| 214 | - | display: flex; | |
| 215 | - | align-items: center; | |
| 216 | - | gap: 8px; | |
| 217 | - | margin-bottom: 8px; | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | .style-group label { | |
| 221 | - | font-weight: bold; | |
| 222 | - | min-width: 50px; | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | .style-group select { | |
| 226 | - | flex: 1; | |
| 227 | - | padding: 2px; | |
| 228 | - | border: 1px inset #dfdfdf; | |
| 229 | - | background: white; | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | .style-group input[type="range"] { | |
| 233 | - | flex: 1; | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | .size-display { | |
| 237 | - | min-width: 35px; | |
| 238 | - | font-size: 10px; | |
| 239 | - | color: #666; | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | .toggles { | |
| 243 | - | gap: 4px; | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | .style-toggle { | |
| 247 | - | width: 24px; | |
| 248 | - | height: 24px; | |
| 249 | - | border: 1px outset #dfdfdf; | |
| 250 | - | background: #f0f0f0; | |
| 251 | - | cursor: pointer; | |
| 252 | - | display: flex; | |
| 253 | - | align-items: center; | |
| 254 | - | justify-content: center; | |
| 255 | - | font-size: 12px; | |
| 256 | - | font-weight: bold; | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | .style-toggle:hover { | |
| 260 | - | background: #e0e0e0; | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | .style-toggle.active { | |
| 264 | - | border: 1px inset #dfdfdf; | |
| 265 | - | background: #d0d0d0; | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | .color-controls { | |
| 269 | - | display: flex; | |
| 270 | - | gap: 4px; | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | .preview-section { | |
| 274 | - | margin-top: 12px; | |
| 275 | - | padding-top: 8px; | |
| 276 | - | border-top: 1px solid #ccc; | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | .preview-section label { | |
| 280 | - | display: block; | |
| 281 | - | margin-bottom: 4px; | |
| 282 | - | font-weight: bold; | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | .preview-text { | |
| 286 | - | padding: 8px; | |
| 287 | - | border: 1px inset #dfdfdf; | |
| 288 | - | background: white; | |
| 289 | - | min-height: 24px; | |
| 290 | - | line-height: 1.4; | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | .loading { | |
| 294 | - | color: #666; | |
| 295 | - | font-style: italic; | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | /* Gradient text support */ | |
| 299 | - | :global(.preview-gradient) { | |
| 300 | - | display: inline; | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | /* Compact mode adjustments */ | |
| 304 | - | .compact .style-group { | |
| 305 | - | margin-bottom: 4px; | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | .compact .style-group label { | |
| 309 | - | min-width: 40px; | |
| 310 | - | font-size: 10px; | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | .compact .style-toggle { | |
| 314 | - | width: 20px; | |
| 315 | - | height: 20px; | |
| 316 | - | font-size: 10px; | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | .compact .color-button, .compact .gradient-button { | |
| 320 | - | font-size: 9px; | |
| 321 | - | padding: 1px 4px; | |
| 322 | - | } | |
| 323 | - | </style>⏎̸ | |
| 196 | + | .text-style-selector { | |
| 197 | + | background: #f0f0f0; | |
| 198 | + | border: 2px inset #dfdfdf; | |
| 199 | + | padding: 8px; | |
| 200 | + | font-family: 'MS Sans Serif', sans-serif; | |
| 201 | + | font-size: 11px; | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | .text-style-selector.compact { | |
| 205 | + | padding: 4px; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | .style-group { | |
| 209 | + | display: flex; | |
| 210 | + | align-items: center; | |
| 211 | + | gap: 8px; | |
| 212 | + | margin-bottom: 8px; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | .style-group label { | |
| 216 | + | font-weight: bold; | |
| 217 | + | min-width: 50px; | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | .style-group select { | |
| 221 | + | flex: 1; | |
| 222 | + | padding: 2px; | |
| 223 | + | border: 1px inset #dfdfdf; | |
| 224 | + | background: white; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | .style-group input[type='range'] { | |
| 228 | + | flex: 1; | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | .size-display { | |
| 232 | + | min-width: 35px; | |
| 233 | + | font-size: 10px; | |
| 234 | + | color: #666; | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | .toggles { | |
| 238 | + | gap: 4px; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | .style-toggle { | |
| 242 | + | width: 24px; | |
| 243 | + | height: 24px; | |
| 244 | + | border: 1px outset #dfdfdf; | |
| 245 | + | background: #f0f0f0; | |
| 246 | + | cursor: pointer; | |
| 247 | + | display: flex; | |
| 248 | + | align-items: center; | |
| 249 | + | justify-content: center; | |
| 250 | + | font-size: 12px; | |
| 251 | + | font-weight: bold; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | .style-toggle:hover { | |
| 255 | + | background: #e0e0e0; | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | .style-toggle.active { | |
| 259 | + | border: 1px inset #dfdfdf; | |
| 260 | + | background: #d0d0d0; | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | .color-controls { | |
| 264 | + | display: flex; | |
| 265 | + | gap: 4px; | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | .preview-section { | |
| 269 | + | margin-top: 12px; | |
| 270 | + | padding-top: 8px; | |
| 271 | + | border-top: 1px solid #ccc; | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | .preview-section label { | |
| 275 | + | display: block; | |
| 276 | + | margin-bottom: 4px; | |
| 277 | + | font-weight: bold; | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | .preview-text { | |
| 281 | + | padding: 8px; | |
| 282 | + | border: 1px inset #dfdfdf; | |
| 283 | + | background: white; | |
| 284 | + | min-height: 24px; | |
| 285 | + | line-height: 1.4; | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | .loading { | |
| 289 | + | color: #666; | |
| 290 | + | font-style: italic; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /* Gradient text support */ | |
| 294 | + | :global(.preview-gradient) { | |
| 295 | + | display: inline; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /* Compact mode adjustments */ | |
| 299 | + | .compact .style-group { | |
| 300 | + | margin-bottom: 4px; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | .compact .style-group label { | |
| 304 | + | min-width: 40px; | |
| 305 | + | font-size: 10px; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | .compact .style-toggle { | |
| 309 | + | width: 20px; | |
| 310 | + | height: 20px; | |
| 311 | + | font-size: 10px; | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | .compact .color-button, | |
| 315 | + | .compact .gradient-button { | |
| 316 | + | font-size: 9px; | |
| 317 | + | padding: 1px 4px; | |
| 318 | + | } | |
| 319 | + | </style> | |
M src/lib/components/turnstile.svelte +112 -116
| @@ -1,123 +1,119 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { onMount } from 'svelte'; | |
| 3 | - | import { fade } from 'svelte/transition'; | |
| 4 | - | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | |
| 5 | - | import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile'; | |
| 6 | - | ||
| 7 | - | let isVerified = $state(false); | |
| 8 | - | ||
| 9 | - | type OnVerifyCallback = (token: string) => void; | |
| 10 | - | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); | |
| 11 | - | ||
| 12 | - | let turnstileElement = $state<HTMLDivElement | null>(null); | |
| 13 | - | let widgetId = ''; | |
| 14 | - | ||
| 15 | - | // Ensure site key is a string and trim any whitespace | |
| 16 | - | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | |
| 17 | - | ||
| 18 | - | onMount(() => { | |
| 19 | - | if (!window.turnstile) { | |
| 20 | - | const script = document.createElement('script'); | |
| 21 | - | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; | |
| 22 | - | script.async = true; | |
| 23 | - | script.defer = true; | |
| 24 | - | document.head.appendChild(script); | |
| 25 | - | ||
| 26 | - | script.onload = initTurnstile; | |
| 27 | - | } else { | |
| 28 | - | initTurnstile(); | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | return () => { | |
| 32 | - | if (widgetId) { | |
| 33 | - | window.turnstile?.remove(widgetId); | |
| 34 | - | } | |
| 35 | - | }; | |
| 36 | - | }); | |
| 37 | - | ||
| 38 | - | function initTurnstile() { | |
| 39 | - | if (!turnstileElement || !window.turnstile) return; | |
| 40 | - | ||
| 41 | - | // Validate site key is a non-empty string | |
| 42 | - | if (!siteKey) { | |
| 43 | - | console.error('Turnstile site key is missing'); | |
| 44 | - | return; | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | const options: TurnstileOptions = { | |
| 48 | - | sitekey: siteKey, | |
| 49 | - | callback: (token: string) => { | |
| 50 | - | if (typeof onVerify === 'function') { | |
| 51 | - | onVerify(token); | |
| 52 | - | isVerified = true; | |
| 53 | - | } else { | |
| 54 | - | console.error('onVerify callback is not a function'); | |
| 55 | - | } | |
| 56 | - | }, | |
| 57 | - | 'refresh-expired': 'auto', | |
| 58 | - | size: 'compact', | |
| 59 | - | 'error-callback': (error: unknown) => { | |
| 60 | - | console.error('Turnstile error:', error); | |
| 61 | - | isVerified = false; // Reset verification state on error | |
| 62 | - | } | |
| 63 | - | }; | |
| 64 | - | ||
| 65 | - | try { | |
| 66 | - | widgetId = window.turnstile.render(turnstileElement, options); | |
| 67 | - | ||
| 68 | - | // Add resize handler | |
| 69 | - | const handleResize = () => scaleTurnstileToFit(); | |
| 70 | - | window.addEventListener('resize', handleResize); | |
| 71 | - | // Initial scale | |
| 72 | - | scaleTurnstileToFit(); | |
| 73 | - | ||
| 74 | - | } catch (error) { | |
| 75 | - | console.error('Failed to render Turnstile:', error); | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | function scaleTurnstileToFit() { | |
| 80 | - | const container = turnstileElement; | |
| 81 | - | if (!container) return; | |
| 82 | - | ||
| 83 | - | const iframe = container.querySelector('iframe'); | |
| 84 | - | if (!iframe) return; | |
| 85 | - | ||
| 86 | - | const containerWidth = container.offsetWidth; | |
| 87 | - | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width | |
| 88 | - | ||
| 89 | - | iframe.style.transform = `scale(${scale})`; | |
| 90 | - | iframe.style.transformOrigin = 'center center'; | |
| 91 | - | } | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import { fade } from 'svelte/transition'; | |
| 4 | + | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | |
| 5 | + | import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile'; | |
| 6 | + | ||
| 7 | + | let isVerified = $state(false); | |
| 8 | + | ||
| 9 | + | type OnVerifyCallback = (token: string) => void; | |
| 10 | + | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); | |
| 11 | + | ||
| 12 | + | let turnstileElement = $state<HTMLDivElement | null>(null); | |
| 13 | + | let widgetId = ''; | |
| 14 | + | ||
| 15 | + | // Ensure site key is a string and trim any whitespace | |
| 16 | + | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | |
| 17 | + | ||
| 18 | + | onMount(() => { | |
| 19 | + | if (!window.turnstile) { | |
| 20 | + | const script = document.createElement('script'); | |
| 21 | + | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; | |
| 22 | + | script.async = true; | |
| 23 | + | script.defer = true; | |
| 24 | + | document.head.appendChild(script); | |
| 25 | + | ||
| 26 | + | script.onload = initTurnstile; | |
| 27 | + | } else { | |
| 28 | + | initTurnstile(); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | return () => { | |
| 32 | + | if (widgetId) { | |
| 33 | + | window.turnstile?.remove(widgetId); | |
| 34 | + | } | |
| 35 | + | }; | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | function initTurnstile() { | |
| 39 | + | if (!turnstileElement || !window.turnstile) return; | |
| 40 | + | ||
| 41 | + | // Validate site key is a non-empty string | |
| 42 | + | if (!siteKey) { | |
| 43 | + | console.error('Turnstile site key is missing'); | |
| 44 | + | return; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | const options: TurnstileOptions = { | |
| 48 | + | sitekey: siteKey, | |
| 49 | + | callback: (token: string) => { | |
| 50 | + | if (typeof onVerify === 'function') { | |
| 51 | + | onVerify(token); | |
| 52 | + | isVerified = true; | |
| 53 | + | } else { | |
| 54 | + | console.error('onVerify callback is not a function'); | |
| 55 | + | } | |
| 56 | + | }, | |
| 57 | + | 'refresh-expired': 'auto', | |
| 58 | + | size: 'compact', | |
| 59 | + | 'error-callback': (error: unknown) => { | |
| 60 | + | console.error('Turnstile error:', error); | |
| 61 | + | isVerified = false; // Reset verification state on error | |
| 62 | + | } | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | try { | |
| 66 | + | widgetId = window.turnstile.render(turnstileElement, options); | |
| 67 | + | ||
| 68 | + | // Add resize handler | |
| 69 | + | const handleResize = () => scaleTurnstileToFit(); | |
| 70 | + | window.addEventListener('resize', handleResize); | |
| 71 | + | // Initial scale | |
| 72 | + | scaleTurnstileToFit(); | |
| 73 | + | } catch (error) { | |
| 74 | + | console.error('Failed to render Turnstile:', error); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | function scaleTurnstileToFit() { | |
| 79 | + | const container = turnstileElement; | |
| 80 | + | if (!container) return; | |
| 81 | + | ||
| 82 | + | const iframe = container.querySelector('iframe'); | |
| 83 | + | if (!iframe) return; | |
| 84 | + | ||
| 85 | + | const containerWidth = container.offsetWidth; | |
| 86 | + | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width | |
| 87 | + | ||
| 88 | + | iframe.style.transform = `scale(${scale})`; | |
| 89 | + | iframe.style.transformOrigin = 'center center'; | |
| 90 | + | } | |
| 92 | 91 | </script> | |
| 93 | 92 | ||
| 94 | 93 | {#if !isVerified} | |
| 95 | - | <div | |
| 96 | - | class="turnstile-wrapper" | |
| 97 | - | transition:fade={{ duration: 300 }} | |
| 98 | - | > | |
| 99 | - | <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div> | |
| 100 | - | </div> | |
| 94 | + | <div class="turnstile-wrapper" transition:fade={{ duration: 300 }}> | |
| 95 | + | <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div> | |
| 96 | + | </div> | |
| 101 | 97 | {/if} | |
| 102 | 98 | ||
| 103 | 99 | <style> | |
| 104 | - | .turnstile-wrapper { | |
| 105 | - | width: 100%; | |
| 106 | - | display: flex; | |
| 107 | - | justify-content: center; | |
| 108 | - | align-items: center; | |
| 109 | - | min-height: 65px; /* Approximate height for compact size */ | |
| 110 | - | overflow: hidden; | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | .turnstile-container { | |
| 114 | - | display: flex; | |
| 115 | - | justify-content: center; | |
| 116 | - | align-items: center; | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | /* Handle iframe scaling */ | |
| 120 | - | :global(.turnstile-wrapper iframe) { | |
| 121 | - | transition: transform 0.2s ease-in-out; | |
| 122 | - | } | |
| 123 | - | </style> ⏎̸ | |
| 100 | + | .turnstile-wrapper { | |
| 101 | + | width: 100%; | |
| 102 | + | display: flex; | |
| 103 | + | justify-content: center; | |
| 104 | + | align-items: center; | |
| 105 | + | min-height: 65px; /* Approximate height for compact size */ | |
| 106 | + | overflow: hidden; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | .turnstile-container { | |
| 110 | + | display: flex; | |
| 111 | + | justify-content: center; | |
| 112 | + | align-items: center; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /* Handle iframe scaling */ | |
| 116 | + | :global(.turnstile-wrapper iframe) { | |
| 117 | + | transition: transform 0.2s ease-in-out; | |
| 118 | + | } | |
| 119 | + | </style> | |
M src/lib/components/ui/button-loading.svelte +73 -67
| @@ -1,77 +1,83 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { onDestroy } from 'svelte'; | |
| 2 | + | import { onDestroy } from 'svelte'; | |
| 3 | 3 | ||
| 4 | - | // Props with defaults: | |
| 5 | - | // - text: button label when not loading (default "Envoyer") | |
| 6 | - | // - loading: indicates if the button should show a spinner (default false) | |
| 7 | - | // - spinnerChar: the character to use for the spinner animation (default '.') | |
| 8 | - | let { onclick, disabled = $bindable(false), text = 'Envoyer', loading = $bindable(false), spinnerChar = $bindable('.') } = $props(); | |
| 4 | + | // Props with defaults: | |
| 5 | + | // - text: button label when not loading (default "Envoyer") | |
| 6 | + | // - loading: indicates if the button should show a spinner (default false) | |
| 7 | + | // - spinnerChar: the character to use for the spinner animation (default '.') | |
| 8 | + | let { | |
| 9 | + | onclick, | |
| 10 | + | disabled = $bindable(false), | |
| 11 | + | text = 'Envoyer', | |
| 12 | + | loading = $bindable(false), | |
| 13 | + | spinnerChar = $bindable('.') | |
| 14 | + | } = $props(); | |
| 9 | 15 | ||
| 10 | - | // Using $state for internal reactive variables. | |
| 11 | - | let animatedDots = $state(''); // will hold the animated spinner text | |
| 12 | - | let count = $state(1); | |
| 13 | - | let isDecreasing = $state(true); | |
| 14 | - | let intervalID = $state<number | null>(null); | |
| 16 | + | // Using $state for internal reactive variables. | |
| 17 | + | let animatedDots = $state(''); // will hold the animated spinner text | |
| 18 | + | let count = $state(1); | |
| 19 | + | let isDecreasing = $state(true); | |
| 20 | + | let intervalID = $state<number | null>(null); | |
| 15 | 21 | ||
| 16 | - | // Effect that starts/stops the spinner based on loading state. | |
| 17 | - | $effect(() => { | |
| 18 | - | if (loading) { | |
| 19 | - | console.debug("LoadingButton: Spinner started"); | |
| 20 | - | // Immediately update spinner text to show 3 dots and start decreasing | |
| 21 | - | animatedDots = spinnerChar.repeat(3); | |
| 22 | - | count = 3; | |
| 23 | - | isDecreasing = true; | |
| 24 | - | ||
| 25 | - | if (!intervalID) { | |
| 26 | - | intervalID = window.setInterval(() => { | |
| 27 | - | // Ping-pong logic: 3,2,1,2,3... | |
| 28 | - | count = count + (isDecreasing ? -1 : 1); | |
| 29 | - | if (count <= 1) { | |
| 30 | - | count = 1; | |
| 31 | - | isDecreasing = false; | |
| 32 | - | } else if (count >= 3) { | |
| 33 | - | count = 3; | |
| 34 | - | isDecreasing = true; | |
| 35 | - | } | |
| 36 | - | animatedDots = spinnerChar.repeat(count); | |
| 37 | - | console.debug("LoadingButton: Animated dots updated to", animatedDots); | |
| 38 | - | }, 500); | |
| 39 | - | } | |
| 40 | - | } else { | |
| 41 | - | console.debug("LoadingButton: Spinner stopped"); | |
| 42 | - | if (intervalID) { | |
| 43 | - | clearInterval(intervalID); | |
| 44 | - | intervalID = null; | |
| 45 | - | } | |
| 46 | - | // When not loading, display the default text. | |
| 47 | - | animatedDots = text; | |
| 48 | - | } | |
| 49 | - | }); | |
| 22 | + | // Effect that starts/stops the spinner based on loading state. | |
| 23 | + | $effect(() => { | |
| 24 | + | if (loading) { | |
| 25 | + | console.debug('LoadingButton: Spinner started'); | |
| 26 | + | // Immediately update spinner text to show 3 dots and start decreasing | |
| 27 | + | animatedDots = spinnerChar.repeat(3); | |
| 28 | + | count = 3; | |
| 29 | + | isDecreasing = true; | |
| 50 | 30 | ||
| 51 | - | onDestroy(() => { | |
| 52 | - | if (intervalID) { | |
| 53 | - | console.debug("LoadingButton: onDestroy clearing interval"); | |
| 54 | - | clearInterval(intervalID); | |
| 55 | - | } | |
| 56 | - | }); | |
| 31 | + | if (!intervalID) { | |
| 32 | + | intervalID = window.setInterval(() => { | |
| 33 | + | // Ping-pong logic: 3,2,1,2,3... | |
| 34 | + | count = count + (isDecreasing ? -1 : 1); | |
| 35 | + | if (count <= 1) { | |
| 36 | + | count = 1; | |
| 37 | + | isDecreasing = false; | |
| 38 | + | } else if (count >= 3) { | |
| 39 | + | count = 3; | |
| 40 | + | isDecreasing = true; | |
| 41 | + | } | |
| 42 | + | animatedDots = spinnerChar.repeat(count); | |
| 43 | + | console.debug('LoadingButton: Animated dots updated to', animatedDots); | |
| 44 | + | }, 500); | |
| 45 | + | } | |
| 46 | + | } else { | |
| 47 | + | console.debug('LoadingButton: Spinner stopped'); | |
| 48 | + | if (intervalID) { | |
| 49 | + | clearInterval(intervalID); | |
| 50 | + | intervalID = null; | |
| 51 | + | } | |
| 52 | + | // When not loading, display the default text. | |
| 53 | + | animatedDots = text; | |
| 54 | + | } | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | onDestroy(() => { | |
| 58 | + | if (intervalID) { | |
| 59 | + | console.debug('LoadingButton: onDestroy clearing interval'); | |
| 60 | + | clearInterval(intervalID); | |
| 61 | + | } | |
| 62 | + | }); | |
| 57 | 63 | </script> | |
| 58 | 64 | ||
| 59 | - | <button onclick={onclick} disabled={disabled}> | |
| 60 | - | {#if loading} | |
| 61 | - | <span>{animatedDots}</span> | |
| 62 | - | {:else} | |
| 63 | - | <span>{text}</span> | |
| 64 | - | {/if} | |
| 65 | + | <button {onclick} {disabled}> | |
| 66 | + | {#if loading} | |
| 67 | + | <span>{animatedDots}</span> | |
| 68 | + | {:else} | |
| 69 | + | <span>{text}</span> | |
| 70 | + | {/if} | |
| 65 | 71 | </button> | |
| 66 | 72 | ||
| 67 | 73 | <style> | |
| 68 | - | button { | |
| 69 | - | padding: 0.5rem 1rem; | |
| 70 | - | font-size: 1rem; | |
| 71 | - | cursor: pointer; | |
| 72 | - | } | |
| 73 | - | button:disabled { | |
| 74 | - | cursor: not-allowed; | |
| 75 | - | opacity: 0.7; | |
| 76 | - | } | |
| 77 | - | </style> ⏎̸ | |
| 74 | + | button { | |
| 75 | + | padding: 0.5rem 1rem; | |
| 76 | + | font-size: 1rem; | |
| 77 | + | cursor: pointer; | |
| 78 | + | } | |
| 79 | + | button:disabled { | |
| 80 | + | cursor: not-allowed; | |
| 81 | + | opacity: 0.7; | |
| 82 | + | } | |
| 83 | + | </style> | |
M src/lib/components/ui/loading-dots.svelte +42 -42
| @@ -1,50 +1,50 @@ | |||
| 1 | 1 | <!-- A reusable loading dots component with animated dots --> | |
| 2 | 2 | <script lang="ts"> | |
| 3 | - | let { text = 'Chargement', color = 'inherit' } = $props(); | |
| 3 | + | let { text = 'Chargement', color = 'inherit' } = $props(); | |
| 4 | 4 | </script> | |
| 5 | 5 | ||
| 6 | 6 | <div class="loading-text" style="color: {color}"> | |
| 7 | - | {text}<span class="dots"><span>.</span><span>.</span><span>.</span></span> | |
| 7 | + | {text}<span class="dots"><span>.</span><span>.</span><span>.</span></span> | |
| 8 | 8 | </div> | |
| 9 | 9 | ||
| 10 | 10 | <style> | |
| 11 | - | .loading-text { | |
| 12 | - | display: inline-flex; | |
| 13 | - | align-items: center; | |
| 14 | - | font-size: inherit; | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | .dots { | |
| 18 | - | display: inline-flex; | |
| 19 | - | margin-left: 2px; | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | .dots span { | |
| 23 | - | animation: dots 1.5s infinite; | |
| 24 | - | opacity: 0; | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | .dots span:nth-child(1) { | |
| 28 | - | animation-delay: 0s; | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | .dots span:nth-child(2) { | |
| 32 | - | animation-delay: 0.5s; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | .dots span:nth-child(3) { | |
| 36 | - | animation-delay: 1s; | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | @keyframes dots { | |
| 40 | - | 0% { | |
| 41 | - | opacity: 0; | |
| 42 | - | } | |
| 43 | - | 50% { | |
| 44 | - | opacity: 1; | |
| 45 | - | } | |
| 46 | - | 100% { | |
| 47 | - | opacity: 0; | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | </style> ⏎̸ | |
| 11 | + | .loading-text { | |
| 12 | + | display: inline-flex; | |
| 13 | + | align-items: center; | |
| 14 | + | font-size: inherit; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | .dots { | |
| 18 | + | display: inline-flex; | |
| 19 | + | margin-left: 2px; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | .dots span { | |
| 23 | + | animation: dots 1.5s infinite; | |
| 24 | + | opacity: 0; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | .dots span:nth-child(1) { | |
| 28 | + | animation-delay: 0s; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .dots span:nth-child(2) { | |
| 32 | + | animation-delay: 0.5s; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | .dots span:nth-child(3) { | |
| 36 | + | animation-delay: 1s; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | @keyframes dots { | |
| 40 | + | 0% { | |
| 41 | + | opacity: 0; | |
| 42 | + | } | |
| 43 | + | 50% { | |
| 44 | + | opacity: 1; | |
| 45 | + | } | |
| 46 | + | 100% { | |
| 47 | + | opacity: 0; | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | </style> | |
M src/lib/components/ui/tooltip.svelte +190 -190
| @@ -1,197 +1,197 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | type $$Props = { | |
| 3 | - | data: { | |
| 4 | - | text: string, | |
| 5 | - | direction?: 'top' | 'bottom' | 'left' | 'right' | |
| 6 | - | } | |
| 7 | - | }; | |
| 8 | - | ||
| 9 | - | let { children, data } = $props(); | |
| 10 | - | ||
| 11 | - | // Derive values from data with defaults | |
| 12 | - | let text = $derived(data.text); | |
| 13 | - | let direction = $derived(data.direction ?? 'top'); | |
| 14 | - | ||
| 15 | - | let wrapper: HTMLButtonElement; | |
| 16 | - | let tooltipDiv = $state<HTMLDivElement | null>(null); | |
| 17 | - | let coords = { x: 0, y: 0 }; | |
| 18 | - | ||
| 19 | - | function updateCoords() { | |
| 20 | - | if (wrapper) { | |
| 21 | - | const rect = wrapper.getBoundingClientRect(); | |
| 22 | - | if (direction === 'top') { | |
| 23 | - | coords = { x: rect.left + rect.width / 2, y: rect.top - 8 }; | |
| 24 | - | } else if (direction === 'bottom') { | |
| 25 | - | coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 }; | |
| 26 | - | } else if (direction === 'left') { | |
| 27 | - | coords = { x: rect.left - 8, y: rect.top + rect.height / 2 }; | |
| 28 | - | } else if (direction === 'right') { | |
| 29 | - | coords = { x: rect.right + 8, y: rect.top + rect.height / 2 }; | |
| 30 | - | } | |
| 31 | - | } | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | function showTooltip() { | |
| 35 | - | updateCoords(); | |
| 36 | - | if (tooltipDiv) return; // Prevent multiple tooltips | |
| 37 | - | tooltipDiv = document.createElement('div'); | |
| 38 | - | tooltipDiv.className = `tooltip ${direction}`; | |
| 39 | - | tooltipDiv.style.position = 'fixed'; | |
| 40 | - | tooltipDiv.style.zIndex = '1000000'; | |
| 41 | - | tooltipDiv.textContent = text; | |
| 42 | - | tooltipDiv.setAttribute('role', 'tooltip'); | |
| 43 | - | tooltipDiv.setAttribute('aria-hidden', 'false'); | |
| 44 | - | document.body.appendChild(tooltipDiv); | |
| 45 | - | ||
| 46 | - | const tooltipRect = tooltipDiv.getBoundingClientRect(); | |
| 47 | - | let finalLeft = coords.x; | |
| 48 | - | let finalTop = coords.y; | |
| 49 | - | if (direction === 'top') { | |
| 50 | - | finalLeft = coords.x - tooltipRect.width / 2; | |
| 51 | - | finalTop = coords.y - tooltipRect.height; | |
| 52 | - | } else if (direction === 'bottom') { | |
| 53 | - | finalLeft = coords.x - tooltipRect.width / 2; | |
| 54 | - | finalTop = coords.y; | |
| 55 | - | } else if (direction === 'left') { | |
| 56 | - | finalLeft = coords.x - tooltipRect.width; | |
| 57 | - | finalTop = coords.y - tooltipRect.height / 2; | |
| 58 | - | } else if (direction === 'right') { | |
| 59 | - | finalLeft = coords.x; | |
| 60 | - | finalTop = coords.y - tooltipRect.height / 2; | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | if (finalLeft < 0) { | |
| 64 | - | finalLeft = 0; | |
| 65 | - | } else if (finalLeft + tooltipRect.width > window.innerWidth) { | |
| 66 | - | finalLeft = window.innerWidth - tooltipRect.width; | |
| 67 | - | } | |
| 68 | - | if (finalTop < 0) { | |
| 69 | - | finalTop = 0; | |
| 70 | - | } else if (finalTop + tooltipRect.height > window.innerHeight) { | |
| 71 | - | finalTop = window.innerHeight - tooltipRect.height; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | tooltipDiv.style.left = `${finalLeft}px`; | |
| 75 | - | tooltipDiv.style.top = `${finalTop}px`; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | function hideTooltip() { | |
| 79 | - | if (tooltipDiv && tooltipDiv.parentNode) { | |
| 80 | - | tooltipDiv.parentNode.removeChild(tooltipDiv); | |
| 81 | - | tooltipDiv = null; | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | function handleKeyDown(e: KeyboardEvent) { | |
| 86 | - | if (e.key === 'Escape') { | |
| 87 | - | hideTooltip(); | |
| 88 | - | } | |
| 89 | - | } | |
| 2 | + | type $$Props = { | |
| 3 | + | data: { | |
| 4 | + | text: string; | |
| 5 | + | direction?: 'top' | 'bottom' | 'left' | 'right'; | |
| 6 | + | }; | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | let { children, data } = $props(); | |
| 10 | + | ||
| 11 | + | // Derive values from data with defaults | |
| 12 | + | let text = $derived(data.text); | |
| 13 | + | let direction = $derived(data.direction ?? 'top'); | |
| 14 | + | ||
| 15 | + | let wrapper: HTMLButtonElement; | |
| 16 | + | let tooltipDiv = $state<HTMLDivElement | null>(null); | |
| 17 | + | let coords = { x: 0, y: 0 }; | |
| 18 | + | ||
| 19 | + | function updateCoords() { | |
| 20 | + | if (wrapper) { | |
| 21 | + | const rect = wrapper.getBoundingClientRect(); | |
| 22 | + | if (direction === 'top') { | |
| 23 | + | coords = { x: rect.left + rect.width / 2, y: rect.top - 8 }; | |
| 24 | + | } else if (direction === 'bottom') { | |
| 25 | + | coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 }; | |
| 26 | + | } else if (direction === 'left') { | |
| 27 | + | coords = { x: rect.left - 8, y: rect.top + rect.height / 2 }; | |
| 28 | + | } else if (direction === 'right') { | |
| 29 | + | coords = { x: rect.right + 8, y: rect.top + rect.height / 2 }; | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function showTooltip() { | |
| 35 | + | updateCoords(); | |
| 36 | + | if (tooltipDiv) return; // Prevent multiple tooltips | |
| 37 | + | tooltipDiv = document.createElement('div'); | |
| 38 | + | tooltipDiv.className = `tooltip ${direction}`; | |
| 39 | + | tooltipDiv.style.position = 'fixed'; | |
| 40 | + | tooltipDiv.style.zIndex = '1000000'; | |
| 41 | + | tooltipDiv.textContent = text; | |
| 42 | + | tooltipDiv.setAttribute('role', 'tooltip'); | |
| 43 | + | tooltipDiv.setAttribute('aria-hidden', 'false'); | |
| 44 | + | document.body.appendChild(tooltipDiv); | |
| 45 | + | ||
| 46 | + | const tooltipRect = tooltipDiv.getBoundingClientRect(); | |
| 47 | + | let finalLeft = coords.x; | |
| 48 | + | let finalTop = coords.y; | |
| 49 | + | if (direction === 'top') { | |
| 50 | + | finalLeft = coords.x - tooltipRect.width / 2; | |
| 51 | + | finalTop = coords.y - tooltipRect.height; | |
| 52 | + | } else if (direction === 'bottom') { | |
| 53 | + | finalLeft = coords.x - tooltipRect.width / 2; | |
| 54 | + | finalTop = coords.y; | |
| 55 | + | } else if (direction === 'left') { | |
| 56 | + | finalLeft = coords.x - tooltipRect.width; | |
| 57 | + | finalTop = coords.y - tooltipRect.height / 2; | |
| 58 | + | } else if (direction === 'right') { | |
| 59 | + | finalLeft = coords.x; | |
| 60 | + | finalTop = coords.y - tooltipRect.height / 2; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if (finalLeft < 0) { | |
| 64 | + | finalLeft = 0; | |
| 65 | + | } else if (finalLeft + tooltipRect.width > window.innerWidth) { | |
| 66 | + | finalLeft = window.innerWidth - tooltipRect.width; | |
| 67 | + | } | |
| 68 | + | if (finalTop < 0) { | |
| 69 | + | finalTop = 0; | |
| 70 | + | } else if (finalTop + tooltipRect.height > window.innerHeight) { | |
| 71 | + | finalTop = window.innerHeight - tooltipRect.height; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | tooltipDiv.style.left = `${finalLeft}px`; | |
| 75 | + | tooltipDiv.style.top = `${finalTop}px`; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | function hideTooltip() { | |
| 79 | + | if (tooltipDiv && tooltipDiv.parentNode) { | |
| 80 | + | tooltipDiv.parentNode.removeChild(tooltipDiv); | |
| 81 | + | tooltipDiv = null; | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | function handleKeyDown(e: KeyboardEvent) { | |
| 86 | + | if (e.key === 'Escape') { | |
| 87 | + | hideTooltip(); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | 90 | </script> | |
| 91 | 91 | ||
| 92 | - | <button | |
| 93 | - | class="tooltip-wrapper" | |
| 94 | - | bind:this={wrapper} | |
| 95 | - | onmouseover={showTooltip} | |
| 96 | - | onmouseout={hideTooltip} | |
| 97 | - | onfocus={showTooltip} | |
| 98 | - | onblur={hideTooltip} | |
| 99 | - | onkeydown={handleKeyDown} | |
| 100 | - | type="button" | |
| 101 | - | aria-describedby={tooltipDiv?.id} | |
| 92 | + | <button | |
| 93 | + | class="tooltip-wrapper" | |
| 94 | + | bind:this={wrapper} | |
| 95 | + | onmouseover={showTooltip} | |
| 96 | + | onmouseout={hideTooltip} | |
| 97 | + | onfocus={showTooltip} | |
| 98 | + | onblur={hideTooltip} | |
| 99 | + | onkeydown={handleKeyDown} | |
| 100 | + | type="button" | |
| 101 | + | aria-describedby={tooltipDiv?.id} | |
| 102 | 102 | > | |
| 103 | - | {@render children()} | |
| 103 | + | {@render children()} | |
| 104 | 104 | </button> | |
| 105 | 105 | ||
| 106 | 106 | <style> | |
| 107 | - | .tooltip-wrapper { | |
| 108 | - | display: inline; | |
| 109 | - | width: auto; | |
| 110 | - | min-width: 0; | |
| 111 | - | background: none !important; | |
| 112 | - | border: none !important; | |
| 113 | - | padding: 0 !important; | |
| 114 | - | margin: 0 !important; | |
| 115 | - | font: inherit !important; | |
| 116 | - | color: inherit !important; | |
| 117 | - | cursor: inherit !important; | |
| 118 | - | text-align: inherit !important; | |
| 119 | - | box-shadow: none !important; | |
| 120 | - | text-shadow: inherit !important; | |
| 121 | - | border-radius: 0 !important; | |
| 122 | - | -webkit-appearance: none !important; | |
| 123 | - | appearance: none !important; | |
| 124 | - | white-space: pre; | |
| 125 | - | } | |
| 126 | - | .tooltip-wrapper:hover, | |
| 127 | - | .tooltip-wrapper:active { | |
| 128 | - | background: none !important; | |
| 129 | - | border: none !important; | |
| 130 | - | box-shadow: none !important; | |
| 131 | - | } | |
| 132 | - | .tooltip-wrapper:focus { | |
| 133 | - | outline: none !important; | |
| 134 | - | box-shadow: none !important; | |
| 135 | - | } | |
| 136 | - | .tooltip-wrapper:focus-visible { | |
| 137 | - | outline: 2px solid #003C74 !important; | |
| 138 | - | outline-offset: 2px !important; | |
| 139 | - | } | |
| 140 | - | /* Use :global for styles that need to apply to elements outside the component */ | |
| 141 | - | :global(.tooltip) { | |
| 142 | - | white-space: nowrap; | |
| 143 | - | padding: 0.5rem; | |
| 144 | - | background: #ECE9D8; | |
| 145 | - | color: #000; | |
| 146 | - | border: 1px solid #003C74; | |
| 147 | - | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); | |
| 148 | - | border-radius: 2px; | |
| 149 | - | font-size: 0.875rem; | |
| 150 | - | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | /* Direction-specific styles for the tooltip arrow */ | |
| 154 | - | :global(.tooltip.top)::before { | |
| 155 | - | content: ''; | |
| 156 | - | position: absolute; | |
| 157 | - | bottom: -6px; | |
| 158 | - | left: 50%; | |
| 159 | - | transform: translateX(-50%); | |
| 160 | - | border-left: 6px solid transparent; | |
| 161 | - | border-right: 6px solid transparent; | |
| 162 | - | border-top: 6px solid #003C74; | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | :global(.tooltip.bottom)::before { | |
| 166 | - | content: ''; | |
| 167 | - | position: absolute; | |
| 168 | - | top: -6px; | |
| 169 | - | left: 50%; | |
| 170 | - | transform: translateX(-50%); | |
| 171 | - | border-left: 6px solid transparent; | |
| 172 | - | border-right: 6px solid transparent; | |
| 173 | - | border-bottom: 6px solid #003C74; | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | :global(.tooltip.left)::before { | |
| 177 | - | content: ''; | |
| 178 | - | position: absolute; | |
| 179 | - | right: -6px; | |
| 180 | - | top: 50%; | |
| 181 | - | transform: translateY(-50%); | |
| 182 | - | border-top: 6px solid transparent; | |
| 183 | - | border-bottom: 6px solid transparent; | |
| 184 | - | border-left: 6px solid #003C74; | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | :global(.tooltip.right)::before { | |
| 188 | - | content: ''; | |
| 189 | - | position: absolute; | |
| 190 | - | left: -6px; | |
| 191 | - | top: 50%; | |
| 192 | - | transform: translateY(-50%); | |
| 193 | - | border-top: 6px solid transparent; | |
| 194 | - | border-bottom: 6px solid transparent; | |
| 195 | - | border-right: 6px solid #003C74; | |
| 196 | - | } | |
| 197 | - | </style>⏎̸ | |
| 107 | + | .tooltip-wrapper { | |
| 108 | + | display: inline; | |
| 109 | + | width: auto; | |
| 110 | + | min-width: 0; | |
| 111 | + | background: none !important; | |
| 112 | + | border: none !important; | |
| 113 | + | padding: 0 !important; | |
| 114 | + | margin: 0 !important; | |
| 115 | + | font: inherit !important; | |
| 116 | + | color: inherit !important; | |
| 117 | + | cursor: inherit !important; | |
| 118 | + | text-align: inherit !important; | |
| 119 | + | box-shadow: none !important; | |
| 120 | + | text-shadow: inherit !important; | |
| 121 | + | border-radius: 0 !important; | |
| 122 | + | -webkit-appearance: none !important; | |
| 123 | + | appearance: none !important; | |
| 124 | + | white-space: pre; | |
| 125 | + | } | |
| 126 | + | .tooltip-wrapper:hover, | |
| 127 | + | .tooltip-wrapper:active { | |
| 128 | + | background: none !important; | |
| 129 | + | border: none !important; | |
| 130 | + | box-shadow: none !important; | |
| 131 | + | } | |
| 132 | + | .tooltip-wrapper:focus { | |
| 133 | + | outline: none !important; | |
| 134 | + | box-shadow: none !important; | |
| 135 | + | } | |
| 136 | + | .tooltip-wrapper:focus-visible { | |
| 137 | + | outline: 2px solid #003c74 !important; | |
| 138 | + | outline-offset: 2px !important; | |
| 139 | + | } | |
| 140 | + | /* Use :global for styles that need to apply to elements outside the component */ | |
| 141 | + | :global(.tooltip) { | |
| 142 | + | white-space: nowrap; | |
| 143 | + | padding: 0.5rem; | |
| 144 | + | background: #ece9d8; | |
| 145 | + | color: #000; | |
| 146 | + | border: 1px solid #003c74; | |
| 147 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); | |
| 148 | + | border-radius: 2px; | |
| 149 | + | font-size: 0.875rem; | |
| 150 | + | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /* Direction-specific styles for the tooltip arrow */ | |
| 154 | + | :global(.tooltip.top)::before { | |
| 155 | + | content: ''; | |
| 156 | + | position: absolute; | |
| 157 | + | bottom: -6px; | |
| 158 | + | left: 50%; | |
| 159 | + | transform: translateX(-50%); | |
| 160 | + | border-left: 6px solid transparent; | |
| 161 | + | border-right: 6px solid transparent; | |
| 162 | + | border-top: 6px solid #003c74; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | :global(.tooltip.bottom)::before { | |
| 166 | + | content: ''; | |
| 167 | + | position: absolute; | |
| 168 | + | top: -6px; | |
| 169 | + | left: 50%; | |
| 170 | + | transform: translateX(-50%); | |
| 171 | + | border-left: 6px solid transparent; | |
| 172 | + | border-right: 6px solid transparent; | |
| 173 | + | border-bottom: 6px solid #003c74; | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | :global(.tooltip.left)::before { | |
| 177 | + | content: ''; | |
| 178 | + | position: absolute; | |
| 179 | + | right: -6px; | |
| 180 | + | top: 50%; | |
| 181 | + | transform: translateY(-50%); | |
| 182 | + | border-top: 6px solid transparent; | |
| 183 | + | border-bottom: 6px solid transparent; | |
| 184 | + | border-left: 6px solid #003c74; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | :global(.tooltip.right)::before { | |
| 188 | + | content: ''; | |
| 189 | + | position: absolute; | |
| 190 | + | left: -6px; | |
| 191 | + | top: 50%; | |
| 192 | + | transform: translateY(-50%); | |
| 193 | + | border-top: 6px solid transparent; | |
| 194 | + | border-bottom: 6px solid transparent; | |
| 195 | + | border-right: 6px solid #003c74; | |
| 196 | + | } | |
| 197 | + | </style> | |
M src/lib/components/user-preferences-modal.svelte +469 -485
| @@ -1,492 +1,476 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { onMount } from 'svelte'; | |
| 3 | - | import TextStyleSelector from './text-style-selector.svelte'; | |
| 4 | - | import { | |
| 5 | - | DEFAULT_USER_PREFERENCES, | |
| 6 | - | DEFAULT_TEXT_STYLE, | |
| 7 | - | type UserTextPreferences, | |
| 8 | - | type TextStyle | |
| 9 | - | } from '../types/text-formatting'; | |
| 10 | - | ||
| 11 | - | // Props | |
| 12 | - | let { | |
| 13 | - | showModal = $bindable(false), | |
| 14 | - | onSave | |
| 15 | - | } = $props<{ | |
| 16 | - | showModal: boolean; | |
| 17 | - | onSave?: (preferences: UserTextPreferences) => void; | |
| 18 | - | }>(); | |
| 19 | - | ||
| 20 | - | // State | |
| 21 | - | let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES }); | |
| 22 | - | let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 23 | - | let isSaving = $state(false); | |
| 24 | - | let hasChanges = $state(false); | |
| 25 | - | ||
| 26 | - | // Track changes | |
| 27 | - | $effect(() => { | |
| 28 | - | hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES); | |
| 29 | - | }); | |
| 30 | - | ||
| 31 | - | // Update preferences when style changes | |
| 32 | - | $effect(() => { | |
| 33 | - | preferences.defaultStyle = { ...currentStyle }; | |
| 34 | - | }); | |
| 35 | - | ||
| 36 | - | // Load user preferences on mount | |
| 37 | - | onMount(async () => { | |
| 38 | - | try { | |
| 39 | - | const response = await fetch('/api/user/text-preferences'); | |
| 40 | - | if (response.ok) { | |
| 41 | - | const data = await response.json(); | |
| 42 | - | if (data.success && data.preferences) { | |
| 43 | - | preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences }; | |
| 44 | - | currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle }; | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | } catch (error) { | |
| 48 | - | console.error('Failed to load user preferences:', error); | |
| 49 | - | } | |
| 50 | - | }); | |
| 51 | - | ||
| 52 | - | // Save preferences | |
| 53 | - | async function savePreferences() { | |
| 54 | - | if (!onSave) return; | |
| 55 | - | ||
| 56 | - | isSaving = true; | |
| 57 | - | try { | |
| 58 | - | await onSave(preferences); | |
| 59 | - | showModal = false; | |
| 60 | - | } catch (error) { | |
| 61 | - | console.error('Failed to save preferences:', error); | |
| 62 | - | alert('Failed to save preferences. Please try again.'); | |
| 63 | - | } finally { | |
| 64 | - | isSaving = false; | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Reset to defaults | |
| 69 | - | function resetToDefaults() { | |
| 70 | - | preferences = { ...DEFAULT_USER_PREFERENCES }; | |
| 71 | - | currentStyle = { ...DEFAULT_TEXT_STYLE }; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // Cancel changes | |
| 75 | - | function cancel() { | |
| 76 | - | showModal = false; | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Handle keyboard shortcuts | |
| 80 | - | function handleKeydown(event: KeyboardEvent) { | |
| 81 | - | if (event.key === 'Escape') { | |
| 82 | - | cancel(); | |
| 83 | - | } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { | |
| 84 | - | savePreferences(); | |
| 85 | - | } | |
| 86 | - | } | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import TextStyleSelector from './text-style-selector.svelte'; | |
| 4 | + | import { | |
| 5 | + | DEFAULT_USER_PREFERENCES, | |
| 6 | + | DEFAULT_TEXT_STYLE, | |
| 7 | + | type UserTextPreferences, | |
| 8 | + | type TextStyle | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | ||
| 11 | + | // Props | |
| 12 | + | let { showModal = $bindable(false), onSave } = $props<{ | |
| 13 | + | showModal: boolean; | |
| 14 | + | onSave?: (preferences: UserTextPreferences) => void; | |
| 15 | + | }>(); | |
| 16 | + | ||
| 17 | + | // State | |
| 18 | + | let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES }); | |
| 19 | + | let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 20 | + | let isSaving = $state(false); | |
| 21 | + | let hasChanges = $state(false); | |
| 22 | + | ||
| 23 | + | // Track changes | |
| 24 | + | $effect(() => { | |
| 25 | + | hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | // Update preferences when style changes | |
| 29 | + | $effect(() => { | |
| 30 | + | preferences.defaultStyle = { ...currentStyle }; | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | // Load user preferences on mount | |
| 34 | + | onMount(async () => { | |
| 35 | + | try { | |
| 36 | + | const response = await fetch('/api/user/text-preferences'); | |
| 37 | + | if (response.ok) { | |
| 38 | + | const data = await response.json(); | |
| 39 | + | if (data.success && data.preferences) { | |
| 40 | + | preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences }; | |
| 41 | + | currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle }; | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } catch (error) { | |
| 45 | + | console.error('Failed to load user preferences:', error); | |
| 46 | + | } | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | // Save preferences | |
| 50 | + | async function savePreferences() { | |
| 51 | + | if (!onSave) return; | |
| 52 | + | ||
| 53 | + | isSaving = true; | |
| 54 | + | try { | |
| 55 | + | await onSave(preferences); | |
| 56 | + | showModal = false; | |
| 57 | + | } catch (error) { | |
| 58 | + | console.error('Failed to save preferences:', error); | |
| 59 | + | alert('Failed to save preferences. Please try again.'); | |
| 60 | + | } finally { | |
| 61 | + | isSaving = false; | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Reset to defaults | |
| 66 | + | function resetToDefaults() { | |
| 67 | + | preferences = { ...DEFAULT_USER_PREFERENCES }; | |
| 68 | + | currentStyle = { ...DEFAULT_TEXT_STYLE }; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Cancel changes | |
| 72 | + | function cancel() { | |
| 73 | + | showModal = false; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Handle keyboard shortcuts | |
| 77 | + | function handleKeydown(event: KeyboardEvent) { | |
| 78 | + | if (event.key === 'Escape') { | |
| 79 | + | cancel(); | |
| 80 | + | } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { | |
| 81 | + | savePreferences(); | |
| 82 | + | } | |
| 83 | + | } | |
| 87 | 84 | </script> | |
| 88 | 85 | ||
| 89 | 86 | {#if showModal} | |
| 90 | - | <!-- Modal backdrop --> | |
| 91 | - | <div | |
| 92 | - | class="modal-backdrop" | |
| 93 | - | onclick={cancel} | |
| 94 | - | onkeydown={handleKeydown} | |
| 95 | - | role="dialog" | |
| 96 | - | aria-modal="true" | |
| 97 | - | aria-labelledby="preferences-title" | |
| 98 | - | > | |
| 99 | - | <!-- Modal content --> | |
| 100 | - | <div class="modal-window window" onclick={(e) => e.stopPropagation()}> | |
| 101 | - | <div class="title-bar"> | |
| 102 | - | <div class="title-bar-text" id="preferences-title"> | |
| 103 | - | Text Formatting Preferences | |
| 104 | - | </div> | |
| 105 | - | <div class="title-bar-controls"> | |
| 106 | - | <button onclick={cancel} aria-label="Close"></button> | |
| 107 | - | </div> | |
| 108 | - | </div> | |
| 109 | - | ||
| 110 | - | <div class="window-body"> | |
| 111 | - | <div class="preferences-content"> | |
| 112 | - | ||
| 113 | - | <!-- Enable/Disable Formatting --> | |
| 114 | - | <div class="preference-group"> | |
| 115 | - | <label class="checkbox-label"> | |
| 116 | - | <input | |
| 117 | - | type="checkbox" | |
| 118 | - | bind:checked={preferences.allowFormatting} | |
| 119 | - | /> | |
| 120 | - | Enable text formatting in messages | |
| 121 | - | </label> | |
| 122 | - | <small class="help-text"> | |
| 123 | - | When enabled, you can use formatting options like bold, italic, colors, and gradients. | |
| 124 | - | </small> | |
| 125 | - | </div> | |
| 126 | - | ||
| 127 | - | <!-- Default Text Style --> | |
| 128 | - | <div class="preference-group"> | |
| 129 | - | <h3>Default Text Style</h3> | |
| 130 | - | <p class="description"> | |
| 131 | - | These settings will be used as defaults when you send new messages. | |
| 132 | - | </p> | |
| 133 | - | ||
| 134 | - | <TextStyleSelector | |
| 135 | - | bind:style={currentStyle} | |
| 136 | - | previewText="This is how your messages will look by default" | |
| 137 | - | showPreview={true} | |
| 138 | - | compact={false} | |
| 139 | - | /> | |
| 140 | - | </div> | |
| 141 | - | ||
| 142 | - | <!-- Message Settings --> | |
| 143 | - | <div class="preference-group"> | |
| 144 | - | <h3>Message Settings</h3> | |
| 145 | - | ||
| 146 | - | <div class="setting-row"> | |
| 147 | - | <label for="max-length">Maximum message length:</label> | |
| 148 | - | <input | |
| 149 | - | id="max-length" | |
| 150 | - | type="number" | |
| 151 | - | min="100" | |
| 152 | - | max="2000" | |
| 153 | - | bind:value={preferences.maxMessageLength} | |
| 154 | - | class="length-input" | |
| 155 | - | /> | |
| 156 | - | <span class="unit">characters</span> | |
| 157 | - | </div> | |
| 158 | - | ||
| 159 | - | <small class="help-text"> | |
| 160 | - | Longer messages may take more time to process and display. | |
| 161 | - | </small> | |
| 162 | - | </div> | |
| 163 | - | ||
| 164 | - | <!-- Format Help --> | |
| 165 | - | <div class="preference-group"> | |
| 166 | - | <details class="format-help"> | |
| 167 | - | <summary>Formatting Guide</summary> | |
| 168 | - | <div class="format-examples"> | |
| 169 | - | <h4>BBCode Style:</h4> | |
| 170 | - | <ul> | |
| 171 | - | <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li> | |
| 172 | - | <li><code>[i]italic text[/i]</code> → <em>italic text</em></li> | |
| 173 | - | <li><code>[u]underlined[/u]</code> → <u>underlined</u></li> | |
| 174 | - | <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li> | |
| 175 | - | <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li> | |
| 176 | - | </ul> | |
| 177 | - | ||
| 178 | - | <h4>Markdown Style:</h4> | |
| 179 | - | <ul> | |
| 180 | - | <li><code>**bold text**</code> → <strong>bold text</strong></li> | |
| 181 | - | <li><code>*italic text*</code> → <em>italic text</em></li> | |
| 182 | - | <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li> | |
| 183 | - | <li><code>`code text`</code> → <code>code text</code></li> | |
| 184 | - | </ul> | |
| 185 | - | </div> | |
| 186 | - | </details> | |
| 187 | - | </div> | |
| 188 | - | ||
| 189 | - | </div> | |
| 190 | - | ||
| 191 | - | <!-- Modal buttons --> | |
| 192 | - | <div class="modal-buttons"> | |
| 193 | - | <button | |
| 194 | - | class="reset-button" | |
| 195 | - | onclick={resetToDefaults} | |
| 196 | - | disabled={isSaving} | |
| 197 | - | > | |
| 198 | - | Reset to Defaults | |
| 199 | - | </button> | |
| 200 | - | ||
| 201 | - | <div class="main-buttons"> | |
| 202 | - | <button | |
| 203 | - | onclick={cancel} | |
| 204 | - | disabled={isSaving} | |
| 205 | - | > | |
| 206 | - | Cancel | |
| 207 | - | </button> | |
| 208 | - | ||
| 209 | - | <button | |
| 210 | - | class="save-button" | |
| 211 | - | onclick={savePreferences} | |
| 212 | - | disabled={isSaving || !hasChanges} | |
| 213 | - | > | |
| 214 | - | {#if isSaving} | |
| 215 | - | Saving... | |
| 216 | - | {:else} | |
| 217 | - | Save Changes | |
| 218 | - | {/if} | |
| 219 | - | </button> | |
| 220 | - | </div> | |
| 221 | - | </div> | |
| 222 | - | </div> | |
| 223 | - | </div> | |
| 224 | - | </div> | |
| 87 | + | <!-- Modal backdrop --> | |
| 88 | + | <div | |
| 89 | + | class="modal-backdrop" | |
| 90 | + | onclick={cancel} | |
| 91 | + | onkeydown={handleKeydown} | |
| 92 | + | role="dialog" | |
| 93 | + | aria-modal="true" | |
| 94 | + | aria-labelledby="preferences-title" | |
| 95 | + | > | |
| 96 | + | <!-- Modal content --> | |
| 97 | + | <div class="modal-window window" onclick={(e) => e.stopPropagation()}> | |
| 98 | + | <div class="title-bar"> | |
| 99 | + | <div class="title-bar-text" id="preferences-title">Text Formatting Preferences</div> | |
| 100 | + | <div class="title-bar-controls"> | |
| 101 | + | <button onclick={cancel} aria-label="Close"></button> | |
| 102 | + | </div> | |
| 103 | + | </div> | |
| 104 | + | ||
| 105 | + | <div class="window-body"> | |
| 106 | + | <div class="preferences-content"> | |
| 107 | + | <!-- Enable/Disable Formatting --> | |
| 108 | + | <div class="preference-group"> | |
| 109 | + | <label class="checkbox-label"> | |
| 110 | + | <input type="checkbox" bind:checked={preferences.allowFormatting} /> | |
| 111 | + | Enable text formatting in messages | |
| 112 | + | </label> | |
| 113 | + | <small class="help-text"> | |
| 114 | + | When enabled, you can use formatting options like bold, italic, colors, and gradients. | |
| 115 | + | </small> | |
| 116 | + | </div> | |
| 117 | + | ||
| 118 | + | <!-- Default Text Style --> | |
| 119 | + | <div class="preference-group"> | |
| 120 | + | <h3>Default Text Style</h3> | |
| 121 | + | <p class="description"> | |
| 122 | + | These settings will be used as defaults when you send new messages. | |
| 123 | + | </p> | |
| 124 | + | ||
| 125 | + | <TextStyleSelector | |
| 126 | + | bind:style={currentStyle} | |
| 127 | + | previewText="This is how your messages will look by default" | |
| 128 | + | showPreview={true} | |
| 129 | + | compact={false} | |
| 130 | + | /> | |
| 131 | + | </div> | |
| 132 | + | ||
| 133 | + | <!-- Message Settings --> | |
| 134 | + | <div class="preference-group"> | |
| 135 | + | <h3>Message Settings</h3> | |
| 136 | + | ||
| 137 | + | <div class="setting-row"> | |
| 138 | + | <label for="max-length">Maximum message length:</label> | |
| 139 | + | <input | |
| 140 | + | id="max-length" | |
| 141 | + | type="number" | |
| 142 | + | min="100" | |
| 143 | + | max="2000" | |
| 144 | + | bind:value={preferences.maxMessageLength} | |
| 145 | + | class="length-input" | |
| 146 | + | /> | |
| 147 | + | <span class="unit">characters</span> | |
| 148 | + | </div> | |
| 149 | + | ||
| 150 | + | <small class="help-text"> | |
| 151 | + | Longer messages may take more time to process and display. | |
| 152 | + | </small> | |
| 153 | + | </div> | |
| 154 | + | ||
| 155 | + | <!-- Format Help --> | |
| 156 | + | <div class="preference-group"> | |
| 157 | + | <details class="format-help"> | |
| 158 | + | <summary>Formatting Guide</summary> | |
| 159 | + | <div class="format-examples"> | |
| 160 | + | <h4>BBCode Style:</h4> | |
| 161 | + | <ul> | |
| 162 | + | <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li> | |
| 163 | + | <li><code>[i]italic text[/i]</code> → <em>italic text</em></li> | |
| 164 | + | <li><code>[u]underlined[/u]</code> → <u>underlined</u></li> | |
| 165 | + | <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li> | |
| 166 | + | <li> | |
| 167 | + | <code>[color=red]red text[/color]</code> → | |
| 168 | + | <span style="color: red">red text</span> | |
| 169 | + | </li> | |
| 170 | + | </ul> | |
| 171 | + | ||
| 172 | + | <h4>Markdown Style:</h4> | |
| 173 | + | <ul> | |
| 174 | + | <li><code>**bold text**</code> → <strong>bold text</strong></li> | |
| 175 | + | <li><code>*italic text*</code> → <em>italic text</em></li> | |
| 176 | + | <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li> | |
| 177 | + | <li><code>`code text`</code> → <code>code text</code></li> | |
| 178 | + | </ul> | |
| 179 | + | </div> | |
| 180 | + | </details> | |
| 181 | + | </div> | |
| 182 | + | </div> | |
| 183 | + | ||
| 184 | + | <!-- Modal buttons --> | |
| 185 | + | <div class="modal-buttons"> | |
| 186 | + | <button class="reset-button" onclick={resetToDefaults} disabled={isSaving}> | |
| 187 | + | Reset to Defaults | |
| 188 | + | </button> | |
| 189 | + | ||
| 190 | + | <div class="main-buttons"> | |
| 191 | + | <button onclick={cancel} disabled={isSaving}> Cancel </button> | |
| 192 | + | ||
| 193 | + | <button | |
| 194 | + | class="save-button" | |
| 195 | + | onclick={savePreferences} | |
| 196 | + | disabled={isSaving || !hasChanges} | |
| 197 | + | > | |
| 198 | + | {#if isSaving} | |
| 199 | + | Saving... | |
| 200 | + | {:else} | |
| 201 | + | Save Changes | |
| 202 | + | {/if} | |
| 203 | + | </button> | |
| 204 | + | </div> | |
| 205 | + | </div> | |
| 206 | + | </div> | |
| 207 | + | </div> | |
| 208 | + | </div> | |
| 225 | 209 | {/if} | |
| 226 | 210 | ||
| 227 | 211 | <style> | |
| 228 | - | .modal-backdrop { | |
| 229 | - | position: fixed; | |
| 230 | - | top: 0; | |
| 231 | - | left: 0; | |
| 232 | - | right: 0; | |
| 233 | - | bottom: 0; | |
| 234 | - | background: rgba(0, 0, 0, 0.5); | |
| 235 | - | display: flex; | |
| 236 | - | align-items: center; | |
| 237 | - | justify-content: center; | |
| 238 | - | z-index: 1000; | |
| 239 | - | padding: 20px; | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | .modal-window { | |
| 243 | - | width: 100%; | |
| 244 | - | max-width: 600px; | |
| 245 | - | max-height: 90vh; | |
| 246 | - | overflow: hidden; | |
| 247 | - | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3); | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | .window-body { | |
| 251 | - | display: flex; | |
| 252 | - | flex-direction: column; | |
| 253 | - | height: 70vh; | |
| 254 | - | max-height: 600px; | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | .preferences-content { | |
| 258 | - | flex: 1; | |
| 259 | - | overflow-y: auto; | |
| 260 | - | padding: 16px; | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | .preference-group { | |
| 264 | - | margin-bottom: 24px; | |
| 265 | - | padding-bottom: 16px; | |
| 266 | - | border-bottom: 1px solid #ddd; | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | .preference-group:last-child { | |
| 270 | - | border-bottom: none; | |
| 271 | - | margin-bottom: 0; | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | .preference-group h3 { | |
| 275 | - | margin: 0 0 8px 0; | |
| 276 | - | font-size: 14px; | |
| 277 | - | font-weight: bold; | |
| 278 | - | color: #2d31a6; | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | .description { | |
| 282 | - | margin: 0 0 12px 0; | |
| 283 | - | font-size: 12px; | |
| 284 | - | color: #666; | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | .checkbox-label { | |
| 288 | - | display: flex; | |
| 289 | - | align-items: center; | |
| 290 | - | gap: 8px; | |
| 291 | - | font-size: 12px; | |
| 292 | - | cursor: pointer; | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | .help-text { | |
| 296 | - | display: block; | |
| 297 | - | margin-top: 4px; | |
| 298 | - | font-size: 11px; | |
| 299 | - | color: #666; | |
| 300 | - | font-style: italic; | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | .setting-row { | |
| 304 | - | display: flex; | |
| 305 | - | align-items: center; | |
| 306 | - | gap: 8px; | |
| 307 | - | margin-bottom: 8px; | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | .setting-row label { | |
| 311 | - | font-size: 12px; | |
| 312 | - | min-width: 140px; | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | .length-input { | |
| 316 | - | width: 80px; | |
| 317 | - | padding: 2px 4px; | |
| 318 | - | border: 1px inset #dfdfdf; | |
| 319 | - | background: white; | |
| 320 | - | font-size: 11px; | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | .unit { | |
| 324 | - | font-size: 11px; | |
| 325 | - | color: #666; | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | .format-help { | |
| 329 | - | margin-top: 8px; | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | .format-help summary { | |
| 333 | - | font-size: 12px; | |
| 334 | - | font-weight: bold; | |
| 335 | - | cursor: pointer; | |
| 336 | - | padding: 4px 0; | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | .format-examples { | |
| 340 | - | padding: 8px 0; | |
| 341 | - | font-size: 11px; | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | .format-examples h4 { | |
| 345 | - | margin: 12px 0 4px 0; | |
| 346 | - | font-size: 11px; | |
| 347 | - | color: #2d31a6; | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | .format-examples ul { | |
| 351 | - | margin: 0; | |
| 352 | - | padding-left: 16px; | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | .format-examples li { | |
| 356 | - | margin-bottom: 2px; | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | .format-examples code { | |
| 360 | - | background: #f0f0f0; | |
| 361 | - | padding: 1px 3px; | |
| 362 | - | border-radius: 2px; | |
| 363 | - | font-family: 'Courier New', monospace; | |
| 364 | - | font-size: 10px; | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | .modal-buttons { | |
| 368 | - | display: flex; | |
| 369 | - | justify-content: space-between; | |
| 370 | - | align-items: center; | |
| 371 | - | padding: 12px 16px; | |
| 372 | - | border-top: 1px solid #ddd; | |
| 373 | - | background: #f8f8f8; | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | .main-buttons { | |
| 377 | - | display: flex; | |
| 378 | - | gap: 8px; | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | .reset-button { | |
| 382 | - | padding: 4px 12px; | |
| 383 | - | font-size: 11px; | |
| 384 | - | border: 1px outset #dfdfdf; | |
| 385 | - | background: #f0f0f0; | |
| 386 | - | cursor: pointer; | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | .reset-button:hover { | |
| 390 | - | background: #e0e0e0; | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | .reset-button:disabled { | |
| 394 | - | opacity: 0.6; | |
| 395 | - | cursor: not-allowed; | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | .save-button { | |
| 399 | - | padding: 4px 16px; | |
| 400 | - | font-size: 11px; | |
| 401 | - | font-weight: bold; | |
| 402 | - | border: 1px outset #dfdfdf; | |
| 403 | - | background: #0078d4; | |
| 404 | - | color: white; | |
| 405 | - | cursor: pointer; | |
| 406 | - | } | |
| 407 | - | ||
| 408 | - | .save-button:hover:not(:disabled) { | |
| 409 | - | background: #106ebe; | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | .save-button:disabled { | |
| 413 | - | opacity: 0.6; | |
| 414 | - | cursor: not-allowed; | |
| 415 | - | background: #999; | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | button { | |
| 419 | - | padding: 4px 12px; | |
| 420 | - | font-size: 11px; | |
| 421 | - | border: 1px outset #dfdfdf; | |
| 422 | - | background: #f0f0f0; | |
| 423 | - | cursor: pointer; | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | button:hover:not(:disabled) { | |
| 427 | - | background: #e0e0e0; | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | button:disabled { | |
| 431 | - | opacity: 0.6; | |
| 432 | - | cursor: not-allowed; | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | /* Scrollbar styling */ | |
| 436 | - | .preferences-content::-webkit-scrollbar { | |
| 437 | - | width: 12px; | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | .preferences-content::-webkit-scrollbar-track { | |
| 441 | - | background: #f0f0f0; | |
| 442 | - | border: 1px inset #dfdfdf; | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | .preferences-content::-webkit-scrollbar-thumb { | |
| 446 | - | background: #c0c0c0; | |
| 447 | - | border: 1px outset #dfdfdf; | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | .preferences-content::-webkit-scrollbar-thumb:hover { | |
| 451 | - | background: #a0a0a0; | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | /* Mobile responsive */ | |
| 455 | - | @media (max-width: 768px) { | |
| 456 | - | .modal-backdrop { | |
| 457 | - | padding: 10px; | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | .modal-window { | |
| 461 | - | max-width: none; | |
| 462 | - | max-height: 95vh; | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | .window-body { | |
| 466 | - | height: 80vh; | |
| 467 | - | } | |
| 468 | - | ||
| 469 | - | .preferences-content { | |
| 470 | - | padding: 12px; | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | .setting-row { | |
| 474 | - | flex-direction: column; | |
| 475 | - | align-items: flex-start; | |
| 476 | - | gap: 4px; | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | .setting-row label { | |
| 480 | - | min-width: auto; | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | .modal-buttons { | |
| 484 | - | flex-direction: column; | |
| 485 | - | gap: 8px; | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | .main-buttons { | |
| 489 | - | order: -1; | |
| 490 | - | } | |
| 491 | - | } | |
| 492 | - | </style>⏎̸ | |
| 212 | + | .modal-backdrop { | |
| 213 | + | position: fixed; | |
| 214 | + | top: 0; | |
| 215 | + | left: 0; | |
| 216 | + | right: 0; | |
| 217 | + | bottom: 0; | |
| 218 | + | background: rgba(0, 0, 0, 0.5); | |
| 219 | + | display: flex; | |
| 220 | + | align-items: center; | |
| 221 | + | justify-content: center; | |
| 222 | + | z-index: 1000; | |
| 223 | + | padding: 20px; | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | .modal-window { | |
| 227 | + | width: 100%; | |
| 228 | + | max-width: 600px; | |
| 229 | + | max-height: 90vh; | |
| 230 | + | overflow: hidden; | |
| 231 | + | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | .window-body { | |
| 235 | + | display: flex; | |
| 236 | + | flex-direction: column; | |
| 237 | + | height: 70vh; | |
| 238 | + | max-height: 600px; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | .preferences-content { | |
| 242 | + | flex: 1; | |
| 243 | + | overflow-y: auto; | |
| 244 | + | padding: 16px; | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | .preference-group { | |
| 248 | + | margin-bottom: 24px; | |
| 249 | + | padding-bottom: 16px; | |
| 250 | + | border-bottom: 1px solid #ddd; | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | .preference-group:last-child { | |
| 254 | + | border-bottom: none; | |
| 255 | + | margin-bottom: 0; | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | .preference-group h3 { | |
| 259 | + | margin: 0 0 8px 0; | |
| 260 | + | font-size: 14px; | |
| 261 | + | font-weight: bold; | |
| 262 | + | color: #2d31a6; | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | .description { | |
| 266 | + | margin: 0 0 12px 0; | |
| 267 | + | font-size: 12px; | |
| 268 | + | color: #666; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | .checkbox-label { | |
| 272 | + | display: flex; | |
| 273 | + | align-items: center; | |
| 274 | + | gap: 8px; | |
| 275 | + | font-size: 12px; | |
| 276 | + | cursor: pointer; | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | .help-text { | |
| 280 | + | display: block; | |
| 281 | + | margin-top: 4px; | |
| 282 | + | font-size: 11px; | |
| 283 | + | color: #666; | |
| 284 | + | font-style: italic; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | .setting-row { | |
| 288 | + | display: flex; | |
| 289 | + | align-items: center; | |
| 290 | + | gap: 8px; | |
| 291 | + | margin-bottom: 8px; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | .setting-row label { | |
| 295 | + | font-size: 12px; | |
| 296 | + | min-width: 140px; | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | .length-input { | |
| 300 | + | width: 80px; | |
| 301 | + | padding: 2px 4px; | |
| 302 | + | border: 1px inset #dfdfdf; | |
| 303 | + | background: white; | |
| 304 | + | font-size: 11px; | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | .unit { | |
| 308 | + | font-size: 11px; | |
| 309 | + | color: #666; | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | .format-help { | |
| 313 | + | margin-top: 8px; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | .format-help summary { | |
| 317 | + | font-size: 12px; | |
| 318 | + | font-weight: bold; | |
| 319 | + | cursor: pointer; | |
| 320 | + | padding: 4px 0; | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | .format-examples { | |
| 324 | + | padding: 8px 0; | |
| 325 | + | font-size: 11px; | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | .format-examples h4 { | |
| 329 | + | margin: 12px 0 4px 0; | |
| 330 | + | font-size: 11px; | |
| 331 | + | color: #2d31a6; | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | .format-examples ul { | |
| 335 | + | margin: 0; | |
| 336 | + | padding-left: 16px; | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | .format-examples li { | |
| 340 | + | margin-bottom: 2px; | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | .format-examples code { | |
| 344 | + | background: #f0f0f0; | |
| 345 | + | padding: 1px 3px; | |
| 346 | + | border-radius: 2px; | |
| 347 | + | font-family: 'Courier New', monospace; | |
| 348 | + | font-size: 10px; | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | .modal-buttons { | |
| 352 | + | display: flex; | |
| 353 | + | justify-content: space-between; | |
| 354 | + | align-items: center; | |
| 355 | + | padding: 12px 16px; | |
| 356 | + | border-top: 1px solid #ddd; | |
| 357 | + | background: #f8f8f8; | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | .main-buttons { | |
| 361 | + | display: flex; | |
| 362 | + | gap: 8px; | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | .reset-button { | |
| 366 | + | padding: 4px 12px; | |
| 367 | + | font-size: 11px; | |
| 368 | + | border: 1px outset #dfdfdf; | |
| 369 | + | background: #f0f0f0; | |
| 370 | + | cursor: pointer; | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | .reset-button:hover { | |
| 374 | + | background: #e0e0e0; | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | .reset-button:disabled { | |
| 378 | + | opacity: 0.6; | |
| 379 | + | cursor: not-allowed; | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | .save-button { | |
| 383 | + | padding: 4px 16px; | |
| 384 | + | font-size: 11px; | |
| 385 | + | font-weight: bold; | |
| 386 | + | border: 1px outset #dfdfdf; | |
| 387 | + | background: #0078d4; | |
| 388 | + | color: white; | |
| 389 | + | cursor: pointer; | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | .save-button:hover:not(:disabled) { | |
| 393 | + | background: #106ebe; | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | .save-button:disabled { | |
| 397 | + | opacity: 0.6; | |
| 398 | + | cursor: not-allowed; | |
| 399 | + | background: #999; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | button { | |
| 403 | + | padding: 4px 12px; | |
| 404 | + | font-size: 11px; | |
| 405 | + | border: 1px outset #dfdfdf; | |
| 406 | + | background: #f0f0f0; | |
| 407 | + | cursor: pointer; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | button:hover:not(:disabled) { | |
| 411 | + | background: #e0e0e0; | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | button:disabled { | |
| 415 | + | opacity: 0.6; | |
| 416 | + | cursor: not-allowed; | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | /* Scrollbar styling */ | |
| 420 | + | .preferences-content::-webkit-scrollbar { | |
| 421 | + | width: 12px; | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | .preferences-content::-webkit-scrollbar-track { | |
| 425 | + | background: #f0f0f0; | |
| 426 | + | border: 1px inset #dfdfdf; | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | .preferences-content::-webkit-scrollbar-thumb { | |
| 430 | + | background: #c0c0c0; | |
| 431 | + | border: 1px outset #dfdfdf; | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | .preferences-content::-webkit-scrollbar-thumb:hover { | |
| 435 | + | background: #a0a0a0; | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /* Mobile responsive */ | |
| 439 | + | @media (max-width: 768px) { | |
| 440 | + | .modal-backdrop { | |
| 441 | + | padding: 10px; | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | .modal-window { | |
| 445 | + | max-width: none; | |
| 446 | + | max-height: 95vh; | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | .window-body { | |
| 450 | + | height: 80vh; | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | .preferences-content { | |
| 454 | + | padding: 12px; | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | .setting-row { | |
| 458 | + | flex-direction: column; | |
| 459 | + | align-items: flex-start; | |
| 460 | + | gap: 4px; | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | .setting-row label { | |
| 464 | + | min-width: auto; | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | .modal-buttons { | |
| 468 | + | flex-direction: column; | |
| 469 | + | gap: 8px; | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | .main-buttons { | |
| 473 | + | order: -1; | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | </style> | |
M src/lib/db/db.server.ts +17 -13
| @@ -12,24 +12,28 @@ const db = drizzle(client, { schema }); | |||
| 12 | 12 | ||
| 13 | 13 | // Initialize database with required data | |
| 14 | 14 | export const initializeDatabase = async () => { | |
| 15 | - | log.debug('Initializing database...'); | |
| 16 | - | try { | |
| 17 | - | await ensureDefaultChatRoom(db); | |
| 18 | - | log.debug('Database initialization complete'); | |
| 19 | - | } catch (error) { | |
| 20 | - | log.error('Error initializing database:', { error: error instanceof Error ? error.message : 'Unknown error' }); | |
| 21 | - | throw error; | |
| 22 | - | } | |
| 15 | + | log.debug('Initializing database...'); | |
| 16 | + | try { | |
| 17 | + | await ensureDefaultChatRoom(db); | |
| 18 | + | log.debug('Database initialization complete'); | |
| 19 | + | } catch (error) { | |
| 20 | + | log.error('Error initializing database:', { | |
| 21 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 22 | + | }); | |
| 23 | + | throw error; | |
| 24 | + | } | |
| 23 | 25 | }; | |
| 24 | 26 | ||
| 25 | 27 | // Call initialization when the module is loaded | |
| 26 | - | initializeDatabase().catch(error => { | |
| 27 | - | log.error('Failed to initialize database:', { error: error instanceof Error ? error.message : 'Unknown error' }); | |
| 28 | + | initializeDatabase().catch((error) => { | |
| 29 | + | log.error('Failed to initialize database:', { | |
| 30 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 31 | + | }); | |
| 28 | 32 | }); | |
| 29 | 33 | ||
| 30 | 34 | export const testConnection = async () => { | |
| 31 | - | const result = await db.query.users.findMany(); | |
| 32 | - | log.debug('Test connection result:', result); | |
| 35 | + | const result = await db.query.users.findMany(); | |
| 36 | + | log.debug('Test connection result:', result); | |
| 33 | 37 | }; | |
| 34 | 38 | ||
| 35 | - | export default db;⏎̸ | |
| 39 | + | export default db; | |
M src/lib/db/flush.ts +38 -36
| @@ -7,46 +7,48 @@ dotenv.config(); | |||
| 7 | 7 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | |
| 8 | 8 | ||
| 9 | 9 | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | |
| 10 | - | throw new Error('Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN'); | |
| 10 | + | throw new Error( | |
| 11 | + | 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN' | |
| 12 | + | ); | |
| 11 | 13 | } | |
| 12 | 14 | ||
| 13 | 15 | async function flushDatabase() { | |
| 14 | - | console.debug('Starting database content flush...'); | |
| 15 | - | ||
| 16 | - | const client = createClient({ | |
| 17 | - | url: TURSO_DATABASE_URL as string, | |
| 18 | - | authToken: TURSO_AUTH_TOKEN as string | |
| 19 | - | }); | |
| 20 | - | ||
| 21 | - | try { | |
| 22 | - | // Clear tables in correct order to respect foreign key constraints | |
| 23 | - | console.debug('Clearing messages table...'); | |
| 24 | - | await client.execute('DELETE FROM messages'); | |
| 25 | - | ||
| 26 | - | console.debug('Clearing sessions table...'); | |
| 27 | - | await client.execute('DELETE FROM sessions'); | |
| 28 | - | ||
| 29 | - | console.debug('Clearing chat rooms table...'); | |
| 30 | - | await client.execute('DELETE FROM chat_rooms'); | |
| 31 | - | ||
| 32 | - | console.debug('Clearing users table...'); | |
| 33 | - | await client.execute('DELETE FROM users'); | |
| 34 | - | ||
| 35 | - | console.debug('Database content flush completed successfully'); | |
| 36 | - | } catch (error) { | |
| 37 | - | console.error('Error flushing database content:', error); | |
| 38 | - | throw error; | |
| 39 | - | } finally { | |
| 40 | - | await client.close(); | |
| 41 | - | } | |
| 16 | + | console.debug('Starting database content flush...'); | |
| 17 | + | ||
| 18 | + | const client = createClient({ | |
| 19 | + | url: TURSO_DATABASE_URL as string, | |
| 20 | + | authToken: TURSO_AUTH_TOKEN as string | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | try { | |
| 24 | + | // Clear tables in correct order to respect foreign key constraints | |
| 25 | + | console.debug('Clearing messages table...'); | |
| 26 | + | await client.execute('DELETE FROM messages'); | |
| 27 | + | ||
| 28 | + | console.debug('Clearing sessions table...'); | |
| 29 | + | await client.execute('DELETE FROM sessions'); | |
| 30 | + | ||
| 31 | + | console.debug('Clearing chat rooms table...'); | |
| 32 | + | await client.execute('DELETE FROM chat_rooms'); | |
| 33 | + | ||
| 34 | + | console.debug('Clearing users table...'); | |
| 35 | + | await client.execute('DELETE FROM users'); | |
| 36 | + | ||
| 37 | + | console.debug('Database content flush completed successfully'); | |
| 38 | + | } catch (error) { | |
| 39 | + | console.error('Error flushing database content:', error); | |
| 40 | + | throw error; | |
| 41 | + | } finally { | |
| 42 | + | await client.close(); | |
| 43 | + | } | |
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | // Execute if this file is run directly | |
| 45 | 47 | if (process.argv[1] === import.meta.url) { | |
| 46 | - | flushDatabase() | |
| 47 | - | .then(() => process.exit(0)) | |
| 48 | - | .catch((error) => { | |
| 49 | - | console.error('Failed to flush database content:', error); | |
| 50 | - | process.exit(1); | |
| 51 | - | }); | |
| 52 | - | } ⏎̸ | |
| 48 | + | flushDatabase() | |
| 49 | + | .then(() => process.exit(0)) | |
| 50 | + | .catch((error) => { | |
| 51 | + | console.error('Failed to flush database content:', error); | |
| 52 | + | process.exit(1); | |
| 53 | + | }); | |
| 54 | + | } | |
M src/lib/db/schema.ts +110 -101
| @@ -1,71 +1,76 @@ | |||
| 1 | - | import { sqliteTable as table } from "drizzle-orm/sqlite-core"; | |
| 2 | - | import * as t from "drizzle-orm/sqlite-core"; | |
| 3 | - | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat"; | |
| 4 | - | import { v4 as uuidv4 } from "uuid"; | |
| 5 | - | import { eq } from "drizzle-orm"; | |
| 1 | + | import { sqliteTable as table } from 'drizzle-orm/sqlite-core'; | |
| 2 | + | import * as t from 'drizzle-orm/sqlite-core'; | |
| 3 | + | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from '../types/chat'; | |
| 4 | + | import { v4 as uuidv4 } from 'uuid'; | |
| 5 | + | import { eq } from 'drizzle-orm'; | |
| 6 | 6 | ||
| 7 | + | export const users = table('users', { | |
| 8 | + | id: t | |
| 9 | + | .text('id') | |
| 10 | + | .primaryKey() | |
| 11 | + | .$default(() => uuidv4()), | |
| 12 | + | password: t.text('password').notNull(), | |
| 13 | + | nickname: t.text('nickname').notNull(), | |
| 14 | + | status: t.text('status').notNull().$type<UserStatus>().default('offline'), | |
| 15 | + | avatarUrl: t.text('avatar_url'), | |
| 16 | + | createdAt: t.integer('created_at').notNull(), | |
| 17 | + | lastSeen: t.integer('last_seen') | |
| 18 | + | }); | |
| 7 | 19 | ||
| 8 | - | export const users = table( | |
| 9 | - | "users", | |
| 10 | - | { | |
| 11 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | |
| 12 | - | password: t.text("password").notNull(), | |
| 13 | - | nickname: t.text("nickname").notNull(), | |
| 14 | - | status: t.text("status").notNull().$type<UserStatus>().default("offline"), | |
| 15 | - | avatarUrl: t.text("avatar_url"), | |
| 16 | - | createdAt: t.integer("created_at").notNull(), | |
| 17 | - | lastSeen: t.integer("last_seen"), | |
| 18 | - | } | |
| 19 | - | ); | |
| 20 | - | ||
| 21 | - | export const sessions = table( | |
| 22 | - | "sessions", | |
| 23 | - | { | |
| 24 | - | id: t.text("id").primaryKey(), | |
| 25 | - | userId: t.text("user_id").references(() => users.id).notNull(), | |
| 26 | - | expiresAt: t.integer("expires_at").notNull(), | |
| 27 | - | createdAt: t.integer("created_at").notNull(), | |
| 28 | - | } | |
| 29 | - | ); | |
| 20 | + | export const sessions = table('sessions', { | |
| 21 | + | id: t.text('id').primaryKey(), | |
| 22 | + | userId: t | |
| 23 | + | .text('user_id') | |
| 24 | + | .references(() => users.id) | |
| 25 | + | .notNull(), | |
| 26 | + | expiresAt: t.integer('expires_at').notNull(), | |
| 27 | + | createdAt: t.integer('created_at').notNull() | |
| 28 | + | }); | |
| 30 | 29 | ||
| 31 | - | export const chatRooms = table( | |
| 32 | - | "chat_rooms", | |
| 33 | - | { | |
| 34 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | |
| 35 | - | name: t.text("name"), | |
| 36 | - | type: t.text("type").$type<"direct" | "group">().notNull().default("direct"), | |
| 37 | - | createdAt: t.integer("created_at").notNull(), | |
| 38 | - | } | |
| 39 | - | ); | |
| 30 | + | export const chatRooms = table('chat_rooms', { | |
| 31 | + | id: t | |
| 32 | + | .text('id') | |
| 33 | + | .primaryKey() | |
| 34 | + | .$default(() => uuidv4()), | |
| 35 | + | name: t.text('name'), | |
| 36 | + | type: t.text('type').$type<'direct' | 'group'>().notNull().default('direct'), | |
| 37 | + | createdAt: t.integer('created_at').notNull() | |
| 38 | + | }); | |
| 40 | 39 | ||
| 41 | - | export const messages = table( | |
| 42 | - | "messages", | |
| 43 | - | { | |
| 44 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | |
| 45 | - | chatRoomId: t.text("chat_room_id").references(() => chatRooms.id).notNull(), | |
| 46 | - | senderId: t.text("sender_id").references(() => users.id).notNull(), | |
| 47 | - | content: t.text("content").notNull(), | |
| 48 | - | type: t.text("type").$type<MessageType>().notNull().default("chat"), | |
| 49 | - | timestamp: t.integer("timestamp").notNull(), | |
| 50 | - | styleData: t.text("style_data"), // JSON string of TextStyle object | |
| 51 | - | hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag | |
| 52 | - | } | |
| 53 | - | ); | |
| 40 | + | export const messages = table('messages', { | |
| 41 | + | id: t | |
| 42 | + | .text('id') | |
| 43 | + | .primaryKey() | |
| 44 | + | .$default(() => uuidv4()), | |
| 45 | + | chatRoomId: t | |
| 46 | + | .text('chat_room_id') | |
| 47 | + | .references(() => chatRooms.id) | |
| 48 | + | .notNull(), | |
| 49 | + | senderId: t | |
| 50 | + | .text('sender_id') | |
| 51 | + | .references(() => users.id) | |
| 52 | + | .notNull(), | |
| 53 | + | content: t.text('content').notNull(), | |
| 54 | + | type: t.text('type').$type<MessageType>().notNull().default('chat'), | |
| 55 | + | timestamp: t.integer('timestamp').notNull(), | |
| 56 | + | styleData: t.text('style_data'), // JSON string of TextStyle object | |
| 57 | + | hasFormatting: t.integer('has_formatting', { mode: 'boolean' }).default(false) // Boolean flag | |
| 58 | + | }); | |
| 54 | 59 | ||
| 55 | - | export const userTextPreferences = table( | |
| 56 | - | "user_text_preferences", | |
| 57 | - | { | |
| 58 | - | userId: t.text("user_id").references(() => users.id).primaryKey(), | |
| 59 | - | defaultFontFamily: t.text("default_font_family").default("tahoma"), | |
| 60 | - | defaultFontSize: t.integer("default_font_size").default(14), | |
| 61 | - | defaultColor: t.text("default_color").default("black"), | |
| 62 | - | allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true), | |
| 63 | - | maxMessageLength: t.integer("max_message_length").default(500), | |
| 64 | - | stylePresets: t.text("style_presets"), // JSON array of saved styles | |
| 65 | - | createdAt: t.integer("created_at").notNull(), | |
| 66 | - | updatedAt: t.integer("updated_at").notNull(), | |
| 67 | - | } | |
| 68 | - | ); | |
| 60 | + | export const userTextPreferences = table('user_text_preferences', { | |
| 61 | + | userId: t | |
| 62 | + | .text('user_id') | |
| 63 | + | .references(() => users.id) | |
| 64 | + | .primaryKey(), | |
| 65 | + | defaultFontFamily: t.text('default_font_family').default('tahoma'), | |
| 66 | + | defaultFontSize: t.integer('default_font_size').default(14), | |
| 67 | + | defaultColor: t.text('default_color').default('black'), | |
| 68 | + | allowFormatting: t.integer('allow_formatting', { mode: 'boolean' }).default(true), | |
| 69 | + | maxMessageLength: t.integer('max_message_length').default(500), | |
| 70 | + | stylePresets: t.text('style_presets'), // JSON array of saved styles | |
| 71 | + | createdAt: t.integer('created_at').notNull(), | |
| 72 | + | updatedAt: t.integer('updated_at').notNull() | |
| 73 | + | }); | |
| 69 | 74 | ||
| 70 | 75 | // Validate schema types against interfaces | |
| 71 | 76 | export type Users = typeof users.$inferSelect; | |
| @@ -75,55 +80,59 @@ export type Sessions = typeof sessions.$inferSelect; | |||
| 75 | 80 | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | |
| 76 | 81 | // These type assertions will fail if the schema doesn't match the interfaces | |
| 77 | 82 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | |
| 78 | - | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | |
| 83 | + | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & | |
| 84 | + | Omit<ChatRooms, keyof ChatRoom>; | |
| 79 | 85 | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; | |
| 80 | 86 | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | |
| 81 | 87 | ||
| 82 | - | export const userView = t.sqliteView("user_view").as((qb) => | |
| 83 | - | qb.select({ | |
| 84 | - | id: users.id, | |
| 85 | - | nickname: users.nickname, | |
| 86 | - | status: users.status, | |
| 87 | - | avatarUrl: users.avatarUrl, | |
| 88 | - | lastSeen: users.lastSeen | |
| 89 | - | }).from(users) | |
| 88 | + | export const userView = t.sqliteView('user_view').as((qb) => | |
| 89 | + | qb | |
| 90 | + | .select({ | |
| 91 | + | id: users.id, | |
| 92 | + | nickname: users.nickname, | |
| 93 | + | status: users.status, | |
| 94 | + | avatarUrl: users.avatarUrl, | |
| 95 | + | lastSeen: users.lastSeen | |
| 96 | + | }) | |
| 97 | + | .from(users) | |
| 90 | 98 | ); | |
| 91 | 99 | ||
| 92 | - | export const chatRoomView = t.sqliteView("chat_room_view").as((qb) => | |
| 93 | - | qb.select({ | |
| 94 | - | id: chatRooms.id, | |
| 95 | - | name: chatRooms.name, | |
| 96 | - | type: chatRooms.type, | |
| 97 | - | createdAt: chatRooms.createdAt | |
| 98 | - | }).from(chatRooms) | |
| 100 | + | export const chatRoomView = t.sqliteView('chat_room_view').as((qb) => | |
| 101 | + | qb | |
| 102 | + | .select({ | |
| 103 | + | id: chatRooms.id, | |
| 104 | + | name: chatRooms.name, | |
| 105 | + | type: chatRooms.type, | |
| 106 | + | createdAt: chatRooms.createdAt | |
| 107 | + | }) | |
| 108 | + | .from(chatRooms) | |
| 99 | 109 | ); | |
| 100 | 110 | ||
| 101 | - | export const messageView = t.sqliteView("message_view").as((qb) => | |
| 102 | - | qb.select({ | |
| 103 | - | id: messages.id, | |
| 104 | - | content: messages.content, | |
| 105 | - | type: messages.type, | |
| 106 | - | timestamp: messages.timestamp, | |
| 107 | - | sender: users.nickname, | |
| 108 | - | chatRoom: chatRooms.name | |
| 109 | - | }) | |
| 110 | - | .from(messages) | |
| 111 | - | .leftJoin(users, eq(messages.senderId, users.id)) | |
| 112 | - | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | |
| 111 | + | export const messageView = t.sqliteView('message_view').as((qb) => | |
| 112 | + | qb | |
| 113 | + | .select({ | |
| 114 | + | id: messages.id, | |
| 115 | + | content: messages.content, | |
| 116 | + | type: messages.type, | |
| 117 | + | timestamp: messages.timestamp, | |
| 118 | + | sender: users.nickname, | |
| 119 | + | chatRoom: chatRooms.name | |
| 120 | + | }) | |
| 121 | + | .from(messages) | |
| 122 | + | .leftJoin(users, eq(messages.senderId, users.id)) | |
| 123 | + | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | |
| 113 | 124 | ); | |
| 114 | 125 | ||
| 115 | 126 | // Create a schema object with all our tables and views | |
| 116 | 127 | const schema = { | |
| 117 | - | users, | |
| 118 | - | sessions, | |
| 119 | - | chatRooms, | |
| 120 | - | messages, | |
| 121 | - | userTextPreferences, | |
| 122 | - | userView, | |
| 123 | - | chatRoomView, | |
| 124 | - | messageView | |
| 128 | + | users, | |
| 129 | + | sessions, | |
| 130 | + | chatRooms, | |
| 131 | + | messages, | |
| 132 | + | userTextPreferences, | |
| 133 | + | userView, | |
| 134 | + | chatRoomView, | |
| 135 | + | messageView | |
| 125 | 136 | }; | |
| 126 | 137 | ||
| 127 | - | ||
| 128 | 138 | export default schema; | |
| 129 | - | ||
M src/lib/sseConnectionTracker.ts +79 -79
| @@ -3,88 +3,88 @@ import { createLogger } from './utils/logger.server'; | |||
| 3 | 3 | const log = createLogger('sse-connection-tracker'); | |
| 4 | 4 | ||
| 5 | 5 | interface ConnectionInfo { | |
| 6 | - | userId: string; | |
| 7 | - | connectedAt: number; | |
| 8 | - | lastActivity: number; | |
| 6 | + | userId: string; | |
| 7 | + | connectedAt: number; | |
| 8 | + | lastActivity: number; | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | class SSEConnectionTracker { | |
| 12 | - | private connections = new Map<string, ConnectionInfo>(); | |
| 13 | - | ||
| 14 | - | addConnection(userId: string): boolean { | |
| 15 | - | const existing = this.connections.get(userId); | |
| 16 | - | if (existing) { | |
| 17 | - | log.debug('User already has active connection', { userId }); | |
| 18 | - | // Update last activity but don't trigger status update | |
| 19 | - | existing.lastActivity = Date.now(); | |
| 20 | - | return false; // Connection already exists | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | this.connections.set(userId, { | |
| 24 | - | userId, | |
| 25 | - | connectedAt: Date.now(), | |
| 26 | - | lastActivity: Date.now() | |
| 27 | - | }); | |
| 28 | - | ||
| 29 | - | log.debug('New SSE connection tracked', { | |
| 30 | - | userId, | |
| 31 | - | totalConnections: this.connections.size | |
| 32 | - | }); | |
| 33 | - | return true; // New connection | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | removeConnection(userId: string): boolean { | |
| 37 | - | const deleted = this.connections.delete(userId); | |
| 38 | - | if (deleted) { | |
| 39 | - | log.debug('SSE connection removed', { | |
| 40 | - | userId, | |
| 41 | - | remainingConnections: this.connections.size | |
| 42 | - | }); | |
| 43 | - | } | |
| 44 | - | return deleted; | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | hasConnection(userId: string): boolean { | |
| 48 | - | return this.connections.has(userId); | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | updateActivity(userId: string): void { | |
| 52 | - | const connection = this.connections.get(userId); | |
| 53 | - | if (connection) { | |
| 54 | - | connection.lastActivity = Date.now(); | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | getActiveUserIds(): string[] { | |
| 59 | - | return Array.from(this.connections.keys()); | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | getConnectionCount(): number { | |
| 63 | - | return this.connections.size; | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Clean up stale connections (e.g., if server didn't properly close them) | |
| 67 | - | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | |
| 68 | - | const now = Date.now(); | |
| 69 | - | const staleUserIds: string[] = []; | |
| 70 | - | ||
| 71 | - | for (const [userId, info] of this.connections.entries()) { | |
| 72 | - | if (now - info.lastActivity > maxInactivityMs) { | |
| 73 | - | this.connections.delete(userId); | |
| 74 | - | staleUserIds.push(userId); | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if (staleUserIds.length > 0) { | |
| 79 | - | log.info('Cleaned up stale connections', { | |
| 80 | - | count: staleUserIds.length, | |
| 81 | - | userIds: staleUserIds | |
| 82 | - | }); | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | return staleUserIds; | |
| 86 | - | } | |
| 12 | + | private connections = new Map<string, ConnectionInfo>(); | |
| 13 | + | ||
| 14 | + | addConnection(userId: string): boolean { | |
| 15 | + | const existing = this.connections.get(userId); | |
| 16 | + | if (existing) { | |
| 17 | + | log.debug('User already has active connection', { userId }); | |
| 18 | + | // Update last activity but don't trigger status update | |
| 19 | + | existing.lastActivity = Date.now(); | |
| 20 | + | return false; // Connection already exists | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | this.connections.set(userId, { | |
| 24 | + | userId, | |
| 25 | + | connectedAt: Date.now(), | |
| 26 | + | lastActivity: Date.now() | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | log.debug('New SSE connection tracked', { | |
| 30 | + | userId, | |
| 31 | + | totalConnections: this.connections.size | |
| 32 | + | }); | |
| 33 | + | return true; // New connection | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | removeConnection(userId: string): boolean { | |
| 37 | + | const deleted = this.connections.delete(userId); | |
| 38 | + | if (deleted) { | |
| 39 | + | log.debug('SSE connection removed', { | |
| 40 | + | userId, | |
| 41 | + | remainingConnections: this.connections.size | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | return deleted; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | hasConnection(userId: string): boolean { | |
| 48 | + | return this.connections.has(userId); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | updateActivity(userId: string): void { | |
| 52 | + | const connection = this.connections.get(userId); | |
| 53 | + | if (connection) { | |
| 54 | + | connection.lastActivity = Date.now(); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | getActiveUserIds(): string[] { | |
| 59 | + | return Array.from(this.connections.keys()); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | getConnectionCount(): number { | |
| 63 | + | return this.connections.size; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Clean up stale connections (e.g., if server didn't properly close them) | |
| 67 | + | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | |
| 68 | + | const now = Date.now(); | |
| 69 | + | const staleUserIds: string[] = []; | |
| 70 | + | ||
| 71 | + | for (const [userId, info] of this.connections.entries()) { | |
| 72 | + | if (now - info.lastActivity > maxInactivityMs) { | |
| 73 | + | this.connections.delete(userId); | |
| 74 | + | staleUserIds.push(userId); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if (staleUserIds.length > 0) { | |
| 79 | + | log.info('Cleaned up stale connections', { | |
| 80 | + | count: staleUserIds.length, | |
| 81 | + | userIds: staleUserIds | |
| 82 | + | }); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return staleUserIds; | |
| 86 | + | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | // Export singleton instance | |
| 90 | - | export const sseConnectionTracker = new SSEConnectionTracker();⏎̸ | |
| 90 | + | export const sseConnectionTracker = new SSEConnectionTracker(); | |
M src/lib/sseEmitter.ts +59 -48
| @@ -1,54 +1,65 @@ | |||
| 1 | 1 | type SSEEventData = { | |
| 2 | - | type: string; | |
| 3 | - | data: unknown; | |
| 2 | + | type: string; | |
| 3 | + | data: unknown; | |
| 4 | 4 | }; | |
| 5 | 5 | ||
| 6 | 6 | class CustomEventEmitter { | |
| 7 | - | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); | |
| 8 | - | private maxListeners: number = 100; | |
| 9 | - | ||
| 10 | - | constructor() { | |
| 11 | - | console.log('[SSE-Emitter] Initialized'); | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | emit(event: string, data: SSEEventData) { | |
| 15 | - | const eventListeners = this.listeners.get(event); | |
| 16 | - | if (eventListeners) { | |
| 17 | - | // Only log non-sensitive event types | |
| 18 | - | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { | |
| 19 | - | console.log('[SSE-Emitter] Broadcasting event:', { type: data.type, listenersCount: eventListeners.length }); | |
| 20 | - | } | |
| 21 | - | eventListeners.forEach(listener => listener(data)); | |
| 22 | - | } | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | addListener(event: string, listener: (data: SSEEventData) => void) { | |
| 26 | - | if (!this.listeners.has(event)) { | |
| 27 | - | this.listeners.set(event, []); | |
| 28 | - | } | |
| 29 | - | const eventListeners = this.listeners.get(event)!; | |
| 30 | - | ||
| 31 | - | if (eventListeners.length >= this.maxListeners) { | |
| 32 | - | console.log(`[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`); | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | eventListeners.push(listener); | |
| 36 | - | console.log('[SSE-Emitter] New listener added:', { event, totalListeners: eventListeners.length }); | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | |
| 40 | - | const eventListeners = this.listeners.get(event); | |
| 41 | - | if (eventListeners) { | |
| 42 | - | const newListeners = eventListeners.filter(listener => listener !== listenerToRemove); | |
| 43 | - | this.listeners.set(event, newListeners); | |
| 44 | - | console.log('[SSE-Emitter] Listener removed:', { event, remainingListeners: newListeners.length }); | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | setMaxListeners(n: number) { | |
| 49 | - | this.maxListeners = n; | |
| 50 | - | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | |
| 51 | - | } | |
| 7 | + | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); | |
| 8 | + | private maxListeners: number = 100; | |
| 9 | + | ||
| 10 | + | constructor() { | |
| 11 | + | console.log('[SSE-Emitter] Initialized'); | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | emit(event: string, data: SSEEventData) { | |
| 15 | + | const eventListeners = this.listeners.get(event); | |
| 16 | + | if (eventListeners) { | |
| 17 | + | // Only log non-sensitive event types | |
| 18 | + | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { | |
| 19 | + | console.log('[SSE-Emitter] Broadcasting event:', { | |
| 20 | + | type: data.type, | |
| 21 | + | listenersCount: eventListeners.length | |
| 22 | + | }); | |
| 23 | + | } | |
| 24 | + | eventListeners.forEach((listener) => listener(data)); | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | addListener(event: string, listener: (data: SSEEventData) => void) { | |
| 29 | + | if (!this.listeners.has(event)) { | |
| 30 | + | this.listeners.set(event, []); | |
| 31 | + | } | |
| 32 | + | const eventListeners = this.listeners.get(event)!; | |
| 33 | + | ||
| 34 | + | if (eventListeners.length >= this.maxListeners) { | |
| 35 | + | console.log( | |
| 36 | + | `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})` | |
| 37 | + | ); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | eventListeners.push(listener); | |
| 41 | + | console.log('[SSE-Emitter] New listener added:', { | |
| 42 | + | event, | |
| 43 | + | totalListeners: eventListeners.length | |
| 44 | + | }); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | |
| 48 | + | const eventListeners = this.listeners.get(event); | |
| 49 | + | if (eventListeners) { | |
| 50 | + | const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove); | |
| 51 | + | this.listeners.set(event, newListeners); | |
| 52 | + | console.log('[SSE-Emitter] Listener removed:', { | |
| 53 | + | event, | |
| 54 | + | remainingListeners: newListeners.length | |
| 55 | + | }); | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | setMaxListeners(n: number) { | |
| 60 | + | this.maxListeners = n; | |
| 61 | + | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | |
| 62 | + | } | |
| 52 | 63 | } | |
| 53 | 64 | ||
| 54 | - | export const sseEmitter = new CustomEventEmitter(); ⏎̸ | |
| 65 | + | export const sseEmitter = new CustomEventEmitter(); | |
M src/lib/states/chat.svelte.ts +732 -717
| @@ -1,728 +1,743 @@ | |||
| 1 | 1 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 2 | 2 | import { createSafeUser } from '../types/chat'; | |
| 3 | - | import type { | |
| 4 | - | SendMessageRequest, | |
| 5 | - | SendMessageResponse, | |
| 6 | - | GetMessagesResponse | |
| 3 | + | import type { | |
| 4 | + | SendMessageRequest, | |
| 5 | + | SendMessageResponse, | |
| 6 | + | GetMessagesResponse | |
| 7 | 7 | } from '../types/payloads'; | |
| 8 | 8 | import { invalidate } from '$app/navigation'; | |
| 9 | 9 | import { env } from '$env/dynamic/public'; | |
| 10 | 10 | ||
| 11 | 11 | // Use the public environment variable with a fallback | |
| 12 | - | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 12 | + | const DEFAULT_CHAT_ROOM_ID = | |
| 13 | + | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 13 | 14 | ||
| 14 | 15 | class ChatState { | |
| 15 | - | private users = $state<SafeUser[]>([]); | |
| 16 | - | private messages = $state<Message[]>([]); | |
| 17 | - | private currentUser = $state<SafeUser | null>(null); | |
| 18 | - | private eventSource: EventSource | null = null; | |
| 19 | - | private reconnectAttempts = 0; | |
| 20 | - | private maxReconnectAttempts = 5; | |
| 21 | - | private reconnectDelay = 1000; // Start with 1 second | |
| 22 | - | private isInitializing = $state(false); | |
| 23 | - | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 24 | - | private userCache = $state<Record<string, SafeUser>>({}); | |
| 25 | - | private sseError = $state<string | null>(null); | |
| 26 | - | private sseRetryAfter = $state<number | null>(null); | |
| 27 | - | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 28 | - | private isReconnecting = $state(false); | |
| 29 | - | private isSettingUser = $state(false); | |
| 30 | - | private isConnecting = $state(false); | |
| 31 | - | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 32 | - | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 33 | - | private _lastEnrichmentKey: string = ''; | |
| 34 | - | // Flag to avoid re-registering SSE event handlers | |
| 35 | - | private sseHandlersRegistered = false; | |
| 36 | - | private lastBuddyListUpdate = 0; | |
| 37 | - | private lastBuddyListHash = ''; | |
| 38 | - | private hasMoreMessages = $state(false); | |
| 39 | - | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | |
| 40 | - | ||
| 41 | - | public async reinitialize() { | |
| 42 | - | if (this.isInitializing) { | |
| 43 | - | console.debug('Reinitialize already in progress, skipping.'); | |
| 44 | - | return; | |
| 45 | - | } | |
| 46 | - | this.isInitializing = true; | |
| 47 | - | console.debug('Reinitializing chat state...'); | |
| 48 | - | if (this.eventSource) { | |
| 49 | - | this.eventSource.close(); | |
| 50 | - | this.eventSource = null; | |
| 51 | - | } | |
| 52 | - | this.reconnectAttempts = 0; | |
| 53 | - | this.reconnectDelay = 1000; | |
| 54 | - | this.messages = []; | |
| 55 | - | // Ensure currentRoomId is set | |
| 56 | - | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 57 | - | ||
| 58 | - | if (this.currentUser) { | |
| 59 | - | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 60 | - | await new Promise(resolve => setTimeout(resolve, 1000)); | |
| 61 | - | // First set up SSE to receive status updates | |
| 62 | - | this.setupSSE(); | |
| 63 | - | // Then initialize messages and room users | |
| 64 | - | await Promise.all([ | |
| 65 | - | this.initializeMessages(), | |
| 66 | - | this.initializeRoomUsers() | |
| 67 | - | ]); | |
| 68 | - | } | |
| 69 | - | this.isInitializing = false; | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | getSSEError() { | |
| 73 | - | return { | |
| 74 | - | error: this.sseError, | |
| 75 | - | retryAfter: this.sseRetryAfter | |
| 76 | - | }; | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // User methods | |
| 80 | - | getCurrentUser() { | |
| 81 | - | return this.currentUser; | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | async setCurrentUser(user: User | SafeUser | null) { | |
| 85 | - | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 86 | - | ||
| 87 | - | // Prevent concurrent setCurrentUser calls | |
| 88 | - | if (this.isSettingUser) { | |
| 89 | - | console.debug('Already setting user, skipping'); | |
| 90 | - | return; | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | this.isSettingUser = true; | |
| 94 | - | ||
| 95 | - | try { | |
| 96 | - | // If user is null and we already have no user, skip | |
| 97 | - | if (!user && !this.currentUser) { | |
| 98 | - | console.debug('No user to set and no current user, skipping'); | |
| 99 | - | return; | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // Convert to SafeUser if full User object is passed | |
| 103 | - | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 104 | - | ||
| 105 | - | // If same user is being set, check SSE connection | |
| 106 | - | if (safeUser?.id === this.currentUser?.id) { | |
| 107 | - | if (!this.eventSource) { | |
| 108 | - | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 109 | - | } else { | |
| 110 | - | console.debug('Same user being set with active connection, skipping.'); | |
| 111 | - | return; | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // Clear existing messages before setting new user | |
| 116 | - | this.messages = []; | |
| 117 | - | this.currentUser = safeUser; | |
| 118 | - | ||
| 119 | - | if (safeUser) { | |
| 120 | - | // Update user cache with current user | |
| 121 | - | this.userCache[safeUser.id] = safeUser; | |
| 122 | - | ||
| 123 | - | // Only reinitialize if we have a valid user | |
| 124 | - | await this.reinitialize(); | |
| 125 | - | } else { | |
| 126 | - | // Cleanup when user is removed | |
| 127 | - | if (this.eventSource) { | |
| 128 | - | this.eventSource.close(); | |
| 129 | - | this.eventSource = null; | |
| 130 | - | } | |
| 131 | - | this.messages = []; | |
| 132 | - | this.users = []; | |
| 133 | - | this.userCache = {}; | |
| 134 | - | } | |
| 135 | - | } finally { | |
| 136 | - | this.isSettingUser = false; | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | private async initializeRoomUsers() { | |
| 141 | - | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 142 | - | try { | |
| 143 | - | const isPublic = !this.currentUser; | |
| 144 | - | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 145 | - | console.debug('Fetching room users from:', url); | |
| 146 | - | ||
| 147 | - | const response = await fetch(url, { | |
| 148 | - | credentials: 'include' | |
| 149 | - | }); | |
| 150 | - | ||
| 151 | - | if (!response.ok) { | |
| 152 | - | throw new Error('Failed to fetch room users'); | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | const data = await response.json(); | |
| 156 | - | if (!data.success) { | |
| 157 | - | throw new Error(data.error); | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | console.debug('Fetched room buddy list:', data.buddyList); | |
| 161 | - | // Merge fetched buddy list with existing cached users to preserve user details even if offline | |
| 162 | - | const mergedUsers = new Map<string, typeof data.buddyList[0]>(); | |
| 163 | - | // Add existing cached users | |
| 164 | - | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |
| 165 | - | ||
| 166 | - | // Overwrite/add with fetched buddy list | |
| 167 | - | data.buddyList.forEach((user: typeof data.buddyList[0]) => { | |
| 168 | - | mergedUsers.set(user.id, user); | |
| 169 | - | }); | |
| 170 | - | ||
| 171 | - | this.users = Array.from(mergedUsers.values()); | |
| 172 | - | ||
| 173 | - | // Update cache with merged users | |
| 174 | - | this.users.forEach(user => { | |
| 175 | - | this.userCache[user.id] = user; | |
| 176 | - | }); | |
| 177 | - | } catch (error) { | |
| 178 | - | console.debug('Error fetching room users:', error); | |
| 179 | - | this.users = []; | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | getOnlineUsers() { | |
| 184 | - | // Sort users by status: online first, then away, then busy, then offline | |
| 185 | - | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 186 | - | const statusOrder: Record<SafeUser['status'], number> = { | |
| 187 | - | 'online': 0, | |
| 188 | - | 'away': 1, | |
| 189 | - | 'busy': 2, | |
| 190 | - | 'offline': 3 | |
| 191 | - | }; | |
| 192 | - | return statusOrder[a.status] - statusOrder[b.status]; | |
| 193 | - | }); | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | getUserById(userId: string) { | |
| 197 | - | // First check the cache | |
| 198 | - | if (this.userCache[userId]) { | |
| 199 | - | return this.userCache[userId]; | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // Return a fallback object and cache it to avoid repeated fetches | |
| 203 | - | const fallback: SafeUser = { | |
| 204 | - | id: userId, | |
| 205 | - | nickname: 'Unknown User', | |
| 206 | - | status: 'offline', | |
| 207 | - | lastSeen: null, | |
| 208 | - | avatarUrl: null | |
| 209 | - | }; | |
| 210 | - | this.userCache[userId] = fallback; | |
| 211 | - | console.debug('getUserById: returning fallback for missing user', { userId, fallback }); | |
| 212 | - | return fallback; | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | |
| 216 | - | console.debug('Updating user status with:', { | |
| 217 | - | userId, | |
| 218 | - | status, | |
| 219 | - | lastSeen, | |
| 220 | - | currentCache: this.userCache[userId], | |
| 221 | - | currentUsers: this.users.find(u => u.id === userId) | |
| 222 | - | }); | |
| 223 | - | ||
| 224 | - | const existingUser = this.userCache[userId]; | |
| 225 | - | if ( | |
| 226 | - | existingUser && | |
| 227 | - | existingUser.status === status && | |
| 228 | - | lastSeen && | |
| 229 | - | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | |
| 230 | - | ) { | |
| 231 | - | console.debug('Skipping redundant status update for user:', userId); | |
| 232 | - | return; | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | // Try to get the user from cache first | |
| 236 | - | let user: SafeUser | undefined = this.userCache[userId]; | |
| 237 | - | ||
| 238 | - | // If not in cache, try to find in users array | |
| 239 | - | if (!user) { | |
| 240 | - | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 241 | - | if (foundUser) { | |
| 242 | - | user = foundUser; | |
| 243 | - | } | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | const now = Date.now(); | |
| 247 | - | if (user) { | |
| 248 | - | const updatedUser: SafeUser = { | |
| 249 | - | ...user, | |
| 250 | - | status, | |
| 251 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 252 | - | }; | |
| 253 | - | ||
| 254 | - | // Update cache first | |
| 255 | - | this.userCache[userId] = updatedUser; | |
| 256 | - | ||
| 257 | - | // Then update or add to users array | |
| 258 | - | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 259 | - | if (index !== -1) { | |
| 260 | - | // Create new array with updated user | |
| 261 | - | this.users = [ | |
| 262 | - | ...this.users.slice(0, index), | |
| 263 | - | updatedUser, | |
| 264 | - | ...this.users.slice(index + 1) | |
| 265 | - | ]; | |
| 266 | - | } else { | |
| 267 | - | this.users = [...this.users, updatedUser]; | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | console.debug('User status updated:', { | |
| 271 | - | userId, | |
| 272 | - | newStatus: status, | |
| 273 | - | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), | |
| 274 | - | updatedInCache: this.userCache[userId].status === status, | |
| 275 | - | updatedInList: this.users.find(u => u.id === userId)?.status === status | |
| 276 | - | }); | |
| 277 | - | } else { | |
| 278 | - | // If user not found anywhere, fetch from API | |
| 279 | - | console.debug('User not found in cache or list, fetching from API:', userId); | |
| 280 | - | fetch(`/api/users/${userId}`, { credentials: 'include' }) | |
| 281 | - | .then(response => response.json()) | |
| 282 | - | .then(data => { | |
| 283 | - | if (data.success && data.user) { | |
| 284 | - | const newUser: SafeUser = { | |
| 285 | - | ...data.user, | |
| 286 | - | status, | |
| 287 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| 288 | - | }; | |
| 289 | - | // Update both cache and users array | |
| 290 | - | this.userCache[userId] = newUser; | |
| 291 | - | this.users = [...this.users, newUser]; | |
| 292 | - | ||
| 293 | - | console.debug('User fetched and status updated:', { | |
| 294 | - | userId, | |
| 295 | - | status: newUser.status, | |
| 296 | - | lastSeen: new Date(newUser.lastSeen || now).toISOString(), | |
| 297 | - | addedToCache: !!this.userCache[userId], | |
| 298 | - | addedToList: this.users.some(u => u.id === userId) | |
| 299 | - | }); | |
| 300 | - | } | |
| 301 | - | }) | |
| 302 | - | .catch(error => console.error('Error fetching user data:', error)); | |
| 303 | - | } | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | // Message methods | |
| 307 | - | getMessages() { | |
| 308 | - | const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| 309 | - | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { | |
| 310 | - | return this._memoizedEnrichedMessages; | |
| 311 | - | } | |
| 312 | - | this._lastEnrichmentKey = key; | |
| 313 | - | console.debug('Enriching messages with user data', { | |
| 314 | - | messagesCount: this.messages.length, | |
| 315 | - | userCacheSize: Object.keys(this.userCache).length | |
| 316 | - | }); | |
| 317 | - | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | |
| 318 | - | return this._memoizedEnrichedMessages; | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 322 | - | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 323 | - | // Get unique user IDs from new messages that aren't in the cache | |
| 324 | - | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | |
| 325 | - | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | |
| 326 | - | ||
| 327 | - | // Fetch missing user data in parallel | |
| 328 | - | if (missingUserIds.length > 0) { | |
| 329 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 330 | - | Promise.all(missingUserIds.map(async (userId) => { | |
| 331 | - | try { | |
| 332 | - | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 333 | - | credentials: 'include' | |
| 334 | - | }); | |
| 335 | - | if (userResponse.ok) { | |
| 336 | - | const userData = await userResponse.json(); | |
| 337 | - | if (userData.success && userData.user) { | |
| 338 | - | this.userCache[userId] = userData.user; | |
| 339 | - | // Only add to users array if not already present | |
| 340 | - | if (!this.users.some(u => u.id === userId)) { | |
| 341 | - | this.users = [...this.users, userData.user]; | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | } | |
| 345 | - | } catch (error) { | |
| 346 | - | console.debug('Error fetching user data:', { userId, error }); | |
| 347 | - | } | |
| 348 | - | })); | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | // Ensure incoming messages are sorted in ascending order before prepending | |
| 352 | - | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 353 | - | const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | |
| 354 | - | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 355 | - | ||
| 356 | - | // Update hasMore if provided | |
| 357 | - | if (hasMore !== undefined) { | |
| 358 | - | this.hasMoreMessages = hasMore; | |
| 359 | - | } | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | // Add method to update user cache for public access | |
| 363 | - | updateUserCache(users: (User | SafeUser)[]) { | |
| 364 | - | console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' }))); | |
| 365 | - | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |
| 366 | - | users.forEach(user => { | |
| 367 | - | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 368 | - | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 369 | - | this.userCache[user.id] = safeUser; | |
| 370 | - | // Update users array if not already present or if status changed | |
| 371 | - | const existingIndex = this.users.findIndex(u => u.id === user.id); | |
| 372 | - | if (existingIndex === -1) { | |
| 373 | - | this.users = [...this.users, safeUser]; | |
| 374 | - | } else if (safeUser.status !== this.users[existingIndex].status) { | |
| 375 | - | this.users = [ | |
| 376 | - | ...this.users.slice(0, existingIndex), | |
| 377 | - | safeUser, | |
| 378 | - | ...this.users.slice(existingIndex + 1) | |
| 379 | - | ]; | |
| 380 | - | } | |
| 381 | - | } | |
| 382 | - | }); | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | // Add method to update online users for public access | |
| 386 | - | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 387 | - | const now = Date.now(); | |
| 388 | - | // Convert all users to SafeUser and create a hash | |
| 389 | - | const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user); | |
| 390 | - | const newHash = JSON.stringify(safeUsers); | |
| 391 | - | ||
| 392 | - | // Only update if the data has changed | |
| 393 | - | if (newHash !== this.lastBuddyListHash) { | |
| 394 | - | const statusCounts = safeUsers.reduce((acc, user) => { | |
| 395 | - | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 396 | - | return acc; | |
| 397 | - | }, {} as Record<string, number>); | |
| 398 | - | ||
| 399 | - | console.debug('Updating online users (data changed):', { | |
| 400 | - | total: safeUsers.length, | |
| 401 | - | byStatus: statusCounts, | |
| 402 | - | timestamp: new Date(now).toISOString() | |
| 403 | - | }); | |
| 404 | - | ||
| 405 | - | this.users = safeUsers; | |
| 406 | - | // Also update cache | |
| 407 | - | safeUsers.forEach(user => { | |
| 408 | - | this.userCache[user.id] = user; | |
| 409 | - | }); | |
| 410 | - | this.lastBuddyListHash = newHash; | |
| 411 | - | this.lastBuddyListUpdate = now; | |
| 412 | - | } | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | // Add method to update messages for public access | |
| 416 | - | updateMessages(messages: Message[]) { | |
| 417 | - | // Deduplicate the messages by their id in case duplicates exist | |
| 418 | - | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | |
| 419 | - | // Sort messages in ascending order (oldest first, newest at bottom) | |
| 420 | - | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | |
| 424 | - | async initializeMessages() { | |
| 425 | - | try { | |
| 426 | - | // Build the URL with appropriate parameters | |
| 427 | - | const params = new URLSearchParams(); | |
| 428 | - | if (!this.currentUser) { | |
| 429 | - | params.append('public', 'true'); | |
| 430 | - | } | |
| 431 | - | params.append('roomId', this.currentRoomId); | |
| 432 | - | ||
| 433 | - | const response = await fetch(`/api/chat/messages?${params}`, { | |
| 434 | - | credentials: 'include' | |
| 435 | - | }); | |
| 436 | - | if (!response.ok) throw new Error('Failed to fetch messages'); | |
| 437 | - | ||
| 438 | - | const data = await response.json() as GetMessagesResponse; | |
| 439 | - | if (!data.success) { | |
| 440 | - | throw new Error(data.error); | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | // Get unique user IDs from messages that aren't in the cache | |
| 444 | - | const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId)); | |
| 445 | - | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | |
| 446 | - | ||
| 447 | - | // Fetch missing user data in parallel | |
| 448 | - | if (missingUserIds.length > 0) { | |
| 449 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 450 | - | await Promise.all(missingUserIds.map(async (userId) => { | |
| 451 | - | try { | |
| 452 | - | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 453 | - | credentials: 'include' | |
| 454 | - | }); | |
| 455 | - | if (userResponse.ok) { | |
| 456 | - | const userData = await userResponse.json(); | |
| 457 | - | if (userData.success && userData.user) { | |
| 458 | - | this.userCache[userId] = userData.user; | |
| 459 | - | // Only add to users array if not already present | |
| 460 | - | if (!this.users.some(u => u.id === userId)) { | |
| 461 | - | this.users = [...this.users, userData.user]; | |
| 462 | - | } | |
| 463 | - | } | |
| 464 | - | } | |
| 465 | - | } catch (error) { | |
| 466 | - | console.debug('Error fetching user data:', { userId, error }); | |
| 467 | - | } | |
| 468 | - | })); | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | // Sort messages in ascending order (oldest first, most recent at the bottom) | |
| 472 | - | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 473 | - | ||
| 474 | - | // Update hasMore based on response | |
| 475 | - | if ('hasMore' in data) { | |
| 476 | - | this.hasMoreMessages = data.hasMore; | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | console.debug('Messages initialized:', { | |
| 480 | - | count: data.messages.length, | |
| 481 | - | isAuthenticated: !!this.currentUser, | |
| 482 | - | hasMore: this.hasMoreMessages | |
| 483 | - | }); | |
| 484 | - | } catch (error) { | |
| 485 | - | console.debug('Error fetching initial messages:', error); | |
| 486 | - | this.messages = []; | |
| 487 | - | this.hasMoreMessages = false; | |
| 488 | - | } | |
| 489 | - | } | |
| 490 | - | ||
| 491 | - | async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> { | |
| 492 | - | const user = this.getCurrentUser(); | |
| 493 | - | if (!user) { | |
| 494 | - | console.debug('Cannot send message: No current user'); | |
| 495 | - | return { | |
| 496 | - | success: false, | |
| 497 | - | error: 'Not logged in' | |
| 498 | - | }; | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | try { | |
| 502 | - | const payload: SendMessageRequest = { | |
| 503 | - | content, | |
| 504 | - | type, | |
| 505 | - | userId: user.id, | |
| 506 | - | chatRoomId: DEFAULT_CHAT_ROOM_ID | |
| 507 | - | }; | |
| 508 | - | ||
| 509 | - | // Add styleData if textStyle is provided | |
| 510 | - | if (textStyle) { | |
| 511 | - | (payload as any).styleData = JSON.stringify(textStyle); | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | console.debug('Sending message with payload:', payload); | |
| 515 | - | ||
| 516 | - | const response = await fetch('/api/chat/messages', { | |
| 517 | - | method: 'POST', | |
| 518 | - | headers: { | |
| 519 | - | 'Content-Type': 'application/json', | |
| 520 | - | }, | |
| 521 | - | credentials: 'include', | |
| 522 | - | body: JSON.stringify(payload) | |
| 523 | - | }); | |
| 524 | - | ||
| 525 | - | if (!response.ok) { | |
| 526 | - | const errorData = await response.json(); | |
| 527 | - | throw new Error(errorData.error || 'Failed to save message'); | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | const data = await response.json() as SendMessageResponse; | |
| 531 | - | if (!data.success) { | |
| 532 | - | throw new Error(data.error); | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | console.debug('Message sent successfully:', data.message); | |
| 536 | - | await invalidate('chat:messages'); | |
| 537 | - | return data; | |
| 538 | - | } catch (error) { | |
| 539 | - | console.debug('Error sending message:', error); | |
| 540 | - | return { | |
| 541 | - | success: false, | |
| 542 | - | error: 'Failed to send message' | |
| 543 | - | }; | |
| 544 | - | } | |
| 545 | - | } | |
| 546 | - | ||
| 547 | - | private setupSSE() { | |
| 548 | - | // Prevent creating multiple SSE connections if one is already active. | |
| 549 | - | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | |
| 550 | - | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 551 | - | return; | |
| 552 | - | } | |
| 553 | - | // Clear any existing timeouts | |
| 554 | - | if (this.connectionTimeout) { | |
| 555 | - | clearTimeout(this.connectionTimeout); | |
| 556 | - | this.connectionTimeout = null; | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | if (this.connectionAttemptTimeout) { | |
| 560 | - | clearTimeout(this.connectionAttemptTimeout); | |
| 561 | - | this.connectionAttemptTimeout = null; | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | // Debounce connection attempts | |
| 565 | - | this.connectionAttemptTimeout = setTimeout(() => { | |
| 566 | - | if (!this.currentUser) { | |
| 567 | - | console.debug('No current user, skipping SSE connection'); | |
| 568 | - | return; | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | if (this.isConnecting) { | |
| 572 | - | console.debug('Already connecting to SSE, skipping'); | |
| 573 | - | return; | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | this.connectSSE(); | |
| 577 | - | }, 1000); | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | private async connectSSE() { | |
| 581 | - | if (this.isConnecting) { | |
| 582 | - | console.debug('Already connecting to SSE, skipping'); | |
| 583 | - | return; | |
| 584 | - | } | |
| 585 | - | ||
| 586 | - | this.isConnecting = true; | |
| 587 | - | ||
| 588 | - | try { | |
| 589 | - | // Ensure clean disconnect of any existing connection | |
| 590 | - | if (this.eventSource) { | |
| 591 | - | console.debug('Closing existing SSE connection'); | |
| 592 | - | this.eventSource.close(); | |
| 593 | - | this.eventSource = null; | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | console.debug('Connecting to SSE...'); | |
| 597 | - | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | |
| 598 | - | // Reset the handlers flag for the new connection | |
| 599 | - | this.sseHandlersRegistered = false; | |
| 600 | - | ||
| 601 | - | this.eventSource.onopen = () => { | |
| 602 | - | console.debug('SSE connection established'); | |
| 603 | - | this.reconnectAttempts = 0; | |
| 604 | - | this.reconnectDelay = 1000; | |
| 605 | - | this.sseError = null; | |
| 606 | - | this.sseRetryAfter = null; | |
| 607 | - | this.isReconnecting = false; | |
| 608 | - | }; | |
| 609 | - | ||
| 610 | - | // Set up error handling | |
| 611 | - | this.eventSource.onerror = (error) => { | |
| 612 | - | console.debug('SSE connection error:', error); | |
| 613 | - | if (this.eventSource?.readyState === EventSource.CLOSED) { | |
| 614 | - | this.handleDisconnection(); | |
| 615 | - | } | |
| 616 | - | }; | |
| 617 | - | ||
| 618 | - | // Register event handlers (only once) | |
| 619 | - | this.setupMessageHandlers(); | |
| 620 | - | ||
| 621 | - | } catch (error) { | |
| 622 | - | console.debug('Error setting up SSE connection:', error); | |
| 623 | - | this.handleDisconnection(); | |
| 624 | - | } finally { | |
| 625 | - | this.isConnecting = false; | |
| 626 | - | } | |
| 627 | - | } | |
| 628 | - | ||
| 629 | - | // Register SSE event handlers only once per connection. | |
| 630 | - | private setupMessageHandlers() { | |
| 631 | - | if (this.sseHandlersRegistered || !this.eventSource) { | |
| 632 | - | return; | |
| 633 | - | } | |
| 634 | - | ||
| 635 | - | // Listen for chat messages | |
| 636 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | |
| 637 | - | try { | |
| 638 | - | const messageData = JSON.parse(event.data) as Message; | |
| 639 | - | console.debug('Received chat message via SSE:', messageData); | |
| 640 | - | // Deduplicate and update messages array with the new message | |
| 641 | - | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | |
| 642 | - | // Sort messages so the newest are at the bottom | |
| 643 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 644 | - | // Ensure the sender's data is available | |
| 645 | - | await this.ensureUserData(messageData.senderId); | |
| 646 | - | } catch (error) { | |
| 647 | - | console.debug('Error handling SSE chat message:', error); | |
| 648 | - | } | |
| 649 | - | }); | |
| 650 | - | ||
| 651 | - | // Modify buddy list update handler | |
| 652 | - | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | |
| 653 | - | try { | |
| 654 | - | const buddyList = JSON.parse(event.data) as SafeUser[]; | |
| 655 | - | this.updateOnlineUsers(buddyList); | |
| 656 | - | } catch (error) { | |
| 657 | - | console.debug('Error handling buddy list update via SSE:', error); | |
| 658 | - | } | |
| 659 | - | }); | |
| 660 | - | ||
| 661 | - | this.sseHandlersRegistered = true; | |
| 662 | - | } | |
| 663 | - | ||
| 664 | - | private async ensureUserData(userId: string) { | |
| 665 | - | if (this.userCache[userId]) return; | |
| 666 | - | ||
| 667 | - | console.debug('Fetching user data for:', userId); | |
| 668 | - | try { | |
| 669 | - | const response = await fetch(`/api/users/${userId}`, { | |
| 670 | - | credentials: 'include' | |
| 671 | - | }); | |
| 672 | - | if (response.ok) { | |
| 673 | - | const userData = await response.json(); | |
| 674 | - | if (userData.success && userData.user) { | |
| 675 | - | this.userCache[userId] = userData.user; | |
| 676 | - | if (!this.users.some(u => u.id === userId)) { | |
| 677 | - | this.users = [...this.users, userData.user]; | |
| 678 | - | } | |
| 679 | - | } | |
| 680 | - | } | |
| 681 | - | } catch (error) { | |
| 682 | - | console.debug('Error fetching user data:', error); | |
| 683 | - | } | |
| 684 | - | } | |
| 685 | - | ||
| 686 | - | private handleDisconnection() { | |
| 687 | - | console.debug('SSE connection closed, reconnecting...'); | |
| 688 | - | this.isReconnecting = true; | |
| 689 | - | this.reconnectAttempts++; | |
| 690 | - | if (this.reconnectAttempts > this.maxReconnectAttempts) { | |
| 691 | - | console.debug('Max reconnect attempts reached, giving up'); | |
| 692 | - | if (this.eventSource) { | |
| 693 | - | this.eventSource.close(); | |
| 694 | - | } | |
| 695 | - | this.eventSource = null; | |
| 696 | - | return; | |
| 697 | - | } | |
| 698 | - | this.reconnectDelay *= 2; // Exponential backoff | |
| 699 | - | // Ensure any existing connection is properly closed before reconnecting | |
| 700 | - | if (this.eventSource) { | |
| 701 | - | this.eventSource.close(); | |
| 702 | - | this.eventSource = null; | |
| 703 | - | } | |
| 704 | - | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. | |
| 705 | - | this.sseHandlersRegistered = false; | |
| 706 | - | this.setupSSE(); | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 710 | - | console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length }); | |
| 711 | - | return messages.map(message => ({ | |
| 712 | - | ...message, | |
| 713 | - | user: this.userCache[message.senderId] || { | |
| 714 | - | id: message.senderId, | |
| 715 | - | nickname: 'Unknown User', | |
| 716 | - | status: 'offline', | |
| 717 | - | avatarUrl: null, | |
| 718 | - | lastSeen: null | |
| 719 | - | } | |
| 720 | - | })); | |
| 721 | - | } | |
| 722 | - | ||
| 723 | - | getDefaultChatRoomId() { | |
| 724 | - | return DEFAULT_CHAT_ROOM_ID; | |
| 725 | - | } | |
| 16 | + | private users = $state<SafeUser[]>([]); | |
| 17 | + | private messages = $state<Message[]>([]); | |
| 18 | + | private currentUser = $state<SafeUser | null>(null); | |
| 19 | + | private eventSource: EventSource | null = null; | |
| 20 | + | private reconnectAttempts = 0; | |
| 21 | + | private maxReconnectAttempts = 5; | |
| 22 | + | private reconnectDelay = 1000; // Start with 1 second | |
| 23 | + | private isInitializing = $state(false); | |
| 24 | + | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 25 | + | private userCache = $state<Record<string, SafeUser>>({}); | |
| 26 | + | private sseError = $state<string | null>(null); | |
| 27 | + | private sseRetryAfter = $state<number | null>(null); | |
| 28 | + | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 29 | + | private isReconnecting = $state(false); | |
| 30 | + | private isSettingUser = $state(false); | |
| 31 | + | private isConnecting = $state(false); | |
| 32 | + | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 33 | + | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 34 | + | private _lastEnrichmentKey: string = ''; | |
| 35 | + | // Flag to avoid re-registering SSE event handlers | |
| 36 | + | private sseHandlersRegistered = false; | |
| 37 | + | private lastBuddyListUpdate = 0; | |
| 38 | + | private lastBuddyListHash = ''; | |
| 39 | + | private hasMoreMessages = $state(false); | |
| 40 | + | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | |
| 41 | + | ||
| 42 | + | public async reinitialize() { | |
| 43 | + | if (this.isInitializing) { | |
| 44 | + | console.debug('Reinitialize already in progress, skipping.'); | |
| 45 | + | return; | |
| 46 | + | } | |
| 47 | + | this.isInitializing = true; | |
| 48 | + | console.debug('Reinitializing chat state...'); | |
| 49 | + | if (this.eventSource) { | |
| 50 | + | this.eventSource.close(); | |
| 51 | + | this.eventSource = null; | |
| 52 | + | } | |
| 53 | + | this.reconnectAttempts = 0; | |
| 54 | + | this.reconnectDelay = 1000; | |
| 55 | + | this.messages = []; | |
| 56 | + | // Ensure currentRoomId is set | |
| 57 | + | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 58 | + | ||
| 59 | + | if (this.currentUser) { | |
| 60 | + | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 61 | + | await new Promise((resolve) => setTimeout(resolve, 1000)); | |
| 62 | + | // First set up SSE to receive status updates | |
| 63 | + | this.setupSSE(); | |
| 64 | + | // Then initialize messages and room users | |
| 65 | + | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | |
| 66 | + | } | |
| 67 | + | this.isInitializing = false; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | getSSEError() { | |
| 71 | + | return { | |
| 72 | + | error: this.sseError, | |
| 73 | + | retryAfter: this.sseRetryAfter | |
| 74 | + | }; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // User methods | |
| 78 | + | getCurrentUser() { | |
| 79 | + | return this.currentUser; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | async setCurrentUser(user: User | SafeUser | null) { | |
| 83 | + | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 84 | + | ||
| 85 | + | // Prevent concurrent setCurrentUser calls | |
| 86 | + | if (this.isSettingUser) { | |
| 87 | + | console.debug('Already setting user, skipping'); | |
| 88 | + | return; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | this.isSettingUser = true; | |
| 92 | + | ||
| 93 | + | try { | |
| 94 | + | // If user is null and we already have no user, skip | |
| 95 | + | if (!user && !this.currentUser) { | |
| 96 | + | console.debug('No user to set and no current user, skipping'); | |
| 97 | + | return; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Convert to SafeUser if full User object is passed | |
| 101 | + | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 102 | + | ||
| 103 | + | // If same user is being set, check SSE connection | |
| 104 | + | if (safeUser?.id === this.currentUser?.id) { | |
| 105 | + | if (!this.eventSource) { | |
| 106 | + | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 107 | + | } else { | |
| 108 | + | console.debug('Same user being set with active connection, skipping.'); | |
| 109 | + | return; | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Clear existing messages before setting new user | |
| 114 | + | this.messages = []; | |
| 115 | + | this.currentUser = safeUser; | |
| 116 | + | ||
| 117 | + | if (safeUser) { | |
| 118 | + | // Update user cache with current user | |
| 119 | + | this.userCache[safeUser.id] = safeUser; | |
| 120 | + | ||
| 121 | + | // Only reinitialize if we have a valid user | |
| 122 | + | await this.reinitialize(); | |
| 123 | + | } else { | |
| 124 | + | // Cleanup when user is removed | |
| 125 | + | if (this.eventSource) { | |
| 126 | + | this.eventSource.close(); | |
| 127 | + | this.eventSource = null; | |
| 128 | + | } | |
| 129 | + | this.messages = []; | |
| 130 | + | this.users = []; | |
| 131 | + | this.userCache = {}; | |
| 132 | + | } | |
| 133 | + | } finally { | |
| 134 | + | this.isSettingUser = false; | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | private async initializeRoomUsers() { | |
| 139 | + | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 140 | + | try { | |
| 141 | + | const isPublic = !this.currentUser; | |
| 142 | + | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 143 | + | console.debug('Fetching room users from:', url); | |
| 144 | + | ||
| 145 | + | const response = await fetch(url, { | |
| 146 | + | credentials: 'include' | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | if (!response.ok) { | |
| 150 | + | throw new Error('Failed to fetch room users'); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | const data = await response.json(); | |
| 154 | + | if (!data.success) { | |
| 155 | + | throw new Error(data.error); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | console.debug('Fetched room buddy list:', data.buddyList); | |
| 159 | + | // Merge fetched buddy list with existing cached users to preserve user details even if offline | |
| 160 | + | const mergedUsers = new Map<string, (typeof data.buddyList)[0]>(); | |
| 161 | + | // Add existing cached users | |
| 162 | + | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |
| 163 | + | ||
| 164 | + | // Overwrite/add with fetched buddy list | |
| 165 | + | data.buddyList.forEach((user: (typeof data.buddyList)[0]) => { | |
| 166 | + | mergedUsers.set(user.id, user); | |
| 167 | + | }); | |
| 168 | + | ||
| 169 | + | this.users = Array.from(mergedUsers.values()); | |
| 170 | + | ||
| 171 | + | // Update cache with merged users | |
| 172 | + | this.users.forEach((user) => { | |
| 173 | + | this.userCache[user.id] = user; | |
| 174 | + | }); | |
| 175 | + | } catch (error) { | |
| 176 | + | console.debug('Error fetching room users:', error); | |
| 177 | + | this.users = []; | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | getOnlineUsers() { | |
| 182 | + | // Sort users by status: online first, then away, then busy, then offline | |
| 183 | + | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 184 | + | const statusOrder: Record<SafeUser['status'], number> = { | |
| 185 | + | online: 0, | |
| 186 | + | away: 1, | |
| 187 | + | busy: 2, | |
| 188 | + | offline: 3 | |
| 189 | + | }; | |
| 190 | + | return statusOrder[a.status] - statusOrder[b.status]; | |
| 191 | + | }); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | getUserById(userId: string) { | |
| 195 | + | // First check the cache | |
| 196 | + | if (this.userCache[userId]) { | |
| 197 | + | return this.userCache[userId]; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Return a fallback object and cache it to avoid repeated fetches | |
| 201 | + | const fallback: SafeUser = { | |
| 202 | + | id: userId, | |
| 203 | + | nickname: 'Unknown User', | |
| 204 | + | status: 'offline', | |
| 205 | + | lastSeen: null, | |
| 206 | + | avatarUrl: null | |
| 207 | + | }; | |
| 208 | + | this.userCache[userId] = fallback; | |
| 209 | + | console.debug('getUserById: returning fallback for missing user', { userId, fallback }); | |
| 210 | + | return fallback; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | |
| 214 | + | console.debug('Updating user status with:', { | |
| 215 | + | userId, | |
| 216 | + | status, | |
| 217 | + | lastSeen, | |
| 218 | + | currentCache: this.userCache[userId], | |
| 219 | + | currentUsers: this.users.find((u) => u.id === userId) | |
| 220 | + | }); | |
| 221 | + | ||
| 222 | + | const existingUser = this.userCache[userId]; | |
| 223 | + | if ( | |
| 224 | + | existingUser && | |
| 225 | + | existingUser.status === status && | |
| 226 | + | lastSeen && | |
| 227 | + | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | |
| 228 | + | ) { | |
| 229 | + | console.debug('Skipping redundant status update for user:', userId); | |
| 230 | + | return; | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Try to get the user from cache first | |
| 234 | + | let user: SafeUser | undefined = this.userCache[userId]; | |
| 235 | + | ||
| 236 | + | // If not in cache, try to find in users array | |
| 237 | + | if (!user) { | |
| 238 | + | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 239 | + | if (foundUser) { | |
| 240 | + | user = foundUser; | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | const now = Date.now(); | |
| 245 | + | if (user) { | |
| 246 | + | const updatedUser: SafeUser = { | |
| 247 | + | ...user, | |
| 248 | + | status, | |
| 249 | + | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 250 | + | }; | |
| 251 | + | ||
| 252 | + | // Update cache first | |
| 253 | + | this.userCache[userId] = updatedUser; | |
| 254 | + | ||
| 255 | + | // Then update or add to users array | |
| 256 | + | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 257 | + | if (index !== -1) { | |
| 258 | + | // Create new array with updated user | |
| 259 | + | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | |
| 260 | + | } else { | |
| 261 | + | this.users = [...this.users, updatedUser]; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | console.debug('User status updated:', { | |
| 265 | + | userId, | |
| 266 | + | newStatus: status, | |
| 267 | + | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), | |
| 268 | + | updatedInCache: this.userCache[userId].status === status, | |
| 269 | + | updatedInList: this.users.find((u) => u.id === userId)?.status === status | |
| 270 | + | }); | |
| 271 | + | } else { | |
| 272 | + | // If user not found anywhere, fetch from API | |
| 273 | + | console.debug('User not found in cache or list, fetching from API:', userId); | |
| 274 | + | fetch(`/api/users/${userId}`, { credentials: 'include' }) | |
| 275 | + | .then((response) => response.json()) | |
| 276 | + | .then((data) => { | |
| 277 | + | if (data.success && data.user) { | |
| 278 | + | const newUser: SafeUser = { | |
| 279 | + | ...data.user, | |
| 280 | + | status, | |
| 281 | + | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| 282 | + | }; | |
| 283 | + | // Update both cache and users array | |
| 284 | + | this.userCache[userId] = newUser; | |
| 285 | + | this.users = [...this.users, newUser]; | |
| 286 | + | ||
| 287 | + | console.debug('User fetched and status updated:', { | |
| 288 | + | userId, | |
| 289 | + | status: newUser.status, | |
| 290 | + | lastSeen: new Date(newUser.lastSeen || now).toISOString(), | |
| 291 | + | addedToCache: !!this.userCache[userId], | |
| 292 | + | addedToList: this.users.some((u) => u.id === userId) | |
| 293 | + | }); | |
| 294 | + | } | |
| 295 | + | }) | |
| 296 | + | .catch((error) => console.error('Error fetching user data:', error)); | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // Message methods | |
| 301 | + | getMessages() { | |
| 302 | + | const key = | |
| 303 | + | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| 304 | + | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { | |
| 305 | + | return this._memoizedEnrichedMessages; | |
| 306 | + | } | |
| 307 | + | this._lastEnrichmentKey = key; | |
| 308 | + | console.debug('Enriching messages with user data', { | |
| 309 | + | messagesCount: this.messages.length, | |
| 310 | + | userCacheSize: Object.keys(this.userCache).length | |
| 311 | + | }); | |
| 312 | + | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | |
| 313 | + | return this._memoizedEnrichedMessages; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 317 | + | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 318 | + | // Get unique user IDs from new messages that aren't in the cache | |
| 319 | + | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | |
| 320 | + | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 321 | + | ||
| 322 | + | // Fetch missing user data in parallel | |
| 323 | + | if (missingUserIds.length > 0) { | |
| 324 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 325 | + | Promise.all( | |
| 326 | + | missingUserIds.map(async (userId) => { | |
| 327 | + | try { | |
| 328 | + | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 329 | + | credentials: 'include' | |
| 330 | + | }); | |
| 331 | + | if (userResponse.ok) { | |
| 332 | + | const userData = await userResponse.json(); | |
| 333 | + | if (userData.success && userData.user) { | |
| 334 | + | this.userCache[userId] = userData.user; | |
| 335 | + | // Only add to users array if not already present | |
| 336 | + | if (!this.users.some((u) => u.id === userId)) { | |
| 337 | + | this.users = [...this.users, userData.user]; | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | } catch (error) { | |
| 342 | + | console.debug('Error fetching user data:', { userId, error }); | |
| 343 | + | } | |
| 344 | + | }) | |
| 345 | + | ); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // Ensure incoming messages are sorted in ascending order before prepending | |
| 349 | + | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 350 | + | const uniqueNewMessages = sortedNewMessages.filter( | |
| 351 | + | (msg) => !this.messages.some((m) => m.id === msg.id) | |
| 352 | + | ); | |
| 353 | + | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 354 | + | ||
| 355 | + | // Update hasMore if provided | |
| 356 | + | if (hasMore !== undefined) { | |
| 357 | + | this.hasMoreMessages = hasMore; | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // Add method to update user cache for public access | |
| 362 | + | updateUserCache(users: (User | SafeUser)[]) { | |
| 363 | + | console.debug( | |
| 364 | + | 'Updating user cache with users:', | |
| 365 | + | users.map((u) => ({ ...u, password: '[REDACTED]' })) | |
| 366 | + | ); | |
| 367 | + | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |
| 368 | + | users.forEach((user) => { | |
| 369 | + | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 370 | + | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 371 | + | this.userCache[user.id] = safeUser; | |
| 372 | + | // Update users array if not already present or if status changed | |
| 373 | + | const existingIndex = this.users.findIndex((u) => u.id === user.id); | |
| 374 | + | if (existingIndex === -1) { | |
| 375 | + | this.users = [...this.users, safeUser]; | |
| 376 | + | } else if (safeUser.status !== this.users[existingIndex].status) { | |
| 377 | + | this.users = [ | |
| 378 | + | ...this.users.slice(0, existingIndex), | |
| 379 | + | safeUser, | |
| 380 | + | ...this.users.slice(existingIndex + 1) | |
| 381 | + | ]; | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | }); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // Add method to update online users for public access | |
| 388 | + | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 389 | + | const now = Date.now(); | |
| 390 | + | // Convert all users to SafeUser and create a hash | |
| 391 | + | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | |
| 392 | + | const newHash = JSON.stringify(safeUsers); | |
| 393 | + | ||
| 394 | + | // Only update if the data has changed | |
| 395 | + | if (newHash !== this.lastBuddyListHash) { | |
| 396 | + | const statusCounts = safeUsers.reduce( | |
| 397 | + | (acc, user) => { | |
| 398 | + | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 399 | + | return acc; | |
| 400 | + | }, | |
| 401 | + | {} as Record<string, number> | |
| 402 | + | ); | |
| 403 | + | ||
| 404 | + | console.debug('Updating online users (data changed):', { | |
| 405 | + | total: safeUsers.length, | |
| 406 | + | byStatus: statusCounts, | |
| 407 | + | timestamp: new Date(now).toISOString() | |
| 408 | + | }); | |
| 409 | + | ||
| 410 | + | this.users = safeUsers; | |
| 411 | + | // Also update cache | |
| 412 | + | safeUsers.forEach((user) => { | |
| 413 | + | this.userCache[user.id] = user; | |
| 414 | + | }); | |
| 415 | + | this.lastBuddyListHash = newHash; | |
| 416 | + | this.lastBuddyListUpdate = now; | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // Add method to update messages for public access | |
| 421 | + | updateMessages(messages: Message[]) { | |
| 422 | + | // Deduplicate the messages by their id in case duplicates exist | |
| 423 | + | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); | |
| 424 | + | // Sort messages in ascending order (oldest first, newest at bottom) | |
| 425 | + | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | |
| 429 | + | async initializeMessages() { | |
| 430 | + | try { | |
| 431 | + | // Build the URL with appropriate parameters | |
| 432 | + | const params = new URLSearchParams(); | |
| 433 | + | if (!this.currentUser) { | |
| 434 | + | params.append('public', 'true'); | |
| 435 | + | } | |
| 436 | + | params.append('roomId', this.currentRoomId); | |
| 437 | + | ||
| 438 | + | const response = await fetch(`/api/chat/messages?${params}`, { | |
| 439 | + | credentials: 'include' | |
| 440 | + | }); | |
| 441 | + | if (!response.ok) throw new Error('Failed to fetch messages'); | |
| 442 | + | ||
| 443 | + | const data = (await response.json()) as GetMessagesResponse; | |
| 444 | + | if (!data.success) { | |
| 445 | + | throw new Error(data.error); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // Get unique user IDs from messages that aren't in the cache | |
| 449 | + | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | |
| 450 | + | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 451 | + | ||
| 452 | + | // Fetch missing user data in parallel | |
| 453 | + | if (missingUserIds.length > 0) { | |
| 454 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 455 | + | await Promise.all( | |
| 456 | + | missingUserIds.map(async (userId) => { | |
| 457 | + | try { | |
| 458 | + | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 459 | + | credentials: 'include' | |
| 460 | + | }); | |
| 461 | + | if (userResponse.ok) { | |
| 462 | + | const userData = await userResponse.json(); | |
| 463 | + | if (userData.success && userData.user) { | |
| 464 | + | this.userCache[userId] = userData.user; | |
| 465 | + | // Only add to users array if not already present | |
| 466 | + | if (!this.users.some((u) => u.id === userId)) { | |
| 467 | + | this.users = [...this.users, userData.user]; | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | } catch (error) { | |
| 472 | + | console.debug('Error fetching user data:', { userId, error }); | |
| 473 | + | } | |
| 474 | + | }) | |
| 475 | + | ); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // Sort messages in ascending order (oldest first, most recent at the bottom) | |
| 479 | + | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 480 | + | ||
| 481 | + | // Update hasMore based on response | |
| 482 | + | if ('hasMore' in data) { | |
| 483 | + | this.hasMoreMessages = data.hasMore; | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | console.debug('Messages initialized:', { | |
| 487 | + | count: data.messages.length, | |
| 488 | + | isAuthenticated: !!this.currentUser, | |
| 489 | + | hasMore: this.hasMoreMessages | |
| 490 | + | }); | |
| 491 | + | } catch (error) { | |
| 492 | + | console.debug('Error fetching initial messages:', error); | |
| 493 | + | this.messages = []; | |
| 494 | + | this.hasMoreMessages = false; | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | async sendMessage( | |
| 499 | + | content: string, | |
| 500 | + | type: Message['type'] = 'chat', | |
| 501 | + | textStyle?: any | |
| 502 | + | ): Promise<SendMessageResponse> { | |
| 503 | + | const user = this.getCurrentUser(); | |
| 504 | + | if (!user) { | |
| 505 | + | console.debug('Cannot send message: No current user'); | |
| 506 | + | return { | |
| 507 | + | success: false, | |
| 508 | + | error: 'Not logged in' | |
| 509 | + | }; | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | try { | |
| 513 | + | const payload: SendMessageRequest = { | |
| 514 | + | content, | |
| 515 | + | type, | |
| 516 | + | userId: user.id, | |
| 517 | + | chatRoomId: DEFAULT_CHAT_ROOM_ID | |
| 518 | + | }; | |
| 519 | + | ||
| 520 | + | // Add styleData if textStyle is provided | |
| 521 | + | if (textStyle) { | |
| 522 | + | (payload as any).styleData = JSON.stringify(textStyle); | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | console.debug('Sending message with payload:', payload); | |
| 526 | + | ||
| 527 | + | const response = await fetch('/api/chat/messages', { | |
| 528 | + | method: 'POST', | |
| 529 | + | headers: { | |
| 530 | + | 'Content-Type': 'application/json' | |
| 531 | + | }, | |
| 532 | + | credentials: 'include', | |
| 533 | + | body: JSON.stringify(payload) | |
| 534 | + | }); | |
| 535 | + | ||
| 536 | + | if (!response.ok) { | |
| 537 | + | const errorData = await response.json(); | |
| 538 | + | throw new Error(errorData.error || 'Failed to save message'); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | const data = (await response.json()) as SendMessageResponse; | |
| 542 | + | if (!data.success) { | |
| 543 | + | throw new Error(data.error); | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | console.debug('Message sent successfully:', data.message); | |
| 547 | + | await invalidate('chat:messages'); | |
| 548 | + | return data; | |
| 549 | + | } catch (error) { | |
| 550 | + | console.debug('Error sending message:', error); | |
| 551 | + | return { | |
| 552 | + | success: false, | |
| 553 | + | error: 'Failed to send message' | |
| 554 | + | }; | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | private setupSSE() { | |
| 559 | + | // Prevent creating multiple SSE connections if one is already active. | |
| 560 | + | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | |
| 561 | + | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 562 | + | return; | |
| 563 | + | } | |
| 564 | + | // Clear any existing timeouts | |
| 565 | + | if (this.connectionTimeout) { | |
| 566 | + | clearTimeout(this.connectionTimeout); | |
| 567 | + | this.connectionTimeout = null; | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | if (this.connectionAttemptTimeout) { | |
| 571 | + | clearTimeout(this.connectionAttemptTimeout); | |
| 572 | + | this.connectionAttemptTimeout = null; | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // Debounce connection attempts | |
| 576 | + | this.connectionAttemptTimeout = setTimeout(() => { | |
| 577 | + | if (!this.currentUser) { | |
| 578 | + | console.debug('No current user, skipping SSE connection'); | |
| 579 | + | return; | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | if (this.isConnecting) { | |
| 583 | + | console.debug('Already connecting to SSE, skipping'); | |
| 584 | + | return; | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | this.connectSSE(); | |
| 588 | + | }, 1000); | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | private async connectSSE() { | |
| 592 | + | if (this.isConnecting) { | |
| 593 | + | console.debug('Already connecting to SSE, skipping'); | |
| 594 | + | return; | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | this.isConnecting = true; | |
| 598 | + | ||
| 599 | + | try { | |
| 600 | + | // Ensure clean disconnect of any existing connection | |
| 601 | + | if (this.eventSource) { | |
| 602 | + | console.debug('Closing existing SSE connection'); | |
| 603 | + | this.eventSource.close(); | |
| 604 | + | this.eventSource = null; | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | console.debug('Connecting to SSE...'); | |
| 608 | + | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | |
| 609 | + | // Reset the handlers flag for the new connection | |
| 610 | + | this.sseHandlersRegistered = false; | |
| 611 | + | ||
| 612 | + | this.eventSource.onopen = () => { | |
| 613 | + | console.debug('SSE connection established'); | |
| 614 | + | this.reconnectAttempts = 0; | |
| 615 | + | this.reconnectDelay = 1000; | |
| 616 | + | this.sseError = null; | |
| 617 | + | this.sseRetryAfter = null; | |
| 618 | + | this.isReconnecting = false; | |
| 619 | + | }; | |
| 620 | + | ||
| 621 | + | // Set up error handling | |
| 622 | + | this.eventSource.onerror = (error) => { | |
| 623 | + | console.debug('SSE connection error:', error); | |
| 624 | + | if (this.eventSource?.readyState === EventSource.CLOSED) { | |
| 625 | + | this.handleDisconnection(); | |
| 626 | + | } | |
| 627 | + | }; | |
| 628 | + | ||
| 629 | + | // Register event handlers (only once) | |
| 630 | + | this.setupMessageHandlers(); | |
| 631 | + | } catch (error) { | |
| 632 | + | console.debug('Error setting up SSE connection:', error); | |
| 633 | + | this.handleDisconnection(); | |
| 634 | + | } finally { | |
| 635 | + | this.isConnecting = false; | |
| 636 | + | } | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // Register SSE event handlers only once per connection. | |
| 640 | + | private setupMessageHandlers() { | |
| 641 | + | if (this.sseHandlersRegistered || !this.eventSource) { | |
| 642 | + | return; | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // Listen for chat messages | |
| 646 | + | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | |
| 647 | + | try { | |
| 648 | + | const messageData = JSON.parse(event.data) as Message; | |
| 649 | + | console.debug('Received chat message via SSE:', messageData); | |
| 650 | + | // Deduplicate and update messages array with the new message | |
| 651 | + | this.messages = Array.from( | |
| 652 | + | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() | |
| 653 | + | ); | |
| 654 | + | // Sort messages so the newest are at the bottom | |
| 655 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 656 | + | // Ensure the sender's data is available | |
| 657 | + | await this.ensureUserData(messageData.senderId); | |
| 658 | + | } catch (error) { | |
| 659 | + | console.debug('Error handling SSE chat message:', error); | |
| 660 | + | } | |
| 661 | + | }); | |
| 662 | + | ||
| 663 | + | // Modify buddy list update handler | |
| 664 | + | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | |
| 665 | + | try { | |
| 666 | + | const buddyList = JSON.parse(event.data) as SafeUser[]; | |
| 667 | + | this.updateOnlineUsers(buddyList); | |
| 668 | + | } catch (error) { | |
| 669 | + | console.debug('Error handling buddy list update via SSE:', error); | |
| 670 | + | } | |
| 671 | + | }); | |
| 672 | + | ||
| 673 | + | this.sseHandlersRegistered = true; | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | private async ensureUserData(userId: string) { | |
| 677 | + | if (this.userCache[userId]) return; | |
| 678 | + | ||
| 679 | + | console.debug('Fetching user data for:', userId); | |
| 680 | + | try { | |
| 681 | + | const response = await fetch(`/api/users/${userId}`, { | |
| 682 | + | credentials: 'include' | |
| 683 | + | }); | |
| 684 | + | if (response.ok) { | |
| 685 | + | const userData = await response.json(); | |
| 686 | + | if (userData.success && userData.user) { | |
| 687 | + | this.userCache[userId] = userData.user; | |
| 688 | + | if (!this.users.some((u) => u.id === userId)) { | |
| 689 | + | this.users = [...this.users, userData.user]; | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | } catch (error) { | |
| 694 | + | console.debug('Error fetching user data:', error); | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | private handleDisconnection() { | |
| 699 | + | console.debug('SSE connection closed, reconnecting...'); | |
| 700 | + | this.isReconnecting = true; | |
| 701 | + | this.reconnectAttempts++; | |
| 702 | + | if (this.reconnectAttempts > this.maxReconnectAttempts) { | |
| 703 | + | console.debug('Max reconnect attempts reached, giving up'); | |
| 704 | + | if (this.eventSource) { | |
| 705 | + | this.eventSource.close(); | |
| 706 | + | } | |
| 707 | + | this.eventSource = null; | |
| 708 | + | return; | |
| 709 | + | } | |
| 710 | + | this.reconnectDelay *= 2; // Exponential backoff | |
| 711 | + | // Ensure any existing connection is properly closed before reconnecting | |
| 712 | + | if (this.eventSource) { | |
| 713 | + | this.eventSource.close(); | |
| 714 | + | this.eventSource = null; | |
| 715 | + | } | |
| 716 | + | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. | |
| 717 | + | this.sseHandlersRegistered = false; | |
| 718 | + | this.setupSSE(); | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 722 | + | console.debug('Enriching messages with user data', { | |
| 723 | + | messagesCount: messages.length, | |
| 724 | + | userCacheSize: Object.keys(this.userCache).length | |
| 725 | + | }); | |
| 726 | + | return messages.map((message) => ({ | |
| 727 | + | ...message, | |
| 728 | + | user: this.userCache[message.senderId] || { | |
| 729 | + | id: message.senderId, | |
| 730 | + | nickname: 'Unknown User', | |
| 731 | + | status: 'offline', | |
| 732 | + | avatarUrl: null, | |
| 733 | + | lastSeen: null | |
| 734 | + | } | |
| 735 | + | })); | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | getDefaultChatRoomId() { | |
| 739 | + | return DEFAULT_CHAT_ROOM_ID; | |
| 740 | + | } | |
| 726 | 741 | } | |
| 727 | 742 | ||
| 728 | - | export const chatState = new ChatState();⏎̸ | |
| 743 | + | export const chatState = new ChatState(); | |
M src/lib/styles/fonts.css +50 -50
| @@ -6,95 +6,95 @@ | |||
| 6 | 6 | ||
| 7 | 7 | /* Tahoma - Primary UI font */ | |
| 8 | 8 | @font-face { | |
| 9 | - | font-family: 'Tahoma Web'; | |
| 10 | - | src: local('Tahoma'), local('Tahoma Regular'); | |
| 11 | - | font-weight: normal; | |
| 12 | - | font-style: normal; | |
| 9 | + | font-family: 'Tahoma Web'; | |
| 10 | + | src: local('Tahoma'), local('Tahoma Regular'); | |
| 11 | + | font-weight: normal; | |
| 12 | + | font-style: normal; | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 15 | @font-face { | |
| 16 | - | font-family: 'Tahoma Web'; | |
| 17 | - | src: local('Tahoma Bold'); | |
| 18 | - | font-weight: bold; | |
| 19 | - | font-style: normal; | |
| 16 | + | font-family: 'Tahoma Web'; | |
| 17 | + | src: local('Tahoma Bold'); | |
| 18 | + | font-weight: bold; | |
| 19 | + | font-style: normal; | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | /* Verdana - Secondary UI font */ | |
| 23 | 23 | @font-face { | |
| 24 | - | font-family: 'Verdana Web'; | |
| 25 | - | src: local('Verdana'), local('Verdana Regular'); | |
| 26 | - | font-weight: normal; | |
| 27 | - | font-style: normal; | |
| 24 | + | font-family: 'Verdana Web'; | |
| 25 | + | src: local('Verdana'), local('Verdana Regular'); | |
| 26 | + | font-weight: normal; | |
| 27 | + | font-style: normal; | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | @font-face { | |
| 31 | - | font-family: 'Verdana Web'; | |
| 32 | - | src: local('Verdana Bold'); | |
| 33 | - | font-weight: bold; | |
| 34 | - | font-style: normal; | |
| 31 | + | font-family: 'Verdana Web'; | |
| 32 | + | src: local('Verdana Bold'); | |
| 33 | + | font-weight: bold; | |
| 34 | + | font-style: normal; | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | /* Arial - General purpose */ | |
| 38 | 38 | @font-face { | |
| 39 | - | font-family: 'Arial Web'; | |
| 40 | - | src: local('Arial'), local('Arial Regular'); | |
| 41 | - | font-weight: normal; | |
| 42 | - | font-style: normal; | |
| 39 | + | font-family: 'Arial Web'; | |
| 40 | + | src: local('Arial'), local('Arial Regular'); | |
| 41 | + | font-weight: normal; | |
| 42 | + | font-style: normal; | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | @font-face { | |
| 46 | - | font-family: 'Arial Web'; | |
| 47 | - | src: local('Arial Bold'); | |
| 48 | - | font-weight: bold; | |
| 49 | - | font-style: normal; | |
| 46 | + | font-family: 'Arial Web'; | |
| 47 | + | src: local('Arial Bold'); | |
| 48 | + | font-weight: bold; | |
| 49 | + | font-style: normal; | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | /* Times New Roman - Serif font */ | |
| 53 | 53 | @font-face { | |
| 54 | - | font-family: 'Times New Roman Web'; | |
| 55 | - | src: local('Times New Roman'), local('Times New Roman Regular'); | |
| 56 | - | font-weight: normal; | |
| 57 | - | font-style: normal; | |
| 54 | + | font-family: 'Times New Roman Web'; | |
| 55 | + | src: local('Times New Roman'), local('Times New Roman Regular'); | |
| 56 | + | font-weight: normal; | |
| 57 | + | font-style: normal; | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | @font-face { | |
| 61 | - | font-family: 'Times New Roman Web'; | |
| 62 | - | src: local('Times New Roman Bold'); | |
| 63 | - | font-weight: bold; | |
| 64 | - | font-style: normal; | |
| 61 | + | font-family: 'Times New Roman Web'; | |
| 62 | + | src: local('Times New Roman Bold'); | |
| 63 | + | font-weight: bold; | |
| 64 | + | font-style: normal; | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | /* Courier New - Monospace */ | |
| 68 | 68 | @font-face { | |
| 69 | - | font-family: 'Courier New Web'; | |
| 70 | - | src: local('Courier New'), local('Courier New Regular'); | |
| 71 | - | font-weight: normal; | |
| 72 | - | font-style: normal; | |
| 69 | + | font-family: 'Courier New Web'; | |
| 70 | + | src: local('Courier New'), local('Courier New Regular'); | |
| 71 | + | font-weight: normal; | |
| 72 | + | font-style: normal; | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | @font-face { | |
| 76 | - | font-family: 'Courier New Web'; | |
| 77 | - | src: local('Courier New Bold'); | |
| 78 | - | font-weight: bold; | |
| 79 | - | font-style: normal; | |
| 76 | + | font-family: 'Courier New Web'; | |
| 77 | + | src: local('Courier New Bold'); | |
| 78 | + | font-weight: bold; | |
| 79 | + | font-style: normal; | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | /* Comic Sans MS - Fun font */ | |
| 83 | 83 | @font-face { | |
| 84 | - | font-family: 'Comic Sans MS Web'; | |
| 85 | - | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | |
| 86 | - | font-weight: normal; | |
| 87 | - | font-style: normal; | |
| 84 | + | font-family: 'Comic Sans MS Web'; | |
| 85 | + | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | |
| 86 | + | font-weight: normal; | |
| 87 | + | font-style: normal; | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | @font-face { | |
| 91 | - | font-family: 'Comic Sans MS Web'; | |
| 92 | - | src: local('Comic Sans MS Bold'); | |
| 93 | - | font-weight: bold; | |
| 94 | - | font-style: normal; | |
| 91 | + | font-family: 'Comic Sans MS Web'; | |
| 92 | + | src: local('Comic Sans MS Bold'); | |
| 93 | + | font-weight: bold; | |
| 94 | + | font-style: normal; | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | /* Ensure font-display for better performance */ | |
| 98 | 98 | @font-face { | |
| 99 | - | font-display: swap; | |
| 100 | - | }⏎̸ | |
| 99 | + | font-display: swap; | |
| 100 | + | } | |
M src/lib/types/chat.ts +59 -58
| @@ -1,62 +1,63 @@ | |||
| 1 | 1 | // Users table: holds authentication and profile details | |
| 2 | 2 | export interface User { | |
| 3 | - | id: string; | |
| 4 | - | password: string; // hashed password | |
| 5 | - | nickname: string; | |
| 6 | - | status: UserStatus; // default is 'offline' | |
| 7 | - | avatarUrl?: string | null; | |
| 8 | - | createdAt: number; // Timestamp (e.g., Unix timestamp) | |
| 9 | - | lastSeen?: number | null; // Optional last active timestamp | |
| 10 | - | } | |
| 3 | + | id: string; | |
| 4 | + | password: string; // hashed password | |
| 5 | + | nickname: string; | |
| 6 | + | status: UserStatus; // default is 'offline' | |
| 7 | + | avatarUrl?: string | null; | |
| 8 | + | createdAt: number; // Timestamp (e.g., Unix timestamp) | |
| 9 | + | lastSeen?: number | null; // Optional last active timestamp | |
| 10 | + | } | |
| 11 | 11 | ||
| 12 | 12 | export type UserStatus = 'offline' | 'online' | 'away' | string; | |
| 13 | - | ||
| 14 | - | // Chat rooms table: supports both direct (private) and group chat conversations | |
| 15 | - | export interface ChatRoom { | |
| 16 | - | id: string; | |
| 17 | - | name?: string; // Optional for direct chats, required for groups | |
| 18 | - | type: 'direct' | 'group' | string; // Default is 'direct' | |
| 19 | - | createdAt: number; // Creation timestamp | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // Messages table: stores the conversation messages | |
| 23 | - | export interface Message { | |
| 24 | - | id: string; | |
| 25 | - | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | |
| 26 | - | senderId: string; // Reference to the sender (User.id) | |
| 27 | - | content: string; | |
| 28 | - | type: MessageType; // Default is 'chat' | |
| 29 | - | timestamp: number; // Timestamp when the message was sent | |
| 30 | - | styleData?: string; // JSON string of TextStyle object for formatting | |
| 31 | - | hasFormatting?: boolean; // Flag to indicate if message has custom formatting | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Safe user type for API responses - omits sensitive information | |
| 35 | - | export type SafeUser = Omit<User, 'password' | 'createdAt'>; | |
| 36 | - | ||
| 37 | - | // Helper function to create a safe user object | |
| 38 | - | export function createSafeUser(user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'>): SafeUser { | |
| 39 | - | const safeUser: SafeUser = { | |
| 40 | - | id: user.id, | |
| 41 | - | nickname: user.nickname, | |
| 42 | - | status: user.status, | |
| 43 | - | avatarUrl: user.avatarUrl, | |
| 44 | - | lastSeen: user.lastSeen ?? 0 | |
| 45 | - | }; | |
| 46 | - | return safeUser; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Update EnrichedMessage to use SafeUser | |
| 50 | - | export interface EnrichedMessage extends Message { | |
| 51 | - | user: SafeUser; | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | export type MessageType = 'chat' | 'emote' | 'system' | string; | |
| 55 | - | ||
| 56 | - | export interface Session { | |
| 57 | - | id: string; | |
| 58 | - | userId: string; | |
| 59 | - | expiresAt: number; | |
| 60 | - | createdAt: number; | |
| 61 | - | } | |
| 62 | - | ||
| 13 | + | ||
| 14 | + | // Chat rooms table: supports both direct (private) and group chat conversations | |
| 15 | + | export interface ChatRoom { | |
| 16 | + | id: string; | |
| 17 | + | name?: string; // Optional for direct chats, required for groups | |
| 18 | + | type: 'direct' | 'group' | string; // Default is 'direct' | |
| 19 | + | createdAt: number; // Creation timestamp | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Messages table: stores the conversation messages | |
| 23 | + | export interface Message { | |
| 24 | + | id: string; | |
| 25 | + | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | |
| 26 | + | senderId: string; // Reference to the sender (User.id) | |
| 27 | + | content: string; | |
| 28 | + | type: MessageType; // Default is 'chat' | |
| 29 | + | timestamp: number; // Timestamp when the message was sent | |
| 30 | + | styleData?: string; // JSON string of TextStyle object for formatting | |
| 31 | + | hasFormatting?: boolean; // Flag to indicate if message has custom formatting | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Safe user type for API responses - omits sensitive information | |
| 35 | + | export type SafeUser = Omit<User, 'password' | 'createdAt'>; | |
| 36 | + | ||
| 37 | + | // Helper function to create a safe user object | |
| 38 | + | export function createSafeUser( | |
| 39 | + | user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'> | |
| 40 | + | ): SafeUser { | |
| 41 | + | const safeUser: SafeUser = { | |
| 42 | + | id: user.id, | |
| 43 | + | nickname: user.nickname, | |
| 44 | + | status: user.status, | |
| 45 | + | avatarUrl: user.avatarUrl, | |
| 46 | + | lastSeen: user.lastSeen ?? 0 | |
| 47 | + | }; | |
| 48 | + | return safeUser; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Update EnrichedMessage to use SafeUser | |
| 52 | + | export interface EnrichedMessage extends Message { | |
| 53 | + | user: SafeUser; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | export type MessageType = 'chat' | 'emote' | 'system' | string; | |
| 57 | + | ||
| 58 | + | export interface Session { | |
| 59 | + | id: string; | |
| 60 | + | userId: string; | |
| 61 | + | expiresAt: number; | |
| 62 | + | createdAt: number; | |
| 63 | + | } | |
M src/lib/types/payloads.ts +22 -22
| @@ -1,4 +1,4 @@ | |||
| 1 | - | import type { SafeUser, Message } from "./chat"; | |
| 1 | + | import type { SafeUser, Message } from './chat'; | |
| 2 | 2 | ||
| 3 | 3 | // Login | |
| 4 | 4 | export interface LoginResponseSuccess { | |
| @@ -40,36 +40,36 @@ export interface SessionResponseError { | |||
| 40 | 40 | ||
| 41 | 41 | // Messages | |
| 42 | 42 | export interface SendMessageRequest { | |
| 43 | - | content: string; | |
| 44 | - | type?: Message['type']; | |
| 45 | - | userId: string; | |
| 46 | - | chatRoomId?: string; | |
| 47 | - | styleData?: string; // JSON stringified TextStyle | |
| 43 | + | content: string; | |
| 44 | + | type?: Message['type']; | |
| 45 | + | userId: string; | |
| 46 | + | chatRoomId?: string; | |
| 47 | + | styleData?: string; // JSON stringified TextStyle | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | export interface SendMessageResponseSuccess { | |
| 51 | - | success: true; | |
| 52 | - | message: Message; | |
| 51 | + | success: true; | |
| 52 | + | message: Message; | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | export interface SendMessageResponseError { | |
| 56 | - | success: false; | |
| 57 | - | error: string; | |
| 58 | - | retryAfter?: number; // Time in ms until next allowed message | |
| 59 | - | isRateLimited?: boolean; | |
| 56 | + | success: false; | |
| 57 | + | error: string; | |
| 58 | + | retryAfter?: number; // Time in ms until next allowed message | |
| 59 | + | isRateLimited?: boolean; | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | |
| 63 | 63 | ||
| 64 | 64 | export interface GetMessagesResponseSuccess { | |
| 65 | - | success: true; | |
| 66 | - | messages: Message[]; | |
| 67 | - | hasMore: boolean; | |
| 65 | + | success: true; | |
| 66 | + | messages: Message[]; | |
| 67 | + | hasMore: boolean; | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | 70 | export interface GetMessagesResponseError { | |
| 71 | - | success: false; | |
| 72 | - | error: string; | |
| 71 | + | success: false; | |
| 72 | + | error: string; | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError; | |
| @@ -77,13 +77,13 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon | |||
| 77 | 77 | // ----- Public Room Payloads ----- | |
| 78 | 78 | ||
| 79 | 79 | export interface PublicRoomResponseSuccess { | |
| 80 | - | success: true; | |
| 81 | - | buddyList: SafeUser[]; | |
| 80 | + | success: true; | |
| 81 | + | buddyList: SafeUser[]; | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | export interface PublicRoomResponseError { | |
| 85 | - | success: false; | |
| 86 | - | error: string; | |
| 85 | + | success: false; | |
| 86 | + | error: string; | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | export type PublicRoomResponse = PublicRoomResponseSuccess | PublicRoomResponseError; ⏎̸ | |
| 89 | + | export type PublicRoomResponse = PublicRoomResponseSuccess | PublicRoomResponseError; | |
M src/lib/types/text-formatting.ts +251 -217
| @@ -4,270 +4,304 @@ | |||
| 4 | 4 | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | |
| 5 | 5 | // Secondary fonts use local system fonts with proper fallbacks | |
| 6 | 6 | export const RETRO_FONTS = { | |
| 7 | - | pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' }, | |
| 8 | - | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 9 | - | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 10 | - | comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' }, | |
| 11 | - | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |
| 12 | - | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |
| 13 | - | courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' }, | |
| 14 | - | dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' } | |
| 7 | + | pixelated: { | |
| 8 | + | name: 'Pixelated MS Sans Serif', | |
| 9 | + | stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' | |
| 10 | + | }, | |
| 11 | + | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 12 | + | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 13 | + | comicSans: { | |
| 14 | + | name: 'Comic Sans MS', | |
| 15 | + | stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' | |
| 16 | + | }, | |
| 17 | + | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |
| 18 | + | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |
| 19 | + | courierNew: { | |
| 20 | + | name: 'Courier New', | |
| 21 | + | stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' | |
| 22 | + | }, | |
| 23 | + | dos: { | |
| 24 | + | name: 'Perfect DOS VGA 437', | |
| 25 | + | stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' | |
| 26 | + | } | |
| 15 | 27 | } as const; | |
| 16 | 28 | ||
| 17 | 29 | export type FontFamily = keyof typeof RETRO_FONTS; | |
| 18 | 30 | ||
| 19 | 31 | // Generate 256-color web-safe palette ordered by hue | |
| 20 | 32 | export function generate256Colors(): string[] { | |
| 21 | - | const colors: string[] = []; | |
| 22 | - | ||
| 23 | - | // Start with grayscale colors | |
| 24 | - | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); | |
| 25 | - | ||
| 26 | - | // Then add colors in a more logical order by hue | |
| 27 | - | // Red hues | |
| 28 | - | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); | |
| 29 | - | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); | |
| 30 | - | ||
| 31 | - | // Orange hues | |
| 32 | - | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); | |
| 33 | - | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); | |
| 34 | - | ||
| 35 | - | // Yellow hues | |
| 36 | - | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); | |
| 37 | - | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); | |
| 38 | - | ||
| 39 | - | // Green hues | |
| 40 | - | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | |
| 41 | - | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | |
| 42 | - | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | |
| 43 | - | ||
| 44 | - | // Cyan hues | |
| 45 | - | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | |
| 46 | - | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); | |
| 47 | - | colors.push('#336666', '#669999', '#99CCCC'); | |
| 48 | - | ||
| 49 | - | // Blue hues | |
| 50 | - | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); | |
| 51 | - | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); | |
| 52 | - | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); | |
| 53 | - | ||
| 54 | - | // Purple hues | |
| 55 | - | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); | |
| 56 | - | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); | |
| 57 | - | colors.push('#663366', '#996699', '#CC99CC'); | |
| 58 | - | ||
| 59 | - | // Pink hues | |
| 60 | - | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); | |
| 61 | - | colors.push('#FF3399', '#FF66CC', '#FF99CC'); | |
| 62 | - | ||
| 63 | - | // Browns | |
| 64 | - | colors.push('#663333', '#996633', '#CC9966', '#996666'); | |
| 65 | - | ||
| 66 | - | // Now fill in any missing combinations to get full 216 web-safe colors | |
| 67 | - | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; | |
| 68 | - | const existingColors = new Set(colors); | |
| 69 | - | ||
| 70 | - | for (const r of webSafeValues) { | |
| 71 | - | for (const g of webSafeValues) { | |
| 72 | - | for (const b of webSafeValues) { | |
| 73 | - | const color = `#${r}${g}${b}`.toUpperCase(); | |
| 74 | - | if (!existingColors.has(color)) { | |
| 75 | - | colors.push(color); | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | return colors; | |
| 33 | + | const colors: string[] = []; | |
| 34 | + | ||
| 35 | + | // Start with grayscale colors | |
| 36 | + | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); | |
| 37 | + | ||
| 38 | + | // Then add colors in a more logical order by hue | |
| 39 | + | // Red hues | |
| 40 | + | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); | |
| 41 | + | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); | |
| 42 | + | ||
| 43 | + | // Orange hues | |
| 44 | + | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); | |
| 45 | + | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); | |
| 46 | + | ||
| 47 | + | // Yellow hues | |
| 48 | + | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); | |
| 49 | + | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); | |
| 50 | + | ||
| 51 | + | // Green hues | |
| 52 | + | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | |
| 53 | + | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | |
| 54 | + | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | |
| 55 | + | ||
| 56 | + | // Cyan hues | |
| 57 | + | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | |
| 58 | + | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); | |
| 59 | + | colors.push('#336666', '#669999', '#99CCCC'); | |
| 60 | + | ||
| 61 | + | // Blue hues | |
| 62 | + | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); | |
| 63 | + | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); | |
| 64 | + | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); | |
| 65 | + | ||
| 66 | + | // Purple hues | |
| 67 | + | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); | |
| 68 | + | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); | |
| 69 | + | colors.push('#663366', '#996699', '#CC99CC'); | |
| 70 | + | ||
| 71 | + | // Pink hues | |
| 72 | + | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); | |
| 73 | + | colors.push('#FF3399', '#FF66CC', '#FF99CC'); | |
| 74 | + | ||
| 75 | + | // Browns | |
| 76 | + | colors.push('#663333', '#996633', '#CC9966', '#996666'); | |
| 77 | + | ||
| 78 | + | // Now fill in any missing combinations to get full 216 web-safe colors | |
| 79 | + | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; | |
| 80 | + | const existingColors = new Set(colors); | |
| 81 | + | ||
| 82 | + | for (const r of webSafeValues) { | |
| 83 | + | for (const g of webSafeValues) { | |
| 84 | + | for (const b of webSafeValues) { | |
| 85 | + | const color = `#${r}${g}${b}`.toUpperCase(); | |
| 86 | + | if (!existingColors.has(color)) { | |
| 87 | + | colors.push(color); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | return colors; | |
| 82 | 94 | } | |
| 83 | 95 | ||
| 84 | 96 | // Classic retro colors for backward compatibility | |
| 85 | 97 | export const CLASSIC_COLORS = { | |
| 86 | - | black: '#000000', | |
| 87 | - | darkBlue: '#000080', | |
| 88 | - | green: '#008000', | |
| 89 | - | teal: '#008080', | |
| 90 | - | darkRed: '#800000', | |
| 91 | - | purple: '#800080', | |
| 92 | - | brown: '#808000', | |
| 93 | - | silver: '#C0C0C0', | |
| 94 | - | gray: '#808080', | |
| 95 | - | blue: '#0000FF', | |
| 96 | - | lime: '#00FF00', | |
| 97 | - | aqua: '#00FFFF', | |
| 98 | - | red: '#FF0000', | |
| 99 | - | fuchsia: '#FF00FF', | |
| 100 | - | yellow: '#FFFF00', | |
| 101 | - | white: '#FFFFFF' | |
| 98 | + | black: '#000000', | |
| 99 | + | darkBlue: '#000080', | |
| 100 | + | green: '#008000', | |
| 101 | + | teal: '#008080', | |
| 102 | + | darkRed: '#800000', | |
| 103 | + | purple: '#800080', | |
| 104 | + | brown: '#808000', | |
| 105 | + | silver: '#C0C0C0', | |
| 106 | + | gray: '#808080', | |
| 107 | + | blue: '#0000FF', | |
| 108 | + | lime: '#00FF00', | |
| 109 | + | aqua: '#00FFFF', | |
| 110 | + | red: '#FF0000', | |
| 111 | + | fuchsia: '#FF00FF', | |
| 112 | + | yellow: '#FFFF00', | |
| 113 | + | white: '#FFFFFF' | |
| 102 | 114 | } as const; | |
| 103 | 115 | ||
| 104 | 116 | export type ClassicColor = keyof typeof CLASSIC_COLORS; | |
| 105 | 117 | ||
| 106 | 118 | // Gradient presets inspired by AOL faders | |
| 107 | 119 | export const GRADIENT_PRESETS = { | |
| 108 | - | rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'], | |
| 109 | - | fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'], | |
| 110 | - | ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'], | |
| 111 | - | sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'], | |
| 112 | - | neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'], | |
| 113 | - | forest: ['#006400', '#228B22', '#32CD32', '#90EE90'], | |
| 114 | - | royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD'] | |
| 120 | + | rainbow: [ | |
| 121 | + | '#FF0000', | |
| 122 | + | '#FF8000', | |
| 123 | + | '#FFFF00', | |
| 124 | + | '#80FF00', | |
| 125 | + | '#00FF00', | |
| 126 | + | '#00FF80', | |
| 127 | + | '#00FFFF', | |
| 128 | + | '#0080FF', | |
| 129 | + | '#0000FF', | |
| 130 | + | '#8000FF', | |
| 131 | + | '#FF00FF', | |
| 132 | + | '#FF0080' | |
| 133 | + | ], | |
| 134 | + | fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'], | |
| 135 | + | ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'], | |
| 136 | + | sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'], | |
| 137 | + | neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'], | |
| 138 | + | forest: ['#006400', '#228B22', '#32CD32', '#90EE90'], | |
| 139 | + | royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD'] | |
| 115 | 140 | } as const; | |
| 116 | 141 | ||
| 117 | 142 | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | |
| 118 | 143 | ||
| 119 | 144 | // Text styling options | |
| 120 | 145 | export interface TextStyle { | |
| 121 | - | fontFamily: FontFamily; | |
| 122 | - | fontSize: number; // in pixels | |
| 123 | - | color?: string; // hex color string | |
| 124 | - | gradient?: string[]; // array of 1-3 hex colors for gradients | |
| 125 | - | bold: boolean; | |
| 126 | - | italic: boolean; | |
| 127 | - | underline: boolean; | |
| 128 | - | strikethrough: boolean; | |
| 146 | + | fontFamily: FontFamily; | |
| 147 | + | fontSize: number; // in pixels | |
| 148 | + | color?: string; // hex color string | |
| 149 | + | gradient?: string[]; // array of 1-3 hex colors for gradients | |
| 150 | + | bold: boolean; | |
| 151 | + | italic: boolean; | |
| 152 | + | underline: boolean; | |
| 153 | + | strikethrough: boolean; | |
| 129 | 154 | } | |
| 130 | 155 | ||
| 131 | 156 | // Default text style | |
| 132 | 157 | export const DEFAULT_TEXT_STYLE: TextStyle = { | |
| 133 | - | fontFamily: 'pixelated', | |
| 134 | - | fontSize: 14, | |
| 135 | - | color: '#000000', | |
| 136 | - | bold: false, | |
| 137 | - | italic: false, | |
| 138 | - | underline: false, | |
| 139 | - | strikethrough: false | |
| 158 | + | fontFamily: 'pixelated', | |
| 159 | + | fontSize: 14, | |
| 160 | + | color: '#000000', | |
| 161 | + | bold: false, | |
| 162 | + | italic: false, | |
| 163 | + | underline: false, | |
| 164 | + | strikethrough: false | |
| 140 | 165 | }; | |
| 141 | 166 | ||
| 142 | 167 | // User preferences for text formatting | |
| 143 | 168 | export interface UserTextPreferences { | |
| 144 | - | defaultStyle: TextStyle; | |
| 145 | - | allowFormatting: boolean; | |
| 146 | - | maxMessageLength: number; | |
| 169 | + | defaultStyle: TextStyle; | |
| 170 | + | allowFormatting: boolean; | |
| 171 | + | maxMessageLength: number; | |
| 147 | 172 | } | |
| 148 | 173 | ||
| 149 | 174 | // Default user preferences | |
| 150 | 175 | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | |
| 151 | - | defaultStyle: DEFAULT_TEXT_STYLE, | |
| 152 | - | allowFormatting: true, | |
| 153 | - | maxMessageLength: 500 | |
| 176 | + | defaultStyle: DEFAULT_TEXT_STYLE, | |
| 177 | + | allowFormatting: true, | |
| 178 | + | maxMessageLength: 500 | |
| 154 | 179 | }; | |
| 155 | 180 | ||
| 156 | 181 | // Hex color validation | |
| 157 | 182 | export function isValidHexColor(color: string): boolean { | |
| 158 | - | return /^#[0-9A-Fa-f]{6}$/.test(color); | |
| 183 | + | return /^#[0-9A-Fa-f]{6}$/.test(color); | |
| 159 | 184 | } | |
| 160 | 185 | ||
| 161 | 186 | // Formatting validation | |
| 162 | 187 | export function validateTextStyle(style: Partial<TextStyle>): TextStyle { | |
| 163 | - | return { | |
| 164 | - | fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily, | |
| 165 | - | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), | |
| 166 | - | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, | |
| 167 | - | gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined, | |
| 168 | - | bold: Boolean(style.bold), | |
| 169 | - | italic: Boolean(style.italic), | |
| 170 | - | underline: Boolean(style.underline), | |
| 171 | - | strikethrough: Boolean(style.strikethrough) | |
| 172 | - | }; | |
| 188 | + | return { | |
| 189 | + | fontFamily: | |
| 190 | + | style.fontFamily && style.fontFamily in RETRO_FONTS | |
| 191 | + | ? style.fontFamily | |
| 192 | + | : DEFAULT_TEXT_STYLE.fontFamily, | |
| 193 | + | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), | |
| 194 | + | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, | |
| 195 | + | gradient: | |
| 196 | + | style.gradient && | |
| 197 | + | Array.isArray(style.gradient) && | |
| 198 | + | style.gradient.length <= 3 && | |
| 199 | + | style.gradient.every(isValidHexColor) | |
| 200 | + | ? style.gradient | |
| 201 | + | : undefined, | |
| 202 | + | bold: Boolean(style.bold), | |
| 203 | + | italic: Boolean(style.italic), | |
| 204 | + | underline: Boolean(style.underline), | |
| 205 | + | strikethrough: Boolean(style.strikethrough) | |
| 206 | + | }; | |
| 173 | 207 | } | |
| 174 | 208 | ||
| 175 | 209 | // Generate CSS style string from TextStyle | |
| 176 | 210 | export function generateCSSStyle(style: TextStyle): string { | |
| 177 | - | const styles: string[] = []; | |
| 178 | - | ||
| 179 | - | // Font family | |
| 180 | - | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); | |
| 181 | - | ||
| 182 | - | // Font size | |
| 183 | - | styles.push(`font-size: ${style.fontSize}px`); | |
| 184 | - | ||
| 185 | - | // Color or gradient | |
| 186 | - | if (style.gradient && style.gradient.length > 1) { | |
| 187 | - | const gradientStops = style.gradient.join(', '); | |
| 188 | - | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | |
| 189 | - | styles.push(`-webkit-background-clip: text`); | |
| 190 | - | styles.push(`-webkit-text-fill-color: transparent`); | |
| 191 | - | styles.push(`background-clip: text`); | |
| 192 | - | } else if (style.color) { | |
| 193 | - | styles.push(`color: ${style.color}`); | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | // Font weight | |
| 197 | - | if (style.bold) { | |
| 198 | - | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 199 | - | if (style.fontFamily === 'tahoma') { | |
| 200 | - | styles.push(`font-weight: 200`); | |
| 201 | - | } else { | |
| 202 | - | styles.push(`font-weight: 700`); | |
| 203 | - | } | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | // Font style | |
| 207 | - | if (style.italic) { | |
| 208 | - | styles.push(`font-style: italic`); | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | // Text decoration | |
| 212 | - | const decorations: string[] = []; | |
| 213 | - | if (style.underline) decorations.push('underline'); | |
| 214 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 215 | - | if (decorations.length > 0) { | |
| 216 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | return styles.join('; '); | |
| 211 | + | const styles: string[] = []; | |
| 212 | + | ||
| 213 | + | // Font family | |
| 214 | + | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); | |
| 215 | + | ||
| 216 | + | // Font size | |
| 217 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 218 | + | ||
| 219 | + | // Color or gradient | |
| 220 | + | if (style.gradient && style.gradient.length > 1) { | |
| 221 | + | const gradientStops = style.gradient.join(', '); | |
| 222 | + | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | |
| 223 | + | styles.push(`-webkit-background-clip: text`); | |
| 224 | + | styles.push(`-webkit-text-fill-color: transparent`); | |
| 225 | + | styles.push(`background-clip: text`); | |
| 226 | + | } else if (style.color) { | |
| 227 | + | styles.push(`color: ${style.color}`); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // Font weight | |
| 231 | + | if (style.bold) { | |
| 232 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 233 | + | if (style.fontFamily === 'tahoma') { | |
| 234 | + | styles.push(`font-weight: 200`); | |
| 235 | + | } else { | |
| 236 | + | styles.push(`font-weight: 700`); | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Font style | |
| 241 | + | if (style.italic) { | |
| 242 | + | styles.push(`font-style: italic`); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Text decoration | |
| 246 | + | const decorations: string[] = []; | |
| 247 | + | if (style.underline) decorations.push('underline'); | |
| 248 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 249 | + | if (decorations.length > 0) { | |
| 250 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | return styles.join('; '); | |
| 220 | 254 | } | |
| 221 | 255 | ||
| 222 | 256 | // Generate CSS style string for input fields (without font-family which should be handled by classes) | |
| 223 | 257 | export function generateInputCSSStyle(style: TextStyle): string { | |
| 224 | - | const styles: string[] = []; | |
| 225 | - | ||
| 226 | - | // Font size | |
| 227 | - | styles.push(`font-size: ${style.fontSize}px`); | |
| 228 | - | ||
| 229 | - | // Color handling for inputs | |
| 230 | - | if (style.gradient && style.gradient.length > 1) { | |
| 231 | - | // Apply gradient background with text clipping for gradient effect | |
| 232 | - | const gradientStops = style.gradient.join(', '); | |
| 233 | - | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | |
| 234 | - | styles.push(`-webkit-background-clip: text`); | |
| 235 | - | styles.push(`-webkit-text-fill-color: transparent`); | |
| 236 | - | styles.push(`background-clip: text`); | |
| 237 | - | styles.push(`letter-spacing: 0`); | |
| 238 | - | styles.push(`word-spacing: 0`); | |
| 239 | - | } else if (style.gradient && style.gradient.length === 1) { | |
| 240 | - | // Single color from gradient array | |
| 241 | - | styles.push(`color: ${style.gradient[0]}`); | |
| 242 | - | } else if (style.color) { | |
| 243 | - | styles.push(`color: ${style.color}`); | |
| 244 | - | } else { | |
| 245 | - | // Fallback to black if no color is set | |
| 246 | - | styles.push(`color: #000000`); | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | // Font weight | |
| 250 | - | if (style.bold) { | |
| 251 | - | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 252 | - | if (style.fontFamily === 'tahoma') { | |
| 253 | - | styles.push(`font-weight: 200`); | |
| 254 | - | } else { | |
| 255 | - | styles.push(`font-weight: 700`); | |
| 256 | - | } | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | // Font style | |
| 260 | - | if (style.italic) { | |
| 261 | - | styles.push(`font-style: italic`); | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | // Text decoration | |
| 265 | - | const decorations: string[] = []; | |
| 266 | - | if (style.underline) decorations.push('underline'); | |
| 267 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 268 | - | if (decorations.length > 0) { | |
| 269 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | return styles.join('; '); | |
| 273 | - | }⏎̸ | |
| 258 | + | const styles: string[] = []; | |
| 259 | + | ||
| 260 | + | // Font size | |
| 261 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 262 | + | ||
| 263 | + | // Color handling for inputs | |
| 264 | + | if (style.gradient && style.gradient.length > 1) { | |
| 265 | + | // Apply gradient background with text clipping for gradient effect | |
| 266 | + | const gradientStops = style.gradient.join(', '); | |
| 267 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | |
| 268 | + | styles.push(`-webkit-background-clip: text`); | |
| 269 | + | styles.push(`-webkit-text-fill-color: transparent`); | |
| 270 | + | styles.push(`background-clip: text`); | |
| 271 | + | styles.push(`letter-spacing: 0`); | |
| 272 | + | styles.push(`word-spacing: 0`); | |
| 273 | + | } else if (style.gradient && style.gradient.length === 1) { | |
| 274 | + | // Single color from gradient array | |
| 275 | + | styles.push(`color: ${style.gradient[0]}`); | |
| 276 | + | } else if (style.color) { | |
| 277 | + | styles.push(`color: ${style.color}`); | |
| 278 | + | } else { | |
| 279 | + | // Fallback to black if no color is set | |
| 280 | + | styles.push(`color: #000000`); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // Font weight | |
| 284 | + | if (style.bold) { | |
| 285 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 286 | + | if (style.fontFamily === 'tahoma') { | |
| 287 | + | styles.push(`font-weight: 200`); | |
| 288 | + | } else { | |
| 289 | + | styles.push(`font-weight: 700`); | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Font style | |
| 294 | + | if (style.italic) { | |
| 295 | + | styles.push(`font-style: italic`); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // Text decoration | |
| 299 | + | const decorations: string[] = []; | |
| 300 | + | if (style.underline) decorations.push('underline'); | |
| 301 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 302 | + | if (decorations.length > 0) { | |
| 303 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | return styles.join('; '); | |
| 307 | + | } | |
M src/lib/types/turnstile.d.ts +14 -14
| @@ -1,23 +1,23 @@ | |||
| 1 | 1 | interface Turnstile { | |
| 2 | - | render: (element: HTMLElement, options: TurnstileOptions) => string; | |
| 3 | - | remove: (widgetId: string) => void; | |
| 2 | + | render: (element: HTMLElement, options: TurnstileOptions) => string; | |
| 3 | + | remove: (widgetId: string) => void; | |
| 4 | 4 | } | |
| 5 | 5 | ||
| 6 | 6 | interface TurnstileOptions { | |
| 7 | - | sitekey: string; | |
| 8 | - | callback: (token: string) => void; | |
| 9 | - | "refresh-expired"?: "auto" | "manual"; | |
| 10 | - | size?: "invisible" | "normal" | "compact"; | |
| 11 | - | "error-callback"?: (error: unknown) => void; | |
| 12 | - | "expired-callback"?: () => void; | |
| 13 | - | theme?: "light" | "dark" | "auto"; | |
| 14 | - | language?: string; | |
| 7 | + | sitekey: string; | |
| 8 | + | callback: (token: string) => void; | |
| 9 | + | 'refresh-expired'?: 'auto' | 'manual'; | |
| 10 | + | size?: 'invisible' | 'normal' | 'compact'; | |
| 11 | + | 'error-callback'?: (error: unknown) => void; | |
| 12 | + | 'expired-callback'?: () => void; | |
| 13 | + | theme?: 'light' | 'dark' | 'auto'; | |
| 14 | + | language?: string; | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | declare global { | |
| 18 | - | interface Window { | |
| 19 | - | turnstile?: Turnstile; | |
| 20 | - | } | |
| 18 | + | interface Window { | |
| 19 | + | turnstile?: Turnstile; | |
| 20 | + | } | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | - | export type { Turnstile, TurnstileOptions }; ⏎̸ | |
| 23 | + | export type { Turnstile, TurnstileOptions }; | |
M src/lib/utils/chat.server.ts +44 -40
| @@ -1,55 +1,59 @@ | |||
| 1 | - | import type { LibSQLDatabase } from "drizzle-orm/libsql"; | |
| 2 | - | import { createLogger } from "$lib/utils/logger.server"; | |
| 3 | - | import schema, { chatRooms } from "$lib/db/schema"; | |
| 4 | - | import { eq } from "drizzle-orm"; | |
| 5 | - | import { env } from "$env/dynamic/public"; | |
| 1 | + | import type { LibSQLDatabase } from 'drizzle-orm/libsql'; | |
| 2 | + | import { createLogger } from '$lib/utils/logger.server'; | |
| 3 | + | import schema, { chatRooms } from '$lib/db/schema'; | |
| 4 | + | import { eq } from 'drizzle-orm'; | |
| 5 | + | import { env } from '$env/dynamic/public'; | |
| 6 | 6 | ||
| 7 | 7 | const log = createLogger('chat-utils'); | |
| 8 | 8 | ||
| 9 | 9 | // Default chat room ID from environment variable | |
| 10 | - | export const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 10 | + | export const DEFAULT_CHAT_ROOM_ID = | |
| 11 | + | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 11 | 12 | ||
| 12 | 13 | // Type guard for LibSQL errors | |
| 13 | 14 | interface LibSQLError { | |
| 14 | - | code: string; | |
| 15 | - | message: string; | |
| 15 | + | code: string; | |
| 16 | + | message: string; | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | function isLibSQLError(error: unknown): error is LibSQLError { | |
| 19 | - | return typeof error === 'object' && error !== null && 'code' in error; | |
| 20 | + | return typeof error === 'object' && error !== null && 'code' in error; | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // Function to ensure default chat room exists | |
| 23 | 24 | export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) { | |
| 24 | - | log.debug('Ensuring default chat room exists...'); | |
| 25 | - | try { | |
| 26 | - | // Use a transaction to handle concurrent creation attempts | |
| 27 | - | await db.transaction(async (tx) => { | |
| 28 | - | const defaultRoom = await tx.select() | |
| 29 | - | .from(chatRooms) | |
| 30 | - | .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID)) | |
| 31 | - | .get(); | |
| 25 | + | log.debug('Ensuring default chat room exists...'); | |
| 26 | + | try { | |
| 27 | + | // Use a transaction to handle concurrent creation attempts | |
| 28 | + | await db.transaction(async (tx) => { | |
| 29 | + | const defaultRoom = await tx | |
| 30 | + | .select() | |
| 31 | + | .from(chatRooms) | |
| 32 | + | .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID)) | |
| 33 | + | .get(); | |
| 32 | 34 | ||
| 33 | - | if (!defaultRoom) { | |
| 34 | - | log.debug('Creating default chat room...'); | |
| 35 | - | await tx.insert(chatRooms).values({ | |
| 36 | - | id: DEFAULT_CHAT_ROOM_ID, | |
| 37 | - | name: 'General', | |
| 38 | - | type: 'group', | |
| 39 | - | createdAt: Date.now() | |
| 40 | - | }); | |
| 41 | - | log.debug('Default chat room created successfully'); | |
| 42 | - | } else { | |
| 43 | - | log.debug('Default chat room already exists'); | |
| 44 | - | } | |
| 45 | - | }); | |
| 46 | - | } catch (error: unknown) { | |
| 47 | - | // If the error is a unique constraint violation, another process probably created the room | |
| 48 | - | if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') { | |
| 49 | - | log.debug('Default chat room was created by another process'); | |
| 50 | - | return; | |
| 51 | - | } | |
| 52 | - | log.error('Error ensuring default chat room:', { error: error instanceof Error ? error.message : 'Unknown error' }); | |
| 53 | - | throw error; | |
| 54 | - | } | |
| 55 | - | } ⏎̸ | |
| 35 | + | if (!defaultRoom) { | |
| 36 | + | log.debug('Creating default chat room...'); | |
| 37 | + | await tx.insert(chatRooms).values({ | |
| 38 | + | id: DEFAULT_CHAT_ROOM_ID, | |
| 39 | + | name: 'General', | |
| 40 | + | type: 'group', | |
| 41 | + | createdAt: Date.now() | |
| 42 | + | }); | |
| 43 | + | log.debug('Default chat room created successfully'); | |
| 44 | + | } else { | |
| 45 | + | log.debug('Default chat room already exists'); | |
| 46 | + | } | |
| 47 | + | }); | |
| 48 | + | } catch (error: unknown) { | |
| 49 | + | // If the error is a unique constraint violation, another process probably created the room | |
| 50 | + | if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') { | |
| 51 | + | log.debug('Default chat room was created by another process'); | |
| 52 | + | return; | |
| 53 | + | } | |
| 54 | + | log.error('Error ensuring default chat room:', { | |
| 55 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 56 | + | }); | |
| 57 | + | throw error; | |
| 58 | + | } | |
| 59 | + | } | |
M src/lib/utils/date-format.ts +101 -95
| @@ -4,15 +4,17 @@ | |||
| 4 | 4 | * @returns The formatted date string | |
| 5 | 5 | */ | |
| 6 | 6 | export function formatFrenchDateTime(date: Date): string { | |
| 7 | - | console.debug('Formatting date to French style:', date); | |
| 8 | - | return date.toLocaleDateString('fr-FR', { | |
| 9 | - | day: '2-digit', | |
| 10 | - | month: '2-digit', | |
| 11 | - | year: 'numeric', | |
| 12 | - | hour: '2-digit', | |
| 13 | - | minute: '2-digit', | |
| 14 | - | hour12: false | |
| 15 | - | }).replace(' à ', ' à '); // Ensure proper spacing around 'à' | |
| 7 | + | console.debug('Formatting date to French style:', date); | |
| 8 | + | return date | |
| 9 | + | .toLocaleDateString('fr-FR', { | |
| 10 | + | day: '2-digit', | |
| 11 | + | month: '2-digit', | |
| 12 | + | year: 'numeric', | |
| 13 | + | hour: '2-digit', | |
| 14 | + | minute: '2-digit', | |
| 15 | + | hour12: false | |
| 16 | + | }) | |
| 17 | + | .replace(' à ', ' à '); // Ensure proper spacing around 'à' | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | 20 | /** | |
| @@ -21,12 +23,14 @@ export function formatFrenchDateTime(date: Date): string { | |||
| 21 | 23 | * @returns The formatted time string | |
| 22 | 24 | */ | |
| 23 | 25 | export function formatFrenchTime(date: Date): string { | |
| 24 | - | console.debug('Formatting time to French style:', date); | |
| 25 | - | return date.toLocaleTimeString('fr-FR', { | |
| 26 | - | hour: '2-digit', | |
| 27 | - | minute: '2-digit', | |
| 28 | - | hour12: false | |
| 29 | - | }).replace(':', 'h'); | |
| 26 | + | console.debug('Formatting time to French style:', date); | |
| 27 | + | return date | |
| 28 | + | .toLocaleTimeString('fr-FR', { | |
| 29 | + | hour: '2-digit', | |
| 30 | + | minute: '2-digit', | |
| 31 | + | hour12: false | |
| 32 | + | }) | |
| 33 | + | .replace(':', 'h'); | |
| 30 | 34 | } | |
| 31 | 35 | ||
| 32 | 36 | /** | |
| @@ -35,12 +39,12 @@ export function formatFrenchTime(date: Date): string { | |||
| 35 | 39 | * @returns The formatted date string | |
| 36 | 40 | */ | |
| 37 | 41 | export function formatFrenchDate(date: Date): string { | |
| 38 | - | console.debug('Formatting date to French style:', date); | |
| 39 | - | return date.toLocaleDateString('fr-FR', { | |
| 40 | - | day: '2-digit', | |
| 41 | - | month: '2-digit', | |
| 42 | - | year: 'numeric' | |
| 43 | - | }); | |
| 42 | + | console.debug('Formatting date to French style:', date); | |
| 43 | + | return date.toLocaleDateString('fr-FR', { | |
| 44 | + | day: '2-digit', | |
| 45 | + | month: '2-digit', | |
| 46 | + | year: 'numeric' | |
| 47 | + | }); | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | /** | |
| @@ -49,37 +53,37 @@ export function formatFrenchDate(date: Date): string { | |||
| 49 | 53 | * @returns The formatted relative time string | |
| 50 | 54 | */ | |
| 51 | 55 | export function formatFrenchRelativeTime(date: Date): string { | |
| 52 | - | console.debug('Formatting relative time to French style:', date); | |
| 53 | - | const now = new Date(); | |
| 54 | - | const diff = now.getTime() - date.getTime(); | |
| 55 | - | const seconds = Math.floor(diff / 1000); | |
| 56 | - | const minutes = Math.floor(seconds / 60); | |
| 57 | - | const hours = Math.floor(minutes / 60); | |
| 58 | - | const days = Math.floor(hours / 24); | |
| 59 | - | const months = Math.floor(days / 30); | |
| 60 | - | const years = Math.floor(days / 365); | |
| 61 | - | ||
| 62 | - | if (seconds < 60) { | |
| 63 | - | return "à l'instant"; | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | if (minutes < 60) { | |
| 67 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | if (hours < 24) { | |
| 71 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | if (days < 30) { | |
| 75 | - | return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if (months < 12) { | |
| 79 | - | return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`; | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`; | |
| 56 | + | console.debug('Formatting relative time to French style:', date); | |
| 57 | + | const now = new Date(); | |
| 58 | + | const diff = now.getTime() - date.getTime(); | |
| 59 | + | const seconds = Math.floor(diff / 1000); | |
| 60 | + | const minutes = Math.floor(seconds / 60); | |
| 61 | + | const hours = Math.floor(minutes / 60); | |
| 62 | + | const days = Math.floor(hours / 24); | |
| 63 | + | const months = Math.floor(days / 30); | |
| 64 | + | const years = Math.floor(days / 365); | |
| 65 | + | ||
| 66 | + | if (seconds < 60) { | |
| 67 | + | return "à l'instant"; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | if (minutes < 60) { | |
| 71 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if (hours < 24) { | |
| 75 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if (days < 30) { | |
| 79 | + | return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if (months < 12) { | |
| 83 | + | return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`; | |
| 83 | 87 | } | |
| 84 | 88 | ||
| 85 | 89 | /** | |
| @@ -89,35 +93,37 @@ export function formatFrenchRelativeTime(date: Date): string { | |||
| 89 | 93 | * @returns The formatted relative time string with more detail | |
| 90 | 94 | */ | |
| 91 | 95 | export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string { | |
| 92 | - | console.debug('Formatting detailed relative time to French style:', date); | |
| 93 | - | const now = new Date(); | |
| 94 | - | const diff = now.getTime() - date.getTime(); | |
| 95 | - | const seconds = Math.floor(diff / 1000); | |
| 96 | - | const minutes = Math.floor(seconds / 60); | |
| 97 | - | const hours = Math.floor(minutes / 60); | |
| 98 | - | ||
| 99 | - | if (seconds < 60) { | |
| 100 | - | return withSeconds ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` : "à l'instant"; | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | if (minutes < 60) { | |
| 104 | - | const remainingSeconds = seconds % 60; | |
| 105 | - | if (withSeconds && remainingSeconds > 0) { | |
| 106 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`; | |
| 107 | - | } | |
| 108 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | if (hours < 24) { | |
| 112 | - | const remainingMinutes = minutes % 60; | |
| 113 | - | if (remainingMinutes > 0) { | |
| 114 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`; | |
| 115 | - | } | |
| 116 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // For anything longer than 24 hours, use the simple format | |
| 120 | - | return formatFrenchRelativeTime(date); | |
| 96 | + | console.debug('Formatting detailed relative time to French style:', date); | |
| 97 | + | const now = new Date(); | |
| 98 | + | const diff = now.getTime() - date.getTime(); | |
| 99 | + | const seconds = Math.floor(diff / 1000); | |
| 100 | + | const minutes = Math.floor(seconds / 60); | |
| 101 | + | const hours = Math.floor(minutes / 60); | |
| 102 | + | ||
| 103 | + | if (seconds < 60) { | |
| 104 | + | return withSeconds | |
| 105 | + | ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` | |
| 106 | + | : "à l'instant"; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if (minutes < 60) { | |
| 110 | + | const remainingSeconds = seconds % 60; | |
| 111 | + | if (withSeconds && remainingSeconds > 0) { | |
| 112 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`; | |
| 113 | + | } | |
| 114 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | if (hours < 24) { | |
| 118 | + | const remainingMinutes = minutes % 60; | |
| 119 | + | if (remainingMinutes > 0) { | |
| 120 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`; | |
| 121 | + | } | |
| 122 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // For anything longer than 24 hours, use the simple format | |
| 126 | + | return formatFrenchRelativeTime(date); | |
| 121 | 127 | } | |
| 122 | 128 | ||
| 123 | 129 | /** | |
| @@ -126,17 +132,17 @@ export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false | |||
| 126 | 132 | * @returns The formatted relative time string or a fallback message | |
| 127 | 133 | */ | |
| 128 | 134 | export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string { | |
| 129 | - | console.debug('Formatting safe relative time to French style:', timestamp); | |
| 130 | - | if (!timestamp) { | |
| 131 | - | return "jamais"; | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | const date = timestamp instanceof Date ? timestamp : new Date(timestamp); | |
| 135 | - | ||
| 136 | - | // Check if the date is valid | |
| 137 | - | if (isNaN(date.getTime())) { | |
| 138 | - | return "date invalide"; | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | return formatFrenchRelativeTime(date); | |
| 142 | - | } ⏎̸ | |
| 135 | + | console.debug('Formatting safe relative time to French style:', timestamp); | |
| 136 | + | if (!timestamp) { | |
| 137 | + | return 'jamais'; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | const date = timestamp instanceof Date ? timestamp : new Date(timestamp); | |
| 141 | + | ||
| 142 | + | // Check if the date is valid | |
| 143 | + | if (isNaN(date.getTime())) { | |
| 144 | + | return 'date invalide'; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | return formatFrenchRelativeTime(date); | |
| 148 | + | } | |
M src/lib/utils/logger.server.ts +40 -40
| @@ -3,53 +3,53 @@ import pino from 'pino'; | |||
| 3 | 3 | const logLevel = process.env.NODE_ENV === 'development' ? 'debug' : 'info'; | |
| 4 | 4 | ||
| 5 | 5 | class LoggerService { | |
| 6 | - | private static instance: LoggerService; | |
| 7 | - | private logger: pino.Logger; | |
| 8 | - | private moduleLoggers: Map<string, Logger>; | |
| 6 | + | private static instance: LoggerService; | |
| 7 | + | private logger: pino.Logger; | |
| 8 | + | private moduleLoggers: Map<string, Logger>; | |
| 9 | 9 | ||
| 10 | - | private constructor() { | |
| 11 | - | this.logger = pino({ | |
| 12 | - | transport: { | |
| 13 | - | target: 'pino-pretty', | |
| 14 | - | options: { | |
| 15 | - | colorize: true, | |
| 16 | - | translateTime: 'HH:MM:ss', | |
| 17 | - | ignore: 'pid,hostname', | |
| 18 | - | }, | |
| 19 | - | }, | |
| 20 | - | level: logLevel, | |
| 21 | - | }); | |
| 22 | - | this.moduleLoggers = new Map(); | |
| 23 | - | } | |
| 10 | + | private constructor() { | |
| 11 | + | this.logger = pino({ | |
| 12 | + | transport: { | |
| 13 | + | target: 'pino-pretty', | |
| 14 | + | options: { | |
| 15 | + | colorize: true, | |
| 16 | + | translateTime: 'HH:MM:ss', | |
| 17 | + | ignore: 'pid,hostname' | |
| 18 | + | } | |
| 19 | + | }, | |
| 20 | + | level: logLevel | |
| 21 | + | }); | |
| 22 | + | this.moduleLoggers = new Map(); | |
| 23 | + | } | |
| 24 | 24 | ||
| 25 | - | public static getInstance(): LoggerService { | |
| 26 | - | if (!LoggerService.instance) { | |
| 27 | - | LoggerService.instance = new LoggerService(); | |
| 28 | - | } | |
| 29 | - | return LoggerService.instance; | |
| 30 | - | } | |
| 25 | + | public static getInstance(): LoggerService { | |
| 26 | + | if (!LoggerService.instance) { | |
| 27 | + | LoggerService.instance = new LoggerService(); | |
| 28 | + | } | |
| 29 | + | return LoggerService.instance; | |
| 30 | + | } | |
| 31 | 31 | ||
| 32 | - | public getLogger(module: string): Logger { | |
| 33 | - | if (!this.moduleLoggers.has(module)) { | |
| 34 | - | this.moduleLoggers.set(module, { | |
| 35 | - | debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg), | |
| 36 | - | info: (msg: string, obj = {}) => this.logger.info({ module, ...obj }, msg), | |
| 37 | - | warn: (msg: string, obj = {}) => this.logger.warn({ module, ...obj }, msg), | |
| 38 | - | error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg) | |
| 39 | - | }); | |
| 40 | - | } | |
| 41 | - | return this.moduleLoggers.get(module)!; | |
| 42 | - | } | |
| 32 | + | public getLogger(module: string): Logger { | |
| 33 | + | if (!this.moduleLoggers.has(module)) { | |
| 34 | + | this.moduleLoggers.set(module, { | |
| 35 | + | debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg), | |
| 36 | + | info: (msg: string, obj = {}) => this.logger.info({ module, ...obj }, msg), | |
| 37 | + | warn: (msg: string, obj = {}) => this.logger.warn({ module, ...obj }, msg), | |
| 38 | + | error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg) | |
| 39 | + | }); | |
| 40 | + | } | |
| 41 | + | return this.moduleLoggers.get(module)!; | |
| 42 | + | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | export interface Logger { | |
| 46 | - | debug: (msg: string, obj?: object) => void; | |
| 47 | - | info: (msg: string, obj?: object) => void; | |
| 48 | - | warn: (msg: string, obj?: object) => void; | |
| 49 | - | error: (msg: string, obj?: object) => void; | |
| 46 | + | debug: (msg: string, obj?: object) => void; | |
| 47 | + | info: (msg: string, obj?: object) => void; | |
| 48 | + | warn: (msg: string, obj?: object) => void; | |
| 49 | + | error: (msg: string, obj?: object) => void; | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | // Export a function to get a logger for a specific module | |
| 53 | 53 | export const createLogger = (module: string): Logger => { | |
| 54 | - | return LoggerService.getInstance().getLogger(module); | |
| 55 | - | }; ⏎̸ | |
| 54 | + | return LoggerService.getInstance().getLogger(module); | |
| 55 | + | }; | |
M src/lib/utils/password.ts +41 -45
| @@ -4,23 +4,23 @@ | |||
| 4 | 4 | * Helper function that converts an ArrayBuffer to a hex string. | |
| 5 | 5 | */ | |
| 6 | 6 | function bufferToHex(buffer: ArrayBuffer): string { | |
| 7 | - | return Array.from(new Uint8Array(buffer)) | |
| 8 | - | .map(b => b.toString(16).padStart(2, '0')) | |
| 9 | - | .join(''); | |
| 7 | + | return Array.from(new Uint8Array(buffer)) | |
| 8 | + | .map((b) => b.toString(16).padStart(2, '0')) | |
| 9 | + | .join(''); | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | /** | |
| 13 | 13 | * Helper function that converts a hex string to a Uint8Array. | |
| 14 | 14 | */ | |
| 15 | 15 | function hexToBuffer(hex: string): Uint8Array { | |
| 16 | - | if (hex.length % 2 !== 0) { | |
| 17 | - | throw new Error('Invalid hex string'); | |
| 18 | - | } | |
| 19 | - | const arr = new Uint8Array(hex.length / 2); | |
| 20 | - | for (let i = 0; i < hex.length; i += 2) { | |
| 21 | - | arr[i / 2] = parseInt(hex.substring(i, i + 2), 16); | |
| 22 | - | } | |
| 23 | - | return arr; | |
| 16 | + | if (hex.length % 2 !== 0) { | |
| 17 | + | throw new Error('Invalid hex string'); | |
| 18 | + | } | |
| 19 | + | const arr = new Uint8Array(hex.length / 2); | |
| 20 | + | for (let i = 0; i < hex.length; i += 2) { | |
| 21 | + | arr[i / 2] = parseInt(hex.substring(i, i + 2), 16); | |
| 22 | + | } | |
| 23 | + | return arr; | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | /** | |
| @@ -30,28 +30,24 @@ function hexToBuffer(hex: string): Uint8Array { | |||
| 30 | 30 | * @returns The derived key in hex format. | |
| 31 | 31 | */ | |
| 32 | 32 | async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> { | |
| 33 | - | const enc = new TextEncoder(); | |
| 34 | - | const keyMaterial = enc.encode(password); | |
| 35 | - | console.debug("$state: Key material for password derivation", keyMaterial); | |
| 36 | - | const key = await crypto.subtle.importKey( | |
| 37 | - | "raw", | |
| 38 | - | keyMaterial, | |
| 39 | - | { name: "PBKDF2" }, | |
| 40 | - | false, | |
| 41 | - | ["deriveBits"] | |
| 42 | - | ); | |
| 43 | - | const derivedBits = await crypto.subtle.deriveBits( | |
| 44 | - | { | |
| 45 | - | name: "PBKDF2", | |
| 46 | - | salt: salt.buffer as ArrayBuffer, | |
| 47 | - | iterations: 100000, | |
| 48 | - | hash: "SHA-512" | |
| 49 | - | }, | |
| 50 | - | key, | |
| 51 | - | 512 | |
| 52 | - | ); | |
| 53 | - | console.debug("$state: Derived bits", derivedBits); | |
| 54 | - | return bufferToHex(derivedBits); | |
| 33 | + | const enc = new TextEncoder(); | |
| 34 | + | const keyMaterial = enc.encode(password); | |
| 35 | + | console.debug('$state: Key material for password derivation', keyMaterial); | |
| 36 | + | const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [ | |
| 37 | + | 'deriveBits' | |
| 38 | + | ]); | |
| 39 | + | const derivedBits = await crypto.subtle.deriveBits( | |
| 40 | + | { | |
| 41 | + | name: 'PBKDF2', | |
| 42 | + | salt: salt.buffer as ArrayBuffer, | |
| 43 | + | iterations: 100000, | |
| 44 | + | hash: 'SHA-512' | |
| 45 | + | }, | |
| 46 | + | key, | |
| 47 | + | 512 | |
| 48 | + | ); | |
| 49 | + | console.debug('$state: Derived bits', derivedBits); | |
| 50 | + | return bufferToHex(derivedBits); | |
| 55 | 51 | } | |
| 56 | 52 | ||
| 57 | 53 | /** | |
| @@ -60,11 +56,11 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s | |||
| 60 | 56 | * @returns A promise that resolves to the hashed password in the format 'salt:hash'. | |
| 61 | 57 | */ | |
| 62 | 58 | export async function hashPassword(password: string): Promise<string> { | |
| 63 | - | const salt = crypto.getRandomValues(new Uint8Array(16)); | |
| 64 | - | console.debug("$state: Generated salt", salt); | |
| 65 | - | const saltHex = bufferToHex(salt.buffer); | |
| 66 | - | const hash = await derivePasswordHash(password, salt); | |
| 67 | - | return `${saltHex}:${hash}`; | |
| 59 | + | const salt = crypto.getRandomValues(new Uint8Array(16)); | |
| 60 | + | console.debug('$state: Generated salt', salt); | |
| 61 | + | const saltHex = bufferToHex(salt.buffer); | |
| 62 | + | const hash = await derivePasswordHash(password, salt); | |
| 63 | + | return `${saltHex}:${hash}`; | |
| 68 | 64 | } | |
| 69 | 65 | ||
| 70 | 66 | /** | |
| @@ -74,10 +70,10 @@ export async function hashPassword(password: string): Promise<string> { | |||
| 74 | 70 | * @returns A promise that resolves to true if the password matches, false otherwise. | |
| 75 | 71 | */ | |
| 76 | 72 | export async function verifyPassword(password: string, hash: string): Promise<boolean> { | |
| 77 | - | const [saltHex, hashValue] = hash.split(':'); | |
| 78 | - | const salt = hexToBuffer(saltHex); | |
| 79 | - | console.debug("$state: Salt extracted from stored hash", salt); | |
| 80 | - | const derivedHash = await derivePasswordHash(password, salt); | |
| 81 | - | console.debug("$state: Derived hash for verification", derivedHash); | |
| 82 | - | return derivedHash === hashValue; | |
| 83 | - | } ⏎̸ | |
| 73 | + | const [saltHex, hashValue] = hash.split(':'); | |
| 74 | + | const salt = hexToBuffer(saltHex); | |
| 75 | + | console.debug('$state: Salt extracted from stored hash', salt); | |
| 76 | + | const derivedHash = await derivePasswordHash(password, salt); | |
| 77 | + | console.debug('$state: Derived hash for verification', derivedHash); | |
| 78 | + | return derivedHash === hashValue; | |
| 79 | + | } | |
M src/lib/utils/security-tests.ts +319 -321
| @@ -3,371 +3,369 @@ import { validateTextStyle, DEFAULT_TEXT_STYLE, type TextStyle } from '../types/ | |||
| 3 | 3 | ||
| 4 | 4 | // XSS Test Vectors | |
| 5 | 5 | const XSS_TEST_VECTORS = [ | |
| 6 | - | // Script injection | |
| 7 | - | '<script>alert("XSS")</script>', | |
| 8 | - | '<script src="malicious.js"></script>', | |
| 9 | - | '<img src="x" onerror="alert(\'XSS\')">', | |
| 10 | - | '<svg onload="alert(\'XSS\')">', | |
| 11 | - | '<iframe src="javascript:alert(\'XSS\')"></iframe>', | |
| 12 | - | ||
| 13 | - | // Event handlers | |
| 14 | - | '<div onclick="alert(\'XSS\')">Click me</div>', | |
| 15 | - | '<input onfocus="alert(\'XSS\')" autofocus>', | |
| 16 | - | '<body onload="alert(\'XSS\')">', | |
| 17 | - | '<a href="javascript:alert(\'XSS\')">Link</a>', | |
| 18 | - | ||
| 19 | - | // CSS injection | |
| 20 | - | '<style>body{background:url("javascript:alert(\'XSS\')")}</style>', | |
| 21 | - | '<div style="background-image:url(javascript:alert(\'XSS\'))">', | |
| 22 | - | '<div style="expression(alert(\'XSS\'))">', | |
| 23 | - | ||
| 24 | - | // Data URIs | |
| 25 | - | '<img src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 26 | - | '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 27 | - | ||
| 28 | - | // HTML injection | |
| 29 | - | '<object data="malicious.swf">', | |
| 30 | - | '<embed src="malicious.swf">', | |
| 31 | - | '<applet code="malicious.class">', | |
| 32 | - | ||
| 33 | - | // BBCode injection attempts | |
| 34 | - | '[script]alert("XSS")[/script]', | |
| 35 | - | '[img=javascript:alert("XSS")]', | |
| 36 | - | '[url=javascript:alert("XSS")]Link[/url]', | |
| 37 | - | ||
| 38 | - | // Markdown injection attempts | |
| 39 | - | ')', | |
| 40 | - | '[XSS](javascript:alert("XSS"))', | |
| 41 | - | ||
| 42 | - | // Unicode and encoding bypasses | |
| 43 | - | '<script>alert("XSS")</script>', | |
| 44 | - | '<script>alert(String.fromCharCode(88,83,83))</script>', | |
| 45 | - | '<img src="x" onerror="alert('XSS')">', | |
| 46 | - | ||
| 47 | - | // CSS expression injection | |
| 48 | - | '<div style="width:expression(alert(\'XSS\'))">', | |
| 49 | - | '<div style="background:url(vbscript:alert(\'XSS\'))">', | |
| 50 | - | ||
| 51 | - | // Mixed content attacks | |
| 52 | - | 'Normal text <script>alert("XSS")</script> more text', | |
| 53 | - | '[b]Bold <script>alert("XSS")</script> text[/b]', | |
| 54 | - | '**Bold <img src="x" onerror="alert(\'XSS\')"> text**' | |
| 6 | + | // Script injection | |
| 7 | + | '<script>alert("XSS")</script>', | |
| 8 | + | '<script src="malicious.js"></script>', | |
| 9 | + | '<img src="x" onerror="alert(\'XSS\')">', | |
| 10 | + | '<svg onload="alert(\'XSS\')">', | |
| 11 | + | '<iframe src="javascript:alert(\'XSS\')"></iframe>', | |
| 12 | + | ||
| 13 | + | // Event handlers | |
| 14 | + | '<div onclick="alert(\'XSS\')">Click me</div>', | |
| 15 | + | '<input onfocus="alert(\'XSS\')" autofocus>', | |
| 16 | + | '<body onload="alert(\'XSS\')">', | |
| 17 | + | '<a href="javascript:alert(\'XSS\')">Link</a>', | |
| 18 | + | ||
| 19 | + | // CSS injection | |
| 20 | + | '<style>body{background:url("javascript:alert(\'XSS\')")}</style>', | |
| 21 | + | '<div style="background-image:url(javascript:alert(\'XSS\'))">', | |
| 22 | + | '<div style="expression(alert(\'XSS\'))">', | |
| 23 | + | ||
| 24 | + | // Data URIs | |
| 25 | + | '<img src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 26 | + | '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 27 | + | ||
| 28 | + | // HTML injection | |
| 29 | + | '<object data="malicious.swf">', | |
| 30 | + | '<embed src="malicious.swf">', | |
| 31 | + | '<applet code="malicious.class">', | |
| 32 | + | ||
| 33 | + | // BBCode injection attempts | |
| 34 | + | '[script]alert("XSS")[/script]', | |
| 35 | + | '[img=javascript:alert("XSS")]', | |
| 36 | + | '[url=javascript:alert("XSS")]Link[/url]', | |
| 37 | + | ||
| 38 | + | // Markdown injection attempts | |
| 39 | + | ')', | |
| 40 | + | '[XSS](javascript:alert("XSS"))', | |
| 41 | + | ||
| 42 | + | // Unicode and encoding bypasses | |
| 43 | + | '<script>alert("XSS")</script>', | |
| 44 | + | '<script>alert(String.fromCharCode(88,83,83))</script>', | |
| 45 | + | '<img src="x" onerror="alert('XSS')">', | |
| 46 | + | ||
| 47 | + | // CSS expression injection | |
| 48 | + | '<div style="width:expression(alert(\'XSS\'))">', | |
| 49 | + | '<div style="background:url(vbscript:alert(\'XSS\'))">', | |
| 50 | + | ||
| 51 | + | // Mixed content attacks | |
| 52 | + | 'Normal text <script>alert("XSS")</script> more text', | |
| 53 | + | '[b]Bold <script>alert("XSS")</script> text[/b]', | |
| 54 | + | '**Bold <img src="x" onerror="alert(\'XSS\')"> text**' | |
| 55 | 55 | ]; | |
| 56 | 56 | ||
| 57 | 57 | // CSS Injection Test Vectors | |
| 58 | 58 | const CSS_INJECTION_VECTORS = [ | |
| 59 | - | 'color: red; background: url(javascript:alert("XSS"))', | |
| 60 | - | 'font-family: "Arial"; } body { background: red; .fake {', | |
| 61 | - | 'font-size: expression(alert("XSS"))', | |
| 62 | - | 'background: url("data:text/html,<script>alert(\'XSS\')</script>")', | |
| 63 | - | 'color: red; } .injected { background: red', | |
| 64 | - | '@import url("malicious.css")', | |
| 65 | - | 'behavior: url("malicious.htc")' | |
| 59 | + | 'color: red; background: url(javascript:alert("XSS"))', | |
| 60 | + | 'font-family: "Arial"; } body { background: red; .fake {', | |
| 61 | + | 'font-size: expression(alert("XSS"))', | |
| 62 | + | 'background: url("data:text/html,<script>alert(\'XSS\')</script>")', | |
| 63 | + | 'color: red; } .injected { background: red', | |
| 64 | + | '@import url("malicious.css")', | |
| 65 | + | 'behavior: url("malicious.htc")' | |
| 66 | 66 | ]; | |
| 67 | 67 | ||
| 68 | 68 | // Test Results Interface | |
| 69 | 69 | interface SecurityTestResult { | |
| 70 | - | input: string; | |
| 71 | - | output: string; | |
| 72 | - | passed: boolean; | |
| 73 | - | vulnerabilities: string[]; | |
| 74 | - | testType: string; | |
| 70 | + | input: string; | |
| 71 | + | output: string; | |
| 72 | + | passed: boolean; | |
| 73 | + | vulnerabilities: string[]; | |
| 74 | + | testType: string; | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | interface SecurityTestSuite { | |
| 78 | - | xssTests: SecurityTestResult[]; | |
| 79 | - | cssInjectionTests: SecurityTestResult[]; | |
| 80 | - | validationTests: SecurityTestResult[]; | |
| 81 | - | gradientTests: SecurityTestResult[]; | |
| 82 | - | summary: { | |
| 83 | - | totalTests: number; | |
| 84 | - | passed: number; | |
| 85 | - | failed: number; | |
| 86 | - | vulnerabilities: string[]; | |
| 87 | - | }; | |
| 78 | + | xssTests: SecurityTestResult[]; | |
| 79 | + | cssInjectionTests: SecurityTestResult[]; | |
| 80 | + | validationTests: SecurityTestResult[]; | |
| 81 | + | gradientTests: SecurityTestResult[]; | |
| 82 | + | summary: { | |
| 83 | + | totalTests: number; | |
| 84 | + | passed: number; | |
| 85 | + | failed: number; | |
| 86 | + | vulnerabilities: string[]; | |
| 87 | + | }; | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | // Check if output contains dangerous patterns | |
| 91 | 91 | function detectVulnerabilities(output: string): string[] { | |
| 92 | - | const vulnerabilities: string[] = []; | |
| 93 | - | ||
| 94 | - | // Script tags | |
| 95 | - | if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) { | |
| 96 | - | vulnerabilities.push('Script tag injection'); | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Event handlers | |
| 100 | - | if (/on\w+\s*=/gi.test(output)) { | |
| 101 | - | vulnerabilities.push('Event handler injection'); | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // JavaScript URLs | |
| 105 | - | if (/javascript\s*:/gi.test(output)) { | |
| 106 | - | vulnerabilities.push('JavaScript URL injection'); | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // Data URLs with HTML/JS | |
| 110 | - | if (/data\s*:\s*text\/html/gi.test(output)) { | |
| 111 | - | vulnerabilities.push('Data URL HTML injection'); | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // CSS expressions | |
| 115 | - | if (/expression\s*\(/gi.test(output)) { | |
| 116 | - | vulnerabilities.push('CSS expression injection'); | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Dangerous CSS URLs | |
| 120 | - | if (/url\s*\(\s*['"]*javascript:/gi.test(output)) { | |
| 121 | - | vulnerabilities.push('CSS JavaScript URL injection'); | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // Iframe with dangerous src | |
| 125 | - | if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) { | |
| 126 | - | vulnerabilities.push('Dangerous iframe injection'); | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // Object/embed tags | |
| 130 | - | if (/<(?:object|embed|applet)\b/gi.test(output)) { | |
| 131 | - | vulnerabilities.push('Dangerous object/embed tag'); | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Style tag injection | |
| 135 | - | if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) { | |
| 136 | - | vulnerabilities.push('Style tag injection'); | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | return vulnerabilities; | |
| 92 | + | const vulnerabilities: string[] = []; | |
| 93 | + | ||
| 94 | + | // Script tags | |
| 95 | + | if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) { | |
| 96 | + | vulnerabilities.push('Script tag injection'); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Event handlers | |
| 100 | + | if (/on\w+\s*=/gi.test(output)) { | |
| 101 | + | vulnerabilities.push('Event handler injection'); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // JavaScript URLs | |
| 105 | + | if (/javascript\s*:/gi.test(output)) { | |
| 106 | + | vulnerabilities.push('JavaScript URL injection'); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Data URLs with HTML/JS | |
| 110 | + | if (/data\s*:\s*text\/html/gi.test(output)) { | |
| 111 | + | vulnerabilities.push('Data URL HTML injection'); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // CSS expressions | |
| 115 | + | if (/expression\s*\(/gi.test(output)) { | |
| 116 | + | vulnerabilities.push('CSS expression injection'); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Dangerous CSS URLs | |
| 120 | + | if (/url\s*\(\s*['"]*javascript:/gi.test(output)) { | |
| 121 | + | vulnerabilities.push('CSS JavaScript URL injection'); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Iframe with dangerous src | |
| 125 | + | if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) { | |
| 126 | + | vulnerabilities.push('Dangerous iframe injection'); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Object/embed tags | |
| 130 | + | if (/<(?:object|embed|applet)\b/gi.test(output)) { | |
| 131 | + | vulnerabilities.push('Dangerous object/embed tag'); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Style tag injection | |
| 135 | + | if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) { | |
| 136 | + | vulnerabilities.push('Style tag injection'); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return vulnerabilities; | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | 142 | // Run XSS tests | |
| 143 | 143 | async function runXSSTests(): Promise<SecurityTestResult[]> { | |
| 144 | - | const results: SecurityTestResult[] = []; | |
| 145 | - | ||
| 146 | - | for (const vector of XSS_TEST_VECTORS) { | |
| 147 | - | try { | |
| 148 | - | const output = await formatText(vector, DEFAULT_TEXT_STYLE, true); | |
| 149 | - | const vulnerabilities = detectVulnerabilities(output); | |
| 150 | - | ||
| 151 | - | results.push({ | |
| 152 | - | input: vector, | |
| 153 | - | output, | |
| 154 | - | passed: vulnerabilities.length === 0, | |
| 155 | - | vulnerabilities, | |
| 156 | - | testType: 'XSS' | |
| 157 | - | }); | |
| 158 | - | } catch (error) { | |
| 159 | - | results.push({ | |
| 160 | - | input: vector, | |
| 161 | - | output: `Error: ${error}`, | |
| 162 | - | passed: true, // Errors are acceptable for malicious input | |
| 163 | - | vulnerabilities: [], | |
| 164 | - | testType: 'XSS' | |
| 165 | - | }); | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | return results; | |
| 144 | + | const results: SecurityTestResult[] = []; | |
| 145 | + | ||
| 146 | + | for (const vector of XSS_TEST_VECTORS) { | |
| 147 | + | try { | |
| 148 | + | const output = await formatText(vector, DEFAULT_TEXT_STYLE, true); | |
| 149 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 150 | + | ||
| 151 | + | results.push({ | |
| 152 | + | input: vector, | |
| 153 | + | output, | |
| 154 | + | passed: vulnerabilities.length === 0, | |
| 155 | + | vulnerabilities, | |
| 156 | + | testType: 'XSS' | |
| 157 | + | }); | |
| 158 | + | } catch (error) { | |
| 159 | + | results.push({ | |
| 160 | + | input: vector, | |
| 161 | + | output: `Error: ${error}`, | |
| 162 | + | passed: true, // Errors are acceptable for malicious input | |
| 163 | + | vulnerabilities: [], | |
| 164 | + | testType: 'XSS' | |
| 165 | + | }); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | return results; | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | 172 | // Run CSS injection tests | |
| 173 | 173 | async function runCSSInjectionTests(): Promise<SecurityTestResult[]> { | |
| 174 | - | const results: SecurityTestResult[] = []; | |
| 175 | - | ||
| 176 | - | for (const vector of CSS_INJECTION_VECTORS) { | |
| 177 | - | try { | |
| 178 | - | const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any }; | |
| 179 | - | const output = await formatText(`Test with style`, testStyle, true); | |
| 180 | - | const vulnerabilities = detectVulnerabilities(output); | |
| 181 | - | ||
| 182 | - | results.push({ | |
| 183 | - | input: vector, | |
| 184 | - | output, | |
| 185 | - | passed: vulnerabilities.length === 0, | |
| 186 | - | vulnerabilities, | |
| 187 | - | testType: 'CSS Injection' | |
| 188 | - | }); | |
| 189 | - | } catch (error) { | |
| 190 | - | results.push({ | |
| 191 | - | input: vector, | |
| 192 | - | output: `Error: ${error}`, | |
| 193 | - | passed: true, | |
| 194 | - | vulnerabilities: [], | |
| 195 | - | testType: 'CSS Injection' | |
| 196 | - | }); | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | return results; | |
| 174 | + | const results: SecurityTestResult[] = []; | |
| 175 | + | ||
| 176 | + | for (const vector of CSS_INJECTION_VECTORS) { | |
| 177 | + | try { | |
| 178 | + | const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any }; | |
| 179 | + | const output = await formatText(`Test with style`, testStyle, true); | |
| 180 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 181 | + | ||
| 182 | + | results.push({ | |
| 183 | + | input: vector, | |
| 184 | + | output, | |
| 185 | + | passed: vulnerabilities.length === 0, | |
| 186 | + | vulnerabilities, | |
| 187 | + | testType: 'CSS Injection' | |
| 188 | + | }); | |
| 189 | + | } catch (error) { | |
| 190 | + | results.push({ | |
| 191 | + | input: vector, | |
| 192 | + | output: `Error: ${error}`, | |
| 193 | + | passed: true, | |
| 194 | + | vulnerabilities: [], | |
| 195 | + | testType: 'CSS Injection' | |
| 196 | + | }); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return results; | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | 203 | // Run validation tests | |
| 204 | 204 | function runValidationTests(): SecurityTestResult[] { | |
| 205 | - | const results: SecurityTestResult[] = []; | |
| 206 | - | ||
| 207 | - | const testCases = [ | |
| 208 | - | 'Normal text', | |
| 209 | - | 'Text with <script>alert("XSS")</script>', | |
| 210 | - | 'Very long text '.repeat(200), // Test length limits | |
| 211 | - | '', // Empty text | |
| 212 | - | 'Text with\nnewlines\nand\ttabs', | |
| 213 | - | 'Special chars: <>&"\'`', | |
| 214 | - | 'Unicode: 你好世界 🌍 🎉', | |
| 215 | - | 'Mixed: Normal **bold** and <script>alert("XSS")</script>' | |
| 216 | - | ]; | |
| 217 | - | ||
| 218 | - | for (const testCase of testCases) { | |
| 219 | - | const validation = validateMessageContent(testCase); | |
| 220 | - | const vulnerabilities = detectVulnerabilities(validation.sanitizedContent); | |
| 221 | - | ||
| 222 | - | results.push({ | |
| 223 | - | input: testCase, | |
| 224 | - | output: validation.sanitizedContent, | |
| 225 | - | passed: validation.isValid && vulnerabilities.length === 0, | |
| 226 | - | vulnerabilities: [...validation.errors, ...vulnerabilities], | |
| 227 | - | testType: 'Validation' | |
| 228 | - | }); | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | return results; | |
| 205 | + | const results: SecurityTestResult[] = []; | |
| 206 | + | ||
| 207 | + | const testCases = [ | |
| 208 | + | 'Normal text', | |
| 209 | + | 'Text with <script>alert("XSS")</script>', | |
| 210 | + | 'Very long text '.repeat(200), // Test length limits | |
| 211 | + | '', // Empty text | |
| 212 | + | 'Text with\nnewlines\nand\ttabs', | |
| 213 | + | 'Special chars: <>&"\'`', | |
| 214 | + | 'Unicode: 你好世界 🌍 🎉', | |
| 215 | + | 'Mixed: Normal **bold** and <script>alert("XSS")</script>' | |
| 216 | + | ]; | |
| 217 | + | ||
| 218 | + | for (const testCase of testCases) { | |
| 219 | + | const validation = validateMessageContent(testCase); | |
| 220 | + | const vulnerabilities = detectVulnerabilities(validation.sanitizedContent); | |
| 221 | + | ||
| 222 | + | results.push({ | |
| 223 | + | input: testCase, | |
| 224 | + | output: validation.sanitizedContent, | |
| 225 | + | passed: validation.isValid && vulnerabilities.length === 0, | |
| 226 | + | vulnerabilities: [...validation.errors, ...vulnerabilities], | |
| 227 | + | testType: 'Validation' | |
| 228 | + | }); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | return results; | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | // Run gradient text security tests | |
| 235 | 235 | function runGradientTests(): SecurityTestResult[] { | |
| 236 | - | const results: SecurityTestResult[] = []; | |
| 237 | - | ||
| 238 | - | const testCases = [ | |
| 239 | - | 'Normal gradient text', | |
| 240 | - | '<script>alert("XSS")</script>', | |
| 241 | - | 'Text with "quotes" and \'apostrophes\'', | |
| 242 | - | 'Very long gradient text '.repeat(50), | |
| 243 | - | 'Special chars: <>&\'"', | |
| 244 | - | '' | |
| 245 | - | ]; | |
| 246 | - | ||
| 247 | - | const testColors = ['#FF0000', '#00FF00', '#0000FF']; | |
| 248 | - | ||
| 249 | - | for (const testCase of testCases) { | |
| 250 | - | try { | |
| 251 | - | const output = createGradientText(testCase, testColors); | |
| 252 | - | const vulnerabilities = detectVulnerabilities(output); | |
| 253 | - | ||
| 254 | - | results.push({ | |
| 255 | - | input: testCase, | |
| 256 | - | output, | |
| 257 | - | passed: vulnerabilities.length === 0, | |
| 258 | - | vulnerabilities, | |
| 259 | - | testType: 'Gradient' | |
| 260 | - | }); | |
| 261 | - | } catch (error) { | |
| 262 | - | results.push({ | |
| 263 | - | input: testCase, | |
| 264 | - | output: `Error: ${error}`, | |
| 265 | - | passed: true, | |
| 266 | - | vulnerabilities: [], | |
| 267 | - | testType: 'Gradient' | |
| 268 | - | }); | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | return results; | |
| 236 | + | const results: SecurityTestResult[] = []; | |
| 237 | + | ||
| 238 | + | const testCases = [ | |
| 239 | + | 'Normal gradient text', | |
| 240 | + | '<script>alert("XSS")</script>', | |
| 241 | + | 'Text with "quotes" and \'apostrophes\'', | |
| 242 | + | 'Very long gradient text '.repeat(50), | |
| 243 | + | 'Special chars: <>&\'"', | |
| 244 | + | '' | |
| 245 | + | ]; | |
| 246 | + | ||
| 247 | + | const testColors = ['#FF0000', '#00FF00', '#0000FF']; | |
| 248 | + | ||
| 249 | + | for (const testCase of testCases) { | |
| 250 | + | try { | |
| 251 | + | const output = createGradientText(testCase, testColors); | |
| 252 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 253 | + | ||
| 254 | + | results.push({ | |
| 255 | + | input: testCase, | |
| 256 | + | output, | |
| 257 | + | passed: vulnerabilities.length === 0, | |
| 258 | + | vulnerabilities, | |
| 259 | + | testType: 'Gradient' | |
| 260 | + | }); | |
| 261 | + | } catch (error) { | |
| 262 | + | results.push({ | |
| 263 | + | input: testCase, | |
| 264 | + | output: `Error: ${error}`, | |
| 265 | + | passed: true, | |
| 266 | + | vulnerabilities: [], | |
| 267 | + | testType: 'Gradient' | |
| 268 | + | }); | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | return results; | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | 275 | // Run complete security test suite | |
| 276 | 276 | export async function runSecurityTestSuite(): Promise<SecurityTestSuite> { | |
| 277 | - | console.log('🔒 Running comprehensive security test suite...'); | |
| 278 | - | ||
| 279 | - | const xssTests = await runXSSTests(); | |
| 280 | - | const cssInjectionTests = await runCSSInjectionTests(); | |
| 281 | - | const validationTests = runValidationTests(); | |
| 282 | - | const gradientTests = runGradientTests(); | |
| 283 | - | ||
| 284 | - | const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests]; | |
| 285 | - | const passed = allTests.filter(test => test.passed).length; | |
| 286 | - | const failed = allTests.length - passed; | |
| 287 | - | ||
| 288 | - | // Collect all unique vulnerabilities | |
| 289 | - | const allVulnerabilities = new Set<string>(); | |
| 290 | - | allTests.forEach(test => { | |
| 291 | - | test.vulnerabilities.forEach(vuln => allVulnerabilities.add(vuln)); | |
| 292 | - | }); | |
| 293 | - | ||
| 294 | - | const results: SecurityTestSuite = { | |
| 295 | - | xssTests, | |
| 296 | - | cssInjectionTests, | |
| 297 | - | validationTests, | |
| 298 | - | gradientTests, | |
| 299 | - | summary: { | |
| 300 | - | totalTests: allTests.length, | |
| 301 | - | passed, | |
| 302 | - | failed, | |
| 303 | - | vulnerabilities: Array.from(allVulnerabilities) | |
| 304 | - | } | |
| 305 | - | }; | |
| 306 | - | ||
| 307 | - | console.log(`✅ Security tests completed:`); | |
| 308 | - | console.log(` Total tests: ${results.summary.totalTests}`); | |
| 309 | - | console.log(` Passed: ${results.summary.passed}`); | |
| 310 | - | console.log(` Failed: ${results.summary.failed}`); | |
| 311 | - | ||
| 312 | - | if (results.summary.vulnerabilities.length > 0) { | |
| 313 | - | console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities); | |
| 314 | - | } else { | |
| 315 | - | console.log(`🛡️ No vulnerabilities detected!`); | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | return results; | |
| 277 | + | console.log('🔒 Running comprehensive security test suite...'); | |
| 278 | + | ||
| 279 | + | const xssTests = await runXSSTests(); | |
| 280 | + | const cssInjectionTests = await runCSSInjectionTests(); | |
| 281 | + | const validationTests = runValidationTests(); | |
| 282 | + | const gradientTests = runGradientTests(); | |
| 283 | + | ||
| 284 | + | const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests]; | |
| 285 | + | const passed = allTests.filter((test) => test.passed).length; | |
| 286 | + | const failed = allTests.length - passed; | |
| 287 | + | ||
| 288 | + | // Collect all unique vulnerabilities | |
| 289 | + | const allVulnerabilities = new Set<string>(); | |
| 290 | + | allTests.forEach((test) => { | |
| 291 | + | test.vulnerabilities.forEach((vuln) => allVulnerabilities.add(vuln)); | |
| 292 | + | }); | |
| 293 | + | ||
| 294 | + | const results: SecurityTestSuite = { | |
| 295 | + | xssTests, | |
| 296 | + | cssInjectionTests, | |
| 297 | + | validationTests, | |
| 298 | + | gradientTests, | |
| 299 | + | summary: { | |
| 300 | + | totalTests: allTests.length, | |
| 301 | + | passed, | |
| 302 | + | failed, | |
| 303 | + | vulnerabilities: Array.from(allVulnerabilities) | |
| 304 | + | } | |
| 305 | + | }; | |
| 306 | + | ||
| 307 | + | console.log(`✅ Security tests completed:`); | |
| 308 | + | console.log(` Total tests: ${results.summary.totalTests}`); | |
| 309 | + | console.log(` Passed: ${results.summary.passed}`); | |
| 310 | + | console.log(` Failed: ${results.summary.failed}`); | |
| 311 | + | ||
| 312 | + | if (results.summary.vulnerabilities.length > 0) { | |
| 313 | + | console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities); | |
| 314 | + | } else { | |
| 315 | + | console.log(`🛡️ No vulnerabilities detected!`); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | return results; | |
| 319 | 319 | } | |
| 320 | 320 | ||
| 321 | 321 | // Quick security check function | |
| 322 | 322 | export async function quickSecurityCheck(input: string): Promise<boolean> { | |
| 323 | - | try { | |
| 324 | - | const output = await formatText(input, DEFAULT_TEXT_STYLE, true); | |
| 325 | - | const vulnerabilities = detectVulnerabilities(output); | |
| 326 | - | return vulnerabilities.length === 0; | |
| 327 | - | } catch (error) { | |
| 328 | - | // Errors on malicious input are acceptable | |
| 329 | - | return true; | |
| 330 | - | } | |
| 323 | + | try { | |
| 324 | + | const output = await formatText(input, DEFAULT_TEXT_STYLE, true); | |
| 325 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 326 | + | return vulnerabilities.length === 0; | |
| 327 | + | } catch (error) { | |
| 328 | + | // Errors on malicious input are acceptable | |
| 329 | + | return true; | |
| 330 | + | } | |
| 331 | 331 | } | |
| 332 | 332 | ||
| 333 | 333 | // Style validation tests | |
| 334 | 334 | export function testStyleValidation(): boolean { | |
| 335 | - | const testCases = [ | |
| 336 | - | // Valid styles | |
| 337 | - | { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true }, | |
| 338 | - | { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true }, | |
| 339 | - | ||
| 340 | - | // Invalid styles (should be sanitized) | |
| 341 | - | { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' }, | |
| 342 | - | { fontFamily: 'tahoma', fontSize: -10, color: 'red' }, | |
| 343 | - | { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' } | |
| 344 | - | ]; | |
| 345 | - | ||
| 346 | - | for (const testCase of testCases) { | |
| 347 | - | try { | |
| 348 | - | const validated = validateTextStyle(testCase as Partial<TextStyle>); | |
| 349 | - | ||
| 350 | - | // Check that validated style has safe values | |
| 351 | - | if (!validated.fontFamily || | |
| 352 | - | validated.fontSize < 8 || | |
| 353 | - | validated.fontSize > 72) { | |
| 354 | - | console.error('Style validation failed for:', testCase); | |
| 355 | - | return false; | |
| 356 | - | } | |
| 357 | - | } catch (error) { | |
| 358 | - | console.error('Style validation error:', error); | |
| 359 | - | return false; | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | return true; | |
| 335 | + | const testCases = [ | |
| 336 | + | // Valid styles | |
| 337 | + | { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true }, | |
| 338 | + | { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true }, | |
| 339 | + | ||
| 340 | + | // Invalid styles (should be sanitized) | |
| 341 | + | { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' }, | |
| 342 | + | { fontFamily: 'tahoma', fontSize: -10, color: 'red' }, | |
| 343 | + | { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' } | |
| 344 | + | ]; | |
| 345 | + | ||
| 346 | + | for (const testCase of testCases) { | |
| 347 | + | try { | |
| 348 | + | const validated = validateTextStyle(testCase as Partial<TextStyle>); | |
| 349 | + | ||
| 350 | + | // Check that validated style has safe values | |
| 351 | + | if (!validated.fontFamily || validated.fontSize < 8 || validated.fontSize > 72) { | |
| 352 | + | console.error('Style validation failed for:', testCase); | |
| 353 | + | return false; | |
| 354 | + | } | |
| 355 | + | } catch (error) { | |
| 356 | + | console.error('Style validation error:', error); | |
| 357 | + | return false; | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | return true; | |
| 364 | 362 | } | |
| 365 | 363 | ||
| 366 | 364 | // Export test utilities | |
| 367 | 365 | export { | |
| 368 | - | XSS_TEST_VECTORS, | |
| 369 | - | CSS_INJECTION_VECTORS, | |
| 370 | - | detectVulnerabilities, | |
| 371 | - | type SecurityTestResult, | |
| 372 | - | type SecurityTestSuite | |
| 373 | - | };⏎̸ | |
| 366 | + | XSS_TEST_VECTORS, | |
| 367 | + | CSS_INJECTION_VECTORS, | |
| 368 | + | detectVulnerabilities, | |
| 369 | + | type SecurityTestResult, | |
| 370 | + | type SecurityTestSuite | |
| 371 | + | }; | |
M src/lib/utils/sseClient.ts +27 -21
| @@ -3,24 +3,30 @@ | |||
| 3 | 3 | * This helps avoid unauthenticated SSE requests that trigger rate limiting on the server. | |
| 4 | 4 | */ | |
| 5 | 5 | ||
| 6 | - | export function initSSE(url: string, maxRetries: number = 5, retryDelay: number = 500): Promise<EventSource> { | |
| 7 | - | return new Promise((resolve, reject) => { | |
| 8 | - | let attempt = 0; | |
| 9 | - | const tryConnect = () => { | |
| 10 | - | if (document.cookie.includes('session=')) { | |
| 11 | - | console.debug('Session cookie found, initializing SSE connection'); | |
| 12 | - | const eventSource = new EventSource(url); | |
| 13 | - | resolve(eventSource); | |
| 14 | - | } else { | |
| 15 | - | attempt++; | |
| 16 | - | console.debug(`Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`); | |
| 17 | - | if (attempt >= maxRetries) { | |
| 18 | - | reject(new Error('Session cookie not found after maximum retries')); | |
| 19 | - | } else { | |
| 20 | - | setTimeout(tryConnect, retryDelay); | |
| 21 | - | } | |
| 22 | - | } | |
| 23 | - | }; | |
| 24 | - | tryConnect(); | |
| 25 | - | }); | |
| 26 | - | } ⏎̸ | |
| 6 | + | export function initSSE( | |
| 7 | + | url: string, | |
| 8 | + | maxRetries: number = 5, | |
| 9 | + | retryDelay: number = 500 | |
| 10 | + | ): Promise<EventSource> { | |
| 11 | + | return new Promise((resolve, reject) => { | |
| 12 | + | let attempt = 0; | |
| 13 | + | const tryConnect = () => { | |
| 14 | + | if (document.cookie.includes('session=')) { | |
| 15 | + | console.debug('Session cookie found, initializing SSE connection'); | |
| 16 | + | const eventSource = new EventSource(url); | |
| 17 | + | resolve(eventSource); | |
| 18 | + | } else { | |
| 19 | + | attempt++; | |
| 20 | + | console.debug( | |
| 21 | + | `Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...` | |
| 22 | + | ); | |
| 23 | + | if (attempt >= maxRetries) { | |
| 24 | + | reject(new Error('Session cookie not found after maximum retries')); | |
| 25 | + | } else { | |
| 26 | + | setTimeout(tryConnect, retryDelay); | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | }; | |
| 30 | + | tryConnect(); | |
| 31 | + | }); | |
| 32 | + | } | |
M src/lib/utils/text-formatter.ts +306 -300
| @@ -8,347 +8,353 @@ import { generateCSSStyle, validateTextStyle } from '../types/text-formatting'; | |||
| 8 | 8 | ||
| 9 | 9 | // Define allowed HTML tags and attributes for security | |
| 10 | 10 | const ALLOWED_SCHEMA = { | |
| 11 | - | ...defaultSchema, | |
| 12 | - | tagNames: [ | |
| 13 | - | ...(defaultSchema.tagNames || []), | |
| 14 | - | 'span', | |
| 15 | - | 'strong', | |
| 16 | - | 'em', | |
| 17 | - | 'u', | |
| 18 | - | 's', | |
| 19 | - | 'mark' | |
| 20 | - | ], | |
| 21 | - | attributes: { | |
| 22 | - | ...defaultSchema.attributes, | |
| 23 | - | span: ['style', 'class', 'data-gradient'], | |
| 24 | - | strong: ['style', 'class'], | |
| 25 | - | em: ['style', 'class'], | |
| 26 | - | u: ['style', 'class'], | |
| 27 | - | s: ['style', 'class'], | |
| 28 | - | mark: ['style', 'class'] | |
| 29 | - | }, | |
| 30 | - | protocols: { | |
| 31 | - | ...defaultSchema.protocols, | |
| 32 | - | style: [] | |
| 33 | - | } | |
| 11 | + | ...defaultSchema, | |
| 12 | + | tagNames: [...(defaultSchema.tagNames || []), 'span', 'strong', 'em', 'u', 's', 'mark'], | |
| 13 | + | attributes: { | |
| 14 | + | ...defaultSchema.attributes, | |
| 15 | + | span: ['style', 'class', 'data-gradient'], | |
| 16 | + | strong: ['style', 'class'], | |
| 17 | + | em: ['style', 'class'], | |
| 18 | + | u: ['style', 'class'], | |
| 19 | + | s: ['style', 'class'], | |
| 20 | + | mark: ['style', 'class'] | |
| 21 | + | }, | |
| 22 | + | protocols: { | |
| 23 | + | ...defaultSchema.protocols, | |
| 24 | + | style: [] | |
| 25 | + | } | |
| 34 | 26 | }; | |
| 35 | 27 | ||
| 36 | 28 | // Custom rehype plugin to apply text styles | |
| 37 | 29 | function rehypeApplyStyles(userStyle: TextStyle) { | |
| 38 | - | return function(tree: any) { | |
| 39 | - | // We don't need to apply styles here anymore | |
| 40 | - | // The formatted-message component handles all styling | |
| 41 | - | return tree; | |
| 42 | - | }; | |
| 30 | + | return function (tree: any) { | |
| 31 | + | // We don't need to apply styles here anymore | |
| 32 | + | // The formatted-message component handles all styling | |
| 33 | + | return tree; | |
| 34 | + | }; | |
| 43 | 35 | } | |
| 44 | 36 | ||
| 45 | 37 | // BBCode-style formatting patterns | |
| 46 | 38 | const BB_CODE_PATTERNS: Array<{ | |
| 47 | - | pattern: RegExp; | |
| 48 | - | replacement: string; | |
| 39 | + | pattern: RegExp; | |
| 40 | + | replacement: string; | |
| 49 | 41 | }> = [ | |
| 50 | - | // Bold | |
| 51 | - | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, | |
| 52 | - | ||
| 53 | - | // Italic | |
| 54 | - | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, | |
| 55 | - | ||
| 56 | - | // Underline | |
| 57 | - | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, | |
| 58 | - | ||
| 59 | - | // Strikethrough | |
| 60 | - | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, | |
| 61 | - | ||
| 62 | - | // Color | |
| 63 | - | { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' }, | |
| 64 | - | ||
| 65 | - | // Size | |
| 66 | - | { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' }, | |
| 67 | - | ||
| 68 | - | // Font | |
| 69 | - | { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' } | |
| 42 | + | // Bold | |
| 43 | + | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, | |
| 44 | + | ||
| 45 | + | // Italic | |
| 46 | + | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, | |
| 47 | + | ||
| 48 | + | // Underline | |
| 49 | + | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, | |
| 50 | + | ||
| 51 | + | // Strikethrough | |
| 52 | + | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, | |
| 53 | + | ||
| 54 | + | // Color | |
| 55 | + | { | |
| 56 | + | pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, | |
| 57 | + | replacement: '<span style="color: $1">$2</span>' | |
| 58 | + | }, | |
| 59 | + | ||
| 60 | + | // Size | |
| 61 | + | { | |
| 62 | + | pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, | |
| 63 | + | replacement: '<span style="font-size: $1px">$2</span>' | |
| 64 | + | }, | |
| 65 | + | ||
| 66 | + | // Font | |
| 67 | + | { | |
| 68 | + | pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, | |
| 69 | + | replacement: '<span style="font-family: $1">$2</span>' | |
| 70 | + | } | |
| 70 | 71 | ]; | |
| 71 | 72 | ||
| 72 | - | // Markdown-style formatting patterns | |
| 73 | + | // Markdown-style formatting patterns | |
| 73 | 74 | const MARKDOWN_PATTERNS: Array<{ | |
| 74 | - | pattern: RegExp; | |
| 75 | - | replacement: string; | |
| 75 | + | pattern: RegExp; | |
| 76 | + | replacement: string; | |
| 76 | 77 | }> = [ | |
| 77 | - | // Bold (double asterisk or double underscore) | |
| 78 | - | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, | |
| 79 | - | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, | |
| 80 | - | ||
| 81 | - | // Italic (single asterisk or single underscore) | |
| 82 | - | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, | |
| 83 | - | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, | |
| 84 | - | ||
| 85 | - | // Strikethrough | |
| 86 | - | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, | |
| 87 | - | ||
| 88 | - | // Inline code | |
| 89 | - | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } | |
| 78 | + | // Bold (double asterisk or double underscore) | |
| 79 | + | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, | |
| 80 | + | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, | |
| 81 | + | ||
| 82 | + | // Italic (single asterisk or single underscore) | |
| 83 | + | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, | |
| 84 | + | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, | |
| 85 | + | ||
| 86 | + | // Strikethrough | |
| 87 | + | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, | |
| 88 | + | ||
| 89 | + | // Inline code | |
| 90 | + | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } | |
| 90 | 91 | ]; | |
| 91 | 92 | ||
| 92 | 93 | // Preprocess text to convert BBCode and Markdown to HTML | |
| 93 | 94 | function preprocessFormatting(text: string): string { | |
| 94 | - | let processedText = text; | |
| 95 | - | ||
| 96 | - | // Apply BBCode patterns first | |
| 97 | - | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 98 | - | processedText = processedText.replace(pattern, replacement); | |
| 99 | - | }); | |
| 100 | - | ||
| 101 | - | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) | |
| 102 | - | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 103 | - | // Only apply if not already inside HTML tags | |
| 104 | - | processedText = processedText.replace(pattern, (match, content) => { | |
| 105 | - | // Simple check to avoid replacing inside existing HTML tags | |
| 106 | - | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); | |
| 107 | - | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; | |
| 108 | - | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; | |
| 109 | - | ||
| 110 | - | // If we're inside an unclosed tag, don't apply markdown | |
| 111 | - | if (openTags > closeTags) { | |
| 112 | - | return match; | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | return replacement.replace('$1', content); | |
| 116 | - | }); | |
| 117 | - | }); | |
| 118 | - | ||
| 119 | - | return processedText; | |
| 95 | + | let processedText = text; | |
| 96 | + | ||
| 97 | + | // Apply BBCode patterns first | |
| 98 | + | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 99 | + | processedText = processedText.replace(pattern, replacement); | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) | |
| 103 | + | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 104 | + | // Only apply if not already inside HTML tags | |
| 105 | + | processedText = processedText.replace(pattern, (match, content) => { | |
| 106 | + | // Simple check to avoid replacing inside existing HTML tags | |
| 107 | + | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); | |
| 108 | + | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; | |
| 109 | + | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; | |
| 110 | + | ||
| 111 | + | // If we're inside an unclosed tag, don't apply markdown | |
| 112 | + | if (openTags > closeTags) { | |
| 113 | + | return match; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return replacement.replace('$1', content); | |
| 117 | + | }); | |
| 118 | + | }); | |
| 119 | + | ||
| 120 | + | return processedText; | |
| 120 | 121 | } | |
| 121 | 122 | ||
| 122 | 123 | // Main text formatting function | |
| 123 | 124 | export async function formatText( | |
| 124 | - | text: string, | |
| 125 | - | userStyle?: Partial<TextStyle>, | |
| 126 | - | allowRichFormatting: boolean = true | |
| 125 | + | text: string, | |
| 126 | + | userStyle?: Partial<TextStyle>, | |
| 127 | + | allowRichFormatting: boolean = true | |
| 127 | 128 | ): Promise<string> { | |
| 128 | - | try { | |
| 129 | - | // If text is empty, return empty string | |
| 130 | - | if (!text) return ''; | |
| 131 | - | ||
| 132 | - | // Validate and apply user style | |
| 133 | - | const validatedStyle = validateTextStyle(userStyle || {}); | |
| 134 | - | ||
| 135 | - | // Preprocess the text for formatting if allowed | |
| 136 | - | let processedText = text; | |
| 137 | - | if (allowRichFormatting) { | |
| 138 | - | processedText = preprocessFormatting(text); | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // If no rich formatting, just return escaped text | |
| 142 | - | if (!allowRichFormatting && !processedText.includes('<')) { | |
| 143 | - | return escapeHtml(text); | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Create the unified processor | |
| 147 | - | const processor = unified() | |
| 148 | - | .use(remarkParse, { | |
| 149 | - | fragment: true // Parse as fragment, not full document | |
| 150 | - | }) | |
| 151 | - | .use(remarkRehype, { | |
| 152 | - | allowDangerousHtml: true // Allow preprocessed HTML to pass through | |
| 153 | - | }) | |
| 154 | - | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML | |
| 155 | - | .use(rehypeStringify); | |
| 156 | - | ||
| 157 | - | // Process the text | |
| 158 | - | const result = await processor.process(processedText); | |
| 159 | - | let formattedHtml = String(result).trim(); | |
| 160 | - | ||
| 161 | - | // If we got an empty result, return the escaped text | |
| 162 | - | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { | |
| 163 | - | return escapeHtml(text); | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // Remove wrapping <p> tags if it's just a single paragraph | |
| 167 | - | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { | |
| 168 | - | const pCount = (formattedHtml.match(/<p>/g) || []).length; | |
| 169 | - | if (pCount === 1) { | |
| 170 | - | formattedHtml = formattedHtml.slice(3, -4).trim(); | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // Final check - if we ended up with empty content, return escaped text | |
| 175 | - | if (!formattedHtml) { | |
| 176 | - | return escapeHtml(text); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | return formattedHtml; | |
| 180 | - | ||
| 181 | - | } catch (error) { | |
| 182 | - | console.error('Text formatting error:', error); | |
| 183 | - | // Return escaped plain text as fallback | |
| 184 | - | return escapeHtml(text); | |
| 185 | - | } | |
| 129 | + | try { | |
| 130 | + | // If text is empty, return empty string | |
| 131 | + | if (!text) return ''; | |
| 132 | + | ||
| 133 | + | // Validate and apply user style | |
| 134 | + | const validatedStyle = validateTextStyle(userStyle || {}); | |
| 135 | + | ||
| 136 | + | // Preprocess the text for formatting if allowed | |
| 137 | + | let processedText = text; | |
| 138 | + | if (allowRichFormatting) { | |
| 139 | + | processedText = preprocessFormatting(text); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // If no rich formatting, just return escaped text | |
| 143 | + | if (!allowRichFormatting && !processedText.includes('<')) { | |
| 144 | + | return escapeHtml(text); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Create the unified processor | |
| 148 | + | const processor = unified() | |
| 149 | + | .use(remarkParse, { | |
| 150 | + | fragment: true // Parse as fragment, not full document | |
| 151 | + | }) | |
| 152 | + | .use(remarkRehype, { | |
| 153 | + | allowDangerousHtml: true // Allow preprocessed HTML to pass through | |
| 154 | + | }) | |
| 155 | + | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML | |
| 156 | + | .use(rehypeStringify); | |
| 157 | + | ||
| 158 | + | // Process the text | |
| 159 | + | const result = await processor.process(processedText); | |
| 160 | + | let formattedHtml = String(result).trim(); | |
| 161 | + | ||
| 162 | + | // If we got an empty result, return the escaped text | |
| 163 | + | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { | |
| 164 | + | return escapeHtml(text); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Remove wrapping <p> tags if it's just a single paragraph | |
| 168 | + | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { | |
| 169 | + | const pCount = (formattedHtml.match(/<p>/g) || []).length; | |
| 170 | + | if (pCount === 1) { | |
| 171 | + | formattedHtml = formattedHtml.slice(3, -4).trim(); | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Final check - if we ended up with empty content, return escaped text | |
| 176 | + | if (!formattedHtml) { | |
| 177 | + | return escapeHtml(text); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | return formattedHtml; | |
| 181 | + | } catch (error) { | |
| 182 | + | console.error('Text formatting error:', error); | |
| 183 | + | // Return escaped plain text as fallback | |
| 184 | + | return escapeHtml(text); | |
| 185 | + | } | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 188 | // Utility function to escape HTML | |
| 189 | 189 | function escapeHtml(text: string): string { | |
| 190 | - | const div = document.createElement('div'); | |
| 191 | - | div.textContent = text; | |
| 192 | - | return div.innerHTML; | |
| 190 | + | const div = document.createElement('div'); | |
| 191 | + | div.textContent = text; | |
| 192 | + | return div.innerHTML; | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | 195 | // Generate CSS style string excluding font-family (which should be handled by classes) | |
| 196 | 196 | function generateCSSStyleWithoutFont(style: TextStyle): string { | |
| 197 | - | const styles: string[] = []; | |
| 198 | - | ||
| 199 | - | // Font size | |
| 200 | - | styles.push(`font-size: ${style.fontSize}px`); | |
| 201 | - | ||
| 202 | - | // Color (not for gradients) | |
| 203 | - | if (style.color && !style.gradient) { | |
| 204 | - | styles.push(`color: ${style.color}`); | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | // Font weight | |
| 208 | - | if (style.bold) { | |
| 209 | - | styles.push(`font-weight: bold`); | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // Font style | |
| 213 | - | if (style.italic) { | |
| 214 | - | styles.push(`font-style: italic`); | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // Text decoration | |
| 218 | - | const decorations: string[] = []; | |
| 219 | - | if (style.underline) decorations.push('underline'); | |
| 220 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 221 | - | if (decorations.length > 0) { | |
| 222 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | return styles.join('; '); | |
| 197 | + | const styles: string[] = []; | |
| 198 | + | ||
| 199 | + | // Font size | |
| 200 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 201 | + | ||
| 202 | + | // Color (not for gradients) | |
| 203 | + | if (style.color && !style.gradient) { | |
| 204 | + | styles.push(`color: ${style.color}`); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Font weight | |
| 208 | + | if (style.bold) { | |
| 209 | + | styles.push(`font-weight: bold`); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Font style | |
| 213 | + | if (style.italic) { | |
| 214 | + | styles.push(`font-style: italic`); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Text decoration | |
| 218 | + | const decorations: string[] = []; | |
| 219 | + | if (style.underline) decorations.push('underline'); | |
| 220 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 221 | + | if (decorations.length > 0) { | |
| 222 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return styles.join('; '); | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | 228 | // Create gradient text effect | |
| 229 | 229 | export function createGradientText( | |
| 230 | - | text: string, | |
| 231 | - | colors: string[], | |
| 232 | - | className: string = 'gradient-text', | |
| 233 | - | style?: TextStyle | |
| 230 | + | text: string, | |
| 231 | + | colors: string[], | |
| 232 | + | className: string = 'gradient-text', | |
| 233 | + | style?: TextStyle | |
| 234 | 234 | ): string { | |
| 235 | - | console.debug('createGradientText called with:', { text, colors, className, style }); | |
| 236 | - | ||
| 237 | - | if (colors.length < 2) { | |
| 238 | - | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 239 | - | const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 240 | - | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | |
| 241 | - | console.debug('Single color result:', result); | |
| 242 | - | return result; | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | const chars = Array.from(text); | |
| 246 | - | const totalChars = chars.length; | |
| 247 | - | ||
| 248 | - | if (totalChars === 0) return ''; | |
| 249 | - | ||
| 250 | - | // Create spans for each character with interpolated colors | |
| 251 | - | const gradientSpans = chars.map((char, index) => { | |
| 252 | - | if (char === ' ') { | |
| 253 | - | return ' '; // Use non-breaking space to preserve spacing | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // Calculate color position (0 to 1) | |
| 257 | - | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | |
| 258 | - | ||
| 259 | - | // Find the two colors to interpolate between | |
| 260 | - | const colorIndex = position * (colors.length - 1); | |
| 261 | - | const lowerIndex = Math.floor(colorIndex); | |
| 262 | - | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | |
| 263 | - | const t = colorIndex - lowerIndex; | |
| 264 | - | ||
| 265 | - | // Interpolate between colors | |
| 266 | - | const color1 = hexToRgb(colors[lowerIndex]); | |
| 267 | - | const color2 = hexToRgb(colors[upperIndex]); | |
| 268 | - | ||
| 269 | - | if (!color1 || !color2) { | |
| 270 | - | const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 271 | - | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | const r = Math.round(color1.r + t * (color2.r - color1.r)); | |
| 275 | - | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |
| 276 | - | const b = Math.round(color1.b + t * (color2.b - color1.b)); | |
| 277 | - | ||
| 278 | - | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | |
| 279 | - | ||
| 280 | - | // Combine interpolated color with other styles | |
| 281 | - | let inlineStyle = `color: ${interpolatedColor}`; | |
| 282 | - | if (style) { | |
| 283 | - | // Get styles without font-family and color | |
| 284 | - | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | |
| 285 | - | if (style.bold) inlineStyle += `; font-weight: bold`; | |
| 286 | - | if (style.italic) inlineStyle += `; font-style: italic`; | |
| 287 | - | const decorations: string[] = []; | |
| 288 | - | if (style.underline) decorations.push('underline'); | |
| 289 | - | if (style.strikethrough) decorations.push('line-through'); | |
| 290 | - | if (decorations.length > 0) { | |
| 291 | - | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 296 | - | }); | |
| 297 | - | ||
| 298 | - | // Wrap all spans in a container that uses flexbox to eliminate spacing | |
| 299 | - | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 300 | - | const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : ''; | |
| 301 | - | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 302 | - | console.debug('Final gradient HTML:', result); | |
| 303 | - | return result; | |
| 235 | + | console.debug('createGradientText called with:', { text, colors, className, style }); | |
| 236 | + | ||
| 237 | + | if (colors.length < 2) { | |
| 238 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 239 | + | const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : ''; | |
| 240 | + | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | |
| 241 | + | console.debug('Single color result:', result); | |
| 242 | + | return result; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | const chars = Array.from(text); | |
| 246 | + | const totalChars = chars.length; | |
| 247 | + | ||
| 248 | + | if (totalChars === 0) return ''; | |
| 249 | + | ||
| 250 | + | // Create spans for each character with interpolated colors | |
| 251 | + | const gradientSpans = chars.map((char, index) => { | |
| 252 | + | if (char === ' ') { | |
| 253 | + | return ' '; // Use non-breaking space to preserve spacing | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Calculate color position (0 to 1) | |
| 257 | + | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | |
| 258 | + | ||
| 259 | + | // Find the two colors to interpolate between | |
| 260 | + | const colorIndex = position * (colors.length - 1); | |
| 261 | + | const lowerIndex = Math.floor(colorIndex); | |
| 262 | + | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | |
| 263 | + | const t = colorIndex - lowerIndex; | |
| 264 | + | ||
| 265 | + | // Interpolate between colors | |
| 266 | + | const color1 = hexToRgb(colors[lowerIndex]); | |
| 267 | + | const color2 = hexToRgb(colors[upperIndex]); | |
| 268 | + | ||
| 269 | + | if (!color1 || !color2) { | |
| 270 | + | const fallbackStyle = style | |
| 271 | + | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) | |
| 272 | + | : ''; | |
| 273 | + | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | const r = Math.round(color1.r + t * (color2.r - color1.r)); | |
| 277 | + | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |
| 278 | + | const b = Math.round(color1.b + t * (color2.b - color1.b)); | |
| 279 | + | ||
| 280 | + | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | |
| 281 | + | ||
| 282 | + | // Combine interpolated color with other styles | |
| 283 | + | let inlineStyle = `color: ${interpolatedColor}`; | |
| 284 | + | if (style) { | |
| 285 | + | // Get styles without font-family and color | |
| 286 | + | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | |
| 287 | + | if (style.bold) inlineStyle += `; font-weight: bold`; | |
| 288 | + | if (style.italic) inlineStyle += `; font-style: italic`; | |
| 289 | + | const decorations: string[] = []; | |
| 290 | + | if (style.underline) decorations.push('underline'); | |
| 291 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 292 | + | if (decorations.length > 0) { | |
| 293 | + | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 298 | + | }); | |
| 299 | + | ||
| 300 | + | // Wrap all spans in a container that uses flexbox to eliminate spacing | |
| 301 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 302 | + | const containerStyle = style | |
| 303 | + | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) | |
| 304 | + | : ''; | |
| 305 | + | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 306 | + | console.debug('Final gradient HTML:', result); | |
| 307 | + | return result; | |
| 304 | 308 | } | |
| 305 | 309 | ||
| 306 | 310 | // Helper function to convert hex to RGB | |
| 307 | 311 | function hexToRgb(hex: string): { r: number; g: number; b: number } | null { | |
| 308 | - | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| 309 | - | return result ? { | |
| 310 | - | r: parseInt(result[1], 16), | |
| 311 | - | g: parseInt(result[2], 16), | |
| 312 | - | b: parseInt(result[3], 16) | |
| 313 | - | } : null; | |
| 312 | + | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| 313 | + | return result | |
| 314 | + | ? { | |
| 315 | + | r: parseInt(result[1], 16), | |
| 316 | + | g: parseInt(result[2], 16), | |
| 317 | + | b: parseInt(result[3], 16) | |
| 318 | + | } | |
| 319 | + | : null; | |
| 314 | 320 | } | |
| 315 | 321 | ||
| 316 | 322 | // Validate message content for security | |
| 317 | 323 | export function validateMessageContent(content: string): { | |
| 318 | - | isValid: boolean; | |
| 319 | - | sanitizedContent: string; | |
| 320 | - | errors: string[]; | |
| 324 | + | isValid: boolean; | |
| 325 | + | sanitizedContent: string; | |
| 326 | + | errors: string[]; | |
| 321 | 327 | } { | |
| 322 | - | const errors: string[] = []; | |
| 323 | - | let sanitizedContent = content; | |
| 324 | - | ||
| 325 | - | // Check length | |
| 326 | - | if (content.length > 2000) { | |
| 327 | - | errors.push('Message too long (max 2000 characters)'); | |
| 328 | - | sanitizedContent = content.substring(0, 2000); | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | // Check for potential XSS patterns | |
| 332 | - | const dangerousPatterns = [ | |
| 333 | - | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, | |
| 334 | - | /javascript:/gi, | |
| 335 | - | /vbscript:/gi, | |
| 336 | - | /onload\s*=/gi, | |
| 337 | - | /onerror\s*=/gi, | |
| 338 | - | /onclick\s*=/gi, | |
| 339 | - | /onmouseover\s*=/gi | |
| 340 | - | ]; | |
| 341 | - | ||
| 342 | - | dangerousPatterns.forEach(pattern => { | |
| 343 | - | if (pattern.test(content)) { | |
| 344 | - | errors.push('Potentially dangerous content detected'); | |
| 345 | - | sanitizedContent = sanitizedContent.replace(pattern, ''); | |
| 346 | - | } | |
| 347 | - | }); | |
| 348 | - | ||
| 349 | - | return { | |
| 350 | - | isValid: errors.length === 0, | |
| 351 | - | sanitizedContent, | |
| 352 | - | errors | |
| 353 | - | }; | |
| 354 | - | }⏎̸ | |
| 328 | + | const errors: string[] = []; | |
| 329 | + | let sanitizedContent = content; | |
| 330 | + | ||
| 331 | + | // Check length | |
| 332 | + | if (content.length > 2000) { | |
| 333 | + | errors.push('Message too long (max 2000 characters)'); | |
| 334 | + | sanitizedContent = content.substring(0, 2000); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // Check for potential XSS patterns | |
| 338 | + | const dangerousPatterns = [ | |
| 339 | + | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, | |
| 340 | + | /javascript:/gi, | |
| 341 | + | /vbscript:/gi, | |
| 342 | + | /onload\s*=/gi, | |
| 343 | + | /onerror\s*=/gi, | |
| 344 | + | /onclick\s*=/gi, | |
| 345 | + | /onmouseover\s*=/gi | |
| 346 | + | ]; | |
| 347 | + | ||
| 348 | + | dangerousPatterns.forEach((pattern) => { | |
| 349 | + | if (pattern.test(content)) { | |
| 350 | + | errors.push('Potentially dangerous content detected'); | |
| 351 | + | sanitizedContent = sanitizedContent.replace(pattern, ''); | |
| 352 | + | } | |
| 353 | + | }); | |
| 354 | + | ||
| 355 | + | return { | |
| 356 | + | isValid: errors.length === 0, | |
| 357 | + | sanitizedContent, | |
| 358 | + | errors | |
| 359 | + | }; | |
| 360 | + | } | |
M src/lib/utils/turnstile.server.ts +41 -41
| @@ -5,47 +5,47 @@ const log = createLogger('turnstile'); | |||
| 5 | 5 | const isDev = process.env.NODE_ENV === 'development'; | |
| 6 | 6 | ||
| 7 | 7 | interface TurnstileVerifyResponse { | |
| 8 | - | "success": boolean; | |
| 9 | - | "error-codes"?: string[]; | |
| 10 | - | "challenge_ts"?: string; | |
| 11 | - | "hostname"?: string; | |
| 12 | - | "action"?: string; | |
| 13 | - | "cdata"?: string; | |
| 8 | + | success: boolean; | |
| 9 | + | 'error-codes'?: string[]; | |
| 10 | + | challenge_ts?: string; | |
| 11 | + | hostname?: string; | |
| 12 | + | action?: string; | |
| 13 | + | cdata?: string; | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> { | |
| 17 | - | // Always return true in development mode | |
| 18 | - | if (isDev) { | |
| 19 | - | log.debug('Development mode: bypassing Turnstile validation'); | |
| 20 | - | return true; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | try { | |
| 24 | - | const formData = new FormData(); | |
| 25 | - | formData.append('secret', TURNSTILE_SECRET_KEY); | |
| 26 | - | formData.append('response', token); | |
| 27 | - | if (remoteip) { | |
| 28 | - | formData.append('remoteip', remoteip); | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | |
| 32 | - | const result = await fetch(url, { | |
| 33 | - | body: formData, | |
| 34 | - | method: 'POST', | |
| 35 | - | }); | |
| 36 | - | ||
| 37 | - | const outcome: TurnstileVerifyResponse = await result.json(); | |
| 38 | - | ||
| 39 | - | if (!outcome.success) { | |
| 40 | - | log.warn('Turnstile validation failed', { | |
| 41 | - | errors: outcome["error-codes"], | |
| 42 | - | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined | |
| 43 | - | }); | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | return outcome.success; | |
| 47 | - | } catch (error) { | |
| 48 | - | log.error('Error validating Turnstile token', { error }); | |
| 49 | - | return false; | |
| 50 | - | } | |
| 51 | - | } ⏎̸ | |
| 17 | + | // Always return true in development mode | |
| 18 | + | if (isDev) { | |
| 19 | + | log.debug('Development mode: bypassing Turnstile validation'); | |
| 20 | + | return true; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | try { | |
| 24 | + | const formData = new FormData(); | |
| 25 | + | formData.append('secret', TURNSTILE_SECRET_KEY); | |
| 26 | + | formData.append('response', token); | |
| 27 | + | if (remoteip) { | |
| 28 | + | formData.append('remoteip', remoteip); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | |
| 32 | + | const result = await fetch(url, { | |
| 33 | + | body: formData, | |
| 34 | + | method: 'POST' | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | const outcome: TurnstileVerifyResponse = await result.json(); | |
| 38 | + | ||
| 39 | + | if (!outcome.success) { | |
| 40 | + | log.warn('Turnstile validation failed', { | |
| 41 | + | errors: outcome['error-codes'], | |
| 42 | + | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined | |
| 43 | + | }); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return outcome.success; | |
| 47 | + | } catch (error) { | |
| 48 | + | log.error('Error validating Turnstile token', { error }); | |
| 49 | + | return false; | |
| 50 | + | } | |
| 51 | + | } | |
M src/lib/validation/password.ts +192 -171
| @@ -2,198 +2,219 @@ import { z } from 'zod'; | |||
| 2 | 2 | ||
| 3 | 3 | // Dynamic password constraints configuration | |
| 4 | 4 | export interface PasswordConstraints { | |
| 5 | - | minLength: number; | |
| 6 | - | maxLength: number; | |
| 7 | - | requireUppercase: boolean; | |
| 8 | - | requireLowercase: boolean; | |
| 9 | - | requireNumbers: boolean; | |
| 10 | - | requireSpecialChars: boolean; | |
| 11 | - | forbiddenPatterns: string[]; | |
| 5 | + | minLength: number; | |
| 6 | + | maxLength: number; | |
| 7 | + | requireUppercase: boolean; | |
| 8 | + | requireLowercase: boolean; | |
| 9 | + | requireNumbers: boolean; | |
| 10 | + | requireSpecialChars: boolean; | |
| 11 | + | forbiddenPatterns: string[]; | |
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | 14 | // Default password constraints | |
| 15 | 15 | export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = { | |
| 16 | - | minLength: 8, | |
| 17 | - | maxLength: 64, | |
| 18 | - | requireUppercase: true, | |
| 19 | - | requireLowercase: true, | |
| 20 | - | requireNumbers: true, | |
| 21 | - | requireSpecialChars: false, | |
| 22 | - | forbiddenPatterns: ['password', '123456', 'qwerty', 'admin'] | |
| 16 | + | minLength: 8, | |
| 17 | + | maxLength: 64, | |
| 18 | + | requireUppercase: true, | |
| 19 | + | requireLowercase: true, | |
| 20 | + | requireNumbers: true, | |
| 21 | + | requireSpecialChars: false, | |
| 22 | + | forbiddenPatterns: ['password', '123456', 'qwerty', 'admin'] | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | // Validation error messages | |
| 26 | 26 | export const PASSWORD_ERROR_MESSAGES = { | |
| 27 | - | minLength: (min: number) => `Password must be at least ${min} characters long`, | |
| 28 | - | maxLength: (max: number) => `Password must be at most ${max} characters long`, | |
| 29 | - | requireUppercase: 'Password must contain at least one uppercase letter', | |
| 30 | - | requireLowercase: 'Password must contain at least one lowercase letter', | |
| 31 | - | requireNumbers: 'Password must contain at least one number', | |
| 32 | - | requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)', | |
| 33 | - | forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`, | |
| 34 | - | common: 'Password is too common or weak' | |
| 27 | + | minLength: (min: number) => `Password must be at least ${min} characters long`, | |
| 28 | + | maxLength: (max: number) => `Password must be at most ${max} characters long`, | |
| 29 | + | requireUppercase: 'Password must contain at least one uppercase letter', | |
| 30 | + | requireLowercase: 'Password must contain at least one lowercase letter', | |
| 31 | + | requireNumbers: 'Password must contain at least one number', | |
| 32 | + | requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)', | |
| 33 | + | forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`, | |
| 34 | + | common: 'Password is too common or weak' | |
| 35 | 35 | }; | |
| 36 | 36 | ||
| 37 | 37 | // Create dynamic password schema based on constraints | |
| 38 | - | export function createPasswordSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | |
| 39 | - | let schema = z.string() | |
| 40 | - | .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)) | |
| 41 | - | .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | |
| 42 | - | ||
| 43 | - | // Add character type requirements | |
| 44 | - | if (constraints.requireUppercase) { | |
| 45 | - | schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase); | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | if (constraints.requireLowercase) { | |
| 49 | - | schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase); | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | if (constraints.requireNumbers) { | |
| 53 | - | schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | if (constraints.requireSpecialChars) { | |
| 57 | - | schema = schema.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, PASSWORD_ERROR_MESSAGES.requireSpecialChars); | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Add forbidden patterns check | |
| 61 | - | if (constraints.forbiddenPatterns.length > 0) { | |
| 62 | - | return schema.refine( | |
| 63 | - | (password) => { | |
| 64 | - | const lowerPassword = password.toLowerCase(); | |
| 65 | - | return !constraints.forbiddenPatterns.some(pattern => | |
| 66 | - | lowerPassword.includes(pattern.toLowerCase()) | |
| 67 | - | ); | |
| 68 | - | }, | |
| 69 | - | { | |
| 70 | - | message: PASSWORD_ERROR_MESSAGES.common | |
| 71 | - | } | |
| 72 | - | ); | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | return schema; | |
| 38 | + | export function createPasswordSchema( | |
| 39 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS | |
| 40 | + | ) { | |
| 41 | + | let schema = z | |
| 42 | + | .string() | |
| 43 | + | .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)) | |
| 44 | + | .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | |
| 45 | + | ||
| 46 | + | // Add character type requirements | |
| 47 | + | if (constraints.requireUppercase) { | |
| 48 | + | schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if (constraints.requireLowercase) { | |
| 52 | + | schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if (constraints.requireNumbers) { | |
| 56 | + | schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if (constraints.requireSpecialChars) { | |
| 60 | + | schema = schema.regex( | |
| 61 | + | /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, | |
| 62 | + | PASSWORD_ERROR_MESSAGES.requireSpecialChars | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Add forbidden patterns check | |
| 67 | + | if (constraints.forbiddenPatterns.length > 0) { | |
| 68 | + | return schema.refine( | |
| 69 | + | (password) => { | |
| 70 | + | const lowerPassword = password.toLowerCase(); | |
| 71 | + | return !constraints.forbiddenPatterns.some((pattern) => | |
| 72 | + | lowerPassword.includes(pattern.toLowerCase()) | |
| 73 | + | ); | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | message: PASSWORD_ERROR_MESSAGES.common | |
| 77 | + | } | |
| 78 | + | ); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return schema; | |
| 76 | 82 | } | |
| 77 | 83 | ||
| 78 | 84 | // Login validation schema | |
| 79 | 85 | export const loginSchema = z.object({ | |
| 80 | - | username: z.string() | |
| 81 | - | .min(3, 'Username must be at least 3 characters') | |
| 82 | - | .max(32, 'Username must be at most 32 characters') | |
| 83 | - | .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'), | |
| 84 | - | password: z.string().min(1, 'Password is required') | |
| 86 | + | username: z | |
| 87 | + | .string() | |
| 88 | + | .min(3, 'Username must be at least 3 characters') | |
| 89 | + | .max(32, 'Username must be at most 32 characters') | |
| 90 | + | .regex( | |
| 91 | + | /^[a-zA-Z0-9_-]+$/, | |
| 92 | + | 'Username can only contain letters, numbers, underscores, and dashes' | |
| 93 | + | ), | |
| 94 | + | password: z.string().min(1, 'Password is required') | |
| 85 | 95 | }); | |
| 86 | 96 | ||
| 87 | 97 | // Registration validation schema (using dynamic password validation) | |
| 88 | - | export function createRegistrationSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | |
| 89 | - | return z.object({ | |
| 90 | - | suUsername: z.string() | |
| 91 | - | .min(3, 'Username must be at least 3 characters') | |
| 92 | - | .max(32, 'Username must be at most 32 characters') | |
| 93 | - | .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'), | |
| 94 | - | suPassword: createPasswordSchema(constraints), | |
| 95 | - | suConfirmPassword: z.string(), | |
| 96 | - | captchaAnswer: z.string().min(1, 'Please answer the PDR question'), | |
| 97 | - | turnstileToken: z.string().optional() | |
| 98 | - | }).refine( | |
| 99 | - | (data) => data.suPassword === data.suConfirmPassword, | |
| 100 | - | { | |
| 101 | - | message: 'Passwords do not match', | |
| 102 | - | path: ['suConfirmPassword'] | |
| 103 | - | } | |
| 104 | - | ); | |
| 98 | + | export function createRegistrationSchema( | |
| 99 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS | |
| 100 | + | ) { | |
| 101 | + | return z | |
| 102 | + | .object({ | |
| 103 | + | suUsername: z | |
| 104 | + | .string() | |
| 105 | + | .min(3, 'Username must be at least 3 characters') | |
| 106 | + | .max(32, 'Username must be at most 32 characters') | |
| 107 | + | .regex( | |
| 108 | + | /^[a-zA-Z0-9_-]+$/, | |
| 109 | + | 'Username can only contain letters, numbers, underscores, and dashes' | |
| 110 | + | ), | |
| 111 | + | suPassword: createPasswordSchema(constraints), | |
| 112 | + | suConfirmPassword: z.string(), | |
| 113 | + | captchaAnswer: z.string().min(1, 'Please answer the PDR question'), | |
| 114 | + | turnstileToken: z.string().optional() | |
| 115 | + | }) | |
| 116 | + | .refine((data) => data.suPassword === data.suConfirmPassword, { | |
| 117 | + | message: 'Passwords do not match', | |
| 118 | + | path: ['suConfirmPassword'] | |
| 119 | + | }); | |
| 105 | 120 | } | |
| 106 | 121 | ||
| 107 | 122 | // Utility function to validate password strength and return detailed feedback | |
| 108 | - | export function validatePasswordStrength(password: string, constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | |
| 109 | - | const errors: string[] = []; | |
| 110 | - | const requirements: { met: boolean; message: string }[] = []; | |
| 111 | - | ||
| 112 | - | // Length checks | |
| 113 | - | if (password.length < constraints.minLength) { | |
| 114 | - | errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)); | |
| 115 | - | } | |
| 116 | - | requirements.push({ | |
| 117 | - | met: password.length >= constraints.minLength, | |
| 118 | - | message: `At least ${constraints.minLength} characters` | |
| 119 | - | }); | |
| 120 | - | ||
| 121 | - | if (password.length > constraints.maxLength) { | |
| 122 | - | errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Character type checks | |
| 126 | - | if (constraints.requireUppercase) { | |
| 127 | - | const hasUppercase = /[A-Z]/.test(password); | |
| 128 | - | if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase); | |
| 129 | - | requirements.push({ | |
| 130 | - | met: hasUppercase, | |
| 131 | - | message: 'At least one uppercase letter' | |
| 132 | - | }); | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | if (constraints.requireLowercase) { | |
| 136 | - | const hasLowercase = /[a-z]/.test(password); | |
| 137 | - | if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase); | |
| 138 | - | requirements.push({ | |
| 139 | - | met: hasLowercase, | |
| 140 | - | message: 'At least one lowercase letter' | |
| 141 | - | }); | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | if (constraints.requireNumbers) { | |
| 145 | - | const hasNumbers = /\d/.test(password); | |
| 146 | - | if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers); | |
| 147 | - | requirements.push({ | |
| 148 | - | met: hasNumbers, | |
| 149 | - | message: 'At least one number' | |
| 150 | - | }); | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | if (constraints.requireSpecialChars) { | |
| 154 | - | const hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); | |
| 155 | - | if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars); | |
| 156 | - | requirements.push({ | |
| 157 | - | met: hasSpecialChars, | |
| 158 | - | message: 'At least one special character' | |
| 159 | - | }); | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // Forbidden patterns check | |
| 163 | - | if (constraints.forbiddenPatterns.length > 0) { | |
| 164 | - | const lowerPassword = password.toLowerCase(); | |
| 165 | - | const hasForbiddenPattern = constraints.forbiddenPatterns.some(pattern => | |
| 166 | - | lowerPassword.includes(pattern.toLowerCase()) | |
| 167 | - | ); | |
| 168 | - | if (hasForbiddenPattern) { | |
| 169 | - | errors.push(PASSWORD_ERROR_MESSAGES.common); | |
| 170 | - | } | |
| 171 | - | requirements.push({ | |
| 172 | - | met: !hasForbiddenPattern, | |
| 173 | - | message: 'Not a common or weak password' | |
| 174 | - | }); | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | return { | |
| 178 | - | isValid: errors.length === 0, | |
| 179 | - | errors, | |
| 180 | - | requirements, | |
| 181 | - | strength: calculatePasswordStrength(password, requirements) | |
| 182 | - | }; | |
| 123 | + | export function validatePasswordStrength( | |
| 124 | + | password: string, | |
| 125 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS | |
| 126 | + | ) { | |
| 127 | + | const errors: string[] = []; | |
| 128 | + | const requirements: { met: boolean; message: string }[] = []; | |
| 129 | + | ||
| 130 | + | // Length checks | |
| 131 | + | if (password.length < constraints.minLength) { | |
| 132 | + | errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)); | |
| 133 | + | } | |
| 134 | + | requirements.push({ | |
| 135 | + | met: password.length >= constraints.minLength, | |
| 136 | + | message: `At least ${constraints.minLength} characters` | |
| 137 | + | }); | |
| 138 | + | ||
| 139 | + | if (password.length > constraints.maxLength) { | |
| 140 | + | errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Character type checks | |
| 144 | + | if (constraints.requireUppercase) { | |
| 145 | + | const hasUppercase = /[A-Z]/.test(password); | |
| 146 | + | if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase); | |
| 147 | + | requirements.push({ | |
| 148 | + | met: hasUppercase, | |
| 149 | + | message: 'At least one uppercase letter' | |
| 150 | + | }); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | if (constraints.requireLowercase) { | |
| 154 | + | const hasLowercase = /[a-z]/.test(password); | |
| 155 | + | if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase); | |
| 156 | + | requirements.push({ | |
| 157 | + | met: hasLowercase, | |
| 158 | + | message: 'At least one lowercase letter' | |
| 159 | + | }); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | if (constraints.requireNumbers) { | |
| 163 | + | const hasNumbers = /\d/.test(password); | |
| 164 | + | if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers); | |
| 165 | + | requirements.push({ | |
| 166 | + | met: hasNumbers, | |
| 167 | + | message: 'At least one number' | |
| 168 | + | }); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if (constraints.requireSpecialChars) { | |
| 172 | + | const hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); | |
| 173 | + | if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars); | |
| 174 | + | requirements.push({ | |
| 175 | + | met: hasSpecialChars, | |
| 176 | + | message: 'At least one special character' | |
| 177 | + | }); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Forbidden patterns check | |
| 181 | + | if (constraints.forbiddenPatterns.length > 0) { | |
| 182 | + | const lowerPassword = password.toLowerCase(); | |
| 183 | + | const hasForbiddenPattern = constraints.forbiddenPatterns.some((pattern) => | |
| 184 | + | lowerPassword.includes(pattern.toLowerCase()) | |
| 185 | + | ); | |
| 186 | + | if (hasForbiddenPattern) { | |
| 187 | + | errors.push(PASSWORD_ERROR_MESSAGES.common); | |
| 188 | + | } | |
| 189 | + | requirements.push({ | |
| 190 | + | met: !hasForbiddenPattern, | |
| 191 | + | message: 'Not a common or weak password' | |
| 192 | + | }); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | return { | |
| 196 | + | isValid: errors.length === 0, | |
| 197 | + | errors, | |
| 198 | + | requirements, | |
| 199 | + | strength: calculatePasswordStrength(password, requirements) | |
| 200 | + | }; | |
| 183 | 201 | } | |
| 184 | 202 | ||
| 185 | 203 | // Calculate password strength score (0-100) | |
| 186 | - | function calculatePasswordStrength(password: string, requirements: { met: boolean; message: string }[]): number { | |
| 187 | - | const metRequirements = requirements.filter(req => req.met).length; | |
| 188 | - | const totalRequirements = requirements.length; | |
| 189 | - | ||
| 190 | - | if (totalRequirements === 0) return 0; | |
| 191 | - | ||
| 192 | - | let baseScore = (metRequirements / totalRequirements) * 80; | |
| 193 | - | ||
| 194 | - | // Bonus points for length beyond minimum | |
| 195 | - | if (password.length > 12) baseScore += 10; | |
| 196 | - | if (password.length > 16) baseScore += 10; | |
| 197 | - | ||
| 198 | - | return Math.min(100, Math.round(baseScore)); | |
| 199 | - | }⏎̸ | |
| 204 | + | function calculatePasswordStrength( | |
| 205 | + | password: string, | |
| 206 | + | requirements: { met: boolean; message: string }[] | |
| 207 | + | ): number { | |
| 208 | + | const metRequirements = requirements.filter((req) => req.met).length; | |
| 209 | + | const totalRequirements = requirements.length; | |
| 210 | + | ||
| 211 | + | if (totalRequirements === 0) return 0; | |
| 212 | + | ||
| 213 | + | let baseScore = (metRequirements / totalRequirements) * 80; | |
| 214 | + | ||
| 215 | + | // Bonus points for length beyond minimum | |
| 216 | + | if (password.length > 12) baseScore += 10; | |
| 217 | + | if (password.length > 16) baseScore += 10; | |
| 218 | + | ||
| 219 | + | return Math.min(100, Math.round(baseScore)); | |
| 220 | + | } | |
M src/lib/validation/text-formatting.ts +68 -68
| @@ -7,86 +7,86 @@ const MAX_GRADIENT_COLORS = 3; | |||
| 7 | 7 | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | |
| 8 | 8 | ||
| 9 | 9 | export function validateHexColor(color: string): boolean { | |
| 10 | - | return HEX_COLOR_REGEX.test(color); | |
| 10 | + | return HEX_COLOR_REGEX.test(color); | |
| 11 | 11 | } | |
| 12 | 12 | ||
| 13 | 13 | export function validateFontFamily(fontFamily: string): boolean { | |
| 14 | - | return fontFamily in RETRO_FONTS; | |
| 14 | + | return fontFamily in RETRO_FONTS; | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | export function validateFontSize(size: number): number { | |
| 18 | - | return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); | |
| 18 | + | return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | export function validateGradient(gradient: unknown): string[] | undefined { | |
| 22 | - | if (!Array.isArray(gradient)) return undefined; | |
| 23 | - | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | |
| 24 | - | ||
| 25 | - | const validColors = gradient.filter(color => | |
| 26 | - | typeof color === 'string' && validateHexColor(color) | |
| 27 | - | ); | |
| 28 | - | ||
| 29 | - | return validColors.length === gradient.length ? validColors : undefined; | |
| 22 | + | if (!Array.isArray(gradient)) return undefined; | |
| 23 | + | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | |
| 24 | + | ||
| 25 | + | const validColors = gradient.filter( | |
| 26 | + | (color) => typeof color === 'string' && validateHexColor(color) | |
| 27 | + | ); | |
| 28 | + | ||
| 29 | + | return validColors.length === gradient.length ? validColors : undefined; | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | export function validateTextStyle(style: unknown): TextStyle | null { | |
| 33 | - | if (!style || typeof style !== 'object') return null; | |
| 34 | - | ||
| 35 | - | const rawStyle = style as Record<string, unknown>; | |
| 36 | - | ||
| 37 | - | const validated: TextStyle = { | |
| 38 | - | fontFamily: 'pixelated', | |
| 39 | - | fontSize: 14, | |
| 40 | - | bold: false, | |
| 41 | - | italic: false, | |
| 42 | - | underline: false, | |
| 43 | - | strikethrough: false | |
| 44 | - | }; | |
| 45 | - | ||
| 46 | - | // Validate font family | |
| 47 | - | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | |
| 48 | - | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // Validate font size | |
| 52 | - | if (typeof rawStyle.fontSize === 'number') { | |
| 53 | - | validated.fontSize = validateFontSize(rawStyle.fontSize); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Validate color | |
| 57 | - | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | |
| 58 | - | validated.color = rawStyle.color; | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Validate gradient | |
| 62 | - | const validatedGradient = validateGradient(rawStyle.gradient); | |
| 63 | - | if (validatedGradient) { | |
| 64 | - | validated.gradient = validatedGradient; | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // Validate boolean properties | |
| 68 | - | validated.bold = Boolean(rawStyle.bold); | |
| 69 | - | validated.italic = Boolean(rawStyle.italic); | |
| 70 | - | validated.underline = Boolean(rawStyle.underline); | |
| 71 | - | validated.strikethrough = Boolean(rawStyle.strikethrough); | |
| 72 | - | ||
| 73 | - | return validated; | |
| 33 | + | if (!style || typeof style !== 'object') return null; | |
| 34 | + | ||
| 35 | + | const rawStyle = style as Record<string, unknown>; | |
| 36 | + | ||
| 37 | + | const validated: TextStyle = { | |
| 38 | + | fontFamily: 'pixelated', | |
| 39 | + | fontSize: 14, | |
| 40 | + | bold: false, | |
| 41 | + | italic: false, | |
| 42 | + | underline: false, | |
| 43 | + | strikethrough: false | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | // Validate font family | |
| 47 | + | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | |
| 48 | + | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Validate font size | |
| 52 | + | if (typeof rawStyle.fontSize === 'number') { | |
| 53 | + | validated.fontSize = validateFontSize(rawStyle.fontSize); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Validate color | |
| 57 | + | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | |
| 58 | + | validated.color = rawStyle.color; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Validate gradient | |
| 62 | + | const validatedGradient = validateGradient(rawStyle.gradient); | |
| 63 | + | if (validatedGradient) { | |
| 64 | + | validated.gradient = validatedGradient; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Validate boolean properties | |
| 68 | + | validated.bold = Boolean(rawStyle.bold); | |
| 69 | + | validated.italic = Boolean(rawStyle.italic); | |
| 70 | + | validated.underline = Boolean(rawStyle.underline); | |
| 71 | + | validated.strikethrough = Boolean(rawStyle.strikethrough); | |
| 72 | + | ||
| 73 | + | return validated; | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | |
| 77 | - | if (!styleData) return undefined; | |
| 78 | - | ||
| 79 | - | // Handle JSON string input | |
| 80 | - | let parsedData: unknown = styleData; | |
| 81 | - | if (typeof styleData === 'string') { | |
| 82 | - | try { | |
| 83 | - | parsedData = JSON.parse(styleData); | |
| 84 | - | } catch (error) { | |
| 85 | - | console.warn('Failed to parse styleData JSON:', error); | |
| 86 | - | return undefined; | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | const validated = validateTextStyle(parsedData); | |
| 91 | - | return validated || undefined; | |
| 92 | - | }⏎̸ | |
| 77 | + | if (!styleData) return undefined; | |
| 78 | + | ||
| 79 | + | // Handle JSON string input | |
| 80 | + | let parsedData: unknown = styleData; | |
| 81 | + | if (typeof styleData === 'string') { | |
| 82 | + | try { | |
| 83 | + | parsedData = JSON.parse(styleData); | |
| 84 | + | } catch (error) { | |
| 85 | + | console.warn('Failed to parse styleData JSON:', error); | |
| 86 | + | return undefined; | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | const validated = validateTextStyle(parsedData); | |
| 91 | + | return validated || undefined; | |
| 92 | + | } | |
M src/routes/+layout.server.ts +6 -8
| @@ -1,10 +1,8 @@ | |||
| 1 | 1 | import type { LayoutServerLoad } from './$types'; | |
| 2 | 2 | ||
| 3 | - | export const load: LayoutServerLoad = async ({ locals }) => { | |
| 4 | - | ||
| 5 | - | ||
| 6 | - | return { | |
| 7 | - | user: locals.user, | |
| 8 | - | session: locals.session | |
| 9 | - | }; | |
| 10 | - | }; ⏎̸ | |
| 3 | + | export const load: LayoutServerLoad = async ({ locals }) => { | |
| 4 | + | return { | |
| 5 | + | user: locals.user, | |
| 6 | + | session: locals.session | |
| 7 | + | }; | |
| 8 | + | }; | |
M src/routes/+layout.svelte +41 -37
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import '../app.css';; | |
| 2 | + | import '../app.css'; | |
| 3 | 3 | import { chatState } from '$lib/states/chat.svelte'; | |
| 4 | 4 | import { onMount, onDestroy } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| @@ -24,12 +24,12 @@ | |||
| 24 | 24 | ||
| 25 | 25 | async function updateUserStatus(status: UserStatus) { | |
| 26 | 26 | if (!data.user?.id || updateInProgress) return; | |
| 27 | - | ||
| 27 | + | ||
| 28 | 28 | const now = Date.now(); | |
| 29 | 29 | const timeSinceLastUpdate = now - lastStatusSentAt; | |
| 30 | - | ||
| 30 | + | ||
| 31 | 31 | if ( | |
| 32 | - | status === lastSentStatus && | |
| 32 | + | status === lastSentStatus && | |
| 33 | 33 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | |
| 34 | 34 | timeSinceLastUpdate < THROTTLE_TIME_MS | |
| 35 | 35 | ) { | |
| @@ -57,18 +57,18 @@ | |||
| 57 | 57 | try { | |
| 58 | 58 | if (status === 'online' && !document.cookie.includes('session=')) { | |
| 59 | 59 | console.debug('Waiting for session cookie before status update...'); | |
| 60 | - | await new Promise(resolve => setTimeout(resolve, 500)); | |
| 60 | + | await new Promise((resolve) => setTimeout(resolve, 500)); | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 63 | const response = await fetch('/api/status', { | |
| 64 | 64 | method: 'POST', | |
| 65 | 65 | headers: { 'Content-Type': 'application/json' }, | |
| 66 | - | body: JSON.stringify({ | |
| 66 | + | body: JSON.stringify({ | |
| 67 | 67 | status, | |
| 68 | 68 | lastSeen: timestamp | |
| 69 | 69 | }) | |
| 70 | 70 | }); | |
| 71 | - | ||
| 71 | + | ||
| 72 | 72 | if (!response.ok) { | |
| 73 | 73 | console.error('Failed to update user status:', await response.json()); | |
| 74 | 74 | } else { | |
| @@ -91,13 +91,13 @@ | |||
| 91 | 91 | if (idleTimeout) { | |
| 92 | 92 | window.clearTimeout(idleTimeout); | |
| 93 | 93 | } | |
| 94 | - | ||
| 94 | + | ||
| 95 | 95 | lastUserActivity = Date.now(); | |
| 96 | - | ||
| 96 | + | ||
| 97 | 97 | if (lastSentStatus === 'idle') { | |
| 98 | 98 | updateUserStatus('online'); | |
| 99 | 99 | } | |
| 100 | - | ||
| 100 | + | ||
| 101 | 101 | idleTimeout = window.setTimeout(() => { | |
| 102 | 102 | if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) { | |
| 103 | 103 | updateUserStatus('idle'); | |
| @@ -111,7 +111,7 @@ | |||
| 111 | 111 | ||
| 112 | 112 | function startStatusTicker() { | |
| 113 | 113 | if (statusInterval) return; | |
| 114 | - | ||
| 114 | + | ||
| 115 | 115 | console.debug('Starting status ticker'); | |
| 116 | 116 | const tickHandler = () => { | |
| 117 | 117 | if (isVisible && lastSentStatus !== 'idle') { | |
| @@ -132,7 +132,7 @@ | |||
| 132 | 132 | function handleVisibilityChange() { | |
| 133 | 133 | if (!browser) return; | |
| 134 | 134 | isVisible = document.visibilityState === 'visible'; | |
| 135 | - | ||
| 135 | + | ||
| 136 | 136 | if (isVisible) { | |
| 137 | 137 | resetIdleTimer(); | |
| 138 | 138 | updateUserStatus('online'); | |
| @@ -162,12 +162,12 @@ | |||
| 162 | 162 | ||
| 163 | 163 | // Set initial online status | |
| 164 | 164 | updateUserStatus('online'); | |
| 165 | - | ||
| 165 | + | ||
| 166 | 166 | // Start monitoring for user activity | |
| 167 | 167 | window.addEventListener('mousemove', handleUserActivity); | |
| 168 | 168 | window.addEventListener('keydown', handleUserActivity); | |
| 169 | 169 | window.addEventListener('click', handleUserActivity); | |
| 170 | - | ||
| 170 | + | ||
| 171 | 171 | // Initialize idle timer | |
| 172 | 172 | resetIdleTimer(); | |
| 173 | 173 | ||
| @@ -176,7 +176,7 @@ | |||
| 176 | 176 | ||
| 177 | 177 | // Add visibility change listener | |
| 178 | 178 | document.addEventListener('visibilitychange', handleVisibilityChange); | |
| 179 | - | ||
| 179 | + | ||
| 180 | 180 | // Add beforeunload listener for cleanup | |
| 181 | 181 | window.addEventListener('beforeunload', cleanup); | |
| 182 | 182 | ||
| @@ -189,32 +189,36 @@ | |||
| 189 | 189 | ||
| 190 | 190 | $effect(() => { | |
| 191 | 191 | if (!browser) return; | |
| 192 | - | ||
| 192 | + | ||
| 193 | 193 | const currentUserJson = data.user ? JSON.stringify(data.user) : null; | |
| 194 | 194 | if (currentUserJson === lastUserUpdate) { | |
| 195 | 195 | console.debug('Skipping duplicate user update'); | |
| 196 | 196 | return; | |
| 197 | 197 | } | |
| 198 | - | ||
| 198 | + | ||
| 199 | 199 | lastUserUpdate = currentUserJson; | |
| 200 | - | ||
| 200 | + | ||
| 201 | 201 | console.debug('Layout reactive update - Session state:', { | |
| 202 | 202 | timestamp: new Date().toISOString(), | |
| 203 | 203 | hasUser: !!data.user, | |
| 204 | - | userDetails: data.user ? { | |
| 205 | - | nickname: data.user.nickname, | |
| 206 | - | status: data.user.status, | |
| 207 | - | id: data.user.id | |
| 208 | - | } : null, | |
| 204 | + | userDetails: data.user | |
| 205 | + | ? { | |
| 206 | + | nickname: data.user.nickname, | |
| 207 | + | status: data.user.status, | |
| 208 | + | id: data.user.id | |
| 209 | + | } | |
| 210 | + | : null, | |
| 209 | 211 | hasSession: !!data.session, | |
| 210 | - | sessionDetails: data.session ? { | |
| 211 | - | id: data.session.id, | |
| 212 | - | expiresAt: new Date(data.session.expiresAt) | |
| 213 | - | } : null, | |
| 212 | + | sessionDetails: data.session | |
| 213 | + | ? { | |
| 214 | + | id: data.session.id, | |
| 215 | + | expiresAt: new Date(data.session.expiresAt) | |
| 216 | + | } | |
| 217 | + | : null, | |
| 214 | 218 | cookiePresent: document.cookie.includes('session='), | |
| 215 | 219 | isVisible | |
| 216 | 220 | }); | |
| 217 | - | ||
| 221 | + | ||
| 218 | 222 | if (data.session?.id) { | |
| 219 | 223 | console.debug('Updating global chatState with data.user:', data.user); | |
| 220 | 224 | chatState.setCurrentUser(data.user); | |
| @@ -227,7 +231,7 @@ | |||
| 227 | 231 | <meta name="description" content={data.meta.description} /> | |
| 228 | 232 | <meta name="keywords" content={data.meta.keywords} /> | |
| 229 | 233 | <meta name="language" content="fr" /> | |
| 230 | - | ||
| 234 | + | ||
| 231 | 235 | <!-- Open Graph / Facebook --> | |
| 232 | 236 | <meta property="og:locale" content={data.meta.locale} /> | |
| 233 | 237 | <meta property="og:type" content={data.meta.type} /> | |
| @@ -235,14 +239,14 @@ | |||
| 235 | 239 | <meta property="og:title" content={data.meta.title} /> | |
| 236 | 240 | <meta property="og:description" content={data.meta.description} /> | |
| 237 | 241 | <meta property="og:image" content={data.meta.ogImage} /> | |
| 238 | - | ||
| 242 | + | ||
| 239 | 243 | <!-- Twitter --> | |
| 240 | 244 | <meta property="twitter:card" content="summary_large_image" /> | |
| 241 | 245 | <meta property="twitter:url" content={data.meta.url} /> | |
| 242 | 246 | <meta property="twitter:title" content={data.meta.title} /> | |
| 243 | 247 | <meta property="twitter:description" content={data.meta.description} /> | |
| 244 | 248 | <meta property="twitter:image" content={data.meta.ogImage} /> | |
| 245 | - | ||
| 249 | + | ||
| 246 | 250 | <!-- Additional Meta Tags --> | |
| 247 | 251 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 248 | 252 | <meta name="theme-color" content="#2195f2" /> | |
| @@ -266,11 +270,11 @@ | |||
| 266 | 270 | overflow: hidden; | |
| 267 | 271 | background: linear-gradient( | |
| 268 | 272 | to bottom, | |
| 269 | - | #2195f2 0%, /* Lighter blue at the top */ | |
| 270 | - | #4aa6e2 25%, /* Mid-light blue */ | |
| 271 | - | #7ab8e0 50%, /* Mid blue */ | |
| 272 | - | #86c55c 75%, /* Blue-green transition */ | |
| 273 | - | #90d047 100% /* Bright green at the bottom */ | |
| 273 | + | #2195f2 0%, | |
| 274 | + | /* Lighter blue at the top */ #4aa6e2 25%, | |
| 275 | + | /* Mid-light blue */ #7ab8e0 50%, | |
| 276 | + | /* Mid blue */ #86c55c 75%, | |
| 277 | + | /* Blue-green transition */ #90d047 100% /* Bright green at the bottom */ | |
| 274 | 278 | ); | |
| 275 | 279 | } | |
| 276 | - | </style>⏎̸ | |
| 280 | + | </style> | |
M src/routes/+layout.ts +38 -32
| @@ -1,36 +1,42 @@ | |||
| 1 | 1 | import type { LayoutLoad } from './$types'; | |
| 2 | 2 | ||
| 3 | 3 | export const load: LayoutLoad = async ({ data, depends }) => { | |
| 4 | - | // Register client-side dependencies for invalidation | |
| 5 | - | depends('app:session'); | |
| 6 | - | depends('app:chat'); | |
| 7 | - | // Debug log for session data with more details | |
| 8 | - | console.debug('Layout load - Client session data:', { | |
| 9 | - | hasUser: !!data.user, | |
| 10 | - | userDetails: data.user ? { | |
| 11 | - | nickname: data.user.nickname, | |
| 12 | - | status: data.user.status, | |
| 13 | - | id: data.user.id | |
| 14 | - | } : null, | |
| 15 | - | hasSession: !!data.session, | |
| 16 | - | sessionDetails: data.session ? { | |
| 17 | - | id: data.session.id, | |
| 18 | - | expiresAt: new Date(data.session.expiresAt) | |
| 19 | - | } : null | |
| 20 | - | }); | |
| 4 | + | // Register client-side dependencies for invalidation | |
| 5 | + | depends('app:session'); | |
| 6 | + | depends('app:chat'); | |
| 7 | + | // Debug log for session data with more details | |
| 8 | + | console.debug('Layout load - Client session data:', { | |
| 9 | + | hasUser: !!data.user, | |
| 10 | + | userDetails: data.user | |
| 11 | + | ? { | |
| 12 | + | nickname: data.user.nickname, | |
| 13 | + | status: data.user.status, | |
| 14 | + | id: data.user.id | |
| 15 | + | } | |
| 16 | + | : null, | |
| 17 | + | hasSession: !!data.session, | |
| 18 | + | sessionDetails: data.session | |
| 19 | + | ? { | |
| 20 | + | id: data.session.id, | |
| 21 | + | expiresAt: new Date(data.session.expiresAt) | |
| 22 | + | } | |
| 23 | + | : null | |
| 24 | + | }); | |
| 21 | 25 | ||
| 22 | - | // Return the data from the server load function | |
| 23 | - | return { | |
| 24 | - | user: data.user, | |
| 25 | - | session: data.session, | |
| 26 | - | meta: { | |
| 27 | - | title: 'PDR AIM - Sandbox', | |
| 28 | - | description: 'Un projet sandbox non lucratif qui recrée l\'ambiance des salons de discussion de l\'époque AOL/AIM. Une expérience nostalgique dans un environnement Windows XP.', | |
| 29 | - | keywords: 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro', | |
| 30 | - | ogImage: '/og-image.jpg', | |
| 31 | - | type: 'website', | |
| 32 | - | url: 'https://pdraim.org', | |
| 33 | - | locale: 'fr_FR' | |
| 34 | - | } | |
| 35 | - | }; | |
| 36 | - | }; ⏎̸ | |
| 26 | + | // Return the data from the server load function | |
| 27 | + | return { | |
| 28 | + | user: data.user, | |
| 29 | + | session: data.session, | |
| 30 | + | meta: { | |
| 31 | + | title: 'PDR AIM - Sandbox', | |
| 32 | + | description: | |
| 33 | + | "Un projet sandbox non lucratif qui recrée l'ambiance des salons de discussion de l'époque AOL/AIM. Une expérience nostalgique dans un environnement Windows XP.", | |
| 34 | + | keywords: | |
| 35 | + | 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro', | |
| 36 | + | ogImage: '/og-image.jpg', | |
| 37 | + | type: 'website', | |
| 38 | + | url: 'https://pdraim.org', | |
| 39 | + | locale: 'fr_FR' | |
| 40 | + | } | |
| 41 | + | }; | |
| 42 | + | }; | |
M src/routes/+page.server.ts +55 -55
| @@ -6,58 +6,58 @@ import { eq, desc } from 'drizzle-orm'; | |||
| 6 | 6 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |
| 7 | 7 | ||
| 8 | 8 | export const load: PageServerLoad = async ({ cookies }) => { | |
| 9 | - | const token = cookies.get('session'); | |
| 10 | - | ||
| 11 | - | if (!token) { | |
| 12 | - | return { | |
| 13 | - | user: null, | |
| 14 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 15 | - | }; | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | try { | |
| 19 | - | const { user } = await validateSessionToken(token); | |
| 20 | - | ||
| 21 | - | if (!user) { | |
| 22 | - | return { | |
| 23 | - | user: null, | |
| 24 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 25 | - | }; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // Get user's last used text styling from their most recent message | |
| 29 | - | const lastMessage = await db.query.messages.findFirst({ | |
| 30 | - | where: eq(messages.senderId, user.id), | |
| 31 | - | orderBy: [desc(messages.timestamp)], | |
| 32 | - | columns: { | |
| 33 | - | styleData: true | |
| 34 | - | } | |
| 35 | - | }); | |
| 36 | - | ||
| 37 | - | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |
| 38 | - | ||
| 39 | - | if (lastMessage?.styleData) { | |
| 40 | - | try { | |
| 41 | - | const parsedStyle = JSON.parse(lastMessage.styleData); | |
| 42 | - | // Merge with default style to ensure all required properties | |
| 43 | - | lastTextStyle = { | |
| 44 | - | ...DEFAULT_TEXT_STYLE, | |
| 45 | - | ...parsedStyle | |
| 46 | - | }; | |
| 47 | - | } catch (error) { | |
| 48 | - | console.warn('Failed to parse last text style:', error); | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | return { | |
| 53 | - | user, | |
| 54 | - | lastTextStyle | |
| 55 | - | }; | |
| 56 | - | } catch (error) { | |
| 57 | - | console.error('Error loading page data:', error); | |
| 58 | - | return { | |
| 59 | - | user: null, | |
| 60 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 61 | - | }; | |
| 62 | - | } | |
| 63 | - | };⏎̸ | |
| 9 | + | const token = cookies.get('session'); | |
| 10 | + | ||
| 11 | + | if (!token) { | |
| 12 | + | return { | |
| 13 | + | user: null, | |
| 14 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 15 | + | }; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | try { | |
| 19 | + | const { user } = await validateSessionToken(token); | |
| 20 | + | ||
| 21 | + | if (!user) { | |
| 22 | + | return { | |
| 23 | + | user: null, | |
| 24 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 25 | + | }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Get user's last used text styling from their most recent message | |
| 29 | + | const lastMessage = await db.query.messages.findFirst({ | |
| 30 | + | where: eq(messages.senderId, user.id), | |
| 31 | + | orderBy: [desc(messages.timestamp)], | |
| 32 | + | columns: { | |
| 33 | + | styleData: true | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |
| 38 | + | ||
| 39 | + | if (lastMessage?.styleData) { | |
| 40 | + | try { | |
| 41 | + | const parsedStyle = JSON.parse(lastMessage.styleData); | |
| 42 | + | // Merge with default style to ensure all required properties | |
| 43 | + | lastTextStyle = { | |
| 44 | + | ...DEFAULT_TEXT_STYLE, | |
| 45 | + | ...parsedStyle | |
| 46 | + | }; | |
| 47 | + | } catch (error) { | |
| 48 | + | console.warn('Failed to parse last text style:', error); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | return { | |
| 53 | + | user, | |
| 54 | + | lastTextStyle | |
| 55 | + | }; | |
| 56 | + | } catch (error) { | |
| 57 | + | console.error('Error loading page data:', error); | |
| 58 | + | return { | |
| 59 | + | user: null, | |
| 60 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 61 | + | }; | |
| 62 | + | } | |
| 63 | + | }; | |
M src/routes/+page.svelte +25 -29
| @@ -1,42 +1,38 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 3 | - | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | - | import AimLogin from '$lib/components/aim-login.svelte'; | |
| 5 | - | import type { PageData } from './$types'; | |
| 2 | + | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 3 | + | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | + | import AimLogin from '$lib/components/aim-login.svelte'; | |
| 5 | + | import type { PageData } from './$types'; | |
| 6 | 6 | ||
| 7 | - | let { data }: { data: PageData } = $props(); | |
| 8 | - | ||
| 9 | - | let showChatRoom = $state(true); | |
| 10 | - | let showAuth = $state(false); | |
| 7 | + | let { data }: { data: PageData } = $props(); | |
| 11 | 8 | ||
| 12 | - | function handleLoginSuccess() { | |
| 13 | - | showAuth = false; | |
| 14 | - | } | |
| 9 | + | let showChatRoom = $state(true); | |
| 10 | + | let showAuth = $state(false); | |
| 11 | + | ||
| 12 | + | function handleLoginSuccess() { | |
| 13 | + | showAuth = false; | |
| 14 | + | } | |
| 15 | 15 | </script> | |
| 16 | - | ||
| 16 | + | ||
| 17 | 17 | <div class="desktop content-wrapper"> | |
| 18 | - | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 19 | - | ||
| 18 | + | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 19 | + | ||
| 20 | 20 | {#if showChatRoom} | |
| 21 | 21 | <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} /> | |
| 22 | 22 | {/if} | |
| 23 | 23 | ||
| 24 | 24 | {#if showAuth} | |
| 25 | - | <AimLogin | |
| 26 | - | bind:showAuth | |
| 27 | - | onLoginSuccess={handleLoginSuccess} | |
| 28 | - | /> | |
| 25 | + | <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} /> | |
| 29 | 26 | {/if} | |
| 30 | 27 | </div> | |
| 31 | - | ||
| 32 | - | <style> | |
| 33 | - | .content-wrapper { | |
| 34 | - | position: absolute; | |
| 35 | - | inset: 0; | |
| 36 | - | display: flex; | |
| 37 | - | align-items: center; | |
| 38 | - | justify-content: center; | |
| 39 | - | box-sizing: border-box; | |
| 40 | - | } | |
| 41 | 28 | ||
| 42 | - | </style>⏎̸ | |
| 29 | + | <style> | |
| 30 | + | .content-wrapper { | |
| 31 | + | position: absolute; | |
| 32 | + | inset: 0; | |
| 33 | + | display: flex; | |
| 34 | + | align-items: center; | |
| 35 | + | justify-content: center; | |
| 36 | + | box-sizing: border-box; | |
| 37 | + | } | |
| 38 | + | </style> | |
M src/routes/api/chat/messages/+server.ts +224 -214
| @@ -2,10 +2,10 @@ import { v4 as uuidv4 } from 'uuid'; | |||
| 2 | 2 | import db from '$lib/db/db.server'; | |
| 3 | 3 | import { messages } from '$lib/db/schema'; | |
| 4 | 4 | import type { Message } from '$lib/types/chat'; | |
| 5 | - | import type { | |
| 6 | - | SendMessageRequest, | |
| 7 | - | SendMessageResponse, | |
| 8 | - | GetMessagesResponse | |
| 5 | + | import type { | |
| 6 | + | SendMessageRequest, | |
| 7 | + | SendMessageResponse, | |
| 8 | + | GetMessagesResponse | |
| 9 | 9 | } from '$lib/types/payloads'; | |
| 10 | 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | |
| 11 | 11 | import type { SQL } from 'drizzle-orm/sql'; | |
| @@ -22,222 +22,232 @@ const log = createLogger('chat-server'); | |||
| 22 | 22 | // Rate limiting configuration | |
| 23 | 23 | const INITIAL_COOLDOWN = 1000; // 1 second | |
| 24 | 24 | const MAX_COOLDOWN = 30000; // 30 seconds | |
| 25 | - | const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>(); | |
| 25 | + | const userMessageTimestamps = new Map< | |
| 26 | + | string, | |
| 27 | + | { lastMessageTime: number; currentCooldown: number } | |
| 28 | + | >(); | |
| 26 | 29 | ||
| 27 | 30 | function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } { | |
| 28 | - | const now = Date.now(); | |
| 29 | - | const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN }; | |
| 30 | - | ||
| 31 | - | // Check if enough time has passed since the last message | |
| 32 | - | const timeSinceLastMessage = now - userState.lastMessageTime; | |
| 33 | - | if (timeSinceLastMessage < userState.currentCooldown) { | |
| 34 | - | return { | |
| 35 | - | canSend: false, | |
| 36 | - | retryAfter: userState.currentCooldown - timeSinceLastMessage | |
| 37 | - | }; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Reset cooldown if it's been long enough | |
| 41 | - | if (timeSinceLastMessage > userState.currentCooldown * 2) { | |
| 42 | - | userState.currentCooldown = INITIAL_COOLDOWN; | |
| 43 | - | } else { | |
| 44 | - | // Exponential backoff | |
| 45 | - | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | userState.lastMessageTime = now; | |
| 49 | - | userMessageTimestamps.set(userId, userState); | |
| 50 | - | ||
| 51 | - | return { canSend: true }; | |
| 31 | + | const now = Date.now(); | |
| 32 | + | const userState = userMessageTimestamps.get(userId) || { | |
| 33 | + | lastMessageTime: 0, | |
| 34 | + | currentCooldown: INITIAL_COOLDOWN | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | // Check if enough time has passed since the last message | |
| 38 | + | const timeSinceLastMessage = now - userState.lastMessageTime; | |
| 39 | + | if (timeSinceLastMessage < userState.currentCooldown) { | |
| 40 | + | return { | |
| 41 | + | canSend: false, | |
| 42 | + | retryAfter: userState.currentCooldown - timeSinceLastMessage | |
| 43 | + | }; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Reset cooldown if it's been long enough | |
| 47 | + | if (timeSinceLastMessage > userState.currentCooldown * 2) { | |
| 48 | + | userState.currentCooldown = INITIAL_COOLDOWN; | |
| 49 | + | } else { | |
| 50 | + | // Exponential backoff | |
| 51 | + | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | userState.lastMessageTime = now; | |
| 55 | + | userMessageTimestamps.set(userId, userState); | |
| 56 | + | ||
| 57 | + | return { canSend: true }; | |
| 52 | 58 | } | |
| 53 | 59 | ||
| 54 | 60 | // GET endpoint: fetch messages directly from DB | |
| 55 | 61 | export async function GET({ request, locals }) { | |
| 56 | - | const url = new URL(request.url); | |
| 57 | - | const beforeTimestamp = url.searchParams.get('before'); | |
| 58 | - | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 59 | - | const isPublic = url.searchParams.get('public') === 'true'; | |
| 60 | - | ||
| 61 | - | try { | |
| 62 | - | log.debug('Fetching messages from database', { | |
| 63 | - | beforeTimestamp, | |
| 64 | - | roomId, | |
| 65 | - | isAuthenticated: !!locals.session, | |
| 66 | - | isPublic | |
| 67 | - | }); | |
| 68 | - | ||
| 69 | - | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | |
| 70 | - | ||
| 71 | - | if (beforeTimestamp) { | |
| 72 | - | conditions = and( | |
| 73 | - | conditions, | |
| 74 | - | lt(messages.timestamp, parseInt(beforeTimestamp)) | |
| 75 | - | ) as SQL<unknown>; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Fetch messages with a limit based on authentication status | |
| 79 | - | // Public requests are limited to 50 messages | |
| 80 | - | // Authenticated users get 100 messages per request | |
| 81 | - | const fetchLimit = (isPublic || !locals.session) ? 50 : 100; | |
| 82 | - | ||
| 83 | - | const fetchedMessages = await db.select() | |
| 84 | - | .from(messages) | |
| 85 | - | .where(conditions) | |
| 86 | - | .orderBy(desc(messages.timestamp)) | |
| 87 | - | .limit(fetchLimit) | |
| 88 | - | .then(rows => rows.map(row => ({ | |
| 89 | - | ...row, | |
| 90 | - | styleData: row.styleData ?? undefined, | |
| 91 | - | hasFormatting: row.hasFormatting ?? false | |
| 92 | - | }))); | |
| 93 | - | ||
| 94 | - | const response: GetMessagesResponse = { | |
| 95 | - | success: true, | |
| 96 | - | messages: fetchedMessages, | |
| 97 | - | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | |
| 98 | - | }; | |
| 99 | - | ||
| 100 | - | return new Response(JSON.stringify(response), { | |
| 101 | - | headers: { | |
| 102 | - | 'Content-Type': 'application/json' | |
| 103 | - | } | |
| 104 | - | }); | |
| 105 | - | } catch (err) { | |
| 106 | - | log.error('Error fetching messages:', { error: err }); | |
| 107 | - | throw error(500, 'Failed to fetch messages'); | |
| 108 | - | } | |
| 62 | + | const url = new URL(request.url); | |
| 63 | + | const beforeTimestamp = url.searchParams.get('before'); | |
| 64 | + | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 65 | + | const isPublic = url.searchParams.get('public') === 'true'; | |
| 66 | + | ||
| 67 | + | try { | |
| 68 | + | log.debug('Fetching messages from database', { | |
| 69 | + | beforeTimestamp, | |
| 70 | + | roomId, | |
| 71 | + | isAuthenticated: !!locals.session, | |
| 72 | + | isPublic | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | |
| 76 | + | ||
| 77 | + | if (beforeTimestamp) { | |
| 78 | + | conditions = and( | |
| 79 | + | conditions, | |
| 80 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) | |
| 81 | + | ) as SQL<unknown>; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Fetch messages with a limit based on authentication status | |
| 85 | + | // Public requests are limited to 50 messages | |
| 86 | + | // Authenticated users get 100 messages per request | |
| 87 | + | const fetchLimit = isPublic || !locals.session ? 50 : 100; | |
| 88 | + | ||
| 89 | + | const fetchedMessages = await db | |
| 90 | + | .select() | |
| 91 | + | .from(messages) | |
| 92 | + | .where(conditions) | |
| 93 | + | .orderBy(desc(messages.timestamp)) | |
| 94 | + | .limit(fetchLimit) | |
| 95 | + | .then((rows) => | |
| 96 | + | rows.map((row) => ({ | |
| 97 | + | ...row, | |
| 98 | + | styleData: row.styleData ?? undefined, | |
| 99 | + | hasFormatting: row.hasFormatting ?? false | |
| 100 | + | })) | |
| 101 | + | ); | |
| 102 | + | ||
| 103 | + | const response: GetMessagesResponse = { | |
| 104 | + | success: true, | |
| 105 | + | messages: fetchedMessages, | |
| 106 | + | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | return new Response(JSON.stringify(response), { | |
| 110 | + | headers: { | |
| 111 | + | 'Content-Type': 'application/json' | |
| 112 | + | } | |
| 113 | + | }); | |
| 114 | + | } catch (err) { | |
| 115 | + | log.error('Error fetching messages:', { error: err }); | |
| 116 | + | throw error(500, 'Failed to fetch messages'); | |
| 117 | + | } | |
| 109 | 118 | } | |
| 110 | 119 | ||
| 111 | 120 | // POST endpoint: receive a new message, save it in the DB, and broadcast via SSE | |
| 112 | - | export async function POST({ request, locals }: { request: Request, locals: App.Locals }) { | |
| 113 | - | if (!locals.user) { | |
| 114 | - | log.warn('Authentication required'); | |
| 115 | - | throw error(401, 'Authentication required'); | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | log.debug('New message received'); | |
| 119 | - | try { | |
| 120 | - | // Check rate limiting | |
| 121 | - | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); | |
| 122 | - | if (!canSend) { | |
| 123 | - | const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`; | |
| 124 | - | log.warn('Rate limited', { userId: maskedUserId, retryAfter }); | |
| 125 | - | const errorResponse: SendMessageResponse = { | |
| 126 | - | success: false, | |
| 127 | - | error: 'Please wait before sending another message', | |
| 128 | - | retryAfter, | |
| 129 | - | isRateLimited: true | |
| 130 | - | }; | |
| 131 | - | return new Response(JSON.stringify(errorResponse), { | |
| 132 | - | status: 429, | |
| 133 | - | headers: { | |
| 134 | - | 'Content-Type': 'application/json', | |
| 135 | - | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() | |
| 136 | - | } | |
| 137 | - | }); | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | const data = await request.json() as SendMessageRequest; | |
| 141 | - | if (!data.content || !data.userId) { | |
| 142 | - | log.warn('Invalid payload received', { hasContent: Boolean(data.content), hasUserId: Boolean(data.userId) }); | |
| 143 | - | const errorResponse: SendMessageResponse = { | |
| 144 | - | success: false, | |
| 145 | - | error: 'Invalid message payload' | |
| 146 | - | }; | |
| 147 | - | return new Response(JSON.stringify(errorResponse), { status: 400 }); | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | if (data.userId !== locals.user.id) { | |
| 151 | - | log.warn('Unauthorized message attempt', { | |
| 152 | - | requestedUserId: data.userId, | |
| 153 | - | actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` | |
| 154 | - | }); | |
| 155 | - | throw error(403, 'Cannot post messages as another user'); | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID; | |
| 159 | - | ||
| 160 | - | // Check chat room existence | |
| 161 | - | const chatRoom = await db.select() | |
| 162 | - | .from(chatRooms) | |
| 163 | - | .where(eq(chatRooms.id, chatRoomId)) | |
| 164 | - | .get(); | |
| 165 | - | ||
| 166 | - | if (!chatRoom) { | |
| 167 | - | log.warn('Chat room not found', { chatRoomId }); | |
| 168 | - | const errorResponse: SendMessageResponse = { | |
| 169 | - | success: false, | |
| 170 | - | error: 'Chat room not found' | |
| 171 | - | }; | |
| 172 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // Check user existence | |
| 176 | - | const user = await db.select() | |
| 177 | - | .from(users) | |
| 178 | - | .where(eq(users.id, data.userId)) | |
| 179 | - | .get(); | |
| 180 | - | ||
| 181 | - | if (!user) { | |
| 182 | - | const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`; | |
| 183 | - | log.warn('User not found', { maskedUserId }); | |
| 184 | - | const errorResponse: SendMessageResponse = { | |
| 185 | - | success: false, | |
| 186 | - | error: 'User not found' | |
| 187 | - | }; | |
| 188 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | const validatedStyleData = sanitizeStyleData(data.styleData); | |
| 192 | - | ||
| 193 | - | const newMessage: Message = { | |
| 194 | - | id: uuidv4(), | |
| 195 | - | chatRoomId, | |
| 196 | - | senderId: data.userId, | |
| 197 | - | content: data.content, | |
| 198 | - | type: data.type || 'chat', | |
| 199 | - | timestamp: Date.now(), | |
| 200 | - | styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, | |
| 201 | - | hasFormatting: Boolean(validatedStyleData) | |
| 202 | - | }; | |
| 203 | - | ||
| 204 | - | ||
| 205 | - | // debug style data content at this point | |
| 206 | - | log.debug('Style data content', { styleData: validatedStyleData }); | |
| 207 | - | ||
| 208 | - | // Save to DB | |
| 209 | - | await db.insert(messages).values(newMessage); | |
| 210 | - | log.debug('Message saved in DB', { messageId: newMessage.id, chatRoomId: newMessage.chatRoomId, type: newMessage.type, timestamp: newMessage.timestamp }); | |
| 211 | - | ||
| 212 | - | // Broadcast the new message via SSE | |
| 213 | - | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 214 | - | ||
| 215 | - | log.debug('Message processed successfully', { messageId: newMessage.id, userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, roomId: newMessage.chatRoomId }); | |
| 216 | - | ||
| 217 | - | const successResponse: SendMessageResponse = { | |
| 218 | - | success: true, | |
| 219 | - | message: newMessage | |
| 220 | - | }; | |
| 221 | - | ||
| 222 | - | return new Response( | |
| 223 | - | JSON.stringify(successResponse), | |
| 224 | - | { | |
| 225 | - | status: 201, | |
| 226 | - | headers: { 'Content-Type': 'application/json' } | |
| 227 | - | } | |
| 228 | - | ); | |
| 229 | - | } catch (error) { | |
| 230 | - | log.error('Error processing message', { error: error instanceof Error ? error.message : 'Unknown error' }); | |
| 231 | - | const errorResponse: SendMessageResponse = { | |
| 232 | - | success: false, | |
| 233 | - | error: 'Failed to save message' | |
| 234 | - | }; | |
| 235 | - | return new Response( | |
| 236 | - | JSON.stringify(errorResponse), | |
| 237 | - | { | |
| 238 | - | status: 500, | |
| 239 | - | headers: { 'Content-Type': 'application/json' } | |
| 240 | - | } | |
| 241 | - | ); | |
| 242 | - | } | |
| 243 | - | }⏎̸ | |
| 121 | + | export async function POST({ request, locals }: { request: Request; locals: App.Locals }) { | |
| 122 | + | if (!locals.user) { | |
| 123 | + | log.warn('Authentication required'); | |
| 124 | + | throw error(401, 'Authentication required'); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | log.debug('New message received'); | |
| 128 | + | try { | |
| 129 | + | // Check rate limiting | |
| 130 | + | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); | |
| 131 | + | if (!canSend) { | |
| 132 | + | const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`; | |
| 133 | + | log.warn('Rate limited', { userId: maskedUserId, retryAfter }); | |
| 134 | + | const errorResponse: SendMessageResponse = { | |
| 135 | + | success: false, | |
| 136 | + | error: 'Please wait before sending another message', | |
| 137 | + | retryAfter, | |
| 138 | + | isRateLimited: true | |
| 139 | + | }; | |
| 140 | + | return new Response(JSON.stringify(errorResponse), { | |
| 141 | + | status: 429, | |
| 142 | + | headers: { | |
| 143 | + | 'Content-Type': 'application/json', | |
| 144 | + | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() | |
| 145 | + | } | |
| 146 | + | }); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | const data = (await request.json()) as SendMessageRequest; | |
| 150 | + | if (!data.content || !data.userId) { | |
| 151 | + | log.warn('Invalid payload received', { | |
| 152 | + | hasContent: Boolean(data.content), | |
| 153 | + | hasUserId: Boolean(data.userId) | |
| 154 | + | }); | |
| 155 | + | const errorResponse: SendMessageResponse = { | |
| 156 | + | success: false, | |
| 157 | + | error: 'Invalid message payload' | |
| 158 | + | }; | |
| 159 | + | return new Response(JSON.stringify(errorResponse), { status: 400 }); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | if (data.userId !== locals.user.id) { | |
| 163 | + | log.warn('Unauthorized message attempt', { | |
| 164 | + | requestedUserId: data.userId, | |
| 165 | + | actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` | |
| 166 | + | }); | |
| 167 | + | throw error(403, 'Cannot post messages as another user'); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID; | |
| 171 | + | ||
| 172 | + | // Check chat room existence | |
| 173 | + | const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get(); | |
| 174 | + | ||
| 175 | + | if (!chatRoom) { | |
| 176 | + | log.warn('Chat room not found', { chatRoomId }); | |
| 177 | + | const errorResponse: SendMessageResponse = { | |
| 178 | + | success: false, | |
| 179 | + | error: 'Chat room not found' | |
| 180 | + | }; | |
| 181 | + | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // Check user existence | |
| 185 | + | const user = await db.select().from(users).where(eq(users.id, data.userId)).get(); | |
| 186 | + | ||
| 187 | + | if (!user) { | |
| 188 | + | const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`; | |
| 189 | + | log.warn('User not found', { maskedUserId }); | |
| 190 | + | const errorResponse: SendMessageResponse = { | |
| 191 | + | success: false, | |
| 192 | + | error: 'User not found' | |
| 193 | + | }; | |
| 194 | + | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | const validatedStyleData = sanitizeStyleData(data.styleData); | |
| 198 | + | ||
| 199 | + | const newMessage: Message = { | |
| 200 | + | id: uuidv4(), | |
| 201 | + | chatRoomId, | |
| 202 | + | senderId: data.userId, | |
| 203 | + | content: data.content, | |
| 204 | + | type: data.type || 'chat', | |
| 205 | + | timestamp: Date.now(), | |
| 206 | + | styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, | |
| 207 | + | hasFormatting: Boolean(validatedStyleData) | |
| 208 | + | }; | |
| 209 | + | ||
| 210 | + | // debug style data content at this point | |
| 211 | + | log.debug('Style data content', { styleData: validatedStyleData }); | |
| 212 | + | ||
| 213 | + | // Save to DB | |
| 214 | + | await db.insert(messages).values(newMessage); | |
| 215 | + | log.debug('Message saved in DB', { | |
| 216 | + | messageId: newMessage.id, | |
| 217 | + | chatRoomId: newMessage.chatRoomId, | |
| 218 | + | type: newMessage.type, | |
| 219 | + | timestamp: newMessage.timestamp | |
| 220 | + | }); | |
| 221 | + | ||
| 222 | + | // Broadcast the new message via SSE | |
| 223 | + | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 224 | + | ||
| 225 | + | log.debug('Message processed successfully', { | |
| 226 | + | messageId: newMessage.id, | |
| 227 | + | userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, | |
| 228 | + | roomId: newMessage.chatRoomId | |
| 229 | + | }); | |
| 230 | + | ||
| 231 | + | const successResponse: SendMessageResponse = { | |
| 232 | + | success: true, | |
| 233 | + | message: newMessage | |
| 234 | + | }; | |
| 235 | + | ||
| 236 | + | return new Response(JSON.stringify(successResponse), { | |
| 237 | + | status: 201, | |
| 238 | + | headers: { 'Content-Type': 'application/json' } | |
| 239 | + | }); | |
| 240 | + | } catch (error) { | |
| 241 | + | log.error('Error processing message', { | |
| 242 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 243 | + | }); | |
| 244 | + | const errorResponse: SendMessageResponse = { | |
| 245 | + | success: false, | |
| 246 | + | error: 'Failed to save message' | |
| 247 | + | }; | |
| 248 | + | return new Response(JSON.stringify(errorResponse), { | |
| 249 | + | status: 500, | |
| 250 | + | headers: { 'Content-Type': 'application/json' } | |
| 251 | + | }); | |
| 252 | + | } | |
| 253 | + | } | |
M src/routes/api/register/+server.ts +52 -25
| @@ -11,30 +11,38 @@ const log = createLogger('register-server'); | |||
| 11 | 11 | const isDev = process.env.NODE_ENV === 'development'; | |
| 12 | 12 | ||
| 13 | 13 | // In-memory map to track failed captcha attempts per IP | |
| 14 | - | const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| 14 | + | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | |
| 15 | 15 | ||
| 16 | 16 | // Cleanup old entries every hour | |
| 17 | - | setInterval(() => { | |
| 18 | - | const now = Date.now(); | |
| 19 | - | const ONE_HOUR = 60 * 60 * 1000; | |
| 20 | - | for (const [ip, data] of captchaAttempts.entries()) { | |
| 21 | - | if (now - data.lastAttempt > ONE_HOUR) { | |
| 22 | - | captchaAttempts.delete(ip); | |
| 17 | + | setInterval( | |
| 18 | + | () => { | |
| 19 | + | const now = Date.now(); | |
| 20 | + | const ONE_HOUR = 60 * 60 * 1000; | |
| 21 | + | for (const [ip, data] of captchaAttempts.entries()) { | |
| 22 | + | if (now - data.lastAttempt > ONE_HOUR) { | |
| 23 | + | captchaAttempts.delete(ip); | |
| 24 | + | } | |
| 23 | 25 | } | |
| 24 | - | } | |
| 25 | - | }, 60 * 60 * 1000); | |
| 26 | + | }, | |
| 27 | + | 60 * 60 * 1000 | |
| 28 | + | ); | |
| 26 | 29 | ||
| 27 | 30 | export const POST: RequestHandler = async ({ request }) => { | |
| 28 | 31 | log.debug('New registration attempt received'); | |
| 29 | 32 | ||
| 30 | 33 | if (request.method !== 'POST') { | |
| 31 | 34 | log.warn('Invalid method used', { method: request.method }); | |
| 32 | - | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 }); | |
| 35 | + | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { | |
| 36 | + | status: 405 | |
| 37 | + | }); | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | // Get the IP address for rate limiting and Turnstile validation | |
| 36 | 41 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 37 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |
| 42 | + | const maskedIp = ip | |
| 43 | + | .split('.') | |
| 44 | + | .map((octet, idx) => (idx < 3 ? 'xxx' : octet)) | |
| 45 | + | .join('.'); | |
| 38 | 46 | const now = Date.now(); | |
| 39 | 47 | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 40 | 48 | ||
| @@ -43,15 +51,17 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 43 | 51 | body = await request.json(); | |
| 44 | 52 | } catch { | |
| 45 | 53 | log.warn('Invalid JSON payload received'); | |
| 46 | - | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 }); | |
| 54 | + | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { | |
| 55 | + | status: 400 | |
| 56 | + | }); | |
| 47 | 57 | } | |
| 48 | 58 | ||
| 49 | - | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { | |
| 50 | - | suUsername: string, | |
| 51 | - | suPassword: string, | |
| 52 | - | suConfirmPassword: string, | |
| 53 | - | captchaAnswer: string, | |
| 54 | - | turnstileToken?: string | |
| 59 | + | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { | |
| 60 | + | suUsername: string; | |
| 61 | + | suPassword: string; | |
| 62 | + | suConfirmPassword: string; | |
| 63 | + | captchaAnswer: string; | |
| 64 | + | turnstileToken?: string; | |
| 55 | 65 | }; | |
| 56 | 66 | ||
| 57 | 67 | // Validate input using Zod schema | |
| @@ -67,10 +77,11 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 67 | 77 | } catch (err: any) { | |
| 68 | 78 | const errorMessage = err.errors?.[0]?.message || 'Invalid input data'; | |
| 69 | 79 | log.warn('Registration validation failed', { error: errorMessage }); | |
| 70 | - | return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { status: 400 }); | |
| 80 | + | return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { | |
| 81 | + | status: 400 | |
| 82 | + | }); | |
| 71 | 83 | } | |
| 72 | 84 | ||
| 73 | - | ||
| 74 | 85 | // Validate PDR captcha | |
| 75 | 86 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |
| 76 | 87 | if (normalizedAnswer !== 'point de rencontre') { | |
| @@ -78,7 +89,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 78 | 89 | attemptData.lastAttempt = now; | |
| 79 | 90 | captchaAttempts.set(ip, attemptData); | |
| 80 | 91 | log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count }); | |
| 81 | - | return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 }); | |
| 92 | + | return new Response( | |
| 93 | + | JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), | |
| 94 | + | { status: 400 } | |
| 95 | + | ); | |
| 82 | 96 | } | |
| 83 | 97 | ||
| 84 | 98 | // Reset captcha attempts on success | |
| @@ -92,7 +106,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 92 | 106 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | |
| 93 | 107 | if (existingUser.length > 0) { | |
| 94 | 108 | log.warn('Username already exists', { username }); | |
| 95 | - | return new Response(JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError), { status: 409 }); | |
| 109 | + | return new Response( | |
| 110 | + | JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError), | |
| 111 | + | { status: 409 } | |
| 112 | + | ); | |
| 96 | 113 | } | |
| 97 | 114 | ||
| 98 | 115 | // Securely hash the user's password. | |
| @@ -101,7 +118,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 101 | 118 | hashedPassword = await hashPassword(password); | |
| 102 | 119 | } catch { | |
| 103 | 120 | log.error('Error hashing password'); | |
| 104 | - | return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 }); | |
| 121 | + | return new Response( | |
| 122 | + | JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), | |
| 123 | + | { status: 500 } | |
| 124 | + | ); | |
| 105 | 125 | } | |
| 106 | 126 | // Insert the new user into the database. | |
| 107 | 127 | try { | |
| @@ -114,8 +134,15 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 114 | 134 | } catch (err: unknown) { | |
| 115 | 135 | log.error('Database insertion error', { error: err as object }); | |
| 116 | 136 | // Assume a duplicate user error if the email (or derived unique field) already exists. | |
| 117 | - | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | |
| 137 | + | return new Response( | |
| 138 | + | JSON.stringify({ | |
| 139 | + | error: 'User registration failed. Possibly user already exists.' | |
| 140 | + | } as RegisterResponseError), | |
| 141 | + | { status: 409 } | |
| 142 | + | ); | |
| 118 | 143 | } | |
| 119 | 144 | ||
| 120 | - | return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 }); | |
| 145 | + | return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { | |
| 146 | + | status: 201 | |
| 147 | + | }); | |
| 121 | 148 | }; | |
M src/routes/api/rooms/[roomId]/+server.ts +85 -89
| @@ -11,101 +11,97 @@ const log = createLogger('rooms-server'); | |||
| 11 | 11 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | |
| 12 | 12 | ||
| 13 | 13 | export async function GET({ params, url, locals }): Promise<Response> { | |
| 14 | - | const { roomId } = params; | |
| 15 | - | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; | |
| 16 | - | ||
| 17 | - | try { | |
| 18 | - | log.debug('Fetching room data', { | |
| 19 | - | roomId, | |
| 20 | - | isPublic, | |
| 21 | - | requestedBy: locals.user ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` : 'public' | |
| 22 | - | }); | |
| 23 | - | ||
| 24 | - | // Calculate timeout threshold | |
| 25 | - | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 14 | + | const { roomId } = params; | |
| 15 | + | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; | |
| 26 | 16 | ||
| 27 | - | // Fetch buddy list: get all users | |
| 28 | - | const fetchedUsers = await db.select() | |
| 29 | - | .from(users) | |
| 30 | - | .execute(); | |
| 17 | + | try { | |
| 18 | + | log.debug('Fetching room data', { | |
| 19 | + | roomId, | |
| 20 | + | isPublic, | |
| 21 | + | requestedBy: locals.user | |
| 22 | + | ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` | |
| 23 | + | : 'public' | |
| 24 | + | }); | |
| 31 | 25 | ||
| 32 | - | // Normalize users to ensure that lastSeen is always a number (using 0 if null) | |
| 33 | - | const normalizedUsers = fetchedUsers.map( | |
| 34 | - | (user: SafeUser & { lastSeen: number | null }) => ({ | |
| 35 | - | ...user, | |
| 36 | - | lastSeen: user.lastSeen ?? 0 | |
| 37 | - | }) | |
| 38 | - | ); | |
| 26 | + | // Calculate timeout threshold | |
| 27 | + | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 39 | 28 | ||
| 40 | - | // Process users to mark them as offline if they've timed out | |
| 41 | - | const processedUsers = normalizedUsers.map( | |
| 42 | - | (user: SafeUser & { lastSeen: number }) => { | |
| 43 | - | const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold; | |
| 44 | - | return { | |
| 45 | - | ...user, | |
| 46 | - | status: shouldBeOffline ? 'offline' : user.status | |
| 47 | - | }; | |
| 48 | - | } | |
| 49 | - | ); | |
| 29 | + | // Fetch buddy list: get all users | |
| 30 | + | const fetchedUsers = await db.select().from(users).execute(); | |
| 50 | 31 | ||
| 51 | - | // Find users who were originally non-offline but now marked offline | |
| 52 | - | const usersToUpdate = processedUsers.filter( | |
| 53 | - | (user: SafeUser & { lastSeen: number }) => | |
| 54 | - | user.status === 'offline' && | |
| 55 | - | normalizedUsers.find( | |
| 56 | - | (u: SafeUser & { lastSeen: number }) => u.id === user.id | |
| 57 | - | )?.status !== 'offline' | |
| 58 | - | ); | |
| 32 | + | // Normalize users to ensure that lastSeen is always a number (using 0 if null) | |
| 33 | + | const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({ | |
| 34 | + | ...user, | |
| 35 | + | lastSeen: user.lastSeen ?? 0 | |
| 36 | + | })); | |
| 59 | 37 | ||
| 60 | - | if (usersToUpdate.length > 0) { | |
| 61 | - | await Promise.all( | |
| 62 | - | usersToUpdate.map((user: SafeUser & { lastSeen: number }) => | |
| 63 | - | db.update(users) | |
| 64 | - | .set({ status: 'offline', lastSeen: Date.now() }) | |
| 65 | - | .where(eq(users.id, user.id)) | |
| 66 | - | .execute() | |
| 67 | - | ) | |
| 68 | - | ); | |
| 69 | - | ||
| 70 | - | log.debug('Updated offline status for users', { | |
| 71 | - | count: usersToUpdate.length, | |
| 72 | - | userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id) | |
| 73 | - | }); | |
| 74 | - | } | |
| 38 | + | // Process users to mark them as offline if they've timed out | |
| 39 | + | const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => { | |
| 40 | + | const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold; | |
| 41 | + | return { | |
| 42 | + | ...user, | |
| 43 | + | status: shouldBeOffline ? 'offline' : user.status | |
| 44 | + | }; | |
| 45 | + | }); | |
| 75 | 46 | ||
| 76 | - | // Sanitize user data using createSafeUser | |
| 77 | - | const sanitizedUsers = processedUsers.map( | |
| 78 | - | (user: SafeUser & { lastSeen: number }) => createSafeUser(user) | |
| 79 | - | ); | |
| 47 | + | // Find users who were originally non-offline but now marked offline | |
| 48 | + | const usersToUpdate = processedUsers.filter( | |
| 49 | + | (user: SafeUser & { lastSeen: number }) => | |
| 50 | + | user.status === 'offline' && | |
| 51 | + | normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !== | |
| 52 | + | 'offline' | |
| 53 | + | ); | |
| 80 | 54 | ||
| 81 | - | const responseData: PublicRoomResponse = { | |
| 82 | - | success: true, | |
| 83 | - | buddyList: sanitizedUsers | |
| 84 | - | }; | |
| 55 | + | if (usersToUpdate.length > 0) { | |
| 56 | + | await Promise.all( | |
| 57 | + | usersToUpdate.map((user: SafeUser & { lastSeen: number }) => | |
| 58 | + | db | |
| 59 | + | .update(users) | |
| 60 | + | .set({ status: 'offline', lastSeen: Date.now() }) | |
| 61 | + | .where(eq(users.id, user.id)) | |
| 62 | + | .execute() | |
| 63 | + | ) | |
| 64 | + | ); | |
| 85 | 65 | ||
| 86 | - | log.debug('Successfully fetched room data', { | |
| 87 | - | roomId, | |
| 88 | - | userCount: sanitizedUsers.length, | |
| 89 | - | isPublic, | |
| 90 | - | offlineUpdates: usersToUpdate.length | |
| 91 | - | }); | |
| 66 | + | log.debug('Updated offline status for users', { | |
| 67 | + | count: usersToUpdate.length, | |
| 68 | + | userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id) | |
| 69 | + | }); | |
| 70 | + | } | |
| 92 | 71 | ||
| 93 | - | return new Response(JSON.stringify(responseData), { | |
| 94 | - | status: 200, | |
| 95 | - | headers: { 'Content-Type': 'application/json' } | |
| 96 | - | }); | |
| 97 | - | } catch (error) { | |
| 98 | - | log.error('Error fetching room data', { | |
| 99 | - | roomId, | |
| 100 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 101 | - | }); | |
| 102 | - | const errorResponse: PublicRoomResponse = { | |
| 103 | - | success: false, | |
| 104 | - | error: 'Failed to fetch room data' | |
| 105 | - | }; | |
| 106 | - | return new Response(JSON.stringify(errorResponse), { | |
| 107 | - | status: 500, | |
| 108 | - | headers: { 'Content-Type': 'application/json' } | |
| 109 | - | }); | |
| 110 | - | } | |
| 111 | - | } ⏎̸ | |
| 72 | + | // Sanitize user data using createSafeUser | |
| 73 | + | const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) => | |
| 74 | + | createSafeUser(user) | |
| 75 | + | ); | |
| 76 | + | ||
| 77 | + | const responseData: PublicRoomResponse = { | |
| 78 | + | success: true, | |
| 79 | + | buddyList: sanitizedUsers | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | log.debug('Successfully fetched room data', { | |
| 83 | + | roomId, | |
| 84 | + | userCount: sanitizedUsers.length, | |
| 85 | + | isPublic, | |
| 86 | + | offlineUpdates: usersToUpdate.length | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | return new Response(JSON.stringify(responseData), { | |
| 90 | + | status: 200, | |
| 91 | + | headers: { 'Content-Type': 'application/json' } | |
| 92 | + | }); | |
| 93 | + | } catch (error) { | |
| 94 | + | log.error('Error fetching room data', { | |
| 95 | + | roomId, | |
| 96 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 97 | + | }); | |
| 98 | + | const errorResponse: PublicRoomResponse = { | |
| 99 | + | success: false, | |
| 100 | + | error: 'Failed to fetch room data' | |
| 101 | + | }; | |
| 102 | + | return new Response(JSON.stringify(errorResponse), { | |
| 103 | + | status: 500, | |
| 104 | + | headers: { 'Content-Type': 'application/json' } | |
| 105 | + | }); | |
| 106 | + | } | |
| 107 | + | } | |
M src/routes/api/session/login/+server.ts +149 -136
| @@ -14,144 +14,157 @@ import { z } from 'zod'; | |||
| 14 | 14 | const log = createLogger('login-server'); | |
| 15 | 15 | ||
| 16 | 16 | // In-memory map to track failed login attempts per IP | |
| 17 | - | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| 17 | + | const loginAttempts = new Map<string, { count: number; lastAttempt: number }>(); | |
| 18 | 18 | const MAX_ATTEMPTS = 3; | |
| 19 | - | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); | |
| 19 | + | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 20 | 20 | ||
| 21 | 21 | // Cleanup old entries every hour | |
| 22 | - | setInterval(() => { | |
| 23 | - | const now = Date.now(); | |
| 24 | - | const ONE_HOUR = 60 * 60 * 1000; | |
| 25 | - | for (const [ip, data] of loginAttempts.entries()) { | |
| 26 | - | if (now - data.lastAttempt > ONE_HOUR) { | |
| 27 | - | loginAttempts.delete(ip); | |
| 28 | - | } | |
| 29 | - | } | |
| 30 | - | }, 60 * 60 * 1000); | |
| 31 | - | ||
| 22 | + | setInterval( | |
| 23 | + | () => { | |
| 24 | + | const now = Date.now(); | |
| 25 | + | const ONE_HOUR = 60 * 60 * 1000; | |
| 26 | + | for (const [ip, data] of loginAttempts.entries()) { | |
| 27 | + | if (now - data.lastAttempt > ONE_HOUR) { | |
| 28 | + | loginAttempts.delete(ip); | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | }, | |
| 32 | + | 60 * 60 * 1000 | |
| 33 | + | ); | |
| 32 | 34 | ||
| 33 | 35 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 34 | - | log.debug('New login attempt received'); | |
| 35 | - | ||
| 36 | - | if (request.method !== 'POST') { | |
| 37 | - | log.warn('Invalid method used', { method: request.method }); | |
| 38 | - | return new Response( | |
| 39 | - | JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), | |
| 40 | - | { status: 405 } | |
| 41 | - | ); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Get the IP address for rate limiting and Turnstile validation | |
| 45 | - | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 46 | - | const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.'); | |
| 47 | - | const now = Date.now(); | |
| 48 | - | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 49 | - | ||
| 50 | - | if (attemptData.count >= MAX_ATTEMPTS) { | |
| 51 | - | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; | |
| 52 | - | log.warn('Rate limit exceeded', { maskedIp, delay }); | |
| 53 | - | await sleep(delay); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | let body: unknown; | |
| 57 | - | try { | |
| 58 | - | body = await request.json(); | |
| 59 | - | } catch { | |
| 60 | - | log.warn('Invalid JSON payload received'); | |
| 61 | - | return new Response( | |
| 62 | - | JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), | |
| 63 | - | { status: 400 } | |
| 64 | - | ); | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | const { username, password } = body as { | |
| 68 | - | username: string; | |
| 69 | - | password: string; | |
| 70 | - | }; | |
| 71 | - | ||
| 72 | - | // Validate input using Zod schema | |
| 73 | - | try { | |
| 74 | - | loginSchema.parse({ username, password }); | |
| 75 | - | } catch (err) { | |
| 76 | - | const zodError = err as z.ZodError; | |
| 77 | - | const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data'; | |
| 78 | - | log.warn('Login validation failed', { error: errorMessage }); | |
| 79 | - | return new Response( | |
| 80 | - | JSON.stringify({ error: errorMessage } as LoginResponseError), | |
| 81 | - | { status: 400 } | |
| 82 | - | ); | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | ||
| 86 | - | // Find user by username | |
| 87 | - | const user = await db.query.users.findFirst({ | |
| 88 | - | where: eq(users.nickname, username.trim()) | |
| 89 | - | }); | |
| 90 | - | ||
| 91 | - | if (!user) { | |
| 92 | - | attemptData.count++; | |
| 93 | - | attemptData.lastAttempt = now; | |
| 94 | - | loginAttempts.set(ip, attemptData); | |
| 95 | - | log.warn('Login failed - user not found', { | |
| 96 | - | maskedIp, | |
| 97 | - | attemptCount: attemptData.count, | |
| 98 | - | username: username.trim() | |
| 99 | - | }); | |
| 100 | - | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | |
| 101 | - | return new Response(JSON.stringify({ | |
| 102 | - | error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` | |
| 103 | - | } as LoginResponseError), { status: 401 }); | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // Verify password | |
| 107 | - | try { | |
| 108 | - | const isValid = await verifyPassword(password.trim(), user.password); | |
| 109 | - | if (!isValid) { | |
| 110 | - | attemptData.count++; | |
| 111 | - | attemptData.lastAttempt = now; | |
| 112 | - | loginAttempts.set(ip, attemptData); | |
| 113 | - | log.warn('Login failed - invalid password', { | |
| 114 | - | maskedIp, | |
| 115 | - | attemptCount: attemptData.count, | |
| 116 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}` | |
| 117 | - | }); | |
| 118 | - | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | |
| 119 | - | return new Response(JSON.stringify({ | |
| 120 | - | error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` | |
| 121 | - | } as LoginResponseError), { status: 401 }); | |
| 122 | - | } | |
| 123 | - | } catch (error) { | |
| 124 | - | log.error('Error verifying password', { error }); | |
| 125 | - | return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 }); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // Reset login attempt data on success | |
| 129 | - | loginAttempts.delete(ip); | |
| 130 | - | ||
| 131 | - | // Generate session token and create session | |
| 132 | - | const token = generateSessionToken(); | |
| 133 | - | const session = await createSession(token, user.id); | |
| 134 | - | ||
| 135 | - | // Update user status to online | |
| 136 | - | await db.update(users) | |
| 137 | - | .set({ | |
| 138 | - | status: 'online', | |
| 139 | - | lastSeen: Date.now() | |
| 140 | - | }) | |
| 141 | - | .where(eq(users.id, user.id)); | |
| 142 | - | ||
| 143 | - | // Set session cookie | |
| 144 | - | setSessionTokenCookie({ cookies }, token, session.expiresAt); | |
| 145 | - | ||
| 146 | - | log.info('Login successful', { | |
| 147 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`, | |
| 148 | - | expiresAt: new Date(session.expiresAt).toISOString() | |
| 149 | - | }); | |
| 150 | - | ||
| 151 | - | return new Response(JSON.stringify({ | |
| 152 | - | success: true, | |
| 153 | - | user: createSafeUser(user) | |
| 154 | - | } as LoginResponseSuccess), { | |
| 155 | - | status: 200 | |
| 156 | - | }); | |
| 157 | - | }; ⏎̸ | |
| 36 | + | log.debug('New login attempt received'); | |
| 37 | + | ||
| 38 | + | if (request.method !== 'POST') { | |
| 39 | + | log.warn('Invalid method used', { method: request.method }); | |
| 40 | + | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { | |
| 41 | + | status: 405 | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Get the IP address for rate limiting and Turnstile validation | |
| 46 | + | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 47 | + | const maskedIp = ip | |
| 48 | + | .split('.') | |
| 49 | + | .map((octet, idx) => (idx < 3 ? 'xxx' : octet)) | |
| 50 | + | .join('.'); | |
| 51 | + | const now = Date.now(); | |
| 52 | + | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 53 | + | ||
| 54 | + | if (attemptData.count >= MAX_ATTEMPTS) { | |
| 55 | + | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; | |
| 56 | + | log.warn('Rate limit exceeded', { maskedIp, delay }); | |
| 57 | + | await sleep(delay); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | let body: unknown; | |
| 61 | + | try { | |
| 62 | + | body = await request.json(); | |
| 63 | + | } catch { | |
| 64 | + | log.warn('Invalid JSON payload received'); | |
| 65 | + | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { | |
| 66 | + | status: 400 | |
| 67 | + | }); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | const { username, password } = body as { | |
| 71 | + | username: string; | |
| 72 | + | password: string; | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | // Validate input using Zod schema | |
| 76 | + | try { | |
| 77 | + | loginSchema.parse({ username, password }); | |
| 78 | + | } catch (err) { | |
| 79 | + | const zodError = err as z.ZodError; | |
| 80 | + | const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data'; | |
| 81 | + | log.warn('Login validation failed', { error: errorMessage }); | |
| 82 | + | return new Response(JSON.stringify({ error: errorMessage } as LoginResponseError), { | |
| 83 | + | status: 400 | |
| 84 | + | }); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Find user by username | |
| 88 | + | const user = await db.query.users.findFirst({ | |
| 89 | + | where: eq(users.nickname, username.trim()) | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | if (!user) { | |
| 93 | + | attemptData.count++; | |
| 94 | + | attemptData.lastAttempt = now; | |
| 95 | + | loginAttempts.set(ip, attemptData); | |
| 96 | + | log.warn('Login failed - user not found', { | |
| 97 | + | maskedIp, | |
| 98 | + | attemptCount: attemptData.count, | |
| 99 | + | username: username.trim() | |
| 100 | + | }); | |
| 101 | + | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | |
| 102 | + | return new Response( | |
| 103 | + | JSON.stringify({ | |
| 104 | + | error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}` | |
| 105 | + | } as LoginResponseError), | |
| 106 | + | { status: 401 } | |
| 107 | + | ); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Verify password | |
| 111 | + | try { | |
| 112 | + | const isValid = await verifyPassword(password.trim(), user.password); | |
| 113 | + | if (!isValid) { | |
| 114 | + | attemptData.count++; | |
| 115 | + | attemptData.lastAttempt = now; | |
| 116 | + | loginAttempts.set(ip, attemptData); | |
| 117 | + | log.warn('Login failed - invalid password', { | |
| 118 | + | maskedIp, | |
| 119 | + | attemptCount: attemptData.count, | |
| 120 | + | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}` | |
| 121 | + | }); | |
| 122 | + | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | |
| 123 | + | return new Response( | |
| 124 | + | JSON.stringify({ | |
| 125 | + | error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}` | |
| 126 | + | } as LoginResponseError), | |
| 127 | + | { status: 401 } | |
| 128 | + | ); | |
| 129 | + | } | |
| 130 | + | } catch (error) { | |
| 131 | + | log.error('Error verifying password', { error }); | |
| 132 | + | return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { | |
| 133 | + | status: 500 | |
| 134 | + | }); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Reset login attempt data on success | |
| 138 | + | loginAttempts.delete(ip); | |
| 139 | + | ||
| 140 | + | // Generate session token and create session | |
| 141 | + | const token = generateSessionToken(); | |
| 142 | + | const session = await createSession(token, user.id); | |
| 143 | + | ||
| 144 | + | // Update user status to online | |
| 145 | + | await db | |
| 146 | + | .update(users) | |
| 147 | + | .set({ | |
| 148 | + | status: 'online', | |
| 149 | + | lastSeen: Date.now() | |
| 150 | + | }) | |
| 151 | + | .where(eq(users.id, user.id)); | |
| 152 | + | ||
| 153 | + | // Set session cookie | |
| 154 | + | setSessionTokenCookie({ cookies }, token, session.expiresAt); | |
| 155 | + | ||
| 156 | + | log.info('Login successful', { | |
| 157 | + | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`, | |
| 158 | + | expiresAt: new Date(session.expiresAt).toISOString() | |
| 159 | + | }); | |
| 160 | + | ||
| 161 | + | return new Response( | |
| 162 | + | JSON.stringify({ | |
| 163 | + | success: true, | |
| 164 | + | user: createSafeUser(user) | |
| 165 | + | } as LoginResponseSuccess), | |
| 166 | + | { | |
| 167 | + | status: 200 | |
| 168 | + | } | |
| 169 | + | ); | |
| 170 | + | }; | |
M src/routes/api/session/logout/+server.ts +15 -13
| @@ -6,16 +6,18 @@ import { createLogger } from '$lib/utils/logger.server'; | |||
| 6 | 6 | const log = createLogger('logout-server'); | |
| 7 | 7 | ||
| 8 | 8 | export async function POST({ cookies }) { | |
| 9 | - | const token = cookies.get('session'); | |
| 10 | - | ||
| 11 | - | if (token) { | |
| 12 | - | const sessionId = await sha256(token); | |
| 13 | - | await invalidateSession(sessionId); | |
| 14 | - | cookies.delete('session', { path: '/' }); | |
| 15 | - | log.info('Session invalidated', { sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` }); | |
| 16 | - | } else { | |
| 17 | - | log.debug('No session token found in cookies'); | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | return json({ success: true }); | |
| 21 | - | } ⏎̸ | |
| 9 | + | const token = cookies.get('session'); | |
| 10 | + | ||
| 11 | + | if (token) { | |
| 12 | + | const sessionId = await sha256(token); | |
| 13 | + | await invalidateSession(sessionId); | |
| 14 | + | cookies.delete('session', { path: '/' }); | |
| 15 | + | log.info('Session invalidated', { | |
| 16 | + | sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` | |
| 17 | + | }); | |
| 18 | + | } else { | |
| 19 | + | log.debug('No session token found in cookies'); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | return json({ success: true }); | |
| 23 | + | } | |
M src/routes/api/session/validate/+server.ts +20 -20
| @@ -5,23 +5,23 @@ import { createLogger } from '$lib/utils/logger.server'; | |||
| 5 | 5 | const log = createLogger('validate-server'); | |
| 6 | 6 | ||
| 7 | 7 | export async function GET({ cookies }) { | |
| 8 | - | const token = cookies.get('session'); | |
| 9 | - | ||
| 10 | - | if (!token) { | |
| 11 | - | log.debug('No session token found in cookies'); | |
| 12 | - | return json({ session: null, user: null }, { status: 401 }); | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | const result = await validateSessionToken(token); | |
| 16 | - | ||
| 17 | - | if (result.session && result.user) { | |
| 18 | - | log.debug('Session validated', { | |
| 19 | - | userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`, | |
| 20 | - | sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}` | |
| 21 | - | }); | |
| 22 | - | } else { | |
| 23 | - | log.debug('Invalid session token'); | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | return json(result); | |
| 27 | - | } ⏎̸ | |
| 8 | + | const token = cookies.get('session'); | |
| 9 | + | ||
| 10 | + | if (!token) { | |
| 11 | + | log.debug('No session token found in cookies'); | |
| 12 | + | return json({ session: null, user: null }, { status: 401 }); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | const result = await validateSessionToken(token); | |
| 16 | + | ||
| 17 | + | if (result.session && result.user) { | |
| 18 | + | log.debug('Session validated', { | |
| 19 | + | userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`, | |
| 20 | + | sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}` | |
| 21 | + | }); | |
| 22 | + | } else { | |
| 23 | + | log.debug('Invalid session token'); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | return json(result); | |
| 27 | + | } | |
M src/routes/api/sse/+server.ts +274 -271
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // At the top of the file, before any code | |
| 2 | 2 | declare global { | |
| 3 | - | // eslint-disable-next-line no-var | |
| 4 | - | var __sseIntervalsStarted: boolean; | |
| 3 | + | // eslint-disable-next-line no-var | |
| 4 | + | var __sseIntervalsStarted: boolean; | |
| 5 | 5 | } | |
| 6 | 6 | ||
| 7 | 7 | import type { RequestHandler } from './$types'; | |
| @@ -34,292 +34,295 @@ let lastBuddyListData: unknown[] = []; | |||
| 34 | 34 | ||
| 35 | 35 | // Initialize periodic check for timed out users | |
| 36 | 36 | function startTimeoutCheck() { | |
| 37 | - | if (timeoutInterval) { | |
| 38 | - | clearInterval(timeoutInterval); | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | timeoutInterval = setInterval(async () => { | |
| 42 | - | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 43 | - | log.debug('Checking for timed out users', { | |
| 44 | - | checkTime: new Date().toISOString(), | |
| 45 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 46 | - | }); | |
| 47 | - | ||
| 48 | - | try { | |
| 49 | - | const now = Date.now(); | |
| 50 | - | const result = await db.update(users) | |
| 51 | - | .set({ | |
| 52 | - | status: 'offline', | |
| 53 | - | lastSeen: now | |
| 54 | - | }) | |
| 55 | - | .where( | |
| 56 | - | and( | |
| 57 | - | ne(users.status, 'offline'), // Update any non-offline users | |
| 58 | - | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | |
| 59 | - | ) | |
| 60 | - | ) | |
| 61 | - | .returning({ | |
| 62 | - | id: users.id, | |
| 63 | - | nickname: users.nickname, | |
| 64 | - | lastSeen: users.lastSeen | |
| 65 | - | }); | |
| 66 | - | ||
| 67 | - | if (result.length > 0) { | |
| 68 | - | log.info('User status updated to offline', { | |
| 69 | - | usersUpdated: result.length, | |
| 70 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 71 | - | }); | |
| 72 | - | ||
| 73 | - | // Invalidate buddy list cache when users timeout | |
| 74 | - | buddyListCache.invalidate(); | |
| 75 | - | } | |
| 76 | - | } catch (error) { | |
| 77 | - | log.error('Error during timeout check', { error }); | |
| 78 | - | } | |
| 79 | - | }, CHECK_INTERVAL_MS); | |
| 37 | + | if (timeoutInterval) { | |
| 38 | + | clearInterval(timeoutInterval); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | timeoutInterval = setInterval(async () => { | |
| 42 | + | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 43 | + | log.debug('Checking for timed out users', { | |
| 44 | + | checkTime: new Date().toISOString(), | |
| 45 | + | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 46 | + | }); | |
| 47 | + | ||
| 48 | + | try { | |
| 49 | + | const now = Date.now(); | |
| 50 | + | const result = await db | |
| 51 | + | .update(users) | |
| 52 | + | .set({ | |
| 53 | + | status: 'offline', | |
| 54 | + | lastSeen: now | |
| 55 | + | }) | |
| 56 | + | .where( | |
| 57 | + | and( | |
| 58 | + | ne(users.status, 'offline'), // Update any non-offline users | |
| 59 | + | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | |
| 60 | + | ) | |
| 61 | + | ) | |
| 62 | + | .returning({ | |
| 63 | + | id: users.id, | |
| 64 | + | nickname: users.nickname, | |
| 65 | + | lastSeen: users.lastSeen | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | if (result.length > 0) { | |
| 69 | + | log.info('User status updated to offline', { | |
| 70 | + | usersUpdated: result.length, | |
| 71 | + | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | // Invalidate buddy list cache when users timeout | |
| 75 | + | buddyListCache.invalidate(); | |
| 76 | + | } | |
| 77 | + | } catch (error) { | |
| 78 | + | log.error('Error during timeout check', { error }); | |
| 79 | + | } | |
| 80 | + | }, CHECK_INTERVAL_MS); | |
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 83 | function startBuddyListUpdateInterval() { | |
| 83 | - | if (buddyListInterval) { | |
| 84 | - | clearInterval(buddyListInterval); | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | buddyListInterval = setInterval(async () => { | |
| 88 | - | try { | |
| 89 | - | const now = Date.now(); | |
| 90 | - | // Only fetch and broadcast if 10 seconds have passed since last update | |
| 91 | - | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 92 | - | // Only query database if cache is invalid or doesn't exist | |
| 93 | - | if (buddyListCache.needsRefresh()) { | |
| 94 | - | log.debug('Fetching buddy list from database (cache miss)...'); | |
| 95 | - | // Query the complete buddy list from the database. | |
| 96 | - | const buddyList = await db.select().from(users); | |
| 97 | - | // Use createSafeUser to properly sanitize user data | |
| 98 | - | const safeBuddyList = buddyList.map(user => createSafeUser(user)); | |
| 99 | - | ||
| 100 | - | // Update cache and check if data changed | |
| 101 | - | const hasChanged = buddyListCache.update(safeBuddyList); | |
| 102 | - | ||
| 103 | - | if (hasChanged) { | |
| 104 | - | log.debug('Broadcasting buddy list update (data changed)...'); | |
| 105 | - | sseEmitter.emit('sse', { | |
| 106 | - | type: 'buddyListUpdate', | |
| 107 | - | data: safeBuddyList | |
| 108 | - | }); | |
| 109 | - | } | |
| 110 | - | } else { | |
| 111 | - | // Use cached data | |
| 112 | - | const cachedData = buddyListCache.get(); | |
| 113 | - | if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) { | |
| 114 | - | log.debug('Broadcasting buddy list update from cache...'); | |
| 115 | - | lastBuddyListData = cachedData; | |
| 116 | - | sseEmitter.emit('sse', { | |
| 117 | - | type: 'buddyListUpdate', | |
| 118 | - | data: cachedData | |
| 119 | - | }); | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | lastBuddyListUpdate = now; | |
| 123 | - | } | |
| 124 | - | } catch (error) { | |
| 125 | - | log.error('Error fetching buddy list for update', { error }); | |
| 126 | - | } | |
| 127 | - | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 84 | + | if (buddyListInterval) { | |
| 85 | + | clearInterval(buddyListInterval); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | buddyListInterval = setInterval(async () => { | |
| 89 | + | try { | |
| 90 | + | const now = Date.now(); | |
| 91 | + | // Only fetch and broadcast if 10 seconds have passed since last update | |
| 92 | + | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 93 | + | // Only query database if cache is invalid or doesn't exist | |
| 94 | + | if (buddyListCache.needsRefresh()) { | |
| 95 | + | log.debug('Fetching buddy list from database (cache miss)...'); | |
| 96 | + | // Query the complete buddy list from the database. | |
| 97 | + | const buddyList = await db.select().from(users); | |
| 98 | + | // Use createSafeUser to properly sanitize user data | |
| 99 | + | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | |
| 100 | + | ||
| 101 | + | // Update cache and check if data changed | |
| 102 | + | const hasChanged = buddyListCache.update(safeBuddyList); | |
| 103 | + | ||
| 104 | + | if (hasChanged) { | |
| 105 | + | log.debug('Broadcasting buddy list update (data changed)...'); | |
| 106 | + | sseEmitter.emit('sse', { | |
| 107 | + | type: 'buddyListUpdate', | |
| 108 | + | data: safeBuddyList | |
| 109 | + | }); | |
| 110 | + | } | |
| 111 | + | } else { | |
| 112 | + | // Use cached data | |
| 113 | + | const cachedData = buddyListCache.get(); | |
| 114 | + | if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) { | |
| 115 | + | log.debug('Broadcasting buddy list update from cache...'); | |
| 116 | + | lastBuddyListData = cachedData; | |
| 117 | + | sseEmitter.emit('sse', { | |
| 118 | + | type: 'buddyListUpdate', | |
| 119 | + | data: cachedData | |
| 120 | + | }); | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | lastBuddyListUpdate = now; | |
| 124 | + | } | |
| 125 | + | } catch (error) { | |
| 126 | + | log.error('Error fetching buddy list for update', { error }); | |
| 127 | + | } | |
| 128 | + | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 130 | 131 | // Add periodic cleanup for stale connections | |
| 131 | 132 | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 132 | 133 | ||
| 133 | 134 | function startConnectionCleanup() { | |
| 134 | - | if (connectionCleanupInterval) { | |
| 135 | - | clearInterval(connectionCleanupInterval); | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | connectionCleanupInterval = setInterval(() => { | |
| 139 | - | const staleUserIds = sseConnectionTracker.cleanupStaleConnections(); | |
| 140 | - | if (staleUserIds.length > 0) { | |
| 141 | - | // Mark stale connections as offline | |
| 142 | - | staleUserIds.forEach(async (userId) => { | |
| 143 | - | try { | |
| 144 | - | await db.update(users) | |
| 145 | - | .set({ status: 'offline', lastSeen: Date.now() }) | |
| 146 | - | .where(eq(users.id, userId)); | |
| 147 | - | ||
| 148 | - | buddyListCache.invalidate(); | |
| 149 | - | } catch (error) { | |
| 150 | - | log.error('Error updating stale user status', { userId, error }); | |
| 151 | - | } | |
| 152 | - | }); | |
| 153 | - | } | |
| 154 | - | }, 60000); // Run every minute | |
| 135 | + | if (connectionCleanupInterval) { | |
| 136 | + | clearInterval(connectionCleanupInterval); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | connectionCleanupInterval = setInterval(() => { | |
| 140 | + | const staleUserIds = sseConnectionTracker.cleanupStaleConnections(); | |
| 141 | + | if (staleUserIds.length > 0) { | |
| 142 | + | // Mark stale connections as offline | |
| 143 | + | staleUserIds.forEach(async (userId) => { | |
| 144 | + | try { | |
| 145 | + | await db | |
| 146 | + | .update(users) | |
| 147 | + | .set({ status: 'offline', lastSeen: Date.now() }) | |
| 148 | + | .where(eq(users.id, userId)); | |
| 149 | + | ||
| 150 | + | buddyListCache.invalidate(); | |
| 151 | + | } catch (error) { | |
| 152 | + | log.error('Error updating stale user status', { userId, error }); | |
| 153 | + | } | |
| 154 | + | }); | |
| 155 | + | } | |
| 156 | + | }, 60000); // Run every minute | |
| 155 | 157 | } | |
| 156 | 158 | ||
| 157 | 159 | // <<< NEW: Ensure intervals are started only once >>> | |
| 158 | 160 | if (!globalThis.__sseIntervalsStarted) { | |
| 159 | - | startTimeoutCheck(); | |
| 160 | - | startBuddyListUpdateInterval(); | |
| 161 | - | startConnectionCleanup(); | |
| 162 | - | globalThis.__sseIntervalsStarted = true; | |
| 163 | - | log.info('Started global intervals for timeout and buddy list updates'); | |
| 161 | + | startTimeoutCheck(); | |
| 162 | + | startBuddyListUpdateInterval(); | |
| 163 | + | startConnectionCleanup(); | |
| 164 | + | globalThis.__sseIntervalsStarted = true; | |
| 165 | + | log.info('Started global intervals for timeout and buddy list updates'); | |
| 164 | 166 | } else { | |
| 165 | - | log.debug('Global intervals already started, skipping initialization'); | |
| 167 | + | log.debug('Global intervals already started, skipping initialization'); | |
| 166 | 168 | } | |
| 167 | 169 | ||
| 168 | 170 | // Ensure the interval is cleared if the module is reloaded | |
| 169 | 171 | if (typeof process !== 'undefined') { | |
| 170 | - | process.on('beforeExit', () => { | |
| 171 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 172 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 173 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 174 | - | }); | |
| 175 | - | ||
| 176 | - | // Also handle SIGTERM and SIGINT | |
| 177 | - | process.on('SIGTERM', () => { | |
| 178 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 179 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 180 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 181 | - | process.exit(0); | |
| 182 | - | }); | |
| 183 | - | ||
| 184 | - | process.on('SIGINT', () => { | |
| 185 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 186 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 187 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 188 | - | process.exit(0); | |
| 189 | - | }); | |
| 172 | + | process.on('beforeExit', () => { | |
| 173 | + | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 174 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 175 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | // Also handle SIGTERM and SIGINT | |
| 179 | + | process.on('SIGTERM', () => { | |
| 180 | + | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 181 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 182 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 183 | + | process.exit(0); | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | process.on('SIGINT', () => { | |
| 187 | + | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 188 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 189 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 190 | + | process.exit(0); | |
| 191 | + | }); | |
| 190 | 192 | } | |
| 191 | 193 | ||
| 192 | - | export const GET: RequestHandler = async ({ request, locals, }) => { | |
| 193 | - | log.debug('Processing SSE request', { | |
| 194 | - | url: request.url, | |
| 195 | - | userId: locals.user?.id | |
| 196 | - | }); | |
| 197 | - | ||
| 198 | - | if (!locals.user) { | |
| 199 | - | log.warn('Authentication failed - no user in locals'); | |
| 200 | - | return new Response('Unauthorized', { | |
| 201 | - | status: 401, | |
| 202 | - | headers: { | |
| 203 | - | 'Content-Type': 'application/json' | |
| 204 | - | } | |
| 205 | - | }); | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | const userId = locals.user.id; | |
| 209 | - | const encoder = new TextEncoder(); | |
| 210 | - | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 211 | - | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | |
| 212 | - | ||
| 213 | - | const now = Date.now(); | |
| 214 | - | ||
| 215 | - | // Only update status if this is a new connection | |
| 216 | - | const isNewConnection = sseConnectionTracker.addConnection(userId); | |
| 217 | - | ||
| 218 | - | if (isNewConnection) { | |
| 219 | - | try { | |
| 220 | - | await db.update(users) | |
| 221 | - | .set({ | |
| 222 | - | status: 'online', | |
| 223 | - | lastSeen: now | |
| 224 | - | }) | |
| 225 | - | .where(eq(users.id, userId)); | |
| 226 | - | ||
| 227 | - | log.info('User status updated to online', { | |
| 228 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | |
| 229 | - | timestamp: new Date(now).toISOString() | |
| 230 | - | }); | |
| 231 | - | ||
| 232 | - | // Invalidate buddy list cache when user status changes | |
| 233 | - | buddyListCache.invalidate(); | |
| 234 | - | ||
| 235 | - | sseEmitter.emit('sse', { | |
| 236 | - | type: 'userStatusUpdate', | |
| 237 | - | data: { | |
| 238 | - | userId, | |
| 239 | - | status: 'online', | |
| 240 | - | lastSeen: now | |
| 241 | - | } | |
| 242 | - | }); | |
| 243 | - | } catch (error) { | |
| 244 | - | log.error('Error updating user status', { error }); | |
| 245 | - | sseConnectionTracker.removeConnection(userId); | |
| 246 | - | } | |
| 247 | - | } else { | |
| 248 | - | log.debug('User already has active SSE connection, skipping status update', { | |
| 249 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}` | |
| 250 | - | }); | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | const stream = new ReadableStream({ | |
| 254 | - | start(controller) { | |
| 255 | - | const sendEvent = (event: { type: string; data: unknown }) => { | |
| 256 | - | try { | |
| 257 | - | const payload = `event: ${event.type}\n` + | |
| 258 | - | `data: ${JSON.stringify(event.data)}\n\n`; | |
| 259 | - | controller.enqueue(encoder.encode(payload)); | |
| 260 | - | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | |
| 261 | - | log.info('Event sent', { type: event.type }); | |
| 262 | - | } | |
| 263 | - | } catch (error) { | |
| 264 | - | log.error('Error sending event', { error }); | |
| 265 | - | } | |
| 266 | - | }; | |
| 267 | - | ||
| 268 | - | onSSE = (event: { type: string; data: unknown }) => { | |
| 269 | - | sendEvent(event); | |
| 270 | - | }; | |
| 271 | - | ||
| 272 | - | sseEmitter.addListener('sse', onSSE); | |
| 273 | - | request.signal.addEventListener('abort', () => { | |
| 274 | - | if (onSSE) { | |
| 275 | - | sseEmitter.removeListener('sse', onSSE); | |
| 276 | - | log.info('Removed listener due to abort signal'); | |
| 277 | - | } | |
| 278 | - | }); | |
| 279 | - | log.info('Added new listener'); | |
| 280 | - | ||
| 281 | - | controller.enqueue(encoder.encode('data: Connected\n\n')); | |
| 282 | - | ||
| 283 | - | keepAliveInterval = setInterval(() => { | |
| 284 | - | controller.enqueue(encoder.encode(':\n\n')); | |
| 285 | - | }, 20000); | |
| 286 | - | }, | |
| 287 | - | async cancel(reason) { | |
| 288 | - | if (keepAliveInterval) clearInterval(keepAliveInterval); | |
| 289 | - | if (onSSE) sseEmitter.removeListener('sse', onSSE); | |
| 290 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 291 | - | log.info('Connection closed', { userId: maskedUserId, reason }); | |
| 292 | - | ||
| 293 | - | // Remove from connection tracker | |
| 294 | - | const wasTracked = sseConnectionTracker.removeConnection(userId); | |
| 295 | - | ||
| 296 | - | // Only update database if this was the last connection | |
| 297 | - | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { | |
| 298 | - | const now = Date.now(); | |
| 299 | - | await db.update(users) | |
| 300 | - | .set({ status: 'offline', lastSeen: now }) | |
| 301 | - | .where(eq(users.id, userId)); | |
| 302 | - | ||
| 303 | - | // Invalidate buddy list cache when user goes offline | |
| 304 | - | buddyListCache.invalidate(); | |
| 305 | - | ||
| 306 | - | sseEmitter.emit('sse', { | |
| 307 | - | type: 'userStatusUpdate', | |
| 308 | - | data: { | |
| 309 | - | userId, | |
| 310 | - | status: 'offline', | |
| 311 | - | lastSeen: now | |
| 312 | - | } | |
| 313 | - | }); | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | }); | |
| 317 | - | ||
| 318 | - | return new Response(stream, { | |
| 319 | - | headers: { | |
| 320 | - | 'Content-Type': 'text/event-stream', | |
| 321 | - | 'Cache-Control': 'no-cache', | |
| 322 | - | 'Connection': 'keep-alive' | |
| 323 | - | } | |
| 324 | - | }); | |
| 325 | - | }; ⏎̸ | |
| 194 | + | export const GET: RequestHandler = async ({ request, locals }) => { | |
| 195 | + | log.debug('Processing SSE request', { | |
| 196 | + | url: request.url, | |
| 197 | + | userId: locals.user?.id | |
| 198 | + | }); | |
| 199 | + | ||
| 200 | + | if (!locals.user) { | |
| 201 | + | log.warn('Authentication failed - no user in locals'); | |
| 202 | + | return new Response('Unauthorized', { | |
| 203 | + | status: 401, | |
| 204 | + | headers: { | |
| 205 | + | 'Content-Type': 'application/json' | |
| 206 | + | } | |
| 207 | + | }); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | const userId = locals.user.id; | |
| 211 | + | const encoder = new TextEncoder(); | |
| 212 | + | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 213 | + | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | |
| 214 | + | ||
| 215 | + | const now = Date.now(); | |
| 216 | + | ||
| 217 | + | // Only update status if this is a new connection | |
| 218 | + | const isNewConnection = sseConnectionTracker.addConnection(userId); | |
| 219 | + | ||
| 220 | + | if (isNewConnection) { | |
| 221 | + | try { | |
| 222 | + | await db | |
| 223 | + | .update(users) | |
| 224 | + | .set({ | |
| 225 | + | status: 'online', | |
| 226 | + | lastSeen: now | |
| 227 | + | }) | |
| 228 | + | .where(eq(users.id, userId)); | |
| 229 | + | ||
| 230 | + | log.info('User status updated to online', { | |
| 231 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | |
| 232 | + | timestamp: new Date(now).toISOString() | |
| 233 | + | }); | |
| 234 | + | ||
| 235 | + | // Invalidate buddy list cache when user status changes | |
| 236 | + | buddyListCache.invalidate(); | |
| 237 | + | ||
| 238 | + | sseEmitter.emit('sse', { | |
| 239 | + | type: 'userStatusUpdate', | |
| 240 | + | data: { | |
| 241 | + | userId, | |
| 242 | + | status: 'online', | |
| 243 | + | lastSeen: now | |
| 244 | + | } | |
| 245 | + | }); | |
| 246 | + | } catch (error) { | |
| 247 | + | log.error('Error updating user status', { error }); | |
| 248 | + | sseConnectionTracker.removeConnection(userId); | |
| 249 | + | } | |
| 250 | + | } else { | |
| 251 | + | log.debug('User already has active SSE connection, skipping status update', { | |
| 252 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}` | |
| 253 | + | }); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | const stream = new ReadableStream({ | |
| 257 | + | start(controller) { | |
| 258 | + | const sendEvent = (event: { type: string; data: unknown }) => { | |
| 259 | + | try { | |
| 260 | + | const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`; | |
| 261 | + | controller.enqueue(encoder.encode(payload)); | |
| 262 | + | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | |
| 263 | + | log.info('Event sent', { type: event.type }); | |
| 264 | + | } | |
| 265 | + | } catch (error) { | |
| 266 | + | log.error('Error sending event', { error }); | |
| 267 | + | } | |
| 268 | + | }; | |
| 269 | + | ||
| 270 | + | onSSE = (event: { type: string; data: unknown }) => { | |
| 271 | + | sendEvent(event); | |
| 272 | + | }; | |
| 273 | + | ||
| 274 | + | sseEmitter.addListener('sse', onSSE); | |
| 275 | + | request.signal.addEventListener('abort', () => { | |
| 276 | + | if (onSSE) { | |
| 277 | + | sseEmitter.removeListener('sse', onSSE); | |
| 278 | + | log.info('Removed listener due to abort signal'); | |
| 279 | + | } | |
| 280 | + | }); | |
| 281 | + | log.info('Added new listener'); | |
| 282 | + | ||
| 283 | + | controller.enqueue(encoder.encode('data: Connected\n\n')); | |
| 284 | + | ||
| 285 | + | keepAliveInterval = setInterval(() => { | |
| 286 | + | controller.enqueue(encoder.encode(':\n\n')); | |
| 287 | + | }, 20000); | |
| 288 | + | }, | |
| 289 | + | async cancel(reason) { | |
| 290 | + | if (keepAliveInterval) clearInterval(keepAliveInterval); | |
| 291 | + | if (onSSE) sseEmitter.removeListener('sse', onSSE); | |
| 292 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 293 | + | log.info('Connection closed', { userId: maskedUserId, reason }); | |
| 294 | + | ||
| 295 | + | // Remove from connection tracker | |
| 296 | + | const wasTracked = sseConnectionTracker.removeConnection(userId); | |
| 297 | + | ||
| 298 | + | // Only update database if this was the last connection | |
| 299 | + | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { | |
| 300 | + | const now = Date.now(); | |
| 301 | + | await db | |
| 302 | + | .update(users) | |
| 303 | + | .set({ status: 'offline', lastSeen: now }) | |
| 304 | + | .where(eq(users.id, userId)); | |
| 305 | + | ||
| 306 | + | // Invalidate buddy list cache when user goes offline | |
| 307 | + | buddyListCache.invalidate(); | |
| 308 | + | ||
| 309 | + | sseEmitter.emit('sse', { | |
| 310 | + | type: 'userStatusUpdate', | |
| 311 | + | data: { | |
| 312 | + | userId, | |
| 313 | + | status: 'offline', | |
| 314 | + | lastSeen: now | |
| 315 | + | } | |
| 316 | + | }); | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | }); | |
| 320 | + | ||
| 321 | + | return new Response(stream, { | |
| 322 | + | headers: { | |
| 323 | + | 'Content-Type': 'text/event-stream', | |
| 324 | + | 'Cache-Control': 'no-cache', | |
| 325 | + | Connection: 'keep-alive' | |
| 326 | + | } | |
| 327 | + | }); | |
| 328 | + | }; | |
M src/routes/api/status/+server.ts +79 -72
| @@ -11,81 +11,88 @@ import type { RequestHandler } from './$types'; | |||
| 11 | 11 | const log = createLogger('status-server'); | |
| 12 | 12 | ||
| 13 | 13 | export const POST: RequestHandler = async ({ request, cookies, locals }) => { | |
| 14 | - | if (!locals.user) { | |
| 15 | - | log.warn('Authentication required'); | |
| 16 | - | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { | |
| 17 | - | status: 401, | |
| 18 | - | headers: { 'Content-Type': 'application/json' } | |
| 19 | - | }); | |
| 20 | - | } | |
| 14 | + | if (!locals.user) { | |
| 15 | + | log.warn('Authentication required'); | |
| 16 | + | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { | |
| 17 | + | status: 401, | |
| 18 | + | headers: { 'Content-Type': 'application/json' } | |
| 19 | + | }); | |
| 20 | + | } | |
| 21 | 21 | ||
| 22 | - | try { | |
| 23 | - | const { status } = await request.json(); | |
| 24 | - | const validStatuses = ['online', 'away', 'busy', 'offline']; | |
| 25 | - | if (!validStatuses.includes(status)) { | |
| 26 | - | log.warn('Invalid status received', { status }); | |
| 27 | - | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { | |
| 28 | - | status: 400, | |
| 29 | - | headers: { 'Content-Type': 'application/json' } | |
| 30 | - | }); | |
| 31 | - | } | |
| 22 | + | try { | |
| 23 | + | const { status } = await request.json(); | |
| 24 | + | const validStatuses = ['online', 'away', 'busy', 'offline']; | |
| 25 | + | if (!validStatuses.includes(status)) { | |
| 26 | + | log.warn('Invalid status received', { status }); | |
| 27 | + | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { | |
| 28 | + | status: 400, | |
| 29 | + | headers: { 'Content-Type': 'application/json' } | |
| 30 | + | }); | |
| 31 | + | } | |
| 32 | 32 | ||
| 33 | - | const userId = locals.user.id; | |
| 34 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 35 | - | log.debug('Updating user status', { userId: maskedUserId, status }); | |
| 33 | + | const userId = locals.user.id; | |
| 34 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 35 | + | log.debug('Updating user status', { userId: maskedUserId, status }); | |
| 36 | 36 | ||
| 37 | - | const now = Date.now(); | |
| 37 | + | const now = Date.now(); | |
| 38 | 38 | ||
| 39 | - | // Update the user status in the database | |
| 40 | - | // Always update lastSeen to support timeout detection | |
| 41 | - | const updatedUser = await db.update(users) | |
| 42 | - | .set({ | |
| 43 | - | status, | |
| 44 | - | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 45 | - | }) | |
| 46 | - | .where(eq(users.id, userId)) | |
| 47 | - | .returning() | |
| 48 | - | .get(); | |
| 49 | - | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 50 | - | ||
| 51 | - | // Invalidate buddy list cache when status changes | |
| 52 | - | buddyListCache.invalidate(); | |
| 39 | + | // Update the user status in the database | |
| 40 | + | // Always update lastSeen to support timeout detection | |
| 41 | + | const updatedUser = await db | |
| 42 | + | .update(users) | |
| 43 | + | .set({ | |
| 44 | + | status, | |
| 45 | + | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 46 | + | }) | |
| 47 | + | .where(eq(users.id, userId)) | |
| 48 | + | .returning() | |
| 49 | + | .get(); | |
| 50 | + | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 53 | 51 | ||
| 54 | - | // Renew session if status is online and session expires in less than 1 day | |
| 55 | - | if (status === 'online') { | |
| 56 | - | const token = cookies.get('session'); | |
| 57 | - | if (token) { | |
| 58 | - | const result = await validateSessionToken(token); | |
| 59 | - | if (result.session) { | |
| 60 | - | const remaining = result.session.expiresAt - now; | |
| 61 | - | const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds | |
| 62 | - | if (remaining < threshold) { | |
| 63 | - | const newToken = generateSessionToken(); | |
| 64 | - | const newSession = await createSession(newToken, userId); | |
| 65 | - | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 66 | - | log.info('Session renewed', { | |
| 67 | - | userId: maskedUserId, | |
| 68 | - | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 69 | - | }); | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | } | |
| 52 | + | // Invalidate buddy list cache when status changes | |
| 53 | + | buddyListCache.invalidate(); | |
| 74 | 54 | ||
| 75 | - | return new Response(JSON.stringify({ | |
| 76 | - | success: true, | |
| 77 | - | user: createSafeUser(updatedUser) | |
| 78 | - | }), { | |
| 79 | - | status: 200, | |
| 80 | - | headers: { 'Content-Type': 'application/json' } | |
| 81 | - | }); | |
| 82 | - | } catch (error) { | |
| 83 | - | return new Response( | |
| 84 | - | JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }), | |
| 85 | - | { | |
| 86 | - | status: 500, | |
| 87 | - | headers: { 'Content-Type': 'application/json' } | |
| 88 | - | } | |
| 89 | - | ); | |
| 90 | - | } | |
| 91 | - | };⏎̸ | |
| 55 | + | // Renew session if status is online and session expires in less than 1 day | |
| 56 | + | if (status === 'online') { | |
| 57 | + | const token = cookies.get('session'); | |
| 58 | + | if (token) { | |
| 59 | + | const result = await validateSessionToken(token); | |
| 60 | + | if (result.session) { | |
| 61 | + | const remaining = result.session.expiresAt - now; | |
| 62 | + | const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds | |
| 63 | + | if (remaining < threshold) { | |
| 64 | + | const newToken = generateSessionToken(); | |
| 65 | + | const newSession = await createSession(newToken, userId); | |
| 66 | + | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 67 | + | log.info('Session renewed', { | |
| 68 | + | userId: maskedUserId, | |
| 69 | + | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 70 | + | }); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return new Response( | |
| 77 | + | JSON.stringify({ | |
| 78 | + | success: true, | |
| 79 | + | user: createSafeUser(updatedUser) | |
| 80 | + | }), | |
| 81 | + | { | |
| 82 | + | status: 200, | |
| 83 | + | headers: { 'Content-Type': 'application/json' } | |
| 84 | + | } | |
| 85 | + | ); | |
| 86 | + | } catch (error) { | |
| 87 | + | return new Response( | |
| 88 | + | JSON.stringify({ | |
| 89 | + | success: false, | |
| 90 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 91 | + | }), | |
| 92 | + | { | |
| 93 | + | status: 500, | |
| 94 | + | headers: { 'Content-Type': 'application/json' } | |
| 95 | + | } | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | }; | |
M src/routes/api/users/[userId]/+server.ts +27 -30
| @@ -9,37 +9,34 @@ import { createLogger } from '$lib/utils/logger.server'; | |||
| 9 | 9 | const log = createLogger('users-server'); | |
| 10 | 10 | ||
| 11 | 11 | export const GET: RequestHandler = async ({ params }) => { | |
| 12 | - | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | |
| 13 | - | log.debug('Fetching user data', { userId: maskedUserId }); | |
| 12 | + | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | |
| 13 | + | log.debug('Fetching user data', { userId: maskedUserId }); | |
| 14 | 14 | ||
| 15 | - | try { | |
| 16 | - | const user = await db.select() | |
| 17 | - | .from(users) | |
| 18 | - | .where(eq(users.id, params.userId)) | |
| 19 | - | .get(); | |
| 15 | + | try { | |
| 16 | + | const user = await db.select().from(users).where(eq(users.id, params.userId)).get(); | |
| 20 | 17 | ||
| 21 | - | if (!user) { | |
| 22 | - | log.warn('User not found', { userId: maskedUserId }); | |
| 23 | - | throw error(404, 'User not found'); | |
| 24 | - | } | |
| 18 | + | if (!user) { | |
| 19 | + | log.warn('User not found', { userId: maskedUserId }); | |
| 20 | + | throw error(404, 'User not found'); | |
| 21 | + | } | |
| 25 | 22 | ||
| 26 | - | const safeUser = createSafeUser(user); | |
| 23 | + | const safeUser = createSafeUser(user); | |
| 27 | 24 | ||
| 28 | - | log.debug('User data retrieved', { | |
| 29 | - | userId: maskedUserId, | |
| 30 | - | status: safeUser.status, | |
| 31 | - | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null | |
| 32 | - | }); | |
| 33 | - | ||
| 34 | - | return json({ | |
| 35 | - | success: true, | |
| 36 | - | user: safeUser | |
| 37 | - | }); | |
| 38 | - | } catch (err) { | |
| 39 | - | log.error('Error fetching user data', { | |
| 40 | - | userId: maskedUserId, | |
| 41 | - | error: err instanceof Error ? err.message : 'Unknown error' | |
| 42 | - | }); | |
| 43 | - | throw error(500, 'Failed to fetch user information'); | |
| 44 | - | } | |
| 45 | - | };⏎̸ | |
| 25 | + | log.debug('User data retrieved', { | |
| 26 | + | userId: maskedUserId, | |
| 27 | + | status: safeUser.status, | |
| 28 | + | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | return json({ | |
| 32 | + | success: true, | |
| 33 | + | user: safeUser | |
| 34 | + | }); | |
| 35 | + | } catch (err) { | |
| 36 | + | log.error('Error fetching user data', { | |
| 37 | + | userId: maskedUserId, | |
| 38 | + | error: err instanceof Error ? err.message : 'Unknown error' | |
| 39 | + | }); | |
| 40 | + | throw error(500, 'Failed to fetch user information'); | |
| 41 | + | } | |
| 42 | + | }; | |
M test-gradient-fixes.md +7 -2
| @@ -3,16 +3,20 @@ | |||
| 3 | 3 | ## Issues Fixed: | |
| 4 | 4 | ||
| 5 | 5 | ### 1. Color Picker Button Gradient Preview | |
| 6 | + | ||
| 6 | 7 | **Problem**: The color picker button wasn't showing gradient preview correctly. | |
| 7 | 8 | **Fix**: Updated `displayStyle` in `color-picker-256.svelte` to use consistent `background` property instead of `background-color`. | |
| 8 | 9 | ||
| 9 | - | ### 2. Text Input Gradient Display | |
| 10 | + | ### 2. Text Input Gradient Display | |
| 11 | + | ||
| 10 | 12 | **Problem**: Text input only showed the first color of a gradient. | |
| 11 | 13 | **Fix**: This is by design to preserve the input background. Added a visual gradient indicator next to the input field in `chat-room.svelte` when gradient mode is active. | |
| 12 | 14 | ||
| 13 | 15 | ### 3. Chat Messages Formatting | |
| 16 | + | ||
| 14 | 17 | **Problem**: Messages weren't displaying with user-specific fonts, styles and colors. | |
| 15 | 18 | **Fixes**: | |
| 19 | + | ||
| 16 | 20 | - Updated `sendMessage` function to accept and pass `textStyle` parameter | |
| 17 | 21 | - Added `styleData` field to `SendMessageRequest` payload type | |
| 18 | 22 | - Updated server-side message handler to save `styleData` and `hasFormatting` fields | |
| @@ -41,9 +45,10 @@ | |||
| 41 | 45 | ## Testing: | |
| 42 | 46 | ||
| 43 | 47 | To test the fixes: | |
| 48 | + | ||
| 44 | 49 | 1. Open the chat window | |
| 45 | 50 | 2. Select a gradient using the color picker (add 2-3 colors) | |
| 46 | 51 | 3. Choose a font and adjust size | |
| 47 | 52 | 4. Type a message - you should see the first gradient color in the input | |
| 48 | 53 | 5. Send the message - it should display with the full gradient effect | |
| 49 | - | 6. Other users should see your message with your chosen formatting⏎̸ | |
| 54 | + | 6. Other users should see your message with your chosen formatting | |
M utils/db-flush.ts +41 -39
| @@ -7,49 +7,51 @@ dotenv.config(); | |||
| 7 | 7 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | |
| 8 | 8 | ||
| 9 | 9 | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | |
| 10 | - | throw new Error('Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN'); | |
| 10 | + | throw new Error( | |
| 11 | + | 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN' | |
| 12 | + | ); | |
| 11 | 13 | } | |
| 12 | 14 | ||
| 13 | 15 | async function flushDatabase() { | |
| 14 | - | console.log('Starting database content flush...'); | |
| 15 | - | ||
| 16 | - | const client = createClient({ | |
| 17 | - | url: TURSO_DATABASE_URL as string, | |
| 18 | - | authToken: TURSO_AUTH_TOKEN as string | |
| 19 | - | }); | |
| 20 | - | ||
| 21 | - | try { | |
| 22 | - | // Clear tables in correct order to respect foreign key constraints | |
| 23 | - | console.log('Clearing messages table...'); | |
| 24 | - | await client.execute('DELETE FROM messages'); | |
| 25 | - | ||
| 26 | - | console.log('Clearing sessions table...'); | |
| 27 | - | await client.execute('DELETE FROM sessions'); | |
| 28 | - | ||
| 29 | - | console.log('Clearing chat rooms table...'); | |
| 30 | - | await client.execute('DELETE FROM chat_rooms'); | |
| 31 | - | ||
| 32 | - | console.log('Clearing users table...'); | |
| 33 | - | await client.execute('DELETE FROM users'); | |
| 34 | - | ||
| 35 | - | console.log('Database content flush completed successfully'); | |
| 36 | - | } catch (error) { | |
| 37 | - | console.error('Error flushing database content:', error); | |
| 38 | - | throw error; | |
| 39 | - | } finally { | |
| 40 | - | await client.close(); | |
| 41 | - | } | |
| 16 | + | console.log('Starting database content flush...'); | |
| 17 | + | ||
| 18 | + | const client = createClient({ | |
| 19 | + | url: TURSO_DATABASE_URL as string, | |
| 20 | + | authToken: TURSO_AUTH_TOKEN as string | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | try { | |
| 24 | + | // Clear tables in correct order to respect foreign key constraints | |
| 25 | + | console.log('Clearing messages table...'); | |
| 26 | + | await client.execute('DELETE FROM messages'); | |
| 27 | + | ||
| 28 | + | console.log('Clearing sessions table...'); | |
| 29 | + | await client.execute('DELETE FROM sessions'); | |
| 30 | + | ||
| 31 | + | console.log('Clearing chat rooms table...'); | |
| 32 | + | await client.execute('DELETE FROM chat_rooms'); | |
| 33 | + | ||
| 34 | + | console.log('Clearing users table...'); | |
| 35 | + | await client.execute('DELETE FROM users'); | |
| 36 | + | ||
| 37 | + | console.log('Database content flush completed successfully'); | |
| 38 | + | } catch (error) { | |
| 39 | + | console.error('Error flushing database content:', error); | |
| 40 | + | throw error; | |
| 41 | + | } finally { | |
| 42 | + | await client.close(); | |
| 43 | + | } | |
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | // Execute if this file is run directly | |
| 45 | 47 | if (new URL(import.meta.url).pathname === process.argv[1]) { | |
| 46 | - | flushDatabase() | |
| 47 | - | .then(() => { | |
| 48 | - | console.debug('[db-flush] Exiting process with code 0'); | |
| 49 | - | process.exit(0); | |
| 50 | - | }) | |
| 51 | - | .catch((error) => { | |
| 52 | - | console.error('[db-flush] Failed to flush database content:', error); | |
| 53 | - | process.exit(1); | |
| 54 | - | }); | |
| 55 | - | } ⏎̸ | |
| 48 | + | flushDatabase() | |
| 49 | + | .then(() => { | |
| 50 | + | console.debug('[db-flush] Exiting process with code 0'); | |
| 51 | + | process.exit(0); | |
| 52 | + | }) | |
| 53 | + | .catch((error) => { | |
| 54 | + | console.error('[db-flush] Failed to flush database content:', error); | |
| 55 | + | process.exit(1); | |
| 56 | + | }); | |
| 57 | + | } | |
M vite.config.ts +3 -3
| @@ -8,7 +8,7 @@ import tailwindcss from '@tailwindcss/vite'; | |||
| 8 | 8 | * This project uses two logging systems: | |
| 9 | 9 | * 1. Console logging (development/client-side) - controlled by console stripping | |
| 10 | 10 | * 2. Pino logging (server-side) - controlled by LOG_LEVEL | |
| 11 | - | * | |
| 11 | + | * | |
| 12 | 12 | * LOG_LEVEL affects both systems: | |
| 13 | 13 | * - error: only error logs | |
| 14 | 14 | * - warn: warn and error logs | |
| @@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => { | |||
| 35 | 35 | // Load env file based on `mode` value | |
| 36 | 36 | const env = loadEnv(mode, process.cwd(), ''); | |
| 37 | 37 | const logLevel = env.LOG_LEVEL || (mode === 'development' ? 'debug' : 'info'); | |
| 38 | - | ||
| 38 | + | ||
| 39 | 39 | console.log(`Building with LOG_LEVEL=${logLevel} in ${mode} mode`); | |
| 40 | 40 | ||
| 41 | 41 | return { | |
| @@ -50,7 +50,7 @@ export default defineConfig(({ mode }) => { | |||
| 50 | 50 | // Strip console methods based on LOG_LEVEL | |
| 51 | 51 | pure_funcs: getConsoleFuncsToStrip(logLevel) | |
| 52 | 52 | } | |
| 53 | - | }, | |
| 53 | + | } | |
| 54 | 54 | }, | |
| 55 | 55 | // Make LOG_LEVEL available to client-side code | |
| 56 | 56 | define: { | |