Merge pull request #48 from kitpdr/feat/ci
ci: add GitHub Actions CI and dependabot auto-merge
73 files · +9966 -9510
M .github/dependabot.yml +37 -37
| @@ -1,67 +1,67 @@ | |||||
| 1 | version: 2 | 1 | version: 2 | ||
| 2 | updates: | 2 | updates: | ||
| 3 | # npm dependencies via pnpm | 3 | # npm dependencies via pnpm | ||
| 4 | - | - package-ecosystem: "npm" | 4 | + | - package-ecosystem: 'npm' |
| 5 | - | directory: "/" | 5 | + | directory: '/' |
| 6 | schedule: | 6 | schedule: | ||
| 7 | - | interval: "weekly" | 7 | + | interval: 'weekly' |
| 8 | - | day: "monday" | 8 | + | day: 'monday' |
| 9 | - | time: "09:00" | 9 | + | time: '09:00' |
| 10 | - | timezone: "Europe/Paris" | 10 | + | timezone: 'Europe/Paris' |
| 11 | # Group updates to reduce PR noise | 11 | # Group updates to reduce PR noise | ||
| 12 | groups: | 12 | groups: | ||
| 13 | # Group SvelteKit and related core framework updates | 13 | # Group SvelteKit and related core framework updates | ||
| 14 | sveltekit: | 14 | sveltekit: | ||
| 15 | patterns: | 15 | patterns: | ||
| 16 | - | - "@sveltejs/*" | 16 | + | - '@sveltejs/*' |
| 17 | - | - "svelte*" | 17 | + | - 'svelte*' |
| 18 | update-types: | 18 | update-types: | ||
| 19 | - | - "minor" | 19 | + | - 'minor' |
| 20 | - | - "patch" | 20 | + | - 'patch' |
| 21 | # Group development tooling updates | 21 | # Group development tooling updates | ||
| 22 | dev-tools: | 22 | dev-tools: | ||
| 23 | patterns: | 23 | patterns: | ||
| 24 | - | - "eslint*" | 24 | + | - 'eslint*' |
| 25 | - | - "prettier*" | 25 | + | - 'prettier*' |
| 26 | - | - "@eslint/*" | 26 | + | - '@eslint/*' |
| 27 | - | - "typescript*" | 27 | + | - 'typescript*' |
| 28 | - | - "@types/*" | 28 | + | - '@types/*' |
| 29 | - | - "vite*" | 29 | + | - 'vite*' |
| 30 | - | - "vitest*" | 30 | + | - 'vitest*' |
| 31 | update-types: | 31 | update-types: | ||
| 32 | - | - "minor" | 32 | + | - 'minor' |
| 33 | - | - "patch" | 33 | + | - 'patch' |
| 34 | # Group Tailwind CSS related packages | 34 | # Group Tailwind CSS related packages | ||
| 35 | tailwind: | 35 | tailwind: | ||
| 36 | patterns: | 36 | patterns: | ||
| 37 | - | - "tailwindcss*" | 37 | + | - 'tailwindcss*' |
| 38 | - | - "@tailwindcss/*" | 38 | + | - '@tailwindcss/*' |
| 39 | - | - "autoprefixer" | 39 | + | - 'autoprefixer' |
| 40 | update-types: | 40 | update-types: | ||
| 41 | - | - "minor" | 41 | + | - 'minor' |
| 42 | - | - "patch" | 42 | + | - 'patch' |
| 43 | # Group Drizzle ORM related packages | 43 | # Group Drizzle ORM related packages | ||
| 44 | drizzle: | 44 | drizzle: | ||
| 45 | patterns: | 45 | patterns: | ||
| 46 | - | - "drizzle-*" | 46 | + | - 'drizzle-*' |
| 47 | - | - "@libsql/*" | 47 | + | - '@libsql/*' |
| 48 | update-types: | 48 | update-types: | ||
| 49 | - | - "minor" | 49 | + | - 'minor' |
| 50 | - | - "patch" | 50 | + | - 'patch' |
| 51 | # Set PR limits to avoid overwhelming the team | 51 | # Set PR limits to avoid overwhelming the team | ||
| 52 | open-pull-requests-limit: 8 | 52 | open-pull-requests-limit: 8 | ||
| 53 | # Ignore packages that require manual updates or are pinned for specific reasons | 53 | # Ignore packages that require manual updates or are pinned for specific reasons | ||
| 54 | ignore: | 54 | ignore: | ||
| 55 | # Node.js version is managed via engines field and should be manually updated | 55 | # Node.js version is managed via engines field and should be manually updated | ||
| 56 | - | - dependency-name: "node" | 56 | + | - dependency-name: 'node' |
| 57 | # Major framework versions should be manually reviewed due to breaking changes | 57 | # Major framework versions should be manually reviewed due to breaking changes | ||
| 58 | - | - dependency-name: "@sveltejs/kit" | 58 | + | - dependency-name: '@sveltejs/kit' |
| 59 | - | update-types: ["version-update:semver-major"] | 59 | + | update-types: ['version-update:semver-major'] |
| 60 | - | - dependency-name: "svelte" | 60 | + | - dependency-name: 'svelte' |
| 61 | - | update-types: ["version-update:semver-major"] | 61 | + | update-types: ['version-update:semver-major'] |
| 62 | - | - dependency-name: "typescript" | 62 | + | - dependency-name: 'typescript' |
| 63 | - | update-types: ["version-update:semver-major"] | 63 | + | update-types: ['version-update:semver-major'] |
| 64 | # Configure commit message format | 64 | # Configure commit message format | ||
| 65 | commit-message: | 65 | commit-message: | ||
| 66 | - | prefix: "deps" | 66 | + | prefix: 'deps' |
| 67 | - | include: "scope"⏎̸ | 67 | + | include: 'scope' |
A .github/workflows/ci.yml +31 -0
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | name: CI | |||
| 2 | + | ||||
| 3 | + | on: | |||
| 4 | + | push: | |||
| 5 | + | branches: [main] | |||
| 6 | + | pull_request: | |||
| 7 | + | branches: [main] | |||
| 8 | + | ||||
| 9 | + | env: | |||
| 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' | |||
| 16 | + | ||||
| 17 | + | jobs: | |||
| 18 | + | check: | |||
| 19 | + | runs-on: ubuntu-latest | |||
| 20 | + | steps: | |||
| 21 | + | - uses: actions/checkout@v5 | |||
| 22 | + | - uses: pnpm/action-setup@v4 | |||
| 23 | + | # version comes from packageManager field in package.json | |||
| 24 | + | - uses: actions/setup-node@v4 | |||
| 25 | + | with: | |||
| 26 | + | node-version: '22.13.0' | |||
| 27 | + | cache: 'pnpm' | |||
| 28 | + | - run: pnpm install --frozen-lockfile | |||
| 29 | + | - run: pnpm run check | |||
| 30 | + | - run: pnpm run lint | |||
| 31 | + | - run: pnpm run build | |||
A .github/workflows/dependabot-auto-merge.yml +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | name: Dependabot Auto-merge | |||
| 2 | + | ||||
| 3 | + | on: | |||
| 4 | + | pull_request: | |||
| 5 | + | ||||
| 6 | + | permissions: | |||
| 7 | + | contents: write | |||
| 8 | + | pull-requests: write | |||
| 9 | + | checks: read | |||
| 10 | + | ||||
| 11 | + | jobs: | |||
| 12 | + | dependabot: | |||
| 13 | + | runs-on: ubuntu-latest | |||
| 14 | + | if: github.actor == 'dependabot[bot]' | |||
| 15 | + | steps: | |||
| 16 | + | - name: Dependabot metadata | |||
| 17 | + | id: metadata | |||
| 18 | + | uses: dependabot/fetch-metadata@v2 | |||
| 19 | + | with: | |||
| 20 | + | github-token: '${{ secrets.GITHUB_TOKEN }}' | |||
| 21 | + | - name: Wait for CI to pass | |||
| 22 | + | if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' | |||
| 23 | + | run: gh pr checks "$PR_URL" --watch --fail-level all | |||
| 24 | + | env: | |||
| 25 | + | PR_URL: ${{ github.event.pull_request.html_url }} | |||
| 26 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||
| 27 | + | - name: Enable auto-merge for minor/patch | |||
| 28 | + | if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' | |||
| 29 | + | run: gh pr merge --auto --squash "$PR_URL" | |||
| 30 | + | env: | |||
| 31 | + | PR_URL: ${{ github.event.pull_request.html_url }} | |||
| 32 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||
M .node-version +1 -1
| @@ -1,1 +1,1 @@ | |||||
| 1 | - | 20.11.0⏎̸ | 1 | + | 22.13.0⏎̸ |
M README.md +10 -3
| @@ -1,20 +1,22 @@ | |||||
| 1 | # Bienvenue | 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 | ## Technologies utilisées | 5 | ## Technologies utilisées | ||
| 6 | + | ||||
| 6 | - SvelteKit (version 5) | 7 | - SvelteKit (version 5) | ||
| 7 | - Node.js | 8 | - Node.js | ||
| 8 | - TypeScript | 9 | - TypeScript | ||
| 9 | - XP.css | 10 | - XP.css | ||
| 10 | - Tailwind CSS | 11 | - Tailwind CSS | ||
| 11 | 12 | ||||
| 12 | - | ||||
| 13 | ## Prérequis | 13 | ## Prérequis | ||
| 14 | + | ||||
| 14 | - Node.js (>=22.13.0) | 15 | - Node.js (>=22.13.0) | ||
| 15 | - npm ou pnpm pour la gestion des packages | 16 | - npm ou pnpm pour la gestion des packages | ||
| 16 | 17 | ||||
| 17 | ## Installation | 18 | ## Installation | ||
| 19 | + | ||||
| 18 | Clonez le dépôt et installez les dépendances : | 20 | Clonez le dépôt et installez les dépendances : | ||
| 19 | 21 | ||||
| 20 | ```bash | 22 | ```bash | ||
| @@ -24,6 +26,7 @@ pnpm install | |||||
| 24 | ``` | 26 | ``` | ||
| 25 | 27 | ||||
| 26 | ## Lancement du projet | 28 | ## Lancement du projet | ||
| 29 | + | ||||
| 27 | Lancez le projet en mode développement : | 30 | Lancez le projet en mode développement : | ||
| 28 | 31 | ||||
| 29 | ```bash | 32 | ```bash | ||
| @@ -35,8 +38,10 @@ pnpm dev | |||||
| 35 | Le site sera accessible via http://localhost:5173 par défaut. | 38 | Le site sera accessible via http://localhost:5173 par défaut. | ||
| 36 | 39 | ||||
| 37 | ## Contribution | 40 | ## Contribution | ||
| 41 | + | ||||
| 38 | Toute contribution visant à améliorer le réalisme et la qualité du projet est la bienvenue ! | 42 | Toute contribution visant à améliorer le réalisme et la qualité du projet est la bienvenue ! | ||
| 39 | Pour contribuer : | 43 | Pour contribuer : | ||
| 44 | + | ||||
| 40 | - Forkez ce dépôt | 45 | - Forkez ce dépôt | ||
| 41 | - Créez une branche pour vos modifications | 46 | - Créez une branche pour vos modifications | ||
| 42 | - Envoyez une pull request en décrivant clairement vos changements | 47 | - Envoyez une pull request en décrivant clairement vos changements | ||
| @@ -60,6 +65,7 @@ npm run db:push | |||||
| 60 | # ou | 65 | # ou | ||
| 61 | pnpm db:push | 66 | pnpm db:push | ||
| 62 | ``` | 67 | ``` | ||
| 68 | + | ||||
| 63 | Vous pouvez avoir un aperçu du contenu de la base en utilisant : | 69 | Vous pouvez avoir un aperçu du contenu de la base en utilisant : | ||
| 64 | 70 | ||||
| 65 | ```bash | 71 | ```bash | ||
| @@ -67,7 +73,8 @@ npx drizzle-kit studio | |||||
| 67 | ``` | 73 | ``` | ||
| 68 | 74 | ||||
| 69 | ## Documentation et Ressources | 75 | ## Documentation et Ressources | ||
| 76 | + | ||||
| 70 | Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles : | 77 | Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles : | ||
| 78 | + | ||||
| 71 | - [Documentation de Svelte](https://svelte.dev/docs/) | 79 | - [Documentation de Svelte](https://svelte.dev/docs/) | ||
| 72 | - [Documentation de SvelteKit](https://svelte.dev/docs/kit/@sveltejs-kit) | 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 | import { defineConfig } from 'drizzle-kit'; | 2 | import { defineConfig } from 'drizzle-kit'; | ||
| 3 | 3 | ||||
| 4 | export default defineConfig({ | 4 | export default defineConfig({ | ||
| 5 | - | out: './drizzle', | 5 | + | out: './drizzle', |
| 6 | - | schema: './src/lib/db/schema.ts', | 6 | + | schema: './src/lib/db/schema.ts', |
| 7 | - | dialect: 'turso', | 7 | + | dialect: 'turso', |
| 8 | - | dbCredentials: { | 8 | + | dbCredentials: { |
| 9 | - | url: process.env.TURSO_DATABASE_URL as string, | 9 | + | url: process.env.TURSO_DATABASE_URL as string, |
| 10 | - | authToken: process.env.TURSO_AUTH_TOKEN as string, | 10 | + | authToken: process.env.TURSO_AUTH_TOKEN as string |
| 11 | - | }, | 11 | + | } |
| 12 | - | });⏎̸ | 12 | + | }); |
M drizzle/meta/0000_snapshot.json +331 -341
| @@ -1,342 +1,332 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | - | "version": "6", | 2 | + | "version": "6", |
| 3 | - | "dialect": "sqlite", | 3 | + | "dialect": "sqlite", |
| 4 | - | "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | 4 | + | "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0", |
| 5 | - | "prevId": "00000000-0000-0000-0000-000000000000", | 5 | + | "prevId": "00000000-0000-0000-0000-000000000000", |
| 6 | - | "tables": { | 6 | + | "tables": { |
| 7 | - | "chat_rooms": { | 7 | + | "chat_rooms": { |
| 8 | - | "name": "chat_rooms", | 8 | + | "name": "chat_rooms", |
| 9 | - | "columns": { | 9 | + | "columns": { |
| 10 | - | "id": { | 10 | + | "id": { |
| 11 | - | "name": "id", | 11 | + | "name": "id", |
| 12 | - | "type": "text", | 12 | + | "type": "text", |
| 13 | - | "primaryKey": true, | 13 | + | "primaryKey": true, |
| 14 | - | "notNull": true, | 14 | + | "notNull": true, |
| 15 | - | "autoincrement": false | 15 | + | "autoincrement": false |
| 16 | - | }, | 16 | + | }, |
| 17 | - | "name": { | 17 | + | "name": { |
| 18 | - | "name": "name", | 18 | + | "name": "name", |
| 19 | - | "type": "text", | 19 | + | "type": "text", |
| 20 | - | "primaryKey": false, | 20 | + | "primaryKey": false, |
| 21 | - | "notNull": false, | 21 | + | "notNull": false, |
| 22 | - | "autoincrement": false | 22 | + | "autoincrement": false |
| 23 | - | }, | 23 | + | }, |
| 24 | - | "type": { | 24 | + | "type": { |
| 25 | - | "name": "type", | 25 | + | "name": "type", |
| 26 | - | "type": "text", | 26 | + | "type": "text", |
| 27 | - | "primaryKey": false, | 27 | + | "primaryKey": false, |
| 28 | - | "notNull": true, | 28 | + | "notNull": true, |
| 29 | - | "autoincrement": false, | 29 | + | "autoincrement": false, |
| 30 | - | "default": "'direct'" | 30 | + | "default": "'direct'" |
| 31 | - | }, | 31 | + | }, |
| 32 | - | "created_at": { | 32 | + | "created_at": { |
| 33 | - | "name": "created_at", | 33 | + | "name": "created_at", |
| 34 | - | "type": "integer", | 34 | + | "type": "integer", |
| 35 | - | "primaryKey": false, | 35 | + | "primaryKey": false, |
| 36 | - | "notNull": true, | 36 | + | "notNull": true, |
| 37 | - | "autoincrement": false | 37 | + | "autoincrement": false |
| 38 | - | } | 38 | + | } |
| 39 | - | }, | 39 | + | }, |
| 40 | - | "indexes": {}, | 40 | + | "indexes": {}, |
| 41 | - | "foreignKeys": {}, | 41 | + | "foreignKeys": {}, |
| 42 | - | "compositePrimaryKeys": {}, | 42 | + | "compositePrimaryKeys": {}, |
| 43 | - | "uniqueConstraints": {}, | 43 | + | "uniqueConstraints": {}, |
| 44 | - | "checkConstraints": {} | 44 | + | "checkConstraints": {} |
| 45 | - | }, | 45 | + | }, |
| 46 | - | "messages": { | 46 | + | "messages": { |
| 47 | - | "name": "messages", | 47 | + | "name": "messages", |
| 48 | - | "columns": { | 48 | + | "columns": { |
| 49 | - | "id": { | 49 | + | "id": { |
| 50 | - | "name": "id", | 50 | + | "name": "id", |
| 51 | - | "type": "text", | 51 | + | "type": "text", |
| 52 | - | "primaryKey": true, | 52 | + | "primaryKey": true, |
| 53 | - | "notNull": true, | 53 | + | "notNull": true, |
| 54 | - | "autoincrement": false | 54 | + | "autoincrement": false |
| 55 | - | }, | 55 | + | }, |
| 56 | - | "chat_room_id": { | 56 | + | "chat_room_id": { |
| 57 | - | "name": "chat_room_id", | 57 | + | "name": "chat_room_id", |
| 58 | - | "type": "text", | 58 | + | "type": "text", |
| 59 | - | "primaryKey": false, | 59 | + | "primaryKey": false, |
| 60 | - | "notNull": true, | 60 | + | "notNull": true, |
| 61 | - | "autoincrement": false | 61 | + | "autoincrement": false |
| 62 | - | }, | 62 | + | }, |
| 63 | - | "sender_id": { | 63 | + | "sender_id": { |
| 64 | - | "name": "sender_id", | 64 | + | "name": "sender_id", |
| 65 | - | "type": "text", | 65 | + | "type": "text", |
| 66 | - | "primaryKey": false, | 66 | + | "primaryKey": false, |
| 67 | - | "notNull": true, | 67 | + | "notNull": true, |
| 68 | - | "autoincrement": false | 68 | + | "autoincrement": false |
| 69 | - | }, | 69 | + | }, |
| 70 | - | "content": { | 70 | + | "content": { |
| 71 | - | "name": "content", | 71 | + | "name": "content", |
| 72 | - | "type": "text", | 72 | + | "type": "text", |
| 73 | - | "primaryKey": false, | 73 | + | "primaryKey": false, |
| 74 | - | "notNull": true, | 74 | + | "notNull": true, |
| 75 | - | "autoincrement": false | 75 | + | "autoincrement": false |
| 76 | - | }, | 76 | + | }, |
| 77 | - | "type": { | 77 | + | "type": { |
| 78 | - | "name": "type", | 78 | + | "name": "type", |
| 79 | - | "type": "text", | 79 | + | "type": "text", |
| 80 | - | "primaryKey": false, | 80 | + | "primaryKey": false, |
| 81 | - | "notNull": true, | 81 | + | "notNull": true, |
| 82 | - | "autoincrement": false, | 82 | + | "autoincrement": false, |
| 83 | - | "default": "'chat'" | 83 | + | "default": "'chat'" |
| 84 | - | }, | 84 | + | }, |
| 85 | - | "timestamp": { | 85 | + | "timestamp": { |
| 86 | - | "name": "timestamp", | 86 | + | "name": "timestamp", |
| 87 | - | "type": "integer", | 87 | + | "type": "integer", |
| 88 | - | "primaryKey": false, | 88 | + | "primaryKey": false, |
| 89 | - | "notNull": true, | 89 | + | "notNull": true, |
| 90 | - | "autoincrement": false | 90 | + | "autoincrement": false |
| 91 | - | } | 91 | + | } |
| 92 | - | }, | 92 | + | }, |
| 93 | - | "indexes": {}, | 93 | + | "indexes": {}, |
| 94 | - | "foreignKeys": { | 94 | + | "foreignKeys": { |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | 95 | + | "messages_chat_room_id_chat_rooms_id_fk": { |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | 96 | + | "name": "messages_chat_room_id_chat_rooms_id_fk", |
| 97 | - | "tableFrom": "messages", | 97 | + | "tableFrom": "messages", |
| 98 | - | "tableTo": "chat_rooms", | 98 | + | "tableTo": "chat_rooms", |
| 99 | - | "columnsFrom": [ | 99 | + | "columnsFrom": ["chat_room_id"], |
| 100 | - | "chat_room_id" | 100 | + | "columnsTo": ["id"], |
| 101 | - | ], | 101 | + | "onDelete": "no action", |
| 102 | - | "columnsTo": [ | 102 | + | "onUpdate": "no action" |
| 103 | - | "id" | 103 | + | }, |
| 104 | - | ], | 104 | + | "messages_sender_id_users_id_fk": { |
| 105 | - | "onDelete": "no action", | 105 | + | "name": "messages_sender_id_users_id_fk", |
| 106 | - | "onUpdate": "no action" | 106 | + | "tableFrom": "messages", |
| 107 | - | }, | 107 | + | "tableTo": "users", |
| 108 | - | "messages_sender_id_users_id_fk": { | 108 | + | "columnsFrom": ["sender_id"], |
| 109 | - | "name": "messages_sender_id_users_id_fk", | 109 | + | "columnsTo": ["id"], |
| 110 | - | "tableFrom": "messages", | 110 | + | "onDelete": "no action", |
| 111 | - | "tableTo": "users", | 111 | + | "onUpdate": "no action" |
| 112 | - | "columnsFrom": [ | 112 | + | } |
| 113 | - | "sender_id" | 113 | + | }, |
| 114 | - | ], | 114 | + | "compositePrimaryKeys": {}, |
| 115 | - | "columnsTo": [ | 115 | + | "uniqueConstraints": {}, |
| 116 | - | "id" | 116 | + | "checkConstraints": {} |
| 117 | - | ], | 117 | + | }, |
| 118 | - | "onDelete": "no action", | 118 | + | "users": { |
| 119 | - | "onUpdate": "no action" | 119 | + | "name": "users", |
| 120 | - | } | 120 | + | "columns": { |
| 121 | - | }, | 121 | + | "id": { |
| 122 | - | "compositePrimaryKeys": {}, | 122 | + | "name": "id", |
| 123 | - | "uniqueConstraints": {}, | 123 | + | "type": "text", |
| 124 | - | "checkConstraints": {} | 124 | + | "primaryKey": true, |
| 125 | - | }, | 125 | + | "notNull": true, |
| 126 | - | "users": { | 126 | + | "autoincrement": false |
| 127 | - | "name": "users", | 127 | + | }, |
| 128 | - | "columns": { | 128 | + | "email": { |
| 129 | - | "id": { | 129 | + | "name": "email", |
| 130 | - | "name": "id", | 130 | + | "type": "text", |
| 131 | - | "type": "text", | 131 | + | "primaryKey": false, |
| 132 | - | "primaryKey": true, | 132 | + | "notNull": true, |
| 133 | - | "notNull": true, | 133 | + | "autoincrement": false |
| 134 | - | "autoincrement": false | 134 | + | }, |
| 135 | - | }, | 135 | + | "password": { |
| 136 | - | "email": { | 136 | + | "name": "password", |
| 137 | - | "name": "email", | 137 | + | "type": "text", |
| 138 | - | "type": "text", | 138 | + | "primaryKey": false, |
| 139 | - | "primaryKey": false, | 139 | + | "notNull": true, |
| 140 | - | "notNull": true, | 140 | + | "autoincrement": false |
| 141 | - | "autoincrement": false | 141 | + | }, |
| 142 | - | }, | 142 | + | "nickname": { |
| 143 | - | "password": { | 143 | + | "name": "nickname", |
| 144 | - | "name": "password", | 144 | + | "type": "text", |
| 145 | - | "type": "text", | 145 | + | "primaryKey": false, |
| 146 | - | "primaryKey": false, | 146 | + | "notNull": true, |
| 147 | - | "notNull": true, | 147 | + | "autoincrement": false |
| 148 | - | "autoincrement": false | 148 | + | }, |
| 149 | - | }, | 149 | + | "status": { |
| 150 | - | "nickname": { | 150 | + | "name": "status", |
| 151 | - | "name": "nickname", | 151 | + | "type": "text", |
| 152 | - | "type": "text", | 152 | + | "primaryKey": false, |
| 153 | - | "primaryKey": false, | 153 | + | "notNull": false, |
| 154 | - | "notNull": true, | 154 | + | "autoincrement": false, |
| 155 | - | "autoincrement": false | 155 | + | "default": "'offline'" |
| 156 | - | }, | 156 | + | }, |
| 157 | - | "status": { | 157 | + | "avatar_url": { |
| 158 | - | "name": "status", | 158 | + | "name": "avatar_url", |
| 159 | - | "type": "text", | 159 | + | "type": "text", |
| 160 | - | "primaryKey": false, | 160 | + | "primaryKey": false, |
| 161 | - | "notNull": false, | 161 | + | "notNull": false, |
| 162 | - | "autoincrement": false, | 162 | + | "autoincrement": false |
| 163 | - | "default": "'offline'" | 163 | + | }, |
| 164 | - | }, | 164 | + | "created_at": { |
| 165 | - | "avatar_url": { | 165 | + | "name": "created_at", |
| 166 | - | "name": "avatar_url", | 166 | + | "type": "integer", |
| 167 | - | "type": "text", | 167 | + | "primaryKey": false, |
| 168 | - | "primaryKey": false, | 168 | + | "notNull": true, |
| 169 | - | "notNull": false, | 169 | + | "autoincrement": false |
| 170 | - | "autoincrement": false | 170 | + | }, |
| 171 | - | }, | 171 | + | "last_seen": { |
| 172 | - | "created_at": { | 172 | + | "name": "last_seen", |
| 173 | - | "name": "created_at", | 173 | + | "type": "integer", |
| 174 | - | "type": "integer", | 174 | + | "primaryKey": false, |
| 175 | - | "primaryKey": false, | 175 | + | "notNull": false, |
| 176 | - | "notNull": true, | 176 | + | "autoincrement": false |
| 177 | - | "autoincrement": false | 177 | + | } |
| 178 | - | }, | 178 | + | }, |
| 179 | - | "last_seen": { | 179 | + | "indexes": { |
| 180 | - | "name": "last_seen", | 180 | + | "users_email_unique": { |
| 181 | - | "type": "integer", | 181 | + | "name": "users_email_unique", |
| 182 | - | "primaryKey": false, | 182 | + | "columns": ["email"], |
| 183 | - | "notNull": false, | 183 | + | "isUnique": true |
| 184 | - | "autoincrement": false | 184 | + | } |
| 185 | - | } | 185 | + | }, |
| 186 | - | }, | 186 | + | "foreignKeys": {}, |
| 187 | - | "indexes": { | 187 | + | "compositePrimaryKeys": {}, |
| 188 | - | "users_email_unique": { | 188 | + | "uniqueConstraints": {}, |
| 189 | - | "name": "users_email_unique", | 189 | + | "checkConstraints": {} |
| 190 | - | "columns": [ | 190 | + | } |
| 191 | - | "email" | 191 | + | }, |
| 192 | - | ], | 192 | + | "views": { |
| 193 | - | "isUnique": true | 193 | + | "chat_room_view": { |
| 194 | - | } | 194 | + | "columns": { |
| 195 | - | }, | 195 | + | "id": { |
| 196 | - | "foreignKeys": {}, | 196 | + | "name": "id", |
| 197 | - | "compositePrimaryKeys": {}, | 197 | + | "type": "text", |
| 198 | - | "uniqueConstraints": {}, | 198 | + | "primaryKey": true, |
| 199 | - | "checkConstraints": {} | 199 | + | "notNull": true, |
| 200 | - | } | 200 | + | "autoincrement": false |
| 201 | - | }, | 201 | + | }, |
| 202 | - | "views": { | 202 | + | "name": { |
| 203 | - | "chat_room_view": { | 203 | + | "name": "name", |
| 204 | - | "columns": { | 204 | + | "type": "text", |
| 205 | - | "id": { | 205 | + | "primaryKey": false, |
| 206 | - | "name": "id", | 206 | + | "notNull": false, |
| 207 | - | "type": "text", | 207 | + | "autoincrement": false |
| 208 | - | "primaryKey": true, | 208 | + | }, |
| 209 | - | "notNull": true, | 209 | + | "type": { |
| 210 | - | "autoincrement": false | 210 | + | "name": "type", |
| 211 | - | }, | 211 | + | "type": "text", |
| 212 | - | "name": { | 212 | + | "primaryKey": false, |
| 213 | - | "name": "name", | 213 | + | "notNull": true, |
| 214 | - | "type": "text", | 214 | + | "autoincrement": false, |
| 215 | - | "primaryKey": false, | 215 | + | "default": "'direct'" |
| 216 | - | "notNull": false, | 216 | + | }, |
| 217 | - | "autoincrement": false | 217 | + | "created_at": { |
| 218 | - | }, | 218 | + | "name": "created_at", |
| 219 | - | "type": { | 219 | + | "type": "integer", |
| 220 | - | "name": "type", | 220 | + | "primaryKey": false, |
| 221 | - | "type": "text", | 221 | + | "notNull": true, |
| 222 | - | "primaryKey": false, | 222 | + | "autoincrement": false |
| 223 | - | "notNull": true, | 223 | + | } |
| 224 | - | "autoincrement": false, | 224 | + | }, |
| 225 | - | "default": "'direct'" | 225 | + | "name": "chat_room_view", |
| 226 | - | }, | 226 | + | "isExisting": false, |
| 227 | - | "created_at": { | 227 | + | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" |
| 228 | - | "name": "created_at", | 228 | + | }, |
| 229 | - | "type": "integer", | 229 | + | "message_view": { |
| 230 | - | "primaryKey": false, | 230 | + | "columns": { |
| 231 | - | "notNull": true, | 231 | + | "id": { |
| 232 | - | "autoincrement": false | 232 | + | "name": "id", |
| 233 | - | } | 233 | + | "type": "text", |
| 234 | - | }, | 234 | + | "primaryKey": true, |
| 235 | - | "name": "chat_room_view", | 235 | + | "notNull": true, |
| 236 | - | "isExisting": false, | 236 | + | "autoincrement": false |
| 237 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | 237 | + | }, |
| 238 | - | }, | 238 | + | "content": { |
| 239 | - | "message_view": { | 239 | + | "name": "content", |
| 240 | - | "columns": { | 240 | + | "type": "text", |
| 241 | - | "id": { | 241 | + | "primaryKey": false, |
| 242 | - | "name": "id", | 242 | + | "notNull": true, |
| 243 | - | "type": "text", | 243 | + | "autoincrement": false |
| 244 | - | "primaryKey": true, | 244 | + | }, |
| 245 | - | "notNull": true, | 245 | + | "type": { |
| 246 | - | "autoincrement": false | 246 | + | "name": "type", |
| 247 | - | }, | 247 | + | "type": "text", |
| 248 | - | "content": { | 248 | + | "primaryKey": false, |
| 249 | - | "name": "content", | 249 | + | "notNull": true, |
| 250 | - | "type": "text", | 250 | + | "autoincrement": false, |
| 251 | - | "primaryKey": false, | 251 | + | "default": "'chat'" |
| 252 | - | "notNull": true, | 252 | + | }, |
| 253 | - | "autoincrement": false | 253 | + | "timestamp": { |
| 254 | - | }, | 254 | + | "name": "timestamp", |
| 255 | - | "type": { | 255 | + | "type": "integer", |
| 256 | - | "name": "type", | 256 | + | "primaryKey": false, |
| 257 | - | "type": "text", | 257 | + | "notNull": true, |
| 258 | - | "primaryKey": false, | 258 | + | "autoincrement": false |
| 259 | - | "notNull": true, | 259 | + | }, |
| 260 | - | "autoincrement": false, | 260 | + | "nickname": { |
| 261 | - | "default": "'chat'" | 261 | + | "name": "nickname", |
| 262 | - | }, | 262 | + | "type": "text", |
| 263 | - | "timestamp": { | 263 | + | "primaryKey": false, |
| 264 | - | "name": "timestamp", | 264 | + | "notNull": true, |
| 265 | - | "type": "integer", | 265 | + | "autoincrement": false |
| 266 | - | "primaryKey": false, | 266 | + | }, |
| 267 | - | "notNull": true, | 267 | + | "name": { |
| 268 | - | "autoincrement": false | 268 | + | "name": "name", |
| 269 | - | }, | 269 | + | "type": "text", |
| 270 | - | "nickname": { | 270 | + | "primaryKey": false, |
| 271 | - | "name": "nickname", | 271 | + | "notNull": false, |
| 272 | - | "type": "text", | 272 | + | "autoincrement": false |
| 273 | - | "primaryKey": false, | 273 | + | } |
| 274 | - | "notNull": true, | 274 | + | }, |
| 275 | - | "autoincrement": false | 275 | + | "name": "message_view", |
| 276 | - | }, | 276 | + | "isExisting": false, |
| 277 | - | "name": { | 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 | - | "name": "name", | 278 | + | }, |
| 279 | - | "type": "text", | 279 | + | "user_view": { |
| 280 | - | "primaryKey": false, | 280 | + | "columns": { |
| 281 | - | "notNull": false, | 281 | + | "id": { |
| 282 | - | "autoincrement": false | 282 | + | "name": "id", |
| 283 | - | } | 283 | + | "type": "text", |
| 284 | - | }, | 284 | + | "primaryKey": true, |
| 285 | - | "name": "message_view", | 285 | + | "notNull": true, |
| 286 | - | "isExisting": false, | 286 | + | "autoincrement": 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\"" | 287 | + | }, |
| 288 | - | }, | 288 | + | "nickname": { |
| 289 | - | "user_view": { | 289 | + | "name": "nickname", |
| 290 | - | "columns": { | 290 | + | "type": "text", |
| 291 | - | "id": { | 291 | + | "primaryKey": false, |
| 292 | - | "name": "id", | 292 | + | "notNull": true, |
| 293 | - | "type": "text", | 293 | + | "autoincrement": false |
| 294 | - | "primaryKey": true, | 294 | + | }, |
| 295 | - | "notNull": true, | 295 | + | "status": { |
| 296 | - | "autoincrement": false | 296 | + | "name": "status", |
| 297 | - | }, | 297 | + | "type": "text", |
| 298 | - | "nickname": { | 298 | + | "primaryKey": false, |
| 299 | - | "name": "nickname", | 299 | + | "notNull": false, |
| 300 | - | "type": "text", | 300 | + | "autoincrement": false, |
| 301 | - | "primaryKey": false, | 301 | + | "default": "'offline'" |
| 302 | - | "notNull": true, | 302 | + | }, |
| 303 | - | "autoincrement": false | 303 | + | "avatar_url": { |
| 304 | - | }, | 304 | + | "name": "avatar_url", |
| 305 | - | "status": { | 305 | + | "type": "text", |
| 306 | - | "name": "status", | 306 | + | "primaryKey": false, |
| 307 | - | "type": "text", | 307 | + | "notNull": false, |
| 308 | - | "primaryKey": false, | 308 | + | "autoincrement": false |
| 309 | - | "notNull": false, | 309 | + | }, |
| 310 | - | "autoincrement": false, | 310 | + | "last_seen": { |
| 311 | - | "default": "'offline'" | 311 | + | "name": "last_seen", |
| 312 | - | }, | 312 | + | "type": "integer", |
| 313 | - | "avatar_url": { | 313 | + | "primaryKey": false, |
| 314 | - | "name": "avatar_url", | 314 | + | "notNull": false, |
| 315 | - | "type": "text", | 315 | + | "autoincrement": false |
| 316 | - | "primaryKey": false, | 316 | + | } |
| 317 | - | "notNull": false, | 317 | + | }, |
| 318 | - | "autoincrement": false | 318 | + | "name": "user_view", |
| 319 | - | }, | 319 | + | "isExisting": false, |
| 320 | - | "last_seen": { | 320 | + | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" |
| 321 | - | "name": "last_seen", | 321 | + | } |
| 322 | - | "type": "integer", | 322 | + | }, |
| 323 | - | "primaryKey": false, | 323 | + | "enums": {}, |
| 324 | - | "notNull": false, | 324 | + | "_meta": { |
| 325 | - | "autoincrement": false | 325 | + | "schemas": {}, |
| 326 | - | } | 326 | + | "tables": {}, |
| 327 | - | }, | 327 | + | "columns": {} |
| 328 | - | "name": "user_view", | 328 | + | }, |
| 329 | - | "isExisting": false, | 329 | + | "internal": { |
| 330 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | 330 | + | "indexes": {} |
| 331 | - | } | 331 | + | } |
| 332 | - | }, | 332 | + | } |
| 333 | - | "enums": {}, | |||
| 334 | - | "_meta": { | |||
| 335 | - | "schemas": {}, | |||
| 336 | - | "tables": {}, | |||
| 337 | - | "columns": {} | |||
| 338 | - | }, | |||
| 339 | - | "internal": { | |||
| 340 | - | "indexes": {} | |||
| 341 | - | } | |||
| 342 | - | }⏎̸ | |||
M drizzle/meta/0001_snapshot.json +366 -378
| @@ -1,379 +1,367 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | - | "version": "6", | 2 | + | "version": "6", |
| 3 | - | "dialect": "sqlite", | 3 | + | "dialect": "sqlite", |
| 4 | - | "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6", | 4 | + | "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6", |
| 5 | - | "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | 5 | + | "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0", |
| 6 | - | "tables": { | 6 | + | "tables": { |
| 7 | - | "chat_rooms": { | 7 | + | "chat_rooms": { |
| 8 | - | "name": "chat_rooms", | 8 | + | "name": "chat_rooms", |
| 9 | - | "columns": { | 9 | + | "columns": { |
| 10 | - | "id": { | 10 | + | "id": { |
| 11 | - | "name": "id", | 11 | + | "name": "id", |
| 12 | - | "type": "text", | 12 | + | "type": "text", |
| 13 | - | "primaryKey": true, | 13 | + | "primaryKey": true, |
| 14 | - | "notNull": true, | 14 | + | "notNull": true, |
| 15 | - | "autoincrement": false | 15 | + | "autoincrement": false |
| 16 | - | }, | 16 | + | }, |
| 17 | - | "name": { | 17 | + | "name": { |
| 18 | - | "name": "name", | 18 | + | "name": "name", |
| 19 | - | "type": "text", | 19 | + | "type": "text", |
| 20 | - | "primaryKey": false, | 20 | + | "primaryKey": false, |
| 21 | - | "notNull": false, | 21 | + | "notNull": false, |
| 22 | - | "autoincrement": false | 22 | + | "autoincrement": false |
| 23 | - | }, | 23 | + | }, |
| 24 | - | "type": { | 24 | + | "type": { |
| 25 | - | "name": "type", | 25 | + | "name": "type", |
| 26 | - | "type": "text", | 26 | + | "type": "text", |
| 27 | - | "primaryKey": false, | 27 | + | "primaryKey": false, |
| 28 | - | "notNull": true, | 28 | + | "notNull": true, |
| 29 | - | "autoincrement": false, | 29 | + | "autoincrement": false, |
| 30 | - | "default": "'direct'" | 30 | + | "default": "'direct'" |
| 31 | - | }, | 31 | + | }, |
| 32 | - | "created_at": { | 32 | + | "created_at": { |
| 33 | - | "name": "created_at", | 33 | + | "name": "created_at", |
| 34 | - | "type": "integer", | 34 | + | "type": "integer", |
| 35 | - | "primaryKey": false, | 35 | + | "primaryKey": false, |
| 36 | - | "notNull": true, | 36 | + | "notNull": true, |
| 37 | - | "autoincrement": false | 37 | + | "autoincrement": false |
| 38 | - | } | 38 | + | } |
| 39 | - | }, | 39 | + | }, |
| 40 | - | "indexes": {}, | 40 | + | "indexes": {}, |
| 41 | - | "foreignKeys": {}, | 41 | + | "foreignKeys": {}, |
| 42 | - | "compositePrimaryKeys": {}, | 42 | + | "compositePrimaryKeys": {}, |
| 43 | - | "uniqueConstraints": {}, | 43 | + | "uniqueConstraints": {}, |
| 44 | - | "checkConstraints": {} | 44 | + | "checkConstraints": {} |
| 45 | - | }, | 45 | + | }, |
| 46 | - | "messages": { | 46 | + | "messages": { |
| 47 | - | "name": "messages", | 47 | + | "name": "messages", |
| 48 | - | "columns": { | 48 | + | "columns": { |
| 49 | - | "id": { | 49 | + | "id": { |
| 50 | - | "name": "id", | 50 | + | "name": "id", |
| 51 | - | "type": "text", | 51 | + | "type": "text", |
| 52 | - | "primaryKey": true, | 52 | + | "primaryKey": true, |
| 53 | - | "notNull": true, | 53 | + | "notNull": true, |
| 54 | - | "autoincrement": false | 54 | + | "autoincrement": false |
| 55 | - | }, | 55 | + | }, |
| 56 | - | "chat_room_id": { | 56 | + | "chat_room_id": { |
| 57 | - | "name": "chat_room_id", | 57 | + | "name": "chat_room_id", |
| 58 | - | "type": "text", | 58 | + | "type": "text", |
| 59 | - | "primaryKey": false, | 59 | + | "primaryKey": false, |
| 60 | - | "notNull": true, | 60 | + | "notNull": true, |
| 61 | - | "autoincrement": false | 61 | + | "autoincrement": false |
| 62 | - | }, | 62 | + | }, |
| 63 | - | "sender_id": { | 63 | + | "sender_id": { |
| 64 | - | "name": "sender_id", | 64 | + | "name": "sender_id", |
| 65 | - | "type": "text", | 65 | + | "type": "text", |
| 66 | - | "primaryKey": false, | 66 | + | "primaryKey": false, |
| 67 | - | "notNull": true, | 67 | + | "notNull": true, |
| 68 | - | "autoincrement": false | 68 | + | "autoincrement": false |
| 69 | - | }, | 69 | + | }, |
| 70 | - | "content": { | 70 | + | "content": { |
| 71 | - | "name": "content", | 71 | + | "name": "content", |
| 72 | - | "type": "text", | 72 | + | "type": "text", |
| 73 | - | "primaryKey": false, | 73 | + | "primaryKey": false, |
| 74 | - | "notNull": true, | 74 | + | "notNull": true, |
| 75 | - | "autoincrement": false | 75 | + | "autoincrement": false |
| 76 | - | }, | 76 | + | }, |
| 77 | - | "type": { | 77 | + | "type": { |
| 78 | - | "name": "type", | 78 | + | "name": "type", |
| 79 | - | "type": "text", | 79 | + | "type": "text", |
| 80 | - | "primaryKey": false, | 80 | + | "primaryKey": false, |
| 81 | - | "notNull": true, | 81 | + | "notNull": true, |
| 82 | - | "autoincrement": false, | 82 | + | "autoincrement": false, |
| 83 | - | "default": "'chat'" | 83 | + | "default": "'chat'" |
| 84 | - | }, | 84 | + | }, |
| 85 | - | "timestamp": { | 85 | + | "timestamp": { |
| 86 | - | "name": "timestamp", | 86 | + | "name": "timestamp", |
| 87 | - | "type": "integer", | 87 | + | "type": "integer", |
| 88 | - | "primaryKey": false, | 88 | + | "primaryKey": false, |
| 89 | - | "notNull": true, | 89 | + | "notNull": true, |
| 90 | - | "autoincrement": false | 90 | + | "autoincrement": false |
| 91 | - | } | 91 | + | } |
| 92 | - | }, | 92 | + | }, |
| 93 | - | "indexes": {}, | 93 | + | "indexes": {}, |
| 94 | - | "foreignKeys": { | 94 | + | "foreignKeys": { |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | 95 | + | "messages_chat_room_id_chat_rooms_id_fk": { |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | 96 | + | "name": "messages_chat_room_id_chat_rooms_id_fk", |
| 97 | - | "tableFrom": "messages", | 97 | + | "tableFrom": "messages", |
| 98 | - | "tableTo": "chat_rooms", | 98 | + | "tableTo": "chat_rooms", |
| 99 | - | "columnsFrom": [ | 99 | + | "columnsFrom": ["chat_room_id"], |
| 100 | - | "chat_room_id" | 100 | + | "columnsTo": ["id"], |
| 101 | - | ], | 101 | + | "onDelete": "no action", |
| 102 | - | "columnsTo": [ | 102 | + | "onUpdate": "no action" |
| 103 | - | "id" | 103 | + | }, |
| 104 | - | ], | 104 | + | "messages_sender_id_users_id_fk": { |
| 105 | - | "onDelete": "no action", | 105 | + | "name": "messages_sender_id_users_id_fk", |
| 106 | - | "onUpdate": "no action" | 106 | + | "tableFrom": "messages", |
| 107 | - | }, | 107 | + | "tableTo": "users", |
| 108 | - | "messages_sender_id_users_id_fk": { | 108 | + | "columnsFrom": ["sender_id"], |
| 109 | - | "name": "messages_sender_id_users_id_fk", | 109 | + | "columnsTo": ["id"], |
| 110 | - | "tableFrom": "messages", | 110 | + | "onDelete": "no action", |
| 111 | - | "tableTo": "users", | 111 | + | "onUpdate": "no action" |
| 112 | - | "columnsFrom": [ | 112 | + | } |
| 113 | - | "sender_id" | 113 | + | }, |
| 114 | - | ], | 114 | + | "compositePrimaryKeys": {}, |
| 115 | - | "columnsTo": [ | 115 | + | "uniqueConstraints": {}, |
| 116 | - | "id" | 116 | + | "checkConstraints": {} |
| 117 | - | ], | 117 | + | }, |
| 118 | - | "onDelete": "no action", | 118 | + | "sessions": { |
| 119 | - | "onUpdate": "no action" | 119 | + | "name": "sessions", |
| 120 | - | } | 120 | + | "columns": { |
| 121 | - | }, | 121 | + | "id": { |
| 122 | - | "compositePrimaryKeys": {}, | 122 | + | "name": "id", |
| 123 | - | "uniqueConstraints": {}, | 123 | + | "type": "text", |
| 124 | - | "checkConstraints": {} | 124 | + | "primaryKey": true, |
| 125 | - | }, | 125 | + | "notNull": true, |
| 126 | - | "sessions": { | 126 | + | "autoincrement": false |
| 127 | - | "name": "sessions", | 127 | + | }, |
| 128 | - | "columns": { | 128 | + | "user_id": { |
| 129 | - | "id": { | 129 | + | "name": "user_id", |
| 130 | - | "name": "id", | 130 | + | "type": "text", |
| 131 | - | "type": "text", | 131 | + | "primaryKey": false, |
| 132 | - | "primaryKey": true, | 132 | + | "notNull": true, |
| 133 | - | "notNull": true, | 133 | + | "autoincrement": false |
| 134 | - | "autoincrement": false | 134 | + | }, |
| 135 | - | }, | 135 | + | "expires_at": { |
| 136 | - | "user_id": { | 136 | + | "name": "expires_at", |
| 137 | - | "name": "user_id", | 137 | + | "type": "integer", |
| 138 | - | "type": "text", | 138 | + | "primaryKey": false, |
| 139 | - | "primaryKey": false, | 139 | + | "notNull": true, |
| 140 | - | "notNull": true, | 140 | + | "autoincrement": false |
| 141 | - | "autoincrement": false | 141 | + | }, |
| 142 | - | }, | 142 | + | "created_at": { |
| 143 | - | "expires_at": { | 143 | + | "name": "created_at", |
| 144 | - | "name": "expires_at", | 144 | + | "type": "integer", |
| 145 | - | "type": "integer", | 145 | + | "primaryKey": false, |
| 146 | - | "primaryKey": false, | 146 | + | "notNull": true, |
| 147 | - | "notNull": true, | 147 | + | "autoincrement": false |
| 148 | - | "autoincrement": false | 148 | + | } |
| 149 | - | }, | 149 | + | }, |
| 150 | - | "created_at": { | 150 | + | "indexes": {}, |
| 151 | - | "name": "created_at", | 151 | + | "foreignKeys": { |
| 152 | - | "type": "integer", | 152 | + | "sessions_user_id_users_id_fk": { |
| 153 | - | "primaryKey": false, | 153 | + | "name": "sessions_user_id_users_id_fk", |
| 154 | - | "notNull": true, | 154 | + | "tableFrom": "sessions", |
| 155 | - | "autoincrement": false | 155 | + | "tableTo": "users", |
| 156 | - | } | 156 | + | "columnsFrom": ["user_id"], |
| 157 | - | }, | 157 | + | "columnsTo": ["id"], |
| 158 | - | "indexes": {}, | 158 | + | "onDelete": "no action", |
| 159 | - | "foreignKeys": { | 159 | + | "onUpdate": "no action" |
| 160 | - | "sessions_user_id_users_id_fk": { | 160 | + | } |
| 161 | - | "name": "sessions_user_id_users_id_fk", | 161 | + | }, |
| 162 | - | "tableFrom": "sessions", | 162 | + | "compositePrimaryKeys": {}, |
| 163 | - | "tableTo": "users", | 163 | + | "uniqueConstraints": {}, |
| 164 | - | "columnsFrom": [ | 164 | + | "checkConstraints": {} |
| 165 | - | "user_id" | 165 | + | }, |
| 166 | - | ], | 166 | + | "users": { |
| 167 | - | "columnsTo": [ | 167 | + | "name": "users", |
| 168 | - | "id" | 168 | + | "columns": { |
| 169 | - | ], | 169 | + | "id": { |
| 170 | - | "onDelete": "no action", | 170 | + | "name": "id", |
| 171 | - | "onUpdate": "no action" | 171 | + | "type": "text", |
| 172 | - | } | 172 | + | "primaryKey": true, |
| 173 | - | }, | 173 | + | "notNull": true, |
| 174 | - | "compositePrimaryKeys": {}, | 174 | + | "autoincrement": false |
| 175 | - | "uniqueConstraints": {}, | 175 | + | }, |
| 176 | - | "checkConstraints": {} | 176 | + | "password": { |
| 177 | - | }, | 177 | + | "name": "password", |
| 178 | - | "users": { | 178 | + | "type": "text", |
| 179 | - | "name": "users", | 179 | + | "primaryKey": false, |
| 180 | - | "columns": { | 180 | + | "notNull": true, |
| 181 | - | "id": { | 181 | + | "autoincrement": false |
| 182 | - | "name": "id", | 182 | + | }, |
| 183 | - | "type": "text", | 183 | + | "nickname": { |
| 184 | - | "primaryKey": true, | 184 | + | "name": "nickname", |
| 185 | - | "notNull": true, | 185 | + | "type": "text", |
| 186 | - | "autoincrement": false | 186 | + | "primaryKey": false, |
| 187 | - | }, | 187 | + | "notNull": true, |
| 188 | - | "password": { | 188 | + | "autoincrement": false |
| 189 | - | "name": "password", | 189 | + | }, |
| 190 | - | "type": "text", | 190 | + | "status": { |
| 191 | - | "primaryKey": false, | 191 | + | "name": "status", |
| 192 | - | "notNull": true, | 192 | + | "type": "text", |
| 193 | - | "autoincrement": false | 193 | + | "primaryKey": false, |
| 194 | - | }, | 194 | + | "notNull": true, |
| 195 | - | "nickname": { | 195 | + | "autoincrement": false, |
| 196 | - | "name": "nickname", | 196 | + | "default": "'offline'" |
| 197 | - | "type": "text", | 197 | + | }, |
| 198 | - | "primaryKey": false, | 198 | + | "avatar_url": { |
| 199 | - | "notNull": true, | 199 | + | "name": "avatar_url", |
| 200 | - | "autoincrement": false | 200 | + | "type": "text", |
| 201 | - | }, | 201 | + | "primaryKey": false, |
| 202 | - | "status": { | 202 | + | "notNull": false, |
| 203 | - | "name": "status", | 203 | + | "autoincrement": false |
| 204 | - | "type": "text", | 204 | + | }, |
| 205 | - | "primaryKey": false, | 205 | + | "created_at": { |
| 206 | - | "notNull": true, | 206 | + | "name": "created_at", |
| 207 | - | "autoincrement": false, | 207 | + | "type": "integer", |
| 208 | - | "default": "'offline'" | 208 | + | "primaryKey": false, |
| 209 | - | }, | 209 | + | "notNull": true, |
| 210 | - | "avatar_url": { | 210 | + | "autoincrement": false |
| 211 | - | "name": "avatar_url", | 211 | + | }, |
| 212 | - | "type": "text", | 212 | + | "last_seen": { |
| 213 | - | "primaryKey": false, | 213 | + | "name": "last_seen", |
| 214 | - | "notNull": false, | 214 | + | "type": "integer", |
| 215 | - | "autoincrement": false | 215 | + | "primaryKey": false, |
| 216 | - | }, | 216 | + | "notNull": false, |
| 217 | - | "created_at": { | 217 | + | "autoincrement": false |
| 218 | - | "name": "created_at", | 218 | + | } |
| 219 | - | "type": "integer", | 219 | + | }, |
| 220 | - | "primaryKey": false, | 220 | + | "indexes": {}, |
| 221 | - | "notNull": true, | 221 | + | "foreignKeys": {}, |
| 222 | - | "autoincrement": false | 222 | + | "compositePrimaryKeys": {}, |
| 223 | - | }, | 223 | + | "uniqueConstraints": {}, |
| 224 | - | "last_seen": { | 224 | + | "checkConstraints": {} |
| 225 | - | "name": "last_seen", | 225 | + | } |
| 226 | - | "type": "integer", | 226 | + | }, |
| 227 | - | "primaryKey": false, | 227 | + | "views": { |
| 228 | - | "notNull": false, | 228 | + | "chat_room_view": { |
| 229 | - | "autoincrement": false | 229 | + | "columns": { |
| 230 | - | } | 230 | + | "id": { |
| 231 | - | }, | 231 | + | "name": "id", |
| 232 | - | "indexes": {}, | 232 | + | "type": "text", |
| 233 | - | "foreignKeys": {}, | 233 | + | "primaryKey": true, |
| 234 | - | "compositePrimaryKeys": {}, | 234 | + | "notNull": true, |
| 235 | - | "uniqueConstraints": {}, | 235 | + | "autoincrement": false |
| 236 | - | "checkConstraints": {} | 236 | + | }, |
| 237 | - | } | 237 | + | "name": { |
| 238 | - | }, | 238 | + | "name": "name", |
| 239 | - | "views": { | 239 | + | "type": "text", |
| 240 | - | "chat_room_view": { | 240 | + | "primaryKey": false, |
| 241 | - | "columns": { | 241 | + | "notNull": false, |
| 242 | - | "id": { | 242 | + | "autoincrement": false |
| 243 | - | "name": "id", | 243 | + | }, |
| 244 | - | "type": "text", | 244 | + | "type": { |
| 245 | - | "primaryKey": true, | 245 | + | "name": "type", |
| 246 | - | "notNull": true, | 246 | + | "type": "text", |
| 247 | - | "autoincrement": false | 247 | + | "primaryKey": false, |
| 248 | - | }, | 248 | + | "notNull": true, |
| 249 | - | "name": { | 249 | + | "autoincrement": false, |
| 250 | - | "name": "name", | 250 | + | "default": "'direct'" |
| 251 | - | "type": "text", | 251 | + | }, |
| 252 | - | "primaryKey": false, | 252 | + | "created_at": { |
| 253 | - | "notNull": false, | 253 | + | "name": "created_at", |
| 254 | - | "autoincrement": false | 254 | + | "type": "integer", |
| 255 | - | }, | 255 | + | "primaryKey": false, |
| 256 | - | "type": { | 256 | + | "notNull": true, |
| 257 | - | "name": "type", | 257 | + | "autoincrement": false |
| 258 | - | "type": "text", | 258 | + | } |
| 259 | - | "primaryKey": false, | 259 | + | }, |
| 260 | - | "notNull": true, | 260 | + | "name": "chat_room_view", |
| 261 | - | "autoincrement": false, | 261 | + | "isExisting": false, |
| 262 | - | "default": "'direct'" | 262 | + | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" |
| 263 | - | }, | 263 | + | }, |
| 264 | - | "created_at": { | 264 | + | "message_view": { |
| 265 | - | "name": "created_at", | 265 | + | "columns": { |
| 266 | - | "type": "integer", | 266 | + | "id": { |
| 267 | - | "primaryKey": false, | 267 | + | "name": "id", |
| 268 | - | "notNull": true, | 268 | + | "type": "text", |
| 269 | - | "autoincrement": false | 269 | + | "primaryKey": true, |
| 270 | - | } | 270 | + | "notNull": true, |
| 271 | - | }, | 271 | + | "autoincrement": false |
| 272 | - | "name": "chat_room_view", | 272 | + | }, |
| 273 | - | "isExisting": false, | 273 | + | "content": { |
| 274 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | 274 | + | "name": "content", |
| 275 | - | }, | 275 | + | "type": "text", |
| 276 | - | "message_view": { | 276 | + | "primaryKey": false, |
| 277 | - | "columns": { | 277 | + | "notNull": true, |
| 278 | - | "id": { | 278 | + | "autoincrement": false |
| 279 | - | "name": "id", | 279 | + | }, |
| 280 | - | "type": "text", | 280 | + | "type": { |
| 281 | - | "primaryKey": true, | 281 | + | "name": "type", |
| 282 | - | "notNull": true, | 282 | + | "type": "text", |
| 283 | - | "autoincrement": false | 283 | + | "primaryKey": false, |
| 284 | - | }, | 284 | + | "notNull": true, |
| 285 | - | "content": { | 285 | + | "autoincrement": false, |
| 286 | - | "name": "content", | 286 | + | "default": "'chat'" |
| 287 | - | "type": "text", | 287 | + | }, |
| 288 | - | "primaryKey": false, | 288 | + | "timestamp": { |
| 289 | - | "notNull": true, | 289 | + | "name": "timestamp", |
| 290 | - | "autoincrement": false | 290 | + | "type": "integer", |
| 291 | - | }, | 291 | + | "primaryKey": false, |
| 292 | - | "type": { | 292 | + | "notNull": true, |
| 293 | - | "name": "type", | 293 | + | "autoincrement": false |
| 294 | - | "type": "text", | 294 | + | }, |
| 295 | - | "primaryKey": false, | 295 | + | "nickname": { |
| 296 | - | "notNull": true, | 296 | + | "name": "nickname", |
| 297 | - | "autoincrement": false, | 297 | + | "type": "text", |
| 298 | - | "default": "'chat'" | 298 | + | "primaryKey": false, |
| 299 | - | }, | 299 | + | "notNull": true, |
| 300 | - | "timestamp": { | 300 | + | "autoincrement": false |
| 301 | - | "name": "timestamp", | 301 | + | }, |
| 302 | - | "type": "integer", | 302 | + | "name": { |
| 303 | - | "primaryKey": false, | 303 | + | "name": "name", |
| 304 | - | "notNull": true, | 304 | + | "type": "text", |
| 305 | - | "autoincrement": false | 305 | + | "primaryKey": false, |
| 306 | - | }, | 306 | + | "notNull": false, |
| 307 | - | "nickname": { | 307 | + | "autoincrement": false |
| 308 | - | "name": "nickname", | 308 | + | } |
| 309 | - | "type": "text", | 309 | + | }, |
| 310 | - | "primaryKey": false, | 310 | + | "name": "message_view", |
| 311 | - | "notNull": true, | 311 | + | "isExisting": false, |
| 312 | - | "autoincrement": 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 | - | }, | 313 | + | }, |
| 314 | - | "name": { | 314 | + | "user_view": { |
| 315 | - | "name": "name", | 315 | + | "columns": { |
| 316 | - | "type": "text", | 316 | + | "id": { |
| 317 | - | "primaryKey": false, | 317 | + | "name": "id", |
| 318 | - | "notNull": false, | 318 | + | "type": "text", |
| 319 | - | "autoincrement": false | 319 | + | "primaryKey": true, |
| 320 | - | } | 320 | + | "notNull": true, |
| 321 | - | }, | 321 | + | "autoincrement": false |
| 322 | - | "name": "message_view", | 322 | + | }, |
| 323 | - | "isExisting": false, | 323 | + | "nickname": { |
| 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\"" | 324 | + | "name": "nickname", |
| 325 | - | }, | 325 | + | "type": "text", |
| 326 | - | "user_view": { | 326 | + | "primaryKey": false, |
| 327 | - | "columns": { | 327 | + | "notNull": true, |
| 328 | - | "id": { | 328 | + | "autoincrement": false |
| 329 | - | "name": "id", | 329 | + | }, |
| 330 | - | "type": "text", | 330 | + | "status": { |
| 331 | - | "primaryKey": true, | 331 | + | "name": "status", |
| 332 | - | "notNull": true, | 332 | + | "type": "text", |
| 333 | - | "autoincrement": false | 333 | + | "primaryKey": false, |
| 334 | - | }, | 334 | + | "notNull": true, |
| 335 | - | "nickname": { | 335 | + | "autoincrement": false, |
| 336 | - | "name": "nickname", | 336 | + | "default": "'offline'" |
| 337 | - | "type": "text", | 337 | + | }, |
| 338 | - | "primaryKey": false, | 338 | + | "avatar_url": { |
| 339 | - | "notNull": true, | 339 | + | "name": "avatar_url", |
| 340 | - | "autoincrement": false | 340 | + | "type": "text", |
| 341 | - | }, | 341 | + | "primaryKey": false, |
| 342 | - | "status": { | 342 | + | "notNull": false, |
| 343 | - | "name": "status", | 343 | + | "autoincrement": false |
| 344 | - | "type": "text", | 344 | + | }, |
| 345 | - | "primaryKey": false, | 345 | + | "last_seen": { |
| 346 | - | "notNull": true, | 346 | + | "name": "last_seen", |
| 347 | - | "autoincrement": false, | 347 | + | "type": "integer", |
| 348 | - | "default": "'offline'" | 348 | + | "primaryKey": false, |
| 349 | - | }, | 349 | + | "notNull": false, |
| 350 | - | "avatar_url": { | 350 | + | "autoincrement": false |
| 351 | - | "name": "avatar_url", | 351 | + | } |
| 352 | - | "type": "text", | 352 | + | }, |
| 353 | - | "primaryKey": false, | 353 | + | "name": "user_view", |
| 354 | - | "notNull": false, | 354 | + | "isExisting": false, |
| 355 | - | "autoincrement": false | 355 | + | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" |
| 356 | - | }, | 356 | + | } |
| 357 | - | "last_seen": { | 357 | + | }, |
| 358 | - | "name": "last_seen", | 358 | + | "enums": {}, |
| 359 | - | "type": "integer", | 359 | + | "_meta": { |
| 360 | - | "primaryKey": false, | 360 | + | "schemas": {}, |
| 361 | - | "notNull": false, | 361 | + | "tables": {}, |
| 362 | - | "autoincrement": false | 362 | + | "columns": {} |
| 363 | - | } | 363 | + | }, |
| 364 | - | }, | 364 | + | "internal": { |
| 365 | - | "name": "user_view", | 365 | + | "indexes": {} |
| 366 | - | "isExisting": false, | 366 | + | } |
| 367 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | 367 | + | } |
| 368 | - | } | |||
| 369 | - | }, | |||
| 370 | - | "enums": {}, | |||
| 371 | - | "_meta": { | |||
| 372 | - | "schemas": {}, | |||
| 373 | - | "tables": {}, | |||
| 374 | - | "columns": {} | |||
| 375 | - | }, | |||
| 376 | - | "internal": { | |||
| 377 | - | "indexes": {} | |||
| 378 | - | } | |||
| 379 | - | }⏎̸ | |||
M drizzle/meta/_journal.json +19 -19
| @@ -1,20 +1,20 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | - | "version": "7", | 2 | + | "version": "7", |
| 3 | - | "dialect": "sqlite", | 3 | + | "dialect": "sqlite", |
| 4 | - | "entries": [ | 4 | + | "entries": [ |
| 5 | - | { | 5 | + | { |
| 6 | - | "idx": 0, | 6 | + | "idx": 0, |
| 7 | - | "version": "6", | 7 | + | "version": "6", |
| 8 | - | "when": 1739208055141, | 8 | + | "when": 1739208055141, |
| 9 | - | "tag": "0000_shallow_ghost_rider", | 9 | + | "tag": "0000_shallow_ghost_rider", |
| 10 | - | "breakpoints": true | 10 | + | "breakpoints": true |
| 11 | - | }, | 11 | + | }, |
| 12 | - | { | 12 | + | { |
| 13 | - | "idx": 1, | 13 | + | "idx": 1, |
| 14 | - | "version": "6", | 14 | + | "version": "6", |
| 15 | - | "when": 1739432549447, | 15 | + | "when": 1739432549447, |
| 16 | - | "tag": "0001_blushing_jean_grey", | 16 | + | "tag": "0001_blushing_jean_grey", |
| 17 | - | "breakpoints": true | 17 | + | "breakpoints": true |
| 18 | - | } | 18 | + | } |
| 19 | - | ] | 19 | + | ] |
| 20 | - | }⏎̸ | 20 | + | } |
M eslint.config.js +10 -0
| @@ -30,5 +30,15 @@ export default ts.config( | |||||
| 30 | parser: ts.parser | 30 | parser: ts.parser | ||
| 31 | } | 31 | } | ||
| 32 | } | 32 | } | ||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | // Svelte 5 .svelte.ts files use runes - need svelte-eslint-parser | |||
| 36 | + | files: ['**/*.svelte.ts', '**/*.svelte.js'], | |||
| 37 | + | languageOptions: { | |||
| 38 | + | parser: svelte.parser, | |||
| 39 | + | parserOptions: { | |||
| 40 | + | parser: ts.parser | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 33 | } | 43 | } | ||
| 34 | ); | 44 | ); | ||
M package.json +2 -1
| @@ -69,5 +69,6 @@ | |||||
| 69 | "uuid": "^11.1.0", | 69 | "uuid": "^11.1.0", | ||
| 70 | "xp.css": "^0.2.6", | 70 | "xp.css": "^0.2.6", | ||
| 71 | "zod": "^4.1.13" | 71 | "zod": "^4.1.13" | ||
| 72 | - | } | 72 | + | }, |
| 73 | + | "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" | |||
| 73 | } | 74 | } | ||
M src/app.css +342 -325
| @@ -1,116 +1,116 @@ | |||||
| 1 | - | @import "tailwindcss"; | 1 | + | @import 'tailwindcss'; |
| 2 | - | @import "tw-animate-css"; | 2 | + | @import 'tw-animate-css'; |
| 3 | 3 | ||||
| 4 | @custom-variant dark (&:is(.dark *)); | 4 | @custom-variant dark (&:is(.dark *)); | ||
| 5 | 5 | ||||
| 6 | :root { | 6 | :root { | ||
| 7 | - | --background: hsl(0 0% 100%); | 7 | + | --background: hsl(0 0% 100%); |
| 8 | - | --foreground: hsl(240 10% 3.9%); | 8 | + | --foreground: hsl(240 10% 3.9%); |
| 9 | - | --muted: hsl(240 4.8% 95.9%); | 9 | + | --muted: hsl(240 4.8% 95.9%); |
| 10 | - | --muted-foreground: hsl(240 3.8% 46.1%); | 10 | + | --muted-foreground: hsl(240 3.8% 46.1%); |
| 11 | - | --popover: hsl(0 0% 100%); | 11 | + | --popover: hsl(0 0% 100%); |
| 12 | - | --popover-foreground: hsl(240 10% 3.9%); | 12 | + | --popover-foreground: hsl(240 10% 3.9%); |
| 13 | - | --card: hsl(0 0% 100%); | 13 | + | --card: hsl(0 0% 100%); |
| 14 | - | --card-foreground: hsl(240 10% 3.9%); | 14 | + | --card-foreground: hsl(240 10% 3.9%); |
| 15 | - | --border: hsl(240 5.9% 90%); | 15 | + | --border: hsl(240 5.9% 90%); |
| 16 | - | --input: hsl(240 5.9% 90%); | 16 | + | --input: hsl(240 5.9% 90%); |
| 17 | - | --primary: hsl(240 5.9% 10%); | 17 | + | --primary: hsl(240 5.9% 10%); |
| 18 | - | --primary-foreground: hsl(0 0% 98%); | 18 | + | --primary-foreground: hsl(0 0% 98%); |
| 19 | - | --secondary: hsl(240 4.8% 95.9%); | 19 | + | --secondary: hsl(240 4.8% 95.9%); |
| 20 | - | --secondary-foreground: hsl(240 5.9% 10%); | 20 | + | --secondary-foreground: hsl(240 5.9% 10%); |
| 21 | - | --accent: hsl(240 4.8% 95.9%); | 21 | + | --accent: hsl(240 4.8% 95.9%); |
| 22 | - | --accent-foreground: hsl(240 5.9% 10%); | 22 | + | --accent-foreground: hsl(240 5.9% 10%); |
| 23 | - | --destructive: hsl(0 72.2% 50.6%); | 23 | + | --destructive: hsl(0 72.2% 50.6%); |
| 24 | - | --destructive-foreground: hsl(0 0% 98%); | 24 | + | --destructive-foreground: hsl(0 0% 98%); |
| 25 | - | --ring: hsl(240 10% 3.9%); | 25 | + | --ring: hsl(240 10% 3.9%); |
| 26 | - | --sidebar: hsl(0 0% 98%); | 26 | + | --sidebar: hsl(0 0% 98%); |
| 27 | - | --sidebar-foreground: hsl(240 5.3% 26.1%); | 27 | + | --sidebar-foreground: hsl(240 5.3% 26.1%); |
| 28 | - | --sidebar-primary: hsl(240 5.9% 10%); | 28 | + | --sidebar-primary: hsl(240 5.9% 10%); |
| 29 | - | --sidebar-primary-foreground: hsl(0 0% 98%); | 29 | + | --sidebar-primary-foreground: hsl(0 0% 98%); |
| 30 | - | --sidebar-accent: hsl(240 4.8% 95.9%); | 30 | + | --sidebar-accent: hsl(240 4.8% 95.9%); |
| 31 | - | --sidebar-accent-foreground: hsl(240 5.9% 10%); | 31 | + | --sidebar-accent-foreground: hsl(240 5.9% 10%); |
| 32 | - | --sidebar-border: hsl(220 13% 91%); | 32 | + | --sidebar-border: hsl(220 13% 91%); |
| 33 | - | --sidebar-ring: hsl(217.2 91.2% 59.8%); | 33 | + | --sidebar-ring: hsl(217.2 91.2% 59.8%); |
| 34 | - | 34 | + | ||
| 35 | - | --radius: 0.5rem; | 35 | + | --radius: 0.5rem; |
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | .dark { | 38 | .dark { | ||
| 39 | - | --background: hsl(240 10% 3.9%); | 39 | + | --background: hsl(240 10% 3.9%); |
| 40 | - | --foreground: hsl(0 0% 98%); | 40 | + | --foreground: hsl(0 0% 98%); |
| 41 | - | --muted: hsl(240 3.7% 15.9%); | 41 | + | --muted: hsl(240 3.7% 15.9%); |
| 42 | - | --muted-foreground: hsl(240 5% 64.9%); | 42 | + | --muted-foreground: hsl(240 5% 64.9%); |
| 43 | - | --popover: hsl(240 10% 3.9%); | 43 | + | --popover: hsl(240 10% 3.9%); |
| 44 | - | --popover-foreground: hsl(0 0% 98%); | 44 | + | --popover-foreground: hsl(0 0% 98%); |
| 45 | - | --card: hsl(240 10% 3.9%); | 45 | + | --card: hsl(240 10% 3.9%); |
| 46 | - | --card-foreground: hsl(0 0% 98%); | 46 | + | --card-foreground: hsl(0 0% 98%); |
| 47 | - | --border: hsl(240 3.7% 15.9%); | 47 | + | --border: hsl(240 3.7% 15.9%); |
| 48 | - | --input: hsl(240 3.7% 15.9%); | 48 | + | --input: hsl(240 3.7% 15.9%); |
| 49 | - | --primary: hsl(0 0% 98%); | 49 | + | --primary: hsl(0 0% 98%); |
| 50 | - | --primary-foreground: hsl(240 5.9% 10%); | 50 | + | --primary-foreground: hsl(240 5.9% 10%); |
| 51 | - | --secondary: hsl(240 3.7% 15.9%); | 51 | + | --secondary: hsl(240 3.7% 15.9%); |
| 52 | - | --secondary-foreground: hsl(0 0% 98%); | 52 | + | --secondary-foreground: hsl(0 0% 98%); |
| 53 | - | --accent: hsl(240 3.7% 15.9%); | 53 | + | --accent: hsl(240 3.7% 15.9%); |
| 54 | - | --accent-foreground: hsl(0 0% 98%); | 54 | + | --accent-foreground: hsl(0 0% 98%); |
| 55 | - | --destructive: hsl(0 62.8% 30.6%); | 55 | + | --destructive: hsl(0 62.8% 30.6%); |
| 56 | - | --destructive-foreground: hsl(0 0% 98%); | 56 | + | --destructive-foreground: hsl(0 0% 98%); |
| 57 | - | --ring: hsl(240 4.9% 83.9%); | 57 | + | --ring: hsl(240 4.9% 83.9%); |
| 58 | - | --sidebar: hsl(240 5.9% 10%); | 58 | + | --sidebar: hsl(240 5.9% 10%); |
| 59 | - | --sidebar-foreground: hsl(240 4.8% 95.9%); | 59 | + | --sidebar-foreground: hsl(240 4.8% 95.9%); |
| 60 | - | --sidebar-primary: hsl(224.3 76.3% 48%); | 60 | + | --sidebar-primary: hsl(224.3 76.3% 48%); |
| 61 | - | --sidebar-primary-foreground: hsl(0 0% 100%); | 61 | + | --sidebar-primary-foreground: hsl(0 0% 100%); |
| 62 | - | --sidebar-accent: hsl(240 3.7% 15.9%); | 62 | + | --sidebar-accent: hsl(240 3.7% 15.9%); |
| 63 | - | --sidebar-accent-foreground: hsl(240 4.8% 95.9%); | 63 | + | --sidebar-accent-foreground: hsl(240 4.8% 95.9%); |
| 64 | - | --sidebar-border: hsl(240 3.7% 15.9%); | 64 | + | --sidebar-border: hsl(240 3.7% 15.9%); |
| 65 | - | --sidebar-ring: hsl(217.2 91.2% 59.8%); | 65 | + | --sidebar-ring: hsl(217.2 91.2% 59.8%); |
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | @theme inline { | 68 | @theme inline { | ||
| 69 | - | /* Radius (for rounded-*) */ | 69 | + | /* Radius (for rounded-*) */ |
| 70 | - | --radius-sm: calc(var(--radius) - 4px); | 70 | + | --radius-sm: calc(var(--radius) - 4px); |
| 71 | - | --radius-md: calc(var(--radius) - 2px); | 71 | + | --radius-md: calc(var(--radius) - 2px); |
| 72 | - | --radius-lg: var(--radius); | 72 | + | --radius-lg: var(--radius); |
| 73 | - | --radius-xl: calc(var(--radius) + 4px); | 73 | + | --radius-xl: calc(var(--radius) + 4px); |
| 74 | - | 74 | + | ||
| 75 | - | /* Colors */ | 75 | + | /* Colors */ |
| 76 | - | --color-background: var(--background); | 76 | + | --color-background: var(--background); |
| 77 | - | --color-foreground: var(--foreground); | 77 | + | --color-foreground: var(--foreground); |
| 78 | - | --color-muted: var(--muted); | 78 | + | --color-muted: var(--muted); |
| 79 | - | --color-muted-foreground: var(--muted-foreground); | 79 | + | --color-muted-foreground: var(--muted-foreground); |
| 80 | - | --color-popover: var(--popover); | 80 | + | --color-popover: var(--popover); |
| 81 | - | --color-popover-foreground: var(--popover-foreground); | 81 | + | --color-popover-foreground: var(--popover-foreground); |
| 82 | - | --color-card: var(--card); | 82 | + | --color-card: var(--card); |
| 83 | - | --color-card-foreground: var(--card-foreground); | 83 | + | --color-card-foreground: var(--card-foreground); |
| 84 | - | --color-border: var(--border); | 84 | + | --color-border: var(--border); |
| 85 | - | --color-input: var(--input); | 85 | + | --color-input: var(--input); |
| 86 | - | --color-primary: var(--primary); | 86 | + | --color-primary: var(--primary); |
| 87 | - | --color-primary-foreground: var(--primary-foreground); | 87 | + | --color-primary-foreground: var(--primary-foreground); |
| 88 | - | --color-secondary: var(--secondary); | 88 | + | --color-secondary: var(--secondary); |
| 89 | - | --color-secondary-foreground: var(--secondary-foreground); | 89 | + | --color-secondary-foreground: var(--secondary-foreground); |
| 90 | - | --color-accent: var(--accent); | 90 | + | --color-accent: var(--accent); |
| 91 | - | --color-accent-foreground: var(--accent-foreground); | 91 | + | --color-accent-foreground: var(--accent-foreground); |
| 92 | - | --color-destructive: var(--destructive); | 92 | + | --color-destructive: var(--destructive); |
| 93 | - | --color-destructive-foreground: var(--destructive-foreground); | 93 | + | --color-destructive-foreground: var(--destructive-foreground); |
| 94 | - | --color-ring: var(--ring); | 94 | + | --color-ring: var(--ring); |
| 95 | - | --color-radius: var(--radius); | 95 | + | --color-radius: var(--radius); |
| 96 | - | --color-sidebar: var(--sidebar); | 96 | + | --color-sidebar: var(--sidebar); |
| 97 | - | --color-sidebar-foreground: var(--sidebar-foreground); | 97 | + | --color-sidebar-foreground: var(--sidebar-foreground); |
| 98 | - | --color-sidebar-primary: var(--sidebar-primary); | 98 | + | --color-sidebar-primary: var(--sidebar-primary); |
| 99 | - | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); | 99 | + | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); |
| 100 | - | --color-sidebar-accent: var(--sidebar-accent); | 100 | + | --color-sidebar-accent: var(--sidebar-accent); |
| 101 | - | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); | 101 | + | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); |
| 102 | - | --color-sidebar-border: var(--sidebar-border); | 102 | + | --color-sidebar-border: var(--sidebar-border); |
| 103 | - | --color-sidebar-ring: var(--sidebar-ring); | 103 | + | --color-sidebar-ring: var(--sidebar-ring); |
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | @layer base { | 106 | @layer base { | ||
| 107 | - | * { | 107 | + | * { |
| 108 | - | @apply border-border; | 108 | + | @apply border-border; |
| 109 | - | } | 109 | + | } |
| 110 | 110 | ||||
| 111 | - | body { | 111 | + | body { |
| 112 | - | @apply bg-background text-foreground; | 112 | + | @apply bg-background text-foreground; |
| 113 | - | } | 113 | + | } |
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | @import 'xp.css/dist/XP.css'; | 116 | @import 'xp.css/dist/XP.css'; | ||
| @@ -118,445 +118,462 @@ | |||||
| 118 | 118 | ||||
| 119 | /* Local font faces with 30-day cache */ | 119 | /* Local font faces with 30-day cache */ | ||
| 120 | @font-face { | 120 | @font-face { | ||
| 121 | - | font-family: 'Arial'; | 121 | + | font-family: 'Arial'; |
| 122 | - | src: url('/fonts/Arial.TTF') format('truetype'); | 122 | + | src: url('/fonts/Arial.TTF') format('truetype'); |
| 123 | - | font-display: swap; | 123 | + | font-display: swap; |
| 124 | - | font-weight: normal; | 124 | + | font-weight: normal; |
| 125 | - | font-style: normal; | 125 | + | font-style: normal; |
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | @font-face { | 128 | @font-face { | ||
| 129 | - | font-family: 'Arial'; | 129 | + | font-family: 'Arial'; |
| 130 | - | src: url('/fonts/Arialbd.TTF') format('truetype'); | 130 | + | src: url('/fonts/Arialbd.TTF') format('truetype'); |
| 131 | - | font-display: swap; | 131 | + | font-display: swap; |
| 132 | - | font-weight: bold; | 132 | + | font-weight: bold; |
| 133 | - | font-style: normal; | 133 | + | font-style: normal; |
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | @font-face { | 136 | @font-face { | ||
| 137 | - | font-family: 'Arial'; | 137 | + | font-family: 'Arial'; |
| 138 | - | src: url('/fonts/Ariali.TTF') format('truetype'); | 138 | + | src: url('/fonts/Ariali.TTF') format('truetype'); |
| 139 | - | font-display: swap; | 139 | + | font-display: swap; |
| 140 | - | font-weight: normal; | 140 | + | font-weight: normal; |
| 141 | - | font-style: italic; | 141 | + | font-style: italic; |
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | @font-face { | 144 | @font-face { | ||
| 145 | - | font-family: 'Arial'; | 145 | + | font-family: 'Arial'; |
| 146 | - | src: url('/fonts/Arialbi.TTF') format('truetype'); | 146 | + | src: url('/fonts/Arialbi.TTF') format('truetype'); |
| 147 | - | font-display: swap; | 147 | + | font-display: swap; |
| 148 | - | font-weight: bold; | 148 | + | font-weight: bold; |
| 149 | - | font-style: italic; | 149 | + | font-style: italic; |
| 150 | } | 150 | } | ||
| 151 | 151 | ||||
| 152 | @font-face { | 152 | @font-face { | ||
| 153 | - | font-family: 'Tahoma'; | 153 | + | font-family: 'Tahoma'; |
| 154 | - | src: url('/fonts/tahoma.ttf') format('truetype'); | 154 | + | src: url('/fonts/tahoma.ttf') format('truetype'); |
| 155 | - | font-display: swap; | 155 | + | font-display: swap; |
| 156 | - | font-weight: 80; | 156 | + | font-weight: 80; |
| 157 | - | font-style: normal; | 157 | + | font-style: normal; |
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | @font-face { | 160 | @font-face { | ||
| 161 | - | font-family: 'Tahoma'; | 161 | + | font-family: 'Tahoma'; |
| 162 | - | src: url('/fonts/tahomabd.ttf') format('truetype'); | 162 | + | src: url('/fonts/tahomabd.ttf') format('truetype'); |
| 163 | - | font-display: swap; | 163 | + | font-display: swap; |
| 164 | - | font-weight: 200; | 164 | + | font-weight: 200; |
| 165 | - | font-style: normal; | 165 | + | font-style: normal; |
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | @font-face { | 168 | @font-face { | ||
| 169 | - | font-family: 'Verdana'; | 169 | + | font-family: 'Verdana'; |
| 170 | - | src: url('/fonts/Verdana.TTF') format('truetype'); | 170 | + | src: url('/fonts/Verdana.TTF') format('truetype'); |
| 171 | - | font-display: swap; | 171 | + | font-display: swap; |
| 172 | - | font-weight: normal; | 172 | + | font-weight: normal; |
| 173 | - | font-style: normal; | 173 | + | font-style: normal; |
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| 176 | @font-face { | 176 | @font-face { | ||
| 177 | - | font-family: 'Verdana'; | 177 | + | font-family: 'Verdana'; |
| 178 | - | src: url('/fonts/Verdanab.TTF') format('truetype'); | 178 | + | src: url('/fonts/Verdanab.TTF') format('truetype'); |
| 179 | - | font-display: swap; | 179 | + | font-display: swap; |
| 180 | - | font-weight: bold; | 180 | + | font-weight: bold; |
| 181 | - | font-style: normal; | 181 | + | font-style: normal; |
| 182 | } | 182 | } | ||
| 183 | 183 | ||||
| 184 | @font-face { | 184 | @font-face { | ||
| 185 | - | font-family: 'Verdana'; | 185 | + | font-family: 'Verdana'; |
| 186 | - | src: url('/fonts/Verdanai.TTF') format('truetype'); | 186 | + | src: url('/fonts/Verdanai.TTF') format('truetype'); |
| 187 | - | font-display: swap; | 187 | + | font-display: swap; |
| 188 | - | font-weight: normal; | 188 | + | font-weight: normal; |
| 189 | - | font-style: italic; | 189 | + | font-style: italic; |
| 190 | } | 190 | } | ||
| 191 | 191 | ||||
| 192 | @font-face { | 192 | @font-face { | ||
| 193 | - | font-family: 'Verdana'; | 193 | + | font-family: 'Verdana'; |
| 194 | - | src: url('/fonts/Verdanaz.TTF') format('truetype'); | 194 | + | src: url('/fonts/Verdanaz.TTF') format('truetype'); |
| 195 | - | font-display: swap; | 195 | + | font-display: swap; |
| 196 | - | font-weight: bold; | 196 | + | font-weight: bold; |
| 197 | - | font-style: italic; | 197 | + | font-style: italic; |
| 198 | } | 198 | } | ||
| 199 | 199 | ||||
| 200 | @font-face { | 200 | @font-face { | ||
| 201 | - | font-family: 'Comic Sans MS'; | 201 | + | font-family: 'Comic Sans MS'; |
| 202 | - | src: url('/fonts/Comic.TTF') format('truetype'); | 202 | + | src: url('/fonts/Comic.TTF') format('truetype'); |
| 203 | - | font-display: swap; | 203 | + | font-display: swap; |
| 204 | - | font-weight: normal; | 204 | + | font-weight: normal; |
| 205 | - | font-style: normal; | 205 | + | font-style: normal; |
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | @font-face { | 208 | @font-face { | ||
| 209 | - | font-family: 'Comic Sans MS'; | 209 | + | font-family: 'Comic Sans MS'; |
| 210 | - | src: url('/fonts/Comicbd.TTF') format('truetype'); | 210 | + | src: url('/fonts/Comicbd.TTF') format('truetype'); |
| 211 | - | font-display: swap; | 211 | + | font-display: swap; |
| 212 | - | font-weight: bold; | 212 | + | font-weight: bold; |
| 213 | - | font-style: normal; | 213 | + | font-style: normal; |
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | @font-face { | 216 | @font-face { | ||
| 217 | - | font-family: 'Times New Roman'; | 217 | + | font-family: 'Times New Roman'; |
| 218 | - | src: url('/fonts/Times.TTF') format('truetype'); | 218 | + | src: url('/fonts/Times.TTF') format('truetype'); |
| 219 | - | font-display: swap; | 219 | + | font-display: swap; |
| 220 | - | font-weight: normal; | 220 | + | font-weight: normal; |
| 221 | - | font-style: normal; | 221 | + | font-style: normal; |
| 222 | } | 222 | } | ||
| 223 | 223 | ||||
| 224 | @font-face { | 224 | @font-face { | ||
| 225 | - | font-family: 'Times New Roman'; | 225 | + | font-family: 'Times New Roman'; |
| 226 | - | src: url('/fonts/Timesbd.TTF') format('truetype'); | 226 | + | src: url('/fonts/Timesbd.TTF') format('truetype'); |
| 227 | - | font-display: swap; | 227 | + | font-display: swap; |
| 228 | - | font-weight: bold; | 228 | + | font-weight: bold; |
| 229 | - | font-style: normal; | 229 | + | font-style: normal; |
| 230 | } | 230 | } | ||
| 231 | 231 | ||||
| 232 | @font-face { | 232 | @font-face { | ||
| 233 | - | font-family: 'Times New Roman'; | 233 | + | font-family: 'Times New Roman'; |
| 234 | - | src: url('/fonts/Timesi.TTF') format('truetype'); | 234 | + | src: url('/fonts/Timesi.TTF') format('truetype'); |
| 235 | - | font-display: swap; | 235 | + | font-display: swap; |
| 236 | - | font-weight: normal; | 236 | + | font-weight: normal; |
| 237 | - | font-style: italic; | 237 | + | font-style: italic; |
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
| 240 | @font-face { | 240 | @font-face { | ||
| 241 | - | font-family: 'Times New Roman'; | 241 | + | font-family: 'Times New Roman'; |
| 242 | - | src: url('/fonts/Timesbi.TTF') format('truetype'); | 242 | + | src: url('/fonts/Timesbi.TTF') format('truetype'); |
| 243 | - | font-display: swap; | 243 | + | font-display: swap; |
| 244 | - | font-weight: bold; | 244 | + | font-weight: bold; |
| 245 | - | font-style: italic; | 245 | + | font-style: italic; |
| 246 | } | 246 | } | ||
| 247 | 247 | ||||
| 248 | @font-face { | 248 | @font-face { | ||
| 249 | - | font-family: 'Courier New'; | 249 | + | font-family: 'Courier New'; |
| 250 | - | src: url('/fonts/LiberationMono-Regular.ttf') format('truetype'); | 250 | + | src: url('/fonts/LiberationMono-Regular.ttf') format('truetype'); |
| 251 | - | font-display: swap; | 251 | + | font-display: swap; |
| 252 | - | font-weight: normal; | 252 | + | font-weight: normal; |
| 253 | - | font-style: normal; | 253 | + | font-style: normal; |
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | @font-face { | 256 | @font-face { | ||
| 257 | - | font-family: 'Courier New'; | 257 | + | font-family: 'Courier New'; |
| 258 | - | src: url('/fonts/LiberationMono-Bold.ttf') format('truetype'); | 258 | + | src: url('/fonts/LiberationMono-Bold.ttf') format('truetype'); |
| 259 | - | font-display: swap; | 259 | + | font-display: swap; |
| 260 | - | font-weight: bold; | 260 | + | font-weight: bold; |
| 261 | - | font-style: normal; | 261 | + | font-style: normal; |
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| 264 | @font-face { | 264 | @font-face { | ||
| 265 | - | font-family: 'Courier New'; | 265 | + | font-family: 'Courier New'; |
| 266 | - | src: url('/fonts/LiberationMono-Italic.ttf') format('truetype'); | 266 | + | src: url('/fonts/LiberationMono-Italic.ttf') format('truetype'); |
| 267 | - | font-display: swap; | 267 | + | font-display: swap; |
| 268 | - | font-weight: normal; | 268 | + | font-weight: normal; |
| 269 | - | font-style: italic; | 269 | + | font-style: italic; |
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | @font-face { | 272 | @font-face { | ||
| 273 | - | font-family: 'Courier New'; | 273 | + | font-family: 'Courier New'; |
| 274 | - | src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype'); | 274 | + | src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype'); |
| 275 | - | font-display: swap; | 275 | + | font-display: swap; |
| 276 | - | font-weight: bold; | 276 | + | font-weight: bold; |
| 277 | - | font-style: italic; | 277 | + | font-style: italic; |
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | /* ===== RETRO FONT STYLING ===== */ | 280 | /* ===== RETRO FONT STYLING ===== */ | ||
| 281 | /* Retro Windows fonts with proper fallbacks */ | 281 | /* Retro Windows fonts with proper fallbacks */ | ||
| 282 | .retro-font-pixelated { | 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 { | 286 | + | .retro-font-tahoma { |
| 287 | - | font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif; | 287 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', Geneva, sans-serif; |
| 288 | } | 288 | } | ||
| 289 | 289 | ||||
| 290 | - | .retro-font-verdana { | 290 | + | .retro-font-verdana { |
| 291 | - | font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif; | 291 | + | font-family: Verdana, 'Pixelated MS Sans Serif', Geneva, sans-serif; |
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | - | .retro-font-comicSans { | 294 | + | .retro-font-comicSans { |
| 295 | - | font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif; | 295 | + | font-family: 'Comic Sans MS', 'Pixelated MS Sans Serif', cursive, sans-serif; |
| 296 | } | 296 | } | ||
| 297 | 297 | ||||
| 298 | - | .retro-font-timesNewRoman { | 298 | + | .retro-font-timesNewRoman { |
| 299 | - | font-family: "Times New Roman", Times, serif; | 299 | + | font-family: 'Times New Roman', Times, serif; |
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | - | .retro-font-arial { | 302 | + | .retro-font-arial { |
| 303 | - | font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif; | 303 | + | font-family: Arial, 'Pixelated MS Sans Serif', Helvetica, sans-serif; |
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | - | .retro-font-courierNew { | 306 | + | .retro-font-courierNew { |
| 307 | - | font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace; | 307 | + | font-family: 'Courier New', 'Perfect DOS VGA 437 Win', Courier, monospace; |
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | .retro-font-dos { | 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 | /* ===== GRADIENT TEXT EFFECTS ===== */ | 314 | /* ===== GRADIENT TEXT EFFECTS ===== */ | ||
| 315 | .gradient-text { | 315 | .gradient-text { | ||
| 316 | - | background-size: 200% 200%; | 316 | + | background-size: 200% 200%; |
| 317 | - | -webkit-background-clip: text; | 317 | + | -webkit-background-clip: text; |
| 318 | - | -webkit-text-fill-color: transparent; | 318 | + | -webkit-text-fill-color: transparent; |
| 319 | - | background-clip: text; | 319 | + | background-clip: text; |
| 320 | - | animation: gradientShift 3s ease infinite; | 320 | + | animation: gradientShift 3s ease infinite; |
| 321 | } | 321 | } | ||
| 322 | 322 | ||||
| 323 | .gradient-text-static { | 323 | .gradient-text-static { | ||
| 324 | - | /* Remove text-fill-color to allow inline color styles to work */ | 324 | + | /* Remove text-fill-color to allow inline color styles to work */ |
| 325 | - | /* Individual spans will have their own colors set inline */ | 325 | + | /* Individual spans will have their own colors set inline */ |
| 326 | - | letter-spacing: normal; | 326 | + | letter-spacing: normal; |
| 327 | - | word-spacing: normal; | 327 | + | word-spacing: normal; |
| 328 | } | 328 | } | ||
| 329 | 329 | ||||
| 330 | /* Gradient text container uses flexbox */ | 330 | /* Gradient text container uses flexbox */ | ||
| 331 | .gradient-text-static { | 331 | .gradient-text-static { | ||
| 332 | - | display: inline-flex !important; | 332 | + | display: inline-flex !important; |
| 333 | - | flex-wrap: wrap !important; | 333 | + | flex-wrap: wrap !important; |
| 334 | - | align-items: baseline !important; | 334 | + | align-items: baseline !important; |
| 335 | } | 335 | } | ||
| 336 | 336 | ||||
| 337 | /* Ensure gradient character spans don't add spacing */ | 337 | /* Ensure gradient character spans don't add spacing */ | ||
| 338 | .gradient-text-static > span { | 338 | .gradient-text-static > span { | ||
| 339 | - | display: inline !important; | 339 | + | display: inline !important; |
| 340 | - | margin: 0 !important; | 340 | + | margin: 0 !important; |
| 341 | - | padding: 0 !important; | 341 | + | padding: 0 !important; |
| 342 | - | letter-spacing: 0 !important; | 342 | + | letter-spacing: 0 !important; |
| 343 | - | line-height: inherit !important; | 343 | + | line-height: inherit !important; |
| 344 | - | font-size: inherit !important; | 344 | + | font-size: inherit !important; |
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | /* Ensure gradient character colors from inline styles are respected */ | 347 | /* Ensure gradient character colors from inline styles are respected */ | ||
| 348 | .gradient-text-static .gradient-char { | 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 | /* Gradient text in input fields - preserve normal input background */ | 352 | /* Gradient text in input fields - preserve normal input background */ | ||
| 353 | - | input[style*="background-clip: text"] { | 353 | + | input[style*='background-clip: text'] { |
| 354 | - | /* Keep default input background while allowing gradient text */ | 354 | + | /* Keep default input background while allowing gradient text */ |
| 355 | } | 355 | } | ||
| 356 | 356 | ||||
| 357 | /* Gradient animation for AOL-style effects */ | 357 | /* Gradient animation for AOL-style effects */ | ||
| 358 | @keyframes gradientShift { | 358 | @keyframes gradientShift { | ||
| 359 | - | 0% { background-position: 0% 50%; } | 359 | + | 0% { |
| 360 | - | 50% { background-position: 100% 50%; } | 360 | + | background-position: 0% 50%; |
| 361 | - | 100% { 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 | /* Specific gradient presets */ | 370 | /* Specific gradient presets */ | ||
| 365 | .gradient-rainbow { | 371 | .gradient-rainbow { | ||
| 366 | - | background: linear-gradient(45deg, | 372 | + | background: linear-gradient( |
| 367 | - | #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80, | 373 | + | 45deg, |
| 368 | - | #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080); | 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 | .gradient-fire { | 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 | .gradient-ocean { | 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 | .gradient-sunset { | 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 | .gradient-neon { | 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 | .gradient-forest { | 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 | .gradient-royal { | 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 | /* ===== CHAT MESSAGE FORMATTING ===== */ | 413 | /* ===== CHAT MESSAGE FORMATTING ===== */ | ||
| 396 | .formatted-message { | 414 | .formatted-message { | ||
| 397 | - | word-break: break-word; | 415 | + | word-break: break-word; |
| 398 | - | line-height: 1.4; | 416 | + | line-height: 1.4; |
| 399 | - | max-width: 100%; | 417 | + | max-width: 100%; |
| 400 | } | 418 | } | ||
| 401 | 419 | ||||
| 402 | .formatted-message strong { | 420 | .formatted-message strong { | ||
| 403 | - | font-weight: bold; | 421 | + | font-weight: bold; |
| 404 | } | 422 | } | ||
| 405 | 423 | ||||
| 406 | .formatted-message em { | 424 | .formatted-message em { | ||
| 407 | - | font-style: italic; | 425 | + | font-style: italic; |
| 408 | } | 426 | } | ||
| 409 | 427 | ||||
| 410 | .formatted-message u { | 428 | .formatted-message u { | ||
| 411 | - | text-decoration: underline; | 429 | + | text-decoration: underline; |
| 412 | } | 430 | } | ||
| 413 | 431 | ||||
| 414 | .formatted-message s { | 432 | .formatted-message s { | ||
| 415 | - | text-decoration: line-through; | 433 | + | text-decoration: line-through; |
| 416 | } | 434 | } | ||
| 417 | 435 | ||||
| 418 | .formatted-message code { | 436 | .formatted-message code { | ||
| 419 | - | background: #f0f0f0; | 437 | + | background: #f0f0f0; |
| 420 | - | padding: 1px 3px; | 438 | + | padding: 1px 3px; |
| 421 | - | border-radius: 2px; | 439 | + | border-radius: 2px; |
| 422 | - | font-family: 'Courier New', Courier, monospace; | 440 | + | font-family: 'Courier New', Courier, monospace; |
| 423 | - | font-size: 0.9em; | 441 | + | font-size: 0.9em; |
| 424 | } | 442 | } | ||
| 425 | 443 | ||||
| 426 | /* ===== INPUT STYLING FIXES ===== */ | 444 | /* ===== INPUT STYLING FIXES ===== */ | ||
| 427 | /* Fix letter spacing issues with gradient text in inputs */ | 445 | /* Fix letter spacing issues with gradient text in inputs */ | ||
| 428 | - | input[style*="background-clip: text"] { | 446 | + | input[style*='background-clip: text'] { |
| 429 | - | letter-spacing: normal !important; | 447 | + | letter-spacing: normal !important; |
| 430 | - | word-spacing: normal !important; | 448 | + | word-spacing: normal !important; |
| 431 | } | 449 | } | ||
| 432 | 450 | ||||
| 433 | /* Ensure gradient inputs don't have extra padding */ | 451 | /* Ensure gradient inputs don't have extra padding */ | ||
| 434 | input.styled-input { | 452 | input.styled-input { | ||
| 435 | - | letter-spacing: 0; | 453 | + | letter-spacing: 0; |
| 436 | - | word-spacing: 0; | 454 | + | word-spacing: 0; |
| 437 | } | 455 | } | ||
| 438 | 456 | ||||
| 439 | /* Ensure proper font weights are used */ | 457 | /* Ensure proper font weights are used */ | ||
| 440 | .retro-font-tahoma { | 458 | .retro-font-tahoma { | ||
| 441 | - | font-weight: 80; | 459 | + | font-weight: 80; |
| 442 | } | 460 | } | ||
| 443 | 461 | ||||
| 444 | /* ===== TEXT STYLE SELECTOR STYLING ===== */ | 462 | /* ===== TEXT STYLE SELECTOR STYLING ===== */ | ||
| 445 | .text-formatting-toolbar { | 463 | .text-formatting-toolbar { | ||
| 446 | - | background: #f0f0f0; | 464 | + | background: #f0f0f0; |
| 447 | - | border: 2px inset #dfdfdf; | 465 | + | border: 2px inset #dfdfdf; |
| 448 | - | padding: 4px; | 466 | + | padding: 4px; |
| 449 | - | margin-bottom: 8px; | 467 | + | margin-bottom: 8px; |
| 450 | - | display: flex; | 468 | + | display: flex; |
| 451 | - | flex-wrap: wrap; | 469 | + | flex-wrap: wrap; |
| 452 | - | gap: 4px; | 470 | + | gap: 4px; |
| 453 | - | align-items: center; | 471 | + | align-items: center; |
| 454 | } | 472 | } | ||
| 455 | 473 | ||||
| 456 | .formatting-button { | 474 | .formatting-button { | ||
| 457 | - | width: 24px; | 475 | + | width: 24px; |
| 458 | - | height: 24px; | 476 | + | height: 24px; |
| 459 | - | border: 1px outset #dfdfdf; | 477 | + | border: 1px outset #dfdfdf; |
| 460 | - | background: #f0f0f0; | 478 | + | background: #f0f0f0; |
| 461 | - | cursor: pointer; | 479 | + | cursor: pointer; |
| 462 | - | display: flex; | 480 | + | display: flex; |
| 463 | - | align-items: center; | 481 | + | align-items: center; |
| 464 | - | justify-content: center; | 482 | + | justify-content: center; |
| 465 | - | font-size: 12px; | 483 | + | font-size: 12px; |
| 466 | - | font-weight: bold; | 484 | + | font-weight: bold; |
| 467 | - | transition: all 0.1s ease; | 485 | + | transition: all 0.1s ease; |
| 468 | } | 486 | } | ||
| 469 | 487 | ||||
| 470 | .formatting-button:hover { | 488 | .formatting-button:hover { | ||
| 471 | - | background: #e0e0e0; | 489 | + | background: #e0e0e0; |
| 472 | } | 490 | } | ||
| 473 | 491 | ||||
| 474 | .formatting-button:active, | 492 | .formatting-button:active, | ||
| 475 | .formatting-button.active { | 493 | .formatting-button.active { | ||
| 476 | - | border: 1px inset #dfdfdf; | 494 | + | border: 1px inset #dfdfdf; |
| 477 | - | background: #d0d0d0; | 495 | + | background: #d0d0d0; |
| 478 | } | 496 | } | ||
| 479 | 497 | ||||
| 480 | .font-selector { | 498 | .font-selector { | ||
| 481 | - | padding: 2px 4px; | 499 | + | padding: 2px 4px; |
| 482 | - | border: 1px inset #dfdfdf; | 500 | + | border: 1px inset #dfdfdf; |
| 483 | - | background: white; | 501 | + | background: white; |
| 484 | - | font-size: 11px; | 502 | + | font-size: 11px; |
| 485 | - | min-width: 80px; | 503 | + | min-width: 80px; |
| 486 | } | 504 | } | ||
| 487 | 505 | ||||
| 488 | .size-slider { | 506 | .size-slider { | ||
| 489 | - | width: 60px; | 507 | + | width: 60px; |
| 490 | } | 508 | } | ||
| 491 | 509 | ||||
| 492 | .color-indicator { | 510 | .color-indicator { | ||
| 493 | - | width: 16px; | 511 | + | width: 16px; |
| 494 | - | height: 16px; | 512 | + | height: 16px; |
| 495 | - | border: 1px solid #000; | 513 | + | border: 1px solid #000; |
| 496 | - | cursor: pointer; | 514 | + | cursor: pointer; |
| 497 | - | display: inline-block; | 515 | + | display: inline-block; |
| 498 | } | 516 | } | ||
| 499 | 517 | ||||
| 500 | /* ===== ACCESSIBILITY IMPROVEMENTS ===== */ | 518 | /* ===== ACCESSIBILITY IMPROVEMENTS ===== */ | ||
| 501 | @media (prefers-reduced-motion: reduce) { | 519 | @media (prefers-reduced-motion: reduce) { | ||
| 502 | - | .gradient-text { | 520 | + | .gradient-text { |
| 503 | - | animation: none; | 521 | + | animation: none; |
| 504 | - | } | 522 | + | } |
| 505 | } | 523 | } | ||
| 506 | 524 | ||||
| 507 | /* High contrast mode support */ | 525 | /* High contrast mode support */ | ||
| 508 | @media (prefers-contrast: high) { | 526 | @media (prefers-contrast: high) { | ||
| 509 | - | .gradient-text { | 527 | + | .gradient-text { |
| 510 | - | -webkit-text-fill-color: unset; | 528 | + | -webkit-text-fill-color: unset; |
| 511 | - | background: none; | 529 | + | background: none; |
| 512 | - | color: inherit; | 530 | + | color: inherit; |
| 513 | - | } | 531 | + | } |
| 514 | - | 532 | + | ||
| 515 | - | .gradient-text-static { | 533 | + | .gradient-text-static { |
| 516 | - | -webkit-text-fill-color: unset; | 534 | + | -webkit-text-fill-color: unset; |
| 517 | - | background: none; | 535 | + | background: none; |
| 518 | - | color: inherit; | 536 | + | color: inherit; |
| 519 | - | } | 537 | + | } |
| 520 | } | 538 | } | ||
| 521 | 539 | ||||
| 522 | /* ===== RETRO STYLING ENHANCEMENTS ===== */ | 540 | /* ===== RETRO STYLING ENHANCEMENTS ===== */ | ||
| 523 | .retro-message { | 541 | .retro-message { | ||
| 524 | - | font-family: inherit; | 542 | + | font-family: inherit; |
| 525 | - | line-height: 1.3; | 543 | + | line-height: 1.3; |
| 526 | - | word-wrap: break-word; | 544 | + | word-wrap: break-word; |
| 527 | } | 545 | } | ||
| 528 | 546 | ||||
| 529 | .retro-message.style-bold { | 547 | .retro-message.style-bold { | ||
| 530 | - | font-weight: bold; | 548 | + | font-weight: bold; |
| 531 | } | 549 | } | ||
| 532 | 550 | ||||
| 533 | .retro-message.style-italic { | 551 | .retro-message.style-italic { | ||
| 534 | - | font-style: italic; | 552 | + | font-style: italic; |
| 535 | } | 553 | } | ||
| 536 | 554 | ||||
| 537 | .retro-message.style-underline { | 555 | .retro-message.style-underline { | ||
| 538 | - | text-decoration: underline; | 556 | + | text-decoration: underline; |
| 539 | } | 557 | } | ||
| 540 | 558 | ||||
| 541 | .retro-message.style-strikethrough { | 559 | .retro-message.style-strikethrough { | ||
| 542 | - | text-decoration: line-through; | 560 | + | text-decoration: line-through; |
| 543 | } | 561 | } | ||
| 544 | 562 | ||||
| 545 | .retro-message.style-underline.style-strikethrough { | 563 | .retro-message.style-underline.style-strikethrough { | ||
| 546 | - | text-decoration: underline line-through; | 564 | + | text-decoration: underline line-through; |
| 547 | } | 565 | } | ||
| 548 | 566 | ||||
| 549 | /* ===== PERFORMANCE OPTIMIZATIONS ===== */ | 567 | /* ===== PERFORMANCE OPTIMIZATIONS ===== */ | ||
| 550 | .gradient-text, | 568 | .gradient-text, | ||
| 551 | .gradient-text-static { | 569 | .gradient-text-static { | ||
| 552 | - | will-change: background-position; | 570 | + | will-change: background-position; |
| 553 | - | contain: layout style paint; | 571 | + | contain: layout style paint; |
| 554 | } | 572 | } | ||
| 555 | 573 | ||||
| 556 | /* Limit gradient text length for performance */ | 574 | /* Limit gradient text length for performance */ | ||
| 557 | .gradient-text-long { | 575 | .gradient-text-long { | ||
| 558 | - | max-width: 500px; | 576 | + | max-width: 500px; |
| 559 | - | overflow: hidden; | 577 | + | overflow: hidden; |
| 560 | - | text-overflow: ellipsis; | 578 | + | text-overflow: ellipsis; |
| 561 | } | 579 | } | ||
| 562 | - | ||||
M src/app.d.ts +2 -2
| @@ -1,6 +1,6 @@ | |||||
| 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | ||
| 2 | // for information about these interfaces | 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 | declare global { | 5 | declare global { | ||
| 6 | namespace App { | 6 | namespace App { | ||
| @@ -13,7 +13,7 @@ declare global { | |||||
| 13 | // interface PageState {} | 13 | // interface PageState {} | ||
| 14 | // interface Platform {} | 14 | // interface Platform {} | ||
| 15 | } | 15 | } | ||
| 16 | - | type Window = object | 16 | + | type Window = object; |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | export {}; | 19 | export {}; | ||
M src/hooks.server.ts +88 -87
| @@ -1,105 +1,106 @@ | |||||
| 1 | - | import type { Handle } from "@sveltejs/kit"; | 1 | + | import type { Handle } from '@sveltejs/kit'; |
| 2 | - | import db from "$lib/db/db.server"; | 2 | + | import db from '$lib/db/db.server'; |
| 3 | - | import { handleRateLimit } from "$lib/api/rate-limiter"; | 3 | + | import { handleRateLimit } from '$lib/api/rate-limiter'; |
| 4 | - | import { users } from "$lib/db/schema"; | 4 | + | import { users } from '$lib/db/schema'; |
| 5 | - | import { createLogger } from "$lib/utils/logger.server"; | 5 | + | import { createLogger } from '$lib/utils/logger.server'; |
| 6 | - | import { ensureDefaultChatRoom } from "$lib/utils/chat.server"; | 6 | + | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; |
| 7 | - | import { validateSessionToken } from "$lib/api/session.server"; | 7 | + | import { validateSessionToken } from '$lib/api/session.server'; |
| 8 | - | import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie"; | 8 | + | import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/session.cookie'; |
| 9 | 9 | ||||
| 10 | const log = createLogger('hooks-server'); | 10 | const log = createLogger('hooks-server'); | ||
| 11 | 11 | ||||
| 12 | // Set all users to offline on server start | 12 | // Set all users to offline on server start | ||
| 13 | async function setAllUsersOffline() { | 13 | async function setAllUsersOffline() { | ||
| 14 | - | try { | 14 | + | try { |
| 15 | - | await db.update(users) | 15 | + | await db.update(users).set({ status: 'offline' }); |
| 16 | - | .set({ status: 'offline'}); | 16 | + | log.info('All users set to offline on server start'); |
| 17 | - | log.info('All users set to offline on server start'); | 17 | + | } catch (error: unknown) { |
| 18 | - | } catch (error: unknown) { | 18 | + | log.error('Failed to set users offline on server start:', { error }); |
| 19 | - | log.error('Failed to set users offline on server start:', { error }); | 19 | + | } |
| 20 | - | } | |||
| 21 | } | 20 | } | ||
| 22 | 21 | ||||
| 23 | // Initialize server | 22 | // Initialize server | ||
| 24 | Promise.resolve() | 23 | Promise.resolve() | ||
| 25 | - | .then(async () => { | 24 | + | .then(async () => { |
| 26 | - | log.info('Initializing server...'); | 25 | + | log.info('Initializing server...'); |
| 27 | - | await ensureDefaultChatRoom(db); | 26 | + | await ensureDefaultChatRoom(db); |
| 28 | - | await setAllUsersOffline(); | 27 | + | await setAllUsersOffline(); |
| 29 | - | log.info('Server initialized successfully'); | 28 | + | log.info('Server initialized successfully'); |
| 30 | - | }) | 29 | + | }) |
| 31 | - | .catch((error: unknown) => { | 30 | + | .catch((error: unknown) => { |
| 32 | - | log.error('Failed to initialize server:', { error }); | 31 | + | log.error('Failed to initialize server:', { error }); |
| 33 | - | process.exit(1); | 32 | + | process.exit(1); |
| 34 | - | }); | 33 | + | }); |
| 35 | 34 | ||||
| 36 | export const handle: Handle = async ({ event, resolve }) => { | 35 | export const handle: Handle = async ({ event, resolve }) => { | ||
| 37 | - | log.debug("Handling request", { path: event.url.pathname }); | 36 | + | log.debug('Handling request', { path: event.url.pathname }); |
| 38 | - | ||||
| 39 | - | // Get session token from cookies | |||
| 40 | - | const token = event.cookies.get("session") ?? null; | |||
| 41 | 37 | ||||
| 42 | - | // Validate session token if token exists | 38 | + | // Get session token from cookies |
| 43 | - | if (token) { | 39 | + | const token = event.cookies.get('session') ?? null; |
| 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 | - | } | |||
| 57 | 40 | ||||
| 58 | - | // Identify public chat and room requests if there is no valid session | 41 | + | // Validate session token if token exists |
| 59 | - | const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; | 42 | + | if (token) { |
| 60 | - | const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; | 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) { | 57 | + | // Identify public chat and room requests if there is no valid session |
| 63 | - | log.debug("Public request accessed", { path: event.url.pathname }); | 58 | + | const isPublicChatRequest = |
| 64 | - | const response = await resolve(event); | 59 | + | event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET'; |
| 65 | - | return response; | 60 | + | const isPublicRoomRequest = |
| 66 | - | } | 61 | + | event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET'; |
| 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 | - | ]; | |||
| 77 | 62 | ||||
| 78 | - | // Handle public routes | 63 | + | if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) { |
| 79 | - | if (publicRoutes.includes(event.url.pathname)) { | 64 | + | log.debug('Public request accessed', { path: event.url.pathname }); |
| 80 | - | log.debug("Public route accessed", { path: event.url.pathname }); | 65 | + | const response = await resolve(event); |
| 81 | - | const response = await resolve(event); | 66 | + | return response; |
| 82 | - | return response; | 67 | + | } |
| 83 | - | } | |||
| 84 | 68 | ||||
| 85 | - | // Skip rate limiting for SSE endpoint when authenticated | 69 | + | // Public routes that don't require authentication |
| 86 | - | if (event.url.pathname.startsWith('/api/sse')) { | 70 | + | const publicRoutes = [ |
| 87 | - | return await resolve(event); | 71 | + | '/', |
| 88 | - | } | 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 | 79 | + | // Handle public routes |
| 91 | - | if (!isPublicChatRequest && !isPublicRoomRequest) { | 80 | + | if (publicRoutes.includes(event.url.pathname)) { |
| 92 | - | const rateLimitResult = await handleRateLimit(event); | 81 | + | log.debug('Public route accessed', { path: event.url.pathname }); |
| 93 | - | if (rateLimitResult?.status === 429) { | 82 | + | const response = await resolve(event); |
| 94 | - | return new Response('Too Many Requests', { | 83 | + | return response; |
| 95 | - | status: 429, | 84 | + | } |
| 96 | - | headers: { | |||
| 97 | - | 'Retry-After': '60' | |||
| 98 | - | } | |||
| 99 | - | }); | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | 85 | ||||
| 103 | - | const response = await resolve(event); | 86 | + | // Skip rate limiting for SSE endpoint when authenticated |
| 104 | - | return response; | 87 | + | if (event.url.pathname.startsWith('/api/sse')) { |
| 105 | - | }; ⏎̸ | 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 | import type { Action } from 'svelte/action'; | 1 | import type { Action } from 'svelte/action'; | ||
| 2 | 2 | ||||
| 3 | interface DraggableParameters { | 3 | interface DraggableParameters { | ||
| 4 | - | handle?: string; | 4 | + | handle?: string; |
| 5 | - | bounds?: 'parent' | 'window'; | 5 | + | bounds?: 'parent' | 'window'; |
| 6 | - | enabled?: boolean; | 6 | + | enabled?: boolean; |
| 7 | } | 7 | } | ||
| 8 | 8 | ||||
| 9 | interface DraggableAttributes { | 9 | interface DraggableAttributes { | ||
| 10 | - | ondragstart?: (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; | 11 | + | ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void; |
| 12 | - | ondragend?: (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 = {}) => { | 15 | + | export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = ( |
| 16 | - | let x = 0; | 16 | + | node, |
| 17 | - | let y = 0; | 17 | + | params = {} |
| 18 | - | let startX = 0; | 18 | + | ) => { |
| 19 | - | let startY = 0; | 19 | + | let x = 0; |
| 20 | - | let isDragging = false; | 20 | + | let y = 0; |
| 21 | - | 21 | + | let startX = 0; | |
| 22 | - | let isEnabled = params.enabled !== undefined ? params.enabled : true; | 22 | + | let startY = 0; |
| 23 | - | const handleSelector = params.handle ?? null; | 23 | + | let isDragging = false; |
| 24 | - | const bounds = params.bounds ?? 'window'; | 24 | + | |
| 25 | - | 25 | + | let isEnabled = params.enabled !== undefined ? params.enabled : true; | |
| 26 | - | const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node; | 26 | + | const handleSelector = params.handle ?? null; |
| 27 | - | if (!handleElement) { | 27 | + | const bounds = params.bounds ?? 'window'; |
| 28 | - | console.warn('Draggable handle element not found'); | 28 | + | |
| 29 | - | return; | 29 | + | const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node; |
| 30 | - | } | 30 | + | if (!handleElement) { |
| 31 | - | 31 | + | console.warn('Draggable handle element not found'); | |
| 32 | - | // Ensure proper positioning | 32 | + | return; |
| 33 | - | if (getComputedStyle(node).position === 'static') { | 33 | + | } |
| 34 | - | node.style.position = 'fixed'; | 34 | + | |
| 35 | - | } | 35 | + | // Ensure proper positioning |
| 36 | - | 36 | + | if (getComputedStyle(node).position === 'static') { | |
| 37 | - | function getNodePosition() { | 37 | + | node.style.position = 'fixed'; |
| 38 | - | const rect = node.getBoundingClientRect(); | 38 | + | } |
| 39 | - | return { | 39 | + | |
| 40 | - | x: rect.left, | 40 | + | function getNodePosition() { |
| 41 | - | y: rect.top | 41 | + | const rect = node.getBoundingClientRect(); |
| 42 | - | }; | 42 | + | return { |
| 43 | - | } | 43 | + | x: rect.left, |
| 44 | - | 44 | + | y: rect.top | |
| 45 | - | function updatePosition(newX: number, newY: number) { | 45 | + | }; |
| 46 | - | x = newX; | 46 | + | } |
| 47 | - | y = newY; | 47 | + | |
| 48 | - | node.style.left = `${x}px`; | 48 | + | function updatePosition(newX: number, newY: number) { |
| 49 | - | node.style.top = `${y}px`; | 49 | + | x = newX; |
| 50 | - | } | 50 | + | y = newY; |
| 51 | - | 51 | + | node.style.left = `${x}px`; | |
| 52 | - | function onMouseDown(event: MouseEvent) { | 52 | + | node.style.top = `${y}px`; |
| 53 | - | if (!isEnabled || event.button !== 0) return; | 53 | + | } |
| 54 | - | 54 | + | ||
| 55 | - | // Check if we're clicking a button in the title bar | 55 | + | function onMouseDown(event: MouseEvent) { |
| 56 | - | const target = event.target as HTMLElement; | 56 | + | if (!isEnabled || event.button !== 0) return; |
| 57 | - | if (target.tagName === 'BUTTON' || target.closest('button')) { | 57 | + | |
| 58 | - | return; | 58 | + | // Check if we're clicking a button in the title bar |
| 59 | - | } | 59 | + | const target = event.target as HTMLElement; |
| 60 | - | 60 | + | if (target.tagName === 'BUTTON' || target.closest('button')) { | |
| 61 | - | isDragging = true; | 61 | + | return; |
| 62 | - | 62 | + | } | |
| 63 | - | // Get current position | 63 | + | |
| 64 | - | const pos = getNodePosition(); | 64 | + | isDragging = true; |
| 65 | - | x = pos.x; | 65 | + | |
| 66 | - | y = pos.y; | 66 | + | // Get current position |
| 67 | - | 67 | + | const pos = getNodePosition(); | |
| 68 | - | startX = event.clientX - x; | 68 | + | x = pos.x; |
| 69 | - | startY = event.clientY - y; | 69 | + | y = pos.y; |
| 70 | - | 70 | + | ||
| 71 | - | node.dispatchEvent(new CustomEvent('dragstart', { | 71 | + | startX = event.clientX - x; |
| 72 | - | detail: { x, y } | 72 | + | startY = event.clientY - y; |
| 73 | - | })); | 73 | + | |
| 74 | - | 74 | + | node.dispatchEvent( | |
| 75 | - | // Add dragging class for styling | 75 | + | new CustomEvent('dragstart', { |
| 76 | - | node.classList.add('dragging'); | 76 | + | detail: { x, y } |
| 77 | - | 77 | + | }) | |
| 78 | - | event.preventDefault(); | 78 | + | ); |
| 79 | - | } | 79 | + | |
| 80 | - | 80 | + | // Add dragging class for styling | |
| 81 | - | function onMouseMove(event: MouseEvent) { | 81 | + | node.classList.add('dragging'); |
| 82 | - | if (!isDragging || !isEnabled) return; | 82 | + | |
| 83 | - | 83 | + | event.preventDefault(); | |
| 84 | - | let newX = event.clientX - startX; | 84 | + | } |
| 85 | - | let newY = event.clientY - startY; | 85 | + | |
| 86 | - | 86 | + | function onMouseMove(event: MouseEvent) { | |
| 87 | - | if (bounds === 'window') { | 87 | + | if (!isDragging || !isEnabled) return; |
| 88 | - | newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth)); | 88 | + | |
| 89 | - | newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight)); | 89 | + | let newX = event.clientX - startX; |
| 90 | - | } else if (bounds === 'parent' && node.parentElement) { | 90 | + | let newY = event.clientY - startY; |
| 91 | - | const parent = node.parentElement; | 91 | + | |
| 92 | - | newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth)); | 92 | + | if (bounds === 'window') { |
| 93 | - | newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight)); | 93 | + | newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth)); |
| 94 | - | } | 94 | + | newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight)); |
| 95 | - | 95 | + | } else if (bounds === 'parent' && node.parentElement) { | |
| 96 | - | requestAnimationFrame(() => { | 96 | + | const parent = node.parentElement; |
| 97 | - | updatePosition(newX, newY); | 97 | + | newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth)); |
| 98 | - | node.dispatchEvent(new CustomEvent('dragmove', { | 98 | + | newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight)); |
| 99 | - | detail: { x: newX, y: newY } | 99 | + | } |
| 100 | - | })); | 100 | + | |
| 101 | - | }); | 101 | + | requestAnimationFrame(() => { |
| 102 | - | } | 102 | + | updatePosition(newX, newY); |
| 103 | - | 103 | + | node.dispatchEvent( | |
| 104 | - | function onMouseUp() { | 104 | + | new CustomEvent('dragmove', { |
| 105 | - | if (!isDragging) return; | 105 | + | detail: { x: newX, y: newY } |
| 106 | - | isDragging = false; | 106 | + | }) |
| 107 | - | 107 | + | ); | |
| 108 | - | node.classList.remove('dragging'); | 108 | + | }); |
| 109 | - | 109 | + | } | |
| 110 | - | node.dispatchEvent(new CustomEvent('dragend', { | 110 | + | |
| 111 | - | detail: { x, y } | 111 | + | function onMouseUp() { |
| 112 | - | })); | 112 | + | if (!isDragging) return; |
| 113 | - | } | 113 | + | isDragging = false; |
| 114 | - | 114 | + | ||
| 115 | - | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | 115 | + | node.classList.remove('dragging'); |
| 116 | - | handleElement.addEventListener('mousedown', onMouseDown); | 116 | + | |
| 117 | - | window.addEventListener('mousemove', onMouseMove, { passive: true }); | 117 | + | node.dispatchEvent( |
| 118 | - | window.addEventListener('mouseup', onMouseUp); | 118 | + | new CustomEvent('dragend', { |
| 119 | - | 119 | + | detail: { x, y } | |
| 120 | - | return { | 120 | + | }) |
| 121 | - | update(newParams: DraggableParameters) { | 121 | + | ); |
| 122 | - | isEnabled = newParams.enabled !== undefined ? newParams.enabled : true; | 122 | + | } |
| 123 | - | handleElement.style.cursor = isEnabled ? 'move' : 'default'; | 123 | + | |
| 124 | - | Object.assign(params, newParams); | 124 | + | handleElement.style.cursor = isEnabled ? 'move' : 'default'; |
| 125 | - | }, | 125 | + | handleElement.addEventListener('mousedown', onMouseDown); |
| 126 | - | destroy() { | 126 | + | window.addEventListener('mousemove', onMouseMove, { passive: true }); |
| 127 | - | handleElement.removeEventListener('mousedown', onMouseDown); | 127 | + | window.addEventListener('mouseup', onMouseUp); |
| 128 | - | window.removeEventListener('mousemove', onMouseMove); | 128 | + | |
| 129 | - | window.removeEventListener('mouseup', onMouseUp); | 129 | + | return { |
| 130 | - | } | 130 | + | update(newParams: DraggableParameters) { |
| 131 | - | }; | 131 | + | isEnabled = newParams.enabled !== undefined ? newParams.enabled : true; |
| 132 | - | }; ⏎̸ | 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 | import type { Action } from 'svelte/action'; | 1 | import type { Action } from 'svelte/action'; | ||
| 2 | interface MaximizableParameters { | 2 | interface MaximizableParameters { | ||
| 3 | - | enabled?: boolean; | 3 | + | enabled?: boolean; |
| 4 | - | padding?: number; | 4 | + | padding?: number; |
| 5 | } | 5 | } | ||
| 6 | 6 | ||||
| 7 | interface MaximizableAttributes { | 7 | interface MaximizableAttributes { | ||
| 8 | - | onmaximize?: (e: CustomEvent<{ | 8 | + | onmaximize?: ( |
| 9 | - | isMaximized: boolean; | 9 | + | e: CustomEvent<{ |
| 10 | - | width: number; | 10 | + | isMaximized: boolean; |
| 11 | - | height: number; | 11 | + | width: number; |
| 12 | - | x: number; | 12 | + | height: number; |
| 13 | - | y: number; | 13 | + | x: number; |
| 14 | - | }>) => void; | 14 | + | y: number; |
| 15 | + | }> | |||
| 16 | + | ) => void; | |||
| 15 | } | 17 | } | ||
| 16 | 18 | ||||
| 17 | interface StoredState { | 19 | interface StoredState { | ||
| 18 | - | width: number; | 20 | + | width: number; |
| 19 | - | height: number; | 21 | + | height: number; |
| 20 | - | x: number; | 22 | + | x: number; |
| 21 | - | y: number; | 23 | + | y: number; |
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | - | export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (node, params = {}) => { | 26 | + | export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = ( |
| 25 | - | const { | 27 | + | node, |
| 26 | - | enabled = true, | 28 | + | params = {} |
| 27 | - | padding = 0 | 29 | + | ) => { |
| 28 | - | } = params; | 30 | + | const { enabled = true, padding = 0 } = params; |
| 29 | - | 31 | + | ||
| 30 | - | let isMaximized = false; | 32 | + | let isMaximized = false; |
| 31 | - | let storedState: StoredState | null = null; | 33 | + | let storedState: StoredState | null = null; |
| 32 | - | 34 | + | ||
| 33 | - | function maximize() { | 35 | + | function maximize() { |
| 34 | - | if (!enabled || isMaximized) return; | 36 | + | if (!enabled || isMaximized) return; |
| 35 | - | 37 | + | ||
| 36 | - | // Store current state | 38 | + | // Store current state |
| 37 | - | storedState = { | 39 | + | storedState = { |
| 38 | - | width: node.offsetWidth, | 40 | + | width: node.offsetWidth, |
| 39 | - | height: node.offsetHeight, | 41 | + | height: node.offsetHeight, |
| 40 | - | x: parseInt(node.style.left) || 0, | 42 | + | x: parseInt(node.style.left) || 0, |
| 41 | - | y: parseInt(node.style.top) || 0 | 43 | + | y: parseInt(node.style.top) || 0 |
| 42 | - | }; | 44 | + | }; |
| 43 | - | 45 | + | ||
| 44 | - | // Set maximized state | 46 | + | // Set maximized state |
| 45 | - | const maxWidth = window.innerWidth - (padding * 2); | 47 | + | const maxWidth = window.innerWidth - padding * 2; |
| 46 | - | const maxHeight = window.innerHeight - (padding * 2); | 48 | + | const maxHeight = window.innerHeight - padding * 2; |
| 47 | - | 49 | + | ||
| 48 | - | node.style.width = `${maxWidth}px`; | 50 | + | node.style.width = `${maxWidth}px`; |
| 49 | - | node.style.height = `${maxHeight}px`; | 51 | + | node.style.height = `${maxHeight}px`; |
| 50 | - | node.style.left = `${padding}px`; | 52 | + | node.style.left = `${padding}px`; |
| 51 | - | node.style.top = `${padding}px`; | 53 | + | node.style.top = `${padding}px`; |
| 52 | - | 54 | + | ||
| 53 | - | isMaximized = true; | 55 | + | isMaximized = true; |
| 54 | - | 56 | + | ||
| 55 | - | node.dispatchEvent(new CustomEvent('maximize', { | 57 | + | node.dispatchEvent( |
| 56 | - | detail: { | 58 | + | new CustomEvent('maximize', { |
| 57 | - | isMaximized: true, | 59 | + | detail: { |
| 58 | - | width: maxWidth, | 60 | + | isMaximized: true, |
| 59 | - | height: maxHeight, | 61 | + | width: maxWidth, |
| 60 | - | x: padding, | 62 | + | height: maxHeight, |
| 61 | - | y: padding | 63 | + | x: padding, |
| 62 | - | } | 64 | + | y: padding |
| 63 | - | })); | 65 | + | } |
| 64 | - | } | 66 | + | }) |
| 65 | - | 67 | + | ); | |
| 66 | - | function restore() { | 68 | + | } |
| 67 | - | if (!enabled || !isMaximized || !storedState) return; | 69 | + | |
| 68 | - | 70 | + | function restore() { | |
| 69 | - | // Restore previous state | 71 | + | if (!enabled || !isMaximized || !storedState) return; |
| 70 | - | node.style.width = `${storedState.width}px`; | 72 | + | |
| 71 | - | node.style.height = `${storedState.height}px`; | 73 | + | // Restore previous state |
| 72 | - | node.style.left = `${storedState.x}px`; | 74 | + | node.style.width = `${storedState.width}px`; |
| 73 | - | node.style.top = `${storedState.y}px`; | 75 | + | node.style.height = `${storedState.height}px`; |
| 74 | - | 76 | + | node.style.left = `${storedState.x}px`; | |
| 75 | - | isMaximized = false; | 77 | + | node.style.top = `${storedState.y}px`; |
| 76 | - | 78 | + | ||
| 77 | - | node.dispatchEvent(new CustomEvent('maximize', { | 79 | + | isMaximized = false; |
| 78 | - | detail: { | 80 | + | |
| 79 | - | isMaximized: false, | 81 | + | node.dispatchEvent( |
| 80 | - | ...storedState | 82 | + | new CustomEvent('maximize', { |
| 81 | - | } | 83 | + | detail: { |
| 82 | - | })); | 84 | + | isMaximized: false, |
| 83 | - | } | 85 | + | ...storedState |
| 84 | - | 86 | + | } | |
| 85 | - | function toggle() { | 87 | + | }) |
| 86 | - | if (isMaximized) { | 88 | + | ); |
| 87 | - | restore(); | 89 | + | } |
| 88 | - | } else { | 90 | + | |
| 89 | - | maximize(); | 91 | + | function toggle() { |
| 90 | - | } | 92 | + | if (isMaximized) { |
| 91 | - | } | 93 | + | restore(); |
| 92 | - | 94 | + | } else { | |
| 93 | - | // Expose methods to the node | 95 | + | maximize(); |
| 94 | - | Object.assign(node, { | 96 | + | } |
| 95 | - | maximize, | 97 | + | } |
| 96 | - | restore, | 98 | + | |
| 97 | - | toggleMaximize: toggle, | 99 | + | // Expose methods to the node |
| 98 | - | isMaximized: () => isMaximized | 100 | + | Object.assign(node, { |
| 99 | - | }); | 101 | + | maximize, |
| 100 | - | 102 | + | restore, | |
| 101 | - | return { | 103 | + | toggleMaximize: toggle, |
| 102 | - | update(newParams: MaximizableParameters) { | 104 | + | isMaximized: () => isMaximized |
| 103 | - | Object.assign(params, newParams); | 105 | + | }); |
| 104 | - | }, | 106 | + | |
| 105 | - | destroy() { | 107 | + | return { |
| 106 | - | // Clean up if needed | 108 | + | update(newParams: MaximizableParameters) { |
| 107 | - | } | 109 | + | Object.assign(params, newParams); |
| 108 | - | }; | 110 | + | }, |
| 109 | - | }; ⏎̸ | 111 | + | destroy() { |
| 112 | + | // Clean up if needed | |||
| 113 | + | } | |||
| 114 | + | }; | |||
| 115 | + | }; | |||
M src/lib/actions/minimizable.ts +168 -157
| @@ -1,168 +1,179 @@ | |||||
| 1 | import type { Action } from 'svelte/action'; | 1 | import type { Action } from 'svelte/action'; | ||
| 2 | 2 | ||||
| 3 | interface MinimizableParameters { | 3 | interface MinimizableParameters { | ||
| 4 | - | enabled?: boolean; | 4 | + | enabled?: boolean; |
| 5 | - | onMinimize?: (isMinimized: boolean) => void; | 5 | + | onMinimize?: (isMinimized: boolean) => void; |
| 6 | + | } | |||
| 7 | + | ||||
| 8 | + | export interface MinimizableNode extends HTMLDivElement { | |||
| 9 | + | minimize: () => void; | |||
| 10 | + | restore: () => void; | |||
| 11 | + | toggleMinimize: () => void; | |||
| 12 | + | isMinimized: () => boolean; | |||
| 6 | } | 13 | } | ||
| 7 | 14 | ||||
| 8 | interface MinimizableAttributes { | 15 | interface MinimizableAttributes { | ||
| 9 | - | onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void; | 16 | + | onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void; |
| 10 | } | 17 | } | ||
| 11 | 18 | ||||
| 12 | interface StoredState { | 19 | interface StoredState { | ||
| 13 | - | width: number; | 20 | + | width: number; |
| 14 | - | height: number; | 21 | + | height: number; |
| 15 | - | x: number; | 22 | + | x: number; |
| 16 | - | y: number; | 23 | + | y: number; |
| 17 | } | 24 | } | ||
| 18 | 25 | ||||
| 19 | - | export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (node, params = {}) => { | 26 | + | export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = ( |
| 20 | - | const { | 27 | + | node, |
| 21 | - | enabled = true, | 28 | + | params = {} |
| 22 | - | onMinimize | 29 | + | ) => { |
| 23 | - | } = params; | 30 | + | const { enabled = true, onMinimize } = params; |
| 24 | - | 31 | + | ||
| 25 | - | let isMinimized = false; | 32 | + | let isMinimized = false; |
| 26 | - | let storedState: StoredState | null = null; | 33 | + | let storedState: StoredState | null = null; |
| 27 | - | 34 | + | ||
| 28 | - | function calculateMinimizedWidth() { | 35 | + | function calculateMinimizedWidth() { |
| 29 | - | const titleBar = node.querySelector('.title-bar-text'); | 36 | + | const titleBar = node.querySelector('.title-bar-text'); |
| 30 | - | if (!titleBar) return 200; | 37 | + | if (!titleBar) return 200; |
| 31 | - | 38 | + | ||
| 32 | - | // Create a temporary element to measure text width | 39 | + | // Create a temporary element to measure text width |
| 33 | - | const temp = document.createElement('div'); | 40 | + | const temp = document.createElement('div'); |
| 34 | - | temp.style.position = 'absolute'; | 41 | + | temp.style.position = 'absolute'; |
| 35 | - | temp.style.visibility = 'hidden'; | 42 | + | temp.style.visibility = 'hidden'; |
| 36 | - | temp.style.whiteSpace = 'nowrap'; | 43 | + | temp.style.whiteSpace = 'nowrap'; |
| 37 | - | temp.style.font = window.getComputedStyle(titleBar).font; | 44 | + | temp.style.font = window.getComputedStyle(titleBar).font; |
| 38 | - | temp.textContent = titleBar.textContent; | 45 | + | temp.textContent = titleBar.textContent; |
| 39 | - | document.body.appendChild(temp); | 46 | + | document.body.appendChild(temp); |
| 40 | - | 47 | + | ||
| 41 | - | // Get the width and add some padding | 48 | + | // Get the width and add some padding |
| 42 | - | const width = temp.offsetWidth + 100; // Add 100px for buttons and padding | 49 | + | const width = temp.offsetWidth + 100; // Add 100px for buttons and padding |
| 43 | - | document.body.removeChild(temp); | 50 | + | document.body.removeChild(temp); |
| 44 | - | 51 | + | ||
| 45 | - | return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px | 52 | + | return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px |
| 46 | - | } | 53 | + | } |
| 47 | - | 54 | + | ||
| 48 | - | function minimize() { | 55 | + | function minimize() { |
| 49 | - | if (!enabled || isMinimized) return; | 56 | + | if (!enabled || isMinimized) return; |
| 50 | - | 57 | + | ||
| 51 | - | // Store current state | 58 | + | // Store current state |
| 52 | - | storedState = { | 59 | + | storedState = { |
| 53 | - | width: node.offsetWidth, | 60 | + | width: node.offsetWidth, |
| 54 | - | height: node.offsetHeight, | 61 | + | height: node.offsetHeight, |
| 55 | - | x: parseInt(node.style.left) || 0, | 62 | + | x: parseInt(node.style.left) || 0, |
| 56 | - | y: parseInt(node.style.top) || 0 | 63 | + | y: parseInt(node.style.top) || 0 |
| 57 | - | }; | 64 | + | }; |
| 58 | - | 65 | + | ||
| 59 | - | // Check if we're in mobile mode | 66 | + | // Check if we're in mobile mode |
| 60 | - | const isMobile = window.innerWidth <= 768; | 67 | + | const isMobile = window.innerWidth <= 768; |
| 61 | - | 68 | + | ||
| 62 | - | if (!isMobile) { | 69 | + | if (!isMobile) { |
| 63 | - | // Desktop minimization | 70 | + | // Desktop minimization |
| 64 | - | const minimizedWidth = calculateMinimizedWidth(); | 71 | + | const minimizedWidth = calculateMinimizedWidth(); |
| 65 | - | const minimizedHeight = 32; // Height of title bar | 72 | + | const minimizedHeight = 32; // Height of title bar |
| 66 | - | const minimizedX = 10; | 73 | + | const minimizedX = 10; |
| 67 | - | const minimizedY = window.innerHeight - minimizedHeight - 10; | 74 | + | const minimizedY = window.innerHeight - minimizedHeight - 10; |
| 68 | - | 75 | + | ||
| 69 | - | node.style.width = `${minimizedWidth}px`; | 76 | + | node.style.width = `${minimizedWidth}px`; |
| 70 | - | node.style.height = `${minimizedHeight}px`; | 77 | + | node.style.height = `${minimizedHeight}px`; |
| 71 | - | node.style.left = `${minimizedX}px`; | 78 | + | node.style.left = `${minimizedX}px`; |
| 72 | - | node.style.top = `${minimizedY}px`; | 79 | + | node.style.top = `${minimizedY}px`; |
| 73 | - | } else { | 80 | + | } else { |
| 74 | - | // Mobile minimization - position at bottom of screen | 81 | + | // Mobile minimization - position at bottom of screen |
| 75 | - | node.style.width = '100%'; | 82 | + | node.style.width = '100%'; |
| 76 | - | node.style.height = '100%'; | 83 | + | node.style.height = '100%'; |
| 77 | - | node.style.left = '0'; | 84 | + | node.style.left = '0'; |
| 78 | - | node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height | 85 | + | node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height |
| 79 | - | } | 86 | + | } |
| 80 | - | 87 | + | ||
| 81 | - | isMinimized = true; | 88 | + | isMinimized = true; |
| 82 | - | node.classList.add('minimized'); | 89 | + | node.classList.add('minimized'); |
| 83 | - | 90 | + | ||
| 84 | - | // Hide resize handle | 91 | + | // Hide resize handle |
| 85 | - | const resizeHandle = node.querySelector('.resize-handle'); | 92 | + | const resizeHandle = node.querySelector('.resize-handle'); |
| 86 | - | if (resizeHandle) { | 93 | + | if (resizeHandle) { |
| 87 | - | (resizeHandle as HTMLElement).style.display = 'none'; | 94 | + | (resizeHandle as HTMLElement).style.display = 'none'; |
| 88 | - | } | 95 | + | } |
| 89 | - | 96 | + | ||
| 90 | - | onMinimize?.(true); | 97 | + | onMinimize?.(true); |
| 91 | - | node.dispatchEvent(new CustomEvent('minimize', { | 98 | + | node.dispatchEvent( |
| 92 | - | detail: { isMinimized: true } | 99 | + | new CustomEvent('minimize', { |
| 93 | - | })); | 100 | + | detail: { isMinimized: true } |
| 94 | - | } | 101 | + | }) |
| 95 | - | 102 | + | ); | |
| 96 | - | function restore() { | 103 | + | } |
| 97 | - | if (!enabled || !isMinimized || !storedState) return; | 104 | + | |
| 98 | - | 105 | + | function restore() { | |
| 99 | - | const isMobile = window.innerWidth <= 768; | 106 | + | if (!enabled || !isMinimized || !storedState) return; |
| 100 | - | 107 | + | ||
| 101 | - | if (!isMobile) { | 108 | + | const isMobile = window.innerWidth <= 768; |
| 102 | - | // Desktop restoration | 109 | + | |
| 103 | - | node.style.width = `${storedState.width}px`; | 110 | + | if (!isMobile) { |
| 104 | - | node.style.height = `${storedState.height}px`; | 111 | + | // Desktop restoration |
| 105 | - | node.style.left = `${storedState.x}px`; | 112 | + | node.style.width = `${storedState.width}px`; |
| 106 | - | node.style.top = `${storedState.y}px`; | 113 | + | node.style.height = `${storedState.height}px`; |
| 107 | - | } else { | 114 | + | node.style.left = `${storedState.x}px`; |
| 108 | - | // Mobile restoration | 115 | + | node.style.top = `${storedState.y}px`; |
| 109 | - | node.style.width = '100%'; | 116 | + | } else { |
| 110 | - | node.style.height = '100%'; | 117 | + | // Mobile restoration |
| 111 | - | node.style.left = '0'; | 118 | + | node.style.width = '100%'; |
| 112 | - | node.style.top = '0'; | 119 | + | node.style.height = '100%'; |
| 113 | - | } | 120 | + | node.style.left = '0'; |
| 114 | - | 121 | + | node.style.top = '0'; | |
| 115 | - | isMinimized = false; | 122 | + | } |
| 116 | - | node.classList.remove('minimized'); | 123 | + | |
| 117 | - | 124 | + | isMinimized = false; | |
| 118 | - | // Show resize handle if not in mobile mode | 125 | + | node.classList.remove('minimized'); |
| 119 | - | const resizeHandle = node.querySelector('.resize-handle'); | 126 | + | |
| 120 | - | if (resizeHandle) { | 127 | + | // Show resize handle if not in mobile mode |
| 121 | - | (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none'; | 128 | + | const resizeHandle = node.querySelector('.resize-handle'); |
| 122 | - | } | 129 | + | if (resizeHandle) { |
| 123 | - | 130 | + | (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none'; | |
| 124 | - | // Ensure window body is visible | 131 | + | } |
| 125 | - | const windowBody = node.querySelector('.window-body'); | 132 | + | |
| 126 | - | if (windowBody) { | 133 | + | // Ensure window body is visible |
| 127 | - | (windowBody as HTMLElement).style.display = 'flex'; | 134 | + | const windowBody = node.querySelector('.window-body'); |
| 128 | - | } | 135 | + | if (windowBody) { |
| 129 | - | 136 | + | (windowBody as HTMLElement).style.display = 'flex'; | |
| 130 | - | onMinimize?.(false); | 137 | + | } |
| 131 | - | node.dispatchEvent(new CustomEvent('minimize', { | 138 | + | |
| 132 | - | detail: { isMinimized: false } | 139 | + | onMinimize?.(false); |
| 133 | - | })); | 140 | + | node.dispatchEvent( |
| 134 | - | } | 141 | + | new CustomEvent('minimize', { |
| 135 | - | 142 | + | detail: { isMinimized: false } | |
| 136 | - | function toggle() { | 143 | + | }) |
| 137 | - | if (isMinimized) { | 144 | + | ); |
| 138 | - | restore(); | 145 | + | } |
| 139 | - | } else { | 146 | + | |
| 140 | - | minimize(); | 147 | + | function toggle() { |
| 141 | - | } | 148 | + | if (isMinimized) { |
| 142 | - | } | 149 | + | restore(); |
| 143 | - | 150 | + | } else { | |
| 144 | - | // Handle double click on title bar | 151 | + | minimize(); |
| 145 | - | const titleBar = node.querySelector('.title-bar'); | 152 | + | } |
| 146 | - | if (titleBar) { | 153 | + | } |
| 147 | - | titleBar.addEventListener('dblclick', toggle); | 154 | + | |
| 148 | - | } | 155 | + | // Handle double click on title bar |
| 149 | - | 156 | + | const titleBar = node.querySelector('.title-bar'); | |
| 150 | - | // Expose methods to the node | 157 | + | if (titleBar) { |
| 151 | - | Object.assign(node, { | 158 | + | titleBar.addEventListener('dblclick', toggle); |
| 152 | - | minimize, | 159 | + | } |
| 153 | - | restore, | 160 | + | |
| 154 | - | toggleMinimize: toggle, | 161 | + | // Expose methods to the node |
| 155 | - | isMinimized: () => isMinimized | 162 | + | Object.assign(node, { |
| 156 | - | }); | 163 | + | minimize, |
| 157 | - | 164 | + | restore, | |
| 158 | - | return { | 165 | + | toggleMinimize: toggle, |
| 159 | - | update(newParams: MinimizableParameters) { | 166 | + | isMinimized: () => isMinimized |
| 160 | - | Object.assign(params, newParams); | 167 | + | }); |
| 161 | - | }, | 168 | + | |
| 162 | - | destroy() { | 169 | + | return { |
| 163 | - | if (titleBar) { | 170 | + | update(newParams: MinimizableParameters) { |
| 164 | - | titleBar.removeEventListener('dblclick', toggle); | 171 | + | Object.assign(params, newParams); |
| 165 | - | } | 172 | + | }, |
| 166 | - | } | 173 | + | destroy() { |
| 167 | - | }; | 174 | + | if (titleBar) { |
| 168 | - | }; ⏎̸ | 175 | + | titleBar.removeEventListener('dblclick', toggle); |
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | }; | |||
| 179 | + | }; | |||
M src/lib/actions/resizable.ts +112 -103
| @@ -1,111 +1,120 @@ | |||||
| 1 | import type { Action } from 'svelte/action'; | 1 | import type { Action } from 'svelte/action'; | ||
| 2 | 2 | ||||
| 3 | interface ResizableParameters { | 3 | interface ResizableParameters { | ||
| 4 | - | enabled?: boolean; | 4 | + | enabled?: boolean; |
| 5 | - | minWidth?: number; | 5 | + | minWidth?: number; |
| 6 | - | minHeight?: number; | 6 | + | minHeight?: number; |
| 7 | - | maxWidth?: number; | 7 | + | maxWidth?: number; |
| 8 | - | maxHeight?: number; | 8 | + | maxHeight?: number; |
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | interface ResizableAttributes { | 11 | interface ResizableAttributes { | ||
| 12 | - | onresizestart?: (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; | 13 | + | onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void; |
| 14 | - | onresizeend?: (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 = {}) => { | 17 | + | export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = ( |
| 18 | - | let isResizing = false; | 18 | + | node, |
| 19 | - | let startWidth = 0; | 19 | + | params = {} |
| 20 | - | let startHeight = 0; | 20 | + | ) => { |
| 21 | - | let startX = 0; | 21 | + | let isResizing = false; |
| 22 | - | let startY = 0; | 22 | + | let startWidth = 0; |
| 23 | - | 23 | + | let startHeight = 0; | |
| 24 | - | const { | 24 | + | let startX = 0; |
| 25 | - | enabled = true, | 25 | + | let startY = 0; |
| 26 | - | minWidth = 200, | 26 | + | |
| 27 | - | minHeight = 200, | 27 | + | const { |
| 28 | - | maxWidth = window.innerWidth, | 28 | + | enabled = true, |
| 29 | - | maxHeight = window.innerHeight | 29 | + | minWidth = 200, |
| 30 | - | } = params; | 30 | + | minHeight = 200, |
| 31 | - | 31 | + | maxWidth = window.innerWidth, | |
| 32 | - | // Create and append resize handle | 32 | + | maxHeight = window.innerHeight |
| 33 | - | const handle = document.createElement('div'); | 33 | + | } = params; |
| 34 | - | handle.className = 'resize-handle'; | 34 | + | |
| 35 | - | Object.assign(handle.style, { | 35 | + | // Create and append resize handle |
| 36 | - | position: 'absolute', | 36 | + | const handle = document.createElement('div'); |
| 37 | - | bottom: '0', | 37 | + | handle.className = 'resize-handle'; |
| 38 | - | right: '0', | 38 | + | Object.assign(handle.style, { |
| 39 | - | width: '15px', | 39 | + | position: 'absolute', |
| 40 | - | height: '15px', | 40 | + | bottom: '0', |
| 41 | - | cursor: 'se-resize', | 41 | + | right: '0', |
| 42 | - | zIndex: '1000' | 42 | + | width: '15px', |
| 43 | - | }); | 43 | + | height: '15px', |
| 44 | - | node.appendChild(handle); | 44 | + | cursor: 'se-resize', |
| 45 | - | 45 | + | zIndex: '1000' | |
| 46 | - | // Ensure parent element has position | 46 | + | }); |
| 47 | - | if (getComputedStyle(node).position === 'static') { | 47 | + | node.appendChild(handle); |
| 48 | - | node.style.position = 'relative'; | 48 | + | |
| 49 | - | } | 49 | + | // Ensure parent element has position |
| 50 | - | 50 | + | if (getComputedStyle(node).position === 'static') { | |
| 51 | - | function onMouseDown(event: MouseEvent) { | 51 | + | node.style.position = 'relative'; |
| 52 | - | if (!enabled) return; | 52 | + | } |
| 53 | - | 53 | + | ||
| 54 | - | isResizing = true; | 54 | + | function onMouseDown(event: MouseEvent) { |
| 55 | - | startX = event.clientX; | 55 | + | if (!enabled) return; |
| 56 | - | startY = event.clientY; | 56 | + | |
| 57 | - | startWidth = node.offsetWidth; | 57 | + | isResizing = true; |
| 58 | - | startHeight = node.offsetHeight; | 58 | + | startX = event.clientX; |
| 59 | - | 59 | + | startY = event.clientY; | |
| 60 | - | node.dispatchEvent(new CustomEvent('resizestart', { | 60 | + | startWidth = node.offsetWidth; |
| 61 | - | detail: { width: startWidth, height: startHeight } | 61 | + | startHeight = node.offsetHeight; |
| 62 | - | })); | 62 | + | |
| 63 | - | 63 | + | node.dispatchEvent( | |
| 64 | - | event.preventDefault(); | 64 | + | new CustomEvent('resizestart', { |
| 65 | - | } | 65 | + | detail: { width: startWidth, height: startHeight } |
| 66 | - | 66 | + | }) | |
| 67 | - | function onMouseMove(event: MouseEvent) { | 67 | + | ); |
| 68 | - | if (!isResizing || !enabled) return; | 68 | + | |
| 69 | - | 69 | + | event.preventDefault(); | |
| 70 | - | const deltaX = event.clientX - startX; | 70 | + | } |
| 71 | - | const deltaY = event.clientY - startY; | 71 | + | |
| 72 | - | 72 | + | function onMouseMove(event: MouseEvent) { | |
| 73 | - | const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX)); | 73 | + | if (!isResizing || !enabled) return; |
| 74 | - | const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY)); | 74 | + | |
| 75 | - | 75 | + | const deltaX = event.clientX - startX; | |
| 76 | - | node.style.width = `${newWidth}px`; | 76 | + | const deltaY = event.clientY - startY; |
| 77 | - | node.style.height = `${newHeight}px`; | 77 | + | |
| 78 | - | 78 | + | const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX)); | |
| 79 | - | node.dispatchEvent(new CustomEvent('resizemove', { | 79 | + | const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY)); |
| 80 | - | detail: { width: newWidth, height: newHeight } | 80 | + | |
| 81 | - | })); | 81 | + | node.style.width = `${newWidth}px`; |
| 82 | - | } | 82 | + | node.style.height = `${newHeight}px`; |
| 83 | - | 83 | + | ||
| 84 | - | function onMouseUp() { | 84 | + | node.dispatchEvent( |
| 85 | - | if (!isResizing) return; | 85 | + | new CustomEvent('resizemove', { |
| 86 | - | 86 | + | detail: { width: newWidth, height: newHeight } | |
| 87 | - | isResizing = false; | 87 | + | }) |
| 88 | - | node.dispatchEvent(new CustomEvent('resizeend', { | 88 | + | ); |
| 89 | - | detail: { | 89 | + | } |
| 90 | - | width: node.offsetWidth, | 90 | + | |
| 91 | - | height: node.offsetHeight | 91 | + | function onMouseUp() { |
| 92 | - | } | 92 | + | if (!isResizing) return; |
| 93 | - | })); | 93 | + | |
| 94 | - | } | 94 | + | isResizing = false; |
| 95 | - | 95 | + | node.dispatchEvent( | |
| 96 | - | handle.addEventListener('mousedown', onMouseDown); | 96 | + | new CustomEvent('resizeend', { |
| 97 | - | window.addEventListener('mousemove', onMouseMove); | 97 | + | detail: { |
| 98 | - | window.addEventListener('mouseup', onMouseUp); | 98 | + | width: node.offsetWidth, |
| 99 | - | 99 | + | height: node.offsetHeight | |
| 100 | - | return { | 100 | + | } |
| 101 | - | update(newParams: ResizableParameters) { | 101 | + | }) |
| 102 | - | Object.assign(params, newParams); | 102 | + | ); |
| 103 | - | }, | 103 | + | } |
| 104 | - | destroy() { | 104 | + | |
| 105 | - | handle.removeEventListener('mousedown', onMouseDown); | 105 | + | handle.addEventListener('mousedown', onMouseDown); |
| 106 | - | window.removeEventListener('mousemove', onMouseMove); | 106 | + | window.addEventListener('mousemove', onMouseMove); |
| 107 | - | window.removeEventListener('mouseup', onMouseUp); | 107 | + | window.addEventListener('mouseup', onMouseUp); |
| 108 | - | node.removeChild(handle); | 108 | + | |
| 109 | - | } | 109 | + | return { |
| 110 | - | }; | 110 | + | update(newParams: ResizableParameters) { |
| 111 | - | }; ⏎̸ | 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 | // Rate limit configuration by endpoint type | 8 | // Rate limit configuration by endpoint type | ||
| 9 | const RATE_LIMITS = { | 9 | const RATE_LIMITS = { | ||
| 10 | - | // Authentication endpoints (login, register) | 10 | + | // Authentication endpoints (login, register) |
| 11 | - | auth: { | 11 | + | auth: { |
| 12 | - | points: isDev ? 1000 : 20, | 12 | + | points: isDev ? 1000 : 20, |
| 13 | - | durationMs: 5 * 60 * 1000 | 13 | + | durationMs: 5 * 60 * 1000 |
| 14 | - | }, | 14 | + | }, |
| 15 | - | 15 | + | ||
| 16 | - | // Public endpoints (public chat, rooms) | 16 | + | // Public endpoints (public chat, rooms) |
| 17 | - | public: { | 17 | + | public: { |
| 18 | - | points: isDev ? 1000 : 30, | 18 | + | points: isDev ? 1000 : 30, |
| 19 | - | durationMs: 60 * 1000 | 19 | + | durationMs: 60 * 1000 |
| 20 | - | }, | 20 | + | }, |
| 21 | - | 21 | + | ||
| 22 | - | // Protected endpoints (authenticated API calls) | 22 | + | // Protected endpoints (authenticated API calls) |
| 23 | - | protected: { | 23 | + | protected: { |
| 24 | - | points: isDev ? 1000 : 100, | 24 | + | points: isDev ? 1000 : 100, |
| 25 | - | durationMs: 60 * 1000 | 25 | + | durationMs: 60 * 1000 |
| 26 | - | }, | 26 | + | }, |
| 27 | - | 27 | + | ||
| 28 | - | // SSE endpoints - separate limits for authenticated and unauthenticated users | 28 | + | // SSE endpoints - separate limits for authenticated and unauthenticated users |
| 29 | - | sse: { | 29 | + | sse: { |
| 30 | - | authenticated: { | 30 | + | authenticated: { |
| 31 | - | points: isDev ? 1000 : 500, | 31 | + | points: isDev ? 1000 : 500, |
| 32 | - | durationMs: 60 * 1000 | 32 | + | durationMs: 60 * 1000 |
| 33 | - | }, | 33 | + | }, |
| 34 | - | unauthenticated: { | 34 | + | unauthenticated: { |
| 35 | - | points: 0, | 35 | + | points: 0, |
| 36 | - | durationMs: 60 * 1000 | 36 | + | durationMs: 60 * 1000 |
| 37 | - | } | 37 | + | } |
| 38 | - | } | 38 | + | } |
| 39 | } as const; | 39 | } as const; | ||
| 40 | 40 | ||||
| 41 | // In-memory store for rate limiting | 41 | // In-memory store for rate limiting | ||
| 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | ||
| 43 | 43 | ||||
| 44 | // Cleanup old entries every 5 minutes | 44 | // Cleanup old entries every 5 minutes | ||
| 45 | - | setInterval(() => { | 45 | + | setInterval( |
| 46 | - | const now = Date.now(); | 46 | + | () => { |
| 47 | - | for (const [ip, timestamps] of ipRequests.entries()) { | 47 | + | const now = Date.now(); |
| 48 | - | // Remove entries older than the maximum duration we care about (5 minutes) | 48 | + | for (const [ip, timestamps] of ipRequests.entries()) { |
| 49 | - | const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000); | 49 | + | // Remove entries older than the maximum duration we care about (5 minutes) |
| 50 | - | if (filtered.length === 0) { | 50 | + | const filtered = timestamps.filter((t) => now - t.timestamp < 5 * 60 * 1000); |
| 51 | - | ipRequests.delete(ip); | 51 | + | if (filtered.length === 0) { |
| 52 | - | } else { | 52 | + | ipRequests.delete(ip); |
| 53 | - | ipRequests.set(ip, filtered); | 53 | + | } else { |
| 54 | - | } | 54 | + | ipRequests.set(ip, filtered); |
| 55 | - | } | 55 | + | } |
| 56 | - | }, 5 * 60 * 1000); | 56 | + | } |
| 57 | + | }, | |||
| 58 | + | 5 * 60 * 1000 | |||
| 59 | + | ); | |||
| 57 | 60 | ||||
| 58 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | 61 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | ||
| 59 | - | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | 62 | + | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { |
| 60 | - | return 'auth'; | 63 | + | return 'auth'; |
| 61 | - | } | 64 | + | } |
| 62 | - | if (pathname.startsWith('/api/sse')) { | 65 | + | if (pathname.startsWith('/api/sse')) { |
| 63 | - | return 'sse'; | 66 | + | return 'sse'; |
| 64 | - | } | 67 | + | } |
| 65 | - | if (isPublic) { | 68 | + | if (isPublic) { |
| 66 | - | return 'public'; | 69 | + | return 'public'; |
| 67 | - | } | 70 | + | } |
| 68 | - | return 'protected'; | 71 | + | return 'protected'; |
| 69 | } | 72 | } | ||
| 70 | 73 | ||||
| 71 | - | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | 74 | + | function isRateLimited( |
| 72 | - | // Skip rate limiting completely in development for localhost | 75 | + | ip: string, |
| 73 | - | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | 76 | + | endpointType: keyof typeof RATE_LIMITS, |
| 74 | - | return { limited: false }; | 77 | + | isAuthenticated: boolean = false |
| 75 | - | } | 78 | + | ): { limited: boolean; retryAfter?: number } { |
| 76 | - | 79 | + | // Skip rate limiting completely in development for localhost | |
| 77 | - | const now = Date.now(); | 80 | + | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { |
| 78 | - | 81 | + | return { limited: false }; | |
| 79 | - | // Handle SSE endpoints differently based on authentication status | 82 | + | } |
| 80 | - | if (endpointType === 'sse') { | 83 | + | |
| 81 | - | const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated; | 84 | + | const now = Date.now(); |
| 82 | - | const { points, durationMs } = config; | 85 | + | |
| 83 | - | 86 | + | // Handle SSE endpoints differently based on authentication status | |
| 84 | - | // Get or initialize request history for this IP | 87 | + | if (endpointType === 'sse') { |
| 85 | - | const requests = ipRequests.get(ip) || []; | 88 | + | const config = isAuthenticated |
| 86 | - | 89 | + | ? RATE_LIMITS.sse.authenticated | |
| 87 | - | // Clean old requests outside the window | 90 | + | : RATE_LIMITS.sse.unauthenticated; |
| 88 | - | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | 91 | + | const { points, durationMs } = config; |
| 89 | - | 92 | + | ||
| 90 | - | // Check if we're over the limit | 93 | + | // Get or initialize request history for this IP |
| 91 | - | if (validRequests.length >= points) { | 94 | + | const requests = ipRequests.get(ip) || []; |
| 92 | - | log.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`); | 95 | + | |
| 93 | - | const oldestRequest = validRequests[0]; | 96 | + | // Clean old requests outside the window |
| 94 | - | const retryAfter = durationMs - (now - oldestRequest.timestamp); | 97 | + | const validRequests = requests.filter((req) => now - req.timestamp < durationMs); |
| 95 | - | return { limited: true, retryAfter }; | 98 | + | |
| 96 | - | } | 99 | + | // Check if we're over the limit |
| 97 | - | 100 | + | if (validRequests.length >= points) { | |
| 98 | - | // Add this request | 101 | + | log.debug( |
| 99 | - | validRequests.push({ timestamp: now }); | 102 | + | `Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})` |
| 100 | - | ipRequests.set(ip, validRequests); | 103 | + | ); |
| 101 | - | 104 | + | const oldestRequest = validRequests[0]; | |
| 102 | - | return { limited: false }; | 105 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); |
| 103 | - | } | 106 | + | return { limited: true, retryAfter }; |
| 104 | - | 107 | + | } | |
| 105 | - | // Handle other endpoints | 108 | + | |
| 106 | - | const { points, durationMs } = RATE_LIMITS[endpointType]; | 109 | + | // Add this request |
| 107 | - | const requests = ipRequests.get(ip) || []; | 110 | + | validRequests.push({ timestamp: now }); |
| 108 | - | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | 111 | + | ipRequests.set(ip, validRequests); |
| 109 | - | 112 | + | ||
| 110 | - | log.debug(`Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs/1000}s`); | 113 | + | return { limited: false }; |
| 111 | - | 114 | + | } | |
| 112 | - | if (validRequests.length >= points) { | 115 | + | |
| 113 | - | const oldestRequest = validRequests[0]; | 116 | + | // Handle other endpoints |
| 114 | - | const retryAfter = durationMs - (now - oldestRequest.timestamp); | 117 | + | const { points, durationMs } = RATE_LIMITS[endpointType]; |
| 115 | - | return { limited: true, retryAfter }; | 118 | + | const requests = ipRequests.get(ip) || []; |
| 116 | - | } | 119 | + | const validRequests = requests.filter((req) => now - req.timestamp < durationMs); |
| 117 | - | 120 | + | ||
| 118 | - | validRequests.push({ timestamp: now }); | 121 | + | log.debug( |
| 119 | - | ipRequests.set(ip, validRequests); | 122 | + | `Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs / 1000}s` |
| 120 | - | 123 | + | ); | |
| 121 | - | return { limited: false }; | 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 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | 137 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | ||
| 125 | - | const ip = event.getClientAddress(); | 138 | + | const ip = event.getClientAddress(); |
| 126 | - | const url = new URL(event.request.url); | 139 | + | const url = new URL(event.request.url); |
| 127 | - | const isPublic = url.searchParams.get('public') === 'true'; | 140 | + | const isPublic = url.searchParams.get('public') === 'true'; |
| 128 | - | const isAuthenticated = Boolean(event.locals.user); | 141 | + | const isAuthenticated = Boolean(event.locals.user); |
| 129 | - | 142 | + | ||
| 130 | - | // Skip rate limiting for non-API routes | 143 | + | // Skip rate limiting for non-API routes |
| 131 | - | if (!url.pathname.startsWith('/api')) { | 144 | + | if (!url.pathname.startsWith('/api')) { |
| 132 | - | return null; | 145 | + | return null; |
| 133 | - | } | 146 | + | } |
| 134 | - | 147 | + | ||
| 135 | - | const endpointType = getEndpointType(url.pathname, isPublic); | 148 | + | const endpointType = getEndpointType(url.pathname, isPublic); |
| 136 | - | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); | 149 | + | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); |
| 137 | - | 150 | + | ||
| 138 | - | if (limited) { | 151 | + | if (limited) { |
| 139 | - | // Create debug headers | 152 | + | // Create debug headers |
| 140 | - | const debugHeaders = { | 153 | + | const debugHeaders = { |
| 141 | - | 'X-Debug-Is-Authenticated': isAuthenticated.toString(), | 154 | + | 'X-Debug-Is-Authenticated': isAuthenticated.toString(), |
| 142 | - | 'X-Debug-Has-User': Boolean(event.locals.user).toString(), | 155 | + | 'X-Debug-Has-User': Boolean(event.locals.user).toString(), |
| 143 | - | 'X-Debug-Has-Session': Boolean(event.locals.session).toString(), | 156 | + | 'X-Debug-Has-Session': Boolean(event.locals.session).toString(), |
| 144 | - | 'X-Debug-Endpoint-Type': endpointType, | 157 | + | 'X-Debug-Endpoint-Type': endpointType, |
| 145 | - | 'X-Debug-Rate-Limit-Points': isAuthenticated | 158 | + | 'X-Debug-Rate-Limit-Points': isAuthenticated |
| 146 | - | ? RATE_LIMITS.sse.authenticated.points.toString() | 159 | + | ? RATE_LIMITS.sse.authenticated.points.toString() |
| 147 | - | : RATE_LIMITS.sse.unauthenticated.points.toString(), | 160 | + | : RATE_LIMITS.sse.unauthenticated.points.toString() |
| 148 | - | }; | 161 | + | }; |
| 149 | - | 162 | + | ||
| 150 | - | // Construct a user-friendly error message | 163 | + | // Construct a user-friendly error message |
| 151 | - | let errorMessage = 'Too many requests. '; | 164 | + | let errorMessage = 'Too many requests. '; |
| 152 | - | if (endpointType === 'sse') { | 165 | + | if (endpointType === 'sse') { |
| 153 | - | errorMessage += isAuthenticated | 166 | + | errorMessage += isAuthenticated |
| 154 | - | ? 'Please wait before attempting to reconnect to the chat.' | 167 | + | ? 'Please wait before attempting to reconnect to the chat.' |
| 155 | - | : 'Please wait before attempting to view the public chat again.'; | 168 | + | : 'Please wait before attempting to view the public chat again.'; |
| 156 | - | } else if (endpointType === 'auth') { | 169 | + | } else if (endpointType === 'auth') { |
| 157 | - | errorMessage += 'Please wait before attempting to log in again.'; | 170 | + | errorMessage += 'Please wait before attempting to log in again.'; |
| 158 | - | } else { | 171 | + | } else { |
| 159 | - | errorMessage += 'Please try again later.'; | 172 | + | errorMessage += 'Please try again later.'; |
| 160 | - | } | 173 | + | } |
| 161 | - | 174 | + | ||
| 162 | - | return new Response(JSON.stringify({ | 175 | + | return new Response( |
| 163 | - | error: errorMessage, | 176 | + | JSON.stringify({ |
| 164 | - | retryAfter: Math.ceil((retryAfter || 0) / 1000), | 177 | + | error: errorMessage, |
| 165 | - | endpointType, | 178 | + | retryAfter: Math.ceil((retryAfter || 0) / 1000), |
| 166 | - | isAuthenticated, | 179 | + | endpointType, |
| 167 | - | debug: { | 180 | + | isAuthenticated, |
| 168 | - | isAuthenticated, | 181 | + | debug: { |
| 169 | - | hasUser: Boolean(event.locals.user), | 182 | + | isAuthenticated, |
| 170 | - | hasSession: Boolean(event.locals.session), | 183 | + | hasUser: Boolean(event.locals.user), |
| 171 | - | endpointType, | 184 | + | hasSession: Boolean(event.locals.session), |
| 172 | - | rateLimitPoints: isAuthenticated | 185 | + | endpointType, |
| 173 | - | ? RATE_LIMITS.sse.authenticated.points | 186 | + | rateLimitPoints: isAuthenticated |
| 174 | - | : RATE_LIMITS.sse.unauthenticated.points | 187 | + | ? RATE_LIMITS.sse.authenticated.points |
| 175 | - | } | 188 | + | : RATE_LIMITS.sse.unauthenticated.points |
| 176 | - | }), { | 189 | + | } |
| 177 | - | status: 429, | 190 | + | }), |
| 178 | - | headers: { | 191 | + | { |
| 179 | - | 'Content-Type': 'application/json', | 192 | + | status: 429, |
| 180 | - | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(), | 193 | + | headers: { |
| 181 | - | ...debugHeaders | 194 | + | 'Content-Type': 'application/json', |
| 182 | - | } | 195 | + | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(), |
| 183 | - | }); | 196 | + | ...debugHeaders |
| 184 | - | } | 197 | + | } |
| 185 | - | 198 | + | } | |
| 186 | - | return null; | 199 | + | ); |
| 187 | - | } ⏎̸ | 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"; | 1 | + | import type { Cookies } from '@sveltejs/kit'; |
| 2 | - | import { createLogger } from "$lib/utils/logger.server"; | 2 | + | import { createLogger } from '$lib/utils/logger.server'; |
| 3 | 3 | ||||
| 4 | const log = createLogger('session-cookie'); | 4 | const log = createLogger('session-cookie'); | ||
| 5 | 5 | ||||
| 6 | // Helper function to determine cookie domain | 6 | // Helper function to determine cookie domain | ||
| 7 | function getCookieDomain(): string | undefined { | 7 | function getCookieDomain(): string | undefined { | ||
| 8 | - | if (process.env.NODE_ENV === 'production') { | 8 | + | if (process.env.NODE_ENV === 'production') { |
| 9 | - | // Check for Cloudflare Pages domain | 9 | + | // Check for Cloudflare Pages domain |
| 10 | - | if (process.env.CF_PAGES_URL) { | 10 | + | if (process.env.CF_PAGES_URL) { |
| 11 | - | const url = process.env.CF_PAGES_URL; | 11 | + | const url = process.env.CF_PAGES_URL; |
| 12 | - | log.debug("Cloudflare Pages URL:", { url }); | 12 | + | log.debug('Cloudflare Pages URL:', { url }); |
| 13 | - | // For preview deployments, use host-only cookie | 13 | + | // For preview deployments, use host-only cookie |
| 14 | - | if (process.env.CF_PAGES_BRANCH !== 'main') { | 14 | + | if (process.env.CF_PAGES_BRANCH !== 'main') { |
| 15 | - | return undefined; | 15 | + | return undefined; |
| 16 | - | } | 16 | + | } |
| 17 | - | // For production, use the main domain | 17 | + | // For production, use the main domain |
| 18 | - | return '.pdraim.pages.dev'; | 18 | + | return '.pdraim.pages.dev'; |
| 19 | - | } | 19 | + | } |
| 20 | - | // Fallback for custom domain | 20 | + | // Fallback for custom domain |
| 21 | - | return '.pdraim.org'; | 21 | + | return '.pdraim.org'; |
| 22 | - | } | 22 | + | } |
| 23 | - | return undefined; | 23 | + | return undefined; |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void { | 26 | + | export function setSessionTokenCookie( |
| 27 | - | log.debug("Setting session cookie with expiry:", new Date(expiresAt)); | 27 | + | { cookies }: { cookies: Cookies }, |
| 28 | - | 28 | + | token: string, | |
| 29 | - | const domain = getCookieDomain(); | 29 | + | expiresAt: number |
| 30 | - | log.debug("Setting cookie for domain:", { domain }); | 30 | + | ): void { |
| 31 | - | 31 | + | log.debug('Setting session cookie with expiry:', new Date(expiresAt)); | |
| 32 | - | cookies.set("session", token, { | 32 | + | |
| 33 | - | httpOnly: true, | 33 | + | const domain = getCookieDomain(); |
| 34 | - | sameSite: "lax", | 34 | + | log.debug('Setting cookie for domain:', { domain }); |
| 35 | - | secure: process.env.NODE_ENV === 'production', | 35 | + | |
| 36 | - | domain, | 36 | + | cookies.set('session', token, { |
| 37 | - | expires: new Date(expiresAt), | 37 | + | httpOnly: true, |
| 38 | - | path: "/" | 38 | + | sameSite: 'lax', |
| 39 | - | }); | 39 | + | secure: process.env.NODE_ENV === 'production', |
| 40 | + | domain, | |||
| 41 | + | expires: new Date(expiresAt), | |||
| 42 | + | path: '/' | |||
| 43 | + | }); | |||
| 40 | } | 44 | } | ||
| 41 | 45 | ||||
| 42 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | 46 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | ||
| 43 | - | log.debug("Deleting session cookie"); | 47 | + | log.debug('Deleting session cookie'); |
| 44 | - | 48 | + | ||
| 45 | - | const domain = getCookieDomain(); | 49 | + | const domain = getCookieDomain(); |
| 46 | - | log.debug("Deleting cookie for domain:", { domain }); | 50 | + | log.debug('Deleting cookie for domain:', { domain }); |
| 47 | - | 51 | + | ||
| 48 | - | cookies.set("session", "", { | 52 | + | cookies.set('session', '', { |
| 49 | - | httpOnly: true, | 53 | + | httpOnly: true, |
| 50 | - | sameSite: "lax", | 54 | + | sameSite: 'lax', |
| 51 | - | secure: process.env.NODE_ENV === 'production', | 55 | + | secure: process.env.NODE_ENV === 'production', |
| 52 | - | domain, | 56 | + | domain, |
| 53 | - | maxAge: 0, | 57 | + | maxAge: 0, |
| 54 | - | path: "/" | 58 | + | path: '/' |
| 55 | - | }); | 59 | + | }); |
| 56 | } | 60 | } | ||
| 57 | 61 | ||||
| 58 | export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined { | 62 | export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined { | ||
| 59 | - | log.debug("Getting session cookie"); | 63 | + | log.debug('Getting session cookie'); |
| 60 | - | return cookies.get("session"); | 64 | + | return cookies.get('session'); |
| 61 | - | } ⏎̸ | 65 | + | } |
M src/lib/api/session.server.ts +73 -73
| @@ -8,61 +8,61 @@ const log = createLogger('session-server'); | |||||
| 8 | 8 | ||||
| 9 | // Helper: Compute SHA-256 hash of a message and encode it as a hex string. | 9 | // Helper: Compute SHA-256 hash of a message and encode it as a hex string. | ||
| 10 | export async function sha256(message: string): Promise<string> { | 10 | export async function sha256(message: string): Promise<string> { | ||
| 11 | - | const encoder = new TextEncoder(); | 11 | + | const encoder = new TextEncoder(); |
| 12 | - | const data = encoder.encode(message); | 12 | + | const data = encoder.encode(message); |
| 13 | - | const hashBuffer = await crypto.subtle.digest('SHA-256', data); | 13 | + | const hashBuffer = await crypto.subtle.digest('SHA-256', data); |
| 14 | - | const hashArray = Array.from(new Uint8Array(hashBuffer)); | 14 | + | const hashArray = Array.from(new Uint8Array(hashBuffer)); |
| 15 | - | const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | 15 | + | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); |
| 16 | - | return hashHex; | 16 | + | return hashHex; |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | // Helper: Custom Base32 encoder using the standard alphabet. | 19 | // Helper: Custom Base32 encoder using the standard alphabet. | ||
| 20 | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | 20 | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | ||
| 21 | - | const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; | 21 | + | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; |
| 22 | function base32Encode(data: Uint8Array): string { | 22 | function base32Encode(data: Uint8Array): string { | ||
| 23 | - | let bits = 0; | 23 | + | let bits = 0; |
| 24 | - | let value = 0; | 24 | + | let value = 0; |
| 25 | - | let output = ""; | 25 | + | let output = ''; |
| 26 | - | for (let i = 0; i < data.length; i++) { | 26 | + | for (let i = 0; i < data.length; i++) { |
| 27 | - | value = (value << 8) | data[i]; | 27 | + | value = (value << 8) | data[i]; |
| 28 | - | bits += 8; | 28 | + | bits += 8; |
| 29 | - | while (bits >= 5) { | 29 | + | while (bits >= 5) { |
| 30 | - | const index = (value >>> (bits - 5)) & 0x1f; | 30 | + | const index = (value >>> (bits - 5)) & 0x1f; |
| 31 | - | output += base32Alphabet[index]; | 31 | + | output += base32Alphabet[index]; |
| 32 | - | bits -= 5; | 32 | + | bits -= 5; |
| 33 | - | } | 33 | + | } |
| 34 | - | } | 34 | + | } |
| 35 | - | if (bits > 0) { | 35 | + | if (bits > 0) { |
| 36 | - | const index = (value << (5 - bits)) & 0x1f; | 36 | + | const index = (value << (5 - bits)) & 0x1f; |
| 37 | - | output += base32Alphabet[index]; | 37 | + | output += base32Alphabet[index]; |
| 38 | - | } | 38 | + | } |
| 39 | - | return output; | 39 | + | return output; |
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | // Generate a secure session token by generating 20 random bytes and Base32 encoding them. | 42 | // Generate a secure session token by generating 20 random bytes and Base32 encoding them. | ||
| 43 | export function generateSessionToken(): string { | 43 | export function generateSessionToken(): string { | ||
| 44 | - | const randomBytesArray = new Uint8Array(20); | 44 | + | const randomBytesArray = new Uint8Array(20); |
| 45 | - | crypto.getRandomValues(randomBytesArray); | 45 | + | crypto.getRandomValues(randomBytesArray); |
| 46 | - | const token = base32Encode(randomBytesArray); | 46 | + | const token = base32Encode(randomBytesArray); |
| 47 | - | log.debug("Generated session token", { token }); | 47 | + | log.debug('Generated session token', { token }); |
| 48 | - | return token; | 48 | + | return token; |
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | // Create a session record once a valid token is available. | 51 | // Create a session record once a valid token is available. | ||
| 52 | // The session ID is the SHA-256 hash of the token. | 52 | // The session ID is the SHA-256 hash of the token. | ||
| 53 | export async function createSession(token: string, userId: string): Promise<Session> { | 53 | export async function createSession(token: string, userId: string): Promise<Session> { | ||
| 54 | - | log.debug("Creating session", { userId }); | 54 | + | log.debug('Creating session', { userId }); |
| 55 | - | const sessionId = await sha256(token); | 55 | + | const sessionId = await sha256(token); |
| 56 | - | const createdAt = Date.now(); | 56 | + | const createdAt = Date.now(); |
| 57 | - | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | 57 | + | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry |
| 58 | - | await db.insert(sessions).values({ | 58 | + | await db.insert(sessions).values({ |
| 59 | - | id: sessionId, | 59 | + | id: sessionId, |
| 60 | - | userId, | 60 | + | userId, |
| 61 | - | createdAt, | 61 | + | createdAt, |
| 62 | - | expiresAt, | 62 | + | expiresAt |
| 63 | - | }); | 63 | + | }); |
| 64 | - | log.debug("Session created", { sessionId }); | 64 | + | log.debug('Session created', { sessionId }); |
| 65 | - | return { id: sessionId, userId, createdAt, expiresAt }; | 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 | * Note: This returns the full User type since it's used server-side in hooks. | 71 | * Note: This returns the full User type since it's used server-side in hooks. | ||
| 72 | */ | 72 | */ | ||
| 73 | export type SessionValidationResult = | 73 | export type SessionValidationResult = | ||
| 74 | - | | { session: Session; user: User } | 74 | + | | { session: Session; user: User } |
| 75 | - | | { session: null; user: null }; | 75 | + | | { session: null; user: null }; |
| 76 | 76 | ||||
| 77 | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | 77 | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | ||
| 78 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | 78 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | ||
| 79 | - | log.debug("Validating session token"); | 79 | + | log.debug('Validating session token'); |
| 80 | - | const sessionId = await sha256(token); | 80 | + | const sessionId = await sha256(token); |
| 81 | - | const session = await db.query.sessions.findFirst({ | 81 | + | const session = await db.query.sessions.findFirst({ |
| 82 | - | where: eq(sessions.id, sessionId) | 82 | + | where: eq(sessions.id, sessionId) |
| 83 | - | }); | 83 | + | }); |
| 84 | - | if (!session) { | 84 | + | if (!session) { |
| 85 | - | log.debug("Session not found", { sessionId }); | 85 | + | log.debug('Session not found', { sessionId }); |
| 86 | - | return { session: null, user: null }; | 86 | + | return { session: null, user: null }; |
| 87 | - | } | 87 | + | } |
| 88 | - | const now = Date.now(); | 88 | + | const now = Date.now(); |
| 89 | - | if (session.expiresAt < now) { | 89 | + | if (session.expiresAt < now) { |
| 90 | - | log.debug("Session expired", { sessionId }); | 90 | + | log.debug('Session expired', { sessionId }); |
| 91 | - | return { session: null, user: null }; | 91 | + | return { session: null, user: null }; |
| 92 | - | } | 92 | + | } |
| 93 | - | const user = await db.query.users.findFirst({ | 93 | + | const user = await db.query.users.findFirst({ |
| 94 | - | where: eq(users.id, session.userId) | 94 | + | where: eq(users.id, session.userId) |
| 95 | - | }); | 95 | + | }); |
| 96 | - | if (!user) { | 96 | + | if (!user) { |
| 97 | - | log.debug("User not found for session", { sessionId }); | 97 | + | log.debug('User not found for session', { sessionId }); |
| 98 | - | return { session: null, user: null }; | 98 | + | return { session: null, user: null }; |
| 99 | - | } | 99 | + | } |
| 100 | - | log.debug("Session validated", { sessionId, userId: user.id }); | 100 | + | log.debug('Session validated', { sessionId, userId: user.id }); |
| 101 | - | return { session, user }; | 101 | + | return { session, user }; |
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | // Invalidate a specific session by deleting it from the database. | 104 | // Invalidate a specific session by deleting it from the database. | ||
| 105 | export async function invalidateSession(sessionId: string): Promise<void> { | 105 | export async function invalidateSession(sessionId: string): Promise<void> { | ||
| 106 | - | log.debug("Invalidating session", { sessionId }); | 106 | + | log.debug('Invalidating session', { sessionId }); |
| 107 | - | await db.delete(sessions).where(eq(sessions.id, sessionId)); | 107 | + | await db.delete(sessions).where(eq(sessions.id, sessionId)); |
| 108 | - | log.debug("Session invalidated", { sessionId }); | 108 | + | log.debug('Session invalidated', { sessionId }); |
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | // Invalidate all sessions for a given user. | 111 | // Invalidate all sessions for a given user. | ||
| 112 | export async function invalidateAllSessions(userId: string): Promise<void> { | 112 | export async function invalidateAllSessions(userId: string): Promise<void> { | ||
| 113 | - | log.debug("Invalidating all sessions for user", { userId }); | 113 | + | log.debug('Invalidating all sessions for user', { userId }); |
| 114 | - | await db.delete(sessions).where(eq(sessions.userId, userId)); | 114 | + | await db.delete(sessions).where(eq(sessions.userId, userId)); |
| 115 | - | log.debug("All sessions invalidated", { userId }); | 115 | + | log.debug('All sessions invalidated', { userId }); |
| 116 | - | } ⏎̸ | 116 | + | } |
M src/lib/buddyListCache.ts +46 -46
| @@ -4,52 +4,52 @@ import type { SafeUser } from './types/chat'; | |||||
| 4 | const log = createLogger('buddy-list-cache'); | 4 | const log = createLogger('buddy-list-cache'); | ||
| 5 | 5 | ||||
| 6 | class BuddyListCache { | 6 | class BuddyListCache { | ||
| 7 | - | private cache: SafeUser[] | null = null; | 7 | + | private cache: SafeUser[] | null = null; |
| 8 | - | private lastHash: string = ''; | 8 | + | private lastHash: string = ''; |
| 9 | - | private lastFetch: number = 0; | 9 | + | private lastFetch: number = 0; |
| 10 | - | private isDirty: boolean = true; // Force initial fetch | 10 | + | private isDirty: boolean = true; // Force initial fetch |
| 11 | - | 11 | + | ||
| 12 | - | // Mark cache as needing refresh | 12 | + | // Mark cache as needing refresh |
| 13 | - | invalidate(): void { | 13 | + | invalidate(): void { |
| 14 | - | this.isDirty = true; | 14 | + | this.isDirty = true; |
| 15 | - | log.debug('Buddy list cache invalidated'); | 15 | + | log.debug('Buddy list cache invalidated'); |
| 16 | - | } | 16 | + | } |
| 17 | - | 17 | + | ||
| 18 | - | // Check if we need to fetch new data | 18 | + | // Check if we need to fetch new data |
| 19 | - | needsRefresh(): boolean { | 19 | + | needsRefresh(): boolean { |
| 20 | - | return this.isDirty || this.cache === null; | 20 | + | return this.isDirty || this.cache === null; |
| 21 | - | } | 21 | + | } |
| 22 | - | 22 | + | ||
| 23 | - | // Update cache with new data | 23 | + | // Update cache with new data |
| 24 | - | update(users: SafeUser[]): boolean { | 24 | + | update(users: SafeUser[]): boolean { |
| 25 | - | const newHash = JSON.stringify(users); | 25 | + | const newHash = JSON.stringify(users); |
| 26 | - | const hasChanged = newHash !== this.lastHash; | 26 | + | const hasChanged = newHash !== this.lastHash; |
| 27 | - | 27 | + | ||
| 28 | - | if (hasChanged) { | 28 | + | if (hasChanged) { |
| 29 | - | this.cache = users; | 29 | + | this.cache = users; |
| 30 | - | this.lastHash = newHash; | 30 | + | this.lastHash = newHash; |
| 31 | - | this.lastFetch = Date.now(); | 31 | + | this.lastFetch = Date.now(); |
| 32 | - | this.isDirty = false; | 32 | + | this.isDirty = false; |
| 33 | - | 33 | + | ||
| 34 | - | log.debug('Buddy list cache updated', { | 34 | + | log.debug('Buddy list cache updated', { |
| 35 | - | userCount: users.length, | 35 | + | userCount: users.length, |
| 36 | - | changed: hasChanged | 36 | + | changed: hasChanged |
| 37 | - | }); | 37 | + | }); |
| 38 | - | } | 38 | + | } |
| 39 | - | 39 | + | ||
| 40 | - | return hasChanged; | 40 | + | return hasChanged; |
| 41 | - | } | 41 | + | } |
| 42 | - | 42 | + | ||
| 43 | - | // Get cached data | 43 | + | // Get cached data |
| 44 | - | get(): SafeUser[] | null { | 44 | + | get(): SafeUser[] | null { |
| 45 | - | return this.cache; | 45 | + | return this.cache; |
| 46 | - | } | 46 | + | } |
| 47 | - | 47 | + | ||
| 48 | - | // Get cache age in milliseconds | 48 | + | // Get cache age in milliseconds |
| 49 | - | getAge(): number { | 49 | + | getAge(): number { |
| 50 | - | return this.cache ? Date.now() - this.lastFetch : Infinity; | 50 | + | return this.cache ? Date.now() - this.lastFetch : Infinity; |
| 51 | - | } | 51 | + | } |
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | // Export singleton instance | 54 | // Export singleton instance | ||
| 55 | - | export const buddyListCache = new BuddyListCache();⏎̸ | 55 | + | export const buddyListCache = new BuddyListCache(); |
M src/lib/components/aim-login.svelte +606 -468
| @@ -1,478 +1,616 @@ | |||||
| 1 | <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs --> | 1 | <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs --> | ||
| 2 | <script lang="ts"> | 2 | <script lang="ts"> | ||
| 3 | - | import { draggable } from '$lib/actions/draggable'; | 3 | + | import { draggable } from '$lib/actions/draggable'; |
| 4 | - | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | 4 | + | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; |
| 5 | - | import type { User, SafeUser } from '$lib/types/chat'; | 5 | + | import { invalidateAll } from '$app/navigation'; |
| 6 | - | import { invalidateAll } from '$app/navigation'; | 6 | + | import { chatState } from '$lib/states/chat.svelte'; |
| 7 | - | import { chatState } from '$lib/states/chat.svelte'; | 7 | + | import { createSafeUser } from '$lib/types/chat'; |
| 8 | - | import { createSafeUser } from '$lib/types/chat'; | 8 | + | import { onMount } from 'svelte'; |
| 9 | - | import { onMount } from 'svelte'; | 9 | + | import PasswordStrengthIndicator from './password-strength-indicator.svelte'; |
| 10 | - | import PasswordStrengthIndicator from './password-strength-indicator.svelte'; | 10 | + | import { |
| 11 | - | import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password'; | 11 | + | loginSchema, |
| 12 | - | 12 | + | createRegistrationSchema, | |
| 13 | - | interface $$Props { | 13 | + | DEFAULT_PASSWORD_CONSTRAINTS, |
| 14 | - | showAuth: boolean; | 14 | + | type PasswordConstraints |
| 15 | - | onLoginSuccess?: (user: SafeUser | null) => void; | 15 | + | } from '../validation/password'; |
| 16 | - | activeTab?: 'signin' | 'signup'; | 16 | + | import { z } from 'zod'; |
| 17 | - | } | 17 | + | |
| 18 | - | 18 | + | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | |
| 19 | - | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | 19 | + | |
| 20 | - | 20 | + | let activeTab = $state(initialTab); | |
| 21 | - | let activeTab = $state(initialTab); | 21 | + | |
| 22 | - | 22 | + | // Sign In state | |
| 23 | - | // Sign In state | 23 | + | let siUsername = $state(''); |
| 24 | - | let siUsername = $state(''); | 24 | + | let siPassword = $state(''); |
| 25 | - | let siPassword = $state(''); | 25 | + | let error = $state(''); |
| 26 | - | let error = $state(''); | 26 | + | let loginStatus = $state('idle'); |
| 27 | - | let loginStatus = $state('idle'); | 27 | + | |
| 28 | - | 28 | + | // Sign Up state | |
| 29 | - | // Sign Up state | 29 | + | let suUsername = $state(''); |
| 30 | - | let suUsername = $state(''); | 30 | + | let suPassword = $state(''); |
| 31 | - | let suPassword = $state(''); | 31 | + | let suConfirmPassword = $state(''); |
| 32 | - | let suConfirmPassword = $state(''); | 32 | + | let captchaAnswer = $state(''); |
| 33 | - | let captchaAnswer = $state(''); | 33 | + | let signupError = $state(''); |
| 34 | - | let signupError = $state(''); | 34 | + | let signupStatus = $state('idle'); |
| 35 | - | let signupStatus = $state('idle'); | 35 | + | let suTurnstileToken = $state(''); |
| 36 | - | let suTurnstileToken = $state(''); | 36 | + | |
| 37 | - | 37 | + | // Password validation state | |
| 38 | - | // Password validation state | 38 | + | let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS; |
| 39 | - | let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS; | 39 | + | let showPasswordStrength = $state(false); |
| 40 | - | let showPasswordStrength = $state(false); | 40 | + | |
| 41 | - | 41 | + | // Added dragging state and handler similar to chat-room | |
| 42 | - | // Added dragging state and handler similar to chat-room | 42 | + | let windowX = $state(0); |
| 43 | - | let windowX = $state(0); | 43 | + | let windowY = $state(0); |
| 44 | - | let windowY = $state(0); | 44 | + | |
| 45 | - | 45 | + | // Initialize window position on mount | |
| 46 | - | // Initialize window position on mount | 46 | + | onMount(() => { |
| 47 | - | onMount(() => { | 47 | + | if (window.innerWidth > 768) { |
| 48 | - | if (window.innerWidth > 768) { // Desktop mode | 48 | + | // Desktop mode |
| 49 | - | windowX = window.innerWidth * 0.2; | 49 | + | windowX = window.innerWidth * 0.2; |
| 50 | - | windowY = window.innerHeight * 0.2; | 50 | + | windowY = window.innerHeight * 0.2; |
| 51 | - | } | 51 | + | } |
| 52 | - | }); | 52 | + | }); |
| 53 | - | 53 | + | ||
| 54 | - | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | 54 | + | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { |
| 55 | - | windowX = event.detail.x; | 55 | + | windowX = event.detail.x; |
| 56 | - | windowY = event.detail.y; | 56 | + | windowY = event.detail.y; |
| 57 | - | } | 57 | + | } |
| 58 | - | 58 | + | ||
| 59 | - | function handleClose() { | 59 | + | function handleClose() { |
| 60 | - | console.debug("Closing AIM Login component."); | 60 | + | console.debug('Closing AIM Login component.'); |
| 61 | - | showAuth = false; | 61 | + | showAuth = false; |
| 62 | - | } | 62 | + | } |
| 63 | - | 63 | + | ||
| 64 | - | async function handleSigninSubmit() { | 64 | + | async function handleSigninSubmit() { |
| 65 | - | error = ''; | 65 | + | error = ''; |
| 66 | - | loginStatus = 'loading'; | 66 | + | loginStatus = 'loading'; |
| 67 | - | 67 | + | ||
| 68 | - | // Validate login data | 68 | + | // Validate login data |
| 69 | - | const validationError = validateLoginData(); | 69 | + | const validationError = validateLoginData(); |
| 70 | - | if (validationError) { | 70 | + | if (validationError) { |
| 71 | - | error = validationError; | 71 | + | error = validationError; |
| 72 | - | loginStatus = 'error'; | 72 | + | loginStatus = 'error'; |
| 73 | - | return; | 73 | + | return; |
| 74 | - | } | 74 | + | } |
| 75 | - | 75 | + | ||
| 76 | - | console.debug("Attempting login for", siUsername); | 76 | + | console.debug('Attempting login for', siUsername); |
| 77 | - | try { | 77 | + | try { |
| 78 | - | const res = await fetch('/api/session/login', { | 78 | + | const res = await fetch('/api/session/login', { |
| 79 | - | method: 'POST', | 79 | + | method: 'POST', |
| 80 | - | headers: { 'Content-Type': 'application/json' }, | 80 | + | headers: { 'Content-Type': 'application/json' }, |
| 81 | - | body: JSON.stringify({ | 81 | + | body: JSON.stringify({ |
| 82 | - | username: siUsername, | 82 | + | username: siUsername, |
| 83 | - | password: siPassword | 83 | + | password: siPassword |
| 84 | - | }) | 84 | + | }) |
| 85 | - | }); | 85 | + | }); |
| 86 | - | 86 | + | ||
| 87 | - | const data = await res.json(); | 87 | + | const data = await res.json(); |
| 88 | - | if (!res.ok) { | 88 | + | if (!res.ok) { |
| 89 | - | error = data.error || "Login failed."; | 89 | + | error = data.error || 'Login failed.'; |
| 90 | - | console.debug("Login failed:", data.error); | 90 | + | console.debug('Login failed:', data.error); |
| 91 | - | loginStatus = 'error'; | 91 | + | loginStatus = 'error'; |
| 92 | - | return; | 92 | + | return; |
| 93 | - | } | 93 | + | } |
| 94 | - | 94 | + | ||
| 95 | - | console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null }); | 95 | + | console.debug('Login succeeded:', { |
| 96 | - | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | 96 | + | ...data, |
| 97 | - | loginStatus = 'success'; | 97 | + | user: data.user ? { ...data.user, password: '[REDACTED]' } : null |
| 98 | - | 98 | + | }); | |
| 99 | - | // Call the callback prop if provided | 99 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); |
| 100 | - | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | 100 | + | loginStatus = 'success'; |
| 101 | - | // Auto close login window after 3 seconds on successful login | 101 | + | |
| 102 | - | setTimeout(() => { | 102 | + | // Call the callback prop if provided |
| 103 | - | console.debug("Auto closing AIM Login component after successful login"); | 103 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); |
| 104 | - | invalidateAll(); | 104 | + | // Auto close login window after 3 seconds on successful login |
| 105 | - | handleClose(); | 105 | + | setTimeout(() => { |
| 106 | - | }, 3000); | 106 | + | console.debug('Auto closing AIM Login component after successful login'); |
| 107 | - | 107 | + | invalidateAll(); | |
| 108 | - | return; | 108 | + | handleClose(); |
| 109 | - | } catch (err) { | 109 | + | }, 3000); |
| 110 | - | console.debug("Login error:", err); | 110 | + | |
| 111 | - | error = "Login error. Please try again."; | 111 | + | return; |
| 112 | - | loginStatus = 'error'; | 112 | + | } catch (err) { |
| 113 | - | } | 113 | + | console.debug('Login error:', err); |
| 114 | - | } | 114 | + | error = 'Login error. Please try again.'; |
| 115 | - | 115 | + | loginStatus = 'error'; | |
| 116 | - | // Validation using Zod schemas | 116 | + | } |
| 117 | - | function validateLoginData() { | 117 | + | } |
| 118 | - | try { | 118 | + | |
| 119 | - | loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() }); | 119 | + | // Validation using Zod schemas |
| 120 | - | return null; | 120 | + | function validateLoginData() { |
| 121 | - | } catch (err: any) { | 121 | + | try { |
| 122 | - | return err.errors?.[0]?.message || 'Invalid input'; | 122 | + | loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() }); |
| 123 | - | } | 123 | + | return null; |
| 124 | - | } | 124 | + | } catch (err) { |
| 125 | - | 125 | + | if (err instanceof z.ZodError) { | |
| 126 | - | function validateSignupData() { | 126 | + | return err.issues?.[0]?.message || 'Invalid input'; |
| 127 | - | try { | 127 | + | } |
| 128 | - | const registrationSchema = createRegistrationSchema(passwordConstraints); | 128 | + | return 'Invalid input'; |
| 129 | - | registrationSchema.parse({ | 129 | + | } |
| 130 | - | suUsername: suUsername.trim(), | 130 | + | } |
| 131 | - | suPassword: suPassword.trim(), | 131 | + | |
| 132 | - | suConfirmPassword: suConfirmPassword.trim(), | 132 | + | function validateSignupData() { |
| 133 | - | captchaAnswer: captchaAnswer.trim(), | 133 | + | try { |
| 134 | - | turnstileToken: suTurnstileToken | 134 | + | const registrationSchema = createRegistrationSchema(passwordConstraints); |
| 135 | - | }); | 135 | + | registrationSchema.parse({ |
| 136 | - | return null; | 136 | + | suUsername: suUsername.trim(), |
| 137 | - | } catch (err: any) { | 137 | + | suPassword: suPassword.trim(), |
| 138 | - | return err.errors?.[0]?.message || 'Invalid input'; | 138 | + | suConfirmPassword: suConfirmPassword.trim(), |
| 139 | - | } | 139 | + | captchaAnswer: captchaAnswer.trim(), |
| 140 | - | } | 140 | + | turnstileToken: suTurnstileToken |
| 141 | - | 141 | + | }); | |
| 142 | - | async function handleSignupSubmit() { | 142 | + | return null; |
| 143 | - | signupError = ''; | 143 | + | } catch (err) { |
| 144 | - | signupStatus = 'loading'; | 144 | + | if (err instanceof z.ZodError) { |
| 145 | - | 145 | + | return err.issues?.[0]?.message || 'Invalid input'; | |
| 146 | - | // Client-side validations before submitting the form. | 146 | + | } |
| 147 | - | const validationError = validateSignupData(); | 147 | + | return 'Invalid input'; |
| 148 | - | if (validationError) { | 148 | + | } |
| 149 | - | signupError = validationError; | 149 | + | } |
| 150 | - | signupStatus = 'error'; | 150 | + | |
| 151 | - | return; | 151 | + | async function handleSignupSubmit() { |
| 152 | - | } | 152 | + | signupError = ''; |
| 153 | - | 153 | + | signupStatus = 'loading'; | |
| 154 | - | // Additional check for captcha | 154 | + | |
| 155 | - | if (!captchaAnswer) { | 155 | + | // Client-side validations before submitting the form. |
| 156 | - | signupError = 'Please complete all fields including the PDR question'; | 156 | + | const validationError = validateSignupData(); |
| 157 | - | signupStatus = 'error'; | 157 | + | if (validationError) { |
| 158 | - | return; | 158 | + | signupError = validationError; |
| 159 | - | } | 159 | + | signupStatus = 'error'; |
| 160 | - | 160 | + | return; | |
| 161 | - | console.debug("Attempting registration for", suUsername); | 161 | + | } |
| 162 | - | try { | 162 | + | |
| 163 | - | const res = await fetch('/api/register', { | 163 | + | // Additional check for captcha |
| 164 | - | method: 'POST', | 164 | + | if (!captchaAnswer) { |
| 165 | - | headers: { 'Content-Type': 'application/json' }, | 165 | + | signupError = 'Please complete all fields including the PDR question'; |
| 166 | - | body: JSON.stringify({ | 166 | + | signupStatus = 'error'; |
| 167 | - | suUsername, | 167 | + | return; |
| 168 | - | suPassword, | 168 | + | } |
| 169 | - | suConfirmPassword, | 169 | + | |
| 170 | - | captchaAnswer, | 170 | + | console.debug('Attempting registration for', suUsername); |
| 171 | - | turnstileToken: suTurnstileToken | 171 | + | try { |
| 172 | - | }) | 172 | + | const res = await fetch('/api/register', { |
| 173 | - | }); | 173 | + | method: 'POST', |
| 174 | - | 174 | + | headers: { 'Content-Type': 'application/json' }, | |
| 175 | - | const data = await res.json() as RegisterResponse; | 175 | + | body: JSON.stringify({ |
| 176 | - | if (!res.ok) { | 176 | + | suUsername, |
| 177 | - | const errorData = data as RegisterResponseError; | 177 | + | suPassword, |
| 178 | - | signupError = errorData.error || "Registration failed."; | 178 | + | suConfirmPassword, |
| 179 | - | signupStatus = 'error'; | 179 | + | captchaAnswer, |
| 180 | - | return; | 180 | + | turnstileToken: suTurnstileToken |
| 181 | - | } | 181 | + | }) |
| 182 | - | console.debug("Registration succeeded:", data); | 182 | + | }); |
| 183 | - | signupStatus = 'success'; | 183 | + | |
| 184 | - | 184 | + | const data = (await res.json()) as RegisterResponse; | |
| 185 | - | // Auto login after registration. | 185 | + | if (!res.ok) { |
| 186 | - | await handleAutoLoginSignup(); | 186 | + | const errorData = data as RegisterResponseError; |
| 187 | - | return; | 187 | + | signupError = errorData.error || 'Registration failed.'; |
| 188 | - | } catch (err) { | 188 | + | signupStatus = 'error'; |
| 189 | - | console.debug("Registration error", err); | 189 | + | return; |
| 190 | - | signupError = "Registration error. Please try again."; | 190 | + | } |
| 191 | - | signupStatus = 'error'; | 191 | + | console.debug('Registration succeeded:', data); |
| 192 | - | } | 192 | + | signupStatus = 'success'; |
| 193 | - | } | 193 | + | |
| 194 | - | 194 | + | // Auto login after registration. | |
| 195 | - | async function handleAutoLoginSignup() { | 195 | + | await handleAutoLoginSignup(); |
| 196 | - | try { | 196 | + | return; |
| 197 | - | const res = await fetch('/api/session/login', { | 197 | + | } catch (err) { |
| 198 | - | method: 'POST', | 198 | + | console.debug('Registration error', err); |
| 199 | - | headers: { 'Content-Type': 'application/json' }, | 199 | + | signupError = 'Registration error. Please try again.'; |
| 200 | - | body: JSON.stringify({ username: suUsername, password: suPassword }) | 200 | + | signupStatus = 'error'; |
| 201 | - | }); | 201 | + | } |
| 202 | - | const data = await res.json(); | 202 | + | } |
| 203 | - | if (!res.ok) { | 203 | + | |
| 204 | - | signupError = data.error || "Login failed after registration."; | 204 | + | async function handleAutoLoginSignup() { |
| 205 | - | signupStatus = 'error'; | 205 | + | try { |
| 206 | - | return; | 206 | + | const res = await fetch('/api/session/login', { |
| 207 | - | } | 207 | + | method: 'POST', |
| 208 | - | console.debug("Auto login after registration succeeded:", data); | 208 | + | headers: { 'Content-Type': 'application/json' }, |
| 209 | - | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | 209 | + | body: JSON.stringify({ username: suUsername, password: suPassword }) |
| 210 | - | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | 210 | + | }); |
| 211 | - | setTimeout(() => { | 211 | + | const data = await res.json(); |
| 212 | - | console.debug("Auto closing AIM Login component after auto login on registration"); | 212 | + | if (!res.ok) { |
| 213 | - | invalidateAll(); | 213 | + | signupError = data.error || 'Login failed after registration.'; |
| 214 | - | handleClose(); | 214 | + | signupStatus = 'error'; |
| 215 | - | }, 3000); | 215 | + | return; |
| 216 | - | 216 | + | } | |
| 217 | - | } catch (err) { | 217 | + | console.debug('Auto login after registration succeeded:', data); |
| 218 | - | console.debug("Auto login error:", err); | 218 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); |
| 219 | - | signupError = "Auto login error. Please try to login manually."; | 219 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); |
| 220 | - | signupStatus = 'error'; | 220 | + | setTimeout(() => { |
| 221 | - | } | 221 | + | console.debug('Auto closing AIM Login component after auto login on registration'); |
| 222 | - | } | 222 | + | invalidateAll(); |
| 223 | - | 223 | + | handleClose(); | |
| 224 | - | function switchTab(tab: string) { | 224 | + | }, 3000); |
| 225 | - | activeTab = tab; | 225 | + | } catch (err) { |
| 226 | - | error = ''; | 226 | + | console.debug('Auto login error:', err); |
| 227 | - | signupError = ''; | 227 | + | signupError = 'Auto login error. Please try to login manually.'; |
| 228 | - | } | 228 | + | signupStatus = 'error'; |
| 229 | - | 229 | + | } | |
| 230 | - | function handleKeydown(event: KeyboardEvent) { | 230 | + | } |
| 231 | - | if (event.key === 'Escape') { | 231 | + | |
| 232 | - | console.debug("Escape key pressed, closing AIM Login component."); | 232 | + | function switchTab(tab: string) { |
| 233 | - | handleClose(); | 233 | + | activeTab = tab; |
| 234 | - | } | 234 | + | error = ''; |
| 235 | - | } | 235 | + | signupError = ''; |
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | function handleKeydown(event: KeyboardEvent) { | |||
| 239 | + | if (event.key === 'Escape') { | |||
| 240 | + | console.debug('Escape key pressed, closing AIM Login component.'); | |||
| 241 | + | handleClose(); | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 236 | </script> | 244 | </script> | ||
| 237 | 245 | ||||
| 238 | <svelte:window onkeydown={handleKeydown} /> | 246 | <svelte:window onkeydown={handleKeydown} /> | ||
| 239 | 247 | ||||
| 240 | <!-- Updated outer div to handle dragging with inline style and drag event --> | 248 | <!-- 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;"> | 249 | + | <div |
| 242 | - | <div class="title-bar"> | 250 | + | class="login-window window" |
| 243 | - | <div class="title-bar-text"> | 251 | + | use:draggable={{ handle: '.title-bar' }} |
| 244 | - | <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" /> | 252 | + | ondragmove={handleDragMove} |
| 245 | - | {activeTab === 'signin' ? 'Connexion' : 'Inscription'} | 253 | + | style="left: {windowX}px; top: {windowY}px;" |
| 246 | - | </div> | 254 | + | > |
| 247 | - | <div class="title-bar-controls"> | 255 | + | <div class="title-bar"> |
| 248 | - | <button aria-label="Close" onclick={handleClose}>×</button> | 256 | + | <div class="title-bar-text"> |
| 249 | - | </div> | 257 | + | <img |
| 250 | - | </div> | 258 | + | src="/desktop/aim-desktop-icon.png" |
| 251 | - | 259 | + | alt="AIM" | |
| 252 | - | <div class="tabs"> | 260 | + | class="title-icon" |
| 253 | - | <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Connexion</button> | 261 | + | width="16" |
| 254 | - | <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Inscription</button> | 262 | + | height="16" |
| 255 | - | </div> | 263 | + | /> |
| 256 | - | 264 | + | {activeTab === 'signin' ? 'Connexion' : 'Inscription'} | |
| 257 | - | <div class="window-content"> | 265 | + | </div> |
| 258 | - | {#if activeTab === 'signin'} | 266 | + | <div class="title-bar-controls"> |
| 259 | - | <form onsubmit={handleSigninSubmit}> | 267 | + | <button aria-label="Close" onclick={handleClose}>×</button> |
| 260 | - | <div class="form-group"> | 268 | + | </div> |
| 261 | - | <label for="si-username">Nom d'utilisateur</label> | 269 | + | </div> |
| 262 | - | <input type="text" id="si-username" bind:value={siUsername} placeholder="Nom d'utilisateur" /> | 270 | + | |
| 263 | - | </div> | 271 | + | <div class="tabs"> |
| 264 | - | <div class="form-group"> | 272 | + | <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')} |
| 265 | - | <label for="si-password">Mot de passe</label> | 273 | + | >Connexion</button |
| 266 | - | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | 274 | + | > |
| 267 | - | </div> | 275 | + | <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')} |
| 268 | - | {#if error} | 276 | + | >Inscription</button |
| 269 | - | <div class="error">{error}</div> | 277 | + | > |
| 270 | - | {/if} | 278 | + | </div> |
| 271 | - | <div class="button-bar"> | 279 | + | |
| 272 | - | <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}"> | 280 | + | <div class="window-content"> |
| 273 | - | {#if loginStatus === 'loading'} | 281 | + | {#if activeTab === 'signin'} |
| 274 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 282 | + | <form onsubmit={handleSigninSubmit}> |
| 275 | - | <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle> | 283 | + | <div class="form-group"> |
| 276 | - | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | 284 | + | <label for="si-username">Nom d'utilisateur</label> |
| 277 | - | <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/> | 285 | + | <input |
| 278 | - | </path> | 286 | + | type="text" |
| 279 | - | </svg> | 287 | + | id="si-username" |
| 280 | - | {:else if loginStatus === 'success'} | 288 | + | bind:value={siUsername} |
| 281 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 289 | + | placeholder="Nom d'utilisateur" |
| 282 | - | <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/> | 290 | + | /> |
| 283 | - | </svg> | 291 | + | </div> |
| 284 | - | {:else if loginStatus === 'error'} | 292 | + | <div class="form-group"> |
| 285 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 293 | + | <label for="si-password">Mot de passe</label> |
| 286 | - | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/> | 294 | + | <input |
| 287 | - | <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/> | 295 | + | type="password" |
| 288 | - | <circle cx="12" cy="17" r="1" fill="red"/> | 296 | + | id="si-password" |
| 289 | - | </svg> | 297 | + | bind:value={siPassword} |
| 290 | - | {:else} | 298 | + | placeholder="Mot de passe" |
| 291 | - | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | 299 | + | /> |
| 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"/> | 300 | + | </div> |
| 293 | - | </svg> | 301 | + | {#if error} |
| 294 | - | {/if} | 302 | + | <div class="error">{error}</div> |
| 295 | - | Se connecter | 303 | + | {/if} |
| 296 | - | </button> | 304 | + | <div class="button-bar"> |
| 297 | - | </div> | 305 | + | <button |
| 298 | - | </form> | 306 | + | type="submit" |
| 299 | - | {:else} | 307 | + | class="icon-button {loginStatus === 'success' |
| 300 | - | <form onsubmit={handleSignupSubmit}> | 308 | + | ? 'success-border' |
| 301 | - | <div class="form-group"> | 309 | + | : loginStatus === 'error' |
| 302 | - | <label for="su-username">Nom d'utilisateur</label> | 310 | + | ? 'error-border' |
| 303 | - | <input type="text" id="su-username" bind:value={suUsername} placeholder="Choisissez un nom d'utilisateur" /> | 311 | + | : ''}" |
| 304 | - | </div> | 312 | + | > |
| 305 | - | <div class="form-group"> | 313 | + | {#if loginStatus === 'loading'} |
| 306 | - | <label for="su-password">Mot de passe</label> | 314 | + | <svg width="24" height="24" viewBox="0 0 24 24"> |
| 307 | - | <input | 315 | + | <circle |
| 308 | - | type="password" | 316 | + | cx="12" |
| 309 | - | id="su-password" | 317 | + | cy="12" |
| 310 | - | bind:value={suPassword} | 318 | + | r="10" |
| 311 | - | placeholder="Mot de passe" | 319 | + | stroke="currentColor" |
| 312 | - | onfocus={() => showPasswordStrength = true} | 320 | + | stroke-width="2" |
| 313 | - | /> | 321 | + | fill="none" |
| 314 | - | {#if showPasswordStrength} | 322 | + | opacity="0.25" |
| 315 | - | <PasswordStrengthIndicator | 323 | + | ></circle> |
| 316 | - | password={suPassword} | 324 | + | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> |
| 317 | - | constraints={passwordConstraints} | 325 | + | <animateTransform |
| 318 | - | showDetails={true} | 326 | + | attributeName="transform" |
| 319 | - | /> | 327 | + | type="rotate" |
| 320 | - | {/if} | 328 | + | values="0 12 12;360 12 12" |
| 321 | - | </div> | 329 | + | dur="1s" |
| 322 | - | <div class="form-group"> | 330 | + | repeatCount="indefinite" |
| 323 | - | <label for="su-confirm-password">Confirmer le mot de passe</label> | 331 | + | /> |
| 324 | - | <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirmer le mot de passe" /> | 332 | + | </path> |
| 325 | - | </div> | 333 | + | </svg> |
| 326 | - | <div class="form-group"> | 334 | + | {:else if loginStatus === 'success'} |
| 327 | - | <label for="captcha">Quelle est la signification de PDR ?</label> | 335 | + | <svg width="24" height="24" viewBox="0 0 24 24"> |
| 328 | - | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | 336 | + | <path |
| 329 | - | </div> | 337 | + | d="M20 6L9 17l-5-5" |
| 330 | - | {#if signupError} | 338 | + | stroke="green" |
| 331 | - | <div class="error">{signupError}</div> | 339 | + | stroke-width="2" |
| 332 | - | {/if} | 340 | + | fill="none" |
| 333 | - | <div class="button-bar"> | 341 | + | stroke-linecap="round" |
| 334 | - | <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}"> | 342 | + | stroke-linejoin="round" |
| 335 | - | {#if signupStatus === 'loading'} | 343 | + | /> |
| 336 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 344 | + | </svg> |
| 337 | - | <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle> | 345 | + | {:else if loginStatus === 'error'} |
| 338 | - | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | 346 | + | <svg width="24" height="24" viewBox="0 0 24 24"> |
| 339 | - | <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform> | 347 | + | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none" /> |
| 340 | - | </path> | 348 | + | <line |
| 341 | - | </svg> | 349 | + | x1="12" |
| 342 | - | {:else if signupStatus === 'success'} | 350 | + | y1="8" |
| 343 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 351 | + | x2="12" |
| 344 | - | <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path> | 352 | + | y2="13" |
| 345 | - | </svg> | 353 | + | stroke="red" |
| 346 | - | {:else if signupStatus === 'error'} | 354 | + | stroke-width="2" |
| 347 | - | <svg width="24" height="24" viewBox="0 0 24 24"> | 355 | + | stroke-linecap="round" |
| 348 | - | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle> | 356 | + | /> |
| 349 | - | <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line> | 357 | + | <circle cx="12" cy="17" r="1" fill="red" /> |
| 350 | - | <circle cx="12" cy="17" r="1" fill="red"></circle> | 358 | + | </svg> |
| 351 | - | </svg> | 359 | + | {:else} |
| 352 | - | {:else} | 360 | + | <svg |
| 353 | - | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | 361 | + | width="24" |
| 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> | 362 | + | height="24" |
| 355 | - | </svg> | 363 | + | viewBox="0 0 24 24" |
| 356 | - | {/if} | 364 | + | fill="none" |
| 357 | - | S'inscrire | 365 | + | xmlns="http://www.w3.org/2000/svg" |
| 358 | - | </button> | 366 | + | > |
| 359 | - | </div> | 367 | + | <path |
| 360 | - | </form> | 368 | + | 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" |
| 361 | - | {/if} | 369 | + | fill="currentColor" |
| 362 | - | </div> | 370 | + | /> |
| 371 | + | </svg> | |||
| 372 | + | {/if} | |||
| 373 | + | Se connecter | |||
| 374 | + | </button> | |||
| 375 | + | </div> | |||
| 376 | + | </form> | |||
| 377 | + | {:else} | |||
| 378 | + | <form onsubmit={handleSignupSubmit}> | |||
| 379 | + | <div class="form-group"> | |||
| 380 | + | <label for="su-username">Nom d'utilisateur</label> | |||
| 381 | + | <input | |||
| 382 | + | type="text" | |||
| 383 | + | id="su-username" | |||
| 384 | + | bind:value={suUsername} | |||
| 385 | + | placeholder="Choisissez un nom d'utilisateur" | |||
| 386 | + | /> | |||
| 387 | + | </div> | |||
| 388 | + | <div class="form-group"> | |||
| 389 | + | <label for="su-password">Mot de passe</label> | |||
| 390 | + | <input | |||
| 391 | + | type="password" | |||
| 392 | + | id="su-password" | |||
| 393 | + | bind:value={suPassword} | |||
| 394 | + | placeholder="Mot de passe" | |||
| 395 | + | onfocus={() => (showPasswordStrength = true)} | |||
| 396 | + | /> | |||
| 397 | + | {#if showPasswordStrength} | |||
| 398 | + | <PasswordStrengthIndicator | |||
| 399 | + | password={suPassword} | |||
| 400 | + | constraints={passwordConstraints} | |||
| 401 | + | showDetails={true} | |||
| 402 | + | /> | |||
| 403 | + | {/if} | |||
| 404 | + | </div> | |||
| 405 | + | <div class="form-group"> | |||
| 406 | + | <label for="su-confirm-password">Confirmer le mot de passe</label> | |||
| 407 | + | <input | |||
| 408 | + | type="password" | |||
| 409 | + | id="su-confirm-password" | |||
| 410 | + | bind:value={suConfirmPassword} | |||
| 411 | + | placeholder="Confirmer le mot de passe" | |||
| 412 | + | /> | |||
| 413 | + | </div> | |||
| 414 | + | <div class="form-group"> | |||
| 415 | + | <label for="captcha">Quelle est la signification de PDR ?</label> | |||
| 416 | + | <input | |||
| 417 | + | type="text" | |||
| 418 | + | id="captcha" | |||
| 419 | + | bind:value={captchaAnswer} | |||
| 420 | + | placeholder="Entrez votre réponse" | |||
| 421 | + | /> | |||
| 422 | + | </div> | |||
| 423 | + | {#if signupError} | |||
| 424 | + | <div class="error">{signupError}</div> | |||
| 425 | + | {/if} | |||
| 426 | + | <div class="button-bar"> | |||
| 427 | + | <button | |||
| 428 | + | type="submit" | |||
| 429 | + | class="icon-button {signupStatus === 'success' | |||
| 430 | + | ? 'success-border' | |||
| 431 | + | : signupStatus === 'error' | |||
| 432 | + | ? 'error-border' | |||
| 433 | + | : ''}" | |||
| 434 | + | > | |||
| 435 | + | {#if signupStatus === 'loading'} | |||
| 436 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |||
| 437 | + | <circle | |||
| 438 | + | cx="12" | |||
| 439 | + | cy="12" | |||
| 440 | + | r="10" | |||
| 441 | + | stroke="currentColor" | |||
| 442 | + | stroke-width="2" | |||
| 443 | + | fill="none" | |||
| 444 | + | opacity="0.25" | |||
| 445 | + | ></circle> | |||
| 446 | + | <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none"> | |||
| 447 | + | <animateTransform | |||
| 448 | + | attributeName="transform" | |||
| 449 | + | type="rotate" | |||
| 450 | + | values="0 12 12;360 12 12" | |||
| 451 | + | dur="1s" | |||
| 452 | + | repeatCount="indefinite" | |||
| 453 | + | ></animateTransform> | |||
| 454 | + | </path> | |||
| 455 | + | </svg> | |||
| 456 | + | {:else if signupStatus === 'success'} | |||
| 457 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |||
| 458 | + | <path | |||
| 459 | + | d="M20 6L9 17l-5-5" | |||
| 460 | + | stroke="green" | |||
| 461 | + | stroke-width="2" | |||
| 462 | + | fill="none" | |||
| 463 | + | stroke-linecap="round" | |||
| 464 | + | stroke-linejoin="round" | |||
| 465 | + | ></path> | |||
| 466 | + | </svg> | |||
| 467 | + | {:else if signupStatus === 'error'} | |||
| 468 | + | <svg width="24" height="24" viewBox="0 0 24 24"> | |||
| 469 | + | <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle> | |||
| 470 | + | <line | |||
| 471 | + | x1="12" | |||
| 472 | + | y1="8" | |||
| 473 | + | x2="12" | |||
| 474 | + | y2="13" | |||
| 475 | + | stroke="red" | |||
| 476 | + | stroke-width="2" | |||
| 477 | + | stroke-linecap="round" | |||
| 478 | + | ></line> | |||
| 479 | + | <circle cx="12" cy="17" r="1" fill="red"></circle> | |||
| 480 | + | </svg> | |||
| 481 | + | {:else} | |||
| 482 | + | <svg | |||
| 483 | + | width="24" | |||
| 484 | + | height="24" | |||
| 485 | + | viewBox="0 0 24 24" | |||
| 486 | + | fill="none" | |||
| 487 | + | xmlns="http://www.w3.org/2000/svg" | |||
| 488 | + | > | |||
| 489 | + | <path | |||
| 490 | + | 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" | |||
| 491 | + | fill="currentColor" | |||
| 492 | + | ></path> | |||
| 493 | + | </svg> | |||
| 494 | + | {/if} | |||
| 495 | + | S'inscrire | |||
| 496 | + | </button> | |||
| 497 | + | </div> | |||
| 498 | + | </form> | |||
| 499 | + | {/if} | |||
| 500 | + | </div> | |||
| 363 | </div> | 501 | </div> | ||
| 364 | 502 | ||||
| 365 | <style> | 503 | <style> | ||
| 366 | - | .login-window { | 504 | + | .login-window { |
| 367 | - | width: 300px; | 505 | + | width: 300px; |
| 368 | - | background: #ECE9D8; | 506 | + | background: #ece9d8; |
| 369 | - | /* Changed to fixed positioning for correct dragging */ | 507 | + | /* Changed to fixed positioning for correct dragging */ |
| 370 | - | position: fixed; | 508 | + | position: fixed; |
| 371 | - | } | 509 | + | } |
| 372 | - | 510 | + | ||
| 373 | - | @media (max-width: 768px) { | 511 | + | @media (max-width: 768px) { |
| 374 | - | .login-window { | 512 | + | .login-window { |
| 375 | - | width: 100% !important; | 513 | + | width: 100% !important; |
| 376 | - | height: 100% !important; | 514 | + | height: 100% !important; |
| 377 | - | top: 0 !important; | 515 | + | top: 0 !important; |
| 378 | - | left: 0 !important; | 516 | + | left: 0 !important; |
| 379 | - | right: 0 !important; | 517 | + | right: 0 !important; |
| 380 | - | bottom: 0 !important; | 518 | + | bottom: 0 !important; |
| 381 | - | margin: 0 !important; | 519 | + | margin: 0 !important; |
| 382 | - | border-radius: 0 !important; | 520 | + | border-radius: 0 !important; |
| 383 | - | transform: none !important; | 521 | + | transform: none !important; |
| 384 | - | } | 522 | + | } |
| 385 | - | 523 | + | ||
| 386 | - | .window-content { | 524 | + | .window-content { |
| 387 | - | height: calc(100% - 80px); /* Account for title bar and tabs */ | 525 | + | height: calc(100% - 80px); /* Account for title bar and tabs */ |
| 388 | - | overflow-y: auto; | 526 | + | overflow-y: auto; |
| 389 | - | } | 527 | + | } |
| 390 | - | 528 | + | ||
| 391 | - | form { | 529 | + | form { |
| 392 | - | max-width: 400px; | 530 | + | max-width: 400px; |
| 393 | - | margin: 0 auto; | 531 | + | margin: 0 auto; |
| 394 | - | padding: 1rem; | 532 | + | padding: 1rem; |
| 395 | - | } | 533 | + | } |
| 396 | - | } | 534 | + | } |
| 397 | - | 535 | + | ||
| 398 | - | .title-bar { | 536 | + | .title-bar { |
| 399 | - | background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%); | 537 | + | background: linear-gradient(180deg, #0054e3 0%, #0047ab 100%); |
| 400 | - | padding: 4px 6px; | 538 | + | padding: 4px 6px; |
| 401 | - | display: flex; | 539 | + | display: flex; |
| 402 | - | justify-content: space-between; | 540 | + | justify-content: space-between; |
| 403 | - | align-items: center; | 541 | + | align-items: center; |
| 404 | - | color: white; | 542 | + | color: white; |
| 405 | - | font-weight: bold; | 543 | + | font-weight: bold; |
| 406 | - | user-select: none; | 544 | + | user-select: none; |
| 407 | - | position: relative; | 545 | + | position: relative; |
| 408 | - | } | 546 | + | } |
| 409 | - | .title-bar-text { | 547 | + | .title-bar-text { |
| 410 | - | display: flex; | 548 | + | display: flex; |
| 411 | - | align-items: center; | 549 | + | align-items: center; |
| 412 | - | gap: 4px; | 550 | + | gap: 4px; |
| 413 | - | font-size: 0.875rem; | 551 | + | font-size: 0.875rem; |
| 414 | - | } | 552 | + | } |
| 415 | - | 553 | + | ||
| 416 | - | .tabs { | 554 | + | .tabs { |
| 417 | - | padding: 0 3px; | 555 | + | padding: 0 3px; |
| 418 | - | display: flex; | 556 | + | display: flex; |
| 419 | - | border-bottom: 1px solid #0054E3; | 557 | + | border-bottom: 1px solid #0054e3; |
| 420 | - | } | 558 | + | } |
| 421 | - | .tabs button { | 559 | + | .tabs button { |
| 422 | - | flex: 1; | 560 | + | flex: 1; |
| 423 | - | padding: 8px; | 561 | + | padding: 8px; |
| 424 | - | border: none; | 562 | + | border: none; |
| 425 | - | background: #ECE9D8; | 563 | + | background: #ece9d8; |
| 426 | - | font-size: 1rem; | 564 | + | font-size: 1rem; |
| 427 | - | cursor: pointer; | 565 | + | cursor: pointer; |
| 428 | - | } | 566 | + | } |
| 429 | - | .tabs button.active { | 567 | + | .tabs button.active { |
| 430 | - | background: #fff; | 568 | + | background: #fff; |
| 431 | - | border-bottom: 2px solid #0054E3; | 569 | + | border-bottom: 2px solid #0054e3; |
| 432 | - | font-weight: bold; | 570 | + | font-weight: bold; |
| 433 | - | } | 571 | + | } |
| 434 | - | .window-content { | 572 | + | .window-content { |
| 435 | - | padding: 16px; | 573 | + | padding: 16px; |
| 436 | - | } | 574 | + | } |
| 437 | - | .form-group { | 575 | + | .form-group { |
| 438 | - | margin-bottom: 16px; | 576 | + | margin-bottom: 16px; |
| 439 | - | } | 577 | + | } |
| 440 | - | .form-group label { | 578 | + | .form-group label { |
| 441 | - | display: block; | 579 | + | display: block; |
| 442 | - | margin-bottom: 4px; | 580 | + | margin-bottom: 4px; |
| 443 | - | font-size: 1rem; | 581 | + | font-size: 1rem; |
| 444 | - | } | 582 | + | } |
| 445 | - | .form-group input { | 583 | + | .form-group input { |
| 446 | - | width: 100%; | 584 | + | width: 100%; |
| 447 | - | padding: 4px; | 585 | + | padding: 4px; |
| 448 | - | font-size: 1rem; | 586 | + | font-size: 1rem; |
| 449 | - | font-family: Arial, Verdana, Tahoma, sans-serif; | 587 | + | font-family: Arial, Verdana, Tahoma, sans-serif; |
| 450 | - | } | 588 | + | } |
| 451 | - | .button-bar { | 589 | + | .button-bar { |
| 452 | - | display: flex; | 590 | + | display: flex; |
| 453 | - | justify-content: center; | 591 | + | justify-content: center; |
| 454 | - | margin-top: 20px; | 592 | + | margin-top: 20px; |
| 455 | - | } | 593 | + | } |
| 456 | - | .icon-button { | 594 | + | .icon-button { |
| 457 | - | display: flex; | 595 | + | display: flex; |
| 458 | - | flex-direction: column; | 596 | + | flex-direction: column; |
| 459 | - | align-items: center; | 597 | + | align-items: center; |
| 460 | - | gap: 4px; | 598 | + | gap: 4px; |
| 461 | - | background: none; | 599 | + | background: none; |
| 462 | - | border: none; | 600 | + | border: none; |
| 463 | - | cursor: pointer; | 601 | + | cursor: pointer; |
| 464 | - | font-size: 1rem; | 602 | + | font-size: 1rem; |
| 465 | - | color: black; | 603 | + | color: black; |
| 466 | - | } | 604 | + | } |
| 467 | - | .error { | 605 | + | .error { |
| 468 | - | color: red; | 606 | + | color: red; |
| 469 | - | font-size: 1rem; | 607 | + | font-size: 1rem; |
| 470 | - | margin-top: 8px; | 608 | + | margin-top: 8px; |
| 471 | - | } | 609 | + | } |
| 472 | - | .success-border { | 610 | + | .success-border { |
| 473 | - | border: 2px solid green; | 611 | + | border: 2px solid green; |
| 474 | - | } | 612 | + | } |
| 475 | - | .error-border { | 613 | + | .error-border { |
| 476 | - | border: 2px solid red; | 614 | + | border: 2px solid red; |
| 477 | - | } | 615 | + | } |
| 478 | - | </style> ⏎̸ | 616 | + | </style> |
M src/lib/components/chat-room.svelte +1116 -1096 large diff (2212 lines changed): collapsed
M src/lib/components/color-picker-256.svelte +450 -446
| @@ -1,453 +1,457 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | 2 | + | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; |
| 3 | - | 3 | + | ||
| 4 | - | // Props | 4 | + | // Props |
| 5 | - | let { | 5 | + | let { |
| 6 | - | selectedColor = $bindable('#000000'), | 6 | + | selectedColor = $bindable('#000000'), |
| 7 | - | selectedGradient = $bindable(undefined), | 7 | + | selectedGradient = $bindable(undefined), |
| 8 | - | onColorChange, | 8 | + | onColorChange, |
| 9 | - | onGradientChange, | 9 | + | onGradientChange, |
| 10 | - | compact = true | 10 | + | compact: _compact = true |
| 11 | - | } = $props<{ | 11 | + | } = $props<{ |
| 12 | - | selectedColor?: string; | 12 | + | selectedColor?: string; |
| 13 | - | selectedGradient?: string[]; | 13 | + | selectedGradient?: string[]; |
| 14 | - | onColorChange?: (color: string) => void; | 14 | + | onColorChange?: (color: string) => void; |
| 15 | - | onGradientChange?: (gradient: string[]) => void; | 15 | + | onGradientChange?: (gradient: string[]) => void; |
| 16 | - | compact?: boolean; | 16 | + | compact?: boolean; |
| 17 | - | }>(); | 17 | + | }>(); |
| 18 | - | 18 | + | void _compact; // Reserved for future use | |
| 19 | - | // State | 19 | + | |
| 20 | - | let showPicker = $state(false); | 20 | + | // State |
| 21 | - | let gradientColors = $state<string[]>([selectedColor || '#000000']); | 21 | + | let showPicker = $state(false); |
| 22 | - | let activeGradientSlot = $state(0); | 22 | + | let gradientColors = $state<string[]>([selectedColor || '#000000']); |
| 23 | - | 23 | + | let activeGradientSlot = $state(0); | |
| 24 | - | // Generate color palette | 24 | + | |
| 25 | - | const colors256 = generate256Colors(); | 25 | + | // Generate color palette |
| 26 | - | const classicColors = Object.values(CLASSIC_COLORS); | 26 | + | const colors256 = generate256Colors(); |
| 27 | - | 27 | + | const classicColors = Object.values(CLASSIC_COLORS); | |
| 28 | - | // Initialize gradient colors from props | 28 | + | |
| 29 | - | $effect(() => { | 29 | + | // Initialize gradient colors from props |
| 30 | - | if (selectedGradient && selectedGradient.length > 0) { | 30 | + | $effect(() => { |
| 31 | - | gradientColors = [...selectedGradient]; | 31 | + | if (selectedGradient && selectedGradient.length > 0) { |
| 32 | - | } else if (selectedColor) { | 32 | + | gradientColors = [...selectedGradient]; |
| 33 | - | gradientColors = [selectedColor]; | 33 | + | } else if (selectedColor) { |
| 34 | - | } | 34 | + | gradientColors = [selectedColor]; |
| 35 | - | }); | 35 | + | } |
| 36 | - | 36 | + | }); | |
| 37 | - | // Get current display color/gradient | 37 | + | |
| 38 | - | let displayStyle = $derived(() => { | 38 | + | // Get current display color/gradient |
| 39 | - | if (gradientColors.length > 1) { | 39 | + | let displayStyle = $derived(() => { |
| 40 | - | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | 40 | + | if (gradientColors.length > 1) { |
| 41 | - | } | 41 | + | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; |
| 42 | - | return `background: ${gradientColors[0] || '#000000'}`; | 42 | + | } |
| 43 | - | }); | 43 | + | return `background: ${gradientColors[0] || '#000000'}`; |
| 44 | - | 44 | + | }); | |
| 45 | - | // Handle color selection | 45 | + | |
| 46 | - | function selectColor(color: string) { | 46 | + | // Handle color selection |
| 47 | - | if (activeGradientSlot < gradientColors.length) { | 47 | + | function selectColor(color: string) { |
| 48 | - | gradientColors[activeGradientSlot] = color; | 48 | + | if (activeGradientSlot < gradientColors.length) { |
| 49 | - | gradientColors = [...gradientColors]; // Trigger reactivity | 49 | + | gradientColors[activeGradientSlot] = color; |
| 50 | - | 50 | + | gradientColors = [...gradientColors]; // Trigger reactivity | |
| 51 | - | if (gradientColors.length === 1) { | 51 | + | |
| 52 | - | // Single color mode | 52 | + | if (gradientColors.length === 1) { |
| 53 | - | selectedColor = color; | 53 | + | // Single color mode |
| 54 | - | selectedGradient = undefined; | 54 | + | selectedColor = color; |
| 55 | - | onColorChange?.(color); | 55 | + | selectedGradient = undefined; |
| 56 | - | } else { | 56 | + | onColorChange?.(color); |
| 57 | - | // Gradient mode | 57 | + | } else { |
| 58 | - | selectedGradient = [...gradientColors]; | 58 | + | // Gradient mode |
| 59 | - | selectedColor = gradientColors[0]; | 59 | + | selectedGradient = [...gradientColors]; |
| 60 | - | onGradientChange?.(gradientColors); | 60 | + | selectedColor = gradientColors[0]; |
| 61 | - | } | 61 | + | onGradientChange?.(gradientColors); |
| 62 | - | } | 62 | + | } |
| 63 | - | } | 63 | + | } |
| 64 | - | 64 | + | } | |
| 65 | - | // Gradient management | 65 | + | |
| 66 | - | function addGradientColor() { | 66 | + | // Gradient management |
| 67 | - | if (gradientColors.length < 3) { | 67 | + | function addGradientColor() { |
| 68 | - | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | 68 | + | if (gradientColors.length < 3) { |
| 69 | - | gradientColors = [...gradientColors, newColor]; | 69 | + | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; |
| 70 | - | activeGradientSlot = gradientColors.length - 1; | 70 | + | gradientColors = [...gradientColors, newColor]; |
| 71 | - | selectedGradient = [...gradientColors]; | 71 | + | activeGradientSlot = gradientColors.length - 1; |
| 72 | - | onGradientChange?.(gradientColors); | 72 | + | selectedGradient = [...gradientColors]; |
| 73 | - | } | 73 | + | onGradientChange?.(gradientColors); |
| 74 | - | } | 74 | + | } |
| 75 | - | 75 | + | } | |
| 76 | - | function removeGradientColor(index: number) { | 76 | + | |
| 77 | - | if (gradientColors.length > 1) { | 77 | + | function removeGradientColor(index: number) { |
| 78 | - | gradientColors = gradientColors.filter((_, i) => i !== index); | 78 | + | if (gradientColors.length > 1) { |
| 79 | - | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | 79 | + | gradientColors = gradientColors.filter((_, i) => i !== index); |
| 80 | - | 80 | + | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |
| 81 | - | if (gradientColors.length === 1) { | 81 | + | |
| 82 | - | // Back to single color mode | 82 | + | if (gradientColors.length === 1) { |
| 83 | - | selectedColor = gradientColors[0]; | 83 | + | // Back to single color mode |
| 84 | - | selectedGradient = undefined; | 84 | + | selectedColor = gradientColors[0]; |
| 85 | - | onColorChange?.(gradientColors[0]); | 85 | + | selectedGradient = undefined; |
| 86 | - | } else { | 86 | + | onColorChange?.(gradientColors[0]); |
| 87 | - | selectedGradient = [...gradientColors]; | 87 | + | } else { |
| 88 | - | onGradientChange?.(gradientColors); | 88 | + | selectedGradient = [...gradientColors]; |
| 89 | - | } | 89 | + | onGradientChange?.(gradientColors); |
| 90 | - | } | 90 | + | } |
| 91 | - | } | 91 | + | } |
| 92 | - | 92 | + | } | |
| 93 | - | function setActiveSlot(index: number) { | 93 | + | |
| 94 | - | activeGradientSlot = index; | 94 | + | function setActiveSlot(index: number) { |
| 95 | - | } | 95 | + | activeGradientSlot = index; |
| 96 | - | 96 | + | } | |
| 97 | - | // Close picker | 97 | + | |
| 98 | - | function closePicker() { | 98 | + | // Close picker |
| 99 | - | showPicker = false; | 99 | + | function closePicker() { |
| 100 | - | } | 100 | + | showPicker = false; |
| 101 | - | 101 | + | } | |
| 102 | - | // Handle click outside to close | 102 | + | |
| 103 | - | function handleClickOutside(event: MouseEvent) { | 103 | + | // Handle click outside to close |
| 104 | - | const target = event.target as HTMLElement; | 104 | + | function handleClickOutside(event: MouseEvent) { |
| 105 | - | const colorPickerContainer = document.querySelector('.color-picker-container'); | 105 | + | const target = event.target as HTMLElement; |
| 106 | - | 106 | + | const colorPickerContainer = document.querySelector('.color-picker-container'); | |
| 107 | - | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | 107 | + | |
| 108 | - | closePicker(); | 108 | + | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { |
| 109 | - | } | 109 | + | closePicker(); |
| 110 | - | } | 110 | + | } |
| 111 | - | 111 | + | } | |
| 112 | - | // Add/remove click outside listener | 112 | + | |
| 113 | - | $effect(() => { | 113 | + | // Add/remove click outside listener |
| 114 | - | if (showPicker) { | 114 | + | $effect(() => { |
| 115 | - | document.addEventListener('click', handleClickOutside); | 115 | + | if (showPicker) { |
| 116 | - | return () => { | 116 | + | document.addEventListener('click', handleClickOutside); |
| 117 | - | document.removeEventListener('click', handleClickOutside); | 117 | + | return () => { |
| 118 | - | }; | 118 | + | document.removeEventListener('click', handleClickOutside); |
| 119 | - | } | 119 | + | }; |
| 120 | - | }); | 120 | + | } |
| 121 | + | }); | |||
| 121 | </script> | 122 | </script> | ||
| 122 | 123 | ||||
| 123 | <div class="color-picker-container"> | 124 | <div class="color-picker-container"> | ||
| 124 | - | <!-- Color picker button --> | 125 | + | <!-- Color picker button --> |
| 125 | - | <button | 126 | + | <button |
| 126 | - | onclick={(e) => { | 127 | + | onclick={(e) => { |
| 127 | - | e.stopPropagation(); | 128 | + | e.stopPropagation(); |
| 128 | - | showPicker = !showPicker; | 129 | + | showPicker = !showPicker; |
| 129 | - | }} | 130 | + | }} |
| 130 | - | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | 131 | + | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} |
| 131 | - | style="width: 20px; height: 20px; padding: 0;" | 132 | + | style="width: 20px; height: 20px; padding: 0;" |
| 132 | - | > | 133 | + | > |
| 133 | - | <div | 134 | + | <div |
| 134 | - | class="color-indicator" | 135 | + | class="color-indicator" |
| 135 | - | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | 136 | + | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" |
| 136 | - | ></div> | 137 | + | ></div> |
| 137 | - | </button> | 138 | + | </button> |
| 138 | - | 139 | + | ||
| 139 | - | <!-- Color picker dropdown --> | 140 | + | <!-- Color picker dropdown --> |
| 140 | - | {#if showPicker} | 141 | + | {#if showPicker} |
| 141 | - | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | 142 | + | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> |
| 142 | - | <!-- Gradient controls --> | 143 | + | <!-- Gradient controls --> |
| 143 | - | <div class="gradient-controls"> | 144 | + | <div class="gradient-controls"> |
| 144 | - | <div class="gradient-slots"> | 145 | + | <div class="gradient-slots"> |
| 145 | - | {#each gradientColors as color, index} | 146 | + | {#each gradientColors as color, index (index)} |
| 146 | - | <button | 147 | + | <button |
| 147 | - | class="gradient-slot" | 148 | + | class="gradient-slot" |
| 148 | - | class:active={activeGradientSlot === index} | 149 | + | class:active={activeGradientSlot === index} |
| 149 | - | style="background-color: {color} !important;" | 150 | + | style="background-color: {color} !important;" |
| 150 | - | onclick={() => setActiveSlot(index)} | 151 | + | onclick={() => setActiveSlot(index)} |
| 151 | - | title="Color {index + 1} - Click to select this color slot" | 152 | + | title="Color {index + 1} - Click to select this color slot" |
| 152 | - | > | 153 | + | > |
| 153 | - | {#if gradientColors.length > 1} | 154 | + | {#if gradientColors.length > 1} |
| 154 | - | <span | 155 | + | <span |
| 155 | - | class="remove-slot" | 156 | + | class="remove-slot" |
| 156 | - | onclick={(e) => { | 157 | + | onclick={(e) => { |
| 157 | - | e.stopPropagation(); | 158 | + | e.stopPropagation(); |
| 158 | - | removeGradientColor(index); | 159 | + | removeGradientColor(index); |
| 159 | - | }} | 160 | + | }} |
| 160 | - | title="Remove this color" | 161 | + | title="Remove this color" |
| 161 | - | role="button" | 162 | + | role="button" |
| 162 | - | tabindex="0" | 163 | + | tabindex="0">×</span |
| 163 | - | >×</span> | 164 | + | > |
| 164 | - | {/if} | 165 | + | {/if} |
| 165 | - | </button> | 166 | + | </button> |
| 166 | - | {/each} | 167 | + | {/each} |
| 167 | - | 168 | + | ||
| 168 | - | {#if gradientColors.length < 3} | 169 | + | {#if gradientColors.length < 3} |
| 169 | - | <button | 170 | + | <button |
| 170 | - | class="add-slot" | 171 | + | class="add-slot" |
| 171 | - | onclick={addGradientColor} | 172 | + | onclick={addGradientColor} |
| 172 | - | title="Add another color to create gradient" | 173 | + | title="Add another color to create gradient">+</button |
| 173 | - | >+</button> | 174 | + | > |
| 174 | - | {/if} | 175 | + | {/if} |
| 175 | - | </div> | 176 | + | </div> |
| 176 | - | 177 | + | ||
| 177 | - | {#if gradientColors.length > 1} | 178 | + | {#if gradientColors.length > 1} |
| 178 | - | <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div> | 179 | + | <div |
| 179 | - | {/if} | 180 | + | class="gradient-preview" |
| 180 | - | </div> | 181 | + | style="background: linear-gradient(90deg, {gradientColors.join(', ')});" |
| 181 | - | 182 | + | ></div> | |
| 182 | - | <!-- Classic colors (first row) --> | 183 | + | {/if} |
| 183 | - | <div class="color-section"> | 184 | + | </div> |
| 184 | - | <div class="section-title">Classic Colors</div> | 185 | + | |
| 185 | - | <div class="color-row"> | 186 | + | <!-- Classic colors (first row) --> |
| 186 | - | {#each classicColors as color} | 187 | + | <div class="color-section"> |
| 187 | - | <button | 188 | + | <div class="section-title">Classic Colors</div> |
| 188 | - | class="color-swatch classic-swatch" | 189 | + | <div class="color-row"> |
| 189 | - | style="--swatch-color: {color}; background-color: {color} !important;" | 190 | + | {#each classicColors as color (color)} |
| 190 | - | onclick={() => selectColor(color)} | 191 | + | <button |
| 191 | - | title={color} | 192 | + | class="color-swatch classic-swatch" |
| 192 | - | ></button> | 193 | + | style="--swatch-color: {color}; background-color: {color} !important;" |
| 193 | - | {/each} | 194 | + | onclick={() => selectColor(color)} |
| 194 | - | </div> | 195 | + | title={color} |
| 195 | - | </div> | 196 | + | ></button> |
| 196 | - | 197 | + | {/each} | |
| 197 | - | <!-- 256-color palette --> | 198 | + | </div> |
| 198 | - | <div class="color-section"> | 199 | + | </div> |
| 199 | - | <div class="section-title">Full Palette</div> | 200 | + | |
| 200 | - | <div class="color-grid"> | 201 | + | <!-- 256-color palette --> |
| 201 | - | {#each colors256 as color} | 202 | + | <div class="color-section"> |
| 202 | - | <button | 203 | + | <div class="section-title">Full Palette</div> |
| 203 | - | class="color-swatch" | 204 | + | <div class="color-grid"> |
| 204 | - | style="--swatch-color: {color}; background-color: {color} !important;" | 205 | + | {#each colors256 as color (color)} |
| 205 | - | onclick={() => selectColor(color)} | 206 | + | <button |
| 206 | - | title={color} | 207 | + | class="color-swatch" |
| 207 | - | ></button> | 208 | + | style="--swatch-color: {color}; background-color: {color} !important;" |
| 208 | - | {/each} | 209 | + | onclick={() => selectColor(color)} |
| 209 | - | </div> | 210 | + | title={color} |
| 210 | - | </div> | 211 | + | ></button> |
| 211 | - | 212 | + | {/each} | |
| 212 | - | <!-- Close button --> | 213 | + | </div> |
| 213 | - | <div class="picker-footer"> | 214 | + | </div> |
| 214 | - | <button onclick={closePicker}>Close</button> | 215 | + | |
| 215 | - | </div> | 216 | + | <!-- Close button --> |
| 216 | - | </div> | 217 | + | <div class="picker-footer"> |
| 217 | - | {/if} | 218 | + | <button onclick={closePicker}>Close</button> |
| 219 | + | </div> | |||
| 220 | + | </div> | |||
| 221 | + | {/if} | |||
| 218 | </div> | 222 | </div> | ||
| 219 | 223 | ||||
| 220 | <style> | 224 | <style> | ||
| 221 | - | .color-picker-container { | 225 | + | .color-picker-container { |
| 222 | - | position: relative; | 226 | + | position: relative; |
| 223 | - | display: inline-block; | 227 | + | display: inline-block; |
| 224 | - | } | 228 | + | } |
| 225 | - | 229 | + | ||
| 226 | - | .picker-dropdown { | 230 | + | .picker-dropdown { |
| 227 | - | position: absolute; | 231 | + | position: absolute; |
| 228 | - | top: 100%; | 232 | + | top: 100%; |
| 229 | - | right: 0; /* Changed from left: 0 to prevent cutoff */ | 233 | + | right: 0; /* Changed from left: 0 to prevent cutoff */ |
| 230 | - | z-index: 1000; | 234 | + | z-index: 1000; |
| 231 | - | padding: 4px; | 235 | + | padding: 4px; |
| 232 | - | margin-top: 2px; | 236 | + | margin-top: 2px; |
| 233 | - | background: #ece9d8; /* Authentic XP dialog background */ | 237 | + | background: #ece9d8; /* Authentic XP dialog background */ |
| 234 | - | width: 420px; | 238 | + | width: 420px; |
| 235 | - | max-height: 380px; | 239 | + | max-height: 380px; |
| 236 | - | overflow-y: auto; | 240 | + | overflow-y: auto; |
| 237 | - | border: 2px outset #c0c0c0; | 241 | + | border: 2px outset #c0c0c0; |
| 238 | - | box-shadow: 2px 2px 4px rgba(0,0,0,0.3); | 242 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); |
| 239 | - | box-sizing: border-box; | 243 | + | box-sizing: border-box; |
| 240 | - | } | 244 | + | } |
| 241 | - | 245 | + | ||
| 242 | - | /* Responsive positioning for mobile/small screens */ | 246 | + | /* Responsive positioning for mobile/small screens */ |
| 243 | - | @media (max-width: 768px) { | 247 | + | @media (max-width: 768px) { |
| 244 | - | .picker-dropdown { | 248 | + | .picker-dropdown { |
| 245 | - | position: fixed; | 249 | + | position: fixed; |
| 246 | - | top: 50%; | 250 | + | top: 50%; |
| 247 | - | left: 50%; | 251 | + | left: 50%; |
| 248 | - | transform: translate(-50%, -50%); | 252 | + | transform: translate(-50%, -50%); |
| 249 | - | right: auto; | 253 | + | right: auto; |
| 250 | - | width: 90vw; | 254 | + | width: 90vw; |
| 251 | - | max-width: 420px; | 255 | + | max-width: 420px; |
| 252 | - | max-height: 85vh; | 256 | + | max-height: 85vh; |
| 253 | - | } | 257 | + | } |
| 254 | - | } | 258 | + | } |
| 255 | - | 259 | + | ||
| 256 | - | .gradient-controls { | 260 | + | .gradient-controls { |
| 257 | - | margin-bottom: 6px; | 261 | + | margin-bottom: 6px; |
| 258 | - | padding: 4px; | 262 | + | padding: 4px; |
| 259 | - | border: 1px inset #dfdfdf; | 263 | + | border: 1px inset #dfdfdf; |
| 260 | - | background: #ece9d8; | 264 | + | background: #ece9d8; |
| 261 | - | } | 265 | + | } |
| 262 | - | 266 | + | ||
| 263 | - | .gradient-slots { | 267 | + | .gradient-slots { |
| 264 | - | display: flex; | 268 | + | display: flex; |
| 265 | - | gap: 4px; | 269 | + | gap: 4px; |
| 266 | - | margin-bottom: 4px; | 270 | + | margin-bottom: 4px; |
| 267 | - | align-items: center; | 271 | + | align-items: center; |
| 268 | - | flex-wrap: wrap; | 272 | + | flex-wrap: wrap; |
| 269 | - | } | 273 | + | } |
| 270 | - | 274 | + | ||
| 271 | - | .gradient-slot { | 275 | + | .gradient-slot { |
| 272 | - | width: 24px; | 276 | + | width: 24px; |
| 273 | - | height: 24px; | 277 | + | height: 24px; |
| 274 | - | border: 2px outset #c0c0c0 !important; | 278 | + | border: 2px outset #c0c0c0 !important; |
| 275 | - | cursor: pointer; | 279 | + | cursor: pointer; |
| 276 | - | position: relative; | 280 | + | position: relative; |
| 277 | - | padding: 0 !important; | 281 | + | padding: 0 !important; |
| 278 | - | margin: 0 !important; | 282 | + | margin: 0 !important; |
| 279 | - | background-image: none !important; | 283 | + | background-image: none !important; |
| 280 | - | min-width: 24px !important; | 284 | + | min-width: 24px !important; |
| 281 | - | min-height: 24px !important; | 285 | + | min-height: 24px !important; |
| 282 | - | max-width: 24px !important; | 286 | + | max-width: 24px !important; |
| 283 | - | max-height: 24px !important; | 287 | + | max-height: 24px !important; |
| 284 | - | box-sizing: border-box !important; | 288 | + | box-sizing: border-box !important; |
| 285 | - | flex-shrink: 0; | 289 | + | flex-shrink: 0; |
| 286 | - | transition: all 0.2s ease; | 290 | + | transition: all 0.2s ease; |
| 287 | - | } | 291 | + | } |
| 288 | - | 292 | + | ||
| 289 | - | .gradient-slot.active { | 293 | + | .gradient-slot.active { |
| 290 | - | border: 2px inset #c0c0c0 !important; | 294 | + | border: 2px inset #c0c0c0 !important; |
| 291 | - | box-shadow: inset 0 0 0 1px #000 !important; | 295 | + | box-shadow: inset 0 0 0 1px #000 !important; |
| 292 | - | } | 296 | + | } |
| 293 | - | 297 | + | ||
| 294 | - | .gradient-slot:hover { | 298 | + | .gradient-slot:hover { |
| 295 | - | transform: scale(1.1); | 299 | + | transform: scale(1.1); |
| 296 | - | } | 300 | + | } |
| 297 | - | 301 | + | ||
| 298 | - | .gradient-slot .remove-slot { | 302 | + | .gradient-slot .remove-slot { |
| 299 | - | position: absolute; | 303 | + | position: absolute; |
| 300 | - | top: -4px; | 304 | + | top: -4px; |
| 301 | - | right: -4px; | 305 | + | right: -4px; |
| 302 | - | width: 12px !important; | 306 | + | width: 12px !important; |
| 303 | - | height: 12px !important; | 307 | + | height: 12px !important; |
| 304 | - | min-width: 12px !important; | 308 | + | min-width: 12px !important; |
| 305 | - | min-height: 12px !important; | 309 | + | min-height: 12px !important; |
| 306 | - | max-width: 12px !important; | 310 | + | max-width: 12px !important; |
| 307 | - | max-height: 12px !important; | 311 | + | max-height: 12px !important; |
| 308 | - | background-color: #808080 !important; | 312 | + | background-color: #808080 !important; |
| 309 | - | color: white !important; | 313 | + | color: white !important; |
| 310 | - | border: 1px solid #000 !important; | 314 | + | border: 1px solid #000 !important; |
| 311 | - | font-size: 9px !important; | 315 | + | font-size: 9px !important; |
| 312 | - | line-height: 1 !important; | 316 | + | line-height: 1 !important; |
| 313 | - | cursor: pointer !important; | 317 | + | cursor: pointer !important; |
| 314 | - | display: inline-flex !important; | 318 | + | display: inline-flex !important; |
| 315 | - | align-items: center !important; | 319 | + | align-items: center !important; |
| 316 | - | justify-content: center !important; | 320 | + | justify-content: center !important; |
| 317 | - | border-radius: 0 !important; | 321 | + | border-radius: 0 !important; |
| 318 | - | padding: 0 !important; | 322 | + | padding: 0 !important; |
| 319 | - | margin: 0 !important; | 323 | + | margin: 0 !important; |
| 320 | - | font-weight: normal !important; | 324 | + | font-weight: normal !important; |
| 321 | - | font-family: 'MS Sans Serif', sans-serif !important; | 325 | + | font-family: 'MS Sans Serif', sans-serif !important; |
| 322 | - | z-index: 10; | 326 | + | z-index: 10; |
| 323 | - | opacity: 0; | 327 | + | opacity: 0; |
| 324 | - | transition: opacity 0.15s ease; | 328 | + | transition: opacity 0.15s ease; |
| 325 | - | text-align: center !important; | 329 | + | text-align: center !important; |
| 326 | - | box-sizing: border-box !important; | 330 | + | box-sizing: border-box !important; |
| 327 | - | user-select: none; | 331 | + | user-select: none; |
| 328 | - | } | 332 | + | } |
| 329 | - | 333 | + | ||
| 330 | - | .gradient-slot:hover .remove-slot { | 334 | + | .gradient-slot:hover .remove-slot { |
| 331 | - | opacity: 0.8; | 335 | + | opacity: 0.8; |
| 332 | - | } | 336 | + | } |
| 333 | - | 337 | + | ||
| 334 | - | .gradient-slot .remove-slot:hover { | 338 | + | .gradient-slot .remove-slot:hover { |
| 335 | - | background-color: #000 !important; | 339 | + | background-color: #000 !important; |
| 336 | - | opacity: 1; | 340 | + | opacity: 1; |
| 337 | - | } | 341 | + | } |
| 338 | - | 342 | + | ||
| 339 | - | .add-slot { | 343 | + | .add-slot { |
| 340 | - | width: 24px; | 344 | + | width: 24px; |
| 341 | - | height: 24px; | 345 | + | height: 24px; |
| 342 | - | border: 2px dashed #666 !important; | 346 | + | border: 2px dashed #666 !important; |
| 343 | - | background: #f0f0f0 !important; | 347 | + | background: #f0f0f0 !important; |
| 344 | - | cursor: pointer; | 348 | + | cursor: pointer; |
| 345 | - | font-weight: bold; | 349 | + | font-weight: bold; |
| 346 | - | padding: 0 !important; | 350 | + | padding: 0 !important; |
| 347 | - | margin: 0 !important; | 351 | + | margin: 0 !important; |
| 348 | - | min-width: 24px !important; | 352 | + | min-width: 24px !important; |
| 349 | - | min-height: 24px !important; | 353 | + | min-height: 24px !important; |
| 350 | - | max-width: 24px !important; | 354 | + | max-width: 24px !important; |
| 351 | - | max-height: 24px !important; | 355 | + | max-height: 24px !important; |
| 352 | - | background-image: none !important; | 356 | + | background-image: none !important; |
| 353 | - | box-sizing: border-box !important; | 357 | + | box-sizing: border-box !important; |
| 354 | - | flex-shrink: 0; | 358 | + | flex-shrink: 0; |
| 355 | - | display: flex !important; | 359 | + | display: flex !important; |
| 356 | - | align-items: center !important; | 360 | + | align-items: center !important; |
| 357 | - | justify-content: center !important; | 361 | + | justify-content: center !important; |
| 358 | - | font-size: 14px !important; | 362 | + | font-size: 14px !important; |
| 359 | - | color: #666 !important; | 363 | + | color: #666 !important; |
| 360 | - | transition: all 0.2s ease; | 364 | + | transition: all 0.2s ease; |
| 361 | - | } | 365 | + | } |
| 362 | - | 366 | + | ||
| 363 | - | .add-slot:hover { | 367 | + | .add-slot:hover { |
| 364 | - | background: #e0e0e0 !important; | 368 | + | background: #e0e0e0 !important; |
| 365 | - | border-color: #333 !important; | 369 | + | border-color: #333 !important; |
| 366 | - | transform: scale(1.05); | 370 | + | transform: scale(1.05); |
| 367 | - | } | 371 | + | } |
| 368 | - | 372 | + | ||
| 369 | - | .gradient-preview { | 373 | + | .gradient-preview { |
| 370 | - | height: 12px; | 374 | + | height: 12px; |
| 371 | - | border: 1px inset #dfdfdf; | 375 | + | border: 1px inset #dfdfdf; |
| 372 | - | } | 376 | + | } |
| 373 | - | 377 | + | ||
| 374 | - | .color-section { | 378 | + | .color-section { |
| 375 | - | margin-bottom: 6px; | 379 | + | margin-bottom: 6px; |
| 376 | - | } | 380 | + | } |
| 377 | - | 381 | + | ||
| 378 | - | .section-title { | 382 | + | .section-title { |
| 379 | - | font-size: 10px; | 383 | + | font-size: 10px; |
| 380 | - | font-weight: bold; | 384 | + | font-weight: bold; |
| 381 | - | margin-bottom: 2px; | 385 | + | margin-bottom: 2px; |
| 382 | - | color: #000; | 386 | + | color: #000; |
| 383 | - | } | 387 | + | } |
| 384 | - | 388 | + | ||
| 385 | - | .color-row { | 389 | + | .color-row { |
| 386 | - | display: flex; | 390 | + | display: flex; |
| 387 | - | flex-wrap: wrap; | 391 | + | flex-wrap: wrap; |
| 388 | - | gap: 2px; | 392 | + | gap: 2px; |
| 389 | - | margin-bottom: 4px; | 393 | + | margin-bottom: 4px; |
| 390 | - | padding: 3px; | 394 | + | padding: 3px; |
| 391 | - | background: #ece9d8; | 395 | + | background: #ece9d8; |
| 392 | - | border: 1px inset #dfdfdf; | 396 | + | border: 1px inset #dfdfdf; |
| 393 | - | } | 397 | + | } |
| 394 | - | 398 | + | ||
| 395 | - | .classic-swatch { | 399 | + | .classic-swatch { |
| 396 | - | width: 18px !important; | 400 | + | width: 18px !important; |
| 397 | - | height: 18px !important; | 401 | + | height: 18px !important; |
| 398 | - | min-width: 18px !important; | 402 | + | min-width: 18px !important; |
| 399 | - | min-height: 18px !important; | 403 | + | min-height: 18px !important; |
| 400 | - | } | 404 | + | } |
| 401 | - | 405 | + | ||
| 402 | - | .color-grid { | 406 | + | .color-grid { |
| 403 | - | display: grid; | 407 | + | display: grid; |
| 404 | - | grid-template-columns: repeat(16, 1fr); | 408 | + | grid-template-columns: repeat(16, 1fr); |
| 405 | - | gap: 2px; | 409 | + | gap: 2px; |
| 406 | - | width: 100%; | 410 | + | width: 100%; |
| 407 | - | padding: 3px; | 411 | + | padding: 3px; |
| 408 | - | background: #ece9d8; | 412 | + | background: #ece9d8; |
| 409 | - | border: 1px inset #dfdfdf; | 413 | + | border: 1px inset #dfdfdf; |
| 410 | - | box-sizing: border-box; | 414 | + | box-sizing: border-box; |
| 411 | - | } | 415 | + | } |
| 412 | - | 416 | + | ||
| 413 | - | .color-swatch { | 417 | + | .color-swatch { |
| 414 | - | width: 18px !important; | 418 | + | width: 18px !important; |
| 415 | - | height: 18px !important; | 419 | + | height: 18px !important; |
| 416 | - | min-width: 18px !important; | 420 | + | min-width: 18px !important; |
| 417 | - | min-height: 18px !important; | 421 | + | min-height: 18px !important; |
| 418 | - | border: 1px solid #808080 !important; /* Override xp.css */ | 422 | + | border: 1px solid #808080 !important; /* Override xp.css */ |
| 419 | - | cursor: pointer; | 423 | + | cursor: pointer; |
| 420 | - | padding: 0 !important; | 424 | + | padding: 0 !important; |
| 421 | - | margin: 0 !important; | 425 | + | margin: 0 !important; |
| 422 | - | box-sizing: border-box !important; | 426 | + | box-sizing: border-box !important; |
| 423 | - | outline: none !important; | 427 | + | outline: none !important; |
| 424 | - | /* Remove any xp.css button styling */ | 428 | + | /* Remove any xp.css button styling */ |
| 425 | - | background-image: none !important; | 429 | + | background-image: none !important; |
| 426 | - | background-color: var(--swatch-color, #000000) !important; | 430 | + | background-color: var(--swatch-color, #000000) !important; |
| 427 | - | font-size: 0 !important; /* Hide any text */ | 431 | + | font-size: 0 !important; /* Hide any text */ |
| 428 | - | line-height: 0 !important; | 432 | + | line-height: 0 !important; |
| 429 | - | } | 433 | + | } |
| 430 | - | 434 | + | ||
| 431 | - | .color-swatch:hover { | 435 | + | .color-swatch:hover { |
| 432 | - | border: 2px solid #000000 !important; | 436 | + | border: 2px solid #000000 !important; |
| 433 | - | transform: scale(1.1) !important; | 437 | + | transform: scale(1.1) !important; |
| 434 | - | z-index: 5; | 438 | + | z-index: 5; |
| 435 | - | position: relative; | 439 | + | position: relative; |
| 436 | - | } | 440 | + | } |
| 437 | - | 441 | + | ||
| 438 | - | .color-swatch:active { | 442 | + | .color-swatch:active { |
| 439 | - | border: 2px solid #ffffff !important; | 443 | + | border: 2px solid #ffffff !important; |
| 440 | - | transform: scale(0.95) !important; | 444 | + | transform: scale(0.95) !important; |
| 441 | - | } | 445 | + | } |
| 442 | - | 446 | + | ||
| 443 | - | .picker-footer { | 447 | + | .picker-footer { |
| 444 | - | text-align: center; | 448 | + | text-align: center; |
| 445 | - | padding-top: 4px; | 449 | + | padding-top: 4px; |
| 446 | - | border-top: 1px solid #808080; | 450 | + | border-top: 1px solid #808080; |
| 447 | - | margin-top: 4px; | 451 | + | margin-top: 4px; |
| 448 | - | } | 452 | + | } |
| 449 | - | 453 | + | ||
| 450 | - | .color-indicator { | 454 | + | .color-indicator { |
| 451 | - | border-radius: 1px; | 455 | + | border-radius: 1px; |
| 452 | - | } | 456 | + | } |
| 453 | - | </style>⏎̸ | 457 | + | </style> |
M src/lib/components/desktop-icons.svelte +80 -79
| @@ -1,94 +1,95 @@ | |||||
| 1 | <script lang="ts"> | 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) { | 4 | + | function handleKeydown(event: KeyboardEvent, action: () => void) { |
| 5 | - | if (event.key === 'Enter' || event.key === ' ') { | 5 | + | if (event.key === 'Enter' || event.key === ' ') { |
| 6 | - | event.preventDefault(); | 6 | + | event.preventDefault(); |
| 7 | - | action(); | 7 | + | action(); |
| 8 | - | } | 8 | + | } |
| 9 | - | } | 9 | + | } |
| 10 | 10 | ||||
| 11 | - | function handleChatRoomClick() { | 11 | + | function handleChatRoomClick() { |
| 12 | - | showChatRoom = true | 12 | + | showChatRoom = true; |
| 13 | - | } | 13 | + | } |
| 14 | 14 | ||||
| 15 | - | function handleAuthClick() { | 15 | + | function handleAuthClick() { |
| 16 | - | showAuth = true; | 16 | + | showAuth = true; |
| 17 | - | } | 17 | + | } |
| 18 | </script> | 18 | </script> | ||
| 19 | 19 | ||||
| 20 | <div class="desktop-icons"> | 20 | <div class="desktop-icons"> | ||
| 21 | - | <div | 21 | + | <div |
| 22 | - | class="desktop-icon" | 22 | + | class="desktop-icon" |
| 23 | - | ondblclick={handleChatRoomClick} | 23 | + | ondblclick={handleChatRoomClick} |
| 24 | - | onkeydown={(e) => handleKeydown(e, handleChatRoomClick)} | 24 | + | onkeydown={(e) => handleKeydown(e, handleChatRoomClick)} |
| 25 | - | role="button" | 25 | + | role="button" |
| 26 | - | tabindex="0" | 26 | + | tabindex="0" |
| 27 | - | > | 27 | + | > |
| 28 | - | <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" /> | 28 | + | <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" /> |
| 29 | - | <span class="icon-label">Pdr Aim</span> | 29 | + | <span class="icon-label">Pdr Aim</span> |
| 30 | - | </div> | 30 | + | </div> |
| 31 | - | 31 | + | ||
| 32 | - | <div | 32 | + | <div |
| 33 | - | class="desktop-icon" | 33 | + | class="desktop-icon" |
| 34 | - | ondblclick={handleAuthClick} | 34 | + | ondblclick={handleAuthClick} |
| 35 | - | onkeydown={(e) => handleKeydown(e, handleAuthClick)} | 35 | + | onkeydown={(e) => handleKeydown(e, handleAuthClick)} |
| 36 | - | role="button" | 36 | + | role="button" |
| 37 | - | tabindex="0" | 37 | + | tabindex="0" |
| 38 | - | > | 38 | + | > |
| 39 | - | <img src="/desktop/application-x-shellscript.png" alt="Connexion" /> | 39 | + | <img src="/desktop/application-x-shellscript.png" alt="Connexion" /> |
| 40 | - | <span class="icon-label">Connexion</span> | 40 | + | <span class="icon-label">Connexion</span> |
| 41 | - | </div> | 41 | + | </div> |
| 42 | </div> | 42 | </div> | ||
| 43 | 43 | ||||
| 44 | <style> | 44 | <style> | ||
| 45 | - | .desktop-icons { | 45 | + | .desktop-icons { |
| 46 | - | position: fixed; | 46 | + | position: fixed; |
| 47 | - | top: 20px; | 47 | + | top: 20px; |
| 48 | - | left: 20px; | 48 | + | left: 20px; |
| 49 | - | display: flex; | 49 | + | display: flex; |
| 50 | - | flex-direction: column; | 50 | + | flex-direction: column; |
| 51 | - | gap: 20px; | 51 | + | gap: 20px; |
| 52 | - | } | 52 | + | } |
| 53 | 53 | ||||
| 54 | - | .desktop-icon { | 54 | + | .desktop-icon { |
| 55 | - | display: flex; | 55 | + | display: flex; |
| 56 | - | flex-direction: column; | 56 | + | flex-direction: column; |
| 57 | - | align-items: center; | 57 | + | align-items: center; |
| 58 | - | cursor: pointer; | 58 | + | cursor: pointer; |
| 59 | - | width: 75px; | 59 | + | width: 75px; |
| 60 | - | text-align: center; | 60 | + | text-align: center; |
| 61 | - | padding: 8px; | 61 | + | padding: 8px; |
| 62 | - | border: 1px dotted transparent; | 62 | + | border: 1px dotted transparent; |
| 63 | - | border-radius: 2px; | 63 | + | border-radius: 2px; |
| 64 | - | margin: 2px; | 64 | + | margin: 2px; |
| 65 | - | } | 65 | + | } |
| 66 | 66 | ||||
| 67 | - | .desktop-icon:hover, .desktop-icon:focus { | 67 | + | .desktop-icon:hover, |
| 68 | - | background: rgba(49, 106, 197, 0.2); | 68 | + | .desktop-icon:focus { |
| 69 | - | border: 1px dotted rgba(255, 255, 255, 0.8); | 69 | + | background: rgba(49, 106, 197, 0.2); |
| 70 | - | outline: 1px solid rgba(49, 106, 197, 0.6); | 70 | + | border: 1px dotted rgba(255, 255, 255, 0.8); |
| 71 | - | outline-offset: -1px; | 71 | + | outline: 1px solid rgba(49, 106, 197, 0.6); |
| 72 | - | } | 72 | + | outline-offset: -1px; |
| 73 | + | } | |||
| 73 | 74 | ||||
| 74 | - | .desktop-icon:active { | 75 | + | .desktop-icon:active { |
| 75 | - | background: rgba(49, 106, 197, 0.4); | 76 | + | background: rgba(49, 106, 197, 0.4); |
| 76 | - | } | 77 | + | } |
| 77 | 78 | ||||
| 78 | - | .desktop-icon img { | 79 | + | .desktop-icon img { |
| 79 | - | width: 48px; | 80 | + | width: 48px; |
| 80 | - | height: 48px; | 81 | + | height: 48px; |
| 81 | - | margin-bottom: 5px; | 82 | + | margin-bottom: 5px; |
| 82 | - | pointer-events: none; /* Prevents image from interfering with hover */ | 83 | + | pointer-events: none; /* Prevents image from interfering with hover */ |
| 83 | - | } | 84 | + | } |
| 84 | 85 | ||||
| 85 | - | .icon-label { | 86 | + | .icon-label { |
| 86 | - | color: white; | 87 | + | color: white; |
| 87 | - | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); | 88 | + | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); |
| 88 | - | font-size: 12px; | 89 | + | font-size: 12px; |
| 89 | - | user-select: none; | 90 | + | user-select: none; |
| 90 | - | pointer-events: none; /* Prevents text from interfering with hover */ | 91 | + | pointer-events: none; /* Prevents text from interfering with hover */ |
| 91 | - | max-width: 100%; | 92 | + | max-width: 100%; |
| 92 | - | overflow-wrap: break-word; | 93 | + | overflow-wrap: break-word; |
| 93 | - | } | 94 | + | } |
| 94 | - | </style> ⏎̸ | 95 | + | </style> |
M src/lib/components/formatted-message.svelte +252 -240
| @@ -1,249 +1,261 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { onMount } from 'svelte'; | 2 | + | import type { EnrichedMessage } from '../types/chat'; |
| 3 | - | import type { EnrichedMessage } from '../types/chat'; | 3 | + | import type { TextStyle } from '../types/text-formatting'; |
| 4 | - | import type { TextStyle } from '../types/text-formatting'; | 4 | + | import { formatText, createGradientText } from '../utils/text-formatter'; |
| 5 | - | import { formatText, createGradientText } from '../utils/text-formatter'; | 5 | + | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; |
| 6 | - | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | 6 | + | |
| 7 | - | 7 | + | // Props | |
| 8 | - | // Props | 8 | + | let { message, allowFormatting = true } = $props<{ |
| 9 | - | let { | 9 | + | message: EnrichedMessage; |
| 10 | - | message, | 10 | + | allowFormatting?: boolean; |
| 11 | - | allowFormatting = true | 11 | + | }>(); |
| 12 | - | } = $props<{ | 12 | + | |
| 13 | - | message: EnrichedMessage; | 13 | + | // State |
| 14 | - | allowFormatting?: boolean; | 14 | + | let formattedContent = $state(''); |
| 15 | - | }>(); | 15 | + | let isProcessing = $state(false); |
| 16 | - | 16 | + | let hasError = $state(false); | |
| 17 | - | // State | 17 | + | |
| 18 | - | let formattedContent = $state(''); | 18 | + | // Process message formatting |
| 19 | - | let isProcessing = $state(false); | 19 | + | async function processMessage() { |
| 20 | - | let hasError = $state(false); | 20 | + | if (!allowFormatting || !message.hasFormatting) { |
| 21 | - | 21 | + | formattedContent = escapeHtml(message.content); | |
| 22 | - | // Process message formatting | 22 | + | return; |
| 23 | - | async function processMessage() { | 23 | + | } |
| 24 | - | if (!allowFormatting || !message.hasFormatting) { | 24 | + | |
| 25 | - | formattedContent = escapeHtml(message.content); | 25 | + | isProcessing = true; |
| 26 | - | return; | 26 | + | hasError = false; |
| 27 | - | } | 27 | + | |
| 28 | - | 28 | + | try { | |
| 29 | - | isProcessing = true; | 29 | + | // Parse style data if available |
| 30 | - | hasError = false; | 30 | + | let style: TextStyle = DEFAULT_TEXT_STYLE; |
| 31 | - | 31 | + | if (message.styleData) { | |
| 32 | - | try { | 32 | + | try { |
| 33 | - | // Parse style data if available | 33 | + | style = JSON.parse(message.styleData); |
| 34 | - | let style: TextStyle = DEFAULT_TEXT_STYLE; | 34 | + | console.debug('Parsed message style:', style, 'for message:', message.content); |
| 35 | - | if (message.styleData) { | 35 | + | } catch (parseError) { |
| 36 | - | try { | 36 | + | console.warn('Failed to parse message style data:', parseError); |
| 37 | - | style = JSON.parse(message.styleData); | 37 | + | } |
| 38 | - | console.debug('Parsed message style:', style, 'for message:', message.content); | 38 | + | } |
| 39 | - | } catch (parseError) { | 39 | + | |
| 40 | - | console.warn('Failed to parse message style data:', parseError); | 40 | + | // Check if this is a gradient message |
| 41 | - | } | 41 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { |
| 42 | - | } | 42 | + | // Use character-by-character gradient for better compatibility |
| 43 | - | 43 | + | console.debug( | |
| 44 | - | // Check if this is a gradient message | 44 | + | 'Creating gradient text with colors:', |
| 45 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | 45 | + | style.gradient, |
| 46 | - | // Use character-by-character gradient for better compatibility | 46 | + | 'for text:', |
| 47 | - | console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content); | 47 | + | message.content |
| 48 | - | formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style); | 48 | + | ); |
| 49 | - | console.debug('Generated gradient HTML:', formattedContent); | 49 | + | formattedContent = createGradientText( |
| 50 | - | return; | 50 | + | message.content, |
| 51 | - | } | 51 | + | style.gradient, |
| 52 | - | 52 | + | 'gradient-text-static', | |
| 53 | - | // Regular formatted text (no gradient) | 53 | + | style |
| 54 | - | const styleWithoutGradient = { ...style, gradient: undefined }; | 54 | + | ); |
| 55 | - | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | 55 | + | console.debug('Generated gradient HTML:', formattedContent); |
| 56 | - | 56 | + | return; | |
| 57 | - | } catch (error) { | 57 | + | } |
| 58 | - | console.error('Message formatting error:', error); | 58 | + | |
| 59 | - | hasError = true; | 59 | + | // Regular formatted text (no gradient) |
| 60 | - | formattedContent = escapeHtml(message.content); | 60 | + | const styleWithoutGradient = { ...style, gradient: undefined }; |
| 61 | - | } finally { | 61 | + | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); |
| 62 | - | isProcessing = false; | 62 | + | } catch (error) { |
| 63 | - | } | 63 | + | console.error('Message formatting error:', error); |
| 64 | - | } | 64 | + | hasError = true; |
| 65 | - | 65 | + | formattedContent = escapeHtml(message.content); | |
| 66 | - | // Process message when it changes | 66 | + | } finally { |
| 67 | - | $effect(() => { | 67 | + | isProcessing = false; |
| 68 | - | processMessage(); | 68 | + | } |
| 69 | - | }); | 69 | + | } |
| 70 | - | 70 | + | ||
| 71 | - | // Utility function to escape HTML | 71 | + | // Process message when it changes |
| 72 | - | function escapeHtml(text: string): string { | 72 | + | $effect(() => { |
| 73 | - | const div = document.createElement('div'); | 73 | + | processMessage(); |
| 74 | - | div.textContent = text; | 74 | + | }); |
| 75 | - | return div.innerHTML; | 75 | + | |
| 76 | - | } | 76 | + | // Utility function to escape HTML |
| 77 | - | 77 | + | function escapeHtml(text: string): string { | |
| 78 | - | // Generate inline styles for the message | 78 | + | const div = document.createElement('div'); |
| 79 | - | function getMessageStyles(): string { | 79 | + | div.textContent = text; |
| 80 | - | if (!message.hasFormatting || !message.styleData) { | 80 | + | return div.innerHTML; |
| 81 | - | return ''; | 81 | + | } |
| 82 | - | } | 82 | + | |
| 83 | - | 83 | + | // Generate inline styles for the message | |
| 84 | - | try { | 84 | + | function getMessageStyles(): string { |
| 85 | - | const style: TextStyle = JSON.parse(message.styleData); | 85 | + | if (!message.hasFormatting || !message.styleData) { |
| 86 | - | // For gradient messages, we still need some styles but not font-family (use class instead) | 86 | + | return ''; |
| 87 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | 87 | + | } |
| 88 | - | // Return only non-color and non-font-family styles for gradient messages | 88 | + | |
| 89 | - | const styles: string[] = []; | 89 | + | try { |
| 90 | - | styles.push(`font-size: ${style.fontSize}px`); | 90 | + | const style: TextStyle = JSON.parse(message.styleData); |
| 91 | - | if (style.bold) styles.push(`font-weight: bold`); | 91 | + | // For gradient messages, we still need some styles but not font-family (use class instead) |
| 92 | - | if (style.italic) styles.push(`font-style: italic`); | 92 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { |
| 93 | - | const decorations: string[] = []; | 93 | + | // Return only non-color and non-font-family styles for gradient messages |
| 94 | - | if (style.underline) decorations.push('underline'); | 94 | + | const styles: string[] = []; |
| 95 | - | if (style.strikethrough) decorations.push('line-through'); | 95 | + | styles.push(`font-size: ${style.fontSize}px`); |
| 96 | - | if (decorations.length > 0) { | 96 | + | if (style.bold) styles.push(`font-weight: bold`); |
| 97 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | 97 | + | if (style.italic) styles.push(`font-style: italic`); |
| 98 | - | } | 98 | + | const decorations: string[] = []; |
| 99 | - | return styles.join('; '); | 99 | + | if (style.underline) decorations.push('underline'); |
| 100 | - | } | 100 | + | if (style.strikethrough) decorations.push('line-through'); |
| 101 | - | // For non-gradient messages, exclude font-family (use class instead) | 101 | + | if (decorations.length > 0) { |
| 102 | - | const styles: string[] = []; | 102 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); |
| 103 | - | styles.push(`font-size: ${style.fontSize}px`); | 103 | + | } |
| 104 | - | if (style.color) styles.push(`color: ${style.color}`); | 104 | + | return styles.join('; '); |
| 105 | - | if (style.bold) styles.push(`font-weight: bold`); | 105 | + | } |
| 106 | - | if (style.italic) styles.push(`font-style: italic`); | 106 | + | // For non-gradient messages, exclude font-family (use class instead) |
| 107 | - | const decorations: string[] = []; | 107 | + | const styles: string[] = []; |
| 108 | - | if (style.underline) decorations.push('underline'); | 108 | + | styles.push(`font-size: ${style.fontSize}px`); |
| 109 | - | if (style.strikethrough) decorations.push('line-through'); | 109 | + | if (style.color) styles.push(`color: ${style.color}`); |
| 110 | - | if (decorations.length > 0) { | 110 | + | if (style.bold) styles.push(`font-weight: bold`); |
| 111 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | 111 | + | if (style.italic) styles.push(`font-style: italic`); |
| 112 | - | } | 112 | + | const decorations: string[] = []; |
| 113 | - | return styles.join('; '); | 113 | + | if (style.underline) decorations.push('underline'); |
| 114 | - | } catch (error) { | 114 | + | if (style.strikethrough) decorations.push('line-through'); |
| 115 | - | console.warn('Failed to generate message styles:', error); | 115 | + | if (decorations.length > 0) { |
| 116 | - | return ''; | 116 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); |
| 117 | - | } | 117 | + | } |
| 118 | - | } | 118 | + | return styles.join('; '); |
| 119 | - | 119 | + | } catch (error) { | |
| 120 | - | // Get CSS classes for the message | 120 | + | console.warn('Failed to generate message styles:', error); |
| 121 | - | function getMessageClasses(): string { | 121 | + | return ''; |
| 122 | - | const classes = ['formatted-message']; | 122 | + | } |
| 123 | - | 123 | + | } | |
| 124 | - | if (!message.hasFormatting || !message.styleData) { | 124 | + | |
| 125 | - | return classes.join(' '); | 125 | + | // Get CSS classes for the message |
| 126 | - | } | 126 | + | function getMessageClasses(): string { |
| 127 | - | 127 | + | const classes = ['formatted-message']; | |
| 128 | - | try { | 128 | + | |
| 129 | - | const style: TextStyle = JSON.parse(message.styleData); | 129 | + | if (!message.hasFormatting || !message.styleData) { |
| 130 | - | 130 | + | return classes.join(' '); | |
| 131 | - | // Add font family class | 131 | + | } |
| 132 | - | classes.push(`retro-font-${style.fontFamily}`); | 132 | + | |
| 133 | - | 133 | + | try { | |
| 134 | - | // Add style classes | 134 | + | const style: TextStyle = JSON.parse(message.styleData); |
| 135 | - | if (style.bold) classes.push('style-bold'); | 135 | + | |
| 136 | - | if (style.italic) classes.push('style-italic'); | 136 | + | // Add font family class |
| 137 | - | if (style.underline) classes.push('style-underline'); | 137 | + | classes.push(`retro-font-${style.fontFamily}`); |
| 138 | - | if (style.strikethrough) classes.push('style-strikethrough'); | 138 | + | |
| 139 | - | 139 | + | // Add style classes | |
| 140 | - | // Add gradient class if applicable | 140 | + | if (style.bold) classes.push('style-bold'); |
| 141 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | 141 | + | if (style.italic) classes.push('style-italic'); |
| 142 | - | classes.push('gradient-text-static'); | 142 | + | if (style.underline) classes.push('style-underline'); |
| 143 | - | } | 143 | + | if (style.strikethrough) classes.push('style-strikethrough'); |
| 144 | - | 144 | + | ||
| 145 | - | } catch (error) { | 145 | + | // Add gradient class if applicable |
| 146 | - | console.warn('Failed to parse message style for classes:', error); | 146 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { |
| 147 | - | } | 147 | + | classes.push('gradient-text-static'); |
| 148 | - | 148 | + | } | |
| 149 | - | return classes.join(' '); | 149 | + | } catch (error) { |
| 150 | - | } | 150 | + | console.warn('Failed to parse message style for classes:', error); |
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | return classes.join(' '); | |||
| 154 | + | } | |||
| 151 | </script> | 155 | </script> | ||
| 152 | 156 | ||||
| 153 | {#if isProcessing} | 157 | {#if isProcessing} | ||
| 154 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | 158 | + | <span class={getMessageClasses()} style={getMessageStyles()}> |
| 155 | - | <span class="processing">...</span> | 159 | + | <span class="processing">...</span> |
| 156 | - | </span> | 160 | + | </span> |
| 157 | {:else if hasError} | 161 | {:else if hasError} | ||
| 158 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | 162 | + | <span class={getMessageClasses()} style={getMessageStyles()}> |
| 159 | - | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | 163 | + | <span class="error" title="Formatting error - showing plain text">{message.content}</span> |
| 160 | - | </span> | 164 | + | </span> |
| 161 | {:else} | 165 | {:else} | ||
| 162 | - | <!-- For gradient messages, render without wrapper to avoid conflicts --> | 166 | + | <!-- For gradient messages, render without wrapper to avoid conflicts --> |
| 163 | - | {#if message.hasFormatting && message.styleData} | 167 | + | {#if message.hasFormatting && message.styleData} |
| 164 | - | {@const parsedStyle = (() => { | 168 | + | {@const parsedStyle = (() => { |
| 165 | - | try { | 169 | + | try { |
| 166 | - | return JSON.parse(message.styleData); | 170 | + | return JSON.parse(message.styleData); |
| 167 | - | } catch { | 171 | + | } catch { |
| 168 | - | return null; | 172 | + | return null; |
| 169 | - | } | 173 | + | } |
| 170 | - | })()} | 174 | + | })()} |
| 171 | - | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} | 175 | + | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} |
| 172 | - | <!-- Gradient messages are already wrapped properly --> | 176 | + | <!-- Gradient messages are already wrapped properly --> |
| 173 | - | {@html formattedContent} | 177 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by createGradientText --> |
| 174 | - | {:else} | 178 | + | {@html formattedContent} |
| 175 | - | <!-- Regular formatted messages --> | 179 | + | {:else} |
| 176 | - | <span class={getMessageClasses()} style={getMessageStyles()}> | 180 | + | <!-- Regular formatted messages --> |
| 177 | - | {@html formattedContent} | 181 | + | <span class={getMessageClasses()} style={getMessageStyles()}> |
| 178 | - | </span> | 182 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by formatText --> |
| 179 | - | {/if} | 183 | + | {@html formattedContent} |
| 180 | - | {:else} | 184 | + | </span> |
| 181 | - | <!-- Plain messages --> | 185 | + | {/if} |
| 182 | - | <span class={getMessageClasses()}> | 186 | + | {:else} |
| 183 | - | {@html formattedContent} | 187 | + | <!-- Plain messages --> |
| 184 | - | </span> | 188 | + | <span class={getMessageClasses()}> |
| 185 | - | {/if} | 189 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by escapeHtml --> |
| 190 | + | {@html formattedContent} | |||
| 191 | + | </span> | |||
| 192 | + | {/if} | |||
| 186 | {/if} | 193 | {/if} | ||
| 187 | 194 | ||||
| 188 | <style> | 195 | <style> | ||
| 189 | - | .processing { | 196 | + | .processing { |
| 190 | - | color: #666; | 197 | + | color: #666; |
| 191 | - | font-style: italic; | 198 | + | font-style: italic; |
| 192 | - | animation: pulse 1s infinite; | 199 | + | animation: pulse 1s infinite; |
| 193 | - | } | 200 | + | } |
| 194 | - | 201 | + | ||
| 195 | - | .error { | 202 | + | .error { |
| 196 | - | color: #cc0000; | 203 | + | color: #cc0000; |
| 197 | - | border-bottom: 1px dotted #cc0000; | 204 | + | border-bottom: 1px dotted #cc0000; |
| 198 | - | } | 205 | + | } |
| 199 | - | 206 | + | ||
| 200 | - | @keyframes pulse { | 207 | + | @keyframes pulse { |
| 201 | - | 0%, 100% { opacity: 1; } | 208 | + | 0%, |
| 202 | - | 50% { opacity: 0.5; } | 209 | + | 100% { |
| 203 | - | } | 210 | + | opacity: 1; |
| 204 | - | 211 | + | } | |
| 205 | - | /* Ensure formatted messages don't break layout */ | 212 | + | 50% { |
| 206 | - | :global(.formatted-message) { | 213 | + | opacity: 0.5; |
| 207 | - | word-break: break-word; | 214 | + | } |
| 208 | - | overflow-wrap: break-word; | 215 | + | } |
| 209 | - | hyphens: auto; | 216 | + | |
| 210 | - | } | 217 | + | /* Ensure formatted messages don't break layout */ |
| 211 | - | 218 | + | :global(.formatted-message) { | |
| 212 | - | /* Improve gradient text readability */ | 219 | + | word-break: break-word; |
| 213 | - | :global(.formatted-message.gradient-text-static) { | 220 | + | overflow-wrap: break-word; |
| 214 | - | font-weight: bold; | 221 | + | hyphens: auto; |
| 215 | - | } | 222 | + | } |
| 216 | - | 223 | + | ||
| 217 | - | /* Ensure gradient spans are visible and preserve their colors */ | 224 | + | /* Improve gradient text readability */ |
| 218 | - | :global(.gradient-text-static .gradient-char) { | 225 | + | :global(.formatted-message.gradient-text-static) { |
| 219 | - | display: inline; | 226 | + | font-weight: bold; |
| 220 | - | opacity: 1 !important; | 227 | + | } |
| 221 | - | margin: 0 !important; | 228 | + | |
| 222 | - | padding: 0 !important; | 229 | + | /* Ensure gradient spans are visible and preserve their colors */ |
| 223 | - | letter-spacing: 0 !important; | 230 | + | :global(.gradient-text-static .gradient-char) { |
| 224 | - | -webkit-text-fill-color: initial !important; | 231 | + | display: inline; |
| 225 | - | } | 232 | + | opacity: 1 !important; |
| 226 | - | 233 | + | margin: 0 !important; | |
| 227 | - | /* Don't override inline styles for gradient chars - removed to let inline styles work */ | 234 | + | padding: 0 !important; |
| 228 | - | 235 | + | letter-spacing: 0 !important; | |
| 229 | - | /* Remove any blur or gray effects */ | 236 | + | -webkit-text-fill-color: initial !important; |
| 230 | - | :global(.formatted-message) { | 237 | + | } |
| 231 | - | opacity: 1 !important; | 238 | + | |
| 232 | - | filter: none !important; | 239 | + | /* Don't override inline styles for gradient chars - removed to let inline styles work */ |
| 233 | - | } | 240 | + | |
| 234 | - | 241 | + | /* Remove any blur or gray effects */ | |
| 235 | - | /* Don't inherit color for gradient text spans */ | 242 | + | :global(.formatted-message) { |
| 236 | - | :global(.formatted-message:not(.gradient-text-static)) { | 243 | + | opacity: 1 !important; |
| 237 | - | color: inherit; | 244 | + | filter: none !important; |
| 238 | - | } | 245 | + | } |
| 239 | - | 246 | + | ||
| 240 | - | /* Ensure accessibility for screen readers */ | 247 | + | /* Don't inherit color for gradient text spans */ |
| 241 | - | :global(.formatted-message[aria-label]) { | 248 | + | :global(.formatted-message:not(.gradient-text-static)) { |
| 242 | - | position: relative; | 249 | + | color: inherit; |
| 243 | - | } | 250 | + | } |
| 244 | - | 251 | + | ||
| 245 | - | /* Performance optimization for complex messages */ | 252 | + | /* Ensure accessibility for screen readers */ |
| 246 | - | :global(.formatted-message) { | 253 | + | :global(.formatted-message[aria-label]) { |
| 247 | - | contain: layout style; | 254 | + | position: relative; |
| 248 | - | } | 255 | + | } |
| 249 | - | </style>⏎̸ | 256 | + | |
| 257 | + | /* Performance optimization for complex messages */ | |||
| 258 | + | :global(.formatted-message) { | |||
| 259 | + | contain: layout style; | |||
| 260 | + | } | |||
| 261 | + | </style> | |||
M src/lib/components/password-strength-indicator.svelte +170 -158
| @@ -1,165 +1,177 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { validatePasswordStrength, type PasswordConstraints, DEFAULT_PASSWORD_CONSTRAINTS } from '../validation/password'; | 2 | + | import { |
| 3 | - | 3 | + | validatePasswordStrength, | |
| 4 | - | interface $$Props { | 4 | + | type PasswordConstraints, |
| 5 | - | password: string; | 5 | + | DEFAULT_PASSWORD_CONSTRAINTS |
| 6 | - | constraints?: PasswordConstraints; | 6 | + | } from '../validation/password'; |
| 7 | - | showDetails?: boolean; | 7 | + | |
| 8 | - | } | 8 | + | let { |
| 9 | - | 9 | + | password = '', | |
| 10 | - | let { | 10 | + | constraints = DEFAULT_PASSWORD_CONSTRAINTS, |
| 11 | - | password = '', | 11 | + | showDetails = true |
| 12 | - | constraints = DEFAULT_PASSWORD_CONSTRAINTS, | 12 | + | } = $props<{ |
| 13 | - | showDetails = true | 13 | + | password: string; |
| 14 | - | } = $props(); | 14 | + | constraints?: PasswordConstraints; |
| 15 | - | 15 | + | showDetails?: boolean; | |
| 16 | - | // Reactive validation result | 16 | + | }>(); |
| 17 | - | let validation = $derived(validatePasswordStrength(password, constraints)); | 17 | + | |
| 18 | - | 18 | + | // Reactive validation result | |
| 19 | - | // Get strength color and label | 19 | + | let validation = $derived(validatePasswordStrength(password, constraints)); |
| 20 | - | function getStrengthInfo(strength: number) { | 20 | + | |
| 21 | - | if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' }; | 21 | + | // Get strength color and label |
| 22 | - | if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' }; | 22 | + | function getStrengthInfo(strength: number) { |
| 23 | - | if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' }; | 23 | + | if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' }; |
| 24 | - | return { color: '#00aa00', label: 'Strong', class: 'strong' }; | 24 | + | if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' }; |
| 25 | - | } | 25 | + | if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' }; |
| 26 | - | 26 | + | return { color: '#00aa00', label: 'Strong', class: 'strong' }; | |
| 27 | - | let strengthInfo = $derived(getStrengthInfo(validation.strength)); | 27 | + | } |
| 28 | + | ||||
| 29 | + | let strengthInfo = $derived(getStrengthInfo(validation.strength)); | |||
| 28 | </script> | 30 | </script> | ||
| 29 | 31 | ||||
| 30 | <div class="password-strength-indicator"> | 32 | <div class="password-strength-indicator"> | ||
| 31 | - | {#if password.length > 0} | 33 | + | {#if password.length > 0} |
| 32 | - | <!-- Strength bar --> | 34 | + | <!-- Strength bar --> |
| 33 | - | <div class="strength-bar"> | 35 | + | <div class="strength-bar"> |
| 34 | - | <div class="strength-bar-bg"> | 36 | + | <div class="strength-bar-bg"> |
| 35 | - | <div | 37 | + | <div |
| 36 | - | class="strength-bar-fill {strengthInfo.class}" | 38 | + | class="strength-bar-fill {strengthInfo.class}" |
| 37 | - | style="width: {validation.strength}%; background-color: {strengthInfo.color};" | 39 | + | style="width: {validation.strength}%; background-color: {strengthInfo.color};" |
| 38 | - | ></div> | 40 | + | ></div> |
| 39 | - | </div> | 41 | + | </div> |
| 40 | - | <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span> | 42 | + | <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span> |
| 41 | - | </div> | 43 | + | </div> |
| 42 | - | 44 | + | ||
| 43 | - | {#if showDetails} | 45 | + | {#if showDetails} |
| 44 | - | <!-- Requirements checklist --> | 46 | + | <!-- Requirements checklist --> |
| 45 | - | <div class="requirements-list"> | 47 | + | <div class="requirements-list"> |
| 46 | - | {#each validation.requirements as requirement} | 48 | + | {#each validation.requirements as requirement (requirement.message)} |
| 47 | - | <div class="requirement {requirement.met ? 'met' : 'unmet'}"> | 49 | + | <div class="requirement {requirement.met ? 'met' : 'unmet'}"> |
| 48 | - | <span class="requirement-icon"> | 50 | + | <span class="requirement-icon"> |
| 49 | - | {requirement.met ? '✓' : '✗'} | 51 | + | {requirement.met ? '✓' : '✗'} |
| 50 | - | </span> | 52 | + | </span> |
| 51 | - | <span class="requirement-text">{requirement.message}</span> | 53 | + | <span class="requirement-text">{requirement.message}</span> |
| 52 | - | </div> | 54 | + | </div> |
| 53 | - | {/each} | 55 | + | {/each} |
| 54 | - | </div> | 56 | + | </div> |
| 55 | - | 57 | + | ||
| 56 | - | <!-- Error messages --> | 58 | + | <!-- Error messages --> |
| 57 | - | {#if validation.errors.length > 0} | 59 | + | {#if validation.errors.length > 0} |
| 58 | - | <div class="error-messages"> | 60 | + | <div class="error-messages"> |
| 59 | - | {#each validation.errors as error} | 61 | + | {#each validation.errors as error (error)} |
| 60 | - | <div class="error-message">{error}</div> | 62 | + | <div class="error-message">{error}</div> |
| 61 | - | {/each} | 63 | + | {/each} |
| 62 | - | </div> | 64 | + | </div> |
| 63 | - | {/if} | 65 | + | {/if} |
| 64 | - | {/if} | 66 | + | {/if} |
| 65 | - | {/if} | 67 | + | {/if} |
| 66 | </div> | 68 | </div> | ||
| 67 | 69 | ||||
| 68 | <style> | 70 | <style> | ||
| 69 | - | .password-strength-indicator { | 71 | + | .password-strength-indicator { |
| 70 | - | margin-top: 8px; | 72 | + | margin-top: 8px; |
| 71 | - | font-size: 0.875rem; | 73 | + | font-size: 0.875rem; |
| 72 | - | } | 74 | + | } |
| 73 | - | 75 | + | ||
| 74 | - | .strength-bar { | 76 | + | .strength-bar { |
| 75 | - | display: flex; | 77 | + | display: flex; |
| 76 | - | align-items: center; | 78 | + | align-items: center; |
| 77 | - | gap: 8px; | 79 | + | gap: 8px; |
| 78 | - | margin-bottom: 8px; | 80 | + | margin-bottom: 8px; |
| 79 | - | } | 81 | + | } |
| 80 | - | 82 | + | ||
| 81 | - | .strength-bar-bg { | 83 | + | .strength-bar-bg { |
| 82 | - | flex: 1; | 84 | + | flex: 1; |
| 83 | - | height: 6px; | 85 | + | height: 6px; |
| 84 | - | background-color: #ddd; | 86 | + | background-color: #ddd; |
| 85 | - | border-radius: 3px; | 87 | + | border-radius: 3px; |
| 86 | - | overflow: hidden; | 88 | + | overflow: hidden; |
| 87 | - | } | 89 | + | } |
| 88 | - | 90 | + | ||
| 89 | - | .strength-bar-fill { | 91 | + | .strength-bar-fill { |
| 90 | - | height: 100%; | 92 | + | height: 100%; |
| 91 | - | transition: width 0.3s ease, background-color 0.3s ease; | 93 | + | transition: |
| 92 | - | border-radius: 3px; | 94 | + | width 0.3s ease, |
| 93 | - | } | 95 | + | background-color 0.3s ease; |
| 94 | - | 96 | + | border-radius: 3px; | |
| 95 | - | .strength-label { | 97 | + | } |
| 96 | - | min-width: 50px; | 98 | + | |
| 97 | - | font-weight: bold; | 99 | + | .strength-label { |
| 98 | - | font-size: 0.75rem; | 100 | + | min-width: 50px; |
| 99 | - | } | 101 | + | font-weight: bold; |
| 100 | - | 102 | + | font-size: 0.75rem; | |
| 101 | - | .strength-label.weak { color: #ff4444; } | 103 | + | } |
| 102 | - | .strength-label.fair { color: #ffaa00; } | 104 | + | |
| 103 | - | .strength-label.good { color: #44aa44; } | 105 | + | .strength-label.weak { |
| 104 | - | .strength-label.strong { color: #00aa00; } | 106 | + | color: #ff4444; |
| 105 | - | 107 | + | } | |
| 106 | - | .requirements-list { | 108 | + | .strength-label.fair { |
| 107 | - | display: flex; | 109 | + | color: #ffaa00; |
| 108 | - | flex-direction: column; | 110 | + | } |
| 109 | - | gap: 4px; | 111 | + | .strength-label.good { |
| 110 | - | margin-bottom: 8px; | 112 | + | color: #44aa44; |
| 111 | - | } | 113 | + | } |
| 112 | - | 114 | + | .strength-label.strong { | |
| 113 | - | .requirement { | 115 | + | color: #00aa00; |
| 114 | - | display: flex; | 116 | + | } |
| 115 | - | align-items: center; | 117 | + | |
| 116 | - | gap: 6px; | 118 | + | .requirements-list { |
| 117 | - | font-size: 0.75rem; | 119 | + | display: flex; |
| 118 | - | } | 120 | + | flex-direction: column; |
| 119 | - | 121 | + | gap: 4px; | |
| 120 | - | .requirement-icon { | 122 | + | margin-bottom: 8px; |
| 121 | - | width: 12px; | 123 | + | } |
| 122 | - | text-align: center; | 124 | + | |
| 123 | - | font-weight: bold; | 125 | + | .requirement { |
| 124 | - | } | 126 | + | display: flex; |
| 125 | - | 127 | + | align-items: center; | |
| 126 | - | .requirement.met { | 128 | + | gap: 6px; |
| 127 | - | color: #00aa00; | 129 | + | font-size: 0.75rem; |
| 128 | - | } | 130 | + | } |
| 129 | - | 131 | + | ||
| 130 | - | .requirement.met .requirement-icon { | 132 | + | .requirement-icon { |
| 131 | - | color: #00aa00; | 133 | + | width: 12px; |
| 132 | - | } | 134 | + | text-align: center; |
| 133 | - | 135 | + | font-weight: bold; | |
| 134 | - | .requirement.unmet { | 136 | + | } |
| 135 | - | color: #888; | 137 | + | |
| 136 | - | } | 138 | + | .requirement.met { |
| 137 | - | 139 | + | color: #00aa00; | |
| 138 | - | .requirement.unmet .requirement-icon { | 140 | + | } |
| 139 | - | color: #ff4444; | 141 | + | |
| 140 | - | } | 142 | + | .requirement.met .requirement-icon { |
| 141 | - | 143 | + | color: #00aa00; | |
| 142 | - | .error-messages { | 144 | + | } |
| 143 | - | display: flex; | 145 | + | |
| 144 | - | flex-direction: column; | 146 | + | .requirement.unmet { |
| 145 | - | gap: 4px; | 147 | + | color: #888; |
| 146 | - | } | 148 | + | } |
| 147 | - | 149 | + | ||
| 148 | - | .error-message { | 150 | + | .requirement.unmet .requirement-icon { |
| 149 | - | color: #ff4444; | 151 | + | color: #ff4444; |
| 150 | - | font-size: 0.75rem; | 152 | + | } |
| 151 | - | } | 153 | + | |
| 152 | - | 154 | + | .error-messages { | |
| 153 | - | /* Compact mode styles */ | 155 | + | display: flex; |
| 154 | - | :global(.compact) .password-strength-indicator { | 156 | + | flex-direction: column; |
| 155 | - | font-size: 0.75rem; | 157 | + | gap: 4px; |
| 156 | - | } | 158 | + | } |
| 157 | - | 159 | + | ||
| 158 | - | :global(.compact) .requirements-list { | 160 | + | .error-message { |
| 159 | - | display: none; | 161 | + | color: #ff4444; |
| 160 | - | } | 162 | + | font-size: 0.75rem; |
| 161 | - | 163 | + | } | |
| 162 | - | :global(.compact) .strength-bar { | 164 | + | |
| 163 | - | margin-bottom: 4px; | 165 | + | /* Compact mode styles */ |
| 164 | - | } | 166 | + | :global(.compact) .password-strength-indicator { |
| 165 | - | </style>⏎̸ | 167 | + | font-size: 0.75rem; |
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | :global(.compact) .requirements-list { | |||
| 171 | + | display: none; | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | :global(.compact) .strength-bar { | |||
| 175 | + | margin-bottom: 4px; | |||
| 176 | + | } | |||
| 177 | + | </style> | |||
M src/lib/components/text-formatting-toolbar.svelte +196 -207
| @@ -1,216 +1,205 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { | 2 | + | import { |
| 3 | - | RETRO_FONTS, | 3 | + | RETRO_FONTS, |
| 4 | - | CLASSIC_COLORS, | 4 | + | DEFAULT_TEXT_STYLE, |
| 5 | - | GRADIENT_PRESETS, | 5 | + | type TextStyle, |
| 6 | - | DEFAULT_TEXT_STYLE, | 6 | + | type FontFamily |
| 7 | - | type TextStyle, | 7 | + | } from '../types/text-formatting'; |
| 8 | - | type FontFamily | 8 | + | import ColorPicker256 from './color-picker-256.svelte'; |
| 9 | - | } from '../types/text-formatting'; | 9 | + | |
| 10 | - | import ColorPicker256 from './color-picker-256.svelte'; | 10 | + | // Props |
| 11 | - | 11 | + | let { | |
| 12 | - | // Props | 12 | + | style = $bindable(DEFAULT_TEXT_STYLE), |
| 13 | - | let { | 13 | + | onPreviewText: _onPreviewText, |
| 14 | - | style = $bindable(DEFAULT_TEXT_STYLE), | 14 | + | compact = true, |
| 15 | - | onPreviewText, | 15 | + | showFontSelector = false, |
| 16 | - | compact = true, | 16 | + | showGradients: _showGradients = true |
| 17 | - | showFontSelector = false, | 17 | + | } = $props<{ |
| 18 | - | showGradients = true | 18 | + | style?: TextStyle; |
| 19 | - | } = $props<{ | 19 | + | onPreviewText?: (text: string) => void; |
| 20 | - | style?: TextStyle; | 20 | + | compact?: boolean; |
| 21 | - | onPreviewText?: (text: string) => void; | 21 | + | showFontSelector?: boolean; |
| 22 | - | compact?: boolean; | 22 | + | showGradients?: boolean; |
| 23 | - | showFontSelector?: boolean; | 23 | + | }>(); |
| 24 | - | showGradients?: boolean; | 24 | + | void _showGradients; // Reserved for future use |
| 25 | - | }>(); | 25 | + | void _onPreviewText; // Reserved for future use |
| 26 | - | 26 | + | ||
| 27 | - | // State | 27 | + | // State |
| 28 | - | let currentColor = $state(style.color || '#000000'); | 28 | + | let currentColor = $state(style.color || '#000000'); |
| 29 | - | let currentGradient = $state(style.gradient); | 29 | + | let currentGradient = $state(style.gradient); |
| 30 | - | 30 | + | ||
| 31 | - | // Toggle functions | 31 | + | // Toggle functions |
| 32 | - | function toggleBold() { | 32 | + | function toggleBold() { |
| 33 | - | style = { ...style, bold: !style.bold }; | 33 | + | style = { ...style, bold: !style.bold }; |
| 34 | - | } | 34 | + | } |
| 35 | - | 35 | + | ||
| 36 | - | function toggleItalic() { | 36 | + | function toggleItalic() { |
| 37 | - | style = { ...style, italic: !style.italic }; | 37 | + | style = { ...style, italic: !style.italic }; |
| 38 | - | } | 38 | + | } |
| 39 | - | 39 | + | ||
| 40 | - | function toggleUnderline() { | 40 | + | function toggleUnderline() { |
| 41 | - | style = { ...style, underline: !style.underline }; | 41 | + | style = { ...style, underline: !style.underline }; |
| 42 | - | } | 42 | + | } |
| 43 | - | 43 | + | ||
| 44 | - | function handleColorChange(color: string) { | 44 | + | function handleColorChange(color: string) { |
| 45 | - | currentColor = color; | 45 | + | currentColor = color; |
| 46 | - | style = { ...style, color, gradient: undefined }; | 46 | + | style = { ...style, color, gradient: undefined }; |
| 47 | - | } | 47 | + | } |
| 48 | - | 48 | + | ||
| 49 | - | function handleGradientChange(gradient: string[]) { | 49 | + | function handleGradientChange(gradient: string[]) { |
| 50 | - | currentGradient = gradient; | 50 | + | currentGradient = gradient; |
| 51 | - | style = { ...style, gradient, color: gradient[0] }; | 51 | + | style = { ...style, gradient, color: gradient[0] }; |
| 52 | - | } | 52 | + | } |
| 53 | - | 53 | + | ||
| 54 | - | function selectFont(fontFamily: FontFamily) { | 54 | + | function selectFont(fontFamily: FontFamily) { |
| 55 | - | style = { ...style, fontFamily }; | 55 | + | style = { ...style, fontFamily }; |
| 56 | - | } | 56 | + | } |
| 57 | - | 57 | + | ||
| 58 | - | // Keyboard shortcuts handler | 58 | + | // Keyboard shortcuts handler |
| 59 | - | function handleKeydown(event: KeyboardEvent) { | 59 | + | function handleKeydown(event: KeyboardEvent) { |
| 60 | - | if (event.ctrlKey || event.metaKey) { | 60 | + | if (event.ctrlKey || event.metaKey) { |
| 61 | - | switch (event.key.toLowerCase()) { | 61 | + | switch (event.key.toLowerCase()) { |
| 62 | - | case 'b': | 62 | + | case 'b': |
| 63 | - | event.preventDefault(); | 63 | + | event.preventDefault(); |
| 64 | - | toggleBold(); | 64 | + | toggleBold(); |
| 65 | - | break; | 65 | + | break; |
| 66 | - | case 'i': | 66 | + | case 'i': |
| 67 | - | event.preventDefault(); | 67 | + | event.preventDefault(); |
| 68 | - | toggleItalic(); | 68 | + | toggleItalic(); |
| 69 | - | break; | 69 | + | break; |
| 70 | - | case 'u': | 70 | + | case 'u': |
| 71 | - | event.preventDefault(); | 71 | + | event.preventDefault(); |
| 72 | - | toggleUnderline(); | 72 | + | toggleUnderline(); |
| 73 | - | break; | 73 | + | break; |
| 74 | - | } | 74 | + | } |
| 75 | - | } | 75 | + | } |
| 76 | - | } | 76 | + | } |
| 77 | - | 77 | + | ||
| 78 | - | // Preview text change | 78 | + | // Update internal state when style prop changes |
| 79 | - | function previewTextChanged() { | 79 | + | $effect(() => { |
| 80 | - | if (onPreviewText) { | 80 | + | currentColor = style.color || '#000000'; |
| 81 | - | onPreviewText('Sample formatted text'); | 81 | + | currentGradient = style.gradient; |
| 82 | - | } | 82 | + | }); |
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | // Update internal state when style prop changes | |||
| 86 | - | $effect(() => { | |||
| 87 | - | currentColor = style.color || '#000000'; | |||
| 88 | - | currentGradient = style.gradient; | |||
| 89 | - | }); | |||
| 90 | </script> | 83 | </script> | ||
| 91 | 84 | ||||
| 92 | <svelte:window onkeydown={handleKeydown} /> | 85 | <svelte:window onkeydown={handleKeydown} /> | ||
| 93 | 86 | ||||
| 94 | <div class="text-formatting-toolbar sunken-panel" class:compact> | 87 | <div class="text-formatting-toolbar sunken-panel" class:compact> | ||
| 95 | - | 88 | + | <!-- Font selector (if enabled) --> | |
| 96 | - | <!-- Font selector (if enabled) --> | 89 | + | {#if showFontSelector} |
| 97 | - | {#if showFontSelector} | 90 | + | <select |
| 98 | - | <select | 91 | + | value={style.fontFamily} |
| 99 | - | value={style.fontFamily} | 92 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} |
| 100 | - | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | 93 | + | title="Font Family" |
| 101 | - | title="Font Family" | 94 | + | > |
| 102 | - | > | 95 | + | {#each Object.entries(RETRO_FONTS) as [key, font] (key)} |
| 103 | - | {#each Object.entries(RETRO_FONTS) as [key, font]} | 96 | + | <option value={key} style="font-family: {font.stack};">{font.name}</option> |
| 104 | - | <option value={key} style="font-family: {font.stack};">{font.name}</option> | 97 | + | {/each} |
| 105 | - | {/each} | 98 | + | </select> |
| 106 | - | </select> | 99 | + | {/if} |
| 107 | - | {/if} | 100 | + | |
| 108 | - | 101 | + | <!-- Size slider --> | |
| 109 | - | <!-- Size slider --> | 102 | + | <div class="size-control"> |
| 110 | - | <div class="size-control"> | 103 | + | <input |
| 111 | - | <input | 104 | + | type="range" |
| 112 | - | type="range" | 105 | + | min="8" |
| 113 | - | min="8" | 106 | + | max="18" |
| 114 | - | max="18" | 107 | + | bind:value={style.fontSize} |
| 115 | - | bind:value={style.fontSize} | 108 | + | class="size-slider" |
| 116 | - | class="size-slider" | 109 | + | title="Font Size: {style.fontSize}px" |
| 117 | - | title="Font Size: {style.fontSize}px" | 110 | + | /> |
| 118 | - | /> | 111 | + | <span class="size-display">{style.fontSize}</span> |
| 119 | - | <span class="size-display">{style.fontSize}</span> | 112 | + | </div> |
| 120 | - | </div> | 113 | + | |
| 121 | - | 114 | + | <!-- Style toggle buttons --> | |
| 122 | - | <!-- Style toggle buttons --> | 115 | + | <div class="style-toggles"> |
| 123 | - | <div class="style-toggles"> | 116 | + | <button |
| 124 | - | <button | 117 | + | onclick={toggleBold} |
| 125 | - | onclick={toggleBold} | 118 | + | title="Bold (Ctrl+B)" |
| 126 | - | title="Bold (Ctrl+B)" | 119 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" |
| 127 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | 120 | + | > |
| 128 | - | > | 121 | + | <strong>B</strong> |
| 129 | - | <strong>B</strong> | 122 | + | </button> |
| 130 | - | </button> | 123 | + | |
| 131 | - | 124 | + | <button | |
| 132 | - | <button | 125 | + | onclick={toggleItalic} |
| 133 | - | onclick={toggleItalic} | 126 | + | title="Italic (Ctrl+I)" |
| 134 | - | title="Italic (Ctrl+I)" | 127 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" |
| 135 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | 128 | + | > |
| 136 | - | > | 129 | + | <em>I</em> |
| 137 | - | <em>I</em> | 130 | + | </button> |
| 138 | - | </button> | 131 | + | |
| 139 | - | 132 | + | <button | |
| 140 | - | <button | 133 | + | onclick={toggleUnderline} |
| 141 | - | onclick={toggleUnderline} | 134 | + | title="Underline (Ctrl+U)" |
| 142 | - | title="Underline (Ctrl+U)" | 135 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" |
| 143 | - | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | 136 | + | > |
| 144 | - | > | 137 | + | <u>U</u> |
| 145 | - | <u>U</u> | 138 | + | </button> |
| 146 | - | </button> | 139 | + | </div> |
| 147 | - | </div> | 140 | + | |
| 148 | - | 141 | + | <!-- Color controls --> | |
| 149 | - | <!-- Color controls --> | 142 | + | <div class="color-controls"> |
| 150 | - | <div class="color-controls"> | 143 | + | <ColorPicker256 |
| 151 | - | <ColorPicker256 | 144 | + | bind:selectedColor={currentColor} |
| 152 | - | bind:selectedColor={currentColor} | 145 | + | bind:selectedGradient={currentGradient} |
| 153 | - | bind:selectedGradient={currentGradient} | 146 | + | onColorChange={handleColorChange} |
| 154 | - | onColorChange={handleColorChange} | 147 | + | onGradientChange={handleGradientChange} |
| 155 | - | onGradientChange={handleGradientChange} | 148 | + | {compact} |
| 156 | - | {compact} | 149 | + | /> |
| 157 | - | /> | 150 | + | </div> |
| 158 | - | </div> | |||
| 159 | - | ||||
| 160 | - | ||||
| 161 | </div> | 151 | </div> | ||
| 162 | 152 | ||||
| 163 | <style> | 153 | <style> | ||
| 164 | - | .text-formatting-toolbar { | 154 | + | .text-formatting-toolbar { |
| 165 | - | display: flex; | 155 | + | display: flex; |
| 166 | - | align-items: center; | 156 | + | align-items: center; |
| 167 | - | gap: 4px; | 157 | + | gap: 4px; |
| 168 | - | padding: 2px; | 158 | + | padding: 2px; |
| 169 | - | font-size: 11px; | 159 | + | font-size: 11px; |
| 170 | - | position: relative; | 160 | + | position: relative; |
| 171 | - | flex-wrap: wrap; | 161 | + | flex-wrap: wrap; |
| 172 | - | } | 162 | + | } |
| 173 | - | 163 | + | ||
| 174 | - | .size-control { | 164 | + | .size-control { |
| 175 | - | display: flex; | 165 | + | display: flex; |
| 176 | - | align-items: center; | 166 | + | align-items: center; |
| 177 | - | gap: 2px; | 167 | + | gap: 2px; |
| 178 | - | } | 168 | + | } |
| 179 | - | 169 | + | ||
| 180 | - | .size-slider { | 170 | + | .size-slider { |
| 181 | - | width: 50px; | 171 | + | width: 50px; |
| 182 | - | height: 12px; | 172 | + | height: 12px; |
| 183 | - | } | 173 | + | } |
| 184 | - | 174 | + | ||
| 185 | - | .size-display { | 175 | + | .size-display { |
| 186 | - | font-size: 9px; | 176 | + | font-size: 9px; |
| 187 | - | color: #666; | 177 | + | color: #666; |
| 188 | - | min-width: 18px; | 178 | + | min-width: 18px; |
| 189 | - | text-align: center; | 179 | + | text-align: center; |
| 190 | - | } | 180 | + | } |
| 191 | - | 181 | + | ||
| 192 | - | .style-toggles { | 182 | + | .style-toggles { |
| 193 | - | display: flex; | 183 | + | display: flex; |
| 194 | - | gap: 1px; | 184 | + | gap: 1px; |
| 195 | - | } | 185 | + | } |
| 196 | - | 186 | + | ||
| 197 | - | .color-controls { | 187 | + | .color-controls { |
| 198 | - | display: flex; | 188 | + | display: flex; |
| 199 | - | gap: 1px; | 189 | + | gap: 1px; |
| 200 | - | } | 190 | + | } |
| 201 | - | 191 | + | ||
| 202 | - | /* Fix font dropdown text alignment */ | 192 | + | /* Fix font dropdown text alignment */ |
| 203 | - | select { | 193 | + | select { |
| 204 | - | height: 20px; | 194 | + | height: 20px; |
| 205 | - | line-height: 1; | 195 | + | line-height: 1; |
| 206 | - | vertical-align: middle; | 196 | + | vertical-align: middle; |
| 207 | - | padding: 1px 4px; | 197 | + | padding: 1px 4px; |
| 208 | - | font-size: 11px; | 198 | + | font-size: 11px; |
| 209 | - | } | 199 | + | } |
| 210 | - | 200 | + | ||
| 211 | - | select option { | 201 | + | select option { |
| 212 | - | padding: 2px 4px; | 202 | + | padding: 2px 4px; |
| 213 | - | line-height: normal; | 203 | + | line-height: normal; |
| 214 | - | } | 204 | + | } |
| 215 | - | 205 | + | </style> | |
| 216 | - | </style>⏎̸ | |||
M src/lib/components/text-style-selector.svelte +312 -316
| @@ -1,323 +1,319 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { | 2 | + | import { |
| 3 | - | RETRO_FONTS, | 3 | + | RETRO_FONTS, |
| 4 | - | CLASSIC_COLORS, | 4 | + | DEFAULT_TEXT_STYLE, |
| 5 | - | DEFAULT_TEXT_STYLE, | 5 | + | type TextStyle, |
| 6 | - | type TextStyle, | 6 | + | type FontFamily, |
| 7 | - | type FontFamily, | 7 | + | generateCSSStyle |
| 8 | - | generateCSSStyle | 8 | + | } from '../types/text-formatting'; |
| 9 | - | } from '../types/text-formatting'; | 9 | + | import ColorPicker256 from './color-picker-256.svelte'; |
| 10 | - | import ColorPicker256 from './color-picker-256.svelte'; | 10 | + | import { formatText, createGradientText } from '../utils/text-formatter'; |
| 11 | - | import { formatText, createGradientText } from '../utils/text-formatter'; | 11 | + | |
| 12 | - | 12 | + | // Props | |
| 13 | - | // Props | 13 | + | let { |
| 14 | - | let { | 14 | + | style = $bindable(DEFAULT_TEXT_STYLE), |
| 15 | - | style = $bindable(DEFAULT_TEXT_STYLE), | 15 | + | previewText = 'Sample text preview', |
| 16 | - | previewText = "Sample text preview", | 16 | + | showPreview = true, |
| 17 | - | showPreview = true, | 17 | + | compact = false |
| 18 | - | compact = false | 18 | + | } = $props<{ |
| 19 | - | } = $props<{ | 19 | + | style?: TextStyle; |
| 20 | - | style?: TextStyle; | 20 | + | previewText?: string; |
| 21 | - | previewText?: string; | 21 | + | showPreview?: boolean; |
| 22 | - | showPreview?: boolean; | 22 | + | compact?: boolean; |
| 23 | - | compact?: boolean; | 23 | + | }>(); |
| 24 | - | }>(); | 24 | + | |
| 25 | - | 25 | + | // Local state | |
| 26 | - | // Local state | 26 | + | let currentColor = $state(style.color || '#000000'); |
| 27 | - | let currentColor = $state(style.color || '#000000'); | 27 | + | let currentGradient = $state(style.gradient); |
| 28 | - | let currentGradient = $state(style.gradient); | 28 | + | let formattedPreview = $state(''); |
| 29 | - | let formattedPreview = $state(''); | 29 | + | let isProcessing = $state(false); |
| 30 | - | let isProcessing = $state(false); | 30 | + | |
| 31 | - | 31 | + | // Update preview when style changes | |
| 32 | - | // Update preview when style changes | 32 | + | $effect(() => { |
| 33 | - | $effect(() => { | 33 | + | if (!showPreview) return; |
| 34 | - | if (!showPreview) return; | 34 | + | |
| 35 | - | 35 | + | async function updatePreview() { | |
| 36 | - | async function updatePreview() { | 36 | + | isProcessing = true; |
| 37 | - | isProcessing = true; | 37 | + | try { |
| 38 | - | try { | 38 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { |
| 39 | - | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | 39 | + | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); |
| 40 | - | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | 40 | + | } else { |
| 41 | - | } else { | 41 | + | formattedPreview = await formatText(previewText, style, false); |
| 42 | - | formattedPreview = await formatText(previewText, style, false); | 42 | + | } |
| 43 | - | } | 43 | + | } catch (error) { |
| 44 | - | } catch (error) { | 44 | + | console.error('Preview formatting error:', error); |
| 45 | - | console.error('Preview formatting error:', error); | 45 | + | formattedPreview = previewText; |
| 46 | - | formattedPreview = previewText; | 46 | + | } finally { |
| 47 | - | } finally { | 47 | + | isProcessing = false; |
| 48 | - | isProcessing = false; | 48 | + | } |
| 49 | - | } | 49 | + | } |
| 50 | - | } | 50 | + | |
| 51 | - | 51 | + | updatePreview(); | |
| 52 | - | updatePreview(); | 52 | + | }); |
| 53 | - | }); | 53 | + | |
| 54 | - | 54 | + | // Font family selection | |
| 55 | - | // Font family selection | 55 | + | function selectFont(fontFamily: FontFamily) { |
| 56 | - | function selectFont(fontFamily: FontFamily) { | 56 | + | style = { ...style, fontFamily }; |
| 57 | - | style = { ...style, fontFamily }; | 57 | + | } |
| 58 | - | } | 58 | + | |
| 59 | - | 59 | + | // Color selection | |
| 60 | - | // Color selection | 60 | + | function handleColorChange(color: string) { |
| 61 | - | function handleColorChange(color: string) { | 61 | + | currentColor = color; |
| 62 | - | currentColor = color; | 62 | + | style = { ...style, color, gradient: undefined }; |
| 63 | - | style = { ...style, color, gradient: undefined }; | 63 | + | } |
| 64 | - | } | 64 | + | |
| 65 | - | 65 | + | // Gradient selection | |
| 66 | - | // Gradient selection | 66 | + | function handleGradientChange(gradient: string[]) { |
| 67 | - | function handleGradientChange(gradient: string[]) { | 67 | + | currentGradient = gradient; |
| 68 | - | currentGradient = gradient; | 68 | + | style = { ...style, gradient, color: gradient[0] }; |
| 69 | - | style = { ...style, gradient, color: gradient[0] }; | 69 | + | } |
| 70 | - | } | 70 | + | |
| 71 | - | 71 | + | // Update internal state when style prop changes | |
| 72 | - | // Update internal state when style prop changes | 72 | + | $effect(() => { |
| 73 | - | $effect(() => { | 73 | + | currentColor = style.color || '#000000'; |
| 74 | - | currentColor = style.color || '#000000'; | 74 | + | currentGradient = style.gradient; |
| 75 | - | currentGradient = style.gradient; | 75 | + | }); |
| 76 | - | }); | 76 | + | |
| 77 | - | 77 | + | // Toggle style options | |
| 78 | - | // Toggle style options | 78 | + | function toggleBold() { |
| 79 | - | function toggleBold() { | 79 | + | style = { ...style, bold: !style.bold }; |
| 80 | - | style = { ...style, bold: !style.bold }; | 80 | + | } |
| 81 | - | } | 81 | + | |
| 82 | - | 82 | + | function toggleItalic() { | |
| 83 | - | function toggleItalic() { | 83 | + | style = { ...style, italic: !style.italic }; |
| 84 | - | style = { ...style, italic: !style.italic }; | 84 | + | } |
| 85 | - | } | 85 | + | |
| 86 | - | 86 | + | function toggleUnderline() { | |
| 87 | - | function toggleUnderline() { | 87 | + | style = { ...style, underline: !style.underline }; |
| 88 | - | style = { ...style, underline: !style.underline }; | 88 | + | } |
| 89 | - | } | 89 | + | |
| 90 | - | 90 | + | function toggleStrikethrough() { | |
| 91 | - | function toggleStrikethrough() { | 91 | + | style = { ...style, strikethrough: !style.strikethrough }; |
| 92 | - | style = { ...style, strikethrough: !style.strikethrough }; | 92 | + | } |
| 93 | - | } | 93 | + | |
| 94 | - | 94 | + | // Font size adjustment | |
| 95 | - | // Font size adjustment | 95 | + | function updateFontSize(size: number) { |
| 96 | - | function updateFontSize(size: number) { | 96 | + | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; |
| 97 | - | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | 97 | + | } |
| 98 | - | } | |||
| 99 | - | ||||
| 100 | </script> | 98 | </script> | ||
| 101 | 99 | ||||
| 102 | <div class="text-style-selector" class:compact> | 100 | <div class="text-style-selector" class:compact> | ||
| 103 | - | <!-- Font Family Selection --> | 101 | + | <!-- Font Family Selection --> |
| 104 | - | <div class="style-group"> | 102 | + | <div class="style-group"> |
| 105 | - | <label>Font:</label> | 103 | + | <label>Font:</label> |
| 106 | - | <select | 104 | + | <select |
| 107 | - | bind:value={style.fontFamily} | 105 | + | bind:value={style.fontFamily} |
| 108 | - | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | 106 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} |
| 109 | - | > | 107 | + | > |
| 110 | - | {#each Object.entries(RETRO_FONTS) as [key, font]} | 108 | + | {#each Object.entries(RETRO_FONTS) as [key, font] (key)} |
| 111 | - | <option value={key} style="font-family: {font.stack}"> | 109 | + | <option value={key} style="font-family: {font.stack}"> |
| 112 | - | {font.name} | 110 | + | {font.name} |
| 113 | - | </option> | 111 | + | </option> |
| 114 | - | {/each} | 112 | + | {/each} |
| 115 | - | </select> | 113 | + | </select> |
| 116 | - | </div> | 114 | + | </div> |
| 117 | - | 115 | + | ||
| 118 | - | <!-- Font Size --> | 116 | + | <!-- Font Size --> |
| 119 | - | <div class="style-group"> | 117 | + | <div class="style-group"> |
| 120 | - | <label>Size:</label> | 118 | + | <label>Size:</label> |
| 121 | - | <input | 119 | + | <input |
| 122 | - | type="range" | 120 | + | type="range" |
| 123 | - | min="8" | 121 | + | min="8" |
| 124 | - | max="18" | 122 | + | max="18" |
| 125 | - | bind:value={style.fontSize} | 123 | + | bind:value={style.fontSize} |
| 126 | - | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} | 124 | + | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} |
| 127 | - | /> | 125 | + | /> |
| 128 | - | <span class="size-display">{style.fontSize}px</span> | 126 | + | <span class="size-display">{style.fontSize}px</span> |
| 129 | - | </div> | 127 | + | </div> |
| 130 | - | 128 | + | ||
| 131 | - | <!-- Style Toggles --> | 129 | + | <!-- Style Toggles --> |
| 132 | - | <div class="style-group toggles"> | 130 | + | <div class="style-group toggles"> |
| 133 | - | <button | 131 | + | <button |
| 134 | - | class="style-toggle" | 132 | + | class="style-toggle" |
| 135 | - | class:active={style.bold} | 133 | + | class:active={style.bold} |
| 136 | - | onclick={toggleBold} | 134 | + | onclick={toggleBold} |
| 137 | - | title="Bold (Ctrl+B)" | 135 | + | title="Bold (Ctrl+B)" |
| 138 | - | > | 136 | + | > |
| 139 | - | <strong>B</strong> | 137 | + | <strong>B</strong> |
| 140 | - | </button> | 138 | + | </button> |
| 141 | - | <button | 139 | + | <button |
| 142 | - | class="style-toggle" | 140 | + | class="style-toggle" |
| 143 | - | class:active={style.italic} | 141 | + | class:active={style.italic} |
| 144 | - | onclick={toggleItalic} | 142 | + | onclick={toggleItalic} |
| 145 | - | title="Italic (Ctrl+I)" | 143 | + | title="Italic (Ctrl+I)" |
| 146 | - | > | 144 | + | > |
| 147 | - | <em>I</em> | 145 | + | <em>I</em> |
| 148 | - | </button> | 146 | + | </button> |
| 149 | - | <button | 147 | + | <button |
| 150 | - | class="style-toggle" | 148 | + | class="style-toggle" |
| 151 | - | class:active={style.underline} | 149 | + | class:active={style.underline} |
| 152 | - | onclick={toggleUnderline} | 150 | + | onclick={toggleUnderline} |
| 153 | - | title="Underline (Ctrl+U)" | 151 | + | title="Underline (Ctrl+U)" |
| 154 | - | > | 152 | + | > |
| 155 | - | <u>U</u> | 153 | + | <u>U</u> |
| 156 | - | </button> | 154 | + | </button> |
| 157 | - | <button | 155 | + | <button |
| 158 | - | class="style-toggle" | 156 | + | class="style-toggle" |
| 159 | - | class:active={style.strikethrough} | 157 | + | class:active={style.strikethrough} |
| 160 | - | onclick={toggleStrikethrough} | 158 | + | onclick={toggleStrikethrough} |
| 161 | - | title="Strikethrough" | 159 | + | title="Strikethrough" |
| 162 | - | > | 160 | + | > |
| 163 | - | <s>S</s> | 161 | + | <s>S</s> |
| 164 | - | </button> | 162 | + | </button> |
| 165 | - | </div> | 163 | + | </div> |
| 166 | - | 164 | + | ||
| 167 | - | <!-- Color & Gradient Selection --> | 165 | + | <!-- Color & Gradient Selection --> |
| 168 | - | <div class="style-group"> | 166 | + | <div class="style-group"> |
| 169 | - | <label>Color:</label> | 167 | + | <label>Color:</label> |
| 170 | - | <div class="color-controls"> | 168 | + | <div class="color-controls"> |
| 171 | - | <ColorPicker256 | 169 | + | <ColorPicker256 |
| 172 | - | bind:selectedColor={currentColor} | 170 | + | bind:selectedColor={currentColor} |
| 173 | - | bind:selectedGradient={currentGradient} | 171 | + | bind:selectedGradient={currentGradient} |
| 174 | - | onColorChange={handleColorChange} | 172 | + | onColorChange={handleColorChange} |
| 175 | - | onGradientChange={handleGradientChange} | 173 | + | onGradientChange={handleGradientChange} |
| 176 | - | compact={false} | 174 | + | compact={false} |
| 177 | - | /> | 175 | + | /> |
| 178 | - | </div> | 176 | + | </div> |
| 179 | - | </div> | 177 | + | </div> |
| 180 | - | 178 | + | ||
| 181 | - | 179 | + | <!-- Preview --> | |
| 182 | - | <!-- Preview --> | 180 | + | {#if showPreview} |
| 183 | - | {#if showPreview} | 181 | + | <div class="preview-section"> |
| 184 | - | <div class="preview-section"> | 182 | + | <label>Preview:</label> |
| 185 | - | <label>Preview:</label> | 183 | + | <div class="preview-text" style={generateCSSStyle(style)}> |
| 186 | - | <div | 184 | + | {#if isProcessing} |
| 187 | - | class="preview-text" | 185 | + | <span class="loading">Processing...</span> |
| 188 | - | style={generateCSSStyle(style)} | 186 | + | {:else} |
| 189 | - | > | 187 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedPreview is sanitized by formatText/createGradientText --> |
| 190 | - | {#if isProcessing} | 188 | + | {@html formattedPreview} |
| 191 | - | <span class="loading">Processing...</span> | 189 | + | {/if} |
| 192 | - | {:else} | 190 | + | </div> |
| 193 | - | {@html formattedPreview} | 191 | + | </div> |
| 194 | - | {/if} | 192 | + | {/if} |
| 195 | - | </div> | |||
| 196 | - | </div> | |||
| 197 | - | {/if} | |||
| 198 | </div> | 193 | </div> | ||
| 199 | 194 | ||||
| 200 | <style> | 195 | <style> | ||
| 201 | - | .text-style-selector { | 196 | + | .text-style-selector { |
| 202 | - | background: #f0f0f0; | 197 | + | background: #f0f0f0; |
| 203 | - | border: 2px inset #dfdfdf; | 198 | + | border: 2px inset #dfdfdf; |
| 204 | - | padding: 8px; | 199 | + | padding: 8px; |
| 205 | - | font-family: 'MS Sans Serif', sans-serif; | 200 | + | font-family: 'MS Sans Serif', sans-serif; |
| 206 | - | font-size: 11px; | 201 | + | font-size: 11px; |
| 207 | - | } | 202 | + | } |
| 208 | - | 203 | + | ||
| 209 | - | .text-style-selector.compact { | 204 | + | .text-style-selector.compact { |
| 210 | - | padding: 4px; | 205 | + | padding: 4px; |
| 211 | - | } | 206 | + | } |
| 212 | - | 207 | + | ||
| 213 | - | .style-group { | 208 | + | .style-group { |
| 214 | - | display: flex; | 209 | + | display: flex; |
| 215 | - | align-items: center; | 210 | + | align-items: center; |
| 216 | - | gap: 8px; | 211 | + | gap: 8px; |
| 217 | - | margin-bottom: 8px; | 212 | + | margin-bottom: 8px; |
| 218 | - | } | 213 | + | } |
| 219 | - | 214 | + | ||
| 220 | - | .style-group label { | 215 | + | .style-group label { |
| 221 | - | font-weight: bold; | 216 | + | font-weight: bold; |
| 222 | - | min-width: 50px; | 217 | + | min-width: 50px; |
| 223 | - | } | 218 | + | } |
| 224 | - | 219 | + | ||
| 225 | - | .style-group select { | 220 | + | .style-group select { |
| 226 | - | flex: 1; | 221 | + | flex: 1; |
| 227 | - | padding: 2px; | 222 | + | padding: 2px; |
| 228 | - | border: 1px inset #dfdfdf; | 223 | + | border: 1px inset #dfdfdf; |
| 229 | - | background: white; | 224 | + | background: white; |
| 230 | - | } | 225 | + | } |
| 231 | - | 226 | + | ||
| 232 | - | .style-group input[type="range"] { | 227 | + | .style-group input[type='range'] { |
| 233 | - | flex: 1; | 228 | + | flex: 1; |
| 234 | - | } | 229 | + | } |
| 235 | - | 230 | + | ||
| 236 | - | .size-display { | 231 | + | .size-display { |
| 237 | - | min-width: 35px; | 232 | + | min-width: 35px; |
| 238 | - | font-size: 10px; | 233 | + | font-size: 10px; |
| 239 | - | color: #666; | 234 | + | color: #666; |
| 240 | - | } | 235 | + | } |
| 241 | - | 236 | + | ||
| 242 | - | .toggles { | 237 | + | .toggles { |
| 243 | - | gap: 4px; | 238 | + | gap: 4px; |
| 244 | - | } | 239 | + | } |
| 245 | - | 240 | + | ||
| 246 | - | .style-toggle { | 241 | + | .style-toggle { |
| 247 | - | width: 24px; | 242 | + | width: 24px; |
| 248 | - | height: 24px; | 243 | + | height: 24px; |
| 249 | - | border: 1px outset #dfdfdf; | 244 | + | border: 1px outset #dfdfdf; |
| 250 | - | background: #f0f0f0; | 245 | + | background: #f0f0f0; |
| 251 | - | cursor: pointer; | 246 | + | cursor: pointer; |
| 252 | - | display: flex; | 247 | + | display: flex; |
| 253 | - | align-items: center; | 248 | + | align-items: center; |
| 254 | - | justify-content: center; | 249 | + | justify-content: center; |
| 255 | - | font-size: 12px; | 250 | + | font-size: 12px; |
| 256 | - | font-weight: bold; | 251 | + | font-weight: bold; |
| 257 | - | } | 252 | + | } |
| 258 | - | 253 | + | ||
| 259 | - | .style-toggle:hover { | 254 | + | .style-toggle:hover { |
| 260 | - | background: #e0e0e0; | 255 | + | background: #e0e0e0; |
| 261 | - | } | 256 | + | } |
| 262 | - | 257 | + | ||
| 263 | - | .style-toggle.active { | 258 | + | .style-toggle.active { |
| 264 | - | border: 1px inset #dfdfdf; | 259 | + | border: 1px inset #dfdfdf; |
| 265 | - | background: #d0d0d0; | 260 | + | background: #d0d0d0; |
| 266 | - | } | 261 | + | } |
| 267 | - | 262 | + | ||
| 268 | - | .color-controls { | 263 | + | .color-controls { |
| 269 | - | display: flex; | 264 | + | display: flex; |
| 270 | - | gap: 4px; | 265 | + | gap: 4px; |
| 271 | - | } | 266 | + | } |
| 272 | - | 267 | + | ||
| 273 | - | .preview-section { | 268 | + | .preview-section { |
| 274 | - | margin-top: 12px; | 269 | + | margin-top: 12px; |
| 275 | - | padding-top: 8px; | 270 | + | padding-top: 8px; |
| 276 | - | border-top: 1px solid #ccc; | 271 | + | border-top: 1px solid #ccc; |
| 277 | - | } | 272 | + | } |
| 278 | - | 273 | + | ||
| 279 | - | .preview-section label { | 274 | + | .preview-section label { |
| 280 | - | display: block; | 275 | + | display: block; |
| 281 | - | margin-bottom: 4px; | 276 | + | margin-bottom: 4px; |
| 282 | - | font-weight: bold; | 277 | + | font-weight: bold; |
| 283 | - | } | 278 | + | } |
| 284 | - | 279 | + | ||
| 285 | - | .preview-text { | 280 | + | .preview-text { |
| 286 | - | padding: 8px; | 281 | + | padding: 8px; |
| 287 | - | border: 1px inset #dfdfdf; | 282 | + | border: 1px inset #dfdfdf; |
| 288 | - | background: white; | 283 | + | background: white; |
| 289 | - | min-height: 24px; | 284 | + | min-height: 24px; |
| 290 | - | line-height: 1.4; | 285 | + | line-height: 1.4; |
| 291 | - | } | 286 | + | } |
| 292 | - | 287 | + | ||
| 293 | - | .loading { | 288 | + | .loading { |
| 294 | - | color: #666; | 289 | + | color: #666; |
| 295 | - | font-style: italic; | 290 | + | font-style: italic; |
| 296 | - | } | 291 | + | } |
| 297 | - | 292 | + | ||
| 298 | - | /* Gradient text support */ | 293 | + | /* Gradient text support */ |
| 299 | - | :global(.preview-gradient) { | 294 | + | :global(.preview-gradient) { |
| 300 | - | display: inline; | 295 | + | display: inline; |
| 301 | - | } | 296 | + | } |
| 302 | - | 297 | + | ||
| 303 | - | /* Compact mode adjustments */ | 298 | + | /* Compact mode adjustments */ |
| 304 | - | .compact .style-group { | 299 | + | .compact .style-group { |
| 305 | - | margin-bottom: 4px; | 300 | + | margin-bottom: 4px; |
| 306 | - | } | 301 | + | } |
| 307 | - | 302 | + | ||
| 308 | - | .compact .style-group label { | 303 | + | .compact .style-group label { |
| 309 | - | min-width: 40px; | 304 | + | min-width: 40px; |
| 310 | - | font-size: 10px; | 305 | + | font-size: 10px; |
| 311 | - | } | 306 | + | } |
| 312 | - | 307 | + | ||
| 313 | - | .compact .style-toggle { | 308 | + | .compact .style-toggle { |
| 314 | - | width: 20px; | 309 | + | width: 20px; |
| 315 | - | height: 20px; | 310 | + | height: 20px; |
| 316 | - | font-size: 10px; | 311 | + | font-size: 10px; |
| 317 | - | } | 312 | + | } |
| 318 | - | 313 | + | ||
| 319 | - | .compact .color-button, .compact .gradient-button { | 314 | + | .compact .color-button, |
| 320 | - | font-size: 9px; | 315 | + | .compact .gradient-button { |
| 321 | - | padding: 1px 4px; | 316 | + | font-size: 9px; |
| 322 | - | } | 317 | + | padding: 1px 4px; |
| 323 | - | </style>⏎̸ | 318 | + | } |
| 319 | + | </style> | |||
M src/lib/components/turnstile.svelte +112 -116
| @@ -1,123 +1,119 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { onMount } from 'svelte'; | 2 | + | import { onMount } from 'svelte'; |
| 3 | - | import { fade } from 'svelte/transition'; | 3 | + | import { fade } from 'svelte/transition'; |
| 4 | - | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | 4 | + | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; |
| 5 | - | import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile'; | 5 | + | import type { TurnstileOptions } from '$lib/types/turnstile'; |
| 6 | - | 6 | + | ||
| 7 | - | let isVerified = $state(false); | 7 | + | let isVerified = $state(false); |
| 8 | - | 8 | + | ||
| 9 | - | type OnVerifyCallback = (token: string) => void; | 9 | + | type OnVerifyCallback = (token: string) => void; |
| 10 | - | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); | 10 | + | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); |
| 11 | - | 11 | + | ||
| 12 | - | let turnstileElement = $state<HTMLDivElement | null>(null); | 12 | + | let turnstileElement = $state<HTMLDivElement | null>(null); |
| 13 | - | let widgetId = ''; | 13 | + | let widgetId = ''; |
| 14 | - | 14 | + | ||
| 15 | - | // Ensure site key is a string and trim any whitespace | 15 | + | // Ensure site key is a string and trim any whitespace |
| 16 | - | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | 16 | + | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); |
| 17 | - | 17 | + | ||
| 18 | - | onMount(() => { | 18 | + | onMount(() => { |
| 19 | - | if (!window.turnstile) { | 19 | + | if (!window.turnstile) { |
| 20 | - | const script = document.createElement('script'); | 20 | + | const script = document.createElement('script'); |
| 21 | - | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; | 21 | + | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; |
| 22 | - | script.async = true; | 22 | + | script.async = true; |
| 23 | - | script.defer = true; | 23 | + | script.defer = true; |
| 24 | - | document.head.appendChild(script); | 24 | + | document.head.appendChild(script); |
| 25 | - | 25 | + | ||
| 26 | - | script.onload = initTurnstile; | 26 | + | script.onload = initTurnstile; |
| 27 | - | } else { | 27 | + | } else { |
| 28 | - | initTurnstile(); | 28 | + | initTurnstile(); |
| 29 | - | } | 29 | + | } |
| 30 | - | 30 | + | ||
| 31 | - | return () => { | 31 | + | return () => { |
| 32 | - | if (widgetId) { | 32 | + | if (widgetId) { |
| 33 | - | window.turnstile?.remove(widgetId); | 33 | + | window.turnstile?.remove(widgetId); |
| 34 | - | } | 34 | + | } |
| 35 | - | }; | 35 | + | }; |
| 36 | - | }); | 36 | + | }); |
| 37 | - | 37 | + | ||
| 38 | - | function initTurnstile() { | 38 | + | function initTurnstile() { |
| 39 | - | if (!turnstileElement || !window.turnstile) return; | 39 | + | if (!turnstileElement || !window.turnstile) return; |
| 40 | - | 40 | + | ||
| 41 | - | // Validate site key is a non-empty string | 41 | + | // Validate site key is a non-empty string |
| 42 | - | if (!siteKey) { | 42 | + | if (!siteKey) { |
| 43 | - | console.error('Turnstile site key is missing'); | 43 | + | console.error('Turnstile site key is missing'); |
| 44 | - | return; | 44 | + | return; |
| 45 | - | } | 45 | + | } |
| 46 | - | 46 | + | ||
| 47 | - | const options: TurnstileOptions = { | 47 | + | const options: TurnstileOptions = { |
| 48 | - | sitekey: siteKey, | 48 | + | sitekey: siteKey, |
| 49 | - | callback: (token: string) => { | 49 | + | callback: (token: string) => { |
| 50 | - | if (typeof onVerify === 'function') { | 50 | + | if (typeof onVerify === 'function') { |
| 51 | - | onVerify(token); | 51 | + | onVerify(token); |
| 52 | - | isVerified = true; | 52 | + | isVerified = true; |
| 53 | - | } else { | 53 | + | } else { |
| 54 | - | console.error('onVerify callback is not a function'); | 54 | + | console.error('onVerify callback is not a function'); |
| 55 | - | } | 55 | + | } |
| 56 | - | }, | 56 | + | }, |
| 57 | - | 'refresh-expired': 'auto', | 57 | + | 'refresh-expired': 'auto', |
| 58 | - | size: 'compact', | 58 | + | size: 'compact', |
| 59 | - | 'error-callback': (error: unknown) => { | 59 | + | 'error-callback': (error: unknown) => { |
| 60 | - | console.error('Turnstile error:', error); | 60 | + | console.error('Turnstile error:', error); |
| 61 | - | isVerified = false; // Reset verification state on error | 61 | + | isVerified = false; // Reset verification state on error |
| 62 | - | } | 62 | + | } |
| 63 | - | }; | 63 | + | }; |
| 64 | - | 64 | + | ||
| 65 | - | try { | 65 | + | try { |
| 66 | - | widgetId = window.turnstile.render(turnstileElement, options); | 66 | + | widgetId = window.turnstile.render(turnstileElement, options); |
| 67 | - | 67 | + | ||
| 68 | - | // Add resize handler | 68 | + | // Add resize handler |
| 69 | - | const handleResize = () => scaleTurnstileToFit(); | 69 | + | const handleResize = () => scaleTurnstileToFit(); |
| 70 | - | window.addEventListener('resize', handleResize); | 70 | + | window.addEventListener('resize', handleResize); |
| 71 | - | // Initial scale | 71 | + | // Initial scale |
| 72 | - | scaleTurnstileToFit(); | 72 | + | scaleTurnstileToFit(); |
| 73 | - | 73 | + | } catch (error) { | |
| 74 | - | } catch (error) { | 74 | + | console.error('Failed to render Turnstile:', error); |
| 75 | - | console.error('Failed to render Turnstile:', error); | 75 | + | } |
| 76 | - | } | 76 | + | } |
| 77 | - | } | 77 | + | |
| 78 | - | 78 | + | function scaleTurnstileToFit() { | |
| 79 | - | function scaleTurnstileToFit() { | 79 | + | const container = turnstileElement; |
| 80 | - | const container = turnstileElement; | 80 | + | if (!container) return; |
| 81 | - | if (!container) return; | 81 | + | |
| 82 | - | 82 | + | const iframe = container.querySelector('iframe'); | |
| 83 | - | const iframe = container.querySelector('iframe'); | 83 | + | if (!iframe) return; |
| 84 | - | if (!iframe) return; | 84 | + | |
| 85 | - | 85 | + | const containerWidth = container.offsetWidth; | |
| 86 | - | const containerWidth = container.offsetWidth; | 86 | + | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width |
| 87 | - | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width | 87 | + | |
| 88 | - | 88 | + | iframe.style.transform = `scale(${scale})`; | |
| 89 | - | iframe.style.transform = `scale(${scale})`; | 89 | + | iframe.style.transformOrigin = 'center center'; |
| 90 | - | iframe.style.transformOrigin = 'center center'; | 90 | + | } |
| 91 | - | } | |||
| 92 | </script> | 91 | </script> | ||
| 93 | 92 | ||||
| 94 | {#if !isVerified} | 93 | {#if !isVerified} | ||
| 95 | - | <div | 94 | + | <div class="turnstile-wrapper" transition:fade={{ duration: 300 }}> |
| 96 | - | class="turnstile-wrapper" | 95 | + | <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div> |
| 97 | - | transition:fade={{ duration: 300 }} | 96 | + | </div> |
| 98 | - | > | |||
| 99 | - | <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div> | |||
| 100 | - | </div> | |||
| 101 | {/if} | 97 | {/if} | ||
| 102 | 98 | ||||
| 103 | <style> | 99 | <style> | ||
| 104 | - | .turnstile-wrapper { | 100 | + | .turnstile-wrapper { |
| 105 | - | width: 100%; | 101 | + | width: 100%; |
| 106 | - | display: flex; | 102 | + | display: flex; |
| 107 | - | justify-content: center; | 103 | + | justify-content: center; |
| 108 | - | align-items: center; | 104 | + | align-items: center; |
| 109 | - | min-height: 65px; /* Approximate height for compact size */ | 105 | + | min-height: 65px; /* Approximate height for compact size */ |
| 110 | - | overflow: hidden; | 106 | + | overflow: hidden; |
| 111 | - | } | 107 | + | } |
| 112 | - | 108 | + | ||
| 113 | - | .turnstile-container { | 109 | + | .turnstile-container { |
| 114 | - | display: flex; | 110 | + | display: flex; |
| 115 | - | justify-content: center; | 111 | + | justify-content: center; |
| 116 | - | align-items: center; | 112 | + | align-items: center; |
| 117 | - | } | 113 | + | } |
| 118 | - | 114 | + | ||
| 119 | - | /* Handle iframe scaling */ | 115 | + | /* Handle iframe scaling */ |
| 120 | - | :global(.turnstile-wrapper iframe) { | 116 | + | :global(.turnstile-wrapper iframe) { |
| 121 | - | transition: transform 0.2s ease-in-out; | 117 | + | transition: transform 0.2s ease-in-out; |
| 122 | - | } | 118 | + | } |
| 123 | - | </style> ⏎̸ | 119 | + | </style> |
M src/lib/components/ui/button-loading.svelte +73 -67
| @@ -1,77 +1,83 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { onDestroy } from 'svelte'; | 2 | + | import { onDestroy } from 'svelte'; |
| 3 | 3 | ||||
| 4 | - | // Props with defaults: | 4 | + | // Props with defaults: |
| 5 | - | // - text: button label when not loading (default "Envoyer") | 5 | + | // - text: button label when not loading (default "Envoyer") |
| 6 | - | // - loading: indicates if the button should show a spinner (default false) | 6 | + | // - loading: indicates if the button should show a spinner (default false) |
| 7 | - | // - spinnerChar: the character to use for the spinner animation (default '.') | 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(); | 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. | 16 | + | // Using $state for internal reactive variables. |
| 11 | - | let animatedDots = $state(''); // will hold the animated spinner text | 17 | + | let animatedDots = $state(''); // will hold the animated spinner text |
| 12 | - | let count = $state(1); | 18 | + | let count = $state(1); |
| 13 | - | let isDecreasing = $state(true); | 19 | + | let isDecreasing = $state(true); |
| 14 | - | let intervalID = $state<number | null>(null); | 20 | + | let intervalID = $state<number | null>(null); |
| 15 | 21 | ||||
| 16 | - | // Effect that starts/stops the spinner based on loading state. | 22 | + | // Effect that starts/stops the spinner based on loading state. |
| 17 | - | $effect(() => { | 23 | + | $effect(() => { |
| 18 | - | if (loading) { | 24 | + | if (loading) { |
| 19 | - | console.debug("LoadingButton: Spinner started"); | 25 | + | console.debug('LoadingButton: Spinner started'); |
| 20 | - | // Immediately update spinner text to show 3 dots and start decreasing | 26 | + | // Immediately update spinner text to show 3 dots and start decreasing |
| 21 | - | animatedDots = spinnerChar.repeat(3); | 27 | + | animatedDots = spinnerChar.repeat(3); |
| 22 | - | count = 3; | 28 | + | count = 3; |
| 23 | - | isDecreasing = true; | 29 | + | 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 | - | }); | |||
| 50 | 30 | ||||
| 51 | - | onDestroy(() => { | 31 | + | if (!intervalID) { |
| 52 | - | if (intervalID) { | 32 | + | intervalID = window.setInterval(() => { |
| 53 | - | console.debug("LoadingButton: onDestroy clearing interval"); | 33 | + | // Ping-pong logic: 3,2,1,2,3... |
| 54 | - | clearInterval(intervalID); | 34 | + | count = count + (isDecreasing ? -1 : 1); |
| 55 | - | } | 35 | + | if (count <= 1) { |
| 56 | - | }); | 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 | </script> | 63 | </script> | ||
| 58 | 64 | ||||
| 59 | - | <button onclick={onclick} disabled={disabled}> | 65 | + | <button {onclick} {disabled}> |
| 60 | - | {#if loading} | 66 | + | {#if loading} |
| 61 | - | <span>{animatedDots}</span> | 67 | + | <span>{animatedDots}</span> |
| 62 | - | {:else} | 68 | + | {:else} |
| 63 | - | <span>{text}</span> | 69 | + | <span>{text}</span> |
| 64 | - | {/if} | 70 | + | {/if} |
| 65 | </button> | 71 | </button> | ||
| 66 | 72 | ||||
| 67 | <style> | 73 | <style> | ||
| 68 | - | button { | 74 | + | button { |
| 69 | - | padding: 0.5rem 1rem; | 75 | + | padding: 0.5rem 1rem; |
| 70 | - | font-size: 1rem; | 76 | + | font-size: 1rem; |
| 71 | - | cursor: pointer; | 77 | + | cursor: pointer; |
| 72 | - | } | 78 | + | } |
| 73 | - | button:disabled { | 79 | + | button:disabled { |
| 74 | - | cursor: not-allowed; | 80 | + | cursor: not-allowed; |
| 75 | - | opacity: 0.7; | 81 | + | opacity: 0.7; |
| 76 | - | } | 82 | + | } |
| 77 | - | </style> ⏎̸ | 83 | + | </style> |
M src/lib/components/ui/loading-dots.svelte +42 -42
| @@ -1,50 +1,50 @@ | |||||
| 1 | <!-- A reusable loading dots component with animated dots --> | 1 | <!-- A reusable loading dots component with animated dots --> | ||
| 2 | <script lang="ts"> | 2 | <script lang="ts"> | ||
| 3 | - | let { text = 'Chargement', color = 'inherit' } = $props(); | 3 | + | let { text = 'Chargement', color = 'inherit' } = $props(); |
| 4 | </script> | 4 | </script> | ||
| 5 | 5 | ||||
| 6 | <div class="loading-text" style="color: {color}"> | 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 | </div> | 8 | </div> | ||
| 9 | 9 | ||||
| 10 | <style> | 10 | <style> | ||
| 11 | - | .loading-text { | 11 | + | .loading-text { |
| 12 | - | display: inline-flex; | 12 | + | display: inline-flex; |
| 13 | - | align-items: center; | 13 | + | align-items: center; |
| 14 | - | font-size: inherit; | 14 | + | font-size: inherit; |
| 15 | - | } | 15 | + | } |
| 16 | - | 16 | + | ||
| 17 | - | .dots { | 17 | + | .dots { |
| 18 | - | display: inline-flex; | 18 | + | display: inline-flex; |
| 19 | - | margin-left: 2px; | 19 | + | margin-left: 2px; |
| 20 | - | } | 20 | + | } |
| 21 | - | 21 | + | ||
| 22 | - | .dots span { | 22 | + | .dots span { |
| 23 | - | animation: dots 1.5s infinite; | 23 | + | animation: dots 1.5s infinite; |
| 24 | - | opacity: 0; | 24 | + | opacity: 0; |
| 25 | - | } | 25 | + | } |
| 26 | - | 26 | + | ||
| 27 | - | .dots span:nth-child(1) { | 27 | + | .dots span:nth-child(1) { |
| 28 | - | animation-delay: 0s; | 28 | + | animation-delay: 0s; |
| 29 | - | } | 29 | + | } |
| 30 | - | 30 | + | ||
| 31 | - | .dots span:nth-child(2) { | 31 | + | .dots span:nth-child(2) { |
| 32 | - | animation-delay: 0.5s; | 32 | + | animation-delay: 0.5s; |
| 33 | - | } | 33 | + | } |
| 34 | - | 34 | + | ||
| 35 | - | .dots span:nth-child(3) { | 35 | + | .dots span:nth-child(3) { |
| 36 | - | animation-delay: 1s; | 36 | + | animation-delay: 1s; |
| 37 | - | } | 37 | + | } |
| 38 | - | 38 | + | ||
| 39 | - | @keyframes dots { | 39 | + | @keyframes dots { |
| 40 | - | 0% { | 40 | + | 0% { |
| 41 | - | opacity: 0; | 41 | + | opacity: 0; |
| 42 | - | } | 42 | + | } |
| 43 | - | 50% { | 43 | + | 50% { |
| 44 | - | opacity: 1; | 44 | + | opacity: 1; |
| 45 | - | } | 45 | + | } |
| 46 | - | 100% { | 46 | + | 100% { |
| 47 | - | opacity: 0; | 47 | + | opacity: 0; |
| 48 | - | } | 48 | + | } |
| 49 | - | } | 49 | + | } |
| 50 | - | </style> ⏎̸ | 50 | + | </style> |
M src/lib/components/ui/tooltip.svelte +191 -190
| @@ -1,197 +1,198 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | type $$Props = { | 2 | + | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- $$Props is used for Svelte type inference |
| 3 | - | data: { | 3 | + | type $$Props = { |
| 4 | - | text: string, | 4 | + | data: { |
| 5 | - | direction?: 'top' | 'bottom' | 'left' | 'right' | 5 | + | text: string; |
| 6 | - | } | 6 | + | direction?: 'top' | 'bottom' | 'left' | 'right'; |
| 7 | - | }; | 7 | + | }; |
| 8 | - | 8 | + | }; | |
| 9 | - | let { children, data } = $props(); | 9 | + | |
| 10 | - | 10 | + | let { children, data } = $props(); | |
| 11 | - | // Derive values from data with defaults | 11 | + | |
| 12 | - | let text = $derived(data.text); | 12 | + | // Derive values from data with defaults |
| 13 | - | let direction = $derived(data.direction ?? 'top'); | 13 | + | let text = $derived(data.text); |
| 14 | - | 14 | + | let direction = $derived(data.direction ?? 'top'); | |
| 15 | - | let wrapper: HTMLButtonElement; | 15 | + | |
| 16 | - | let tooltipDiv = $state<HTMLDivElement | null>(null); | 16 | + | let wrapper: HTMLButtonElement; |
| 17 | - | let coords = { x: 0, y: 0 }; | 17 | + | let tooltipDiv = $state<HTMLDivElement | null>(null); |
| 18 | - | 18 | + | let coords = { x: 0, y: 0 }; | |
| 19 | - | function updateCoords() { | 19 | + | |
| 20 | - | if (wrapper) { | 20 | + | function updateCoords() { |
| 21 | - | const rect = wrapper.getBoundingClientRect(); | 21 | + | if (wrapper) { |
| 22 | - | if (direction === 'top') { | 22 | + | const rect = wrapper.getBoundingClientRect(); |
| 23 | - | coords = { x: rect.left + rect.width / 2, y: rect.top - 8 }; | 23 | + | if (direction === 'top') { |
| 24 | - | } else if (direction === 'bottom') { | 24 | + | coords = { x: rect.left + rect.width / 2, y: rect.top - 8 }; |
| 25 | - | coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 }; | 25 | + | } else if (direction === 'bottom') { |
| 26 | - | } else if (direction === 'left') { | 26 | + | coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 }; |
| 27 | - | coords = { x: rect.left - 8, y: rect.top + rect.height / 2 }; | 27 | + | } else if (direction === 'left') { |
| 28 | - | } else if (direction === 'right') { | 28 | + | coords = { x: rect.left - 8, y: rect.top + rect.height / 2 }; |
| 29 | - | coords = { x: rect.right + 8, y: rect.top + rect.height / 2 }; | 29 | + | } else if (direction === 'right') { |
| 30 | - | } | 30 | + | coords = { x: rect.right + 8, y: rect.top + rect.height / 2 }; |
| 31 | - | } | 31 | + | } |
| 32 | - | } | 32 | + | } |
| 33 | - | 33 | + | } | |
| 34 | - | function showTooltip() { | 34 | + | |
| 35 | - | updateCoords(); | 35 | + | function showTooltip() { |
| 36 | - | if (tooltipDiv) return; // Prevent multiple tooltips | 36 | + | updateCoords(); |
| 37 | - | tooltipDiv = document.createElement('div'); | 37 | + | if (tooltipDiv) return; // Prevent multiple tooltips |
| 38 | - | tooltipDiv.className = `tooltip ${direction}`; | 38 | + | tooltipDiv = document.createElement('div'); |
| 39 | - | tooltipDiv.style.position = 'fixed'; | 39 | + | tooltipDiv.className = `tooltip ${direction}`; |
| 40 | - | tooltipDiv.style.zIndex = '1000000'; | 40 | + | tooltipDiv.style.position = 'fixed'; |
| 41 | - | tooltipDiv.textContent = text; | 41 | + | tooltipDiv.style.zIndex = '1000000'; |
| 42 | - | tooltipDiv.setAttribute('role', 'tooltip'); | 42 | + | tooltipDiv.textContent = text; |
| 43 | - | tooltipDiv.setAttribute('aria-hidden', 'false'); | 43 | + | tooltipDiv.setAttribute('role', 'tooltip'); |
| 44 | - | document.body.appendChild(tooltipDiv); | 44 | + | tooltipDiv.setAttribute('aria-hidden', 'false'); |
| 45 | - | 45 | + | document.body.appendChild(tooltipDiv); | |
| 46 | - | const tooltipRect = tooltipDiv.getBoundingClientRect(); | 46 | + | |
| 47 | - | let finalLeft = coords.x; | 47 | + | const tooltipRect = tooltipDiv.getBoundingClientRect(); |
| 48 | - | let finalTop = coords.y; | 48 | + | let finalLeft = coords.x; |
| 49 | - | if (direction === 'top') { | 49 | + | let finalTop = coords.y; |
| 50 | - | finalLeft = coords.x - tooltipRect.width / 2; | 50 | + | if (direction === 'top') { |
| 51 | - | finalTop = coords.y - tooltipRect.height; | 51 | + | finalLeft = coords.x - tooltipRect.width / 2; |
| 52 | - | } else if (direction === 'bottom') { | 52 | + | finalTop = coords.y - tooltipRect.height; |
| 53 | - | finalLeft = coords.x - tooltipRect.width / 2; | 53 | + | } else if (direction === 'bottom') { |
| 54 | - | finalTop = coords.y; | 54 | + | finalLeft = coords.x - tooltipRect.width / 2; |
| 55 | - | } else if (direction === 'left') { | 55 | + | finalTop = coords.y; |
| 56 | - | finalLeft = coords.x - tooltipRect.width; | 56 | + | } else if (direction === 'left') { |
| 57 | - | finalTop = coords.y - tooltipRect.height / 2; | 57 | + | finalLeft = coords.x - tooltipRect.width; |
| 58 | - | } else if (direction === 'right') { | 58 | + | finalTop = coords.y - tooltipRect.height / 2; |
| 59 | - | finalLeft = coords.x; | 59 | + | } else if (direction === 'right') { |
| 60 | - | finalTop = coords.y - tooltipRect.height / 2; | 60 | + | finalLeft = coords.x; |
| 61 | - | } | 61 | + | finalTop = coords.y - tooltipRect.height / 2; |
| 62 | - | 62 | + | } | |
| 63 | - | if (finalLeft < 0) { | 63 | + | |
| 64 | - | finalLeft = 0; | 64 | + | if (finalLeft < 0) { |
| 65 | - | } else if (finalLeft + tooltipRect.width > window.innerWidth) { | 65 | + | finalLeft = 0; |
| 66 | - | finalLeft = window.innerWidth - tooltipRect.width; | 66 | + | } else if (finalLeft + tooltipRect.width > window.innerWidth) { |
| 67 | - | } | 67 | + | finalLeft = window.innerWidth - tooltipRect.width; |
| 68 | - | if (finalTop < 0) { | 68 | + | } |
| 69 | - | finalTop = 0; | 69 | + | if (finalTop < 0) { |
| 70 | - | } else if (finalTop + tooltipRect.height > window.innerHeight) { | 70 | + | finalTop = 0; |
| 71 | - | finalTop = window.innerHeight - tooltipRect.height; | 71 | + | } else if (finalTop + tooltipRect.height > window.innerHeight) { |
| 72 | - | } | 72 | + | finalTop = window.innerHeight - tooltipRect.height; |
| 73 | - | 73 | + | } | |
| 74 | - | tooltipDiv.style.left = `${finalLeft}px`; | 74 | + | |
| 75 | - | tooltipDiv.style.top = `${finalTop}px`; | 75 | + | tooltipDiv.style.left = `${finalLeft}px`; |
| 76 | - | } | 76 | + | tooltipDiv.style.top = `${finalTop}px`; |
| 77 | - | 77 | + | } | |
| 78 | - | function hideTooltip() { | 78 | + | |
| 79 | - | if (tooltipDiv && tooltipDiv.parentNode) { | 79 | + | function hideTooltip() { |
| 80 | - | tooltipDiv.parentNode.removeChild(tooltipDiv); | 80 | + | if (tooltipDiv && tooltipDiv.parentNode) { |
| 81 | - | tooltipDiv = null; | 81 | + | tooltipDiv.parentNode.removeChild(tooltipDiv); |
| 82 | - | } | 82 | + | tooltipDiv = null; |
| 83 | - | } | 83 | + | } |
| 84 | - | 84 | + | } | |
| 85 | - | function handleKeyDown(e: KeyboardEvent) { | 85 | + | |
| 86 | - | if (e.key === 'Escape') { | 86 | + | function handleKeyDown(e: KeyboardEvent) { |
| 87 | - | hideTooltip(); | 87 | + | if (e.key === 'Escape') { |
| 88 | - | } | 88 | + | hideTooltip(); |
| 89 | - | } | 89 | + | } |
| 90 | + | } | |||
| 90 | </script> | 91 | </script> | ||
| 91 | 92 | ||||
| 92 | - | <button | 93 | + | <button |
| 93 | - | class="tooltip-wrapper" | 94 | + | class="tooltip-wrapper" |
| 94 | - | bind:this={wrapper} | 95 | + | bind:this={wrapper} |
| 95 | - | onmouseover={showTooltip} | 96 | + | onmouseover={showTooltip} |
| 96 | - | onmouseout={hideTooltip} | 97 | + | onmouseout={hideTooltip} |
| 97 | - | onfocus={showTooltip} | 98 | + | onfocus={showTooltip} |
| 98 | - | onblur={hideTooltip} | 99 | + | onblur={hideTooltip} |
| 99 | - | onkeydown={handleKeyDown} | 100 | + | onkeydown={handleKeyDown} |
| 100 | - | type="button" | 101 | + | type="button" |
| 101 | - | aria-describedby={tooltipDiv?.id} | 102 | + | aria-describedby={tooltipDiv?.id} |
| 102 | > | 103 | > | ||
| 103 | - | {@render children()} | 104 | + | {@render children()} |
| 104 | </button> | 105 | </button> | ||
| 105 | 106 | ||||
| 106 | <style> | 107 | <style> | ||
| 107 | - | .tooltip-wrapper { | 108 | + | .tooltip-wrapper { |
| 108 | - | display: inline; | 109 | + | display: inline; |
| 109 | - | width: auto; | 110 | + | width: auto; |
| 110 | - | min-width: 0; | 111 | + | min-width: 0; |
| 111 | - | background: none !important; | 112 | + | background: none !important; |
| 112 | - | border: none !important; | 113 | + | border: none !important; |
| 113 | - | padding: 0 !important; | 114 | + | padding: 0 !important; |
| 114 | - | margin: 0 !important; | 115 | + | margin: 0 !important; |
| 115 | - | font: inherit !important; | 116 | + | font: inherit !important; |
| 116 | - | color: inherit !important; | 117 | + | color: inherit !important; |
| 117 | - | cursor: inherit !important; | 118 | + | cursor: inherit !important; |
| 118 | - | text-align: inherit !important; | 119 | + | text-align: inherit !important; |
| 119 | - | box-shadow: none !important; | 120 | + | box-shadow: none !important; |
| 120 | - | text-shadow: inherit !important; | 121 | + | text-shadow: inherit !important; |
| 121 | - | border-radius: 0 !important; | 122 | + | border-radius: 0 !important; |
| 122 | - | -webkit-appearance: none !important; | 123 | + | -webkit-appearance: none !important; |
| 123 | - | appearance: none !important; | 124 | + | appearance: none !important; |
| 124 | - | white-space: pre; | 125 | + | white-space: pre; |
| 125 | - | } | 126 | + | } |
| 126 | - | .tooltip-wrapper:hover, | 127 | + | .tooltip-wrapper:hover, |
| 127 | - | .tooltip-wrapper:active { | 128 | + | .tooltip-wrapper:active { |
| 128 | - | background: none !important; | 129 | + | background: none !important; |
| 129 | - | border: none !important; | 130 | + | border: none !important; |
| 130 | - | box-shadow: none !important; | 131 | + | box-shadow: none !important; |
| 131 | - | } | 132 | + | } |
| 132 | - | .tooltip-wrapper:focus { | 133 | + | .tooltip-wrapper:focus { |
| 133 | - | outline: none !important; | 134 | + | outline: none !important; |
| 134 | - | box-shadow: none !important; | 135 | + | box-shadow: none !important; |
| 135 | - | } | 136 | + | } |
| 136 | - | .tooltip-wrapper:focus-visible { | 137 | + | .tooltip-wrapper:focus-visible { |
| 137 | - | outline: 2px solid #003C74 !important; | 138 | + | outline: 2px solid #003c74 !important; |
| 138 | - | outline-offset: 2px !important; | 139 | + | outline-offset: 2px !important; |
| 139 | - | } | 140 | + | } |
| 140 | - | /* Use :global for styles that need to apply to elements outside the component */ | 141 | + | /* Use :global for styles that need to apply to elements outside the component */ |
| 141 | - | :global(.tooltip) { | 142 | + | :global(.tooltip) { |
| 142 | - | white-space: nowrap; | 143 | + | white-space: nowrap; |
| 143 | - | padding: 0.5rem; | 144 | + | padding: 0.5rem; |
| 144 | - | background: #ECE9D8; | 145 | + | background: #ece9d8; |
| 145 | - | color: #000; | 146 | + | color: #000; |
| 146 | - | border: 1px solid #003C74; | 147 | + | border: 1px solid #003c74; |
| 147 | - | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); | 148 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); |
| 148 | - | border-radius: 2px; | 149 | + | border-radius: 2px; |
| 149 | - | font-size: 0.875rem; | 150 | + | font-size: 0.875rem; |
| 150 | - | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; | 151 | + | font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif; |
| 151 | - | } | 152 | + | } |
| 152 | - | 153 | + | ||
| 153 | - | /* Direction-specific styles for the tooltip arrow */ | 154 | + | /* Direction-specific styles for the tooltip arrow */ |
| 154 | - | :global(.tooltip.top)::before { | 155 | + | :global(.tooltip.top)::before { |
| 155 | - | content: ''; | 156 | + | content: ''; |
| 156 | - | position: absolute; | 157 | + | position: absolute; |
| 157 | - | bottom: -6px; | 158 | + | bottom: -6px; |
| 158 | - | left: 50%; | 159 | + | left: 50%; |
| 159 | - | transform: translateX(-50%); | 160 | + | transform: translateX(-50%); |
| 160 | - | border-left: 6px solid transparent; | 161 | + | border-left: 6px solid transparent; |
| 161 | - | border-right: 6px solid transparent; | 162 | + | border-right: 6px solid transparent; |
| 162 | - | border-top: 6px solid #003C74; | 163 | + | border-top: 6px solid #003c74; |
| 163 | - | } | 164 | + | } |
| 164 | - | 165 | + | ||
| 165 | - | :global(.tooltip.bottom)::before { | 166 | + | :global(.tooltip.bottom)::before { |
| 166 | - | content: ''; | 167 | + | content: ''; |
| 167 | - | position: absolute; | 168 | + | position: absolute; |
| 168 | - | top: -6px; | 169 | + | top: -6px; |
| 169 | - | left: 50%; | 170 | + | left: 50%; |
| 170 | - | transform: translateX(-50%); | 171 | + | transform: translateX(-50%); |
| 171 | - | border-left: 6px solid transparent; | 172 | + | border-left: 6px solid transparent; |
| 172 | - | border-right: 6px solid transparent; | 173 | + | border-right: 6px solid transparent; |
| 173 | - | border-bottom: 6px solid #003C74; | 174 | + | border-bottom: 6px solid #003c74; |
| 174 | - | } | 175 | + | } |
| 175 | - | 176 | + | ||
| 176 | - | :global(.tooltip.left)::before { | 177 | + | :global(.tooltip.left)::before { |
| 177 | - | content: ''; | 178 | + | content: ''; |
| 178 | - | position: absolute; | 179 | + | position: absolute; |
| 179 | - | right: -6px; | 180 | + | right: -6px; |
| 180 | - | top: 50%; | 181 | + | top: 50%; |
| 181 | - | transform: translateY(-50%); | 182 | + | transform: translateY(-50%); |
| 182 | - | border-top: 6px solid transparent; | 183 | + | border-top: 6px solid transparent; |
| 183 | - | border-bottom: 6px solid transparent; | 184 | + | border-bottom: 6px solid transparent; |
| 184 | - | border-left: 6px solid #003C74; | 185 | + | border-left: 6px solid #003c74; |
| 185 | - | } | 186 | + | } |
| 186 | - | 187 | + | ||
| 187 | - | :global(.tooltip.right)::before { | 188 | + | :global(.tooltip.right)::before { |
| 188 | - | content: ''; | 189 | + | content: ''; |
| 189 | - | position: absolute; | 190 | + | position: absolute; |
| 190 | - | left: -6px; | 191 | + | left: -6px; |
| 191 | - | top: 50%; | 192 | + | top: 50%; |
| 192 | - | transform: translateY(-50%); | 193 | + | transform: translateY(-50%); |
| 193 | - | border-top: 6px solid transparent; | 194 | + | border-top: 6px solid transparent; |
| 194 | - | border-bottom: 6px solid transparent; | 195 | + | border-bottom: 6px solid transparent; |
| 195 | - | border-right: 6px solid #003C74; | 196 | + | border-right: 6px solid #003c74; |
| 196 | - | } | 197 | + | } |
| 197 | - | </style>⏎̸ | 198 | + | </style> |
M src/lib/components/user-preferences-modal.svelte +466 -485
| @@ -1,492 +1,473 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import { onMount } from 'svelte'; | 2 | + | import { onMount } from 'svelte'; |
| 3 | - | import TextStyleSelector from './text-style-selector.svelte'; | 3 | + | import TextStyleSelector from './text-style-selector.svelte'; |
| 4 | - | import { | 4 | + | import { |
| 5 | - | DEFAULT_USER_PREFERENCES, | 5 | + | DEFAULT_USER_PREFERENCES, |
| 6 | - | DEFAULT_TEXT_STYLE, | 6 | + | DEFAULT_TEXT_STYLE, |
| 7 | - | type UserTextPreferences, | 7 | + | type UserTextPreferences, |
| 8 | - | type TextStyle | 8 | + | type TextStyle |
| 9 | - | } from '../types/text-formatting'; | 9 | + | } from '../types/text-formatting'; |
| 10 | - | 10 | + | ||
| 11 | - | // Props | 11 | + | // Props |
| 12 | - | let { | 12 | + | let { showModal = $bindable(false), onSave } = $props<{ |
| 13 | - | showModal = $bindable(false), | 13 | + | showModal: boolean; |
| 14 | - | onSave | 14 | + | onSave?: (preferences: UserTextPreferences) => void; |
| 15 | - | } = $props<{ | 15 | + | }>(); |
| 16 | - | showModal: boolean; | 16 | + | |
| 17 | - | onSave?: (preferences: UserTextPreferences) => void; | 17 | + | // State |
| 18 | - | }>(); | 18 | + | let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES }); |
| 19 | - | 19 | + | let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 20 | - | // State | 20 | + | let isSaving = $state(false); |
| 21 | - | let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES }); | 21 | + | let hasChanges = $derived( |
| 22 | - | let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | 22 | + | JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES) |
| 23 | - | let isSaving = $state(false); | 23 | + | ); |
| 24 | - | let hasChanges = $state(false); | 24 | + | |
| 25 | - | 25 | + | // Update preferences when style changes | |
| 26 | - | // Track changes | 26 | + | $effect(() => { |
| 27 | - | $effect(() => { | 27 | + | preferences.defaultStyle = { ...currentStyle }; |
| 28 | - | hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES); | 28 | + | }); |
| 29 | - | }); | 29 | + | |
| 30 | - | 30 | + | // Load user preferences on mount | |
| 31 | - | // Update preferences when style changes | 31 | + | onMount(async () => { |
| 32 | - | $effect(() => { | 32 | + | try { |
| 33 | - | preferences.defaultStyle = { ...currentStyle }; | 33 | + | const response = await fetch('/api/user/text-preferences'); |
| 34 | - | }); | 34 | + | if (response.ok) { |
| 35 | - | 35 | + | const data = await response.json(); | |
| 36 | - | // Load user preferences on mount | 36 | + | if (data.success && data.preferences) { |
| 37 | - | onMount(async () => { | 37 | + | preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences }; |
| 38 | - | try { | 38 | + | currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle }; |
| 39 | - | const response = await fetch('/api/user/text-preferences'); | 39 | + | } |
| 40 | - | if (response.ok) { | 40 | + | } |
| 41 | - | const data = await response.json(); | 41 | + | } catch (error) { |
| 42 | - | if (data.success && data.preferences) { | 42 | + | console.error('Failed to load user preferences:', error); |
| 43 | - | preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences }; | 43 | + | } |
| 44 | - | currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle }; | 44 | + | }); |
| 45 | - | } | 45 | + | |
| 46 | - | } | 46 | + | // Save preferences |
| 47 | - | } catch (error) { | 47 | + | async function savePreferences() { |
| 48 | - | console.error('Failed to load user preferences:', error); | 48 | + | if (!onSave) return; |
| 49 | - | } | 49 | + | |
| 50 | - | }); | 50 | + | isSaving = true; |
| 51 | - | 51 | + | try { | |
| 52 | - | // Save preferences | 52 | + | await onSave(preferences); |
| 53 | - | async function savePreferences() { | 53 | + | showModal = false; |
| 54 | - | if (!onSave) return; | 54 | + | } catch (error) { |
| 55 | - | 55 | + | console.error('Failed to save preferences:', error); | |
| 56 | - | isSaving = true; | 56 | + | alert('Failed to save preferences. Please try again.'); |
| 57 | - | try { | 57 | + | } finally { |
| 58 | - | await onSave(preferences); | 58 | + | isSaving = false; |
| 59 | - | showModal = false; | 59 | + | } |
| 60 | - | } catch (error) { | 60 | + | } |
| 61 | - | console.error('Failed to save preferences:', error); | 61 | + | |
| 62 | - | alert('Failed to save preferences. Please try again.'); | 62 | + | // Reset to defaults |
| 63 | - | } finally { | 63 | + | function resetToDefaults() { |
| 64 | - | isSaving = false; | 64 | + | preferences = { ...DEFAULT_USER_PREFERENCES }; |
| 65 | - | } | 65 | + | currentStyle = { ...DEFAULT_TEXT_STYLE }; |
| 66 | - | } | 66 | + | } |
| 67 | - | 67 | + | ||
| 68 | - | // Reset to defaults | 68 | + | // Cancel changes |
| 69 | - | function resetToDefaults() { | 69 | + | function cancel() { |
| 70 | - | preferences = { ...DEFAULT_USER_PREFERENCES }; | 70 | + | showModal = false; |
| 71 | - | currentStyle = { ...DEFAULT_TEXT_STYLE }; | 71 | + | } |
| 72 | - | } | 72 | + | |
| 73 | - | 73 | + | // Handle keyboard shortcuts | |
| 74 | - | // Cancel changes | 74 | + | function handleKeydown(event: KeyboardEvent) { |
| 75 | - | function cancel() { | 75 | + | if (event.key === 'Escape') { |
| 76 | - | showModal = false; | 76 | + | cancel(); |
| 77 | - | } | 77 | + | } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { |
| 78 | - | 78 | + | savePreferences(); | |
| 79 | - | // Handle keyboard shortcuts | 79 | + | } |
| 80 | - | function handleKeydown(event: KeyboardEvent) { | 80 | + | } |
| 81 | - | if (event.key === 'Escape') { | |||
| 82 | - | cancel(); | |||
| 83 | - | } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { | |||
| 84 | - | savePreferences(); | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | </script> | 81 | </script> | ||
| 88 | 82 | ||||
| 89 | {#if showModal} | 83 | {#if showModal} | ||
| 90 | - | <!-- Modal backdrop --> | 84 | + | <!-- Modal backdrop --> |
| 91 | - | <div | 85 | + | <div |
| 92 | - | class="modal-backdrop" | 86 | + | class="modal-backdrop" |
| 93 | - | onclick={cancel} | 87 | + | onclick={cancel} |
| 94 | - | onkeydown={handleKeydown} | 88 | + | onkeydown={handleKeydown} |
| 95 | - | role="dialog" | 89 | + | role="dialog" |
| 96 | - | aria-modal="true" | 90 | + | aria-modal="true" |
| 97 | - | aria-labelledby="preferences-title" | 91 | + | aria-labelledby="preferences-title" |
| 98 | - | > | 92 | + | > |
| 99 | - | <!-- Modal content --> | 93 | + | <!-- Modal content --> |
| 100 | - | <div class="modal-window window" onclick={(e) => e.stopPropagation()}> | 94 | + | <div class="modal-window window" onclick={(e) => e.stopPropagation()}> |
| 101 | - | <div class="title-bar"> | 95 | + | <div class="title-bar"> |
| 102 | - | <div class="title-bar-text" id="preferences-title"> | 96 | + | <div class="title-bar-text" id="preferences-title">Text Formatting Preferences</div> |
| 103 | - | Text Formatting Preferences | 97 | + | <div class="title-bar-controls"> |
| 104 | - | </div> | 98 | + | <button onclick={cancel} aria-label="Close"></button> |
| 105 | - | <div class="title-bar-controls"> | 99 | + | </div> |
| 106 | - | <button onclick={cancel} aria-label="Close"></button> | 100 | + | </div> |
| 107 | - | </div> | 101 | + | |
| 108 | - | </div> | 102 | + | <div class="window-body"> |
| 109 | - | 103 | + | <div class="preferences-content"> | |
| 110 | - | <div class="window-body"> | 104 | + | <!-- Enable/Disable Formatting --> |
| 111 | - | <div class="preferences-content"> | 105 | + | <div class="preference-group"> |
| 112 | - | 106 | + | <label class="checkbox-label"> | |
| 113 | - | <!-- Enable/Disable Formatting --> | 107 | + | <input type="checkbox" bind:checked={preferences.allowFormatting} /> |
| 114 | - | <div class="preference-group"> | 108 | + | Enable text formatting in messages |
| 115 | - | <label class="checkbox-label"> | 109 | + | </label> |
| 116 | - | <input | 110 | + | <small class="help-text"> |
| 117 | - | type="checkbox" | 111 | + | When enabled, you can use formatting options like bold, italic, colors, and gradients. |
| 118 | - | bind:checked={preferences.allowFormatting} | 112 | + | </small> |
| 119 | - | /> | 113 | + | </div> |
| 120 | - | Enable text formatting in messages | 114 | + | |
| 121 | - | </label> | 115 | + | <!-- Default Text Style --> |
| 122 | - | <small class="help-text"> | 116 | + | <div class="preference-group"> |
| 123 | - | When enabled, you can use formatting options like bold, italic, colors, and gradients. | 117 | + | <h3>Default Text Style</h3> |
| 124 | - | </small> | 118 | + | <p class="description"> |
| 125 | - | </div> | 119 | + | These settings will be used as defaults when you send new messages. |
| 126 | - | 120 | + | </p> | |
| 127 | - | <!-- Default Text Style --> | 121 | + | |
| 128 | - | <div class="preference-group"> | 122 | + | <TextStyleSelector |
| 129 | - | <h3>Default Text Style</h3> | 123 | + | bind:style={currentStyle} |
| 130 | - | <p class="description"> | 124 | + | previewText="This is how your messages will look by default" |
| 131 | - | These settings will be used as defaults when you send new messages. | 125 | + | showPreview={true} |
| 132 | - | </p> | 126 | + | compact={false} |
| 133 | - | 127 | + | /> | |
| 134 | - | <TextStyleSelector | 128 | + | </div> |
| 135 | - | bind:style={currentStyle} | 129 | + | |
| 136 | - | previewText="This is how your messages will look by default" | 130 | + | <!-- Message Settings --> |
| 137 | - | showPreview={true} | 131 | + | <div class="preference-group"> |
| 138 | - | compact={false} | 132 | + | <h3>Message Settings</h3> |
| 139 | - | /> | 133 | + | |
| 140 | - | </div> | 134 | + | <div class="setting-row"> |
| 141 | - | 135 | + | <label for="max-length">Maximum message length:</label> | |
| 142 | - | <!-- Message Settings --> | 136 | + | <input |
| 143 | - | <div class="preference-group"> | 137 | + | id="max-length" |
| 144 | - | <h3>Message Settings</h3> | 138 | + | type="number" |
| 145 | - | 139 | + | min="100" | |
| 146 | - | <div class="setting-row"> | 140 | + | max="2000" |
| 147 | - | <label for="max-length">Maximum message length:</label> | 141 | + | bind:value={preferences.maxMessageLength} |
| 148 | - | <input | 142 | + | class="length-input" |
| 149 | - | id="max-length" | 143 | + | /> |
| 150 | - | type="number" | 144 | + | <span class="unit">characters</span> |
| 151 | - | min="100" | 145 | + | </div> |
| 152 | - | max="2000" | 146 | + | |
| 153 | - | bind:value={preferences.maxMessageLength} | 147 | + | <small class="help-text"> |
| 154 | - | class="length-input" | 148 | + | Longer messages may take more time to process and display. |
| 155 | - | /> | 149 | + | </small> |
| 156 | - | <span class="unit">characters</span> | 150 | + | </div> |
| 157 | - | </div> | 151 | + | |
| 158 | - | 152 | + | <!-- Format Help --> | |
| 159 | - | <small class="help-text"> | 153 | + | <div class="preference-group"> |
| 160 | - | Longer messages may take more time to process and display. | 154 | + | <details class="format-help"> |
| 161 | - | </small> | 155 | + | <summary>Formatting Guide</summary> |
| 162 | - | </div> | 156 | + | <div class="format-examples"> |
| 163 | - | 157 | + | <h4>BBCode Style:</h4> | |
| 164 | - | <!-- Format Help --> | 158 | + | <ul> |
| 165 | - | <div class="preference-group"> | 159 | + | <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li> |
| 166 | - | <details class="format-help"> | 160 | + | <li><code>[i]italic text[/i]</code> → <em>italic text</em></li> |
| 167 | - | <summary>Formatting Guide</summary> | 161 | + | <li><code>[u]underlined[/u]</code> → <u>underlined</u></li> |
| 168 | - | <div class="format-examples"> | 162 | + | <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li> |
| 169 | - | <h4>BBCode Style:</h4> | 163 | + | <li> |
| 170 | - | <ul> | 164 | + | <code>[color=red]red text[/color]</code> → |
| 171 | - | <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li> | 165 | + | <span style="color: red">red text</span> |
| 172 | - | <li><code>[i]italic text[/i]</code> → <em>italic text</em></li> | 166 | + | </li> |
| 173 | - | <li><code>[u]underlined[/u]</code> → <u>underlined</u></li> | 167 | + | </ul> |
| 174 | - | <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li> | 168 | + | |
| 175 | - | <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li> | 169 | + | <h4>Markdown Style:</h4> |
| 176 | - | </ul> | 170 | + | <ul> |
| 177 | - | 171 | + | <li><code>**bold text**</code> → <strong>bold text</strong></li> | |
| 178 | - | <h4>Markdown Style:</h4> | 172 | + | <li><code>*italic text*</code> → <em>italic text</em></li> |
| 179 | - | <ul> | 173 | + | <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li> |
| 180 | - | <li><code>**bold text**</code> → <strong>bold text</strong></li> | 174 | + | <li><code>`code text`</code> → <code>code text</code></li> |
| 181 | - | <li><code>*italic text*</code> → <em>italic text</em></li> | 175 | + | </ul> |
| 182 | - | <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li> | 176 | + | </div> |
| 183 | - | <li><code>`code text`</code> → <code>code text</code></li> | 177 | + | </details> |
| 184 | - | </ul> | 178 | + | </div> |
| 185 | - | </div> | 179 | + | </div> |
| 186 | - | </details> | 180 | + | |
| 187 | - | </div> | 181 | + | <!-- Modal buttons --> |
| 188 | - | 182 | + | <div class="modal-buttons"> | |
| 189 | - | </div> | 183 | + | <button class="reset-button" onclick={resetToDefaults} disabled={isSaving}> |
| 190 | - | 184 | + | Reset to Defaults | |
| 191 | - | <!-- Modal buttons --> | 185 | + | </button> |
| 192 | - | <div class="modal-buttons"> | 186 | + | |
| 193 | - | <button | 187 | + | <div class="main-buttons"> |
| 194 | - | class="reset-button" | 188 | + | <button onclick={cancel} disabled={isSaving}> Cancel </button> |
| 195 | - | onclick={resetToDefaults} | 189 | + | |
| 196 | - | disabled={isSaving} | 190 | + | <button |
| 197 | - | > | 191 | + | class="save-button" |
| 198 | - | Reset to Defaults | 192 | + | onclick={savePreferences} |
| 199 | - | </button> | 193 | + | disabled={isSaving || !hasChanges} |
| 200 | - | 194 | + | > | |
| 201 | - | <div class="main-buttons"> | 195 | + | {#if isSaving} |
| 202 | - | <button | 196 | + | Saving... |
| 203 | - | onclick={cancel} | 197 | + | {:else} |
| 204 | - | disabled={isSaving} | 198 | + | Save Changes |
| 205 | - | > | 199 | + | {/if} |
| 206 | - | Cancel | 200 | + | </button> |
| 207 | - | </button> | 201 | + | </div> |
| 208 | - | 202 | + | </div> | |
| 209 | - | <button | 203 | + | </div> |
| 210 | - | class="save-button" | 204 | + | </div> |
| 211 | - | onclick={savePreferences} | 205 | + | </div> |
| 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> | |||
| 225 | {/if} | 206 | {/if} | ||
| 226 | 207 | ||||
| 227 | <style> | 208 | <style> | ||
| 228 | - | .modal-backdrop { | 209 | + | .modal-backdrop { |
| 229 | - | position: fixed; | 210 | + | position: fixed; |
| 230 | - | top: 0; | 211 | + | top: 0; |
| 231 | - | left: 0; | 212 | + | left: 0; |
| 232 | - | right: 0; | 213 | + | right: 0; |
| 233 | - | bottom: 0; | 214 | + | bottom: 0; |
| 234 | - | background: rgba(0, 0, 0, 0.5); | 215 | + | background: rgba(0, 0, 0, 0.5); |
| 235 | - | display: flex; | 216 | + | display: flex; |
| 236 | - | align-items: center; | 217 | + | align-items: center; |
| 237 | - | justify-content: center; | 218 | + | justify-content: center; |
| 238 | - | z-index: 1000; | 219 | + | z-index: 1000; |
| 239 | - | padding: 20px; | 220 | + | padding: 20px; |
| 240 | - | } | 221 | + | } |
| 241 | - | 222 | + | ||
| 242 | - | .modal-window { | 223 | + | .modal-window { |
| 243 | - | width: 100%; | 224 | + | width: 100%; |
| 244 | - | max-width: 600px; | 225 | + | max-width: 600px; |
| 245 | - | max-height: 90vh; | 226 | + | max-height: 90vh; |
| 246 | - | overflow: hidden; | 227 | + | overflow: hidden; |
| 247 | - | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3); | 228 | + | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3); |
| 248 | - | } | 229 | + | } |
| 249 | - | 230 | + | ||
| 250 | - | .window-body { | 231 | + | .window-body { |
| 251 | - | display: flex; | 232 | + | display: flex; |
| 252 | - | flex-direction: column; | 233 | + | flex-direction: column; |
| 253 | - | height: 70vh; | 234 | + | height: 70vh; |
| 254 | - | max-height: 600px; | 235 | + | max-height: 600px; |
| 255 | - | } | 236 | + | } |
| 256 | - | 237 | + | ||
| 257 | - | .preferences-content { | 238 | + | .preferences-content { |
| 258 | - | flex: 1; | 239 | + | flex: 1; |
| 259 | - | overflow-y: auto; | 240 | + | overflow-y: auto; |
| 260 | - | padding: 16px; | 241 | + | padding: 16px; |
| 261 | - | } | 242 | + | } |
| 262 | - | 243 | + | ||
| 263 | - | .preference-group { | 244 | + | .preference-group { |
| 264 | - | margin-bottom: 24px; | 245 | + | margin-bottom: 24px; |
| 265 | - | padding-bottom: 16px; | 246 | + | padding-bottom: 16px; |
| 266 | - | border-bottom: 1px solid #ddd; | 247 | + | border-bottom: 1px solid #ddd; |
| 267 | - | } | 248 | + | } |
| 268 | - | 249 | + | ||
| 269 | - | .preference-group:last-child { | 250 | + | .preference-group:last-child { |
| 270 | - | border-bottom: none; | 251 | + | border-bottom: none; |
| 271 | - | margin-bottom: 0; | 252 | + | margin-bottom: 0; |
| 272 | - | } | 253 | + | } |
| 273 | - | 254 | + | ||
| 274 | - | .preference-group h3 { | 255 | + | .preference-group h3 { |
| 275 | - | margin: 0 0 8px 0; | 256 | + | margin: 0 0 8px 0; |
| 276 | - | font-size: 14px; | 257 | + | font-size: 14px; |
| 277 | - | font-weight: bold; | 258 | + | font-weight: bold; |
| 278 | - | color: #2d31a6; | 259 | + | color: #2d31a6; |
| 279 | - | } | 260 | + | } |
| 280 | - | 261 | + | ||
| 281 | - | .description { | 262 | + | .description { |
| 282 | - | margin: 0 0 12px 0; | 263 | + | margin: 0 0 12px 0; |
| 283 | - | font-size: 12px; | 264 | + | font-size: 12px; |
| 284 | - | color: #666; | 265 | + | color: #666; |
| 285 | - | } | 266 | + | } |
| 286 | - | 267 | + | ||
| 287 | - | .checkbox-label { | 268 | + | .checkbox-label { |
| 288 | - | display: flex; | 269 | + | display: flex; |
| 289 | - | align-items: center; | 270 | + | align-items: center; |
| 290 | - | gap: 8px; | 271 | + | gap: 8px; |
| 291 | - | font-size: 12px; | 272 | + | font-size: 12px; |
| 292 | - | cursor: pointer; | 273 | + | cursor: pointer; |
| 293 | - | } | 274 | + | } |
| 294 | - | 275 | + | ||
| 295 | - | .help-text { | 276 | + | .help-text { |
| 296 | - | display: block; | 277 | + | display: block; |
| 297 | - | margin-top: 4px; | 278 | + | margin-top: 4px; |
| 298 | - | font-size: 11px; | 279 | + | font-size: 11px; |
| 299 | - | color: #666; | 280 | + | color: #666; |
| 300 | - | font-style: italic; | 281 | + | font-style: italic; |
| 301 | - | } | 282 | + | } |
| 302 | - | 283 | + | ||
| 303 | - | .setting-row { | 284 | + | .setting-row { |
| 304 | - | display: flex; | 285 | + | display: flex; |
| 305 | - | align-items: center; | 286 | + | align-items: center; |
| 306 | - | gap: 8px; | 287 | + | gap: 8px; |
| 307 | - | margin-bottom: 8px; | 288 | + | margin-bottom: 8px; |
| 308 | - | } | 289 | + | } |
| 309 | - | 290 | + | ||
| 310 | - | .setting-row label { | 291 | + | .setting-row label { |
| 311 | - | font-size: 12px; | 292 | + | font-size: 12px; |
| 312 | - | min-width: 140px; | 293 | + | min-width: 140px; |
| 313 | - | } | 294 | + | } |
| 314 | - | 295 | + | ||
| 315 | - | .length-input { | 296 | + | .length-input { |
| 316 | - | width: 80px; | 297 | + | width: 80px; |
| 317 | - | padding: 2px 4px; | 298 | + | padding: 2px 4px; |
| 318 | - | border: 1px inset #dfdfdf; | 299 | + | border: 1px inset #dfdfdf; |
| 319 | - | background: white; | 300 | + | background: white; |
| 320 | - | font-size: 11px; | 301 | + | font-size: 11px; |
| 321 | - | } | 302 | + | } |
| 322 | - | 303 | + | ||
| 323 | - | .unit { | 304 | + | .unit { |
| 324 | - | font-size: 11px; | 305 | + | font-size: 11px; |
| 325 | - | color: #666; | 306 | + | color: #666; |
| 326 | - | } | 307 | + | } |
| 327 | - | 308 | + | ||
| 328 | - | .format-help { | 309 | + | .format-help { |
| 329 | - | margin-top: 8px; | 310 | + | margin-top: 8px; |
| 330 | - | } | 311 | + | } |
| 331 | - | 312 | + | ||
| 332 | - | .format-help summary { | 313 | + | .format-help summary { |
| 333 | - | font-size: 12px; | 314 | + | font-size: 12px; |
| 334 | - | font-weight: bold; | 315 | + | font-weight: bold; |
| 335 | - | cursor: pointer; | 316 | + | cursor: pointer; |
| 336 | - | padding: 4px 0; | 317 | + | padding: 4px 0; |
| 337 | - | } | 318 | + | } |
| 338 | - | 319 | + | ||
| 339 | - | .format-examples { | 320 | + | .format-examples { |
| 340 | - | padding: 8px 0; | 321 | + | padding: 8px 0; |
| 341 | - | font-size: 11px; | 322 | + | font-size: 11px; |
| 342 | - | } | 323 | + | } |
| 343 | - | 324 | + | ||
| 344 | - | .format-examples h4 { | 325 | + | .format-examples h4 { |
| 345 | - | margin: 12px 0 4px 0; | 326 | + | margin: 12px 0 4px 0; |
| 346 | - | font-size: 11px; | 327 | + | font-size: 11px; |
| 347 | - | color: #2d31a6; | 328 | + | color: #2d31a6; |
| 348 | - | } | 329 | + | } |
| 349 | - | 330 | + | ||
| 350 | - | .format-examples ul { | 331 | + | .format-examples ul { |
| 351 | - | margin: 0; | 332 | + | margin: 0; |
| 352 | - | padding-left: 16px; | 333 | + | padding-left: 16px; |
| 353 | - | } | 334 | + | } |
| 354 | - | 335 | + | ||
| 355 | - | .format-examples li { | 336 | + | .format-examples li { |
| 356 | - | margin-bottom: 2px; | 337 | + | margin-bottom: 2px; |
| 357 | - | } | 338 | + | } |
| 358 | - | 339 | + | ||
| 359 | - | .format-examples code { | 340 | + | .format-examples code { |
| 360 | - | background: #f0f0f0; | 341 | + | background: #f0f0f0; |
| 361 | - | padding: 1px 3px; | 342 | + | padding: 1px 3px; |
| 362 | - | border-radius: 2px; | 343 | + | border-radius: 2px; |
| 363 | - | font-family: 'Courier New', monospace; | 344 | + | font-family: 'Courier New', monospace; |
| 364 | - | font-size: 10px; | 345 | + | font-size: 10px; |
| 365 | - | } | 346 | + | } |
| 366 | - | 347 | + | ||
| 367 | - | .modal-buttons { | 348 | + | .modal-buttons { |
| 368 | - | display: flex; | 349 | + | display: flex; |
| 369 | - | justify-content: space-between; | 350 | + | justify-content: space-between; |
| 370 | - | align-items: center; | 351 | + | align-items: center; |
| 371 | - | padding: 12px 16px; | 352 | + | padding: 12px 16px; |
| 372 | - | border-top: 1px solid #ddd; | 353 | + | border-top: 1px solid #ddd; |
| 373 | - | background: #f8f8f8; | 354 | + | background: #f8f8f8; |
| 374 | - | } | 355 | + | } |
| 375 | - | 356 | + | ||
| 376 | - | .main-buttons { | 357 | + | .main-buttons { |
| 377 | - | display: flex; | 358 | + | display: flex; |
| 378 | - | gap: 8px; | 359 | + | gap: 8px; |
| 379 | - | } | 360 | + | } |
| 380 | - | 361 | + | ||
| 381 | - | .reset-button { | 362 | + | .reset-button { |
| 382 | - | padding: 4px 12px; | 363 | + | padding: 4px 12px; |
| 383 | - | font-size: 11px; | 364 | + | font-size: 11px; |
| 384 | - | border: 1px outset #dfdfdf; | 365 | + | border: 1px outset #dfdfdf; |
| 385 | - | background: #f0f0f0; | 366 | + | background: #f0f0f0; |
| 386 | - | cursor: pointer; | 367 | + | cursor: pointer; |
| 387 | - | } | 368 | + | } |
| 388 | - | 369 | + | ||
| 389 | - | .reset-button:hover { | 370 | + | .reset-button:hover { |
| 390 | - | background: #e0e0e0; | 371 | + | background: #e0e0e0; |
| 391 | - | } | 372 | + | } |
| 392 | - | 373 | + | ||
| 393 | - | .reset-button:disabled { | 374 | + | .reset-button:disabled { |
| 394 | - | opacity: 0.6; | 375 | + | opacity: 0.6; |
| 395 | - | cursor: not-allowed; | 376 | + | cursor: not-allowed; |
| 396 | - | } | 377 | + | } |
| 397 | - | 378 | + | ||
| 398 | - | .save-button { | 379 | + | .save-button { |
| 399 | - | padding: 4px 16px; | 380 | + | padding: 4px 16px; |
| 400 | - | font-size: 11px; | 381 | + | font-size: 11px; |
| 401 | - | font-weight: bold; | 382 | + | font-weight: bold; |
| 402 | - | border: 1px outset #dfdfdf; | 383 | + | border: 1px outset #dfdfdf; |
| 403 | - | background: #0078d4; | 384 | + | background: #0078d4; |
| 404 | - | color: white; | 385 | + | color: white; |
| 405 | - | cursor: pointer; | 386 | + | cursor: pointer; |
| 406 | - | } | 387 | + | } |
| 407 | - | 388 | + | ||
| 408 | - | .save-button:hover:not(:disabled) { | 389 | + | .save-button:hover:not(:disabled) { |
| 409 | - | background: #106ebe; | 390 | + | background: #106ebe; |
| 410 | - | } | 391 | + | } |
| 411 | - | 392 | + | ||
| 412 | - | .save-button:disabled { | 393 | + | .save-button:disabled { |
| 413 | - | opacity: 0.6; | 394 | + | opacity: 0.6; |
| 414 | - | cursor: not-allowed; | 395 | + | cursor: not-allowed; |
| 415 | - | background: #999; | 396 | + | background: #999; |
| 416 | - | } | 397 | + | } |
| 417 | - | 398 | + | ||
| 418 | - | button { | 399 | + | button { |
| 419 | - | padding: 4px 12px; | 400 | + | padding: 4px 12px; |
| 420 | - | font-size: 11px; | 401 | + | font-size: 11px; |
| 421 | - | border: 1px outset #dfdfdf; | 402 | + | border: 1px outset #dfdfdf; |
| 422 | - | background: #f0f0f0; | 403 | + | background: #f0f0f0; |
| 423 | - | cursor: pointer; | 404 | + | cursor: pointer; |
| 424 | - | } | 405 | + | } |
| 425 | - | 406 | + | ||
| 426 | - | button:hover:not(:disabled) { | 407 | + | button:hover:not(:disabled) { |
| 427 | - | background: #e0e0e0; | 408 | + | background: #e0e0e0; |
| 428 | - | } | 409 | + | } |
| 429 | - | 410 | + | ||
| 430 | - | button:disabled { | 411 | + | button:disabled { |
| 431 | - | opacity: 0.6; | 412 | + | opacity: 0.6; |
| 432 | - | cursor: not-allowed; | 413 | + | cursor: not-allowed; |
| 433 | - | } | 414 | + | } |
| 434 | - | 415 | + | ||
| 435 | - | /* Scrollbar styling */ | 416 | + | /* Scrollbar styling */ |
| 436 | - | .preferences-content::-webkit-scrollbar { | 417 | + | .preferences-content::-webkit-scrollbar { |
| 437 | - | width: 12px; | 418 | + | width: 12px; |
| 438 | - | } | 419 | + | } |
| 439 | - | 420 | + | ||
| 440 | - | .preferences-content::-webkit-scrollbar-track { | 421 | + | .preferences-content::-webkit-scrollbar-track { |
| 441 | - | background: #f0f0f0; | 422 | + | background: #f0f0f0; |
| 442 | - | border: 1px inset #dfdfdf; | 423 | + | border: 1px inset #dfdfdf; |
| 443 | - | } | 424 | + | } |
| 444 | - | 425 | + | ||
| 445 | - | .preferences-content::-webkit-scrollbar-thumb { | 426 | + | .preferences-content::-webkit-scrollbar-thumb { |
| 446 | - | background: #c0c0c0; | 427 | + | background: #c0c0c0; |
| 447 | - | border: 1px outset #dfdfdf; | 428 | + | border: 1px outset #dfdfdf; |
| 448 | - | } | 429 | + | } |
| 449 | - | 430 | + | ||
| 450 | - | .preferences-content::-webkit-scrollbar-thumb:hover { | 431 | + | .preferences-content::-webkit-scrollbar-thumb:hover { |
| 451 | - | background: #a0a0a0; | 432 | + | background: #a0a0a0; |
| 452 | - | } | 433 | + | } |
| 453 | - | 434 | + | ||
| 454 | - | /* Mobile responsive */ | 435 | + | /* Mobile responsive */ |
| 455 | - | @media (max-width: 768px) { | 436 | + | @media (max-width: 768px) { |
| 456 | - | .modal-backdrop { | 437 | + | .modal-backdrop { |
| 457 | - | padding: 10px; | 438 | + | padding: 10px; |
| 458 | - | } | 439 | + | } |
| 459 | - | 440 | + | ||
| 460 | - | .modal-window { | 441 | + | .modal-window { |
| 461 | - | max-width: none; | 442 | + | max-width: none; |
| 462 | - | max-height: 95vh; | 443 | + | max-height: 95vh; |
| 463 | - | } | 444 | + | } |
| 464 | - | 445 | + | ||
| 465 | - | .window-body { | 446 | + | .window-body { |
| 466 | - | height: 80vh; | 447 | + | height: 80vh; |
| 467 | - | } | 448 | + | } |
| 468 | - | 449 | + | ||
| 469 | - | .preferences-content { | 450 | + | .preferences-content { |
| 470 | - | padding: 12px; | 451 | + | padding: 12px; |
| 471 | - | } | 452 | + | } |
| 472 | - | 453 | + | ||
| 473 | - | .setting-row { | 454 | + | .setting-row { |
| 474 | - | flex-direction: column; | 455 | + | flex-direction: column; |
| 475 | - | align-items: flex-start; | 456 | + | align-items: flex-start; |
| 476 | - | gap: 4px; | 457 | + | gap: 4px; |
| 477 | - | } | 458 | + | } |
| 478 | - | 459 | + | ||
| 479 | - | .setting-row label { | 460 | + | .setting-row label { |
| 480 | - | min-width: auto; | 461 | + | min-width: auto; |
| 481 | - | } | 462 | + | } |
| 482 | - | 463 | + | ||
| 483 | - | .modal-buttons { | 464 | + | .modal-buttons { |
| 484 | - | flex-direction: column; | 465 | + | flex-direction: column; |
| 485 | - | gap: 8px; | 466 | + | gap: 8px; |
| 486 | - | } | 467 | + | } |
| 487 | - | 468 | + | ||
| 488 | - | .main-buttons { | 469 | + | .main-buttons { |
| 489 | - | order: -1; | 470 | + | order: -1; |
| 490 | - | } | 471 | + | } |
| 491 | - | } | 472 | + | } |
| 492 | - | </style>⏎̸ | 473 | + | </style> |
M src/lib/db/db.server.ts +17 -13
| @@ -12,24 +12,28 @@ const db = drizzle(client, { schema }); | |||||
| 12 | 12 | ||||
| 13 | // Initialize database with required data | 13 | // Initialize database with required data | ||
| 14 | export const initializeDatabase = async () => { | 14 | export const initializeDatabase = async () => { | ||
| 15 | - | log.debug('Initializing database...'); | 15 | + | log.debug('Initializing database...'); |
| 16 | - | try { | 16 | + | try { |
| 17 | - | await ensureDefaultChatRoom(db); | 17 | + | await ensureDefaultChatRoom(db); |
| 18 | - | log.debug('Database initialization complete'); | 18 | + | log.debug('Database initialization complete'); |
| 19 | - | } catch (error) { | 19 | + | } catch (error) { |
| 20 | - | log.error('Error initializing database:', { error: error instanceof Error ? error.message : 'Unknown error' }); | 20 | + | log.error('Error initializing database:', { |
| 21 | - | throw error; | 21 | + | error: error instanceof Error ? error.message : 'Unknown error' |
| 22 | - | } | 22 | + | }); |
| 23 | + | throw error; | |||
| 24 | + | } | |||
| 23 | }; | 25 | }; | ||
| 24 | 26 | ||||
| 25 | // Call initialization when the module is loaded | 27 | // Call initialization when the module is loaded | ||
| 26 | - | initializeDatabase().catch(error => { | 28 | + | initializeDatabase().catch((error) => { |
| 27 | - | log.error('Failed to initialize database:', { error: error instanceof Error ? error.message : 'Unknown error' }); | 29 | + | log.error('Failed to initialize database:', { |
| 30 | + | error: error instanceof Error ? error.message : 'Unknown error' | |||
| 31 | + | }); | |||
| 28 | }); | 32 | }); | ||
| 29 | 33 | ||||
| 30 | export const testConnection = async () => { | 34 | export const testConnection = async () => { | ||
| 31 | - | const result = await db.query.users.findMany(); | 35 | + | const result = await db.query.users.findMany(); |
| 32 | - | log.debug('Test connection result:', result); | 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 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | 7 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | ||
| 8 | 8 | ||||
| 9 | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | 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 | async function flushDatabase() { | 15 | async function flushDatabase() { | ||
| 14 | - | console.debug('Starting database content flush...'); | 16 | + | console.debug('Starting database content flush...'); |
| 15 | - | 17 | + | ||
| 16 | - | const client = createClient({ | 18 | + | const client = createClient({ |
| 17 | - | url: TURSO_DATABASE_URL as string, | 19 | + | url: TURSO_DATABASE_URL as string, |
| 18 | - | authToken: TURSO_AUTH_TOKEN as string | 20 | + | authToken: TURSO_AUTH_TOKEN as string |
| 19 | - | }); | 21 | + | }); |
| 20 | - | 22 | + | ||
| 21 | - | try { | 23 | + | try { |
| 22 | - | // Clear tables in correct order to respect foreign key constraints | 24 | + | // Clear tables in correct order to respect foreign key constraints |
| 23 | - | console.debug('Clearing messages table...'); | 25 | + | console.debug('Clearing messages table...'); |
| 24 | - | await client.execute('DELETE FROM messages'); | 26 | + | await client.execute('DELETE FROM messages'); |
| 25 | - | 27 | + | ||
| 26 | - | console.debug('Clearing sessions table...'); | 28 | + | console.debug('Clearing sessions table...'); |
| 27 | - | await client.execute('DELETE FROM sessions'); | 29 | + | await client.execute('DELETE FROM sessions'); |
| 28 | - | 30 | + | ||
| 29 | - | console.debug('Clearing chat rooms table...'); | 31 | + | console.debug('Clearing chat rooms table...'); |
| 30 | - | await client.execute('DELETE FROM chat_rooms'); | 32 | + | await client.execute('DELETE FROM chat_rooms'); |
| 31 | - | 33 | + | ||
| 32 | - | console.debug('Clearing users table...'); | 34 | + | console.debug('Clearing users table...'); |
| 33 | - | await client.execute('DELETE FROM users'); | 35 | + | await client.execute('DELETE FROM users'); |
| 34 | - | 36 | + | ||
| 35 | - | console.debug('Database content flush completed successfully'); | 37 | + | console.debug('Database content flush completed successfully'); |
| 36 | - | } catch (error) { | 38 | + | } catch (error) { |
| 37 | - | console.error('Error flushing database content:', error); | 39 | + | console.error('Error flushing database content:', error); |
| 38 | - | throw error; | 40 | + | throw error; |
| 39 | - | } finally { | 41 | + | } finally { |
| 40 | - | await client.close(); | 42 | + | await client.close(); |
| 41 | - | } | 43 | + | } |
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 44 | // Execute if this file is run directly | 46 | // Execute if this file is run directly | ||
| 45 | if (process.argv[1] === import.meta.url) { | 47 | if (process.argv[1] === import.meta.url) { | ||
| 46 | - | flushDatabase() | 48 | + | flushDatabase() |
| 47 | - | .then(() => process.exit(0)) | 49 | + | .then(() => process.exit(0)) |
| 48 | - | .catch((error) => { | 50 | + | .catch((error) => { |
| 49 | - | console.error('Failed to flush database content:', error); | 51 | + | console.error('Failed to flush database content:', error); |
| 50 | - | process.exit(1); | 52 | + | process.exit(1); |
| 51 | - | }); | 53 | + | }); |
| 52 | - | } ⏎̸ | 54 | + | } |
M src/lib/db/schema.ts +110 -101
| @@ -1,71 +1,76 @@ | |||||
| 1 | - | import { sqliteTable as table } from "drizzle-orm/sqlite-core"; | 1 | + | import { sqliteTable as table } from 'drizzle-orm/sqlite-core'; |
| 2 | - | import * as t 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"; | 3 | + | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from '../types/chat'; |
| 4 | - | import { v4 as uuidv4 } from "uuid"; | 4 | + | import { v4 as uuidv4 } from 'uuid'; |
| 5 | - | import { eq } from "drizzle-orm"; | 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( | 20 | + | export const sessions = table('sessions', { |
| 9 | - | "users", | 21 | + | id: t.text('id').primaryKey(), |
| 10 | - | { | 22 | + | userId: t |
| 11 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | 23 | + | .text('user_id') |
| 12 | - | password: t.text("password").notNull(), | 24 | + | .references(() => users.id) |
| 13 | - | nickname: t.text("nickname").notNull(), | 25 | + | .notNull(), |
| 14 | - | status: t.text("status").notNull().$type<UserStatus>().default("offline"), | 26 | + | expiresAt: t.integer('expires_at').notNull(), |
| 15 | - | avatarUrl: t.text("avatar_url"), | 27 | + | createdAt: t.integer('created_at').notNull() |
| 16 | - | createdAt: t.integer("created_at").notNull(), | 28 | + | }); |
| 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 | - | ); | |||
| 30 | 29 | ||||
| 31 | - | export const chatRooms = table( | 30 | + | export const chatRooms = table('chat_rooms', { |
| 32 | - | "chat_rooms", | 31 | + | id: t |
| 33 | - | { | 32 | + | .text('id') |
| 34 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | 33 | + | .primaryKey() |
| 35 | - | name: t.text("name"), | 34 | + | .$default(() => uuidv4()), |
| 36 | - | type: t.text("type").$type<"direct" | "group">().notNull().default("direct"), | 35 | + | name: t.text('name'), |
| 37 | - | createdAt: t.integer("created_at").notNull(), | 36 | + | type: t.text('type').$type<'direct' | 'group'>().notNull().default('direct'), |
| 38 | - | } | 37 | + | createdAt: t.integer('created_at').notNull() |
| 39 | - | ); | 38 | + | }); |
| 40 | 39 | ||||
| 41 | - | export const messages = table( | 40 | + | export const messages = table('messages', { |
| 42 | - | "messages", | 41 | + | id: t |
| 43 | - | { | 42 | + | .text('id') |
| 44 | - | id: t.text("id").primaryKey().$default(() => uuidv4()), | 43 | + | .primaryKey() |
| 45 | - | chatRoomId: t.text("chat_room_id").references(() => chatRooms.id).notNull(), | 44 | + | .$default(() => uuidv4()), |
| 46 | - | senderId: t.text("sender_id").references(() => users.id).notNull(), | 45 | + | chatRoomId: t |
| 47 | - | content: t.text("content").notNull(), | 46 | + | .text('chat_room_id') |
| 48 | - | type: t.text("type").$type<MessageType>().notNull().default("chat"), | 47 | + | .references(() => chatRooms.id) |
| 49 | - | timestamp: t.integer("timestamp").notNull(), | 48 | + | .notNull(), |
| 50 | - | styleData: t.text("style_data"), // JSON string of TextStyle object | 49 | + | senderId: t |
| 51 | - | hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag | 50 | + | .text('sender_id') |
| 52 | - | } | 51 | + | .references(() => users.id) |
| 53 | - | ); | 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( | 60 | + | export const userTextPreferences = table('user_text_preferences', { |
| 56 | - | "user_text_preferences", | 61 | + | userId: t |
| 57 | - | { | 62 | + | .text('user_id') |
| 58 | - | userId: t.text("user_id").references(() => users.id).primaryKey(), | 63 | + | .references(() => users.id) |
| 59 | - | defaultFontFamily: t.text("default_font_family").default("tahoma"), | 64 | + | .primaryKey(), |
| 60 | - | defaultFontSize: t.integer("default_font_size").default(14), | 65 | + | defaultFontFamily: t.text('default_font_family').default('tahoma'), |
| 61 | - | defaultColor: t.text("default_color").default("black"), | 66 | + | defaultFontSize: t.integer('default_font_size').default(14), |
| 62 | - | allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true), | 67 | + | defaultColor: t.text('default_color').default('black'), |
| 63 | - | maxMessageLength: t.integer("max_message_length").default(500), | 68 | + | allowFormatting: t.integer('allow_formatting', { mode: 'boolean' }).default(true), |
| 64 | - | stylePresets: t.text("style_presets"), // JSON array of saved styles | 69 | + | maxMessageLength: t.integer('max_message_length').default(500), |
| 65 | - | createdAt: t.integer("created_at").notNull(), | 70 | + | stylePresets: t.text('style_presets'), // JSON array of saved styles |
| 66 | - | updatedAt: t.integer("updated_at").notNull(), | 71 | + | createdAt: t.integer('created_at').notNull(), |
| 67 | - | } | 72 | + | updatedAt: t.integer('updated_at').notNull() |
| 68 | - | ); | 73 | + | }); |
| 69 | 74 | ||||
| 70 | // Validate schema types against interfaces | 75 | // Validate schema types against interfaces | ||
| 71 | export type Users = typeof users.$inferSelect; | 76 | export type Users = typeof users.$inferSelect; | ||
| @@ -75,55 +80,59 @@ export type Sessions = typeof sessions.$inferSelect; | |||||
| 75 | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | 80 | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | ||
| 76 | // These type assertions will fail if the schema doesn't match the interfaces | 81 | // These type assertions will fail if the schema doesn't match the interfaces | ||
| 77 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | 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 | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; | 85 | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; | ||
| 80 | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | 86 | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | ||
| 81 | 87 | ||||
| 82 | - | export const userView = t.sqliteView("user_view").as((qb) => | 88 | + | export const userView = t.sqliteView('user_view').as((qb) => |
| 83 | - | qb.select({ | 89 | + | qb |
| 84 | - | id: users.id, | 90 | + | .select({ |
| 85 | - | nickname: users.nickname, | 91 | + | id: users.id, |
| 86 | - | status: users.status, | 92 | + | nickname: users.nickname, |
| 87 | - | avatarUrl: users.avatarUrl, | 93 | + | status: users.status, |
| 88 | - | lastSeen: users.lastSeen | 94 | + | avatarUrl: users.avatarUrl, |
| 89 | - | }).from(users) | 95 | + | lastSeen: users.lastSeen |
| 96 | + | }) | |||
| 97 | + | .from(users) | |||
| 90 | ); | 98 | ); | ||
| 91 | 99 | ||||
| 92 | - | export const chatRoomView = t.sqliteView("chat_room_view").as((qb) => | 100 | + | export const chatRoomView = t.sqliteView('chat_room_view').as((qb) => |
| 93 | - | qb.select({ | 101 | + | qb |
| 94 | - | id: chatRooms.id, | 102 | + | .select({ |
| 95 | - | name: chatRooms.name, | 103 | + | id: chatRooms.id, |
| 96 | - | type: chatRooms.type, | 104 | + | name: chatRooms.name, |
| 97 | - | createdAt: chatRooms.createdAt | 105 | + | type: chatRooms.type, |
| 98 | - | }).from(chatRooms) | 106 | + | createdAt: chatRooms.createdAt |
| 107 | + | }) | |||
| 108 | + | .from(chatRooms) | |||
| 99 | ); | 109 | ); | ||
| 100 | 110 | ||||
| 101 | - | export const messageView = t.sqliteView("message_view").as((qb) => | 111 | + | export const messageView = t.sqliteView('message_view').as((qb) => |
| 102 | - | qb.select({ | 112 | + | qb |
| 103 | - | id: messages.id, | 113 | + | .select({ |
| 104 | - | content: messages.content, | 114 | + | id: messages.id, |
| 105 | - | type: messages.type, | 115 | + | content: messages.content, |
| 106 | - | timestamp: messages.timestamp, | 116 | + | type: messages.type, |
| 107 | - | sender: users.nickname, | 117 | + | timestamp: messages.timestamp, |
| 108 | - | chatRoom: chatRooms.name | 118 | + | sender: users.nickname, |
| 109 | - | }) | 119 | + | chatRoom: chatRooms.name |
| 110 | - | .from(messages) | 120 | + | }) |
| 111 | - | .leftJoin(users, eq(messages.senderId, users.id)) | 121 | + | .from(messages) |
| 112 | - | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | 122 | + | .leftJoin(users, eq(messages.senderId, users.id)) |
| 123 | + | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | |||
| 113 | ); | 124 | ); | ||
| 114 | 125 | ||||
| 115 | // Create a schema object with all our tables and views | 126 | // Create a schema object with all our tables and views | ||
| 116 | const schema = { | 127 | const schema = { | ||
| 117 | - | users, | 128 | + | users, |
| 118 | - | sessions, | 129 | + | sessions, |
| 119 | - | chatRooms, | 130 | + | chatRooms, |
| 120 | - | messages, | 131 | + | messages, |
| 121 | - | userTextPreferences, | 132 | + | userTextPreferences, |
| 122 | - | userView, | 133 | + | userView, |
| 123 | - | chatRoomView, | 134 | + | chatRoomView, |
| 124 | - | messageView | 135 | + | messageView |
| 125 | }; | 136 | }; | ||
| 126 | 137 | ||||
| 127 | - | ||||
| 128 | export default schema; | 138 | export default schema; | ||
| 129 | - | ||||
M src/lib/sseConnectionTracker.ts +79 -79
| @@ -3,88 +3,88 @@ import { createLogger } from './utils/logger.server'; | |||||
| 3 | const log = createLogger('sse-connection-tracker'); | 3 | const log = createLogger('sse-connection-tracker'); | ||
| 4 | 4 | ||||
| 5 | interface ConnectionInfo { | 5 | interface ConnectionInfo { | ||
| 6 | - | userId: string; | 6 | + | userId: string; |
| 7 | - | connectedAt: number; | 7 | + | connectedAt: number; |
| 8 | - | lastActivity: number; | 8 | + | lastActivity: number; |
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | class SSEConnectionTracker { | 11 | class SSEConnectionTracker { | ||
| 12 | - | private connections = new Map<string, ConnectionInfo>(); | 12 | + | private connections = new Map<string, ConnectionInfo>(); |
| 13 | - | 13 | + | ||
| 14 | - | addConnection(userId: string): boolean { | 14 | + | addConnection(userId: string): boolean { |
| 15 | - | const existing = this.connections.get(userId); | 15 | + | const existing = this.connections.get(userId); |
| 16 | - | if (existing) { | 16 | + | if (existing) { |
| 17 | - | log.debug('User already has active connection', { userId }); | 17 | + | log.debug('User already has active connection', { userId }); |
| 18 | - | // Update last activity but don't trigger status update | 18 | + | // Update last activity but don't trigger status update |
| 19 | - | existing.lastActivity = Date.now(); | 19 | + | existing.lastActivity = Date.now(); |
| 20 | - | return false; // Connection already exists | 20 | + | return false; // Connection already exists |
| 21 | - | } | 21 | + | } |
| 22 | - | 22 | + | ||
| 23 | - | this.connections.set(userId, { | 23 | + | this.connections.set(userId, { |
| 24 | - | userId, | 24 | + | userId, |
| 25 | - | connectedAt: Date.now(), | 25 | + | connectedAt: Date.now(), |
| 26 | - | lastActivity: Date.now() | 26 | + | lastActivity: Date.now() |
| 27 | - | }); | 27 | + | }); |
| 28 | - | 28 | + | ||
| 29 | - | log.debug('New SSE connection tracked', { | 29 | + | log.debug('New SSE connection tracked', { |
| 30 | - | userId, | 30 | + | userId, |
| 31 | - | totalConnections: this.connections.size | 31 | + | totalConnections: this.connections.size |
| 32 | - | }); | 32 | + | }); |
| 33 | - | return true; // New connection | 33 | + | return true; // New connection |
| 34 | - | } | 34 | + | } |
| 35 | - | 35 | + | ||
| 36 | - | removeConnection(userId: string): boolean { | 36 | + | removeConnection(userId: string): boolean { |
| 37 | - | const deleted = this.connections.delete(userId); | 37 | + | const deleted = this.connections.delete(userId); |
| 38 | - | if (deleted) { | 38 | + | if (deleted) { |
| 39 | - | log.debug('SSE connection removed', { | 39 | + | log.debug('SSE connection removed', { |
| 40 | - | userId, | 40 | + | userId, |
| 41 | - | remainingConnections: this.connections.size | 41 | + | remainingConnections: this.connections.size |
| 42 | - | }); | 42 | + | }); |
| 43 | - | } | 43 | + | } |
| 44 | - | return deleted; | 44 | + | return deleted; |
| 45 | - | } | 45 | + | } |
| 46 | - | 46 | + | ||
| 47 | - | hasConnection(userId: string): boolean { | 47 | + | hasConnection(userId: string): boolean { |
| 48 | - | return this.connections.has(userId); | 48 | + | return this.connections.has(userId); |
| 49 | - | } | 49 | + | } |
| 50 | - | 50 | + | ||
| 51 | - | updateActivity(userId: string): void { | 51 | + | updateActivity(userId: string): void { |
| 52 | - | const connection = this.connections.get(userId); | 52 | + | const connection = this.connections.get(userId); |
| 53 | - | if (connection) { | 53 | + | if (connection) { |
| 54 | - | connection.lastActivity = Date.now(); | 54 | + | connection.lastActivity = Date.now(); |
| 55 | - | } | 55 | + | } |
| 56 | - | } | 56 | + | } |
| 57 | - | 57 | + | ||
| 58 | - | getActiveUserIds(): string[] { | 58 | + | getActiveUserIds(): string[] { |
| 59 | - | return Array.from(this.connections.keys()); | 59 | + | return Array.from(this.connections.keys()); |
| 60 | - | } | 60 | + | } |
| 61 | - | 61 | + | ||
| 62 | - | getConnectionCount(): number { | 62 | + | getConnectionCount(): number { |
| 63 | - | return this.connections.size; | 63 | + | return this.connections.size; |
| 64 | - | } | 64 | + | } |
| 65 | - | 65 | + | ||
| 66 | - | // Clean up stale connections (e.g., if server didn't properly close them) | 66 | + | // Clean up stale connections (e.g., if server didn't properly close them) |
| 67 | - | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | 67 | + | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { |
| 68 | - | const now = Date.now(); | 68 | + | const now = Date.now(); |
| 69 | - | const staleUserIds: string[] = []; | 69 | + | const staleUserIds: string[] = []; |
| 70 | - | 70 | + | ||
| 71 | - | for (const [userId, info] of this.connections.entries()) { | 71 | + | for (const [userId, info] of this.connections.entries()) { |
| 72 | - | if (now - info.lastActivity > maxInactivityMs) { | 72 | + | if (now - info.lastActivity > maxInactivityMs) { |
| 73 | - | this.connections.delete(userId); | 73 | + | this.connections.delete(userId); |
| 74 | - | staleUserIds.push(userId); | 74 | + | staleUserIds.push(userId); |
| 75 | - | } | 75 | + | } |
| 76 | - | } | 76 | + | } |
| 77 | - | 77 | + | ||
| 78 | - | if (staleUserIds.length > 0) { | 78 | + | if (staleUserIds.length > 0) { |
| 79 | - | log.info('Cleaned up stale connections', { | 79 | + | log.info('Cleaned up stale connections', { |
| 80 | - | count: staleUserIds.length, | 80 | + | count: staleUserIds.length, |
| 81 | - | userIds: staleUserIds | 81 | + | userIds: staleUserIds |
| 82 | - | }); | 82 | + | }); |
| 83 | - | } | 83 | + | } |
| 84 | - | 84 | + | ||
| 85 | - | return staleUserIds; | 85 | + | return staleUserIds; |
| 86 | - | } | 86 | + | } |
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | // Export singleton instance | 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 | type SSEEventData = { | 1 | type SSEEventData = { | ||
| 2 | - | type: string; | 2 | + | type: string; |
| 3 | - | data: unknown; | 3 | + | data: unknown; |
| 4 | }; | 4 | }; | ||
| 5 | 5 | ||||
| 6 | class CustomEventEmitter { | 6 | class CustomEventEmitter { | ||
| 7 | - | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); | 7 | + | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); |
| 8 | - | private maxListeners: number = 100; | 8 | + | private maxListeners: number = 100; |
| 9 | - | 9 | + | ||
| 10 | - | constructor() { | 10 | + | constructor() { |
| 11 | - | console.log('[SSE-Emitter] Initialized'); | 11 | + | console.log('[SSE-Emitter] Initialized'); |
| 12 | - | } | 12 | + | } |
| 13 | - | 13 | + | ||
| 14 | - | emit(event: string, data: SSEEventData) { | 14 | + | emit(event: string, data: SSEEventData) { |
| 15 | - | const eventListeners = this.listeners.get(event); | 15 | + | const eventListeners = this.listeners.get(event); |
| 16 | - | if (eventListeners) { | 16 | + | if (eventListeners) { |
| 17 | - | // Only log non-sensitive event types | 17 | + | // Only log non-sensitive event types |
| 18 | - | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { | 18 | + | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { |
| 19 | - | console.log('[SSE-Emitter] Broadcasting event:', { type: data.type, listenersCount: eventListeners.length }); | 19 | + | console.log('[SSE-Emitter] Broadcasting event:', { |
| 20 | - | } | 20 | + | type: data.type, |
| 21 | - | eventListeners.forEach(listener => listener(data)); | 21 | + | listenersCount: eventListeners.length |
| 22 | - | } | 22 | + | }); |
| 23 | - | } | 23 | + | } |
| 24 | - | 24 | + | eventListeners.forEach((listener) => listener(data)); | |
| 25 | - | addListener(event: string, listener: (data: SSEEventData) => void) { | 25 | + | } |
| 26 | - | if (!this.listeners.has(event)) { | 26 | + | } |
| 27 | - | this.listeners.set(event, []); | 27 | + | |
| 28 | - | } | 28 | + | addListener(event: string, listener: (data: SSEEventData) => void) { |
| 29 | - | const eventListeners = this.listeners.get(event)!; | 29 | + | if (!this.listeners.has(event)) { |
| 30 | - | 30 | + | this.listeners.set(event, []); | |
| 31 | - | if (eventListeners.length >= this.maxListeners) { | 31 | + | } |
| 32 | - | console.log(`[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`); | 32 | + | const eventListeners = this.listeners.get(event)!; |
| 33 | - | } | 33 | + | |
| 34 | - | 34 | + | if (eventListeners.length >= this.maxListeners) { | |
| 35 | - | eventListeners.push(listener); | 35 | + | console.log( |
| 36 | - | console.log('[SSE-Emitter] New listener added:', { event, totalListeners: eventListeners.length }); | 36 | + | `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})` |
| 37 | - | } | 37 | + | ); |
| 38 | - | 38 | + | } | |
| 39 | - | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | 39 | + | |
| 40 | - | const eventListeners = this.listeners.get(event); | 40 | + | eventListeners.push(listener); |
| 41 | - | if (eventListeners) { | 41 | + | console.log('[SSE-Emitter] New listener added:', { |
| 42 | - | const newListeners = eventListeners.filter(listener => listener !== listenerToRemove); | 42 | + | event, |
| 43 | - | this.listeners.set(event, newListeners); | 43 | + | totalListeners: eventListeners.length |
| 44 | - | console.log('[SSE-Emitter] Listener removed:', { event, remainingListeners: newListeners.length }); | 44 | + | }); |
| 45 | - | } | 45 | + | } |
| 46 | - | } | 46 | + | |
| 47 | - | 47 | + | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | |
| 48 | - | setMaxListeners(n: number) { | 48 | + | const eventListeners = this.listeners.get(event); |
| 49 | - | this.maxListeners = n; | 49 | + | if (eventListeners) { |
| 50 | - | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | 50 | + | const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove); |
| 51 | - | } | 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 +731 -717
| @@ -1,728 +1,742 @@ | |||||
| 1 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | 1 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | ||
| 2 | import { createSafeUser } from '../types/chat'; | 2 | import { createSafeUser } from '../types/chat'; | ||
| 3 | - | import type { | 3 | + | import type { |
| 4 | - | SendMessageRequest, | 4 | + | SendMessageRequest, |
| 5 | - | SendMessageResponse, | 5 | + | SendMessageResponse, |
| 6 | - | GetMessagesResponse | 6 | + | GetMessagesResponse |
| 7 | } from '../types/payloads'; | 7 | } from '../types/payloads'; | ||
| 8 | + | import type { TextStyle } from '../types/text-formatting'; | |||
| 8 | import { invalidate } from '$app/navigation'; | 9 | import { invalidate } from '$app/navigation'; | ||
| 9 | import { env } from '$env/dynamic/public'; | 10 | import { env } from '$env/dynamic/public'; | ||
| 10 | 11 | ||||
| 11 | // Use the public environment variable with a fallback | 12 | // 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'; | 13 | + | const DEFAULT_CHAT_ROOM_ID = |
| 14 | + | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |||
| 13 | 15 | ||||
| 14 | class ChatState { | 16 | class ChatState { | ||
| 15 | - | private users = $state<SafeUser[]>([]); | 17 | + | private users = $state<SafeUser[]>([]); |
| 16 | - | private messages = $state<Message[]>([]); | 18 | + | private messages = $state<Message[]>([]); |
| 17 | - | private currentUser = $state<SafeUser | null>(null); | 19 | + | private currentUser = $state<SafeUser | null>(null); |
| 18 | - | private eventSource: EventSource | null = null; | 20 | + | private eventSource: EventSource | null = null; |
| 19 | - | private reconnectAttempts = 0; | 21 | + | private reconnectAttempts = 0; |
| 20 | - | private maxReconnectAttempts = 5; | 22 | + | private maxReconnectAttempts = 5; |
| 21 | - | private reconnectDelay = 1000; // Start with 1 second | 23 | + | private reconnectDelay = 1000; // Start with 1 second |
| 22 | - | private isInitializing = $state(false); | 24 | + | private isInitializing = $state(false); |
| 23 | - | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | 25 | + | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); |
| 24 | - | private userCache = $state<Record<string, SafeUser>>({}); | 26 | + | private userCache = $state<Record<string, SafeUser>>({}); |
| 25 | - | private sseError = $state<string | null>(null); | 27 | + | private sseError = $state<string | null>(null); |
| 26 | - | private sseRetryAfter = $state<number | null>(null); | 28 | + | private sseRetryAfter = $state<number | null>(null); |
| 27 | - | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | 29 | + | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; |
| 28 | - | private isReconnecting = $state(false); | 30 | + | private isReconnecting = $state(false); |
| 29 | - | private isSettingUser = $state(false); | 31 | + | private isSettingUser = $state(false); |
| 30 | - | private isConnecting = $state(false); | 32 | + | private isConnecting = $state(false); |
| 31 | - | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | 33 | + | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; |
| 32 | - | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | 34 | + | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; |
| 33 | - | private _lastEnrichmentKey: string = ''; | 35 | + | private _lastEnrichmentKey: string = ''; |
| 34 | - | // Flag to avoid re-registering SSE event handlers | 36 | + | // Flag to avoid re-registering SSE event handlers |
| 35 | - | private sseHandlersRegistered = false; | 37 | + | private sseHandlersRegistered = false; |
| 36 | - | private lastBuddyListUpdate = 0; | 38 | + | private lastBuddyListUpdate = 0; |
| 37 | - | private lastBuddyListHash = ''; | 39 | + | private lastBuddyListHash = ''; |
| 38 | - | private hasMoreMessages = $state(false); | 40 | + | private hasMoreMessages = $state(false); |
| 39 | - | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | 41 | + | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; |
| 40 | - | 42 | + | ||
| 41 | - | public async reinitialize() { | 43 | + | public async reinitialize() { |
| 42 | - | if (this.isInitializing) { | 44 | + | if (this.isInitializing) { |
| 43 | - | console.debug('Reinitialize already in progress, skipping.'); | 45 | + | console.debug('Reinitialize already in progress, skipping.'); |
| 44 | - | return; | 46 | + | return; |
| 45 | - | } | 47 | + | } |
| 46 | - | this.isInitializing = true; | 48 | + | this.isInitializing = true; |
| 47 | - | console.debug('Reinitializing chat state...'); | 49 | + | console.debug('Reinitializing chat state...'); |
| 48 | - | if (this.eventSource) { | 50 | + | if (this.eventSource) { |
| 49 | - | this.eventSource.close(); | 51 | + | this.eventSource.close(); |
| 50 | - | this.eventSource = null; | 52 | + | this.eventSource = null; |
| 51 | - | } | 53 | + | } |
| 52 | - | this.reconnectAttempts = 0; | 54 | + | this.reconnectAttempts = 0; |
| 53 | - | this.reconnectDelay = 1000; | 55 | + | this.reconnectDelay = 1000; |
| 54 | - | this.messages = []; | 56 | + | this.messages = []; |
| 55 | - | // Ensure currentRoomId is set | 57 | + | // Ensure currentRoomId is set |
| 56 | - | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | 58 | + | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; |
| 57 | - | 59 | + | ||
| 58 | - | if (this.currentUser) { | 60 | + | if (this.currentUser) { |
| 59 | - | console.debug('Waiting for session cookie update before setting up SSE...'); | 61 | + | console.debug('Waiting for session cookie update before setting up SSE...'); |
| 60 | - | await new Promise(resolve => setTimeout(resolve, 1000)); | 62 | + | await new Promise((resolve) => setTimeout(resolve, 1000)); |
| 61 | - | // First set up SSE to receive status updates | 63 | + | // First set up SSE to receive status updates |
| 62 | - | this.setupSSE(); | 64 | + | this.setupSSE(); |
| 63 | - | // Then initialize messages and room users | 65 | + | // Then initialize messages and room users |
| 64 | - | await Promise.all([ | 66 | + | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); |
| 65 | - | this.initializeMessages(), | 67 | + | } |
| 66 | - | this.initializeRoomUsers() | 68 | + | this.isInitializing = false; |
| 67 | - | ]); | 69 | + | } |
| 68 | - | } | 70 | + | |
| 69 | - | this.isInitializing = false; | 71 | + | getSSEError() { |
| 70 | - | } | 72 | + | return { |
| 71 | - | 73 | + | error: this.sseError, | |
| 72 | - | getSSEError() { | 74 | + | retryAfter: this.sseRetryAfter |
| 73 | - | return { | 75 | + | }; |
| 74 | - | error: this.sseError, | 76 | + | } |
| 75 | - | retryAfter: this.sseRetryAfter | 77 | + | |
| 76 | - | }; | 78 | + | // User methods |
| 77 | - | } | 79 | + | getCurrentUser() { |
| 78 | - | 80 | + | return this.currentUser; | |
| 79 | - | // User methods | 81 | + | } |
| 80 | - | getCurrentUser() { | 82 | + | |
| 81 | - | return this.currentUser; | 83 | + | async setCurrentUser(user: User | SafeUser | null) { |
| 82 | - | } | 84 | + | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); |
| 83 | - | 85 | + | ||
| 84 | - | async setCurrentUser(user: User | SafeUser | null) { | 86 | + | // Prevent concurrent setCurrentUser calls |
| 85 | - | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | 87 | + | if (this.isSettingUser) { |
| 86 | - | 88 | + | console.debug('Already setting user, skipping'); | |
| 87 | - | // Prevent concurrent setCurrentUser calls | 89 | + | return; |
| 88 | - | if (this.isSettingUser) { | 90 | + | } |
| 89 | - | console.debug('Already setting user, skipping'); | 91 | + | |
| 90 | - | return; | 92 | + | this.isSettingUser = true; |
| 91 | - | } | 93 | + | |
| 92 | - | 94 | + | try { | |
| 93 | - | this.isSettingUser = true; | 95 | + | // If user is null and we already have no user, skip |
| 94 | - | 96 | + | if (!user && !this.currentUser) { | |
| 95 | - | try { | 97 | + | console.debug('No user to set and no current user, skipping'); |
| 96 | - | // If user is null and we already have no user, skip | 98 | + | return; |
| 97 | - | if (!user && !this.currentUser) { | 99 | + | } |
| 98 | - | console.debug('No user to set and no current user, skipping'); | 100 | + | |
| 99 | - | return; | 101 | + | // Convert to SafeUser if full User object is passed |
| 100 | - | } | 102 | + | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; |
| 101 | - | 103 | + | ||
| 102 | - | // Convert to SafeUser if full User object is passed | 104 | + | // If same user is being set, check SSE connection |
| 103 | - | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | 105 | + | if (safeUser?.id === this.currentUser?.id) { |
| 104 | - | 106 | + | if (!this.eventSource) { | |
| 105 | - | // If same user is being set, check SSE connection | 107 | + | console.debug('Same user being set but no active SSE connection, reinitializing.'); |
| 106 | - | if (safeUser?.id === this.currentUser?.id) { | 108 | + | } else { |
| 107 | - | if (!this.eventSource) { | 109 | + | console.debug('Same user being set with active connection, skipping.'); |
| 108 | - | console.debug('Same user being set but no active SSE connection, reinitializing.'); | 110 | + | return; |
| 109 | - | } else { | 111 | + | } |
| 110 | - | console.debug('Same user being set with active connection, skipping.'); | 112 | + | } |
| 111 | - | return; | 113 | + | |
| 112 | - | } | 114 | + | // Clear existing messages before setting new user |
| 113 | - | } | 115 | + | this.messages = []; |
| 114 | - | 116 | + | this.currentUser = safeUser; | |
| 115 | - | // Clear existing messages before setting new user | 117 | + | |
| 116 | - | this.messages = []; | 118 | + | if (safeUser) { |
| 117 | - | this.currentUser = safeUser; | 119 | + | // Update user cache with current user |
| 118 | - | 120 | + | this.userCache[safeUser.id] = safeUser; | |
| 119 | - | if (safeUser) { | 121 | + | |
| 120 | - | // Update user cache with current user | 122 | + | // Only reinitialize if we have a valid user |
| 121 | - | this.userCache[safeUser.id] = safeUser; | 123 | + | await this.reinitialize(); |
| 122 | - | 124 | + | } else { | |
| 123 | - | // Only reinitialize if we have a valid user | 125 | + | // Cleanup when user is removed |
| 124 | - | await this.reinitialize(); | 126 | + | if (this.eventSource) { |
| 125 | - | } else { | 127 | + | this.eventSource.close(); |
| 126 | - | // Cleanup when user is removed | 128 | + | this.eventSource = null; |
| 127 | - | if (this.eventSource) { | 129 | + | } |
| 128 | - | this.eventSource.close(); | 130 | + | this.messages = []; |
| 129 | - | this.eventSource = null; | 131 | + | this.users = []; |
| 130 | - | } | 132 | + | this.userCache = {}; |
| 131 | - | this.messages = []; | 133 | + | } |
| 132 | - | this.users = []; | 134 | + | } finally { |
| 133 | - | this.userCache = {}; | 135 | + | this.isSettingUser = false; |
| 134 | - | } | 136 | + | } |
| 135 | - | } finally { | 137 | + | } |
| 136 | - | this.isSettingUser = false; | 138 | + | |
| 137 | - | } | 139 | + | private async initializeRoomUsers() { |
| 138 | - | } | 140 | + | console.debug('Initializing room users for room:', this.currentRoomId); |
| 139 | - | 141 | + | try { | |
| 140 | - | private async initializeRoomUsers() { | 142 | + | const isPublic = !this.currentUser; |
| 141 | - | console.debug('Initializing room users for room:', this.currentRoomId); | 143 | + | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; |
| 142 | - | try { | 144 | + | console.debug('Fetching room users from:', url); |
| 143 | - | const isPublic = !this.currentUser; | 145 | + | |
| 144 | - | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | 146 | + | const response = await fetch(url, { |
| 145 | - | console.debug('Fetching room users from:', url); | 147 | + | credentials: 'include' |
| 146 | - | 148 | + | }); | |
| 147 | - | const response = await fetch(url, { | 149 | + | |
| 148 | - | credentials: 'include' | 150 | + | if (!response.ok) { |
| 149 | - | }); | 151 | + | throw new Error('Failed to fetch room users'); |
| 150 | - | 152 | + | } | |
| 151 | - | if (!response.ok) { | 153 | + | |
| 152 | - | throw new Error('Failed to fetch room users'); | 154 | + | const data = await response.json(); |
| 153 | - | } | 155 | + | if (!data.success) { |
| 154 | - | 156 | + | throw new Error(data.error); | |
| 155 | - | const data = await response.json(); | 157 | + | } |
| 156 | - | if (!data.success) { | 158 | + | |
| 157 | - | throw new Error(data.error); | 159 | + | console.debug('Fetched room buddy list:', data.buddyList); |
| 158 | - | } | 160 | + | // Merge fetched buddy list with existing cached users to preserve user details even if offline |
| 159 | - | 161 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state | |
| 160 | - | console.debug('Fetched room buddy list:', data.buddyList); | 162 | + | const mergedUsers = new Map<string, (typeof data.buddyList)[0]>(); |
| 161 | - | // Merge fetched buddy list with existing cached users to preserve user details even if offline | 163 | + | // Add existing cached users |
| 162 | - | const mergedUsers = new Map<string, typeof data.buddyList[0]>(); | 164 | + | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); |
| 163 | - | // Add existing cached users | 165 | + | |
| 164 | - | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | 166 | + | // Overwrite/add with fetched buddy list |
| 165 | - | 167 | + | data.buddyList.forEach((user: (typeof data.buddyList)[0]) => { | |
| 166 | - | // Overwrite/add with fetched buddy list | 168 | + | mergedUsers.set(user.id, user); |
| 167 | - | data.buddyList.forEach((user: typeof data.buddyList[0]) => { | 169 | + | }); |
| 168 | - | mergedUsers.set(user.id, user); | 170 | + | |
| 169 | - | }); | 171 | + | this.users = Array.from(mergedUsers.values()); |
| 170 | - | 172 | + | ||
| 171 | - | this.users = Array.from(mergedUsers.values()); | 173 | + | // Update cache with merged users |
| 172 | - | 174 | + | this.users.forEach((user) => { | |
| 173 | - | // Update cache with merged users | 175 | + | this.userCache[user.id] = user; |
| 174 | - | this.users.forEach(user => { | 176 | + | }); |
| 175 | - | this.userCache[user.id] = user; | 177 | + | } catch (error) { |
| 176 | - | }); | 178 | + | console.debug('Error fetching room users:', error); |
| 177 | - | } catch (error) { | 179 | + | this.users = []; |
| 178 | - | console.debug('Error fetching room users:', error); | 180 | + | } |
| 179 | - | this.users = []; | 181 | + | } |
| 180 | - | } | 182 | + | |
| 181 | - | } | 183 | + | getOnlineUsers() { |
| 182 | - | 184 | + | // Sort users by status: online first, then away, then busy, then offline | |
| 183 | - | getOnlineUsers() { | 185 | + | return [...this.users].sort((a: SafeUser, b: SafeUser) => { |
| 184 | - | // Sort users by status: online first, then away, then busy, then offline | 186 | + | const statusOrder: Record<SafeUser['status'], number> = { |
| 185 | - | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | 187 | + | online: 0, |
| 186 | - | const statusOrder: Record<SafeUser['status'], number> = { | 188 | + | away: 1, |
| 187 | - | 'online': 0, | 189 | + | busy: 2, |
| 188 | - | 'away': 1, | 190 | + | offline: 3 |
| 189 | - | 'busy': 2, | 191 | + | }; |
| 190 | - | 'offline': 3 | 192 | + | return statusOrder[a.status] - statusOrder[b.status]; |
| 191 | - | }; | 193 | + | }); |
| 192 | - | return statusOrder[a.status] - statusOrder[b.status]; | 194 | + | } |
| 193 | - | }); | 195 | + | |
| 194 | - | } | 196 | + | getUserById(userId: string) { |
| 195 | - | 197 | + | // First check the cache | |
| 196 | - | getUserById(userId: string) { | 198 | + | if (this.userCache[userId]) { |
| 197 | - | // First check the cache | 199 | + | return this.userCache[userId]; |
| 198 | - | if (this.userCache[userId]) { | 200 | + | } |
| 199 | - | return this.userCache[userId]; | 201 | + | |
| 200 | - | } | 202 | + | // Return a fallback object and cache it to avoid repeated fetches |
| 201 | - | 203 | + | const fallback: SafeUser = { | |
| 202 | - | // Return a fallback object and cache it to avoid repeated fetches | 204 | + | id: userId, |
| 203 | - | const fallback: SafeUser = { | 205 | + | nickname: 'Unknown User', |
| 204 | - | id: userId, | 206 | + | status: 'offline', |
| 205 | - | nickname: 'Unknown User', | 207 | + | lastSeen: null, |
| 206 | - | status: 'offline', | 208 | + | avatarUrl: null |
| 207 | - | lastSeen: null, | 209 | + | }; |
| 208 | - | avatarUrl: null | 210 | + | this.userCache[userId] = fallback; |
| 209 | - | }; | 211 | + | console.debug('getUserById: returning fallback for missing user', { userId, fallback }); |
| 210 | - | this.userCache[userId] = fallback; | 212 | + | return fallback; |
| 211 | - | console.debug('getUserById: returning fallback for missing user', { userId, fallback }); | 213 | + | } |
| 212 | - | return fallback; | 214 | + | |
| 213 | - | } | 215 | + | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { |
| 214 | - | 216 | + | console.debug('Updating user status with:', { | |
| 215 | - | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | 217 | + | userId, |
| 216 | - | console.debug('Updating user status with:', { | 218 | + | status, |
| 217 | - | userId, | 219 | + | lastSeen, |
| 218 | - | status, | 220 | + | currentCache: this.userCache[userId], |
| 219 | - | lastSeen, | 221 | + | currentUsers: this.users.find((u) => u.id === userId) |
| 220 | - | currentCache: this.userCache[userId], | 222 | + | }); |
| 221 | - | currentUsers: this.users.find(u => u.id === userId) | 223 | + | |
| 222 | - | }); | 224 | + | const existingUser = this.userCache[userId]; |
| 223 | - | 225 | + | if ( | |
| 224 | - | const existingUser = this.userCache[userId]; | 226 | + | existingUser && |
| 225 | - | if ( | 227 | + | existingUser.status === status && |
| 226 | - | existingUser && | 228 | + | lastSeen && |
| 227 | - | existingUser.status === status && | 229 | + | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 |
| 228 | - | lastSeen && | 230 | + | ) { |
| 229 | - | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | 231 | + | console.debug('Skipping redundant status update for user:', userId); |
| 230 | - | ) { | 232 | + | return; |
| 231 | - | console.debug('Skipping redundant status update for user:', userId); | 233 | + | } |
| 232 | - | return; | 234 | + | |
| 233 | - | } | 235 | + | // Try to get the user from cache first |
| 234 | - | 236 | + | let user: SafeUser | undefined = this.userCache[userId]; | |
| 235 | - | // Try to get the user from cache first | 237 | + | |
| 236 | - | let user: SafeUser | undefined = this.userCache[userId]; | 238 | + | // If not in cache, try to find in users array |
| 237 | - | 239 | + | if (!user) { | |
| 238 | - | // If not in cache, try to find in users array | 240 | + | const foundUser = this.users.find((u: SafeUser) => u.id === userId); |
| 239 | - | if (!user) { | 241 | + | if (foundUser) { |
| 240 | - | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | 242 | + | user = foundUser; |
| 241 | - | if (foundUser) { | 243 | + | } |
| 242 | - | user = foundUser; | 244 | + | } |
| 243 | - | } | 245 | + | |
| 244 | - | } | 246 | + | const now = Date.now(); |
| 245 | - | 247 | + | if (user) { | |
| 246 | - | const now = Date.now(); | 248 | + | const updatedUser: SafeUser = { |
| 247 | - | if (user) { | 249 | + | ...user, |
| 248 | - | const updatedUser: SafeUser = { | 250 | + | status, |
| 249 | - | ...user, | 251 | + | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) |
| 250 | - | status, | 252 | + | }; |
| 251 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | 253 | + | |
| 252 | - | }; | 254 | + | // Update cache first |
| 253 | - | 255 | + | this.userCache[userId] = updatedUser; | |
| 254 | - | // Update cache first | 256 | + | |
| 255 | - | this.userCache[userId] = updatedUser; | 257 | + | // Then update or add to users array |
| 256 | - | 258 | + | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 257 | - | // Then update or add to users array | 259 | + | if (index !== -1) { |
| 258 | - | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | 260 | + | // Create new array with updated user |
| 259 | - | if (index !== -1) { | 261 | + | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; |
| 260 | - | // Create new array with updated user | 262 | + | } else { |
| 261 | - | this.users = [ | 263 | + | this.users = [...this.users, updatedUser]; |
| 262 | - | ...this.users.slice(0, index), | 264 | + | } |
| 263 | - | updatedUser, | 265 | + | |
| 264 | - | ...this.users.slice(index + 1) | 266 | + | console.debug('User status updated:', { |
| 265 | - | ]; | 267 | + | userId, |
| 266 | - | } else { | 268 | + | newStatus: status, |
| 267 | - | this.users = [...this.users, updatedUser]; | 269 | + | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), |
| 268 | - | } | 270 | + | updatedInCache: this.userCache[userId].status === status, |
| 269 | - | 271 | + | updatedInList: this.users.find((u) => u.id === userId)?.status === status | |
| 270 | - | console.debug('User status updated:', { | 272 | + | }); |
| 271 | - | userId, | 273 | + | } else { |
| 272 | - | newStatus: status, | 274 | + | // If user not found anywhere, fetch from API |
| 273 | - | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), | 275 | + | console.debug('User not found in cache or list, fetching from API:', userId); |
| 274 | - | updatedInCache: this.userCache[userId].status === status, | 276 | + | fetch(`/api/users/${userId}`, { credentials: 'include' }) |
| 275 | - | updatedInList: this.users.find(u => u.id === userId)?.status === status | 277 | + | .then((response) => response.json()) |
| 276 | - | }); | 278 | + | .then((data) => { |
| 277 | - | } else { | 279 | + | if (data.success && data.user) { |
| 278 | - | // If user not found anywhere, fetch from API | 280 | + | const newUser: SafeUser = { |
| 279 | - | console.debug('User not found in cache or list, fetching from API:', userId); | 281 | + | ...data.user, |
| 280 | - | fetch(`/api/users/${userId}`, { credentials: 'include' }) | 282 | + | status, |
| 281 | - | .then(response => response.json()) | 283 | + | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) |
| 282 | - | .then(data => { | 284 | + | }; |
| 283 | - | if (data.success && data.user) { | 285 | + | // Update both cache and users array |
| 284 | - | const newUser: SafeUser = { | 286 | + | this.userCache[userId] = newUser; |
| 285 | - | ...data.user, | 287 | + | this.users = [...this.users, newUser]; |
| 286 | - | status, | 288 | + | |
| 287 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | 289 | + | console.debug('User fetched and status updated:', { |
| 288 | - | }; | 290 | + | userId, |
| 289 | - | // Update both cache and users array | 291 | + | status: newUser.status, |
| 290 | - | this.userCache[userId] = newUser; | 292 | + | lastSeen: new Date(newUser.lastSeen || now).toISOString(), |
| 291 | - | this.users = [...this.users, newUser]; | 293 | + | addedToCache: !!this.userCache[userId], |
| 292 | - | 294 | + | addedToList: this.users.some((u) => u.id === userId) | |
| 293 | - | console.debug('User fetched and status updated:', { | 295 | + | }); |
| 294 | - | userId, | 296 | + | } |
| 295 | - | status: newUser.status, | 297 | + | }) |
| 296 | - | lastSeen: new Date(newUser.lastSeen || now).toISOString(), | 298 | + | .catch((error) => console.error('Error fetching user data:', error)); |
| 297 | - | addedToCache: !!this.userCache[userId], | 299 | + | } |
| 298 | - | addedToList: this.users.some(u => u.id === userId) | 300 | + | } |
| 299 | - | }); | 301 | + | |
| 300 | - | } | 302 | + | // Message methods |
| 301 | - | }) | 303 | + | getMessages() { |
| 302 | - | .catch(error => console.error('Error fetching user data:', error)); | 304 | + | const key = |
| 303 | - | } | 305 | + | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); |
| 304 | - | } | 306 | + | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { |
| 305 | - | 307 | + | return this._memoizedEnrichedMessages; | |
| 306 | - | // Message methods | 308 | + | } |
| 307 | - | getMessages() { | 309 | + | this._lastEnrichmentKey = key; |
| 308 | - | const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | 310 | + | console.debug('Enriching messages with user data', { |
| 309 | - | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { | 311 | + | messagesCount: this.messages.length, |
| 310 | - | return this._memoizedEnrichedMessages; | 312 | + | userCacheSize: Object.keys(this.userCache).length |
| 311 | - | } | 313 | + | }); |
| 312 | - | this._lastEnrichmentKey = key; | 314 | + | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); |
| 313 | - | console.debug('Enriching messages with user data', { | 315 | + | return this._memoizedEnrichedMessages; |
| 314 | - | messagesCount: this.messages.length, | 316 | + | } |
| 315 | - | userCacheSize: Object.keys(this.userCache).length | 317 | + | |
| 316 | - | }); | 318 | + | public prependMessages(newMessages: Message[], hasMore?: boolean) { |
| 317 | - | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | 319 | + | console.debug('Prepending messages:', { count: newMessages.length }); |
| 318 | - | return this._memoizedEnrichedMessages; | 320 | + | // Get unique user IDs from new messages that aren't in the cache |
| 319 | - | } | 321 | + | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); |
| 320 | - | 322 | + | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 321 | - | public prependMessages(newMessages: Message[], hasMore?: boolean) { | 323 | + | |
| 322 | - | console.debug('Prepending messages:', { count: newMessages.length }); | 324 | + | // Fetch missing user data in parallel |
| 323 | - | // Get unique user IDs from new messages that aren't in the cache | 325 | + | if (missingUserIds.length > 0) { |
| 324 | - | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | 326 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); |
| 325 | - | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | 327 | + | Promise.all( |
| 326 | - | 328 | + | missingUserIds.map(async (userId) => { | |
| 327 | - | // Fetch missing user data in parallel | 329 | + | try { |
| 328 | - | if (missingUserIds.length > 0) { | 330 | + | const userResponse = await fetch(`/api/users/${userId}`, { |
| 329 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | 331 | + | credentials: 'include' |
| 330 | - | Promise.all(missingUserIds.map(async (userId) => { | 332 | + | }); |
| 331 | - | try { | 333 | + | if (userResponse.ok) { |
| 332 | - | const userResponse = await fetch(`/api/users/${userId}`, { | 334 | + | const userData = await userResponse.json(); |
| 333 | - | credentials: 'include' | 335 | + | if (userData.success && userData.user) { |
| 334 | - | }); | 336 | + | this.userCache[userId] = userData.user; |
| 335 | - | if (userResponse.ok) { | 337 | + | // Only add to users array if not already present |
| 336 | - | const userData = await userResponse.json(); | 338 | + | if (!this.users.some((u) => u.id === userId)) { |
| 337 | - | if (userData.success && userData.user) { | 339 | + | this.users = [...this.users, userData.user]; |
| 338 | - | this.userCache[userId] = userData.user; | 340 | + | } |
| 339 | - | // Only add to users array if not already present | 341 | + | } |
| 340 | - | if (!this.users.some(u => u.id === userId)) { | 342 | + | } |
| 341 | - | this.users = [...this.users, userData.user]; | 343 | + | } catch (error) { |
| 342 | - | } | 344 | + | console.debug('Error fetching user data:', { userId, error }); |
| 343 | - | } | 345 | + | } |
| 344 | - | } | 346 | + | }) |
| 345 | - | } catch (error) { | 347 | + | ); |
| 346 | - | console.debug('Error fetching user data:', { userId, error }); | 348 | + | } |
| 347 | - | } | 349 | + | |
| 348 | - | })); | 350 | + | // Ensure incoming messages are sorted in ascending order before prepending |
| 349 | - | } | 351 | + | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); |
| 350 | - | 352 | + | const uniqueNewMessages = sortedNewMessages.filter( | |
| 351 | - | // Ensure incoming messages are sorted in ascending order before prepending | 353 | + | (msg) => !this.messages.some((m) => m.id === msg.id) |
| 352 | - | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | 354 | + | ); |
| 353 | - | const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | 355 | + | this.messages = [...uniqueNewMessages, ...this.messages]; |
| 354 | - | this.messages = [...uniqueNewMessages, ...this.messages]; | 356 | + | |
| 355 | - | 357 | + | // Update hasMore if provided | |
| 356 | - | // Update hasMore if provided | 358 | + | if (hasMore !== undefined) { |
| 357 | - | if (hasMore !== undefined) { | 359 | + | this.hasMoreMessages = hasMore; |
| 358 | - | this.hasMoreMessages = hasMore; | 360 | + | } |
| 359 | - | } | 361 | + | } |
| 360 | - | } | 362 | + | |
| 361 | - | 363 | + | // Add method to update user cache for public access | |
| 362 | - | // Add method to update user cache for public access | 364 | + | updateUserCache(users: (User | SafeUser)[]) { |
| 363 | - | updateUserCache(users: (User | SafeUser)[]) { | 365 | + | console.debug( |
| 364 | - | console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' }))); | 366 | + | 'Updating user cache with users:', |
| 365 | - | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | 367 | + | users.map((u) => ({ ...u, password: '[REDACTED]' })) |
| 366 | - | users.forEach(user => { | 368 | + | ); |
| 367 | - | const safeUser = 'password' in user ? createSafeUser(user) : user; | 369 | + | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects |
| 368 | - | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | 370 | + | users.forEach((user) => { |
| 369 | - | this.userCache[user.id] = safeUser; | 371 | + | const safeUser = 'password' in user ? createSafeUser(user) : user; |
| 370 | - | // Update users array if not already present or if status changed | 372 | + | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { |
| 371 | - | const existingIndex = this.users.findIndex(u => u.id === user.id); | 373 | + | this.userCache[user.id] = safeUser; |
| 372 | - | if (existingIndex === -1) { | 374 | + | // Update users array if not already present or if status changed |
| 373 | - | this.users = [...this.users, safeUser]; | 375 | + | const existingIndex = this.users.findIndex((u) => u.id === user.id); |
| 374 | - | } else if (safeUser.status !== this.users[existingIndex].status) { | 376 | + | if (existingIndex === -1) { |
| 375 | - | this.users = [ | 377 | + | this.users = [...this.users, safeUser]; |
| 376 | - | ...this.users.slice(0, existingIndex), | 378 | + | } else if (safeUser.status !== this.users[existingIndex].status) { |
| 377 | - | safeUser, | 379 | + | this.users = [ |
| 378 | - | ...this.users.slice(existingIndex + 1) | 380 | + | ...this.users.slice(0, existingIndex), |
| 379 | - | ]; | 381 | + | safeUser, |
| 380 | - | } | 382 | + | ...this.users.slice(existingIndex + 1) |
| 381 | - | } | 383 | + | ]; |
| 382 | - | }); | 384 | + | } |
| 383 | - | } | 385 | + | } |
| 384 | - | 386 | + | }); | |
| 385 | - | // Add method to update online users for public access | 387 | + | } |
| 386 | - | updateOnlineUsers(users: (User | SafeUser)[]) { | 388 | + | |
| 387 | - | const now = Date.now(); | 389 | + | // Add method to update online users for public access |
| 388 | - | // Convert all users to SafeUser and create a hash | 390 | + | updateOnlineUsers(users: (User | SafeUser)[]) { |
| 389 | - | const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user); | 391 | + | const now = Date.now(); |
| 390 | - | const newHash = JSON.stringify(safeUsers); | 392 | + | // Convert all users to SafeUser and create a hash |
| 391 | - | 393 | + | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | |
| 392 | - | // Only update if the data has changed | 394 | + | const newHash = JSON.stringify(safeUsers); |
| 393 | - | if (newHash !== this.lastBuddyListHash) { | 395 | + | |
| 394 | - | const statusCounts = safeUsers.reduce((acc, user) => { | 396 | + | // Only update if the data has changed |
| 395 | - | acc[user.status] = (acc[user.status] || 0) + 1; | 397 | + | if (newHash !== this.lastBuddyListHash) { |
| 396 | - | return acc; | 398 | + | const statusCounts = safeUsers.reduce( |
| 397 | - | }, {} as Record<string, number>); | 399 | + | (acc, user) => { |
| 398 | - | 400 | + | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 399 | - | console.debug('Updating online users (data changed):', { | 401 | + | return acc; |
| 400 | - | total: safeUsers.length, | 402 | + | }, |
| 401 | - | byStatus: statusCounts, | 403 | + | {} as Record<string, number> |
| 402 | - | timestamp: new Date(now).toISOString() | 404 | + | ); |
| 403 | - | }); | 405 | + | |
| 404 | - | 406 | + | console.debug('Updating online users (data changed):', { | |
| 405 | - | this.users = safeUsers; | 407 | + | total: safeUsers.length, |
| 406 | - | // Also update cache | 408 | + | byStatus: statusCounts, |
| 407 | - | safeUsers.forEach(user => { | 409 | + | timestamp: new Date(now).toISOString() |
| 408 | - | this.userCache[user.id] = user; | 410 | + | }); |
| 409 | - | }); | 411 | + | |
| 410 | - | this.lastBuddyListHash = newHash; | 412 | + | this.users = safeUsers; |
| 411 | - | this.lastBuddyListUpdate = now; | 413 | + | // Also update cache |
| 412 | - | } | 414 | + | safeUsers.forEach((user) => { |
| 413 | - | } | 415 | + | this.userCache[user.id] = user; |
| 414 | - | 416 | + | }); | |
| 415 | - | // Add method to update messages for public access | 417 | + | this.lastBuddyListHash = newHash; |
| 416 | - | updateMessages(messages: Message[]) { | 418 | + | this.lastBuddyListUpdate = now; |
| 417 | - | // Deduplicate the messages by their id in case duplicates exist | 419 | + | } |
| 418 | - | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | 420 | + | } |
| 419 | - | // Sort messages in ascending order (oldest first, newest at bottom) | 421 | + | |
| 420 | - | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | 422 | + | // Add method to update messages for public access |
| 421 | - | } | 423 | + | updateMessages(messages: Message[]) { |
| 422 | - | 424 | + | // Deduplicate the messages by their id in case duplicates exist | |
| 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. | 425 | + | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); |
| 424 | - | async initializeMessages() { | 426 | + | // Sort messages in ascending order (oldest first, newest at bottom) |
| 425 | - | try { | 427 | + | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); |
| 426 | - | // Build the URL with appropriate parameters | 428 | + | } |
| 427 | - | const params = new URLSearchParams(); | 429 | + | |
| 428 | - | if (!this.currentUser) { | 430 | + | // 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 | - | params.append('public', 'true'); | 431 | + | async initializeMessages() { |
| 430 | - | } | 432 | + | try { |
| 431 | - | params.append('roomId', this.currentRoomId); | 433 | + | // Build the URL with appropriate parameters |
| 432 | - | 434 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state | |
| 433 | - | const response = await fetch(`/api/chat/messages?${params}`, { | 435 | + | const params = new URLSearchParams(); |
| 434 | - | credentials: 'include' | 436 | + | if (!this.currentUser) { |
| 435 | - | }); | 437 | + | params.append('public', 'true'); |
| 436 | - | if (!response.ok) throw new Error('Failed to fetch messages'); | 438 | + | } |
| 437 | - | 439 | + | params.append('roomId', this.currentRoomId); | |
| 438 | - | const data = await response.json() as GetMessagesResponse; | 440 | + | |
| 439 | - | if (!data.success) { | 441 | + | const response = await fetch(`/api/chat/messages?${params}`, { |
| 440 | - | throw new Error(data.error); | 442 | + | credentials: 'include' |
| 441 | - | } | 443 | + | }); |
| 442 | - | 444 | + | if (!response.ok) throw new Error('Failed to fetch messages'); | |
| 443 | - | // Get unique user IDs from messages that aren't in the cache | 445 | + | |
| 444 | - | const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId)); | 446 | + | const data = (await response.json()) as GetMessagesResponse; |
| 445 | - | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | 447 | + | if (!data.success) { |
| 446 | - | 448 | + | throw new Error(data.error); | |
| 447 | - | // Fetch missing user data in parallel | 449 | + | } |
| 448 | - | if (missingUserIds.length > 0) { | 450 | + | |
| 449 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | 451 | + | // Get unique user IDs from messages that aren't in the cache |
| 450 | - | await Promise.all(missingUserIds.map(async (userId) => { | 452 | + | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); |
| 451 | - | try { | 453 | + | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); |
| 452 | - | const userResponse = await fetch(`/api/users/${userId}`, { | 454 | + | |
| 453 | - | credentials: 'include' | 455 | + | // Fetch missing user data in parallel |
| 454 | - | }); | 456 | + | if (missingUserIds.length > 0) { |
| 455 | - | if (userResponse.ok) { | 457 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); |
| 456 | - | const userData = await userResponse.json(); | 458 | + | await Promise.all( |
| 457 | - | if (userData.success && userData.user) { | 459 | + | missingUserIds.map(async (userId) => { |
| 458 | - | this.userCache[userId] = userData.user; | 460 | + | try { |
| 459 | - | // Only add to users array if not already present | 461 | + | const userResponse = await fetch(`/api/users/${userId}`, { |
| 460 | - | if (!this.users.some(u => u.id === userId)) { | 462 | + | credentials: 'include' |
| 461 | - | this.users = [...this.users, userData.user]; | 463 | + | }); |
| 462 | - | } | 464 | + | if (userResponse.ok) { |
| 463 | - | } | 465 | + | const userData = await userResponse.json(); |
| 464 | - | } | 466 | + | if (userData.success && userData.user) { |
| 465 | - | } catch (error) { | 467 | + | this.userCache[userId] = userData.user; |
| 466 | - | console.debug('Error fetching user data:', { userId, error }); | 468 | + | // Only add to users array if not already present |
| 467 | - | } | 469 | + | if (!this.users.some((u) => u.id === userId)) { |
| 468 | - | })); | 470 | + | this.users = [...this.users, userData.user]; |
| 469 | - | } | 471 | + | } |
| 470 | - | 472 | + | } | |
| 471 | - | // Sort messages in ascending order (oldest first, most recent at the bottom) | 473 | + | } |
| 472 | - | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | 474 | + | } catch (error) { |
| 473 | - | 475 | + | console.debug('Error fetching user data:', { userId, error }); | |
| 474 | - | // Update hasMore based on response | 476 | + | } |
| 475 | - | if ('hasMore' in data) { | 477 | + | }) |
| 476 | - | this.hasMoreMessages = data.hasMore; | 478 | + | ); |
| 477 | - | } | 479 | + | } |
| 478 | - | 480 | + | ||
| 479 | - | console.debug('Messages initialized:', { | 481 | + | // Sort messages in ascending order (oldest first, most recent at the bottom) |
| 480 | - | count: data.messages.length, | 482 | + | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); |
| 481 | - | isAuthenticated: !!this.currentUser, | 483 | + | |
| 482 | - | hasMore: this.hasMoreMessages | 484 | + | // Update hasMore based on response |
| 483 | - | }); | 485 | + | if ('hasMore' in data) { |
| 484 | - | } catch (error) { | 486 | + | this.hasMoreMessages = data.hasMore; |
| 485 | - | console.debug('Error fetching initial messages:', error); | 487 | + | } |
| 486 | - | this.messages = []; | 488 | + | |
| 487 | - | this.hasMoreMessages = false; | 489 | + | console.debug('Messages initialized:', { |
| 488 | - | } | 490 | + | count: data.messages.length, |
| 489 | - | } | 491 | + | isAuthenticated: !!this.currentUser, |
| 490 | - | 492 | + | hasMore: this.hasMoreMessages | |
| 491 | - | async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> { | 493 | + | }); |
| 492 | - | const user = this.getCurrentUser(); | 494 | + | } catch (error) { |
| 493 | - | if (!user) { | 495 | + | console.debug('Error fetching initial messages:', error); |
| 494 | - | console.debug('Cannot send message: No current user'); | 496 | + | this.messages = []; |
| 495 | - | return { | 497 | + | this.hasMoreMessages = false; |
| 496 | - | success: false, | 498 | + | } |
| 497 | - | error: 'Not logged in' | 499 | + | } |
| 498 | - | }; | 500 | + | |
| 499 | - | } | 501 | + | async sendMessage( |
| 500 | - | 502 | + | content: string, | |
| 501 | - | try { | 503 | + | type: Message['type'] = 'chat', |
| 502 | - | const payload: SendMessageRequest = { | 504 | + | textStyle?: TextStyle |
| 503 | - | content, | 505 | + | ): Promise<SendMessageResponse> { |
| 504 | - | type, | 506 | + | const user = this.getCurrentUser(); |
| 505 | - | userId: user.id, | 507 | + | if (!user) { |
| 506 | - | chatRoomId: DEFAULT_CHAT_ROOM_ID | 508 | + | console.debug('Cannot send message: No current user'); |
| 507 | - | }; | 509 | + | return { |
| 508 | - | 510 | + | success: false, | |
| 509 | - | // Add styleData if textStyle is provided | 511 | + | error: 'Not logged in' |
| 510 | - | if (textStyle) { | 512 | + | }; |
| 511 | - | (payload as any).styleData = JSON.stringify(textStyle); | 513 | + | } |
| 512 | - | } | 514 | + | |
| 513 | - | 515 | + | try { | |
| 514 | - | console.debug('Sending message with payload:', payload); | 516 | + | const payload: SendMessageRequest = { |
| 515 | - | 517 | + | content, | |
| 516 | - | const response = await fetch('/api/chat/messages', { | 518 | + | type, |
| 517 | - | method: 'POST', | 519 | + | userId: user.id, |
| 518 | - | headers: { | 520 | + | chatRoomId: DEFAULT_CHAT_ROOM_ID, |
| 519 | - | 'Content-Type': 'application/json', | 521 | + | styleData: textStyle ? JSON.stringify(textStyle) : undefined |
| 520 | - | }, | 522 | + | }; |
| 521 | - | credentials: 'include', | 523 | + | |
| 522 | - | body: JSON.stringify(payload) | 524 | + | console.debug('Sending message with payload:', payload); |
| 523 | - | }); | 525 | + | |
| 524 | - | 526 | + | const response = await fetch('/api/chat/messages', { | |
| 525 | - | if (!response.ok) { | 527 | + | method: 'POST', |
| 526 | - | const errorData = await response.json(); | 528 | + | headers: { |
| 527 | - | throw new Error(errorData.error || 'Failed to save message'); | 529 | + | 'Content-Type': 'application/json' |
| 528 | - | } | 530 | + | }, |
| 529 | - | 531 | + | credentials: 'include', | |
| 530 | - | const data = await response.json() as SendMessageResponse; | 532 | + | body: JSON.stringify(payload) |
| 531 | - | if (!data.success) { | 533 | + | }); |
| 532 | - | throw new Error(data.error); | 534 | + | |
| 533 | - | } | 535 | + | if (!response.ok) { |
| 534 | - | 536 | + | const errorData = await response.json(); | |
| 535 | - | console.debug('Message sent successfully:', data.message); | 537 | + | throw new Error(errorData.error || 'Failed to save message'); |
| 536 | - | await invalidate('chat:messages'); | 538 | + | } |
| 537 | - | return data; | 539 | + | |
| 538 | - | } catch (error) { | 540 | + | const data = (await response.json()) as SendMessageResponse; |
| 539 | - | console.debug('Error sending message:', error); | 541 | + | if (!data.success) { |
| 540 | - | return { | 542 | + | throw new Error(data.error); |
| 541 | - | success: false, | 543 | + | } |
| 542 | - | error: 'Failed to send message' | 544 | + | |
| 543 | - | }; | 545 | + | console.debug('Message sent successfully:', data.message); |
| 544 | - | } | 546 | + | await invalidate('chat:messages'); |
| 545 | - | } | 547 | + | return data; |
| 546 | - | 548 | + | } catch (error) { | |
| 547 | - | private setupSSE() { | 549 | + | console.debug('Error sending message:', error); |
| 548 | - | // Prevent creating multiple SSE connections if one is already active. | 550 | + | return { |
| 549 | - | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | 551 | + | success: false, |
| 550 | - | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | 552 | + | error: 'Failed to send message' |
| 551 | - | return; | 553 | + | }; |
| 552 | - | } | 554 | + | } |
| 553 | - | // Clear any existing timeouts | 555 | + | } |
| 554 | - | if (this.connectionTimeout) { | 556 | + | |
| 555 | - | clearTimeout(this.connectionTimeout); | 557 | + | private setupSSE() { |
| 556 | - | this.connectionTimeout = null; | 558 | + | // Prevent creating multiple SSE connections if one is already active. |
| 557 | - | } | 559 | + | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { |
| 558 | - | 560 | + | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 559 | - | if (this.connectionAttemptTimeout) { | 561 | + | return; |
| 560 | - | clearTimeout(this.connectionAttemptTimeout); | 562 | + | } |
| 561 | - | this.connectionAttemptTimeout = null; | 563 | + | // Clear any existing timeouts |
| 562 | - | } | 564 | + | if (this.connectionTimeout) { |
| 563 | - | 565 | + | clearTimeout(this.connectionTimeout); | |
| 564 | - | // Debounce connection attempts | 566 | + | this.connectionTimeout = null; |
| 565 | - | this.connectionAttemptTimeout = setTimeout(() => { | 567 | + | } |
| 566 | - | if (!this.currentUser) { | 568 | + | |
| 567 | - | console.debug('No current user, skipping SSE connection'); | 569 | + | if (this.connectionAttemptTimeout) { |
| 568 | - | return; | 570 | + | clearTimeout(this.connectionAttemptTimeout); |
| 569 | - | } | 571 | + | this.connectionAttemptTimeout = null; |
| 570 | - | 572 | + | } | |
| 571 | - | if (this.isConnecting) { | 573 | + | |
| 572 | - | console.debug('Already connecting to SSE, skipping'); | 574 | + | // Debounce connection attempts |
| 573 | - | return; | 575 | + | this.connectionAttemptTimeout = setTimeout(() => { |
| 574 | - | } | 576 | + | if (!this.currentUser) { |
| 575 | - | 577 | + | console.debug('No current user, skipping SSE connection'); | |
| 576 | - | this.connectSSE(); | 578 | + | return; |
| 577 | - | }, 1000); | 579 | + | } |
| 578 | - | } | 580 | + | |
| 579 | - | 581 | + | if (this.isConnecting) { | |
| 580 | - | private async connectSSE() { | 582 | + | console.debug('Already connecting to SSE, skipping'); |
| 581 | - | if (this.isConnecting) { | 583 | + | return; |
| 582 | - | console.debug('Already connecting to SSE, skipping'); | 584 | + | } |
| 583 | - | return; | 585 | + | |
| 584 | - | } | 586 | + | this.connectSSE(); |
| 585 | - | 587 | + | }, 1000); | |
| 586 | - | this.isConnecting = true; | 588 | + | } |
| 587 | - | 589 | + | ||
| 588 | - | try { | 590 | + | private async connectSSE() { |
| 589 | - | // Ensure clean disconnect of any existing connection | 591 | + | if (this.isConnecting) { |
| 590 | - | if (this.eventSource) { | 592 | + | console.debug('Already connecting to SSE, skipping'); |
| 591 | - | console.debug('Closing existing SSE connection'); | 593 | + | return; |
| 592 | - | this.eventSource.close(); | 594 | + | } |
| 593 | - | this.eventSource = null; | 595 | + | |
| 594 | - | } | 596 | + | this.isConnecting = true; |
| 595 | - | 597 | + | ||
| 596 | - | console.debug('Connecting to SSE...'); | 598 | + | try { |
| 597 | - | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | 599 | + | // Ensure clean disconnect of any existing connection |
| 598 | - | // Reset the handlers flag for the new connection | 600 | + | if (this.eventSource) { |
| 599 | - | this.sseHandlersRegistered = false; | 601 | + | console.debug('Closing existing SSE connection'); |
| 600 | - | 602 | + | this.eventSource.close(); | |
| 601 | - | this.eventSource.onopen = () => { | 603 | + | this.eventSource = null; |
| 602 | - | console.debug('SSE connection established'); | 604 | + | } |
| 603 | - | this.reconnectAttempts = 0; | 605 | + | |
| 604 | - | this.reconnectDelay = 1000; | 606 | + | console.debug('Connecting to SSE...'); |
| 605 | - | this.sseError = null; | 607 | + | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); |
| 606 | - | this.sseRetryAfter = null; | 608 | + | // Reset the handlers flag for the new connection |
| 607 | - | this.isReconnecting = false; | 609 | + | this.sseHandlersRegistered = false; |
| 608 | - | }; | 610 | + | |
| 609 | - | 611 | + | this.eventSource.onopen = () => { | |
| 610 | - | // Set up error handling | 612 | + | console.debug('SSE connection established'); |
| 611 | - | this.eventSource.onerror = (error) => { | 613 | + | this.reconnectAttempts = 0; |
| 612 | - | console.debug('SSE connection error:', error); | 614 | + | this.reconnectDelay = 1000; |
| 613 | - | if (this.eventSource?.readyState === EventSource.CLOSED) { | 615 | + | this.sseError = null; |
| 614 | - | this.handleDisconnection(); | 616 | + | this.sseRetryAfter = null; |
| 615 | - | } | 617 | + | this.isReconnecting = false; |
| 616 | - | }; | 618 | + | }; |
| 617 | - | 619 | + | ||
| 618 | - | // Register event handlers (only once) | 620 | + | // Set up error handling |
| 619 | - | this.setupMessageHandlers(); | 621 | + | this.eventSource.onerror = (error) => { |
| 620 | - | 622 | + | console.debug('SSE connection error:', error); | |
| 621 | - | } catch (error) { | 623 | + | if (this.eventSource?.readyState === EventSource.CLOSED) { |
| 622 | - | console.debug('Error setting up SSE connection:', error); | 624 | + | this.handleDisconnection(); |
| 623 | - | this.handleDisconnection(); | 625 | + | } |
| 624 | - | } finally { | 626 | + | }; |
| 625 | - | this.isConnecting = false; | 627 | + | |
| 626 | - | } | 628 | + | // Register event handlers (only once) |
| 627 | - | } | 629 | + | this.setupMessageHandlers(); |
| 628 | - | 630 | + | } catch (error) { | |
| 629 | - | // Register SSE event handlers only once per connection. | 631 | + | console.debug('Error setting up SSE connection:', error); |
| 630 | - | private setupMessageHandlers() { | 632 | + | this.handleDisconnection(); |
| 631 | - | if (this.sseHandlersRegistered || !this.eventSource) { | 633 | + | } finally { |
| 632 | - | return; | 634 | + | this.isConnecting = false; |
| 633 | - | } | 635 | + | } |
| 634 | - | 636 | + | } | |
| 635 | - | // Listen for chat messages | 637 | + | |
| 636 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | 638 | + | // Register SSE event handlers only once per connection. |
| 637 | - | try { | 639 | + | private setupMessageHandlers() { |
| 638 | - | const messageData = JSON.parse(event.data) as Message; | 640 | + | if (this.sseHandlersRegistered || !this.eventSource) { |
| 639 | - | console.debug('Received chat message via SSE:', messageData); | 641 | + | return; |
| 640 | - | // Deduplicate and update messages array with the new message | 642 | + | } |
| 641 | - | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | 643 | + | |
| 642 | - | // Sort messages so the newest are at the bottom | 644 | + | // Listen for chat messages |
| 643 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | 645 | + | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { |
| 644 | - | // Ensure the sender's data is available | 646 | + | try { |
| 645 | - | await this.ensureUserData(messageData.senderId); | 647 | + | const messageData = JSON.parse(event.data) as Message; |
| 646 | - | } catch (error) { | 648 | + | console.debug('Received chat message via SSE:', messageData); |
| 647 | - | console.debug('Error handling SSE chat message:', error); | 649 | + | // Deduplicate and update messages array with the new message |
| 648 | - | } | 650 | + | this.messages = Array.from( |
| 649 | - | }); | 651 | + | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() |
| 650 | - | 652 | + | ); | |
| 651 | - | // Modify buddy list update handler | 653 | + | // Sort messages so the newest are at the bottom |
| 652 | - | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | 654 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); |
| 653 | - | try { | 655 | + | // Ensure the sender's data is available |
| 654 | - | const buddyList = JSON.parse(event.data) as SafeUser[]; | 656 | + | await this.ensureUserData(messageData.senderId); |
| 655 | - | this.updateOnlineUsers(buddyList); | 657 | + | } catch (error) { |
| 656 | - | } catch (error) { | 658 | + | console.debug('Error handling SSE chat message:', error); |
| 657 | - | console.debug('Error handling buddy list update via SSE:', error); | 659 | + | } |
| 658 | - | } | 660 | + | }); |
| 659 | - | }); | 661 | + | |
| 660 | - | 662 | + | // Modify buddy list update handler | |
| 661 | - | this.sseHandlersRegistered = true; | 663 | + | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { |
| 662 | - | } | 664 | + | try { |
| 663 | - | 665 | + | const buddyList = JSON.parse(event.data) as SafeUser[]; | |
| 664 | - | private async ensureUserData(userId: string) { | 666 | + | this.updateOnlineUsers(buddyList); |
| 665 | - | if (this.userCache[userId]) return; | 667 | + | } catch (error) { |
| 666 | - | 668 | + | console.debug('Error handling buddy list update via SSE:', error); | |
| 667 | - | console.debug('Fetching user data for:', userId); | 669 | + | } |
| 668 | - | try { | 670 | + | }); |
| 669 | - | const response = await fetch(`/api/users/${userId}`, { | 671 | + | |
| 670 | - | credentials: 'include' | 672 | + | this.sseHandlersRegistered = true; |
| 671 | - | }); | 673 | + | } |
| 672 | - | if (response.ok) { | 674 | + | |
| 673 | - | const userData = await response.json(); | 675 | + | private async ensureUserData(userId: string) { |
| 674 | - | if (userData.success && userData.user) { | 676 | + | if (this.userCache[userId]) return; |
| 675 | - | this.userCache[userId] = userData.user; | 677 | + | |
| 676 | - | if (!this.users.some(u => u.id === userId)) { | 678 | + | console.debug('Fetching user data for:', userId); |
| 677 | - | this.users = [...this.users, userData.user]; | 679 | + | try { |
| 678 | - | } | 680 | + | const response = await fetch(`/api/users/${userId}`, { |
| 679 | - | } | 681 | + | credentials: 'include' |
| 680 | - | } | 682 | + | }); |
| 681 | - | } catch (error) { | 683 | + | if (response.ok) { |
| 682 | - | console.debug('Error fetching user data:', error); | 684 | + | const userData = await response.json(); |
| 683 | - | } | 685 | + | if (userData.success && userData.user) { |
| 684 | - | } | 686 | + | this.userCache[userId] = userData.user; |
| 685 | - | 687 | + | if (!this.users.some((u) => u.id === userId)) { | |
| 686 | - | private handleDisconnection() { | 688 | + | this.users = [...this.users, userData.user]; |
| 687 | - | console.debug('SSE connection closed, reconnecting...'); | 689 | + | } |
| 688 | - | this.isReconnecting = true; | 690 | + | } |
| 689 | - | this.reconnectAttempts++; | 691 | + | } |
| 690 | - | if (this.reconnectAttempts > this.maxReconnectAttempts) { | 692 | + | } catch (error) { |
| 691 | - | console.debug('Max reconnect attempts reached, giving up'); | 693 | + | console.debug('Error fetching user data:', error); |
| 692 | - | if (this.eventSource) { | 694 | + | } |
| 693 | - | this.eventSource.close(); | 695 | + | } |
| 694 | - | } | 696 | + | |
| 695 | - | this.eventSource = null; | 697 | + | private handleDisconnection() { |
| 696 | - | return; | 698 | + | console.debug('SSE connection closed, reconnecting...'); |
| 697 | - | } | 699 | + | this.isReconnecting = true; |
| 698 | - | this.reconnectDelay *= 2; // Exponential backoff | 700 | + | this.reconnectAttempts++; |
| 699 | - | // Ensure any existing connection is properly closed before reconnecting | 701 | + | if (this.reconnectAttempts > this.maxReconnectAttempts) { |
| 700 | - | if (this.eventSource) { | 702 | + | console.debug('Max reconnect attempts reached, giving up'); |
| 701 | - | this.eventSource.close(); | 703 | + | if (this.eventSource) { |
| 702 | - | this.eventSource = null; | 704 | + | this.eventSource.close(); |
| 703 | - | } | 705 | + | } |
| 704 | - | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. | 706 | + | this.eventSource = null; |
| 705 | - | this.sseHandlersRegistered = false; | 707 | + | return; |
| 706 | - | this.setupSSE(); | 708 | + | } |
| 707 | - | } | 709 | + | this.reconnectDelay *= 2; // Exponential backoff |
| 708 | - | 710 | + | // Ensure any existing connection is properly closed before reconnecting | |
| 709 | - | enrichMessages(messages: Message[]): EnrichedMessage[] { | 711 | + | if (this.eventSource) { |
| 710 | - | console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length }); | 712 | + | this.eventSource.close(); |
| 711 | - | return messages.map(message => ({ | 713 | + | this.eventSource = null; |
| 712 | - | ...message, | 714 | + | } |
| 713 | - | user: this.userCache[message.senderId] || { | 715 | + | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. |
| 714 | - | id: message.senderId, | 716 | + | this.sseHandlersRegistered = false; |
| 715 | - | nickname: 'Unknown User', | 717 | + | this.setupSSE(); |
| 716 | - | status: 'offline', | 718 | + | } |
| 717 | - | avatarUrl: null, | 719 | + | |
| 718 | - | lastSeen: null | 720 | + | enrichMessages(messages: Message[]): EnrichedMessage[] { |
| 719 | - | } | 721 | + | console.debug('Enriching messages with user data', { |
| 720 | - | })); | 722 | + | messagesCount: messages.length, |
| 721 | - | } | 723 | + | userCacheSize: Object.keys(this.userCache).length |
| 722 | - | 724 | + | }); | |
| 723 | - | getDefaultChatRoomId() { | 725 | + | return messages.map((message) => ({ |
| 724 | - | return DEFAULT_CHAT_ROOM_ID; | 726 | + | ...message, |
| 725 | - | } | 727 | + | user: this.userCache[message.senderId] || { |
| 728 | + | id: message.senderId, | |||
| 729 | + | nickname: 'Unknown User', | |||
| 730 | + | status: 'offline', | |||
| 731 | + | avatarUrl: null, | |||
| 732 | + | lastSeen: null | |||
| 733 | + | } | |||
| 734 | + | })); | |||
| 735 | + | } | |||
| 736 | + | ||||
| 737 | + | getDefaultChatRoomId() { | |||
| 738 | + | return DEFAULT_CHAT_ROOM_ID; | |||
| 739 | + | } | |||
| 726 | } | 740 | } | ||
| 727 | 741 | ||||
| 728 | - | export const chatState = new ChatState();⏎̸ | 742 | + | export const chatState = new ChatState(); |
M src/lib/styles/fonts.css +50 -50
| @@ -6,95 +6,95 @@ | |||||
| 6 | 6 | ||||
| 7 | /* Tahoma - Primary UI font */ | 7 | /* Tahoma - Primary UI font */ | ||
| 8 | @font-face { | 8 | @font-face { | ||
| 9 | - | font-family: 'Tahoma Web'; | 9 | + | font-family: 'Tahoma Web'; |
| 10 | - | src: local('Tahoma'), local('Tahoma Regular'); | 10 | + | src: local('Tahoma'), local('Tahoma Regular'); |
| 11 | - | font-weight: normal; | 11 | + | font-weight: normal; |
| 12 | - | font-style: normal; | 12 | + | font-style: normal; |
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | @font-face { | 15 | @font-face { | ||
| 16 | - | font-family: 'Tahoma Web'; | 16 | + | font-family: 'Tahoma Web'; |
| 17 | - | src: local('Tahoma Bold'); | 17 | + | src: local('Tahoma Bold'); |
| 18 | - | font-weight: bold; | 18 | + | font-weight: bold; |
| 19 | - | font-style: normal; | 19 | + | font-style: normal; |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | /* Verdana - Secondary UI font */ | 22 | /* Verdana - Secondary UI font */ | ||
| 23 | @font-face { | 23 | @font-face { | ||
| 24 | - | font-family: 'Verdana Web'; | 24 | + | font-family: 'Verdana Web'; |
| 25 | - | src: local('Verdana'), local('Verdana Regular'); | 25 | + | src: local('Verdana'), local('Verdana Regular'); |
| 26 | - | font-weight: normal; | 26 | + | font-weight: normal; |
| 27 | - | font-style: normal; | 27 | + | font-style: normal; |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | @font-face { | 30 | @font-face { | ||
| 31 | - | font-family: 'Verdana Web'; | 31 | + | font-family: 'Verdana Web'; |
| 32 | - | src: local('Verdana Bold'); | 32 | + | src: local('Verdana Bold'); |
| 33 | - | font-weight: bold; | 33 | + | font-weight: bold; |
| 34 | - | font-style: normal; | 34 | + | font-style: normal; |
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | /* Arial - General purpose */ | 37 | /* Arial - General purpose */ | ||
| 38 | @font-face { | 38 | @font-face { | ||
| 39 | - | font-family: 'Arial Web'; | 39 | + | font-family: 'Arial Web'; |
| 40 | - | src: local('Arial'), local('Arial Regular'); | 40 | + | src: local('Arial'), local('Arial Regular'); |
| 41 | - | font-weight: normal; | 41 | + | font-weight: normal; |
| 42 | - | font-style: normal; | 42 | + | font-style: normal; |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | @font-face { | 45 | @font-face { | ||
| 46 | - | font-family: 'Arial Web'; | 46 | + | font-family: 'Arial Web'; |
| 47 | - | src: local('Arial Bold'); | 47 | + | src: local('Arial Bold'); |
| 48 | - | font-weight: bold; | 48 | + | font-weight: bold; |
| 49 | - | font-style: normal; | 49 | + | font-style: normal; |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | /* Times New Roman - Serif font */ | 52 | /* Times New Roman - Serif font */ | ||
| 53 | @font-face { | 53 | @font-face { | ||
| 54 | - | font-family: 'Times New Roman Web'; | 54 | + | font-family: 'Times New Roman Web'; |
| 55 | - | src: local('Times New Roman'), local('Times New Roman Regular'); | 55 | + | src: local('Times New Roman'), local('Times New Roman Regular'); |
| 56 | - | font-weight: normal; | 56 | + | font-weight: normal; |
| 57 | - | font-style: normal; | 57 | + | font-style: normal; |
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | @font-face { | 60 | @font-face { | ||
| 61 | - | font-family: 'Times New Roman Web'; | 61 | + | font-family: 'Times New Roman Web'; |
| 62 | - | src: local('Times New Roman Bold'); | 62 | + | src: local('Times New Roman Bold'); |
| 63 | - | font-weight: bold; | 63 | + | font-weight: bold; |
| 64 | - | font-style: normal; | 64 | + | font-style: normal; |
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | /* Courier New - Monospace */ | 67 | /* Courier New - Monospace */ | ||
| 68 | @font-face { | 68 | @font-face { | ||
| 69 | - | font-family: 'Courier New Web'; | 69 | + | font-family: 'Courier New Web'; |
| 70 | - | src: local('Courier New'), local('Courier New Regular'); | 70 | + | src: local('Courier New'), local('Courier New Regular'); |
| 71 | - | font-weight: normal; | 71 | + | font-weight: normal; |
| 72 | - | font-style: normal; | 72 | + | font-style: normal; |
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | @font-face { | 75 | @font-face { | ||
| 76 | - | font-family: 'Courier New Web'; | 76 | + | font-family: 'Courier New Web'; |
| 77 | - | src: local('Courier New Bold'); | 77 | + | src: local('Courier New Bold'); |
| 78 | - | font-weight: bold; | 78 | + | font-weight: bold; |
| 79 | - | font-style: normal; | 79 | + | font-style: normal; |
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | /* Comic Sans MS - Fun font */ | 82 | /* Comic Sans MS - Fun font */ | ||
| 83 | @font-face { | 83 | @font-face { | ||
| 84 | - | font-family: 'Comic Sans MS Web'; | 84 | + | font-family: 'Comic Sans MS Web'; |
| 85 | - | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | 85 | + | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); |
| 86 | - | font-weight: normal; | 86 | + | font-weight: normal; |
| 87 | - | font-style: normal; | 87 | + | font-style: normal; |
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | @font-face { | 90 | @font-face { | ||
| 91 | - | font-family: 'Comic Sans MS Web'; | 91 | + | font-family: 'Comic Sans MS Web'; |
| 92 | - | src: local('Comic Sans MS Bold'); | 92 | + | src: local('Comic Sans MS Bold'); |
| 93 | - | font-weight: bold; | 93 | + | font-weight: bold; |
| 94 | - | font-style: normal; | 94 | + | font-style: normal; |
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | /* Ensure font-display for better performance */ | 97 | /* Ensure font-display for better performance */ | ||
| 98 | @font-face { | 98 | @font-face { | ||
| 99 | - | font-display: swap; | 99 | + | font-display: swap; |
| 100 | - | }⏎̸ | 100 | + | } |
M src/lib/types/chat.ts +59 -58
| @@ -1,62 +1,63 @@ | |||||
| 1 | // Users table: holds authentication and profile details | 1 | // Users table: holds authentication and profile details | ||
| 2 | export interface User { | 2 | export interface User { | ||
| 3 | - | id: string; | 3 | + | id: string; |
| 4 | - | password: string; // hashed password | 4 | + | password: string; // hashed password |
| 5 | - | nickname: string; | 5 | + | nickname: string; |
| 6 | - | status: UserStatus; // default is 'offline' | 6 | + | status: UserStatus; // default is 'offline' |
| 7 | - | avatarUrl?: string | null; | 7 | + | avatarUrl?: string | null; |
| 8 | - | createdAt: number; // Timestamp (e.g., Unix timestamp) | 8 | + | createdAt: number; // Timestamp (e.g., Unix timestamp) |
| 9 | - | lastSeen?: number | null; // Optional last active timestamp | 9 | + | lastSeen?: number | null; // Optional last active timestamp |
| 10 | - | } | 10 | + | } |
| 11 | 11 | ||||
| 12 | export type UserStatus = 'offline' | 'online' | 'away' | string; | 12 | export type UserStatus = 'offline' | 'online' | 'away' | string; | ||
| 13 | - | 13 | + | ||
| 14 | - | // Chat rooms table: supports both direct (private) and group chat conversations | 14 | + | // Chat rooms table: supports both direct (private) and group chat conversations |
| 15 | - | export interface ChatRoom { | 15 | + | export interface ChatRoom { |
| 16 | - | id: string; | 16 | + | id: string; |
| 17 | - | name?: string; // Optional for direct chats, required for groups | 17 | + | name?: string; // Optional for direct chats, required for groups |
| 18 | - | type: 'direct' | 'group' | string; // Default is 'direct' | 18 | + | type: 'direct' | 'group' | string; // Default is 'direct' |
| 19 | - | createdAt: number; // Creation timestamp | 19 | + | createdAt: number; // Creation timestamp |
| 20 | - | } | 20 | + | } |
| 21 | - | 21 | + | ||
| 22 | - | // Messages table: stores the conversation messages | 22 | + | // Messages table: stores the conversation messages |
| 23 | - | export interface Message { | 23 | + | export interface Message { |
| 24 | - | id: string; | 24 | + | id: string; |
| 25 | - | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | 25 | + | chatRoomId: string; // Reference to the chat room (ChatRoom.id) |
| 26 | - | senderId: string; // Reference to the sender (User.id) | 26 | + | senderId: string; // Reference to the sender (User.id) |
| 27 | - | content: string; | 27 | + | content: string; |
| 28 | - | type: MessageType; // Default is 'chat' | 28 | + | type: MessageType; // Default is 'chat' |
| 29 | - | timestamp: number; // Timestamp when the message was sent | 29 | + | timestamp: number; // Timestamp when the message was sent |
| 30 | - | styleData?: string; // JSON string of TextStyle object for formatting | 30 | + | styleData?: string; // JSON string of TextStyle object for formatting |
| 31 | - | hasFormatting?: boolean; // Flag to indicate if message has custom formatting | 31 | + | hasFormatting?: boolean; // Flag to indicate if message has custom formatting |
| 32 | - | } | 32 | + | } |
| 33 | - | 33 | + | ||
| 34 | - | // Safe user type for API responses - omits sensitive information | 34 | + | // Safe user type for API responses - omits sensitive information |
| 35 | - | export type SafeUser = Omit<User, 'password' | 'createdAt'>; | 35 | + | export type SafeUser = Omit<User, 'password' | 'createdAt'>; |
| 36 | - | 36 | + | ||
| 37 | - | // Helper function to create a safe user object | 37 | + | // Helper function to create a safe user object |
| 38 | - | export function createSafeUser(user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'>): SafeUser { | 38 | + | export function createSafeUser( |
| 39 | - | const safeUser: SafeUser = { | 39 | + | user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'> |
| 40 | - | id: user.id, | 40 | + | ): SafeUser { |
| 41 | - | nickname: user.nickname, | 41 | + | const safeUser: SafeUser = { |
| 42 | - | status: user.status, | 42 | + | id: user.id, |
| 43 | - | avatarUrl: user.avatarUrl, | 43 | + | nickname: user.nickname, |
| 44 | - | lastSeen: user.lastSeen ?? 0 | 44 | + | status: user.status, |
| 45 | - | }; | 45 | + | avatarUrl: user.avatarUrl, |
| 46 | - | return safeUser; | 46 | + | lastSeen: user.lastSeen ?? 0 |
| 47 | - | } | 47 | + | }; |
| 48 | - | 48 | + | return safeUser; | |
| 49 | - | // Update EnrichedMessage to use SafeUser | 49 | + | } |
| 50 | - | export interface EnrichedMessage extends Message { | 50 | + | |
| 51 | - | user: SafeUser; | 51 | + | // Update EnrichedMessage to use SafeUser |
| 52 | - | } | 52 | + | export interface EnrichedMessage extends Message { |
| 53 | - | 53 | + | user: SafeUser; | |
| 54 | - | export type MessageType = 'chat' | 'emote' | 'system' | string; | 54 | + | } |
| 55 | - | 55 | + | ||
| 56 | - | export interface Session { | 56 | + | export type MessageType = 'chat' | 'emote' | 'system' | string; |
| 57 | - | id: string; | 57 | + | |
| 58 | - | userId: string; | 58 | + | export interface Session { |
| 59 | - | expiresAt: number; | 59 | + | id: string; |
| 60 | - | createdAt: number; | 60 | + | userId: string; |
| 61 | - | } | 61 | + | expiresAt: number; |
| 62 | - | 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 | // Login | 3 | // Login | ||
| 4 | export interface LoginResponseSuccess { | 4 | export interface LoginResponseSuccess { | ||
| @@ -40,36 +40,36 @@ export interface SessionResponseError { | |||||
| 40 | 40 | ||||
| 41 | // Messages | 41 | // Messages | ||
| 42 | export interface SendMessageRequest { | 42 | export interface SendMessageRequest { | ||
| 43 | - | content: string; | 43 | + | content: string; |
| 44 | - | type?: Message['type']; | 44 | + | type?: Message['type']; |
| 45 | - | userId: string; | 45 | + | userId: string; |
| 46 | - | chatRoomId?: string; | 46 | + | chatRoomId?: string; |
| 47 | - | styleData?: string; // JSON stringified TextStyle | 47 | + | styleData?: string; // JSON stringified TextStyle |
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | export interface SendMessageResponseSuccess { | 50 | export interface SendMessageResponseSuccess { | ||
| 51 | - | success: true; | 51 | + | success: true; |
| 52 | - | message: Message; | 52 | + | message: Message; |
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | export interface SendMessageResponseError { | 55 | export interface SendMessageResponseError { | ||
| 56 | - | success: false; | 56 | + | success: false; |
| 57 | - | error: string; | 57 | + | error: string; |
| 58 | - | retryAfter?: number; // Time in ms until next allowed message | 58 | + | retryAfter?: number; // Time in ms until next allowed message |
| 59 | - | isRateLimited?: boolean; | 59 | + | isRateLimited?: boolean; |
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | 62 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | ||
| 63 | 63 | ||||
| 64 | export interface GetMessagesResponseSuccess { | 64 | export interface GetMessagesResponseSuccess { | ||
| 65 | - | success: true; | 65 | + | success: true; |
| 66 | - | messages: Message[]; | 66 | + | messages: Message[]; |
| 67 | - | hasMore: boolean; | 67 | + | hasMore: boolean; |
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | export interface GetMessagesResponseError { | 70 | export interface GetMessagesResponseError { | ||
| 71 | - | success: false; | 71 | + | success: false; |
| 72 | - | error: string; | 72 | + | error: string; |
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError; | 75 | export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError; | ||
| @@ -77,13 +77,13 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon | |||||
| 77 | // ----- Public Room Payloads ----- | 77 | // ----- Public Room Payloads ----- | ||
| 78 | 78 | ||||
| 79 | export interface PublicRoomResponseSuccess { | 79 | export interface PublicRoomResponseSuccess { | ||
| 80 | - | success: true; | 80 | + | success: true; |
| 81 | - | buddyList: SafeUser[]; | 81 | + | buddyList: SafeUser[]; |
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | export interface PublicRoomResponseError { | 84 | export interface PublicRoomResponseError { | ||
| 85 | - | success: false; | 85 | + | success: false; |
| 86 | - | error: string; | 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 | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | 4 | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | ||
| 5 | // Secondary fonts use local system fonts with proper fallbacks | 5 | // Secondary fonts use local system fonts with proper fallbacks | ||
| 6 | export const RETRO_FONTS = { | 6 | export const RETRO_FONTS = { | ||
| 7 | - | pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' }, | 7 | + | pixelated: { |
| 8 | - | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | 8 | + | name: 'Pixelated MS Sans Serif', |
| 9 | - | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | 9 | + | stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' |
| 10 | - | comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' }, | 10 | + | }, |
| 11 | - | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | 11 | + | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, |
| 12 | - | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | 12 | + | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, |
| 13 | - | courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' }, | 13 | + | comicSans: { |
| 14 | - | dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' } | 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 | } as const; | 27 | } as const; | ||
| 16 | 28 | ||||
| 17 | export type FontFamily = keyof typeof RETRO_FONTS; | 29 | export type FontFamily = keyof typeof RETRO_FONTS; | ||
| 18 | 30 | ||||
| 19 | // Generate 256-color web-safe palette ordered by hue | 31 | // Generate 256-color web-safe palette ordered by hue | ||
| 20 | export function generate256Colors(): string[] { | 32 | export function generate256Colors(): string[] { | ||
| 21 | - | const colors: string[] = []; | 33 | + | const colors: string[] = []; |
| 22 | - | 34 | + | ||
| 23 | - | // Start with grayscale colors | 35 | + | // Start with grayscale colors |
| 24 | - | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); | 36 | + | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); |
| 25 | - | 37 | + | ||
| 26 | - | // Then add colors in a more logical order by hue | 38 | + | // Then add colors in a more logical order by hue |
| 27 | - | // Red hues | 39 | + | // Red hues |
| 28 | - | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); | 40 | + | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); |
| 29 | - | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); | 41 | + | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); |
| 30 | - | 42 | + | ||
| 31 | - | // Orange hues | 43 | + | // Orange hues |
| 32 | - | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); | 44 | + | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); |
| 33 | - | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); | 45 | + | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); |
| 34 | - | 46 | + | ||
| 35 | - | // Yellow hues | 47 | + | // Yellow hues |
| 36 | - | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); | 48 | + | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); |
| 37 | - | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); | 49 | + | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); |
| 38 | - | 50 | + | ||
| 39 | - | // Green hues | 51 | + | // Green hues |
| 40 | - | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | 52 | + | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); |
| 41 | - | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | 53 | + | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); |
| 42 | - | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | 54 | + | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); |
| 43 | - | 55 | + | ||
| 44 | - | // Cyan hues | 56 | + | // Cyan hues |
| 45 | - | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | 57 | + | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); |
| 46 | - | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); | 58 | + | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); |
| 47 | - | colors.push('#336666', '#669999', '#99CCCC'); | 59 | + | colors.push('#336666', '#669999', '#99CCCC'); |
| 48 | - | 60 | + | ||
| 49 | - | // Blue hues | 61 | + | // Blue hues |
| 50 | - | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); | 62 | + | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); |
| 51 | - | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); | 63 | + | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); |
| 52 | - | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); | 64 | + | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); |
| 53 | - | 65 | + | ||
| 54 | - | // Purple hues | 66 | + | // Purple hues |
| 55 | - | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); | 67 | + | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); |
| 56 | - | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); | 68 | + | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); |
| 57 | - | colors.push('#663366', '#996699', '#CC99CC'); | 69 | + | colors.push('#663366', '#996699', '#CC99CC'); |
| 58 | - | 70 | + | ||
| 59 | - | // Pink hues | 71 | + | // Pink hues |
| 60 | - | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); | 72 | + | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); |
| 61 | - | colors.push('#FF3399', '#FF66CC', '#FF99CC'); | 73 | + | colors.push('#FF3399', '#FF66CC', '#FF99CC'); |
| 62 | - | 74 | + | ||
| 63 | - | // Browns | 75 | + | // Browns |
| 64 | - | colors.push('#663333', '#996633', '#CC9966', '#996666'); | 76 | + | colors.push('#663333', '#996633', '#CC9966', '#996666'); |
| 65 | - | 77 | + | ||
| 66 | - | // Now fill in any missing combinations to get full 216 web-safe colors | 78 | + | // Now fill in any missing combinations to get full 216 web-safe colors |
| 67 | - | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; | 79 | + | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; |
| 68 | - | const existingColors = new Set(colors); | 80 | + | const existingColors = new Set(colors); |
| 69 | - | 81 | + | ||
| 70 | - | for (const r of webSafeValues) { | 82 | + | for (const r of webSafeValues) { |
| 71 | - | for (const g of webSafeValues) { | 83 | + | for (const g of webSafeValues) { |
| 72 | - | for (const b of webSafeValues) { | 84 | + | for (const b of webSafeValues) { |
| 73 | - | const color = `#${r}${g}${b}`.toUpperCase(); | 85 | + | const color = `#${r}${g}${b}`.toUpperCase(); |
| 74 | - | if (!existingColors.has(color)) { | 86 | + | if (!existingColors.has(color)) { |
| 75 | - | colors.push(color); | 87 | + | colors.push(color); |
| 76 | - | } | 88 | + | } |
| 77 | - | } | 89 | + | } |
| 78 | - | } | 90 | + | } |
| 79 | - | } | 91 | + | } |
| 80 | - | 92 | + | ||
| 81 | - | return colors; | 93 | + | return colors; |
| 82 | } | 94 | } | ||
| 83 | 95 | ||||
| 84 | // Classic retro colors for backward compatibility | 96 | // Classic retro colors for backward compatibility | ||
| 85 | export const CLASSIC_COLORS = { | 97 | export const CLASSIC_COLORS = { | ||
| 86 | - | black: '#000000', | 98 | + | black: '#000000', |
| 87 | - | darkBlue: '#000080', | 99 | + | darkBlue: '#000080', |
| 88 | - | green: '#008000', | 100 | + | green: '#008000', |
| 89 | - | teal: '#008080', | 101 | + | teal: '#008080', |
| 90 | - | darkRed: '#800000', | 102 | + | darkRed: '#800000', |
| 91 | - | purple: '#800080', | 103 | + | purple: '#800080', |
| 92 | - | brown: '#808000', | 104 | + | brown: '#808000', |
| 93 | - | silver: '#C0C0C0', | 105 | + | silver: '#C0C0C0', |
| 94 | - | gray: '#808080', | 106 | + | gray: '#808080', |
| 95 | - | blue: '#0000FF', | 107 | + | blue: '#0000FF', |
| 96 | - | lime: '#00FF00', | 108 | + | lime: '#00FF00', |
| 97 | - | aqua: '#00FFFF', | 109 | + | aqua: '#00FFFF', |
| 98 | - | red: '#FF0000', | 110 | + | red: '#FF0000', |
| 99 | - | fuchsia: '#FF00FF', | 111 | + | fuchsia: '#FF00FF', |
| 100 | - | yellow: '#FFFF00', | 112 | + | yellow: '#FFFF00', |
| 101 | - | white: '#FFFFFF' | 113 | + | white: '#FFFFFF' |
| 102 | } as const; | 114 | } as const; | ||
| 103 | 115 | ||||
| 104 | export type ClassicColor = keyof typeof CLASSIC_COLORS; | 116 | export type ClassicColor = keyof typeof CLASSIC_COLORS; | ||
| 105 | 117 | ||||
| 106 | // Gradient presets inspired by AOL faders | 118 | // Gradient presets inspired by AOL faders | ||
| 107 | export const GRADIENT_PRESETS = { | 119 | export const GRADIENT_PRESETS = { | ||
| 108 | - | rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'], | 120 | + | rainbow: [ |
| 109 | - | fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'], | 121 | + | '#FF0000', |
| 110 | - | ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'], | 122 | + | '#FF8000', |
| 111 | - | sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'], | 123 | + | '#FFFF00', |
| 112 | - | neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'], | 124 | + | '#80FF00', |
| 113 | - | forest: ['#006400', '#228B22', '#32CD32', '#90EE90'], | 125 | + | '#00FF00', |
| 114 | - | royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD'] | 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 | } as const; | 140 | } as const; | ||
| 116 | 141 | ||||
| 117 | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | 142 | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | ||
| 118 | 143 | ||||
| 119 | // Text styling options | 144 | // Text styling options | ||
| 120 | export interface TextStyle { | 145 | export interface TextStyle { | ||
| 121 | - | fontFamily: FontFamily; | 146 | + | fontFamily: FontFamily; |
| 122 | - | fontSize: number; // in pixels | 147 | + | fontSize: number; // in pixels |
| 123 | - | color?: string; // hex color string | 148 | + | color?: string; // hex color string |
| 124 | - | gradient?: string[]; // array of 1-3 hex colors for gradients | 149 | + | gradient?: string[]; // array of 1-3 hex colors for gradients |
| 125 | - | bold: boolean; | 150 | + | bold: boolean; |
| 126 | - | italic: boolean; | 151 | + | italic: boolean; |
| 127 | - | underline: boolean; | 152 | + | underline: boolean; |
| 128 | - | strikethrough: boolean; | 153 | + | strikethrough: boolean; |
| 129 | } | 154 | } | ||
| 130 | 155 | ||||
| 131 | // Default text style | 156 | // Default text style | ||
| 132 | export const DEFAULT_TEXT_STYLE: TextStyle = { | 157 | export const DEFAULT_TEXT_STYLE: TextStyle = { | ||
| 133 | - | fontFamily: 'pixelated', | 158 | + | fontFamily: 'pixelated', |
| 134 | - | fontSize: 14, | 159 | + | fontSize: 14, |
| 135 | - | color: '#000000', | 160 | + | color: '#000000', |
| 136 | - | bold: false, | 161 | + | bold: false, |
| 137 | - | italic: false, | 162 | + | italic: false, |
| 138 | - | underline: false, | 163 | + | underline: false, |
| 139 | - | strikethrough: false | 164 | + | strikethrough: false |
| 140 | }; | 165 | }; | ||
| 141 | 166 | ||||
| 142 | // User preferences for text formatting | 167 | // User preferences for text formatting | ||
| 143 | export interface UserTextPreferences { | 168 | export interface UserTextPreferences { | ||
| 144 | - | defaultStyle: TextStyle; | 169 | + | defaultStyle: TextStyle; |
| 145 | - | allowFormatting: boolean; | 170 | + | allowFormatting: boolean; |
| 146 | - | maxMessageLength: number; | 171 | + | maxMessageLength: number; |
| 147 | } | 172 | } | ||
| 148 | 173 | ||||
| 149 | // Default user preferences | 174 | // Default user preferences | ||
| 150 | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | 175 | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | ||
| 151 | - | defaultStyle: DEFAULT_TEXT_STYLE, | 176 | + | defaultStyle: DEFAULT_TEXT_STYLE, |
| 152 | - | allowFormatting: true, | 177 | + | allowFormatting: true, |
| 153 | - | maxMessageLength: 500 | 178 | + | maxMessageLength: 500 |
| 154 | }; | 179 | }; | ||
| 155 | 180 | ||||
| 156 | // Hex color validation | 181 | // Hex color validation | ||
| 157 | export function isValidHexColor(color: string): boolean { | 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 | // Formatting validation | 186 | // Formatting validation | ||
| 162 | export function validateTextStyle(style: Partial<TextStyle>): TextStyle { | 187 | export function validateTextStyle(style: Partial<TextStyle>): TextStyle { | ||
| 163 | - | return { | 188 | + | return { |
| 164 | - | fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily, | 189 | + | fontFamily: |
| 165 | - | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), | 190 | + | style.fontFamily && style.fontFamily in RETRO_FONTS |
| 166 | - | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, | 191 | + | ? style.fontFamily |
| 167 | - | gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined, | 192 | + | : DEFAULT_TEXT_STYLE.fontFamily, |
| 168 | - | bold: Boolean(style.bold), | 193 | + | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), |
| 169 | - | italic: Boolean(style.italic), | 194 | + | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, |
| 170 | - | underline: Boolean(style.underline), | 195 | + | gradient: |
| 171 | - | strikethrough: Boolean(style.strikethrough) | 196 | + | style.gradient && |
| 172 | - | }; | 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 | // Generate CSS style string from TextStyle | 209 | // Generate CSS style string from TextStyle | ||
| 176 | export function generateCSSStyle(style: TextStyle): string { | 210 | export function generateCSSStyle(style: TextStyle): string { | ||
| 177 | - | const styles: string[] = []; | 211 | + | const styles: string[] = []; |
| 178 | - | 212 | + | ||
| 179 | - | // Font family | 213 | + | // Font family |
| 180 | - | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); | 214 | + | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); |
| 181 | - | 215 | + | ||
| 182 | - | // Font size | 216 | + | // Font size |
| 183 | - | styles.push(`font-size: ${style.fontSize}px`); | 217 | + | styles.push(`font-size: ${style.fontSize}px`); |
| 184 | - | 218 | + | ||
| 185 | - | // Color or gradient | 219 | + | // Color or gradient |
| 186 | - | if (style.gradient && style.gradient.length > 1) { | 220 | + | if (style.gradient && style.gradient.length > 1) { |
| 187 | - | const gradientStops = style.gradient.join(', '); | 221 | + | const gradientStops = style.gradient.join(', '); |
| 188 | - | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | 222 | + | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); |
| 189 | - | styles.push(`-webkit-background-clip: text`); | 223 | + | styles.push(`-webkit-background-clip: text`); |
| 190 | - | styles.push(`-webkit-text-fill-color: transparent`); | 224 | + | styles.push(`-webkit-text-fill-color: transparent`); |
| 191 | - | styles.push(`background-clip: text`); | 225 | + | styles.push(`background-clip: text`); |
| 192 | - | } else if (style.color) { | 226 | + | } else if (style.color) { |
| 193 | - | styles.push(`color: ${style.color}`); | 227 | + | styles.push(`color: ${style.color}`); |
| 194 | - | } | 228 | + | } |
| 195 | - | 229 | + | ||
| 196 | - | // Font weight | 230 | + | // Font weight |
| 197 | - | if (style.bold) { | 231 | + | if (style.bold) { |
| 198 | - | // Use correct weight for Tahoma (200) vs standard bold (700) | 232 | + | // Use correct weight for Tahoma (200) vs standard bold (700) |
| 199 | - | if (style.fontFamily === 'tahoma') { | 233 | + | if (style.fontFamily === 'tahoma') { |
| 200 | - | styles.push(`font-weight: 200`); | 234 | + | styles.push(`font-weight: 200`); |
| 201 | - | } else { | 235 | + | } else { |
| 202 | - | styles.push(`font-weight: 700`); | 236 | + | styles.push(`font-weight: 700`); |
| 203 | - | } | 237 | + | } |
| 204 | - | } | 238 | + | } |
| 205 | - | 239 | + | ||
| 206 | - | // Font style | 240 | + | // Font style |
| 207 | - | if (style.italic) { | 241 | + | if (style.italic) { |
| 208 | - | styles.push(`font-style: italic`); | 242 | + | styles.push(`font-style: italic`); |
| 209 | - | } | 243 | + | } |
| 210 | - | 244 | + | ||
| 211 | - | // Text decoration | 245 | + | // Text decoration |
| 212 | - | const decorations: string[] = []; | 246 | + | const decorations: string[] = []; |
| 213 | - | if (style.underline) decorations.push('underline'); | 247 | + | if (style.underline) decorations.push('underline'); |
| 214 | - | if (style.strikethrough) decorations.push('line-through'); | 248 | + | if (style.strikethrough) decorations.push('line-through'); |
| 215 | - | if (decorations.length > 0) { | 249 | + | if (decorations.length > 0) { |
| 216 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | 250 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); |
| 217 | - | } | 251 | + | } |
| 218 | - | 252 | + | ||
| 219 | - | return styles.join('; '); | 253 | + | return styles.join('; '); |
| 220 | } | 254 | } | ||
| 221 | 255 | ||||
| 222 | // Generate CSS style string for input fields (without font-family which should be handled by classes) | 256 | // Generate CSS style string for input fields (without font-family which should be handled by classes) | ||
| 223 | export function generateInputCSSStyle(style: TextStyle): string { | 257 | export function generateInputCSSStyle(style: TextStyle): string { | ||
| 224 | - | const styles: string[] = []; | 258 | + | const styles: string[] = []; |
| 225 | - | 259 | + | ||
| 226 | - | // Font size | 260 | + | // Font size |
| 227 | - | styles.push(`font-size: ${style.fontSize}px`); | 261 | + | styles.push(`font-size: ${style.fontSize}px`); |
| 228 | - | 262 | + | ||
| 229 | - | // Color handling for inputs | 263 | + | // Color handling for inputs |
| 230 | - | if (style.gradient && style.gradient.length > 1) { | 264 | + | if (style.gradient && style.gradient.length > 1) { |
| 231 | - | // Apply gradient background with text clipping for gradient effect | 265 | + | // Apply gradient background with text clipping for gradient effect |
| 232 | - | const gradientStops = style.gradient.join(', '); | 266 | + | const gradientStops = style.gradient.join(', '); |
| 233 | - | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | 267 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); |
| 234 | - | styles.push(`-webkit-background-clip: text`); | 268 | + | styles.push(`-webkit-background-clip: text`); |
| 235 | - | styles.push(`-webkit-text-fill-color: transparent`); | 269 | + | styles.push(`-webkit-text-fill-color: transparent`); |
| 236 | - | styles.push(`background-clip: text`); | 270 | + | styles.push(`background-clip: text`); |
| 237 | - | styles.push(`letter-spacing: 0`); | 271 | + | styles.push(`letter-spacing: 0`); |
| 238 | - | styles.push(`word-spacing: 0`); | 272 | + | styles.push(`word-spacing: 0`); |
| 239 | - | } else if (style.gradient && style.gradient.length === 1) { | 273 | + | } else if (style.gradient && style.gradient.length === 1) { |
| 240 | - | // Single color from gradient array | 274 | + | // Single color from gradient array |
| 241 | - | styles.push(`color: ${style.gradient[0]}`); | 275 | + | styles.push(`color: ${style.gradient[0]}`); |
| 242 | - | } else if (style.color) { | 276 | + | } else if (style.color) { |
| 243 | - | styles.push(`color: ${style.color}`); | 277 | + | styles.push(`color: ${style.color}`); |
| 244 | - | } else { | 278 | + | } else { |
| 245 | - | // Fallback to black if no color is set | 279 | + | // Fallback to black if no color is set |
| 246 | - | styles.push(`color: #000000`); | 280 | + | styles.push(`color: #000000`); |
| 247 | - | } | 281 | + | } |
| 248 | - | 282 | + | ||
| 249 | - | // Font weight | 283 | + | // Font weight |
| 250 | - | if (style.bold) { | 284 | + | if (style.bold) { |
| 251 | - | // Use correct weight for Tahoma (200) vs standard bold (700) | 285 | + | // Use correct weight for Tahoma (200) vs standard bold (700) |
| 252 | - | if (style.fontFamily === 'tahoma') { | 286 | + | if (style.fontFamily === 'tahoma') { |
| 253 | - | styles.push(`font-weight: 200`); | 287 | + | styles.push(`font-weight: 200`); |
| 254 | - | } else { | 288 | + | } else { |
| 255 | - | styles.push(`font-weight: 700`); | 289 | + | styles.push(`font-weight: 700`); |
| 256 | - | } | 290 | + | } |
| 257 | - | } | 291 | + | } |
| 258 | - | 292 | + | ||
| 259 | - | // Font style | 293 | + | // Font style |
| 260 | - | if (style.italic) { | 294 | + | if (style.italic) { |
| 261 | - | styles.push(`font-style: italic`); | 295 | + | styles.push(`font-style: italic`); |
| 262 | - | } | 296 | + | } |
| 263 | - | 297 | + | ||
| 264 | - | // Text decoration | 298 | + | // Text decoration |
| 265 | - | const decorations: string[] = []; | 299 | + | const decorations: string[] = []; |
| 266 | - | if (style.underline) decorations.push('underline'); | 300 | + | if (style.underline) decorations.push('underline'); |
| 267 | - | if (style.strikethrough) decorations.push('line-through'); | 301 | + | if (style.strikethrough) decorations.push('line-through'); |
| 268 | - | if (decorations.length > 0) { | 302 | + | if (decorations.length > 0) { |
| 269 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | 303 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); |
| 270 | - | } | 304 | + | } |
| 271 | - | 305 | + | ||
| 272 | - | return styles.join('; '); | 306 | + | return styles.join('; '); |
| 273 | - | }⏎̸ | 307 | + | } |
M src/lib/types/turnstile.d.ts +14 -14
| @@ -1,23 +1,23 @@ | |||||
| 1 | interface Turnstile { | 1 | interface Turnstile { | ||
| 2 | - | render: (element: HTMLElement, options: TurnstileOptions) => string; | 2 | + | render: (element: HTMLElement, options: TurnstileOptions) => string; |
| 3 | - | remove: (widgetId: string) => void; | 3 | + | remove: (widgetId: string) => void; |
| 4 | } | 4 | } | ||
| 5 | 5 | ||||
| 6 | interface TurnstileOptions { | 6 | interface TurnstileOptions { | ||
| 7 | - | sitekey: string; | 7 | + | sitekey: string; |
| 8 | - | callback: (token: string) => void; | 8 | + | callback: (token: string) => void; |
| 9 | - | "refresh-expired"?: "auto" | "manual"; | 9 | + | 'refresh-expired'?: 'auto' | 'manual'; |
| 10 | - | size?: "invisible" | "normal" | "compact"; | 10 | + | size?: 'invisible' | 'normal' | 'compact'; |
| 11 | - | "error-callback"?: (error: unknown) => void; | 11 | + | 'error-callback'?: (error: unknown) => void; |
| 12 | - | "expired-callback"?: () => void; | 12 | + | 'expired-callback'?: () => void; |
| 13 | - | theme?: "light" | "dark" | "auto"; | 13 | + | theme?: 'light' | 'dark' | 'auto'; |
| 14 | - | language?: string; | 14 | + | language?: string; |
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | declare global { | 17 | declare global { | ||
| 18 | - | interface Window { | 18 | + | interface Window { |
| 19 | - | turnstile?: Turnstile; | 19 | + | turnstile?: Turnstile; |
| 20 | - | } | 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"; | 1 | + | import type { LibSQLDatabase } from 'drizzle-orm/libsql'; |
| 2 | - | import { createLogger } from "$lib/utils/logger.server"; | 2 | + | import { createLogger } from '$lib/utils/logger.server'; |
| 3 | - | import schema, { chatRooms } from "$lib/db/schema"; | 3 | + | import schema, { chatRooms } from '$lib/db/schema'; |
| 4 | - | import { eq } from "drizzle-orm"; | 4 | + | import { eq } from 'drizzle-orm'; |
| 5 | - | import { env } from "$env/dynamic/public"; | 5 | + | import { env } from '$env/dynamic/public'; |
| 6 | 6 | ||||
| 7 | const log = createLogger('chat-utils'); | 7 | const log = createLogger('chat-utils'); | ||
| 8 | 8 | ||||
| 9 | // Default chat room ID from environment variable | 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 | // Type guard for LibSQL errors | 13 | // Type guard for LibSQL errors | ||
| 13 | interface LibSQLError { | 14 | interface LibSQLError { | ||
| 14 | - | code: string; | 15 | + | code: string; |
| 15 | - | message: string; | 16 | + | message: string; |
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | function isLibSQLError(error: unknown): error is LibSQLError { | 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 | // Function to ensure default chat room exists | 23 | // Function to ensure default chat room exists | ||
| 23 | export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) { | 24 | export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) { | ||
| 24 | - | log.debug('Ensuring default chat room exists...'); | 25 | + | log.debug('Ensuring default chat room exists...'); |
| 25 | - | try { | 26 | + | try { |
| 26 | - | // Use a transaction to handle concurrent creation attempts | 27 | + | // Use a transaction to handle concurrent creation attempts |
| 27 | - | await db.transaction(async (tx) => { | 28 | + | await db.transaction(async (tx) => { |
| 28 | - | const defaultRoom = await tx.select() | 29 | + | const defaultRoom = await tx |
| 29 | - | .from(chatRooms) | 30 | + | .select() |
| 30 | - | .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID)) | 31 | + | .from(chatRooms) |
| 31 | - | .get(); | 32 | + | .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID)) |
| 33 | + | .get(); | |||
| 32 | 34 | ||||
| 33 | - | if (!defaultRoom) { | 35 | + | if (!defaultRoom) { |
| 34 | - | log.debug('Creating default chat room...'); | 36 | + | log.debug('Creating default chat room...'); |
| 35 | - | await tx.insert(chatRooms).values({ | 37 | + | await tx.insert(chatRooms).values({ |
| 36 | - | id: DEFAULT_CHAT_ROOM_ID, | 38 | + | id: DEFAULT_CHAT_ROOM_ID, |
| 37 | - | name: 'General', | 39 | + | name: 'General', |
| 38 | - | type: 'group', | 40 | + | type: 'group', |
| 39 | - | createdAt: Date.now() | 41 | + | createdAt: Date.now() |
| 40 | - | }); | 42 | + | }); |
| 41 | - | log.debug('Default chat room created successfully'); | 43 | + | log.debug('Default chat room created successfully'); |
| 42 | - | } else { | 44 | + | } else { |
| 43 | - | log.debug('Default chat room already exists'); | 45 | + | log.debug('Default chat room already exists'); |
| 44 | - | } | 46 | + | } |
| 45 | - | }); | 47 | + | }); |
| 46 | - | } catch (error: unknown) { | 48 | + | } catch (error: unknown) { |
| 47 | - | // If the error is a unique constraint violation, another process probably created the room | 49 | + | // If the error is a unique constraint violation, another process probably created the room |
| 48 | - | if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') { | 50 | + | if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') { |
| 49 | - | log.debug('Default chat room was created by another process'); | 51 | + | log.debug('Default chat room was created by another process'); |
| 50 | - | return; | 52 | + | return; |
| 51 | - | } | 53 | + | } |
| 52 | - | log.error('Error ensuring default chat room:', { error: error instanceof Error ? error.message : 'Unknown error' }); | 54 | + | log.error('Error ensuring default chat room:', { |
| 53 | - | throw error; | 55 | + | error: error instanceof Error ? error.message : 'Unknown error' |
| 54 | - | } | 56 | + | }); |
| 55 | - | } ⏎̸ | 57 | + | throw error; |
| 58 | + | } | |||
| 59 | + | } | |||
M src/lib/utils/date-format.ts +101 -95
| @@ -4,15 +4,17 @@ | |||||
| 4 | * @returns The formatted date string | 4 | * @returns The formatted date string | ||
| 5 | */ | 5 | */ | ||
| 6 | export function formatFrenchDateTime(date: Date): string { | 6 | export function formatFrenchDateTime(date: Date): string { | ||
| 7 | - | console.debug('Formatting date to French style:', date); | 7 | + | console.debug('Formatting date to French style:', date); |
| 8 | - | return date.toLocaleDateString('fr-FR', { | 8 | + | return date |
| 9 | - | day: '2-digit', | 9 | + | .toLocaleDateString('fr-FR', { |
| 10 | - | month: '2-digit', | 10 | + | day: '2-digit', |
| 11 | - | year: 'numeric', | 11 | + | month: '2-digit', |
| 12 | - | hour: '2-digit', | 12 | + | year: 'numeric', |
| 13 | - | minute: '2-digit', | 13 | + | hour: '2-digit', |
| 14 | - | hour12: false | 14 | + | minute: '2-digit', |
| 15 | - | }).replace(' à ', ' à '); // Ensure proper spacing around 'à' | 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 | * @returns The formatted time string | 23 | * @returns The formatted time string | ||
| 22 | */ | 24 | */ | ||
| 23 | export function formatFrenchTime(date: Date): string { | 25 | export function formatFrenchTime(date: Date): string { | ||
| 24 | - | console.debug('Formatting time to French style:', date); | 26 | + | console.debug('Formatting time to French style:', date); |
| 25 | - | return date.toLocaleTimeString('fr-FR', { | 27 | + | return date |
| 26 | - | hour: '2-digit', | 28 | + | .toLocaleTimeString('fr-FR', { |
| 27 | - | minute: '2-digit', | 29 | + | hour: '2-digit', |
| 28 | - | hour12: false | 30 | + | minute: '2-digit', |
| 29 | - | }).replace(':', 'h'); | 31 | + | hour12: false |
| 32 | + | }) | |||
| 33 | + | .replace(':', 'h'); | |||
| 30 | } | 34 | } | ||
| 31 | 35 | ||||
| 32 | /** | 36 | /** | ||
| @@ -35,12 +39,12 @@ export function formatFrenchTime(date: Date): string { | |||||
| 35 | * @returns The formatted date string | 39 | * @returns The formatted date string | ||
| 36 | */ | 40 | */ | ||
| 37 | export function formatFrenchDate(date: Date): string { | 41 | export function formatFrenchDate(date: Date): string { | ||
| 38 | - | console.debug('Formatting date to French style:', date); | 42 | + | console.debug('Formatting date to French style:', date); |
| 39 | - | return date.toLocaleDateString('fr-FR', { | 43 | + | return date.toLocaleDateString('fr-FR', { |
| 40 | - | day: '2-digit', | 44 | + | day: '2-digit', |
| 41 | - | month: '2-digit', | 45 | + | month: '2-digit', |
| 42 | - | year: 'numeric' | 46 | + | year: 'numeric' |
| 43 | - | }); | 47 | + | }); |
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | /** | 50 | /** | ||
| @@ -49,37 +53,37 @@ export function formatFrenchDate(date: Date): string { | |||||
| 49 | * @returns The formatted relative time string | 53 | * @returns The formatted relative time string | ||
| 50 | */ | 54 | */ | ||
| 51 | export function formatFrenchRelativeTime(date: Date): string { | 55 | export function formatFrenchRelativeTime(date: Date): string { | ||
| 52 | - | console.debug('Formatting relative time to French style:', date); | 56 | + | console.debug('Formatting relative time to French style:', date); |
| 53 | - | const now = new Date(); | 57 | + | const now = new Date(); |
| 54 | - | const diff = now.getTime() - date.getTime(); | 58 | + | const diff = now.getTime() - date.getTime(); |
| 55 | - | const seconds = Math.floor(diff / 1000); | 59 | + | const seconds = Math.floor(diff / 1000); |
| 56 | - | const minutes = Math.floor(seconds / 60); | 60 | + | const minutes = Math.floor(seconds / 60); |
| 57 | - | const hours = Math.floor(minutes / 60); | 61 | + | const hours = Math.floor(minutes / 60); |
| 58 | - | const days = Math.floor(hours / 24); | 62 | + | const days = Math.floor(hours / 24); |
| 59 | - | const months = Math.floor(days / 30); | 63 | + | const months = Math.floor(days / 30); |
| 60 | - | const years = Math.floor(days / 365); | 64 | + | const years = Math.floor(days / 365); |
| 61 | - | 65 | + | ||
| 62 | - | if (seconds < 60) { | 66 | + | if (seconds < 60) { |
| 63 | - | return "à l'instant"; | 67 | + | return "à l'instant"; |
| 64 | - | } | 68 | + | } |
| 65 | - | 69 | + | ||
| 66 | - | if (minutes < 60) { | 70 | + | if (minutes < 60) { |
| 67 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | 71 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; |
| 68 | - | } | 72 | + | } |
| 69 | - | 73 | + | ||
| 70 | - | if (hours < 24) { | 74 | + | if (hours < 24) { |
| 71 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | 75 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; |
| 72 | - | } | 76 | + | } |
| 73 | - | 77 | + | ||
| 74 | - | if (days < 30) { | 78 | + | if (days < 30) { |
| 75 | - | return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`; | 79 | + | return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`; |
| 76 | - | } | 80 | + | } |
| 77 | - | 81 | + | ||
| 78 | - | if (months < 12) { | 82 | + | if (months < 12) { |
| 79 | - | return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`; | 83 | + | return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`; |
| 80 | - | } | 84 | + | } |
| 81 | - | 85 | + | ||
| 82 | - | return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`; | 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 | * @returns The formatted relative time string with more detail | 93 | * @returns The formatted relative time string with more detail | ||
| 90 | */ | 94 | */ | ||
| 91 | export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string { | 95 | export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string { | ||
| 92 | - | console.debug('Formatting detailed relative time to French style:', date); | 96 | + | console.debug('Formatting detailed relative time to French style:', date); |
| 93 | - | const now = new Date(); | 97 | + | const now = new Date(); |
| 94 | - | const diff = now.getTime() - date.getTime(); | 98 | + | const diff = now.getTime() - date.getTime(); |
| 95 | - | const seconds = Math.floor(diff / 1000); | 99 | + | const seconds = Math.floor(diff / 1000); |
| 96 | - | const minutes = Math.floor(seconds / 60); | 100 | + | const minutes = Math.floor(seconds / 60); |
| 97 | - | const hours = Math.floor(minutes / 60); | 101 | + | const hours = Math.floor(minutes / 60); |
| 98 | - | 102 | + | ||
| 99 | - | if (seconds < 60) { | 103 | + | if (seconds < 60) { |
| 100 | - | return withSeconds ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` : "à l'instant"; | 104 | + | return withSeconds |
| 101 | - | } | 105 | + | ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` |
| 102 | - | 106 | + | : "à l'instant"; | |
| 103 | - | if (minutes < 60) { | 107 | + | } |
| 104 | - | const remainingSeconds = seconds % 60; | 108 | + | |
| 105 | - | if (withSeconds && remainingSeconds > 0) { | 109 | + | if (minutes < 60) { |
| 106 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`; | 110 | + | const remainingSeconds = seconds % 60; |
| 107 | - | } | 111 | + | if (withSeconds && remainingSeconds > 0) { |
| 108 | - | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | 112 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`; |
| 109 | - | } | 113 | + | } |
| 110 | - | 114 | + | return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; | |
| 111 | - | if (hours < 24) { | 115 | + | } |
| 112 | - | const remainingMinutes = minutes % 60; | 116 | + | |
| 113 | - | if (remainingMinutes > 0) { | 117 | + | if (hours < 24) { |
| 114 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`; | 118 | + | const remainingMinutes = minutes % 60; |
| 115 | - | } | 119 | + | if (remainingMinutes > 0) { |
| 116 | - | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | 120 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`; |
| 117 | - | } | 121 | + | } |
| 118 | - | 122 | + | return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`; | |
| 119 | - | // For anything longer than 24 hours, use the simple format | 123 | + | } |
| 120 | - | return formatFrenchRelativeTime(date); | 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 | * @returns The formatted relative time string or a fallback message | 132 | * @returns The formatted relative time string or a fallback message | ||
| 127 | */ | 133 | */ | ||
| 128 | export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string { | 134 | export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string { | ||
| 129 | - | console.debug('Formatting safe relative time to French style:', timestamp); | 135 | + | console.debug('Formatting safe relative time to French style:', timestamp); |
| 130 | - | if (!timestamp) { | 136 | + | if (!timestamp) { |
| 131 | - | return "jamais"; | 137 | + | return 'jamais'; |
| 132 | - | } | 138 | + | } |
| 133 | - | 139 | + | ||
| 134 | - | const date = timestamp instanceof Date ? timestamp : new Date(timestamp); | 140 | + | const date = timestamp instanceof Date ? timestamp : new Date(timestamp); |
| 135 | - | 141 | + | ||
| 136 | - | // Check if the date is valid | 142 | + | // Check if the date is valid |
| 137 | - | if (isNaN(date.getTime())) { | 143 | + | if (isNaN(date.getTime())) { |
| 138 | - | return "date invalide"; | 144 | + | return 'date invalide'; |
| 139 | - | } | 145 | + | } |
| 140 | - | 146 | + | ||
| 141 | - | return formatFrenchRelativeTime(date); | 147 | + | return formatFrenchRelativeTime(date); |
| 142 | - | } ⏎̸ | 148 | + | } |
M src/lib/utils/logger.server.ts +40 -40
| @@ -3,53 +3,53 @@ import pino from 'pino'; | |||||
| 3 | const logLevel = process.env.NODE_ENV === 'development' ? 'debug' : 'info'; | 3 | const logLevel = process.env.NODE_ENV === 'development' ? 'debug' : 'info'; | ||
| 4 | 4 | ||||
| 5 | class LoggerService { | 5 | class LoggerService { | ||
| 6 | - | private static instance: LoggerService; | 6 | + | private static instance: LoggerService; |
| 7 | - | private logger: pino.Logger; | 7 | + | private logger: pino.Logger; |
| 8 | - | private moduleLoggers: Map<string, Logger>; | 8 | + | private moduleLoggers: Map<string, Logger>; |
| 9 | 9 | ||||
| 10 | - | private constructor() { | 10 | + | private constructor() { |
| 11 | - | this.logger = pino({ | 11 | + | this.logger = pino({ |
| 12 | - | transport: { | 12 | + | transport: { |
| 13 | - | target: 'pino-pretty', | 13 | + | target: 'pino-pretty', |
| 14 | - | options: { | 14 | + | options: { |
| 15 | - | colorize: true, | 15 | + | colorize: true, |
| 16 | - | translateTime: 'HH:MM:ss', | 16 | + | translateTime: 'HH:MM:ss', |
| 17 | - | ignore: 'pid,hostname', | 17 | + | ignore: 'pid,hostname' |
| 18 | - | }, | 18 | + | } |
| 19 | - | }, | 19 | + | }, |
| 20 | - | level: logLevel, | 20 | + | level: logLevel |
| 21 | - | }); | 21 | + | }); |
| 22 | - | this.moduleLoggers = new Map(); | 22 | + | this.moduleLoggers = new Map(); |
| 23 | - | } | 23 | + | } |
| 24 | 24 | ||||
| 25 | - | public static getInstance(): LoggerService { | 25 | + | public static getInstance(): LoggerService { |
| 26 | - | if (!LoggerService.instance) { | 26 | + | if (!LoggerService.instance) { |
| 27 | - | LoggerService.instance = new LoggerService(); | 27 | + | LoggerService.instance = new LoggerService(); |
| 28 | - | } | 28 | + | } |
| 29 | - | return LoggerService.instance; | 29 | + | return LoggerService.instance; |
| 30 | - | } | 30 | + | } |
| 31 | 31 | ||||
| 32 | - | public getLogger(module: string): Logger { | 32 | + | public getLogger(module: string): Logger { |
| 33 | - | if (!this.moduleLoggers.has(module)) { | 33 | + | if (!this.moduleLoggers.has(module)) { |
| 34 | - | this.moduleLoggers.set(module, { | 34 | + | this.moduleLoggers.set(module, { |
| 35 | - | debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg), | 35 | + | debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg), |
| 36 | - | info: (msg: string, obj = {}) => this.logger.info({ 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), | 37 | + | warn: (msg: string, obj = {}) => this.logger.warn({ module, ...obj }, msg), |
| 38 | - | error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg) | 38 | + | error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg) |
| 39 | - | }); | 39 | + | }); |
| 40 | - | } | 40 | + | } |
| 41 | - | return this.moduleLoggers.get(module)!; | 41 | + | return this.moduleLoggers.get(module)!; |
| 42 | - | } | 42 | + | } |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | export interface Logger { | 45 | export interface Logger { | ||
| 46 | - | debug: (msg: string, obj?: object) => void; | 46 | + | debug: (msg: string, obj?: object) => void; |
| 47 | - | info: (msg: string, obj?: object) => void; | 47 | + | info: (msg: string, obj?: object) => void; |
| 48 | - | warn: (msg: string, obj?: object) => void; | 48 | + | warn: (msg: string, obj?: object) => void; |
| 49 | - | error: (msg: string, obj?: object) => void; | 49 | + | error: (msg: string, obj?: object) => void; |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | // Export a function to get a logger for a specific module | 52 | // Export a function to get a logger for a specific module | ||
| 53 | export const createLogger = (module: string): Logger => { | 53 | export const createLogger = (module: string): Logger => { | ||
| 54 | - | return LoggerService.getInstance().getLogger(module); | 54 | + | return LoggerService.getInstance().getLogger(module); |
| 55 | - | }; ⏎̸ | 55 | + | }; |
M src/lib/utils/password.ts +41 -45
| @@ -4,23 +4,23 @@ | |||||
| 4 | * Helper function that converts an ArrayBuffer to a hex string. | 4 | * Helper function that converts an ArrayBuffer to a hex string. | ||
| 5 | */ | 5 | */ | ||
| 6 | function bufferToHex(buffer: ArrayBuffer): string { | 6 | function bufferToHex(buffer: ArrayBuffer): string { | ||
| 7 | - | return Array.from(new Uint8Array(buffer)) | 7 | + | return Array.from(new Uint8Array(buffer)) |
| 8 | - | .map(b => b.toString(16).padStart(2, '0')) | 8 | + | .map((b) => b.toString(16).padStart(2, '0')) |
| 9 | - | .join(''); | 9 | + | .join(''); |
| 10 | } | 10 | } | ||
| 11 | 11 | ||||
| 12 | /** | 12 | /** | ||
| 13 | * Helper function that converts a hex string to a Uint8Array. | 13 | * Helper function that converts a hex string to a Uint8Array. | ||
| 14 | */ | 14 | */ | ||
| 15 | function hexToBuffer(hex: string): Uint8Array { | 15 | function hexToBuffer(hex: string): Uint8Array { | ||
| 16 | - | if (hex.length % 2 !== 0) { | 16 | + | if (hex.length % 2 !== 0) { |
| 17 | - | throw new Error('Invalid hex string'); | 17 | + | throw new Error('Invalid hex string'); |
| 18 | - | } | 18 | + | } |
| 19 | - | const arr = new Uint8Array(hex.length / 2); | 19 | + | const arr = new Uint8Array(hex.length / 2); |
| 20 | - | for (let i = 0; i < hex.length; i += 2) { | 20 | + | for (let i = 0; i < hex.length; i += 2) { |
| 21 | - | arr[i / 2] = parseInt(hex.substring(i, i + 2), 16); | 21 | + | arr[i / 2] = parseInt(hex.substring(i, i + 2), 16); |
| 22 | - | } | 22 | + | } |
| 23 | - | return arr; | 23 | + | return arr; |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | /** | 26 | /** | ||
| @@ -30,28 +30,24 @@ function hexToBuffer(hex: string): Uint8Array { | |||||
| 30 | * @returns The derived key in hex format. | 30 | * @returns The derived key in hex format. | ||
| 31 | */ | 31 | */ | ||
| 32 | async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> { | 32 | async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> { | ||
| 33 | - | const enc = new TextEncoder(); | 33 | + | const enc = new TextEncoder(); |
| 34 | - | const keyMaterial = enc.encode(password); | 34 | + | const keyMaterial = enc.encode(password); |
| 35 | - | console.debug("$state: Key material for password derivation", keyMaterial); | 35 | + | console.debug('$state: Key material for password derivation', keyMaterial); |
| 36 | - | const key = await crypto.subtle.importKey( | 36 | + | const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [ |
| 37 | - | "raw", | 37 | + | 'deriveBits' |
| 38 | - | keyMaterial, | 38 | + | ]); |
| 39 | - | { name: "PBKDF2" }, | 39 | + | const derivedBits = await crypto.subtle.deriveBits( |
| 40 | - | false, | 40 | + | { |
| 41 | - | ["deriveBits"] | 41 | + | name: 'PBKDF2', |
| 42 | - | ); | 42 | + | salt: salt.buffer as ArrayBuffer, |
| 43 | - | const derivedBits = await crypto.subtle.deriveBits( | 43 | + | iterations: 100000, |
| 44 | - | { | 44 | + | hash: 'SHA-512' |
| 45 | - | name: "PBKDF2", | 45 | + | }, |
| 46 | - | salt: salt, | 46 | + | key, |
| 47 | - | iterations: 100000, | 47 | + | 512 |
| 48 | - | hash: "SHA-512" | 48 | + | ); |
| 49 | - | }, | 49 | + | console.debug('$state: Derived bits', derivedBits); |
| 50 | - | key, | 50 | + | return bufferToHex(derivedBits); |
| 51 | - | 512 | |||
| 52 | - | ); | |||
| 53 | - | console.debug("$state: Derived bits", derivedBits); | |||
| 54 | - | return bufferToHex(derivedBits); | |||
| 55 | } | 51 | } | ||
| 56 | 52 | ||||
| 57 | /** | 53 | /** | ||
| @@ -60,11 +56,11 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s | |||||
| 60 | * @returns A promise that resolves to the hashed password in the format 'salt:hash'. | 56 | * @returns A promise that resolves to the hashed password in the format 'salt:hash'. | ||
| 61 | */ | 57 | */ | ||
| 62 | export async function hashPassword(password: string): Promise<string> { | 58 | export async function hashPassword(password: string): Promise<string> { | ||
| 63 | - | const salt = crypto.getRandomValues(new Uint8Array(16)); | 59 | + | const salt = crypto.getRandomValues(new Uint8Array(16)); |
| 64 | - | console.debug("$state: Generated salt", salt); | 60 | + | console.debug('$state: Generated salt', salt); |
| 65 | - | const saltHex = bufferToHex(salt.buffer); | 61 | + | const saltHex = bufferToHex(salt.buffer); |
| 66 | - | const hash = await derivePasswordHash(password, salt); | 62 | + | const hash = await derivePasswordHash(password, salt); |
| 67 | - | return `${saltHex}:${hash}`; | 63 | + | return `${saltHex}:${hash}`; |
| 68 | } | 64 | } | ||
| 69 | 65 | ||||
| 70 | /** | 66 | /** | ||
| @@ -74,10 +70,10 @@ export async function hashPassword(password: string): Promise<string> { | |||||
| 74 | * @returns A promise that resolves to true if the password matches, false otherwise. | 70 | * @returns A promise that resolves to true if the password matches, false otherwise. | ||
| 75 | */ | 71 | */ | ||
| 76 | export async function verifyPassword(password: string, hash: string): Promise<boolean> { | 72 | export async function verifyPassword(password: string, hash: string): Promise<boolean> { | ||
| 77 | - | const [saltHex, hashValue] = hash.split(':'); | 73 | + | const [saltHex, hashValue] = hash.split(':'); |
| 78 | - | const salt = hexToBuffer(saltHex); | 74 | + | const salt = hexToBuffer(saltHex); |
| 79 | - | console.debug("$state: Salt extracted from stored hash", salt); | 75 | + | console.debug('$state: Salt extracted from stored hash', salt); |
| 80 | - | const derivedHash = await derivePasswordHash(password, salt); | 76 | + | const derivedHash = await derivePasswordHash(password, salt); |
| 81 | - | console.debug("$state: Derived hash for verification", derivedHash); | 77 | + | console.debug('$state: Derived hash for verification', derivedHash); |
| 82 | - | return derivedHash === hashValue; | 78 | + | return derivedHash === hashValue; |
| 83 | - | } ⏎̸ | 79 | + | } |
M src/lib/utils/security-tests.ts +320 -322
| @@ -1,373 +1,371 @@ | |||||
| 1 | import { formatText, validateMessageContent, createGradientText } from './text-formatter'; | 1 | import { formatText, validateMessageContent, createGradientText } from './text-formatter'; | ||
| 2 | - | import { validateTextStyle, DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | 2 | + | import { validateTextStyle, DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting'; |
| 3 | 3 | ||||
| 4 | // XSS Test Vectors | 4 | // XSS Test Vectors | ||
| 5 | const XSS_TEST_VECTORS = [ | 5 | const XSS_TEST_VECTORS = [ | ||
| 6 | - | // Script injection | 6 | + | // Script injection |
| 7 | - | '<script>alert("XSS")</script>', | 7 | + | '<script>alert("XSS")</script>', |
| 8 | - | '<script src="malicious.js"></script>', | 8 | + | '<script src="malicious.js"></script>', |
| 9 | - | '<img src="x" onerror="alert(\'XSS\')">', | 9 | + | '<img src="x" onerror="alert(\'XSS\')">', |
| 10 | - | '<svg onload="alert(\'XSS\')">', | 10 | + | '<svg onload="alert(\'XSS\')">', |
| 11 | - | '<iframe src="javascript:alert(\'XSS\')"></iframe>', | 11 | + | '<iframe src="javascript:alert(\'XSS\')"></iframe>', |
| 12 | - | 12 | + | ||
| 13 | - | // Event handlers | 13 | + | // Event handlers |
| 14 | - | '<div onclick="alert(\'XSS\')">Click me</div>', | 14 | + | '<div onclick="alert(\'XSS\')">Click me</div>', |
| 15 | - | '<input onfocus="alert(\'XSS\')" autofocus>', | 15 | + | '<input onfocus="alert(\'XSS\')" autofocus>', |
| 16 | - | '<body onload="alert(\'XSS\')">', | 16 | + | '<body onload="alert(\'XSS\')">', |
| 17 | - | '<a href="javascript:alert(\'XSS\')">Link</a>', | 17 | + | '<a href="javascript:alert(\'XSS\')">Link</a>', |
| 18 | - | 18 | + | ||
| 19 | - | // CSS injection | 19 | + | // CSS injection |
| 20 | - | '<style>body{background:url("javascript:alert(\'XSS\')")}</style>', | 20 | + | '<style>body{background:url("javascript:alert(\'XSS\')")}</style>', |
| 21 | - | '<div style="background-image:url(javascript:alert(\'XSS\'))">', | 21 | + | '<div style="background-image:url(javascript:alert(\'XSS\'))">', |
| 22 | - | '<div style="expression(alert(\'XSS\'))">', | 22 | + | '<div style="expression(alert(\'XSS\'))">', |
| 23 | - | 23 | + | ||
| 24 | - | // Data URIs | 24 | + | // Data URIs |
| 25 | - | '<img src="data:text/html,<script>alert(\'XSS\')</script>">', | 25 | + | '<img src="data:text/html,<script>alert(\'XSS\')</script>">', |
| 26 | - | '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">', | 26 | + | '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">', |
| 27 | - | 27 | + | ||
| 28 | - | // HTML injection | 28 | + | // HTML injection |
| 29 | - | '<object data="malicious.swf">', | 29 | + | '<object data="malicious.swf">', |
| 30 | - | '<embed src="malicious.swf">', | 30 | + | '<embed src="malicious.swf">', |
| 31 | - | '<applet code="malicious.class">', | 31 | + | '<applet code="malicious.class">', |
| 32 | - | 32 | + | ||
| 33 | - | // BBCode injection attempts | 33 | + | // BBCode injection attempts |
| 34 | - | '[script]alert("XSS")[/script]', | 34 | + | '[script]alert("XSS")[/script]', |
| 35 | - | '[img=javascript:alert("XSS")]', | 35 | + | '[img=javascript:alert("XSS")]', |
| 36 | - | '[url=javascript:alert("XSS")]Link[/url]', | 36 | + | '[url=javascript:alert("XSS")]Link[/url]', |
| 37 | - | 37 | + | ||
| 38 | - | // Markdown injection attempts | 38 | + | // Markdown injection attempts |
| 39 | - | ')', | 39 | + | ')', |
| 40 | - | '[XSS](javascript:alert("XSS"))', | 40 | + | '[XSS](javascript:alert("XSS"))', |
| 41 | - | 41 | + | ||
| 42 | - | // Unicode and encoding bypasses | 42 | + | // Unicode and encoding bypasses |
| 43 | - | '<script>alert("XSS")</script>', | 43 | + | '<script>alert("XSS")</script>', |
| 44 | - | '<script>alert(String.fromCharCode(88,83,83))</script>', | 44 | + | '<script>alert(String.fromCharCode(88,83,83))</script>', |
| 45 | - | '<img src="x" onerror="alert('XSS')">', | 45 | + | '<img src="x" onerror="alert('XSS')">', |
| 46 | - | 46 | + | ||
| 47 | - | // CSS expression injection | 47 | + | // CSS expression injection |
| 48 | - | '<div style="width:expression(alert(\'XSS\'))">', | 48 | + | '<div style="width:expression(alert(\'XSS\'))">', |
| 49 | - | '<div style="background:url(vbscript:alert(\'XSS\'))">', | 49 | + | '<div style="background:url(vbscript:alert(\'XSS\'))">', |
| 50 | - | 50 | + | ||
| 51 | - | // Mixed content attacks | 51 | + | // Mixed content attacks |
| 52 | - | 'Normal text <script>alert("XSS")</script> more text', | 52 | + | 'Normal text <script>alert("XSS")</script> more text', |
| 53 | - | '[b]Bold <script>alert("XSS")</script> text[/b]', | 53 | + | '[b]Bold <script>alert("XSS")</script> text[/b]', |
| 54 | - | '**Bold <img src="x" onerror="alert(\'XSS\')"> text**' | 54 | + | '**Bold <img src="x" onerror="alert(\'XSS\')"> text**' |
| 55 | ]; | 55 | ]; | ||
| 56 | 56 | ||||
| 57 | // CSS Injection Test Vectors | 57 | // CSS Injection Test Vectors | ||
| 58 | const CSS_INJECTION_VECTORS = [ | 58 | const CSS_INJECTION_VECTORS = [ | ||
| 59 | - | 'color: red; background: url(javascript:alert("XSS"))', | 59 | + | 'color: red; background: url(javascript:alert("XSS"))', |
| 60 | - | 'font-family: "Arial"; } body { background: red; .fake {', | 60 | + | 'font-family: "Arial"; } body { background: red; .fake {', |
| 61 | - | 'font-size: expression(alert("XSS"))', | 61 | + | 'font-size: expression(alert("XSS"))', |
| 62 | - | 'background: url("data:text/html,<script>alert(\'XSS\')</script>")', | 62 | + | 'background: url("data:text/html,<script>alert(\'XSS\')</script>")', |
| 63 | - | 'color: red; } .injected { background: red', | 63 | + | 'color: red; } .injected { background: red', |
| 64 | - | '@import url("malicious.css")', | 64 | + | '@import url("malicious.css")', |
| 65 | - | 'behavior: url("malicious.htc")' | 65 | + | 'behavior: url("malicious.htc")' |
| 66 | ]; | 66 | ]; | ||
| 67 | 67 | ||||
| 68 | // Test Results Interface | 68 | // Test Results Interface | ||
| 69 | interface SecurityTestResult { | 69 | interface SecurityTestResult { | ||
| 70 | - | input: string; | 70 | + | input: string; |
| 71 | - | output: string; | 71 | + | output: string; |
| 72 | - | passed: boolean; | 72 | + | passed: boolean; |
| 73 | - | vulnerabilities: string[]; | 73 | + | vulnerabilities: string[]; |
| 74 | - | testType: string; | 74 | + | testType: string; |
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | interface SecurityTestSuite { | 77 | interface SecurityTestSuite { | ||
| 78 | - | xssTests: SecurityTestResult[]; | 78 | + | xssTests: SecurityTestResult[]; |
| 79 | - | cssInjectionTests: SecurityTestResult[]; | 79 | + | cssInjectionTests: SecurityTestResult[]; |
| 80 | - | validationTests: SecurityTestResult[]; | 80 | + | validationTests: SecurityTestResult[]; |
| 81 | - | gradientTests: SecurityTestResult[]; | 81 | + | gradientTests: SecurityTestResult[]; |
| 82 | - | summary: { | 82 | + | summary: { |
| 83 | - | totalTests: number; | 83 | + | totalTests: number; |
| 84 | - | passed: number; | 84 | + | passed: number; |
| 85 | - | failed: number; | 85 | + | failed: number; |
| 86 | - | vulnerabilities: string[]; | 86 | + | vulnerabilities: string[]; |
| 87 | - | }; | 87 | + | }; |
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | // Check if output contains dangerous patterns | 90 | // Check if output contains dangerous patterns | ||
| 91 | function detectVulnerabilities(output: string): string[] { | 91 | function detectVulnerabilities(output: string): string[] { | ||
| 92 | - | const vulnerabilities: string[] = []; | 92 | + | const vulnerabilities: string[] = []; |
| 93 | - | 93 | + | ||
| 94 | - | // Script tags | 94 | + | // Script tags |
| 95 | - | if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) { | 95 | + | if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) { |
| 96 | - | vulnerabilities.push('Script tag injection'); | 96 | + | vulnerabilities.push('Script tag injection'); |
| 97 | - | } | 97 | + | } |
| 98 | - | 98 | + | ||
| 99 | - | // Event handlers | 99 | + | // Event handlers |
| 100 | - | if (/on\w+\s*=/gi.test(output)) { | 100 | + | if (/on\w+\s*=/gi.test(output)) { |
| 101 | - | vulnerabilities.push('Event handler injection'); | 101 | + | vulnerabilities.push('Event handler injection'); |
| 102 | - | } | 102 | + | } |
| 103 | - | 103 | + | ||
| 104 | - | // JavaScript URLs | 104 | + | // JavaScript URLs |
| 105 | - | if (/javascript\s*:/gi.test(output)) { | 105 | + | if (/javascript\s*:/gi.test(output)) { |
| 106 | - | vulnerabilities.push('JavaScript URL injection'); | 106 | + | vulnerabilities.push('JavaScript URL injection'); |
| 107 | - | } | 107 | + | } |
| 108 | - | 108 | + | ||
| 109 | - | // Data URLs with HTML/JS | 109 | + | // Data URLs with HTML/JS |
| 110 | - | if (/data\s*:\s*text\/html/gi.test(output)) { | 110 | + | if (/data\s*:\s*text\/html/gi.test(output)) { |
| 111 | - | vulnerabilities.push('Data URL HTML injection'); | 111 | + | vulnerabilities.push('Data URL HTML injection'); |
| 112 | - | } | 112 | + | } |
| 113 | - | 113 | + | ||
| 114 | - | // CSS expressions | 114 | + | // CSS expressions |
| 115 | - | if (/expression\s*\(/gi.test(output)) { | 115 | + | if (/expression\s*\(/gi.test(output)) { |
| 116 | - | vulnerabilities.push('CSS expression injection'); | 116 | + | vulnerabilities.push('CSS expression injection'); |
| 117 | - | } | 117 | + | } |
| 118 | - | 118 | + | ||
| 119 | - | // Dangerous CSS URLs | 119 | + | // Dangerous CSS URLs |
| 120 | - | if (/url\s*\(\s*['"]*javascript:/gi.test(output)) { | 120 | + | if (/url\s*\(\s*['"]*javascript:/gi.test(output)) { |
| 121 | - | vulnerabilities.push('CSS JavaScript URL injection'); | 121 | + | vulnerabilities.push('CSS JavaScript URL injection'); |
| 122 | - | } | 122 | + | } |
| 123 | - | 123 | + | ||
| 124 | - | // Iframe with dangerous src | 124 | + | // Iframe with dangerous src |
| 125 | - | if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) { | 125 | + | if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) { |
| 126 | - | vulnerabilities.push('Dangerous iframe injection'); | 126 | + | vulnerabilities.push('Dangerous iframe injection'); |
| 127 | - | } | 127 | + | } |
| 128 | - | 128 | + | ||
| 129 | - | // Object/embed tags | 129 | + | // Object/embed tags |
| 130 | - | if (/<(?:object|embed|applet)\b/gi.test(output)) { | 130 | + | if (/<(?:object|embed|applet)\b/gi.test(output)) { |
| 131 | - | vulnerabilities.push('Dangerous object/embed tag'); | 131 | + | vulnerabilities.push('Dangerous object/embed tag'); |
| 132 | - | } | 132 | + | } |
| 133 | - | 133 | + | ||
| 134 | - | // Style tag injection | 134 | + | // Style tag injection |
| 135 | - | if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) { | 135 | + | if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) { |
| 136 | - | vulnerabilities.push('Style tag injection'); | 136 | + | vulnerabilities.push('Style tag injection'); |
| 137 | - | } | 137 | + | } |
| 138 | - | 138 | + | ||
| 139 | - | return vulnerabilities; | 139 | + | return vulnerabilities; |
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | // Run XSS tests | 142 | // Run XSS tests | ||
| 143 | async function runXSSTests(): Promise<SecurityTestResult[]> { | 143 | async function runXSSTests(): Promise<SecurityTestResult[]> { | ||
| 144 | - | const results: SecurityTestResult[] = []; | 144 | + | const results: SecurityTestResult[] = []; |
| 145 | - | 145 | + | ||
| 146 | - | for (const vector of XSS_TEST_VECTORS) { | 146 | + | for (const vector of XSS_TEST_VECTORS) { |
| 147 | - | try { | 147 | + | try { |
| 148 | - | const output = await formatText(vector, DEFAULT_TEXT_STYLE, true); | 148 | + | const output = await formatText(vector, DEFAULT_TEXT_STYLE, true); |
| 149 | - | const vulnerabilities = detectVulnerabilities(output); | 149 | + | const vulnerabilities = detectVulnerabilities(output); |
| 150 | - | 150 | + | ||
| 151 | - | results.push({ | 151 | + | results.push({ |
| 152 | - | input: vector, | 152 | + | input: vector, |
| 153 | - | output, | 153 | + | output, |
| 154 | - | passed: vulnerabilities.length === 0, | 154 | + | passed: vulnerabilities.length === 0, |
| 155 | - | vulnerabilities, | 155 | + | vulnerabilities, |
| 156 | - | testType: 'XSS' | 156 | + | testType: 'XSS' |
| 157 | - | }); | 157 | + | }); |
| 158 | - | } catch (error) { | 158 | + | } catch (error) { |
| 159 | - | results.push({ | 159 | + | results.push({ |
| 160 | - | input: vector, | 160 | + | input: vector, |
| 161 | - | output: `Error: ${error}`, | 161 | + | output: `Error: ${error}`, |
| 162 | - | passed: true, // Errors are acceptable for malicious input | 162 | + | passed: true, // Errors are acceptable for malicious input |
| 163 | - | vulnerabilities: [], | 163 | + | vulnerabilities: [], |
| 164 | - | testType: 'XSS' | 164 | + | testType: 'XSS' |
| 165 | - | }); | 165 | + | }); |
| 166 | - | } | 166 | + | } |
| 167 | - | } | 167 | + | } |
| 168 | - | 168 | + | ||
| 169 | - | return results; | 169 | + | return results; |
| 170 | } | 170 | } | ||
| 171 | 171 | ||||
| 172 | // Run CSS injection tests | 172 | // Run CSS injection tests | ||
| 173 | async function runCSSInjectionTests(): Promise<SecurityTestResult[]> { | 173 | async function runCSSInjectionTests(): Promise<SecurityTestResult[]> { | ||
| 174 | - | const results: SecurityTestResult[] = []; | 174 | + | const results: SecurityTestResult[] = []; |
| 175 | - | 175 | + | ||
| 176 | - | for (const vector of CSS_INJECTION_VECTORS) { | 176 | + | for (const vector of CSS_INJECTION_VECTORS) { |
| 177 | - | try { | 177 | + | try { |
| 178 | - | const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any }; | 178 | + | const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' }; |
| 179 | - | const output = await formatText(`Test with style`, testStyle, true); | 179 | + | const output = await formatText(`Test with style`, testStyle, true); |
| 180 | - | const vulnerabilities = detectVulnerabilities(output); | 180 | + | const vulnerabilities = detectVulnerabilities(output); |
| 181 | - | 181 | + | ||
| 182 | - | results.push({ | 182 | + | results.push({ |
| 183 | - | input: vector, | 183 | + | input: vector, |
| 184 | - | output, | 184 | + | output, |
| 185 | - | passed: vulnerabilities.length === 0, | 185 | + | passed: vulnerabilities.length === 0, |
| 186 | - | vulnerabilities, | 186 | + | vulnerabilities, |
| 187 | - | testType: 'CSS Injection' | 187 | + | testType: 'CSS Injection' |
| 188 | - | }); | 188 | + | }); |
| 189 | - | } catch (error) { | 189 | + | } catch (error) { |
| 190 | - | results.push({ | 190 | + | results.push({ |
| 191 | - | input: vector, | 191 | + | input: vector, |
| 192 | - | output: `Error: ${error}`, | 192 | + | output: `Error: ${error}`, |
| 193 | - | passed: true, | 193 | + | passed: true, |
| 194 | - | vulnerabilities: [], | 194 | + | vulnerabilities: [], |
| 195 | - | testType: 'CSS Injection' | 195 | + | testType: 'CSS Injection' |
| 196 | - | }); | 196 | + | }); |
| 197 | - | } | 197 | + | } |
| 198 | - | } | 198 | + | } |
| 199 | - | 199 | + | ||
| 200 | - | return results; | 200 | + | return results; |
| 201 | } | 201 | } | ||
| 202 | 202 | ||||
| 203 | // Run validation tests | 203 | // Run validation tests | ||
| 204 | function runValidationTests(): SecurityTestResult[] { | 204 | function runValidationTests(): SecurityTestResult[] { | ||
| 205 | - | const results: SecurityTestResult[] = []; | 205 | + | const results: SecurityTestResult[] = []; |
| 206 | - | 206 | + | ||
| 207 | - | const testCases = [ | 207 | + | const testCases = [ |
| 208 | - | 'Normal text', | 208 | + | 'Normal text', |
| 209 | - | 'Text with <script>alert("XSS")</script>', | 209 | + | 'Text with <script>alert("XSS")</script>', |
| 210 | - | 'Very long text '.repeat(200), // Test length limits | 210 | + | 'Very long text '.repeat(200), // Test length limits |
| 211 | - | '', // Empty text | 211 | + | '', // Empty text |
| 212 | - | 'Text with\nnewlines\nand\ttabs', | 212 | + | 'Text with\nnewlines\nand\ttabs', |
| 213 | - | 'Special chars: <>&"\'`', | 213 | + | 'Special chars: <>&"\'`', |
| 214 | - | 'Unicode: 你好世界 🌍 🎉', | 214 | + | 'Unicode: 你好世界 🌍 🎉', |
| 215 | - | 'Mixed: Normal **bold** and <script>alert("XSS")</script>' | 215 | + | 'Mixed: Normal **bold** and <script>alert("XSS")</script>' |
| 216 | - | ]; | 216 | + | ]; |
| 217 | - | 217 | + | ||
| 218 | - | for (const testCase of testCases) { | 218 | + | for (const testCase of testCases) { |
| 219 | - | const validation = validateMessageContent(testCase); | 219 | + | const validation = validateMessageContent(testCase); |
| 220 | - | const vulnerabilities = detectVulnerabilities(validation.sanitizedContent); | 220 | + | const vulnerabilities = detectVulnerabilities(validation.sanitizedContent); |
| 221 | - | 221 | + | ||
| 222 | - | results.push({ | 222 | + | results.push({ |
| 223 | - | input: testCase, | 223 | + | input: testCase, |
| 224 | - | output: validation.sanitizedContent, | 224 | + | output: validation.sanitizedContent, |
| 225 | - | passed: validation.isValid && vulnerabilities.length === 0, | 225 | + | passed: validation.isValid && vulnerabilities.length === 0, |
| 226 | - | vulnerabilities: [...validation.errors, ...vulnerabilities], | 226 | + | vulnerabilities: [...validation.errors, ...vulnerabilities], |
| 227 | - | testType: 'Validation' | 227 | + | testType: 'Validation' |
| 228 | - | }); | 228 | + | }); |
| 229 | - | } | 229 | + | } |
| 230 | - | 230 | + | ||
| 231 | - | return results; | 231 | + | return results; |
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | // Run gradient text security tests | 234 | // Run gradient text security tests | ||
| 235 | function runGradientTests(): SecurityTestResult[] { | 235 | function runGradientTests(): SecurityTestResult[] { | ||
| 236 | - | const results: SecurityTestResult[] = []; | 236 | + | const results: SecurityTestResult[] = []; |
| 237 | - | 237 | + | ||
| 238 | - | const testCases = [ | 238 | + | const testCases = [ |
| 239 | - | 'Normal gradient text', | 239 | + | 'Normal gradient text', |
| 240 | - | '<script>alert("XSS")</script>', | 240 | + | '<script>alert("XSS")</script>', |
| 241 | - | 'Text with "quotes" and \'apostrophes\'', | 241 | + | 'Text with "quotes" and \'apostrophes\'', |
| 242 | - | 'Very long gradient text '.repeat(50), | 242 | + | 'Very long gradient text '.repeat(50), |
| 243 | - | 'Special chars: <>&\'"', | 243 | + | 'Special chars: <>&\'"', |
| 244 | - | '' | 244 | + | '' |
| 245 | - | ]; | 245 | + | ]; |
| 246 | - | 246 | + | ||
| 247 | - | const testColors = ['#FF0000', '#00FF00', '#0000FF']; | 247 | + | const testColors = ['#FF0000', '#00FF00', '#0000FF']; |
| 248 | - | 248 | + | ||
| 249 | - | for (const testCase of testCases) { | 249 | + | for (const testCase of testCases) { |
| 250 | - | try { | 250 | + | try { |
| 251 | - | const output = createGradientText(testCase, testColors); | 251 | + | const output = createGradientText(testCase, testColors); |
| 252 | - | const vulnerabilities = detectVulnerabilities(output); | 252 | + | const vulnerabilities = detectVulnerabilities(output); |
| 253 | - | 253 | + | ||
| 254 | - | results.push({ | 254 | + | results.push({ |
| 255 | - | input: testCase, | 255 | + | input: testCase, |
| 256 | - | output, | 256 | + | output, |
| 257 | - | passed: vulnerabilities.length === 0, | 257 | + | passed: vulnerabilities.length === 0, |
| 258 | - | vulnerabilities, | 258 | + | vulnerabilities, |
| 259 | - | testType: 'Gradient' | 259 | + | testType: 'Gradient' |
| 260 | - | }); | 260 | + | }); |
| 261 | - | } catch (error) { | 261 | + | } catch (error) { |
| 262 | - | results.push({ | 262 | + | results.push({ |
| 263 | - | input: testCase, | 263 | + | input: testCase, |
| 264 | - | output: `Error: ${error}`, | 264 | + | output: `Error: ${error}`, |
| 265 | - | passed: true, | 265 | + | passed: true, |
| 266 | - | vulnerabilities: [], | 266 | + | vulnerabilities: [], |
| 267 | - | testType: 'Gradient' | 267 | + | testType: 'Gradient' |
| 268 | - | }); | 268 | + | }); |
| 269 | - | } | 269 | + | } |
| 270 | - | } | 270 | + | } |
| 271 | - | 271 | + | ||
| 272 | - | return results; | 272 | + | return results; |
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | // Run complete security test suite | 275 | // Run complete security test suite | ||
| 276 | export async function runSecurityTestSuite(): Promise<SecurityTestSuite> { | 276 | export async function runSecurityTestSuite(): Promise<SecurityTestSuite> { | ||
| 277 | - | console.log('🔒 Running comprehensive security test suite...'); | 277 | + | console.log('🔒 Running comprehensive security test suite...'); |
| 278 | - | 278 | + | ||
| 279 | - | const xssTests = await runXSSTests(); | 279 | + | const xssTests = await runXSSTests(); |
| 280 | - | const cssInjectionTests = await runCSSInjectionTests(); | 280 | + | const cssInjectionTests = await runCSSInjectionTests(); |
| 281 | - | const validationTests = runValidationTests(); | 281 | + | const validationTests = runValidationTests(); |
| 282 | - | const gradientTests = runGradientTests(); | 282 | + | const gradientTests = runGradientTests(); |
| 283 | - | 283 | + | ||
| 284 | - | const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests]; | 284 | + | const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests]; |
| 285 | - | const passed = allTests.filter(test => test.passed).length; | 285 | + | const passed = allTests.filter((test) => test.passed).length; |
| 286 | - | const failed = allTests.length - passed; | 286 | + | const failed = allTests.length - passed; |
| 287 | - | 287 | + | ||
| 288 | - | // Collect all unique vulnerabilities | 288 | + | // Collect all unique vulnerabilities |
| 289 | - | const allVulnerabilities = new Set<string>(); | 289 | + | const allVulnerabilities = new Set<string>(); |
| 290 | - | allTests.forEach(test => { | 290 | + | allTests.forEach((test) => { |
| 291 | - | test.vulnerabilities.forEach(vuln => allVulnerabilities.add(vuln)); | 291 | + | test.vulnerabilities.forEach((vuln) => allVulnerabilities.add(vuln)); |
| 292 | - | }); | 292 | + | }); |
| 293 | - | 293 | + | ||
| 294 | - | const results: SecurityTestSuite = { | 294 | + | const results: SecurityTestSuite = { |
| 295 | - | xssTests, | 295 | + | xssTests, |
| 296 | - | cssInjectionTests, | 296 | + | cssInjectionTests, |
| 297 | - | validationTests, | 297 | + | validationTests, |
| 298 | - | gradientTests, | 298 | + | gradientTests, |
| 299 | - | summary: { | 299 | + | summary: { |
| 300 | - | totalTests: allTests.length, | 300 | + | totalTests: allTests.length, |
| 301 | - | passed, | 301 | + | passed, |
| 302 | - | failed, | 302 | + | failed, |
| 303 | - | vulnerabilities: Array.from(allVulnerabilities) | 303 | + | vulnerabilities: Array.from(allVulnerabilities) |
| 304 | - | } | 304 | + | } |
| 305 | - | }; | 305 | + | }; |
| 306 | - | 306 | + | ||
| 307 | - | console.log(`✅ Security tests completed:`); | 307 | + | console.log(`✅ Security tests completed:`); |
| 308 | - | console.log(` Total tests: ${results.summary.totalTests}`); | 308 | + | console.log(` Total tests: ${results.summary.totalTests}`); |
| 309 | - | console.log(` Passed: ${results.summary.passed}`); | 309 | + | console.log(` Passed: ${results.summary.passed}`); |
| 310 | - | console.log(` Failed: ${results.summary.failed}`); | 310 | + | console.log(` Failed: ${results.summary.failed}`); |
| 311 | - | 311 | + | ||
| 312 | - | if (results.summary.vulnerabilities.length > 0) { | 312 | + | if (results.summary.vulnerabilities.length > 0) { |
| 313 | - | console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities); | 313 | + | console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities); |
| 314 | - | } else { | 314 | + | } else { |
| 315 | - | console.log(`🛡️ No vulnerabilities detected!`); | 315 | + | console.log(`🛡️ No vulnerabilities detected!`); |
| 316 | - | } | 316 | + | } |
| 317 | - | 317 | + | ||
| 318 | - | return results; | 318 | + | return results; |
| 319 | } | 319 | } | ||
| 320 | 320 | ||||
| 321 | // Quick security check function | 321 | // Quick security check function | ||
| 322 | export async function quickSecurityCheck(input: string): Promise<boolean> { | 322 | export async function quickSecurityCheck(input: string): Promise<boolean> { | ||
| 323 | - | try { | 323 | + | try { |
| 324 | - | const output = await formatText(input, DEFAULT_TEXT_STYLE, true); | 324 | + | const output = await formatText(input, DEFAULT_TEXT_STYLE, true); |
| 325 | - | const vulnerabilities = detectVulnerabilities(output); | 325 | + | const vulnerabilities = detectVulnerabilities(output); |
| 326 | - | return vulnerabilities.length === 0; | 326 | + | return vulnerabilities.length === 0; |
| 327 | - | } catch (error) { | 327 | + | } catch { |
| 328 | - | // Errors on malicious input are acceptable | 328 | + | // Errors on malicious input are acceptable |
| 329 | - | return true; | 329 | + | return true; |
| 330 | - | } | 330 | + | } |
| 331 | } | 331 | } | ||
| 332 | 332 | ||||
| 333 | // Style validation tests | 333 | // Style validation tests | ||
| 334 | export function testStyleValidation(): boolean { | 334 | export function testStyleValidation(): boolean { | ||
| 335 | - | const testCases = [ | 335 | + | const testCases = [ |
| 336 | - | // Valid styles | 336 | + | // Valid styles |
| 337 | - | { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true }, | 337 | + | { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true }, |
| 338 | - | { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true }, | 338 | + | { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true }, |
| 339 | - | 339 | + | ||
| 340 | - | // Invalid styles (should be sanitized) | 340 | + | // Invalid styles (should be sanitized) |
| 341 | - | { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' }, | 341 | + | { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' }, |
| 342 | - | { fontFamily: 'tahoma', fontSize: -10, color: 'red' }, | 342 | + | { fontFamily: 'tahoma', fontSize: -10, color: 'red' }, |
| 343 | - | { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' } | 343 | + | { fontFamily: 'tahoma', fontSize: 'invalid' as unknown as number, color: 'red' } |
| 344 | - | ]; | 344 | + | ]; |
| 345 | - | 345 | + | ||
| 346 | - | for (const testCase of testCases) { | 346 | + | for (const testCase of testCases) { |
| 347 | - | try { | 347 | + | try { |
| 348 | - | const validated = validateTextStyle(testCase as Partial<TextStyle>); | 348 | + | const validated = validateTextStyle(testCase as Partial<TextStyle>); |
| 349 | - | 349 | + | ||
| 350 | - | // Check that validated style has safe values | 350 | + | // Check that validated style has safe values |
| 351 | - | if (!validated.fontFamily || | 351 | + | if (!validated.fontFamily || validated.fontSize < 8 || validated.fontSize > 72) { |
| 352 | - | validated.fontSize < 8 || | 352 | + | console.error('Style validation failed for:', testCase); |
| 353 | - | validated.fontSize > 72) { | 353 | + | return false; |
| 354 | - | console.error('Style validation failed for:', testCase); | 354 | + | } |
| 355 | - | return false; | 355 | + | } catch (error) { |
| 356 | - | } | 356 | + | console.error('Style validation error:', error); |
| 357 | - | } catch (error) { | 357 | + | return false; |
| 358 | - | console.error('Style validation error:', error); | 358 | + | } |
| 359 | - | return false; | 359 | + | } |
| 360 | - | } | 360 | + | |
| 361 | - | } | 361 | + | return true; |
| 362 | - | ||||
| 363 | - | return true; | |||
| 364 | } | 362 | } | ||
| 365 | 363 | ||||
| 366 | // Export test utilities | 364 | // Export test utilities | ||
| 367 | export { | 365 | export { | ||
| 368 | - | XSS_TEST_VECTORS, | 366 | + | XSS_TEST_VECTORS, |
| 369 | - | CSS_INJECTION_VECTORS, | 367 | + | CSS_INJECTION_VECTORS, |
| 370 | - | detectVulnerabilities, | 368 | + | detectVulnerabilities, |
| 371 | - | type SecurityTestResult, | 369 | + | type SecurityTestResult, |
| 372 | - | type SecurityTestSuite | 370 | + | type SecurityTestSuite |
| 373 | - | };⏎̸ | 371 | + | }; |
M src/lib/utils/sseClient.ts +27 -21
| @@ -3,24 +3,30 @@ | |||||
| 3 | * This helps avoid unauthenticated SSE requests that trigger rate limiting on the server. | 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> { | 6 | + | export function initSSE( |
| 7 | - | return new Promise((resolve, reject) => { | 7 | + | url: string, |
| 8 | - | let attempt = 0; | 8 | + | maxRetries: number = 5, |
| 9 | - | const tryConnect = () => { | 9 | + | retryDelay: number = 500 |
| 10 | - | if (document.cookie.includes('session=')) { | 10 | + | ): Promise<EventSource> { |
| 11 | - | console.debug('Session cookie found, initializing SSE connection'); | 11 | + | return new Promise((resolve, reject) => { |
| 12 | - | const eventSource = new EventSource(url); | 12 | + | let attempt = 0; |
| 13 | - | resolve(eventSource); | 13 | + | const tryConnect = () => { |
| 14 | - | } else { | 14 | + | if (document.cookie.includes('session=')) { |
| 15 | - | attempt++; | 15 | + | console.debug('Session cookie found, initializing SSE connection'); |
| 16 | - | console.debug(`Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`); | 16 | + | const eventSource = new EventSource(url); |
| 17 | - | if (attempt >= maxRetries) { | 17 | + | resolve(eventSource); |
| 18 | - | reject(new Error('Session cookie not found after maximum retries')); | 18 | + | } else { |
| 19 | - | } else { | 19 | + | attempt++; |
| 20 | - | setTimeout(tryConnect, retryDelay); | 20 | + | console.debug( |
| 21 | - | } | 21 | + | `Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...` |
| 22 | - | } | 22 | + | ); |
| 23 | - | }; | 23 | + | if (attempt >= maxRetries) { |
| 24 | - | tryConnect(); | 24 | + | reject(new Error('Session cookie not found after maximum retries')); |
| 25 | - | }); | 25 | + | } else { |
| 26 | - | } ⏎̸ | 26 | + | setTimeout(tryConnect, retryDelay); |
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | }; | |||
| 30 | + | tryConnect(); | |||
| 31 | + | }); | |||
| 32 | + | } | |||
M src/lib/utils/text-formatter.ts +302 -305
| @@ -4,351 +4,348 @@ import remarkRehype from 'remark-rehype'; | |||||
| 4 | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | 4 | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | ||
| 5 | import rehypeStringify from 'rehype-stringify'; | 5 | import rehypeStringify from 'rehype-stringify'; | ||
| 6 | import type { TextStyle } from '../types/text-formatting'; | 6 | import type { TextStyle } from '../types/text-formatting'; | ||
| 7 | - | import { generateCSSStyle, validateTextStyle } from '../types/text-formatting'; | 7 | + | import { validateTextStyle } from '../types/text-formatting'; |
| 8 | 8 | ||||
| 9 | // Define allowed HTML tags and attributes for security | 9 | // Define allowed HTML tags and attributes for security | ||
| 10 | const ALLOWED_SCHEMA = { | 10 | const ALLOWED_SCHEMA = { | ||
| 11 | - | ...defaultSchema, | 11 | + | ...defaultSchema, |
| 12 | - | tagNames: [ | 12 | + | tagNames: [...(defaultSchema.tagNames || []), 'span', 'strong', 'em', 'u', 's', 'mark'], |
| 13 | - | ...(defaultSchema.tagNames || []), | 13 | + | attributes: { |
| 14 | - | 'span', | 14 | + | ...defaultSchema.attributes, |
| 15 | - | 'strong', | 15 | + | span: ['style', 'class', 'data-gradient'], |
| 16 | - | 'em', | 16 | + | strong: ['style', 'class'], |
| 17 | - | 'u', | 17 | + | em: ['style', 'class'], |
| 18 | - | 's', | 18 | + | u: ['style', 'class'], |
| 19 | - | 'mark' | 19 | + | s: ['style', 'class'], |
| 20 | - | ], | 20 | + | mark: ['style', 'class'] |
| 21 | - | attributes: { | 21 | + | }, |
| 22 | - | ...defaultSchema.attributes, | 22 | + | protocols: { |
| 23 | - | span: ['style', 'class', 'data-gradient'], | 23 | + | ...defaultSchema.protocols, |
| 24 | - | strong: ['style', 'class'], | 24 | + | style: [] |
| 25 | - | em: ['style', 'class'], | 25 | + | } |
| 26 | - | u: ['style', 'class'], | |||
| 27 | - | s: ['style', 'class'], | |||
| 28 | - | mark: ['style', 'class'] | |||
| 29 | - | }, | |||
| 30 | - | protocols: { | |||
| 31 | - | ...defaultSchema.protocols, | |||
| 32 | - | style: [] | |||
| 33 | - | } | |||
| 34 | }; | 26 | }; | ||
| 35 | 27 | ||||
| 36 | - | // Custom rehype plugin to apply text styles | |||
| 37 | - | 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 | - | }; | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | // BBCode-style formatting patterns | 28 | // BBCode-style formatting patterns | ||
| 46 | const BB_CODE_PATTERNS: Array<{ | 29 | const BB_CODE_PATTERNS: Array<{ | ||
| 47 | - | pattern: RegExp; | 30 | + | pattern: RegExp; |
| 48 | - | replacement: string; | 31 | + | replacement: string; |
| 49 | }> = [ | 32 | }> = [ | ||
| 50 | - | // Bold | 33 | + | // Bold |
| 51 | - | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, | 34 | + | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, |
| 52 | - | 35 | + | ||
| 53 | - | // Italic | 36 | + | // Italic |
| 54 | - | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, | 37 | + | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, |
| 55 | - | 38 | + | ||
| 56 | - | // Underline | 39 | + | // Underline |
| 57 | - | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, | 40 | + | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, |
| 58 | - | 41 | + | ||
| 59 | - | // Strikethrough | 42 | + | // Strikethrough |
| 60 | - | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, | 43 | + | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, |
| 61 | - | 44 | + | ||
| 62 | - | // Color | 45 | + | // Color |
| 63 | - | { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' }, | 46 | + | { |
| 64 | - | 47 | + | pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, | |
| 65 | - | // Size | 48 | + | replacement: '<span style="color: $1">$2</span>' |
| 66 | - | { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' }, | 49 | + | }, |
| 67 | - | 50 | + | ||
| 68 | - | // Font | 51 | + | // Size |
| 69 | - | { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' } | 52 | + | { |
| 53 | + | pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, | |||
| 54 | + | replacement: '<span style="font-size: $1px">$2</span>' | |||
| 55 | + | }, | |||
| 56 | + | ||||
| 57 | + | // Font | |||
| 58 | + | { | |||
| 59 | + | pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, | |||
| 60 | + | replacement: '<span style="font-family: $1">$2</span>' | |||
| 61 | + | } | |||
| 70 | ]; | 62 | ]; | ||
| 71 | 63 | ||||
| 72 | - | // Markdown-style formatting patterns | 64 | + | // Markdown-style formatting patterns |
| 73 | const MARKDOWN_PATTERNS: Array<{ | 65 | const MARKDOWN_PATTERNS: Array<{ | ||
| 74 | - | pattern: RegExp; | 66 | + | pattern: RegExp; |
| 75 | - | replacement: string; | 67 | + | replacement: string; |
| 76 | }> = [ | 68 | }> = [ | ||
| 77 | - | // Bold (double asterisk or double underscore) | 69 | + | // Bold (double asterisk or double underscore) |
| 78 | - | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, | 70 | + | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, |
| 79 | - | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, | 71 | + | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, |
| 80 | - | 72 | + | ||
| 81 | - | // Italic (single asterisk or single underscore) | 73 | + | // Italic (single asterisk or single underscore) |
| 82 | - | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, | 74 | + | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, |
| 83 | - | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, | 75 | + | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, |
| 84 | - | 76 | + | ||
| 85 | - | // Strikethrough | 77 | + | // Strikethrough |
| 86 | - | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, | 78 | + | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, |
| 87 | - | 79 | + | ||
| 88 | - | // Inline code | 80 | + | // Inline code |
| 89 | - | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } | 81 | + | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } |
| 90 | ]; | 82 | ]; | ||
| 91 | 83 | ||||
| 92 | // Preprocess text to convert BBCode and Markdown to HTML | 84 | // Preprocess text to convert BBCode and Markdown to HTML | ||
| 93 | function preprocessFormatting(text: string): string { | 85 | function preprocessFormatting(text: string): string { | ||
| 94 | - | let processedText = text; | 86 | + | let processedText = text; |
| 95 | - | 87 | + | ||
| 96 | - | // Apply BBCode patterns first | 88 | + | // Apply BBCode patterns first |
| 97 | - | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { | 89 | + | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { |
| 98 | - | processedText = processedText.replace(pattern, replacement); | 90 | + | processedText = processedText.replace(pattern, replacement); |
| 99 | - | }); | 91 | + | }); |
| 100 | - | 92 | + | ||
| 101 | - | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) | 93 | + | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) |
| 102 | - | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { | 94 | + | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { |
| 103 | - | // Only apply if not already inside HTML tags | 95 | + | // Only apply if not already inside HTML tags |
| 104 | - | processedText = processedText.replace(pattern, (match, content) => { | 96 | + | processedText = processedText.replace(pattern, (match, content) => { |
| 105 | - | // Simple check to avoid replacing inside existing HTML tags | 97 | + | // Simple check to avoid replacing inside existing HTML tags |
| 106 | - | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); | 98 | + | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); |
| 107 | - | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; | 99 | + | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; |
| 108 | - | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; | 100 | + | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; |
| 109 | - | 101 | + | ||
| 110 | - | // If we're inside an unclosed tag, don't apply markdown | 102 | + | // If we're inside an unclosed tag, don't apply markdown |
| 111 | - | if (openTags > closeTags) { | 103 | + | if (openTags > closeTags) { |
| 112 | - | return match; | 104 | + | return match; |
| 113 | - | } | 105 | + | } |
| 114 | - | 106 | + | ||
| 115 | - | return replacement.replace('$1', content); | 107 | + | return replacement.replace('$1', content); |
| 116 | - | }); | 108 | + | }); |
| 117 | - | }); | 109 | + | }); |
| 118 | - | 110 | + | ||
| 119 | - | return processedText; | 111 | + | return processedText; |
| 120 | } | 112 | } | ||
| 121 | 113 | ||||
| 122 | // Main text formatting function | 114 | // Main text formatting function | ||
| 123 | export async function formatText( | 115 | export async function formatText( | ||
| 124 | - | text: string, | 116 | + | text: string, |
| 125 | - | userStyle?: Partial<TextStyle>, | 117 | + | userStyle?: Partial<TextStyle>, |
| 126 | - | allowRichFormatting: boolean = true | 118 | + | allowRichFormatting: boolean = true |
| 127 | ): Promise<string> { | 119 | ): Promise<string> { | ||
| 128 | - | try { | 120 | + | try { |
| 129 | - | // If text is empty, return empty string | 121 | + | // If text is empty, return empty string |
| 130 | - | if (!text) return ''; | 122 | + | if (!text) return ''; |
| 131 | - | 123 | + | ||
| 132 | - | // Validate and apply user style | 124 | + | // Validate user style (ensures safe values) |
| 133 | - | const validatedStyle = validateTextStyle(userStyle || {}); | 125 | + | validateTextStyle(userStyle || {}); |
| 134 | - | 126 | + | ||
| 135 | - | // Preprocess the text for formatting if allowed | 127 | + | // Preprocess the text for formatting if allowed |
| 136 | - | let processedText = text; | 128 | + | let processedText = text; |
| 137 | - | if (allowRichFormatting) { | 129 | + | if (allowRichFormatting) { |
| 138 | - | processedText = preprocessFormatting(text); | 130 | + | processedText = preprocessFormatting(text); |
| 139 | - | } | 131 | + | } |
| 140 | - | 132 | + | ||
| 141 | - | // If no rich formatting, just return escaped text | 133 | + | // If no rich formatting, just return escaped text |
| 142 | - | if (!allowRichFormatting && !processedText.includes('<')) { | 134 | + | if (!allowRichFormatting && !processedText.includes('<')) { |
| 143 | - | return escapeHtml(text); | 135 | + | return escapeHtml(text); |
| 144 | - | } | 136 | + | } |
| 145 | - | 137 | + | ||
| 146 | - | // Create the unified processor | 138 | + | // Create the unified processor |
| 147 | - | const processor = unified() | 139 | + | const processor = unified() |
| 148 | - | .use(remarkParse, { | 140 | + | .use(remarkParse, { |
| 149 | - | fragment: true // Parse as fragment, not full document | 141 | + | fragment: true // Parse as fragment, not full document |
| 150 | - | }) | 142 | + | }) |
| 151 | - | .use(remarkRehype, { | 143 | + | .use(remarkRehype, { |
| 152 | - | allowDangerousHtml: true // Allow preprocessed HTML to pass through | 144 | + | allowDangerousHtml: true // Allow preprocessed HTML to pass through |
| 153 | - | }) | 145 | + | }) |
| 154 | - | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML | 146 | + | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML |
| 155 | - | .use(rehypeStringify); | 147 | + | .use(rehypeStringify); |
| 156 | - | 148 | + | ||
| 157 | - | // Process the text | 149 | + | // Process the text |
| 158 | - | const result = await processor.process(processedText); | 150 | + | const result = await processor.process(processedText); |
| 159 | - | let formattedHtml = String(result).trim(); | 151 | + | let formattedHtml = String(result).trim(); |
| 160 | - | 152 | + | ||
| 161 | - | // If we got an empty result, return the escaped text | 153 | + | // If we got an empty result, return the escaped text |
| 162 | - | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { | 154 | + | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { |
| 163 | - | return escapeHtml(text); | 155 | + | return escapeHtml(text); |
| 164 | - | } | 156 | + | } |
| 165 | - | 157 | + | ||
| 166 | - | // Remove wrapping <p> tags if it's just a single paragraph | 158 | + | // Remove wrapping <p> tags if it's just a single paragraph |
| 167 | - | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { | 159 | + | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { |
| 168 | - | const pCount = (formattedHtml.match(/<p>/g) || []).length; | 160 | + | const pCount = (formattedHtml.match(/<p>/g) || []).length; |
| 169 | - | if (pCount === 1) { | 161 | + | if (pCount === 1) { |
| 170 | - | formattedHtml = formattedHtml.slice(3, -4).trim(); | 162 | + | formattedHtml = formattedHtml.slice(3, -4).trim(); |
| 171 | - | } | 163 | + | } |
| 172 | - | } | 164 | + | } |
| 173 | - | 165 | + | ||
| 174 | - | // Final check - if we ended up with empty content, return escaped text | 166 | + | // Final check - if we ended up with empty content, return escaped text |
| 175 | - | if (!formattedHtml) { | 167 | + | if (!formattedHtml) { |
| 176 | - | return escapeHtml(text); | 168 | + | return escapeHtml(text); |
| 177 | - | } | 169 | + | } |
| 178 | - | 170 | + | ||
| 179 | - | return formattedHtml; | 171 | + | return formattedHtml; |
| 180 | - | 172 | + | } catch (error) { | |
| 181 | - | } catch (error) { | 173 | + | console.error('Text formatting error:', error); |
| 182 | - | console.error('Text formatting error:', error); | 174 | + | // Return escaped plain text as fallback |
| 183 | - | // Return escaped plain text as fallback | 175 | + | return escapeHtml(text); |
| 184 | - | return escapeHtml(text); | 176 | + | } |
| 185 | - | } | |||
| 186 | } | 177 | } | ||
| 187 | 178 | ||||
| 188 | // Utility function to escape HTML | 179 | // Utility function to escape HTML | ||
| 189 | function escapeHtml(text: string): string { | 180 | function escapeHtml(text: string): string { | ||
| 190 | - | const div = document.createElement('div'); | 181 | + | const div = document.createElement('div'); |
| 191 | - | div.textContent = text; | 182 | + | div.textContent = text; |
| 192 | - | return div.innerHTML; | 183 | + | return div.innerHTML; |
| 193 | } | 184 | } | ||
| 194 | 185 | ||||
| 195 | // Generate CSS style string excluding font-family (which should be handled by classes) | 186 | // Generate CSS style string excluding font-family (which should be handled by classes) | ||
| 196 | function generateCSSStyleWithoutFont(style: TextStyle): string { | 187 | function generateCSSStyleWithoutFont(style: TextStyle): string { | ||
| 197 | - | const styles: string[] = []; | 188 | + | const styles: string[] = []; |
| 198 | - | 189 | + | ||
| 199 | - | // Font size | 190 | + | // Font size |
| 200 | - | styles.push(`font-size: ${style.fontSize}px`); | 191 | + | styles.push(`font-size: ${style.fontSize}px`); |
| 201 | - | 192 | + | ||
| 202 | - | // Color (not for gradients) | 193 | + | // Color (not for gradients) |
| 203 | - | if (style.color && !style.gradient) { | 194 | + | if (style.color && !style.gradient) { |
| 204 | - | styles.push(`color: ${style.color}`); | 195 | + | styles.push(`color: ${style.color}`); |
| 205 | - | } | 196 | + | } |
| 206 | - | 197 | + | ||
| 207 | - | // Font weight | 198 | + | // Font weight |
| 208 | - | if (style.bold) { | 199 | + | if (style.bold) { |
| 209 | - | styles.push(`font-weight: bold`); | 200 | + | styles.push(`font-weight: bold`); |
| 210 | - | } | 201 | + | } |
| 211 | - | 202 | + | ||
| 212 | - | // Font style | 203 | + | // Font style |
| 213 | - | if (style.italic) { | 204 | + | if (style.italic) { |
| 214 | - | styles.push(`font-style: italic`); | 205 | + | styles.push(`font-style: italic`); |
| 215 | - | } | 206 | + | } |
| 216 | - | 207 | + | ||
| 217 | - | // Text decoration | 208 | + | // Text decoration |
| 218 | - | const decorations: string[] = []; | 209 | + | const decorations: string[] = []; |
| 219 | - | if (style.underline) decorations.push('underline'); | 210 | + | if (style.underline) decorations.push('underline'); |
| 220 | - | if (style.strikethrough) decorations.push('line-through'); | 211 | + | if (style.strikethrough) decorations.push('line-through'); |
| 221 | - | if (decorations.length > 0) { | 212 | + | if (decorations.length > 0) { |
| 222 | - | styles.push(`text-decoration: ${decorations.join(' ')}`); | 213 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); |
| 223 | - | } | 214 | + | } |
| 224 | - | 215 | + | ||
| 225 | - | return styles.join('; '); | 216 | + | return styles.join('; '); |
| 226 | } | 217 | } | ||
| 227 | 218 | ||||
| 228 | // Create gradient text effect | 219 | // Create gradient text effect | ||
| 229 | export function createGradientText( | 220 | export function createGradientText( | ||
| 230 | - | text: string, | 221 | + | text: string, |
| 231 | - | colors: string[], | 222 | + | colors: string[], |
| 232 | - | className: string = 'gradient-text', | 223 | + | className: string = 'gradient-text', |
| 233 | - | style?: TextStyle | 224 | + | style?: TextStyle |
| 234 | ): string { | 225 | ): string { | ||
| 235 | - | console.debug('createGradientText called with:', { text, colors, className, style }); | 226 | + | console.debug('createGradientText called with:', { text, colors, className, style }); |
| 236 | - | 227 | + | ||
| 237 | - | if (colors.length < 2) { | 228 | + | if (colors.length < 2) { |
| 238 | - | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | 229 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; |
| 239 | - | const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | 230 | + | const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : ''; |
| 240 | - | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | 231 | + | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; |
| 241 | - | console.debug('Single color result:', result); | 232 | + | console.debug('Single color result:', result); |
| 242 | - | return result; | 233 | + | return result; |
| 243 | - | } | 234 | + | } |
| 244 | - | 235 | + | ||
| 245 | - | const chars = Array.from(text); | 236 | + | const chars = Array.from(text); |
| 246 | - | const totalChars = chars.length; | 237 | + | const totalChars = chars.length; |
| 247 | - | 238 | + | ||
| 248 | - | if (totalChars === 0) return ''; | 239 | + | if (totalChars === 0) return ''; |
| 249 | - | 240 | + | ||
| 250 | - | // Create spans for each character with interpolated colors | 241 | + | // Create spans for each character with interpolated colors |
| 251 | - | const gradientSpans = chars.map((char, index) => { | 242 | + | const gradientSpans = chars.map((char, index) => { |
| 252 | - | if (char === ' ') { | 243 | + | if (char === ' ') { |
| 253 | - | return ' '; // Use non-breaking space to preserve spacing | 244 | + | return ' '; // Use non-breaking space to preserve spacing |
| 254 | - | } | 245 | + | } |
| 255 | - | 246 | + | ||
| 256 | - | // Calculate color position (0 to 1) | 247 | + | // Calculate color position (0 to 1) |
| 257 | - | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | 248 | + | const position = totalChars === 1 ? 0 : index / (totalChars - 1); |
| 258 | - | 249 | + | ||
| 259 | - | // Find the two colors to interpolate between | 250 | + | // Find the two colors to interpolate between |
| 260 | - | const colorIndex = position * (colors.length - 1); | 251 | + | const colorIndex = position * (colors.length - 1); |
| 261 | - | const lowerIndex = Math.floor(colorIndex); | 252 | + | const lowerIndex = Math.floor(colorIndex); |
| 262 | - | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | 253 | + | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); |
| 263 | - | const t = colorIndex - lowerIndex; | 254 | + | const t = colorIndex - lowerIndex; |
| 264 | - | 255 | + | ||
| 265 | - | // Interpolate between colors | 256 | + | // Interpolate between colors |
| 266 | - | const color1 = hexToRgb(colors[lowerIndex]); | 257 | + | const color1 = hexToRgb(colors[lowerIndex]); |
| 267 | - | const color2 = hexToRgb(colors[upperIndex]); | 258 | + | const color2 = hexToRgb(colors[upperIndex]); |
| 268 | - | 259 | + | ||
| 269 | - | if (!color1 || !color2) { | 260 | + | if (!color1 || !color2) { |
| 270 | - | const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | 261 | + | const fallbackStyle = style |
| 271 | - | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | 262 | + | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) |
| 272 | - | } | 263 | + | : ''; |
| 273 | - | 264 | + | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |
| 274 | - | const r = Math.round(color1.r + t * (color2.r - color1.r)); | 265 | + | } |
| 275 | - | const g = Math.round(color1.g + t * (color2.g - color1.g)); | 266 | + | |
| 276 | - | const b = Math.round(color1.b + t * (color2.b - color1.b)); | 267 | + | const r = Math.round(color1.r + t * (color2.r - color1.r)); |
| 277 | - | 268 | + | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |
| 278 | - | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | 269 | + | const b = Math.round(color1.b + t * (color2.b - color1.b)); |
| 279 | - | 270 | + | ||
| 280 | - | // Combine interpolated color with other styles | 271 | + | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; |
| 281 | - | let inlineStyle = `color: ${interpolatedColor}`; | 272 | + | |
| 282 | - | if (style) { | 273 | + | // Combine interpolated color with other styles |
| 283 | - | // Get styles without font-family and color | 274 | + | let inlineStyle = `color: ${interpolatedColor}`; |
| 284 | - | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | 275 | + | if (style) { |
| 285 | - | if (style.bold) inlineStyle += `; font-weight: bold`; | 276 | + | // Get styles without font-family and color |
| 286 | - | if (style.italic) inlineStyle += `; font-style: italic`; | 277 | + | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; |
| 287 | - | const decorations: string[] = []; | 278 | + | if (style.bold) inlineStyle += `; font-weight: bold`; |
| 288 | - | if (style.underline) decorations.push('underline'); | 279 | + | if (style.italic) inlineStyle += `; font-style: italic`; |
| 289 | - | if (style.strikethrough) decorations.push('line-through'); | 280 | + | const decorations: string[] = []; |
| 290 | - | if (decorations.length > 0) { | 281 | + | if (style.underline) decorations.push('underline'); |
| 291 | - | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | 282 | + | if (style.strikethrough) decorations.push('line-through'); |
| 292 | - | } | 283 | + | if (decorations.length > 0) { |
| 293 | - | } | 284 | + | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; |
| 294 | - | 285 | + | } | |
| 295 | - | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | 286 | + | } |
| 296 | - | }); | 287 | + | |
| 297 | - | 288 | + | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 298 | - | // Wrap all spans in a container that uses flexbox to eliminate spacing | 289 | + | }); |
| 299 | - | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | 290 | + | |
| 300 | - | const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : ''; | 291 | + | // Wrap all spans in a container that uses flexbox to eliminate spacing |
| 301 | - | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | 292 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; |
| 302 | - | console.debug('Final gradient HTML:', result); | 293 | + | const containerStyle = style |
| 303 | - | return result; | 294 | + | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) |
| 295 | + | : ''; | |||
| 296 | + | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |||
| 297 | + | console.debug('Final gradient HTML:', result); | |||
| 298 | + | return result; | |||
| 304 | } | 299 | } | ||
| 305 | 300 | ||||
| 306 | // Helper function to convert hex to RGB | 301 | // Helper function to convert hex to RGB | ||
| 307 | function hexToRgb(hex: string): { r: number; g: number; b: number } | null { | 302 | 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); | 303 | + | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); |
| 309 | - | return result ? { | 304 | + | return result |
| 310 | - | r: parseInt(result[1], 16), | 305 | + | ? { |
| 311 | - | g: parseInt(result[2], 16), | 306 | + | r: parseInt(result[1], 16), |
| 312 | - | b: parseInt(result[3], 16) | 307 | + | g: parseInt(result[2], 16), |
| 313 | - | } : null; | 308 | + | b: parseInt(result[3], 16) |
| 309 | + | } | |||
| 310 | + | : null; | |||
| 314 | } | 311 | } | ||
| 315 | 312 | ||||
| 316 | // Validate message content for security | 313 | // Validate message content for security | ||
| 317 | export function validateMessageContent(content: string): { | 314 | export function validateMessageContent(content: string): { | ||
| 318 | - | isValid: boolean; | 315 | + | isValid: boolean; |
| 319 | - | sanitizedContent: string; | 316 | + | sanitizedContent: string; |
| 320 | - | errors: string[]; | 317 | + | errors: string[]; |
| 321 | } { | 318 | } { | ||
| 322 | - | const errors: string[] = []; | 319 | + | const errors: string[] = []; |
| 323 | - | let sanitizedContent = content; | 320 | + | let sanitizedContent = content; |
| 324 | - | 321 | + | ||
| 325 | - | // Check length | 322 | + | // Check length |
| 326 | - | if (content.length > 2000) { | 323 | + | if (content.length > 2000) { |
| 327 | - | errors.push('Message too long (max 2000 characters)'); | 324 | + | errors.push('Message too long (max 2000 characters)'); |
| 328 | - | sanitizedContent = content.substring(0, 2000); | 325 | + | sanitizedContent = content.substring(0, 2000); |
| 329 | - | } | 326 | + | } |
| 330 | - | 327 | + | ||
| 331 | - | // Check for potential XSS patterns | 328 | + | // Check for potential XSS patterns |
| 332 | - | const dangerousPatterns = [ | 329 | + | const dangerousPatterns = [ |
| 333 | - | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, | 330 | + | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, |
| 334 | - | /javascript:/gi, | 331 | + | /javascript:/gi, |
| 335 | - | /vbscript:/gi, | 332 | + | /vbscript:/gi, |
| 336 | - | /onload\s*=/gi, | 333 | + | /onload\s*=/gi, |
| 337 | - | /onerror\s*=/gi, | 334 | + | /onerror\s*=/gi, |
| 338 | - | /onclick\s*=/gi, | 335 | + | /onclick\s*=/gi, |
| 339 | - | /onmouseover\s*=/gi | 336 | + | /onmouseover\s*=/gi |
| 340 | - | ]; | 337 | + | ]; |
| 341 | - | 338 | + | ||
| 342 | - | dangerousPatterns.forEach(pattern => { | 339 | + | dangerousPatterns.forEach((pattern) => { |
| 343 | - | if (pattern.test(content)) { | 340 | + | if (pattern.test(content)) { |
| 344 | - | errors.push('Potentially dangerous content detected'); | 341 | + | errors.push('Potentially dangerous content detected'); |
| 345 | - | sanitizedContent = sanitizedContent.replace(pattern, ''); | 342 | + | sanitizedContent = sanitizedContent.replace(pattern, ''); |
| 346 | - | } | 343 | + | } |
| 347 | - | }); | 344 | + | }); |
| 348 | - | 345 | + | ||
| 349 | - | return { | 346 | + | return { |
| 350 | - | isValid: errors.length === 0, | 347 | + | isValid: errors.length === 0, |
| 351 | - | sanitizedContent, | 348 | + | sanitizedContent, |
| 352 | - | errors | 349 | + | errors |
| 353 | - | }; | 350 | + | }; |
| 354 | - | }⏎̸ | 351 | + | } |
M src/lib/utils/turnstile.server.ts +41 -41
| @@ -5,47 +5,47 @@ const log = createLogger('turnstile'); | |||||
| 5 | const isDev = process.env.NODE_ENV === 'development'; | 5 | const isDev = process.env.NODE_ENV === 'development'; | ||
| 6 | 6 | ||||
| 7 | interface TurnstileVerifyResponse { | 7 | interface TurnstileVerifyResponse { | ||
| 8 | - | "success": boolean; | 8 | + | success: boolean; |
| 9 | - | "error-codes"?: string[]; | 9 | + | 'error-codes'?: string[]; |
| 10 | - | "challenge_ts"?: string; | 10 | + | challenge_ts?: string; |
| 11 | - | "hostname"?: string; | 11 | + | hostname?: string; |
| 12 | - | "action"?: string; | 12 | + | action?: string; |
| 13 | - | "cdata"?: string; | 13 | + | cdata?: string; |
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> { | 16 | export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> { | ||
| 17 | - | // Always return true in development mode | 17 | + | // Always return true in development mode |
| 18 | - | if (isDev) { | 18 | + | if (isDev) { |
| 19 | - | log.debug('Development mode: bypassing Turnstile validation'); | 19 | + | log.debug('Development mode: bypassing Turnstile validation'); |
| 20 | - | return true; | 20 | + | return true; |
| 21 | - | } | 21 | + | } |
| 22 | - | 22 | + | ||
| 23 | - | try { | 23 | + | try { |
| 24 | - | const formData = new FormData(); | 24 | + | const formData = new FormData(); |
| 25 | - | formData.append('secret', TURNSTILE_SECRET_KEY); | 25 | + | formData.append('secret', TURNSTILE_SECRET_KEY); |
| 26 | - | formData.append('response', token); | 26 | + | formData.append('response', token); |
| 27 | - | if (remoteip) { | 27 | + | if (remoteip) { |
| 28 | - | formData.append('remoteip', remoteip); | 28 | + | formData.append('remoteip', remoteip); |
| 29 | - | } | 29 | + | } |
| 30 | - | 30 | + | ||
| 31 | - | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | 31 | + | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; |
| 32 | - | const result = await fetch(url, { | 32 | + | const result = await fetch(url, { |
| 33 | - | body: formData, | 33 | + | body: formData, |
| 34 | - | method: 'POST', | 34 | + | method: 'POST' |
| 35 | - | }); | 35 | + | }); |
| 36 | - | 36 | + | ||
| 37 | - | const outcome: TurnstileVerifyResponse = await result.json(); | 37 | + | const outcome: TurnstileVerifyResponse = await result.json(); |
| 38 | - | 38 | + | ||
| 39 | - | if (!outcome.success) { | 39 | + | if (!outcome.success) { |
| 40 | - | log.warn('Turnstile validation failed', { | 40 | + | log.warn('Turnstile validation failed', { |
| 41 | - | errors: outcome["error-codes"], | 41 | + | errors: outcome['error-codes'], |
| 42 | - | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined | 42 | + | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined |
| 43 | - | }); | 43 | + | }); |
| 44 | - | } | 44 | + | } |
| 45 | - | 45 | + | ||
| 46 | - | return outcome.success; | 46 | + | return outcome.success; |
| 47 | - | } catch (error) { | 47 | + | } catch (error) { |
| 48 | - | log.error('Error validating Turnstile token', { error }); | 48 | + | log.error('Error validating Turnstile token', { error }); |
| 49 | - | return false; | 49 | + | return false; |
| 50 | - | } | 50 | + | } |
| 51 | - | } ⏎̸ | 51 | + | } |
M src/lib/validation/password.ts +192 -171
| @@ -2,198 +2,219 @@ import { z } from 'zod'; | |||||
| 2 | 2 | ||||
| 3 | // Dynamic password constraints configuration | 3 | // Dynamic password constraints configuration | ||
| 4 | export interface PasswordConstraints { | 4 | export interface PasswordConstraints { | ||
| 5 | - | minLength: number; | 5 | + | minLength: number; |
| 6 | - | maxLength: number; | 6 | + | maxLength: number; |
| 7 | - | requireUppercase: boolean; | 7 | + | requireUppercase: boolean; |
| 8 | - | requireLowercase: boolean; | 8 | + | requireLowercase: boolean; |
| 9 | - | requireNumbers: boolean; | 9 | + | requireNumbers: boolean; |
| 10 | - | requireSpecialChars: boolean; | 10 | + | requireSpecialChars: boolean; |
| 11 | - | forbiddenPatterns: string[]; | 11 | + | forbiddenPatterns: string[]; |
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | // Default password constraints | 14 | // Default password constraints | ||
| 15 | export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = { | 15 | export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = { | ||
| 16 | - | minLength: 8, | 16 | + | minLength: 8, |
| 17 | - | maxLength: 64, | 17 | + | maxLength: 64, |
| 18 | - | requireUppercase: true, | 18 | + | requireUppercase: true, |
| 19 | - | requireLowercase: true, | 19 | + | requireLowercase: true, |
| 20 | - | requireNumbers: true, | 20 | + | requireNumbers: true, |
| 21 | - | requireSpecialChars: false, | 21 | + | requireSpecialChars: false, |
| 22 | - | forbiddenPatterns: ['password', '123456', 'qwerty', 'admin'] | 22 | + | forbiddenPatterns: ['password', '123456', 'qwerty', 'admin'] |
| 23 | }; | 23 | }; | ||
| 24 | 24 | ||||
| 25 | // Validation error messages | 25 | // Validation error messages | ||
| 26 | export const PASSWORD_ERROR_MESSAGES = { | 26 | export const PASSWORD_ERROR_MESSAGES = { | ||
| 27 | - | minLength: (min: number) => `Password must be at least ${min} characters long`, | 27 | + | minLength: (min: number) => `Password must be at least ${min} characters long`, |
| 28 | - | maxLength: (max: number) => `Password must be at most ${max} characters long`, | 28 | + | maxLength: (max: number) => `Password must be at most ${max} characters long`, |
| 29 | - | requireUppercase: 'Password must contain at least one uppercase letter', | 29 | + | requireUppercase: 'Password must contain at least one uppercase letter', |
| 30 | - | requireLowercase: 'Password must contain at least one lowercase letter', | 30 | + | requireLowercase: 'Password must contain at least one lowercase letter', |
| 31 | - | requireNumbers: 'Password must contain at least one number', | 31 | + | requireNumbers: 'Password must contain at least one number', |
| 32 | - | requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)', | 32 | + | requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)', |
| 33 | - | forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`, | 33 | + | forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`, |
| 34 | - | common: 'Password is too common or weak' | 34 | + | common: 'Password is too common or weak' |
| 35 | }; | 35 | }; | ||
| 36 | 36 | ||||
| 37 | // Create dynamic password schema based on constraints | 37 | // Create dynamic password schema based on constraints | ||
| 38 | - | export function createPasswordSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | 38 | + | export function createPasswordSchema( |
| 39 | - | let schema = z.string() | 39 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS |
| 40 | - | .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)) | 40 | + | ) { |
| 41 | - | .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | 41 | + | let schema = z |
| 42 | - | 42 | + | .string() | |
| 43 | - | // Add character type requirements | 43 | + | .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)) |
| 44 | - | if (constraints.requireUppercase) { | 44 | + | .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); |
| 45 | - | schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase); | 45 | + | |
| 46 | - | } | 46 | + | // Add character type requirements |
| 47 | - | 47 | + | if (constraints.requireUppercase) { | |
| 48 | - | if (constraints.requireLowercase) { | 48 | + | schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase); |
| 49 | - | schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase); | 49 | + | } |
| 50 | - | } | 50 | + | |
| 51 | - | 51 | + | if (constraints.requireLowercase) { | |
| 52 | - | if (constraints.requireNumbers) { | 52 | + | schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase); |
| 53 | - | schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers); | 53 | + | } |
| 54 | - | } | 54 | + | |
| 55 | - | 55 | + | if (constraints.requireNumbers) { | |
| 56 | - | if (constraints.requireSpecialChars) { | 56 | + | schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers); |
| 57 | - | schema = schema.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, PASSWORD_ERROR_MESSAGES.requireSpecialChars); | 57 | + | } |
| 58 | - | } | 58 | + | |
| 59 | - | 59 | + | if (constraints.requireSpecialChars) { | |
| 60 | - | // Add forbidden patterns check | 60 | + | schema = schema.regex( |
| 61 | - | if (constraints.forbiddenPatterns.length > 0) { | 61 | + | /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, |
| 62 | - | return schema.refine( | 62 | + | PASSWORD_ERROR_MESSAGES.requireSpecialChars |
| 63 | - | (password) => { | 63 | + | ); |
| 64 | - | const lowerPassword = password.toLowerCase(); | 64 | + | } |
| 65 | - | return !constraints.forbiddenPatterns.some(pattern => | 65 | + | |
| 66 | - | lowerPassword.includes(pattern.toLowerCase()) | 66 | + | // Add forbidden patterns check |
| 67 | - | ); | 67 | + | if (constraints.forbiddenPatterns.length > 0) { |
| 68 | - | }, | 68 | + | return schema.refine( |
| 69 | - | { | 69 | + | (password) => { |
| 70 | - | message: PASSWORD_ERROR_MESSAGES.common | 70 | + | const lowerPassword = password.toLowerCase(); |
| 71 | - | } | 71 | + | return !constraints.forbiddenPatterns.some((pattern) => |
| 72 | - | ); | 72 | + | lowerPassword.includes(pattern.toLowerCase()) |
| 73 | - | } | 73 | + | ); |
| 74 | - | 74 | + | }, | |
| 75 | - | return schema; | 75 | + | { |
| 76 | + | message: PASSWORD_ERROR_MESSAGES.common | |||
| 77 | + | } | |||
| 78 | + | ); | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return schema; | |||
| 76 | } | 82 | } | ||
| 77 | 83 | ||||
| 78 | // Login validation schema | 84 | // Login validation schema | ||
| 79 | export const loginSchema = z.object({ | 85 | export const loginSchema = z.object({ | ||
| 80 | - | username: z.string() | 86 | + | username: z |
| 81 | - | .min(3, 'Username must be at least 3 characters') | 87 | + | .string() |
| 82 | - | .max(32, 'Username must be at most 32 characters') | 88 | + | .min(3, 'Username must be at least 3 characters') |
| 83 | - | .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'), | 89 | + | .max(32, 'Username must be at most 32 characters') |
| 84 | - | password: z.string().min(1, 'Password is required') | 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 | // Registration validation schema (using dynamic password validation) | 97 | // Registration validation schema (using dynamic password validation) | ||
| 88 | - | export function createRegistrationSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | 98 | + | export function createRegistrationSchema( |
| 89 | - | return z.object({ | 99 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS |
| 90 | - | suUsername: z.string() | 100 | + | ) { |
| 91 | - | .min(3, 'Username must be at least 3 characters') | 101 | + | return z |
| 92 | - | .max(32, 'Username must be at most 32 characters') | 102 | + | .object({ |
| 93 | - | .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'), | 103 | + | suUsername: z |
| 94 | - | suPassword: createPasswordSchema(constraints), | 104 | + | .string() |
| 95 | - | suConfirmPassword: z.string(), | 105 | + | .min(3, 'Username must be at least 3 characters') |
| 96 | - | captchaAnswer: z.string().min(1, 'Please answer the PDR question'), | 106 | + | .max(32, 'Username must be at most 32 characters') |
| 97 | - | turnstileToken: z.string().optional() | 107 | + | .regex( |
| 98 | - | }).refine( | 108 | + | /^[a-zA-Z0-9_-]+$/, |
| 99 | - | (data) => data.suPassword === data.suConfirmPassword, | 109 | + | 'Username can only contain letters, numbers, underscores, and dashes' |
| 100 | - | { | 110 | + | ), |
| 101 | - | message: 'Passwords do not match', | 111 | + | suPassword: createPasswordSchema(constraints), |
| 102 | - | path: ['suConfirmPassword'] | 112 | + | suConfirmPassword: z.string(), |
| 103 | - | } | 113 | + | captchaAnswer: z.string().min(1, 'Please answer the PDR question'), |
| 104 | - | ); | 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 | // Utility function to validate password strength and return detailed feedback | 122 | // Utility function to validate password strength and return detailed feedback | ||
| 108 | - | export function validatePasswordStrength(password: string, constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | 123 | + | export function validatePasswordStrength( |
| 109 | - | const errors: string[] = []; | 124 | + | password: string, |
| 110 | - | const requirements: { met: boolean; message: string }[] = []; | 125 | + | constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS |
| 111 | - | 126 | + | ) { | |
| 112 | - | // Length checks | 127 | + | const errors: string[] = []; |
| 113 | - | if (password.length < constraints.minLength) { | 128 | + | const requirements: { met: boolean; message: string }[] = []; |
| 114 | - | errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)); | 129 | + | |
| 115 | - | } | 130 | + | // Length checks |
| 116 | - | requirements.push({ | 131 | + | if (password.length < constraints.minLength) { |
| 117 | - | met: password.length >= constraints.minLength, | 132 | + | errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)); |
| 118 | - | message: `At least ${constraints.minLength} characters` | 133 | + | } |
| 119 | - | }); | 134 | + | requirements.push({ |
| 120 | - | 135 | + | met: password.length >= constraints.minLength, | |
| 121 | - | if (password.length > constraints.maxLength) { | 136 | + | message: `At least ${constraints.minLength} characters` |
| 122 | - | errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | 137 | + | }); |
| 123 | - | } | 138 | + | |
| 124 | - | 139 | + | if (password.length > constraints.maxLength) { | |
| 125 | - | // Character type checks | 140 | + | errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); |
| 126 | - | if (constraints.requireUppercase) { | 141 | + | } |
| 127 | - | const hasUppercase = /[A-Z]/.test(password); | 142 | + | |
| 128 | - | if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase); | 143 | + | // Character type checks |
| 129 | - | requirements.push({ | 144 | + | if (constraints.requireUppercase) { |
| 130 | - | met: hasUppercase, | 145 | + | const hasUppercase = /[A-Z]/.test(password); |
| 131 | - | message: 'At least one uppercase letter' | 146 | + | if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase); |
| 132 | - | }); | 147 | + | requirements.push({ |
| 133 | - | } | 148 | + | met: hasUppercase, |
| 134 | - | 149 | + | message: 'At least one uppercase letter' | |
| 135 | - | if (constraints.requireLowercase) { | 150 | + | }); |
| 136 | - | const hasLowercase = /[a-z]/.test(password); | 151 | + | } |
| 137 | - | if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase); | 152 | + | |
| 138 | - | requirements.push({ | 153 | + | if (constraints.requireLowercase) { |
| 139 | - | met: hasLowercase, | 154 | + | const hasLowercase = /[a-z]/.test(password); |
| 140 | - | message: 'At least one lowercase letter' | 155 | + | if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase); |
| 141 | - | }); | 156 | + | requirements.push({ |
| 142 | - | } | 157 | + | met: hasLowercase, |
| 143 | - | 158 | + | message: 'At least one lowercase letter' | |
| 144 | - | if (constraints.requireNumbers) { | 159 | + | }); |
| 145 | - | const hasNumbers = /\d/.test(password); | 160 | + | } |
| 146 | - | if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers); | 161 | + | |
| 147 | - | requirements.push({ | 162 | + | if (constraints.requireNumbers) { |
| 148 | - | met: hasNumbers, | 163 | + | const hasNumbers = /\d/.test(password); |
| 149 | - | message: 'At least one number' | 164 | + | if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers); |
| 150 | - | }); | 165 | + | requirements.push({ |
| 151 | - | } | 166 | + | met: hasNumbers, |
| 152 | - | 167 | + | message: 'At least one number' | |
| 153 | - | if (constraints.requireSpecialChars) { | 168 | + | }); |
| 154 | - | const hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); | 169 | + | } |
| 155 | - | if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars); | 170 | + | |
| 156 | - | requirements.push({ | 171 | + | if (constraints.requireSpecialChars) { |
| 157 | - | met: hasSpecialChars, | 172 | + | const hasSpecialChars = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password); |
| 158 | - | message: 'At least one special character' | 173 | + | if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars); |
| 159 | - | }); | 174 | + | requirements.push({ |
| 160 | - | } | 175 | + | met: hasSpecialChars, |
| 161 | - | 176 | + | message: 'At least one special character' | |
| 162 | - | // Forbidden patterns check | 177 | + | }); |
| 163 | - | if (constraints.forbiddenPatterns.length > 0) { | 178 | + | } |
| 164 | - | const lowerPassword = password.toLowerCase(); | 179 | + | |
| 165 | - | const hasForbiddenPattern = constraints.forbiddenPatterns.some(pattern => | 180 | + | // Forbidden patterns check |
| 166 | - | lowerPassword.includes(pattern.toLowerCase()) | 181 | + | if (constraints.forbiddenPatterns.length > 0) { |
| 167 | - | ); | 182 | + | const lowerPassword = password.toLowerCase(); |
| 168 | - | if (hasForbiddenPattern) { | 183 | + | const hasForbiddenPattern = constraints.forbiddenPatterns.some((pattern) => |
| 169 | - | errors.push(PASSWORD_ERROR_MESSAGES.common); | 184 | + | lowerPassword.includes(pattern.toLowerCase()) |
| 170 | - | } | 185 | + | ); |
| 171 | - | requirements.push({ | 186 | + | if (hasForbiddenPattern) { |
| 172 | - | met: !hasForbiddenPattern, | 187 | + | errors.push(PASSWORD_ERROR_MESSAGES.common); |
| 173 | - | message: 'Not a common or weak password' | 188 | + | } |
| 174 | - | }); | 189 | + | requirements.push({ |
| 175 | - | } | 190 | + | met: !hasForbiddenPattern, |
| 176 | - | 191 | + | message: 'Not a common or weak password' | |
| 177 | - | return { | 192 | + | }); |
| 178 | - | isValid: errors.length === 0, | 193 | + | } |
| 179 | - | errors, | 194 | + | |
| 180 | - | requirements, | 195 | + | return { |
| 181 | - | strength: calculatePasswordStrength(password, requirements) | 196 | + | isValid: errors.length === 0, |
| 182 | - | }; | 197 | + | errors, |
| 198 | + | requirements, | |||
| 199 | + | strength: calculatePasswordStrength(password, requirements) | |||
| 200 | + | }; | |||
| 183 | } | 201 | } | ||
| 184 | 202 | ||||
| 185 | // Calculate password strength score (0-100) | 203 | // Calculate password strength score (0-100) | ||
| 186 | - | function calculatePasswordStrength(password: string, requirements: { met: boolean; message: string }[]): number { | 204 | + | function calculatePasswordStrength( |
| 187 | - | const metRequirements = requirements.filter(req => req.met).length; | 205 | + | password: string, |
| 188 | - | const totalRequirements = requirements.length; | 206 | + | requirements: { met: boolean; message: string }[] |
| 189 | - | 207 | + | ): number { | |
| 190 | - | if (totalRequirements === 0) return 0; | 208 | + | const metRequirements = requirements.filter((req) => req.met).length; |
| 191 | - | 209 | + | const totalRequirements = requirements.length; | |
| 192 | - | let baseScore = (metRequirements / totalRequirements) * 80; | 210 | + | |
| 193 | - | 211 | + | if (totalRequirements === 0) return 0; | |
| 194 | - | // Bonus points for length beyond minimum | 212 | + | |
| 195 | - | if (password.length > 12) baseScore += 10; | 213 | + | let baseScore = (metRequirements / totalRequirements) * 80; |
| 196 | - | if (password.length > 16) baseScore += 10; | 214 | + | |
| 197 | - | 215 | + | // Bonus points for length beyond minimum | |
| 198 | - | return Math.min(100, Math.round(baseScore)); | 216 | + | if (password.length > 12) baseScore += 10; |
| 199 | - | }⏎̸ | 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 | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | 7 | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | ||
| 8 | 8 | ||||
| 9 | export function validateHexColor(color: string): boolean { | 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 | export function validateFontFamily(fontFamily: string): boolean { | 13 | export function validateFontFamily(fontFamily: string): boolean { | ||
| 14 | - | return fontFamily in RETRO_FONTS; | 14 | + | return fontFamily in RETRO_FONTS; |
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | export function validateFontSize(size: number): number { | 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 | export function validateGradient(gradient: unknown): string[] | undefined { | 21 | export function validateGradient(gradient: unknown): string[] | undefined { | ||
| 22 | - | if (!Array.isArray(gradient)) return undefined; | 22 | + | if (!Array.isArray(gradient)) return undefined; |
| 23 | - | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | 23 | + | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; |
| 24 | - | 24 | + | ||
| 25 | - | const validColors = gradient.filter(color => | 25 | + | const validColors = gradient.filter( |
| 26 | - | typeof color === 'string' && validateHexColor(color) | 26 | + | (color) => typeof color === 'string' && validateHexColor(color) |
| 27 | - | ); | 27 | + | ); |
| 28 | - | 28 | + | ||
| 29 | - | return validColors.length === gradient.length ? validColors : undefined; | 29 | + | return validColors.length === gradient.length ? validColors : undefined; |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | export function validateTextStyle(style: unknown): TextStyle | null { | 32 | export function validateTextStyle(style: unknown): TextStyle | null { | ||
| 33 | - | if (!style || typeof style !== 'object') return null; | 33 | + | if (!style || typeof style !== 'object') return null; |
| 34 | - | 34 | + | ||
| 35 | - | const rawStyle = style as Record<string, unknown>; | 35 | + | const rawStyle = style as Record<string, unknown>; |
| 36 | - | 36 | + | ||
| 37 | - | const validated: TextStyle = { | 37 | + | const validated: TextStyle = { |
| 38 | - | fontFamily: 'pixelated', | 38 | + | fontFamily: 'pixelated', |
| 39 | - | fontSize: 14, | 39 | + | fontSize: 14, |
| 40 | - | bold: false, | 40 | + | bold: false, |
| 41 | - | italic: false, | 41 | + | italic: false, |
| 42 | - | underline: false, | 42 | + | underline: false, |
| 43 | - | strikethrough: false | 43 | + | strikethrough: false |
| 44 | - | }; | 44 | + | }; |
| 45 | - | 45 | + | ||
| 46 | - | // Validate font family | 46 | + | // Validate font family |
| 47 | - | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | 47 | + | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { |
| 48 | - | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | 48 | + | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; |
| 49 | - | } | 49 | + | } |
| 50 | - | 50 | + | ||
| 51 | - | // Validate font size | 51 | + | // Validate font size |
| 52 | - | if (typeof rawStyle.fontSize === 'number') { | 52 | + | if (typeof rawStyle.fontSize === 'number') { |
| 53 | - | validated.fontSize = validateFontSize(rawStyle.fontSize); | 53 | + | validated.fontSize = validateFontSize(rawStyle.fontSize); |
| 54 | - | } | 54 | + | } |
| 55 | - | 55 | + | ||
| 56 | - | // Validate color | 56 | + | // Validate color |
| 57 | - | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | 57 | + | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { |
| 58 | - | validated.color = rawStyle.color; | 58 | + | validated.color = rawStyle.color; |
| 59 | - | } | 59 | + | } |
| 60 | - | 60 | + | ||
| 61 | - | // Validate gradient | 61 | + | // Validate gradient |
| 62 | - | const validatedGradient = validateGradient(rawStyle.gradient); | 62 | + | const validatedGradient = validateGradient(rawStyle.gradient); |
| 63 | - | if (validatedGradient) { | 63 | + | if (validatedGradient) { |
| 64 | - | validated.gradient = validatedGradient; | 64 | + | validated.gradient = validatedGradient; |
| 65 | - | } | 65 | + | } |
| 66 | - | 66 | + | ||
| 67 | - | // Validate boolean properties | 67 | + | // Validate boolean properties |
| 68 | - | validated.bold = Boolean(rawStyle.bold); | 68 | + | validated.bold = Boolean(rawStyle.bold); |
| 69 | - | validated.italic = Boolean(rawStyle.italic); | 69 | + | validated.italic = Boolean(rawStyle.italic); |
| 70 | - | validated.underline = Boolean(rawStyle.underline); | 70 | + | validated.underline = Boolean(rawStyle.underline); |
| 71 | - | validated.strikethrough = Boolean(rawStyle.strikethrough); | 71 | + | validated.strikethrough = Boolean(rawStyle.strikethrough); |
| 72 | - | 72 | + | ||
| 73 | - | return validated; | 73 | + | return validated; |
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | 76 | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | ||
| 77 | - | if (!styleData) return undefined; | 77 | + | if (!styleData) return undefined; |
| 78 | - | 78 | + | ||
| 79 | - | // Handle JSON string input | 79 | + | // Handle JSON string input |
| 80 | - | let parsedData: unknown = styleData; | 80 | + | let parsedData: unknown = styleData; |
| 81 | - | if (typeof styleData === 'string') { | 81 | + | if (typeof styleData === 'string') { |
| 82 | - | try { | 82 | + | try { |
| 83 | - | parsedData = JSON.parse(styleData); | 83 | + | parsedData = JSON.parse(styleData); |
| 84 | - | } catch (error) { | 84 | + | } catch (error) { |
| 85 | - | console.warn('Failed to parse styleData JSON:', error); | 85 | + | console.warn('Failed to parse styleData JSON:', error); |
| 86 | - | return undefined; | 86 | + | return undefined; |
| 87 | - | } | 87 | + | } |
| 88 | - | } | 88 | + | } |
| 89 | - | 89 | + | ||
| 90 | - | const validated = validateTextStyle(parsedData); | 90 | + | const validated = validateTextStyle(parsedData); |
| 91 | - | return validated || undefined; | 91 | + | return validated || undefined; |
| 92 | - | }⏎̸ | 92 | + | } |
M src/routes/+layout.server.ts +6 -8
| @@ -1,10 +1,8 @@ | |||||
| 1 | import type { LayoutServerLoad } from './$types'; | 1 | import type { LayoutServerLoad } from './$types'; | ||
| 2 | 2 | ||||
| 3 | - | export const load: LayoutServerLoad = async ({ locals }) => { | 3 | + | export const load: LayoutServerLoad = async ({ locals }) => { |
| 4 | - | 4 | + | return { | |
| 5 | - | 5 | + | user: locals.user, | |
| 6 | - | return { | 6 | + | session: locals.session |
| 7 | - | user: locals.user, | 7 | + | }; |
| 8 | - | session: locals.session | 8 | + | }; |
| 9 | - | }; | |||
| 10 | - | }; ⏎̸ | |||
M src/routes/+layout.svelte +42 -38
| @@ -1,7 +1,7 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import '../app.css';; | 2 | + | import '../app.css'; |
| 3 | import { chatState } from '$lib/states/chat.svelte'; | 3 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 4 | - | import { onMount, onDestroy } from 'svelte'; | 4 | + | import { onMount } from 'svelte'; |
| 5 | import { browser } from '$app/environment'; | 5 | import { browser } from '$app/environment'; | ||
| 6 | 6 | ||||
| 7 | let { children, data } = $props(); | 7 | let { children, data } = $props(); | ||
| @@ -24,12 +24,12 @@ | |||||
| 24 | 24 | ||||
| 25 | async function updateUserStatus(status: UserStatus) { | 25 | async function updateUserStatus(status: UserStatus) { | ||
| 26 | if (!data.user?.id || updateInProgress) return; | 26 | if (!data.user?.id || updateInProgress) return; | ||
| 27 | - | 27 | + | ||
| 28 | const now = Date.now(); | 28 | const now = Date.now(); | ||
| 29 | const timeSinceLastUpdate = now - lastStatusSentAt; | 29 | const timeSinceLastUpdate = now - lastStatusSentAt; | ||
| 30 | - | 30 | + | ||
| 31 | if ( | 31 | if ( | ||
| 32 | - | status === lastSentStatus && | 32 | + | status === lastSentStatus && |
| 33 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | 33 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | ||
| 34 | timeSinceLastUpdate < THROTTLE_TIME_MS | 34 | timeSinceLastUpdate < THROTTLE_TIME_MS | ||
| 35 | ) { | 35 | ) { | ||
| @@ -57,18 +57,18 @@ | |||||
| 57 | try { | 57 | try { | ||
| 58 | if (status === 'online' && !document.cookie.includes('session=')) { | 58 | if (status === 'online' && !document.cookie.includes('session=')) { | ||
| 59 | console.debug('Waiting for session cookie before status update...'); | 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 | const response = await fetch('/api/status', { | 63 | const response = await fetch('/api/status', { | ||
| 64 | method: 'POST', | 64 | method: 'POST', | ||
| 65 | headers: { 'Content-Type': 'application/json' }, | 65 | headers: { 'Content-Type': 'application/json' }, | ||
| 66 | - | body: JSON.stringify({ | 66 | + | body: JSON.stringify({ |
| 67 | status, | 67 | status, | ||
| 68 | lastSeen: timestamp | 68 | lastSeen: timestamp | ||
| 69 | }) | 69 | }) | ||
| 70 | }); | 70 | }); | ||
| 71 | - | 71 | + | ||
| 72 | if (!response.ok) { | 72 | if (!response.ok) { | ||
| 73 | console.error('Failed to update user status:', await response.json()); | 73 | console.error('Failed to update user status:', await response.json()); | ||
| 74 | } else { | 74 | } else { | ||
| @@ -91,13 +91,13 @@ | |||||
| 91 | if (idleTimeout) { | 91 | if (idleTimeout) { | ||
| 92 | window.clearTimeout(idleTimeout); | 92 | window.clearTimeout(idleTimeout); | ||
| 93 | } | 93 | } | ||
| 94 | - | 94 | + | ||
| 95 | lastUserActivity = Date.now(); | 95 | lastUserActivity = Date.now(); | ||
| 96 | - | 96 | + | ||
| 97 | if (lastSentStatus === 'idle') { | 97 | if (lastSentStatus === 'idle') { | ||
| 98 | updateUserStatus('online'); | 98 | updateUserStatus('online'); | ||
| 99 | } | 99 | } | ||
| 100 | - | 100 | + | ||
| 101 | idleTimeout = window.setTimeout(() => { | 101 | idleTimeout = window.setTimeout(() => { | ||
| 102 | if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) { | 102 | if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) { | ||
| 103 | updateUserStatus('idle'); | 103 | updateUserStatus('idle'); | ||
| @@ -111,7 +111,7 @@ | |||||
| 111 | 111 | ||||
| 112 | function startStatusTicker() { | 112 | function startStatusTicker() { | ||
| 113 | if (statusInterval) return; | 113 | if (statusInterval) return; | ||
| 114 | - | 114 | + | ||
| 115 | console.debug('Starting status ticker'); | 115 | console.debug('Starting status ticker'); | ||
| 116 | const tickHandler = () => { | 116 | const tickHandler = () => { | ||
| 117 | if (isVisible && lastSentStatus !== 'idle') { | 117 | if (isVisible && lastSentStatus !== 'idle') { | ||
| @@ -132,7 +132,7 @@ | |||||
| 132 | function handleVisibilityChange() { | 132 | function handleVisibilityChange() { | ||
| 133 | if (!browser) return; | 133 | if (!browser) return; | ||
| 134 | isVisible = document.visibilityState === 'visible'; | 134 | isVisible = document.visibilityState === 'visible'; | ||
| 135 | - | 135 | + | ||
| 136 | if (isVisible) { | 136 | if (isVisible) { | ||
| 137 | resetIdleTimer(); | 137 | resetIdleTimer(); | ||
| 138 | updateUserStatus('online'); | 138 | updateUserStatus('online'); | ||
| @@ -162,12 +162,12 @@ | |||||
| 162 | 162 | ||||
| 163 | // Set initial online status | 163 | // Set initial online status | ||
| 164 | updateUserStatus('online'); | 164 | updateUserStatus('online'); | ||
| 165 | - | 165 | + | ||
| 166 | // Start monitoring for user activity | 166 | // Start monitoring for user activity | ||
| 167 | window.addEventListener('mousemove', handleUserActivity); | 167 | window.addEventListener('mousemove', handleUserActivity); | ||
| 168 | window.addEventListener('keydown', handleUserActivity); | 168 | window.addEventListener('keydown', handleUserActivity); | ||
| 169 | window.addEventListener('click', handleUserActivity); | 169 | window.addEventListener('click', handleUserActivity); | ||
| 170 | - | 170 | + | ||
| 171 | // Initialize idle timer | 171 | // Initialize idle timer | ||
| 172 | resetIdleTimer(); | 172 | resetIdleTimer(); | ||
| 173 | 173 | ||||
| @@ -176,7 +176,7 @@ | |||||
| 176 | 176 | ||||
| 177 | // Add visibility change listener | 177 | // Add visibility change listener | ||
| 178 | document.addEventListener('visibilitychange', handleVisibilityChange); | 178 | document.addEventListener('visibilitychange', handleVisibilityChange); | ||
| 179 | - | 179 | + | ||
| 180 | // Add beforeunload listener for cleanup | 180 | // Add beforeunload listener for cleanup | ||
| 181 | window.addEventListener('beforeunload', cleanup); | 181 | window.addEventListener('beforeunload', cleanup); | ||
| 182 | 182 | ||||
| @@ -189,32 +189,36 @@ | |||||
| 189 | 189 | ||||
| 190 | $effect(() => { | 190 | $effect(() => { | ||
| 191 | if (!browser) return; | 191 | if (!browser) return; | ||
| 192 | - | 192 | + | ||
| 193 | const currentUserJson = data.user ? JSON.stringify(data.user) : null; | 193 | const currentUserJson = data.user ? JSON.stringify(data.user) : null; | ||
| 194 | if (currentUserJson === lastUserUpdate) { | 194 | if (currentUserJson === lastUserUpdate) { | ||
| 195 | console.debug('Skipping duplicate user update'); | 195 | console.debug('Skipping duplicate user update'); | ||
| 196 | return; | 196 | return; | ||
| 197 | } | 197 | } | ||
| 198 | - | 198 | + | ||
| 199 | lastUserUpdate = currentUserJson; | 199 | lastUserUpdate = currentUserJson; | ||
| 200 | - | 200 | + | ||
| 201 | console.debug('Layout reactive update - Session state:', { | 201 | console.debug('Layout reactive update - Session state:', { | ||
| 202 | timestamp: new Date().toISOString(), | 202 | timestamp: new Date().toISOString(), | ||
| 203 | hasUser: !!data.user, | 203 | hasUser: !!data.user, | ||
| 204 | - | userDetails: data.user ? { | 204 | + | userDetails: data.user |
| 205 | - | nickname: data.user.nickname, | 205 | + | ? { |
| 206 | - | status: data.user.status, | 206 | + | nickname: data.user.nickname, |
| 207 | - | id: data.user.id | 207 | + | status: data.user.status, |
| 208 | - | } : null, | 208 | + | id: data.user.id |
| 209 | + | } | |||
| 210 | + | : null, | |||
| 209 | hasSession: !!data.session, | 211 | hasSession: !!data.session, | ||
| 210 | - | sessionDetails: data.session ? { | 212 | + | sessionDetails: data.session |
| 211 | - | id: data.session.id, | 213 | + | ? { |
| 212 | - | expiresAt: new Date(data.session.expiresAt) | 214 | + | id: data.session.id, |
| 213 | - | } : null, | 215 | + | expiresAt: new Date(data.session.expiresAt) |
| 216 | + | } | |||
| 217 | + | : null, | |||
| 214 | cookiePresent: document.cookie.includes('session='), | 218 | cookiePresent: document.cookie.includes('session='), | ||
| 215 | isVisible | 219 | isVisible | ||
| 216 | }); | 220 | }); | ||
| 217 | - | 221 | + | ||
| 218 | if (data.session?.id) { | 222 | if (data.session?.id) { | ||
| 219 | console.debug('Updating global chatState with data.user:', data.user); | 223 | console.debug('Updating global chatState with data.user:', data.user); | ||
| 220 | chatState.setCurrentUser(data.user); | 224 | chatState.setCurrentUser(data.user); | ||
| @@ -227,7 +231,7 @@ | |||||
| 227 | <meta name="description" content={data.meta.description} /> | 231 | <meta name="description" content={data.meta.description} /> | ||
| 228 | <meta name="keywords" content={data.meta.keywords} /> | 232 | <meta name="keywords" content={data.meta.keywords} /> | ||
| 229 | <meta name="language" content="fr" /> | 233 | <meta name="language" content="fr" /> | ||
| 230 | - | 234 | + | ||
| 231 | <!-- Open Graph / Facebook --> | 235 | <!-- Open Graph / Facebook --> | ||
| 232 | <meta property="og:locale" content={data.meta.locale} /> | 236 | <meta property="og:locale" content={data.meta.locale} /> | ||
| 233 | <meta property="og:type" content={data.meta.type} /> | 237 | <meta property="og:type" content={data.meta.type} /> | ||
| @@ -235,14 +239,14 @@ | |||||
| 235 | <meta property="og:title" content={data.meta.title} /> | 239 | <meta property="og:title" content={data.meta.title} /> | ||
| 236 | <meta property="og:description" content={data.meta.description} /> | 240 | <meta property="og:description" content={data.meta.description} /> | ||
| 237 | <meta property="og:image" content={data.meta.ogImage} /> | 241 | <meta property="og:image" content={data.meta.ogImage} /> | ||
| 238 | - | 242 | + | ||
| 239 | <!-- Twitter --> | 243 | <!-- Twitter --> | ||
| 240 | <meta property="twitter:card" content="summary_large_image" /> | 244 | <meta property="twitter:card" content="summary_large_image" /> | ||
| 241 | <meta property="twitter:url" content={data.meta.url} /> | 245 | <meta property="twitter:url" content={data.meta.url} /> | ||
| 242 | <meta property="twitter:title" content={data.meta.title} /> | 246 | <meta property="twitter:title" content={data.meta.title} /> | ||
| 243 | <meta property="twitter:description" content={data.meta.description} /> | 247 | <meta property="twitter:description" content={data.meta.description} /> | ||
| 244 | <meta property="twitter:image" content={data.meta.ogImage} /> | 248 | <meta property="twitter:image" content={data.meta.ogImage} /> | ||
| 245 | - | 249 | + | ||
| 246 | <!-- Additional Meta Tags --> | 250 | <!-- Additional Meta Tags --> | ||
| 247 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | 251 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 248 | <meta name="theme-color" content="#2195f2" /> | 252 | <meta name="theme-color" content="#2195f2" /> | ||
| @@ -266,11 +270,11 @@ | |||||
| 266 | overflow: hidden; | 270 | overflow: hidden; | ||
| 267 | background: linear-gradient( | 271 | background: linear-gradient( | ||
| 268 | to bottom, | 272 | to bottom, | ||
| 269 | - | #2195f2 0%, /* Lighter blue at the top */ | 273 | + | #2195f2 0%, |
| 270 | - | #4aa6e2 25%, /* Mid-light blue */ | 274 | + | /* Lighter blue at the top */ #4aa6e2 25%, |
| 271 | - | #7ab8e0 50%, /* Mid blue */ | 275 | + | /* Mid-light blue */ #7ab8e0 50%, |
| 272 | - | #86c55c 75%, /* Blue-green transition */ | 276 | + | /* Mid blue */ #86c55c 75%, |
| 273 | - | #90d047 100% /* Bright green at the bottom */ | 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 | import type { LayoutLoad } from './$types'; | 1 | import type { LayoutLoad } from './$types'; | ||
| 2 | 2 | ||||
| 3 | export const load: LayoutLoad = async ({ data, depends }) => { | 3 | export const load: LayoutLoad = async ({ data, depends }) => { | ||
| 4 | - | // Register client-side dependencies for invalidation | 4 | + | // Register client-side dependencies for invalidation |
| 5 | - | depends('app:session'); | 5 | + | depends('app:session'); |
| 6 | - | depends('app:chat'); | 6 | + | depends('app:chat'); |
| 7 | - | // Debug log for session data with more details | 7 | + | // Debug log for session data with more details |
| 8 | - | console.debug('Layout load - Client session data:', { | 8 | + | console.debug('Layout load - Client session data:', { |
| 9 | - | hasUser: !!data.user, | 9 | + | hasUser: !!data.user, |
| 10 | - | userDetails: data.user ? { | 10 | + | userDetails: data.user |
| 11 | - | nickname: data.user.nickname, | 11 | + | ? { |
| 12 | - | status: data.user.status, | 12 | + | nickname: data.user.nickname, |
| 13 | - | id: data.user.id | 13 | + | status: data.user.status, |
| 14 | - | } : null, | 14 | + | id: data.user.id |
| 15 | - | hasSession: !!data.session, | 15 | + | } |
| 16 | - | sessionDetails: data.session ? { | 16 | + | : null, |
| 17 | - | id: data.session.id, | 17 | + | hasSession: !!data.session, |
| 18 | - | expiresAt: new Date(data.session.expiresAt) | 18 | + | sessionDetails: data.session |
| 19 | - | } : null | 19 | + | ? { |
| 20 | - | }); | 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 | 26 | + | // Return the data from the server load function |
| 23 | - | return { | 27 | + | return { |
| 24 | - | user: data.user, | 28 | + | user: data.user, |
| 25 | - | session: data.session, | 29 | + | session: data.session, |
| 26 | - | meta: { | 30 | + | meta: { |
| 27 | - | title: 'PDR AIM - Sandbox', | 31 | + | 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.', | 32 | + | description: |
| 29 | - | keywords: 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro', | 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.", |
| 30 | - | ogImage: '/og-image.jpg', | 34 | + | keywords: |
| 31 | - | type: 'website', | 35 | + | 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro', |
| 32 | - | url: 'https://pdraim.org', | 36 | + | ogImage: '/og-image.jpg', |
| 33 | - | locale: 'fr_FR' | 37 | + | type: 'website', |
| 34 | - | } | 38 | + | url: 'https://pdraim.org', |
| 35 | - | }; | 39 | + | locale: 'fr_FR' |
| 36 | - | }; ⏎̸ | 40 | + | } |
| 41 | + | }; | |||
| 42 | + | }; | |||
M src/routes/+page.server.ts +56 -56
| @@ -1,63 +1,63 @@ | |||||
| 1 | import type { PageServerLoad } from './$types'; | 1 | import type { PageServerLoad } from './$types'; | ||
| 2 | import { validateSessionToken } from '$lib/api/session.server'; | 2 | import { validateSessionToken } from '$lib/api/session.server'; | ||
| 3 | import db from '$lib/db/db.server'; | 3 | import db from '$lib/db/db.server'; | ||
| 4 | - | import { userTextPreferences, users, messages } from '$lib/db/schema'; | 4 | + | import { messages } from '$lib/db/schema'; |
| 5 | import { eq, desc } from 'drizzle-orm'; | 5 | import { eq, desc } from 'drizzle-orm'; | ||
| 6 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | 6 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | ||
| 7 | 7 | ||||
| 8 | export const load: PageServerLoad = async ({ cookies }) => { | 8 | export const load: PageServerLoad = async ({ cookies }) => { | ||
| 9 | - | const token = cookies.get('session'); | 9 | + | const token = cookies.get('session'); |
| 10 | - | 10 | + | ||
| 11 | - | if (!token) { | 11 | + | if (!token) { |
| 12 | - | return { | 12 | + | return { |
| 13 | - | user: null, | 13 | + | user: null, |
| 14 | - | lastTextStyle: DEFAULT_TEXT_STYLE | 14 | + | lastTextStyle: DEFAULT_TEXT_STYLE |
| 15 | - | }; | 15 | + | }; |
| 16 | - | } | 16 | + | } |
| 17 | - | 17 | + | ||
| 18 | - | try { | 18 | + | try { |
| 19 | - | const { user } = await validateSessionToken(token); | 19 | + | const { user } = await validateSessionToken(token); |
| 20 | - | 20 | + | ||
| 21 | - | if (!user) { | 21 | + | if (!user) { |
| 22 | - | return { | 22 | + | return { |
| 23 | - | user: null, | 23 | + | user: null, |
| 24 | - | lastTextStyle: DEFAULT_TEXT_STYLE | 24 | + | lastTextStyle: DEFAULT_TEXT_STYLE |
| 25 | - | }; | 25 | + | }; |
| 26 | - | } | 26 | + | } |
| 27 | - | 27 | + | ||
| 28 | - | // Get user's last used text styling from their most recent message | 28 | + | // Get user's last used text styling from their most recent message |
| 29 | - | const lastMessage = await db.query.messages.findFirst({ | 29 | + | const lastMessage = await db.query.messages.findFirst({ |
| 30 | - | where: eq(messages.senderId, user.id), | 30 | + | where: eq(messages.senderId, user.id), |
| 31 | - | orderBy: [desc(messages.timestamp)], | 31 | + | orderBy: [desc(messages.timestamp)], |
| 32 | - | columns: { | 32 | + | columns: { |
| 33 | - | styleData: true | 33 | + | styleData: true |
| 34 | - | } | 34 | + | } |
| 35 | - | }); | 35 | + | }); |
| 36 | - | 36 | + | ||
| 37 | - | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | 37 | + | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; |
| 38 | - | 38 | + | ||
| 39 | - | if (lastMessage?.styleData) { | 39 | + | if (lastMessage?.styleData) { |
| 40 | - | try { | 40 | + | try { |
| 41 | - | const parsedStyle = JSON.parse(lastMessage.styleData); | 41 | + | const parsedStyle = JSON.parse(lastMessage.styleData); |
| 42 | - | // Merge with default style to ensure all required properties | 42 | + | // Merge with default style to ensure all required properties |
| 43 | - | lastTextStyle = { | 43 | + | lastTextStyle = { |
| 44 | - | ...DEFAULT_TEXT_STYLE, | 44 | + | ...DEFAULT_TEXT_STYLE, |
| 45 | - | ...parsedStyle | 45 | + | ...parsedStyle |
| 46 | - | }; | 46 | + | }; |
| 47 | - | } catch (error) { | 47 | + | } catch (error) { |
| 48 | - | console.warn('Failed to parse last text style:', error); | 48 | + | console.warn('Failed to parse last text style:', error); |
| 49 | - | } | 49 | + | } |
| 50 | - | } | 50 | + | } |
| 51 | - | 51 | + | ||
| 52 | - | return { | 52 | + | return { |
| 53 | - | user, | 53 | + | user, |
| 54 | - | lastTextStyle | 54 | + | lastTextStyle |
| 55 | - | }; | 55 | + | }; |
| 56 | - | } catch (error) { | 56 | + | } catch (error) { |
| 57 | - | console.error('Error loading page data:', error); | 57 | + | console.error('Error loading page data:', error); |
| 58 | - | return { | 58 | + | return { |
| 59 | - | user: null, | 59 | + | user: null, |
| 60 | - | lastTextStyle: DEFAULT_TEXT_STYLE | 60 | + | lastTextStyle: DEFAULT_TEXT_STYLE |
| 61 | - | }; | 61 | + | }; |
| 62 | - | } | 62 | + | } |
| 63 | - | };⏎̸ | 63 | + | }; |
M src/routes/+page.svelte +25 -29
| @@ -1,42 +1,38 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | import ChatRoom from '$lib/components/chat-room.svelte'; | 2 | + | import ChatRoom from '$lib/components/chat-room.svelte'; |
| 3 | - | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | 3 | + | import DesktopIcons from '$lib/components/desktop-icons.svelte'; |
| 4 | - | import AimLogin from '$lib/components/aim-login.svelte'; | 4 | + | import AimLogin from '$lib/components/aim-login.svelte'; |
| 5 | - | import type { PageData } from './$types'; | 5 | + | import type { PageData } from './$types'; |
| 6 | 6 | ||||
| 7 | - | let { data }: { data: PageData } = $props(); | 7 | + | let { data }: { data: PageData } = $props(); |
| 8 | - | ||||
| 9 | - | let showChatRoom = $state(true); | |||
| 10 | - | let showAuth = $state(false); | |||
| 11 | 8 | ||||
| 12 | - | function handleLoginSuccess() { | 9 | + | let showChatRoom = $state(true); |
| 13 | - | showAuth = false; | 10 | + | let showAuth = $state(false); |
| 14 | - | } | 11 | + | |
| 12 | + | function handleLoginSuccess() { | |||
| 13 | + | showAuth = false; | |||
| 14 | + | } | |||
| 15 | </script> | 15 | </script> | ||
| 16 | - | 16 | + | ||
| 17 | <div class="desktop content-wrapper"> | 17 | <div class="desktop content-wrapper"> | ||
| 18 | - | <DesktopIcons bind:showChatRoom bind:showAuth /> | 18 | + | <DesktopIcons bind:showChatRoom bind:showAuth /> |
| 19 | - | 19 | + | ||
| 20 | {#if showChatRoom} | 20 | {#if showChatRoom} | ||
| 21 | <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} /> | 21 | <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} /> | ||
| 22 | {/if} | 22 | {/if} | ||
| 23 | 23 | ||||
| 24 | {#if showAuth} | 24 | {#if showAuth} | ||
| 25 | - | <AimLogin | 25 | + | <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} /> |
| 26 | - | bind:showAuth | |||
| 27 | - | onLoginSuccess={handleLoginSuccess} | |||
| 28 | - | /> | |||
| 29 | {/if} | 26 | {/if} | ||
| 30 | </div> | 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 | import db from '$lib/db/db.server'; | 2 | import db from '$lib/db/db.server'; | ||
| 3 | import { messages } from '$lib/db/schema'; | 3 | import { messages } from '$lib/db/schema'; | ||
| 4 | import type { Message } from '$lib/types/chat'; | 4 | import type { Message } from '$lib/types/chat'; | ||
| 5 | - | import type { | 5 | + | import type { |
| 6 | - | SendMessageRequest, | 6 | + | SendMessageRequest, |
| 7 | - | SendMessageResponse, | 7 | + | SendMessageResponse, |
| 8 | - | GetMessagesResponse | 8 | + | GetMessagesResponse |
| 9 | } from '$lib/types/payloads'; | 9 | } from '$lib/types/payloads'; | ||
| 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | ||
| 11 | import type { SQL } from 'drizzle-orm/sql'; | 11 | import type { SQL } from 'drizzle-orm/sql'; | ||
| @@ -22,222 +22,232 @@ const log = createLogger('chat-server'); | |||||
| 22 | // Rate limiting configuration | 22 | // Rate limiting configuration | ||
| 23 | const INITIAL_COOLDOWN = 1000; // 1 second | 23 | const INITIAL_COOLDOWN = 1000; // 1 second | ||
| 24 | const MAX_COOLDOWN = 30000; // 30 seconds | 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 | function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } { | 30 | function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } { | ||
| 28 | - | const now = Date.now(); | 31 | + | const now = Date.now(); |
| 29 | - | const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN }; | 32 | + | const userState = userMessageTimestamps.get(userId) || { |
| 30 | - | 33 | + | lastMessageTime: 0, | |
| 31 | - | // Check if enough time has passed since the last message | 34 | + | currentCooldown: INITIAL_COOLDOWN |
| 32 | - | const timeSinceLastMessage = now - userState.lastMessageTime; | 35 | + | }; |
| 33 | - | if (timeSinceLastMessage < userState.currentCooldown) { | 36 | + | |
| 34 | - | return { | 37 | + | // Check if enough time has passed since the last message |
| 35 | - | canSend: false, | 38 | + | const timeSinceLastMessage = now - userState.lastMessageTime; |
| 36 | - | retryAfter: userState.currentCooldown - timeSinceLastMessage | 39 | + | if (timeSinceLastMessage < userState.currentCooldown) { |
| 37 | - | }; | 40 | + | return { |
| 38 | - | } | 41 | + | canSend: false, |
| 39 | - | 42 | + | retryAfter: userState.currentCooldown - timeSinceLastMessage | |
| 40 | - | // Reset cooldown if it's been long enough | 43 | + | }; |
| 41 | - | if (timeSinceLastMessage > userState.currentCooldown * 2) { | 44 | + | } |
| 42 | - | userState.currentCooldown = INITIAL_COOLDOWN; | 45 | + | |
| 43 | - | } else { | 46 | + | // Reset cooldown if it's been long enough |
| 44 | - | // Exponential backoff | 47 | + | if (timeSinceLastMessage > userState.currentCooldown * 2) { |
| 45 | - | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | 48 | + | userState.currentCooldown = INITIAL_COOLDOWN; |
| 46 | - | } | 49 | + | } else { |
| 47 | - | 50 | + | // Exponential backoff | |
| 48 | - | userState.lastMessageTime = now; | 51 | + | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); |
| 49 | - | userMessageTimestamps.set(userId, userState); | 52 | + | } |
| 50 | - | 53 | + | ||
| 51 | - | return { canSend: true }; | 54 | + | userState.lastMessageTime = now; |
| 55 | + | userMessageTimestamps.set(userId, userState); | |||
| 56 | + | ||||
| 57 | + | return { canSend: true }; | |||
| 52 | } | 58 | } | ||
| 53 | 59 | ||||
| 54 | // GET endpoint: fetch messages directly from DB | 60 | // GET endpoint: fetch messages directly from DB | ||
| 55 | export async function GET({ request, locals }) { | 61 | export async function GET({ request, locals }) { | ||
| 56 | - | const url = new URL(request.url); | 62 | + | const url = new URL(request.url); |
| 57 | - | const beforeTimestamp = url.searchParams.get('before'); | 63 | + | const beforeTimestamp = url.searchParams.get('before'); |
| 58 | - | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | 64 | + | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; |
| 59 | - | const isPublic = url.searchParams.get('public') === 'true'; | 65 | + | const isPublic = url.searchParams.get('public') === 'true'; |
| 60 | - | 66 | + | ||
| 61 | - | try { | 67 | + | try { |
| 62 | - | log.debug('Fetching messages from database', { | 68 | + | log.debug('Fetching messages from database', { |
| 63 | - | beforeTimestamp, | 69 | + | beforeTimestamp, |
| 64 | - | roomId, | 70 | + | roomId, |
| 65 | - | isAuthenticated: !!locals.session, | 71 | + | isAuthenticated: !!locals.session, |
| 66 | - | isPublic | 72 | + | isPublic |
| 67 | - | }); | 73 | + | }); |
| 68 | - | 74 | + | ||
| 69 | - | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | 75 | + | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; |
| 70 | - | 76 | + | ||
| 71 | - | if (beforeTimestamp) { | 77 | + | if (beforeTimestamp) { |
| 72 | - | conditions = and( | 78 | + | conditions = and( |
| 73 | - | conditions, | 79 | + | conditions, |
| 74 | - | lt(messages.timestamp, parseInt(beforeTimestamp)) | 80 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) |
| 75 | - | ) as SQL<unknown>; | 81 | + | ) as SQL<unknown>; |
| 76 | - | } | 82 | + | } |
| 77 | - | 83 | + | ||
| 78 | - | // Fetch messages with a limit based on authentication status | 84 | + | // Fetch messages with a limit based on authentication status |
| 79 | - | // Public requests are limited to 50 messages | 85 | + | // Public requests are limited to 50 messages |
| 80 | - | // Authenticated users get 100 messages per request | 86 | + | // Authenticated users get 100 messages per request |
| 81 | - | const fetchLimit = (isPublic || !locals.session) ? 50 : 100; | 87 | + | const fetchLimit = isPublic || !locals.session ? 50 : 100; |
| 82 | - | 88 | + | ||
| 83 | - | const fetchedMessages = await db.select() | 89 | + | const fetchedMessages = await db |
| 84 | - | .from(messages) | 90 | + | .select() |
| 85 | - | .where(conditions) | 91 | + | .from(messages) |
| 86 | - | .orderBy(desc(messages.timestamp)) | 92 | + | .where(conditions) |
| 87 | - | .limit(fetchLimit) | 93 | + | .orderBy(desc(messages.timestamp)) |
| 88 | - | .then(rows => rows.map(row => ({ | 94 | + | .limit(fetchLimit) |
| 89 | - | ...row, | 95 | + | .then((rows) => |
| 90 | - | styleData: row.styleData ?? undefined, | 96 | + | rows.map((row) => ({ |
| 91 | - | hasFormatting: row.hasFormatting ?? false | 97 | + | ...row, |
| 92 | - | }))); | 98 | + | styleData: row.styleData ?? undefined, |
| 93 | - | 99 | + | hasFormatting: row.hasFormatting ?? false | |
| 94 | - | const response: GetMessagesResponse = { | 100 | + | })) |
| 95 | - | success: true, | 101 | + | ); |
| 96 | - | messages: fetchedMessages, | 102 | + | |
| 97 | - | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | 103 | + | const response: GetMessagesResponse = { |
| 98 | - | }; | 104 | + | success: true, |
| 99 | - | 105 | + | messages: fetchedMessages, | |
| 100 | - | return new Response(JSON.stringify(response), { | 106 | + | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages |
| 101 | - | headers: { | 107 | + | }; |
| 102 | - | 'Content-Type': 'application/json' | 108 | + | |
| 103 | - | } | 109 | + | return new Response(JSON.stringify(response), { |
| 104 | - | }); | 110 | + | headers: { |
| 105 | - | } catch (err) { | 111 | + | 'Content-Type': 'application/json' |
| 106 | - | log.error('Error fetching messages:', { error: err }); | 112 | + | } |
| 107 | - | throw error(500, 'Failed to fetch messages'); | 113 | + | }); |
| 108 | - | } | 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 | // POST endpoint: receive a new message, save it in the DB, and broadcast via SSE | 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 }) { | 121 | + | export async function POST({ request, locals }: { request: Request; locals: App.Locals }) { |
| 113 | - | if (!locals.user) { | 122 | + | if (!locals.user) { |
| 114 | - | log.warn('Authentication required'); | 123 | + | log.warn('Authentication required'); |
| 115 | - | throw error(401, 'Authentication required'); | 124 | + | throw error(401, 'Authentication required'); |
| 116 | - | } | 125 | + | } |
| 117 | - | 126 | + | ||
| 118 | - | log.debug('New message received'); | 127 | + | log.debug('New message received'); |
| 119 | - | try { | 128 | + | try { |
| 120 | - | // Check rate limiting | 129 | + | // Check rate limiting |
| 121 | - | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); | 130 | + | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); |
| 122 | - | if (!canSend) { | 131 | + | if (!canSend) { |
| 123 | - | const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`; | 132 | + | const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`; |
| 124 | - | log.warn('Rate limited', { userId: maskedUserId, retryAfter }); | 133 | + | log.warn('Rate limited', { userId: maskedUserId, retryAfter }); |
| 125 | - | const errorResponse: SendMessageResponse = { | 134 | + | const errorResponse: SendMessageResponse = { |
| 126 | - | success: false, | 135 | + | success: false, |
| 127 | - | error: 'Please wait before sending another message', | 136 | + | error: 'Please wait before sending another message', |
| 128 | - | retryAfter, | 137 | + | retryAfter, |
| 129 | - | isRateLimited: true | 138 | + | isRateLimited: true |
| 130 | - | }; | 139 | + | }; |
| 131 | - | return new Response(JSON.stringify(errorResponse), { | 140 | + | return new Response(JSON.stringify(errorResponse), { |
| 132 | - | status: 429, | 141 | + | status: 429, |
| 133 | - | headers: { | 142 | + | headers: { |
| 134 | - | 'Content-Type': 'application/json', | 143 | + | 'Content-Type': 'application/json', |
| 135 | - | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() | 144 | + | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() |
| 136 | - | } | 145 | + | } |
| 137 | - | }); | 146 | + | }); |
| 138 | - | } | 147 | + | } |
| 139 | - | 148 | + | ||
| 140 | - | const data = await request.json() as SendMessageRequest; | 149 | + | const data = (await request.json()) as SendMessageRequest; |
| 141 | - | if (!data.content || !data.userId) { | 150 | + | if (!data.content || !data.userId) { |
| 142 | - | log.warn('Invalid payload received', { hasContent: Boolean(data.content), hasUserId: Boolean(data.userId) }); | 151 | + | log.warn('Invalid payload received', { |
| 143 | - | const errorResponse: SendMessageResponse = { | 152 | + | hasContent: Boolean(data.content), |
| 144 | - | success: false, | 153 | + | hasUserId: Boolean(data.userId) |
| 145 | - | error: 'Invalid message payload' | 154 | + | }); |
| 146 | - | }; | 155 | + | const errorResponse: SendMessageResponse = { |
| 147 | - | return new Response(JSON.stringify(errorResponse), { status: 400 }); | 156 | + | success: false, |
| 148 | - | } | 157 | + | error: 'Invalid message payload' |
| 149 | - | 158 | + | }; | |
| 150 | - | if (data.userId !== locals.user.id) { | 159 | + | return new Response(JSON.stringify(errorResponse), { status: 400 }); |
| 151 | - | log.warn('Unauthorized message attempt', { | 160 | + | } |
| 152 | - | requestedUserId: data.userId, | 161 | + | |
| 153 | - | actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` | 162 | + | if (data.userId !== locals.user.id) { |
| 154 | - | }); | 163 | + | log.warn('Unauthorized message attempt', { |
| 155 | - | throw error(403, 'Cannot post messages as another user'); | 164 | + | requestedUserId: data.userId, |
| 156 | - | } | 165 | + | actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` |
| 157 | - | 166 | + | }); | |
| 158 | - | const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID; | 167 | + | throw error(403, 'Cannot post messages as another user'); |
| 159 | - | 168 | + | } | |
| 160 | - | // Check chat room existence | 169 | + | |
| 161 | - | const chatRoom = await db.select() | 170 | + | const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID; |
| 162 | - | .from(chatRooms) | 171 | + | |
| 163 | - | .where(eq(chatRooms.id, chatRoomId)) | 172 | + | // Check chat room existence |
| 164 | - | .get(); | 173 | + | const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get(); |
| 165 | - | 174 | + | ||
| 166 | - | if (!chatRoom) { | 175 | + | if (!chatRoom) { |
| 167 | - | log.warn('Chat room not found', { chatRoomId }); | 176 | + | log.warn('Chat room not found', { chatRoomId }); |
| 168 | - | const errorResponse: SendMessageResponse = { | 177 | + | const errorResponse: SendMessageResponse = { |
| 169 | - | success: false, | 178 | + | success: false, |
| 170 | - | error: 'Chat room not found' | 179 | + | error: 'Chat room not found' |
| 171 | - | }; | 180 | + | }; |
| 172 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | 181 | + | return new Response(JSON.stringify(errorResponse), { status: 404 }); |
| 173 | - | } | 182 | + | } |
| 174 | - | 183 | + | ||
| 175 | - | // Check user existence | 184 | + | // Check user existence |
| 176 | - | const user = await db.select() | 185 | + | const user = await db.select().from(users).where(eq(users.id, data.userId)).get(); |
| 177 | - | .from(users) | 186 | + | |
| 178 | - | .where(eq(users.id, data.userId)) | 187 | + | if (!user) { |
| 179 | - | .get(); | 188 | + | const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`; |
| 180 | - | 189 | + | log.warn('User not found', { maskedUserId }); | |
| 181 | - | if (!user) { | 190 | + | const errorResponse: SendMessageResponse = { |
| 182 | - | const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`; | 191 | + | success: false, |
| 183 | - | log.warn('User not found', { maskedUserId }); | 192 | + | error: 'User not found' |
| 184 | - | const errorResponse: SendMessageResponse = { | 193 | + | }; |
| 185 | - | success: false, | 194 | + | return new Response(JSON.stringify(errorResponse), { status: 404 }); |
| 186 | - | error: 'User not found' | 195 | + | } |
| 187 | - | }; | 196 | + | |
| 188 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | 197 | + | const validatedStyleData = sanitizeStyleData(data.styleData); |
| 189 | - | } | 198 | + | |
| 190 | - | 199 | + | const newMessage: Message = { | |
| 191 | - | const validatedStyleData = sanitizeStyleData(data.styleData); | 200 | + | id: uuidv4(), |
| 192 | - | 201 | + | chatRoomId, | |
| 193 | - | const newMessage: Message = { | 202 | + | senderId: data.userId, |
| 194 | - | id: uuidv4(), | 203 | + | content: data.content, |
| 195 | - | chatRoomId, | 204 | + | type: data.type || 'chat', |
| 196 | - | senderId: data.userId, | 205 | + | timestamp: Date.now(), |
| 197 | - | content: data.content, | 206 | + | styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, |
| 198 | - | type: data.type || 'chat', | 207 | + | hasFormatting: Boolean(validatedStyleData) |
| 199 | - | timestamp: Date.now(), | 208 | + | }; |
| 200 | - | styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, | 209 | + | |
| 201 | - | hasFormatting: Boolean(validatedStyleData) | 210 | + | // debug style data content at this point |
| 202 | - | }; | 211 | + | log.debug('Style data content', { styleData: validatedStyleData }); |
| 203 | - | 212 | + | ||
| 204 | - | 213 | + | // Save to DB | |
| 205 | - | // debug style data content at this point | 214 | + | await db.insert(messages).values(newMessage); |
| 206 | - | log.debug('Style data content', { styleData: validatedStyleData }); | 215 | + | log.debug('Message saved in DB', { |
| 207 | - | 216 | + | messageId: newMessage.id, | |
| 208 | - | // Save to DB | 217 | + | chatRoomId: newMessage.chatRoomId, |
| 209 | - | await db.insert(messages).values(newMessage); | 218 | + | type: newMessage.type, |
| 210 | - | log.debug('Message saved in DB', { messageId: newMessage.id, chatRoomId: newMessage.chatRoomId, type: newMessage.type, timestamp: newMessage.timestamp }); | 219 | + | timestamp: newMessage.timestamp |
| 211 | - | 220 | + | }); | |
| 212 | - | // Broadcast the new message via SSE | 221 | + | |
| 213 | - | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | 222 | + | // Broadcast the new message via SSE |
| 214 | - | 223 | + | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 215 | - | log.debug('Message processed successfully', { messageId: newMessage.id, userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, roomId: newMessage.chatRoomId }); | 224 | + | |
| 216 | - | 225 | + | log.debug('Message processed successfully', { | |
| 217 | - | const successResponse: SendMessageResponse = { | 226 | + | messageId: newMessage.id, |
| 218 | - | success: true, | 227 | + | userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, |
| 219 | - | message: newMessage | 228 | + | roomId: newMessage.chatRoomId |
| 220 | - | }; | 229 | + | }); |
| 221 | - | 230 | + | ||
| 222 | - | return new Response( | 231 | + | const successResponse: SendMessageResponse = { |
| 223 | - | JSON.stringify(successResponse), | 232 | + | success: true, |
| 224 | - | { | 233 | + | message: newMessage |
| 225 | - | status: 201, | 234 | + | }; |
| 226 | - | headers: { 'Content-Type': 'application/json' } | 235 | + | |
| 227 | - | } | 236 | + | return new Response(JSON.stringify(successResponse), { |
| 228 | - | ); | 237 | + | status: 201, |
| 229 | - | } catch (error) { | 238 | + | headers: { 'Content-Type': 'application/json' } |
| 230 | - | log.error('Error processing message', { error: error instanceof Error ? error.message : 'Unknown error' }); | 239 | + | }); |
| 231 | - | const errorResponse: SendMessageResponse = { | 240 | + | } catch (error) { |
| 232 | - | success: false, | 241 | + | log.error('Error processing message', { |
| 233 | - | error: 'Failed to save message' | 242 | + | error: error instanceof Error ? error.message : 'Unknown error' |
| 234 | - | }; | 243 | + | }); |
| 235 | - | return new Response( | 244 | + | const errorResponse: SendMessageResponse = { |
| 236 | - | JSON.stringify(errorResponse), | 245 | + | success: false, |
| 237 | - | { | 246 | + | error: 'Failed to save message' |
| 238 | - | status: 500, | 247 | + | }; |
| 239 | - | headers: { 'Content-Type': 'application/json' } | 248 | + | return new Response(JSON.stringify(errorResponse), { |
| 240 | - | } | 249 | + | status: 500, |
| 241 | - | ); | 250 | + | headers: { 'Content-Type': 'application/json' } |
| 242 | - | } | 251 | + | }); |
| 243 | - | }⏎̸ | 252 | + | } |
| 253 | + | } | |||
M src/routes/api/register/+server.ts +55 -28
| @@ -8,33 +8,40 @@ import { createLogger } from '$lib/utils/logger.server'; | |||||
| 8 | import { eq } from 'drizzle-orm/sql'; | 8 | import { eq } from 'drizzle-orm/sql'; | ||
| 9 | 9 | ||||
| 10 | const log = createLogger('register-server'); | 10 | const log = createLogger('register-server'); | ||
| 11 | - | const isDev = process.env.NODE_ENV === 'development'; | |||
| 12 | 11 | ||||
| 13 | // In-memory map to track failed captcha attempts per IP | 12 | // In-memory map to track failed captcha attempts per IP | ||
| 14 | - | const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>(); | 13 | + | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); |
| 15 | 14 | ||||
| 16 | // Cleanup old entries every hour | 15 | // Cleanup old entries every hour | ||
| 17 | - | setInterval(() => { | 16 | + | setInterval( |
| 18 | - | const now = Date.now(); | 17 | + | () => { |
| 19 | - | const ONE_HOUR = 60 * 60 * 1000; | 18 | + | const now = Date.now(); |
| 20 | - | for (const [ip, data] of captchaAttempts.entries()) { | 19 | + | const ONE_HOUR = 60 * 60 * 1000; |
| 21 | - | if (now - data.lastAttempt > ONE_HOUR) { | 20 | + | for (const [ip, data] of captchaAttempts.entries()) { |
| 22 | - | captchaAttempts.delete(ip); | 21 | + | if (now - data.lastAttempt > ONE_HOUR) { |
| 22 | + | captchaAttempts.delete(ip); | |||
| 23 | + | } | |||
| 23 | } | 24 | } | ||
| 24 | - | } | 25 | + | }, |
| 25 | - | }, 60 * 60 * 1000); | 26 | + | 60 * 60 * 1000 |
| 27 | + | ); | |||
| 26 | 28 | ||||
| 27 | export const POST: RequestHandler = async ({ request }) => { | 29 | export const POST: RequestHandler = async ({ request }) => { | ||
| 28 | log.debug('New registration attempt received'); | 30 | log.debug('New registration attempt received'); | ||
| 29 | 31 | ||||
| 30 | if (request.method !== 'POST') { | 32 | if (request.method !== 'POST') { | ||
| 31 | log.warn('Invalid method used', { method: request.method }); | 33 | log.warn('Invalid method used', { method: request.method }); | ||
| 32 | - | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 }); | 34 | + | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { |
| 35 | + | status: 405 | |||
| 36 | + | }); | |||
| 33 | } | 37 | } | ||
| 34 | 38 | ||||
| 35 | // Get the IP address for rate limiting and Turnstile validation | 39 | // Get the IP address for rate limiting and Turnstile validation | ||
| 36 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | 40 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | ||
| 37 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | 41 | + | const maskedIp = ip |
| 42 | + | .split('.') | |||
| 43 | + | .map((octet, idx) => (idx < 3 ? 'xxx' : octet)) | |||
| 44 | + | .join('.'); | |||
| 38 | const now = Date.now(); | 45 | const now = Date.now(); | ||
| 39 | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | 46 | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | ||
| 40 | 47 | ||||
| @@ -43,15 +50,17 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 43 | body = await request.json(); | 50 | body = await request.json(); | ||
| 44 | } catch { | 51 | } catch { | ||
| 45 | log.warn('Invalid JSON payload received'); | 52 | log.warn('Invalid JSON payload received'); | ||
| 46 | - | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 }); | 53 | + | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { |
| 54 | + | status: 400 | |||
| 55 | + | }); | |||
| 47 | } | 56 | } | ||
| 48 | 57 | ||||
| 49 | - | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { | 58 | + | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { |
| 50 | - | suUsername: string, | 59 | + | suUsername: string; |
| 51 | - | suPassword: string, | 60 | + | suPassword: string; |
| 52 | - | suConfirmPassword: string, | 61 | + | suConfirmPassword: string; |
| 53 | - | captchaAnswer: string, | 62 | + | captchaAnswer: string; |
| 54 | - | turnstileToken?: string | 63 | + | turnstileToken?: string; |
| 55 | }; | 64 | }; | ||
| 56 | 65 | ||||
| 57 | // Validate input using Zod schema | 66 | // Validate input using Zod schema | ||
| @@ -64,13 +73,15 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 64 | captchaAnswer, | 73 | captchaAnswer, | ||
| 65 | turnstileToken | 74 | turnstileToken | ||
| 66 | }); | 75 | }); | ||
| 67 | - | } catch (err: any) { | 76 | + | } catch (err: unknown) { |
| 68 | - | const errorMessage = err.errors?.[0]?.message || 'Invalid input data'; | 77 | + | const zodError = err as { errors?: Array<{ message?: string }> }; |
| 78 | + | const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data'; | |||
| 69 | log.warn('Registration validation failed', { error: errorMessage }); | 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 | // Validate PDR captcha | 85 | // Validate PDR captcha | ||
| 75 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | 86 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | ||
| 76 | if (normalizedAnswer !== 'point de rencontre') { | 87 | if (normalizedAnswer !== 'point de rencontre') { | ||
| @@ -78,7 +89,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 78 | attemptData.lastAttempt = now; | 89 | attemptData.lastAttempt = now; | ||
| 79 | captchaAttempts.set(ip, attemptData); | 90 | captchaAttempts.set(ip, attemptData); | ||
| 80 | log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count }); | 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 | // Reset captcha attempts on success | 98 | // Reset captcha attempts on success | ||
| @@ -92,7 +106,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 92 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | 106 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | ||
| 93 | if (existingUser.length > 0) { | 107 | if (existingUser.length > 0) { | ||
| 94 | log.warn('Username already exists', { username }); | 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 | // Securely hash the user's password. | 115 | // Securely hash the user's password. | ||
| @@ -101,7 +118,10 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 101 | hashedPassword = await hashPassword(password); | 118 | hashedPassword = await hashPassword(password); | ||
| 102 | } catch { | 119 | } catch { | ||
| 103 | log.error('Error hashing password'); | 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 | // Insert the new user into the database. | 126 | // Insert the new user into the database. | ||
| 107 | try { | 127 | try { | ||
| @@ -114,8 +134,15 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 114 | } catch (err: unknown) { | 134 | } catch (err: unknown) { | ||
| 115 | log.error('Database insertion error', { error: err as object }); | 135 | log.error('Database insertion error', { error: err as object }); | ||
| 116 | // Assume a duplicate user error if the email (or derived unique field) already exists. | 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 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | 11 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | ||
| 12 | 12 | ||||
| 13 | export async function GET({ params, url, locals }): Promise<Response> { | 13 | export async function GET({ params, url, locals }): Promise<Response> { | ||
| 14 | - | const { roomId } = params; | 14 | + | const { roomId } = params; |
| 15 | - | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; | 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; | |||
| 26 | 16 | ||||
| 27 | - | // Fetch buddy list: get all users | 17 | + | try { |
| 28 | - | const fetchedUsers = await db.select() | 18 | + | log.debug('Fetching room data', { |
| 29 | - | .from(users) | 19 | + | roomId, |
| 30 | - | .execute(); | 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) | 26 | + | // Calculate timeout threshold |
| 33 | - | const normalizedUsers = fetchedUsers.map( | 27 | + | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; |
| 34 | - | (user: SafeUser & { lastSeen: number | null }) => ({ | |||
| 35 | - | ...user, | |||
| 36 | - | lastSeen: user.lastSeen ?? 0 | |||
| 37 | - | }) | |||
| 38 | - | ); | |||
| 39 | 28 | ||||
| 40 | - | // Process users to mark them as offline if they've timed out | 29 | + | // Fetch buddy list: get all users |
| 41 | - | const processedUsers = normalizedUsers.map( | 30 | + | const fetchedUsers = await db.select().from(users).execute(); |
| 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 | - | ); | |||
| 50 | 31 | ||||
| 51 | - | // Find users who were originally non-offline but now marked offline | 32 | + | // Normalize users to ensure that lastSeen is always a number (using 0 if null) |
| 52 | - | const usersToUpdate = processedUsers.filter( | 33 | + | const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({ |
| 53 | - | (user: SafeUser & { lastSeen: number }) => | 34 | + | ...user, |
| 54 | - | user.status === 'offline' && | 35 | + | lastSeen: user.lastSeen ?? 0 |
| 55 | - | normalizedUsers.find( | 36 | + | })); |
| 56 | - | (u: SafeUser & { lastSeen: number }) => u.id === user.id | |||
| 57 | - | )?.status !== 'offline' | |||
| 58 | - | ); | |||
| 59 | 37 | ||||
| 60 | - | if (usersToUpdate.length > 0) { | 38 | + | // Process users to mark them as offline if they've timed out |
| 61 | - | await Promise.all( | 39 | + | const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => { |
| 62 | - | usersToUpdate.map((user: SafeUser & { lastSeen: number }) => | 40 | + | const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold; |
| 63 | - | db.update(users) | 41 | + | return { |
| 64 | - | .set({ status: 'offline', lastSeen: Date.now() }) | 42 | + | ...user, |
| 65 | - | .where(eq(users.id, user.id)) | 43 | + | status: shouldBeOffline ? 'offline' : user.status |
| 66 | - | .execute() | 44 | + | }; |
| 67 | - | ) | 45 | + | }); |
| 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 | - | } | |||
| 75 | 46 | ||||
| 76 | - | // Sanitize user data using createSafeUser | 47 | + | // Find users who were originally non-offline but now marked offline |
| 77 | - | const sanitizedUsers = processedUsers.map( | 48 | + | const usersToUpdate = processedUsers.filter( |
| 78 | - | (user: SafeUser & { lastSeen: number }) => createSafeUser(user) | 49 | + | (user: SafeUser & { lastSeen: number }) => |
| 79 | - | ); | 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 = { | 55 | + | if (usersToUpdate.length > 0) { |
| 82 | - | success: true, | 56 | + | await Promise.all( |
| 83 | - | buddyList: sanitizedUsers | 57 | + | usersToUpdate.map((user: SafeUser & { lastSeen: number }) => |
| 84 | - | }; | 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', { | 66 | + | log.debug('Updated offline status for users', { |
| 87 | - | roomId, | 67 | + | count: usersToUpdate.length, |
| 88 | - | userCount: sanitizedUsers.length, | 68 | + | userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id) |
| 89 | - | isPublic, | 69 | + | }); |
| 90 | - | offlineUpdates: usersToUpdate.length | 70 | + | } |
| 91 | - | }); | |||
| 92 | 71 | ||||
| 93 | - | return new Response(JSON.stringify(responseData), { | 72 | + | // Sanitize user data using createSafeUser |
| 94 | - | status: 200, | 73 | + | const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) => |
| 95 | - | headers: { 'Content-Type': 'application/json' } | 74 | + | createSafeUser(user) |
| 96 | - | }); | 75 | + | ); |
| 97 | - | } catch (error) { | 76 | + | |
| 98 | - | log.error('Error fetching room data', { | 77 | + | const responseData: PublicRoomResponse = { |
| 99 | - | roomId, | 78 | + | success: true, |
| 100 | - | error: error instanceof Error ? error.message : 'Unknown error' | 79 | + | buddyList: sanitizedUsers |
| 101 | - | }); | 80 | + | }; |
| 102 | - | const errorResponse: PublicRoomResponse = { | 81 | + | |
| 103 | - | success: false, | 82 | + | log.debug('Successfully fetched room data', { |
| 104 | - | error: 'Failed to fetch room data' | 83 | + | roomId, |
| 105 | - | }; | 84 | + | userCount: sanitizedUsers.length, |
| 106 | - | return new Response(JSON.stringify(errorResponse), { | 85 | + | isPublic, |
| 107 | - | status: 500, | 86 | + | offlineUpdates: usersToUpdate.length |
| 108 | - | headers: { 'Content-Type': 'application/json' } | 87 | + | }); |
| 109 | - | }); | 88 | + | |
| 110 | - | } | 89 | + | return new Response(JSON.stringify(responseData), { |
| 111 | - | } ⏎̸ | 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 | const log = createLogger('login-server'); | 14 | const log = createLogger('login-server'); | ||
| 15 | 15 | ||||
| 16 | // In-memory map to track failed login attempts per IP | 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 | const MAX_ATTEMPTS = 3; | 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 | // Cleanup old entries every hour | 21 | // Cleanup old entries every hour | ||
| 22 | - | setInterval(() => { | 22 | + | setInterval( |
| 23 | - | const now = Date.now(); | 23 | + | () => { |
| 24 | - | const ONE_HOUR = 60 * 60 * 1000; | 24 | + | const now = Date.now(); |
| 25 | - | for (const [ip, data] of loginAttempts.entries()) { | 25 | + | const ONE_HOUR = 60 * 60 * 1000; |
| 26 | - | if (now - data.lastAttempt > ONE_HOUR) { | 26 | + | for (const [ip, data] of loginAttempts.entries()) { |
| 27 | - | loginAttempts.delete(ip); | 27 | + | if (now - data.lastAttempt > ONE_HOUR) { |
| 28 | - | } | 28 | + | loginAttempts.delete(ip); |
| 29 | - | } | 29 | + | } |
| 30 | - | }, 60 * 60 * 1000); | 30 | + | } |
| 31 | - | 31 | + | }, | |
| 32 | + | 60 * 60 * 1000 | |||
| 33 | + | ); | |||
| 32 | 34 | ||||
| 33 | export const POST: RequestHandler = async ({ request, cookies }) => { | 35 | export const POST: RequestHandler = async ({ request, cookies }) => { | ||
| 34 | - | log.debug('New login attempt received'); | 36 | + | log.debug('New login attempt received'); |
| 35 | - | 37 | + | ||
| 36 | - | if (request.method !== 'POST') { | 38 | + | if (request.method !== 'POST') { |
| 37 | - | log.warn('Invalid method used', { method: request.method }); | 39 | + | log.warn('Invalid method used', { method: request.method }); |
| 38 | - | return new Response( | 40 | + | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { |
| 39 | - | JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), | 41 | + | status: 405 |
| 40 | - | { status: 405 } | 42 | + | }); |
| 41 | - | ); | 43 | + | } |
| 42 | - | } | 44 | + | |
| 43 | - | 45 | + | // Get the IP address for rate limiting and Turnstile validation | |
| 44 | - | // Get the IP address for rate limiting and Turnstile validation | 46 | + | const ip = request.headers.get('x-forwarded-for') || 'unknown'; |
| 45 | - | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | 47 | + | const maskedIp = ip |
| 46 | - | const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.'); | 48 | + | .split('.') |
| 47 | - | const now = Date.now(); | 49 | + | .map((octet, idx) => (idx < 3 ? 'xxx' : octet)) |
| 48 | - | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | 50 | + | .join('.'); |
| 49 | - | 51 | + | const now = Date.now(); | |
| 50 | - | if (attemptData.count >= MAX_ATTEMPTS) { | 52 | + | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; |
| 51 | - | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; | 53 | + | |
| 52 | - | log.warn('Rate limit exceeded', { maskedIp, delay }); | 54 | + | if (attemptData.count >= MAX_ATTEMPTS) { |
| 53 | - | await sleep(delay); | 55 | + | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; |
| 54 | - | } | 56 | + | log.warn('Rate limit exceeded', { maskedIp, delay }); |
| 55 | - | 57 | + | await sleep(delay); | |
| 56 | - | let body: unknown; | 58 | + | } |
| 57 | - | try { | 59 | + | |
| 58 | - | body = await request.json(); | 60 | + | let body: unknown; |
| 59 | - | } catch { | 61 | + | try { |
| 60 | - | log.warn('Invalid JSON payload received'); | 62 | + | body = await request.json(); |
| 61 | - | return new Response( | 63 | + | } catch { |
| 62 | - | JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), | 64 | + | log.warn('Invalid JSON payload received'); |
| 63 | - | { status: 400 } | 65 | + | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { |
| 64 | - | ); | 66 | + | status: 400 |
| 65 | - | } | 67 | + | }); |
| 66 | - | 68 | + | } | |
| 67 | - | const { username, password } = body as { | 69 | + | |
| 68 | - | username: string; | 70 | + | const { username, password } = body as { |
| 69 | - | password: string; | 71 | + | username: string; |
| 70 | - | }; | 72 | + | password: string; |
| 71 | - | 73 | + | }; | |
| 72 | - | // Validate input using Zod schema | 74 | + | |
| 73 | - | try { | 75 | + | // Validate input using Zod schema |
| 74 | - | loginSchema.parse({ username, password }); | 76 | + | try { |
| 75 | - | } catch (err) { | 77 | + | loginSchema.parse({ username, password }); |
| 76 | - | const zodError = err as z.ZodError; | 78 | + | } catch (err) { |
| 77 | - | const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data'; | 79 | + | const zodError = err as z.ZodError; |
| 78 | - | log.warn('Login validation failed', { error: errorMessage }); | 80 | + | const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data'; |
| 79 | - | return new Response( | 81 | + | log.warn('Login validation failed', { error: errorMessage }); |
| 80 | - | JSON.stringify({ error: errorMessage } as LoginResponseError), | 82 | + | return new Response(JSON.stringify({ error: errorMessage } as LoginResponseError), { |
| 81 | - | { status: 400 } | 83 | + | status: 400 |
| 82 | - | ); | 84 | + | }); |
| 83 | - | } | 85 | + | } |
| 84 | - | 86 | + | ||
| 85 | - | 87 | + | // Find user by username | |
| 86 | - | // Find user by username | 88 | + | const user = await db.query.users.findFirst({ |
| 87 | - | const user = await db.query.users.findFirst({ | 89 | + | where: eq(users.nickname, username.trim()) |
| 88 | - | where: eq(users.nickname, username.trim()) | 90 | + | }); |
| 89 | - | }); | 91 | + | |
| 90 | - | 92 | + | if (!user) { | |
| 91 | - | if (!user) { | 93 | + | attemptData.count++; |
| 92 | - | attemptData.count++; | 94 | + | attemptData.lastAttempt = now; |
| 93 | - | attemptData.lastAttempt = now; | 95 | + | loginAttempts.set(ip, attemptData); |
| 94 | - | loginAttempts.set(ip, attemptData); | 96 | + | log.warn('Login failed - user not found', { |
| 95 | - | log.warn('Login failed - user not found', { | 97 | + | maskedIp, |
| 96 | - | maskedIp, | 98 | + | attemptCount: attemptData.count, |
| 97 | - | attemptCount: attemptData.count, | 99 | + | username: username.trim() |
| 98 | - | username: username.trim() | 100 | + | }); |
| 99 | - | }); | 101 | + | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); |
| 100 | - | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | 102 | + | return new Response( |
| 101 | - | return new Response(JSON.stringify({ | 103 | + | JSON.stringify({ |
| 102 | - | error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` | 104 | + | error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}` |
| 103 | - | } as LoginResponseError), { status: 401 }); | 105 | + | } as LoginResponseError), |
| 104 | - | } | 106 | + | { status: 401 } |
| 105 | - | 107 | + | ); | |
| 106 | - | // Verify password | 108 | + | } |
| 107 | - | try { | 109 | + | |
| 108 | - | const isValid = await verifyPassword(password.trim(), user.password); | 110 | + | // Verify password |
| 109 | - | if (!isValid) { | 111 | + | try { |
| 110 | - | attemptData.count++; | 112 | + | const isValid = await verifyPassword(password.trim(), user.password); |
| 111 | - | attemptData.lastAttempt = now; | 113 | + | if (!isValid) { |
| 112 | - | loginAttempts.set(ip, attemptData); | 114 | + | attemptData.count++; |
| 113 | - | log.warn('Login failed - invalid password', { | 115 | + | attemptData.lastAttempt = now; |
| 114 | - | maskedIp, | 116 | + | loginAttempts.set(ip, attemptData); |
| 115 | - | attemptCount: attemptData.count, | 117 | + | log.warn('Login failed - invalid password', { |
| 116 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}` | 118 | + | maskedIp, |
| 117 | - | }); | 119 | + | attemptCount: attemptData.count, |
| 118 | - | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | 120 | + | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}` |
| 119 | - | return new Response(JSON.stringify({ | 121 | + | }); |
| 120 | - | error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` | 122 | + | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); |
| 121 | - | } as LoginResponseError), { status: 401 }); | 123 | + | return new Response( |
| 122 | - | } | 124 | + | JSON.stringify({ |
| 123 | - | } catch (error) { | 125 | + | error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}` |
| 124 | - | log.error('Error verifying password', { error }); | 126 | + | } as LoginResponseError), |
| 125 | - | return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 }); | 127 | + | { status: 401 } |
| 126 | - | } | 128 | + | ); |
| 127 | - | 129 | + | } | |
| 128 | - | // Reset login attempt data on success | 130 | + | } catch (error) { |
| 129 | - | loginAttempts.delete(ip); | 131 | + | log.error('Error verifying password', { error }); |
| 130 | - | 132 | + | return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { | |
| 131 | - | // Generate session token and create session | 133 | + | status: 500 |
| 132 | - | const token = generateSessionToken(); | 134 | + | }); |
| 133 | - | const session = await createSession(token, user.id); | 135 | + | } |
| 134 | - | 136 | + | ||
| 135 | - | // Update user status to online | 137 | + | // Reset login attempt data on success |
| 136 | - | await db.update(users) | 138 | + | loginAttempts.delete(ip); |
| 137 | - | .set({ | 139 | + | |
| 138 | - | status: 'online', | 140 | + | // Generate session token and create session |
| 139 | - | lastSeen: Date.now() | 141 | + | const token = generateSessionToken(); |
| 140 | - | }) | 142 | + | const session = await createSession(token, user.id); |
| 141 | - | .where(eq(users.id, user.id)); | 143 | + | |
| 142 | - | 144 | + | // Update user status to online | |
| 143 | - | // Set session cookie | 145 | + | await db |
| 144 | - | setSessionTokenCookie({ cookies }, token, session.expiresAt); | 146 | + | .update(users) |
| 145 | - | 147 | + | .set({ | |
| 146 | - | log.info('Login successful', { | 148 | + | status: 'online', |
| 147 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`, | 149 | + | lastSeen: Date.now() |
| 148 | - | expiresAt: new Date(session.expiresAt).toISOString() | 150 | + | }) |
| 149 | - | }); | 151 | + | .where(eq(users.id, user.id)); |
| 150 | - | 152 | + | ||
| 151 | - | return new Response(JSON.stringify({ | 153 | + | // Set session cookie |
| 152 | - | success: true, | 154 | + | setSessionTokenCookie({ cookies }, token, session.expiresAt); |
| 153 | - | user: createSafeUser(user) | 155 | + | |
| 154 | - | } as LoginResponseSuccess), { | 156 | + | log.info('Login successful', { |
| 155 | - | status: 200 | 157 | + | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`, |
| 156 | - | }); | 158 | + | expiresAt: new Date(session.expiresAt).toISOString() |
| 157 | - | }; ⏎̸ | 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 | const log = createLogger('logout-server'); | 6 | const log = createLogger('logout-server'); | ||
| 7 | 7 | ||||
| 8 | export async function POST({ cookies }) { | 8 | export async function POST({ cookies }) { | ||
| 9 | - | const token = cookies.get('session'); | 9 | + | const token = cookies.get('session'); |
| 10 | - | 10 | + | ||
| 11 | - | if (token) { | 11 | + | if (token) { |
| 12 | - | const sessionId = await sha256(token); | 12 | + | const sessionId = await sha256(token); |
| 13 | - | await invalidateSession(sessionId); | 13 | + | await invalidateSession(sessionId); |
| 14 | - | cookies.delete('session', { path: '/' }); | 14 | + | cookies.delete('session', { path: '/' }); |
| 15 | - | log.info('Session invalidated', { sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` }); | 15 | + | log.info('Session invalidated', { |
| 16 | - | } else { | 16 | + | sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` |
| 17 | - | log.debug('No session token found in cookies'); | 17 | + | }); |
| 18 | - | } | 18 | + | } else { |
| 19 | - | 19 | + | log.debug('No session token found in cookies'); | |
| 20 | - | return json({ success: true }); | 20 | + | } |
| 21 | - | } ⏎̸ | 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 | const log = createLogger('validate-server'); | 5 | const log = createLogger('validate-server'); | ||
| 6 | 6 | ||||
| 7 | export async function GET({ cookies }) { | 7 | export async function GET({ cookies }) { | ||
| 8 | - | const token = cookies.get('session'); | 8 | + | const token = cookies.get('session'); |
| 9 | - | 9 | + | ||
| 10 | - | if (!token) { | 10 | + | if (!token) { |
| 11 | - | log.debug('No session token found in cookies'); | 11 | + | log.debug('No session token found in cookies'); |
| 12 | - | return json({ session: null, user: null }, { status: 401 }); | 12 | + | return json({ session: null, user: null }, { status: 401 }); |
| 13 | - | } | 13 | + | } |
| 14 | - | 14 | + | ||
| 15 | - | const result = await validateSessionToken(token); | 15 | + | const result = await validateSessionToken(token); |
| 16 | - | 16 | + | ||
| 17 | - | if (result.session && result.user) { | 17 | + | if (result.session && result.user) { |
| 18 | - | log.debug('Session validated', { | 18 | + | log.debug('Session validated', { |
| 19 | - | userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`, | 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)}` | 20 | + | sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}` |
| 21 | - | }); | 21 | + | }); |
| 22 | - | } else { | 22 | + | } else { |
| 23 | - | log.debug('Invalid session token'); | 23 | + | log.debug('Invalid session token'); |
| 24 | - | } | 24 | + | } |
| 25 | - | 25 | + | ||
| 26 | - | return json(result); | 26 | + | return json(result); |
| 27 | - | } ⏎̸ | 27 | + | } |
M src/routes/api/sse/+server.ts +273 -271
| @@ -1,7 +1,6 @@ | |||||
| 1 | // At the top of the file, before any code | 1 | // At the top of the file, before any code | ||
| 2 | declare global { | 2 | declare global { | ||
| 3 | - | // eslint-disable-next-line no-var | 3 | + | var __sseIntervalsStarted: boolean; |
| 4 | - | var __sseIntervalsStarted: boolean; | |||
| 5 | } | 4 | } | ||
| 6 | 5 | ||||
| 7 | import type { RequestHandler } from './$types'; | 6 | import type { RequestHandler } from './$types'; | ||
| @@ -34,292 +33,295 @@ let lastBuddyListData: unknown[] = []; | |||||
| 34 | 33 | ||||
| 35 | // Initialize periodic check for timed out users | 34 | // Initialize periodic check for timed out users | ||
| 36 | function startTimeoutCheck() { | 35 | function startTimeoutCheck() { | ||
| 37 | - | if (timeoutInterval) { | 36 | + | if (timeoutInterval) { |
| 38 | - | clearInterval(timeoutInterval); | 37 | + | clearInterval(timeoutInterval); |
| 39 | - | } | 38 | + | } |
| 40 | - | 39 | + | ||
| 41 | - | timeoutInterval = setInterval(async () => { | 40 | + | timeoutInterval = setInterval(async () => { |
| 42 | - | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | 41 | + | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; |
| 43 | - | log.debug('Checking for timed out users', { | 42 | + | log.debug('Checking for timed out users', { |
| 44 | - | checkTime: new Date().toISOString(), | 43 | + | checkTime: new Date().toISOString(), |
| 45 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | 44 | + | timeoutThreshold: new Date(timeoutThreshold).toISOString() |
| 46 | - | }); | 45 | + | }); |
| 47 | - | 46 | + | ||
| 48 | - | try { | 47 | + | try { |
| 49 | - | const now = Date.now(); | 48 | + | const now = Date.now(); |
| 50 | - | const result = await db.update(users) | 49 | + | const result = await db |
| 51 | - | .set({ | 50 | + | .update(users) |
| 52 | - | status: 'offline', | 51 | + | .set({ |
| 53 | - | lastSeen: now | 52 | + | status: 'offline', |
| 54 | - | }) | 53 | + | lastSeen: now |
| 55 | - | .where( | 54 | + | }) |
| 56 | - | and( | 55 | + | .where( |
| 57 | - | ne(users.status, 'offline'), // Update any non-offline users | 56 | + | and( |
| 58 | - | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | 57 | + | ne(users.status, 'offline'), // Update any non-offline users |
| 59 | - | ) | 58 | + | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently |
| 60 | - | ) | 59 | + | ) |
| 61 | - | .returning({ | 60 | + | ) |
| 62 | - | id: users.id, | 61 | + | .returning({ |
| 63 | - | nickname: users.nickname, | 62 | + | id: users.id, |
| 64 | - | lastSeen: users.lastSeen | 63 | + | nickname: users.nickname, |
| 65 | - | }); | 64 | + | lastSeen: users.lastSeen |
| 66 | - | 65 | + | }); | |
| 67 | - | if (result.length > 0) { | 66 | + | |
| 68 | - | log.info('User status updated to offline', { | 67 | + | if (result.length > 0) { |
| 69 | - | usersUpdated: result.length, | 68 | + | log.info('User status updated to offline', { |
| 70 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | 69 | + | usersUpdated: result.length, |
| 71 | - | }); | 70 | + | timeoutThreshold: new Date(timeoutThreshold).toISOString() |
| 72 | - | 71 | + | }); | |
| 73 | - | // Invalidate buddy list cache when users timeout | 72 | + | |
| 74 | - | buddyListCache.invalidate(); | 73 | + | // Invalidate buddy list cache when users timeout |
| 75 | - | } | 74 | + | buddyListCache.invalidate(); |
| 76 | - | } catch (error) { | 75 | + | } |
| 77 | - | log.error('Error during timeout check', { error }); | 76 | + | } catch (error) { |
| 78 | - | } | 77 | + | log.error('Error during timeout check', { error }); |
| 79 | - | }, CHECK_INTERVAL_MS); | 78 | + | } |
| 79 | + | }, CHECK_INTERVAL_MS); | |||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | function startBuddyListUpdateInterval() { | 82 | function startBuddyListUpdateInterval() { | ||
| 83 | - | if (buddyListInterval) { | 83 | + | if (buddyListInterval) { |
| 84 | - | clearInterval(buddyListInterval); | 84 | + | clearInterval(buddyListInterval); |
| 85 | - | } | 85 | + | } |
| 86 | - | 86 | + | ||
| 87 | - | buddyListInterval = setInterval(async () => { | 87 | + | buddyListInterval = setInterval(async () => { |
| 88 | - | try { | 88 | + | try { |
| 89 | - | const now = Date.now(); | 89 | + | const now = Date.now(); |
| 90 | - | // Only fetch and broadcast if 10 seconds have passed since last update | 90 | + | // Only fetch and broadcast if 10 seconds have passed since last update |
| 91 | - | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | 91 | + | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { |
| 92 | - | // Only query database if cache is invalid or doesn't exist | 92 | + | // Only query database if cache is invalid or doesn't exist |
| 93 | - | if (buddyListCache.needsRefresh()) { | 93 | + | if (buddyListCache.needsRefresh()) { |
| 94 | - | log.debug('Fetching buddy list from database (cache miss)...'); | 94 | + | log.debug('Fetching buddy list from database (cache miss)...'); |
| 95 | - | // Query the complete buddy list from the database. | 95 | + | // Query the complete buddy list from the database. |
| 96 | - | const buddyList = await db.select().from(users); | 96 | + | const buddyList = await db.select().from(users); |
| 97 | - | // Use createSafeUser to properly sanitize user data | 97 | + | // Use createSafeUser to properly sanitize user data |
| 98 | - | const safeBuddyList = buddyList.map(user => createSafeUser(user)); | 98 | + | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); |
| 99 | - | 99 | + | ||
| 100 | - | // Update cache and check if data changed | 100 | + | // Update cache and check if data changed |
| 101 | - | const hasChanged = buddyListCache.update(safeBuddyList); | 101 | + | const hasChanged = buddyListCache.update(safeBuddyList); |
| 102 | - | 102 | + | ||
| 103 | - | if (hasChanged) { | 103 | + | if (hasChanged) { |
| 104 | - | log.debug('Broadcasting buddy list update (data changed)...'); | 104 | + | log.debug('Broadcasting buddy list update (data changed)...'); |
| 105 | - | sseEmitter.emit('sse', { | 105 | + | sseEmitter.emit('sse', { |
| 106 | - | type: 'buddyListUpdate', | 106 | + | type: 'buddyListUpdate', |
| 107 | - | data: safeBuddyList | 107 | + | data: safeBuddyList |
| 108 | - | }); | 108 | + | }); |
| 109 | - | } | 109 | + | } |
| 110 | - | } else { | 110 | + | } else { |
| 111 | - | // Use cached data | 111 | + | // Use cached data |
| 112 | - | const cachedData = buddyListCache.get(); | 112 | + | const cachedData = buddyListCache.get(); |
| 113 | - | if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) { | 113 | + | if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) { |
| 114 | - | log.debug('Broadcasting buddy list update from cache...'); | 114 | + | log.debug('Broadcasting buddy list update from cache...'); |
| 115 | - | lastBuddyListData = cachedData; | 115 | + | lastBuddyListData = cachedData; |
| 116 | - | sseEmitter.emit('sse', { | 116 | + | sseEmitter.emit('sse', { |
| 117 | - | type: 'buddyListUpdate', | 117 | + | type: 'buddyListUpdate', |
| 118 | - | data: cachedData | 118 | + | data: cachedData |
| 119 | - | }); | 119 | + | }); |
| 120 | - | } | 120 | + | } |
| 121 | - | } | 121 | + | } |
| 122 | - | lastBuddyListUpdate = now; | 122 | + | lastBuddyListUpdate = now; |
| 123 | - | } | 123 | + | } |
| 124 | - | } catch (error) { | 124 | + | } catch (error) { |
| 125 | - | log.error('Error fetching buddy list for update', { error }); | 125 | + | log.error('Error fetching buddy list for update', { error }); |
| 126 | - | } | 126 | + | } |
| 127 | - | }, 1000); // Check every second but only broadcast every 10 seconds | 127 | + | }, 1000); // Check every second but only broadcast every 10 seconds |
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | // Add periodic cleanup for stale connections | 130 | // Add periodic cleanup for stale connections | ||
| 131 | let connectionCleanupInterval: ReturnType<typeof setInterval>; | 131 | let connectionCleanupInterval: ReturnType<typeof setInterval>; | ||
| 132 | 132 | ||||
| 133 | function startConnectionCleanup() { | 133 | function startConnectionCleanup() { | ||
| 134 | - | if (connectionCleanupInterval) { | 134 | + | if (connectionCleanupInterval) { |
| 135 | - | clearInterval(connectionCleanupInterval); | 135 | + | clearInterval(connectionCleanupInterval); |
| 136 | - | } | 136 | + | } |
| 137 | - | 137 | + | ||
| 138 | - | connectionCleanupInterval = setInterval(() => { | 138 | + | connectionCleanupInterval = setInterval(() => { |
| 139 | - | const staleUserIds = sseConnectionTracker.cleanupStaleConnections(); | 139 | + | const staleUserIds = sseConnectionTracker.cleanupStaleConnections(); |
| 140 | - | if (staleUserIds.length > 0) { | 140 | + | if (staleUserIds.length > 0) { |
| 141 | - | // Mark stale connections as offline | 141 | + | // Mark stale connections as offline |
| 142 | - | staleUserIds.forEach(async (userId) => { | 142 | + | staleUserIds.forEach(async (userId) => { |
| 143 | - | try { | 143 | + | try { |
| 144 | - | await db.update(users) | 144 | + | await db |
| 145 | - | .set({ status: 'offline', lastSeen: Date.now() }) | 145 | + | .update(users) |
| 146 | - | .where(eq(users.id, userId)); | 146 | + | .set({ status: 'offline', lastSeen: Date.now() }) |
| 147 | - | 147 | + | .where(eq(users.id, userId)); | |
| 148 | - | buddyListCache.invalidate(); | 148 | + | |
| 149 | - | } catch (error) { | 149 | + | buddyListCache.invalidate(); |
| 150 | - | log.error('Error updating stale user status', { userId, error }); | 150 | + | } catch (error) { |
| 151 | - | } | 151 | + | log.error('Error updating stale user status', { userId, error }); |
| 152 | - | }); | 152 | + | } |
| 153 | - | } | 153 | + | }); |
| 154 | - | }, 60000); // Run every minute | 154 | + | } |
| 155 | + | }, 60000); // Run every minute | |||
| 155 | } | 156 | } | ||
| 156 | 157 | ||||
| 157 | // <<< NEW: Ensure intervals are started only once >>> | 158 | // <<< NEW: Ensure intervals are started only once >>> | ||
| 158 | if (!globalThis.__sseIntervalsStarted) { | 159 | if (!globalThis.__sseIntervalsStarted) { | ||
| 159 | - | startTimeoutCheck(); | 160 | + | startTimeoutCheck(); |
| 160 | - | startBuddyListUpdateInterval(); | 161 | + | startBuddyListUpdateInterval(); |
| 161 | - | startConnectionCleanup(); | 162 | + | startConnectionCleanup(); |
| 162 | - | globalThis.__sseIntervalsStarted = true; | 163 | + | globalThis.__sseIntervalsStarted = true; |
| 163 | - | log.info('Started global intervals for timeout and buddy list updates'); | 164 | + | log.info('Started global intervals for timeout and buddy list updates'); |
| 164 | } else { | 165 | } else { | ||
| 165 | - | log.debug('Global intervals already started, skipping initialization'); | 166 | + | log.debug('Global intervals already started, skipping initialization'); |
| 166 | } | 167 | } | ||
| 167 | 168 | ||||
| 168 | // Ensure the interval is cleared if the module is reloaded | 169 | // Ensure the interval is cleared if the module is reloaded | ||
| 169 | if (typeof process !== 'undefined') { | 170 | if (typeof process !== 'undefined') { | ||
| 170 | - | process.on('beforeExit', () => { | 171 | + | process.on('beforeExit', () => { |
| 171 | - | if (timeoutInterval) clearInterval(timeoutInterval); | 172 | + | if (timeoutInterval) clearInterval(timeoutInterval); |
| 172 | - | if (buddyListInterval) clearInterval(buddyListInterval); | 173 | + | if (buddyListInterval) clearInterval(buddyListInterval); |
| 173 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | 174 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); |
| 174 | - | }); | 175 | + | }); |
| 175 | - | 176 | + | ||
| 176 | - | // Also handle SIGTERM and SIGINT | 177 | + | // Also handle SIGTERM and SIGINT |
| 177 | - | process.on('SIGTERM', () => { | 178 | + | process.on('SIGTERM', () => { |
| 178 | - | if (timeoutInterval) clearInterval(timeoutInterval); | 179 | + | if (timeoutInterval) clearInterval(timeoutInterval); |
| 179 | - | if (buddyListInterval) clearInterval(buddyListInterval); | 180 | + | if (buddyListInterval) clearInterval(buddyListInterval); |
| 180 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | 181 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); |
| 181 | - | process.exit(0); | 182 | + | process.exit(0); |
| 182 | - | }); | 183 | + | }); |
| 183 | - | 184 | + | ||
| 184 | - | process.on('SIGINT', () => { | 185 | + | process.on('SIGINT', () => { |
| 185 | - | if (timeoutInterval) clearInterval(timeoutInterval); | 186 | + | if (timeoutInterval) clearInterval(timeoutInterval); |
| 186 | - | if (buddyListInterval) clearInterval(buddyListInterval); | 187 | + | if (buddyListInterval) clearInterval(buddyListInterval); |
| 187 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | 188 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); |
| 188 | - | process.exit(0); | 189 | + | process.exit(0); |
| 189 | - | }); | 190 | + | }); |
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| 192 | - | export const GET: RequestHandler = async ({ request, locals, }) => { | 193 | + | export const GET: RequestHandler = async ({ request, locals }) => { |
| 193 | - | log.debug('Processing SSE request', { | 194 | + | log.debug('Processing SSE request', { |
| 194 | - | url: request.url, | 195 | + | url: request.url, |
| 195 | - | userId: locals.user?.id | 196 | + | userId: locals.user?.id |
| 196 | - | }); | 197 | + | }); |
| 197 | - | 198 | + | ||
| 198 | - | if (!locals.user) { | 199 | + | if (!locals.user) { |
| 199 | - | log.warn('Authentication failed - no user in locals'); | 200 | + | log.warn('Authentication failed - no user in locals'); |
| 200 | - | return new Response('Unauthorized', { | 201 | + | return new Response('Unauthorized', { |
| 201 | - | status: 401, | 202 | + | status: 401, |
| 202 | - | headers: { | 203 | + | headers: { |
| 203 | - | 'Content-Type': 'application/json' | 204 | + | 'Content-Type': 'application/json' |
| 204 | - | } | 205 | + | } |
| 205 | - | }); | 206 | + | }); |
| 206 | - | } | 207 | + | } |
| 207 | - | 208 | + | ||
| 208 | - | const userId = locals.user.id; | 209 | + | const userId = locals.user.id; |
| 209 | - | const encoder = new TextEncoder(); | 210 | + | const encoder = new TextEncoder(); |
| 210 | - | let keepAliveInterval: ReturnType<typeof setInterval>; | 211 | + | let keepAliveInterval: ReturnType<typeof setInterval>; |
| 211 | - | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | 212 | + | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; |
| 212 | - | 213 | + | ||
| 213 | - | const now = Date.now(); | 214 | + | const now = Date.now(); |
| 214 | - | 215 | + | ||
| 215 | - | // Only update status if this is a new connection | 216 | + | // Only update status if this is a new connection |
| 216 | - | const isNewConnection = sseConnectionTracker.addConnection(userId); | 217 | + | const isNewConnection = sseConnectionTracker.addConnection(userId); |
| 217 | - | 218 | + | ||
| 218 | - | if (isNewConnection) { | 219 | + | if (isNewConnection) { |
| 219 | - | try { | 220 | + | try { |
| 220 | - | await db.update(users) | 221 | + | await db |
| 221 | - | .set({ | 222 | + | .update(users) |
| 222 | - | status: 'online', | 223 | + | .set({ |
| 223 | - | lastSeen: now | 224 | + | status: 'online', |
| 224 | - | }) | 225 | + | lastSeen: now |
| 225 | - | .where(eq(users.id, userId)); | 226 | + | }) |
| 226 | - | 227 | + | .where(eq(users.id, userId)); | |
| 227 | - | log.info('User status updated to online', { | 228 | + | |
| 228 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | 229 | + | log.info('User status updated to online', { |
| 229 | - | timestamp: new Date(now).toISOString() | 230 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, |
| 230 | - | }); | 231 | + | timestamp: new Date(now).toISOString() |
| 231 | - | 232 | + | }); | |
| 232 | - | // Invalidate buddy list cache when user status changes | 233 | + | |
| 233 | - | buddyListCache.invalidate(); | 234 | + | // Invalidate buddy list cache when user status changes |
| 234 | - | 235 | + | buddyListCache.invalidate(); | |
| 235 | - | sseEmitter.emit('sse', { | 236 | + | |
| 236 | - | type: 'userStatusUpdate', | 237 | + | sseEmitter.emit('sse', { |
| 237 | - | data: { | 238 | + | type: 'userStatusUpdate', |
| 238 | - | userId, | 239 | + | data: { |
| 239 | - | status: 'online', | 240 | + | userId, |
| 240 | - | lastSeen: now | 241 | + | status: 'online', |
| 241 | - | } | 242 | + | lastSeen: now |
| 242 | - | }); | 243 | + | } |
| 243 | - | } catch (error) { | 244 | + | }); |
| 244 | - | log.error('Error updating user status', { error }); | 245 | + | } catch (error) { |
| 245 | - | sseConnectionTracker.removeConnection(userId); | 246 | + | log.error('Error updating user status', { error }); |
| 246 | - | } | 247 | + | sseConnectionTracker.removeConnection(userId); |
| 247 | - | } else { | 248 | + | } |
| 248 | - | log.debug('User already has active SSE connection, skipping status update', { | 249 | + | } else { |
| 249 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}` | 250 | + | log.debug('User already has active SSE connection, skipping status update', { |
| 250 | - | }); | 251 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}` |
| 251 | - | } | 252 | + | }); |
| 252 | - | 253 | + | } | |
| 253 | - | const stream = new ReadableStream({ | 254 | + | |
| 254 | - | start(controller) { | 255 | + | const stream = new ReadableStream({ |
| 255 | - | const sendEvent = (event: { type: string; data: unknown }) => { | 256 | + | start(controller) { |
| 256 | - | try { | 257 | + | const sendEvent = (event: { type: string; data: unknown }) => { |
| 257 | - | const payload = `event: ${event.type}\n` + | 258 | + | try { |
| 258 | - | `data: ${JSON.stringify(event.data)}\n\n`; | 259 | + | const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`; |
| 259 | - | controller.enqueue(encoder.encode(payload)); | 260 | + | controller.enqueue(encoder.encode(payload)); |
| 260 | - | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | 261 | + | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { |
| 261 | - | log.info('Event sent', { type: event.type }); | 262 | + | log.info('Event sent', { type: event.type }); |
| 262 | - | } | 263 | + | } |
| 263 | - | } catch (error) { | 264 | + | } catch (error) { |
| 264 | - | log.error('Error sending event', { error }); | 265 | + | log.error('Error sending event', { error }); |
| 265 | - | } | 266 | + | } |
| 266 | - | }; | 267 | + | }; |
| 267 | - | 268 | + | ||
| 268 | - | onSSE = (event: { type: string; data: unknown }) => { | 269 | + | onSSE = (event: { type: string; data: unknown }) => { |
| 269 | - | sendEvent(event); | 270 | + | sendEvent(event); |
| 270 | - | }; | 271 | + | }; |
| 271 | - | 272 | + | ||
| 272 | - | sseEmitter.addListener('sse', onSSE); | 273 | + | sseEmitter.addListener('sse', onSSE); |
| 273 | - | request.signal.addEventListener('abort', () => { | 274 | + | request.signal.addEventListener('abort', () => { |
| 274 | - | if (onSSE) { | 275 | + | if (onSSE) { |
| 275 | - | sseEmitter.removeListener('sse', onSSE); | 276 | + | sseEmitter.removeListener('sse', onSSE); |
| 276 | - | log.info('Removed listener due to abort signal'); | 277 | + | log.info('Removed listener due to abort signal'); |
| 277 | - | } | 278 | + | } |
| 278 | - | }); | 279 | + | }); |
| 279 | - | log.info('Added new listener'); | 280 | + | log.info('Added new listener'); |
| 280 | - | 281 | + | ||
| 281 | - | controller.enqueue(encoder.encode('data: Connected\n\n')); | 282 | + | controller.enqueue(encoder.encode('data: Connected\n\n')); |
| 282 | - | 283 | + | ||
| 283 | - | keepAliveInterval = setInterval(() => { | 284 | + | keepAliveInterval = setInterval(() => { |
| 284 | - | controller.enqueue(encoder.encode(':\n\n')); | 285 | + | controller.enqueue(encoder.encode(':\n\n')); |
| 285 | - | }, 20000); | 286 | + | }, 20000); |
| 286 | - | }, | 287 | + | }, |
| 287 | - | async cancel(reason) { | 288 | + | async cancel(reason) { |
| 288 | - | if (keepAliveInterval) clearInterval(keepAliveInterval); | 289 | + | if (keepAliveInterval) clearInterval(keepAliveInterval); |
| 289 | - | if (onSSE) sseEmitter.removeListener('sse', onSSE); | 290 | + | if (onSSE) sseEmitter.removeListener('sse', onSSE); |
| 290 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | 291 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; |
| 291 | - | log.info('Connection closed', { userId: maskedUserId, reason }); | 292 | + | log.info('Connection closed', { userId: maskedUserId, reason }); |
| 292 | - | 293 | + | ||
| 293 | - | // Remove from connection tracker | 294 | + | // Remove from connection tracker |
| 294 | - | const wasTracked = sseConnectionTracker.removeConnection(userId); | 295 | + | const wasTracked = sseConnectionTracker.removeConnection(userId); |
| 295 | - | 296 | + | ||
| 296 | - | // Only update database if this was the last connection | 297 | + | // Only update database if this was the last connection |
| 297 | - | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { | 298 | + | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { |
| 298 | - | const now = Date.now(); | 299 | + | const now = Date.now(); |
| 299 | - | await db.update(users) | 300 | + | await db |
| 300 | - | .set({ status: 'offline', lastSeen: now }) | 301 | + | .update(users) |
| 301 | - | .where(eq(users.id, userId)); | 302 | + | .set({ status: 'offline', lastSeen: now }) |
| 302 | - | 303 | + | .where(eq(users.id, userId)); | |
| 303 | - | // Invalidate buddy list cache when user goes offline | 304 | + | |
| 304 | - | buddyListCache.invalidate(); | 305 | + | // Invalidate buddy list cache when user goes offline |
| 305 | - | 306 | + | buddyListCache.invalidate(); | |
| 306 | - | sseEmitter.emit('sse', { | 307 | + | |
| 307 | - | type: 'userStatusUpdate', | 308 | + | sseEmitter.emit('sse', { |
| 308 | - | data: { | 309 | + | type: 'userStatusUpdate', |
| 309 | - | userId, | 310 | + | data: { |
| 310 | - | status: 'offline', | 311 | + | userId, |
| 311 | - | lastSeen: now | 312 | + | status: 'offline', |
| 312 | - | } | 313 | + | lastSeen: now |
| 313 | - | }); | 314 | + | } |
| 314 | - | } | 315 | + | }); |
| 315 | - | } | 316 | + | } |
| 316 | - | }); | 317 | + | } |
| 317 | - | 318 | + | }); | |
| 318 | - | return new Response(stream, { | 319 | + | |
| 319 | - | headers: { | 320 | + | return new Response(stream, { |
| 320 | - | 'Content-Type': 'text/event-stream', | 321 | + | headers: { |
| 321 | - | 'Cache-Control': 'no-cache', | 322 | + | 'Content-Type': 'text/event-stream', |
| 322 | - | 'Connection': 'keep-alive' | 323 | + | 'Cache-Control': 'no-cache', |
| 323 | - | } | 324 | + | Connection: 'keep-alive' |
| 324 | - | }); | 325 | + | } |
| 325 | - | }; ⏎̸ | 326 | + | }); |
| 327 | + | }; | |||
M src/routes/api/status/+server.ts +79 -72
| @@ -11,81 +11,88 @@ import type { RequestHandler } from './$types'; | |||||
| 11 | const log = createLogger('status-server'); | 11 | const log = createLogger('status-server'); | ||
| 12 | 12 | ||||
| 13 | export const POST: RequestHandler = async ({ request, cookies, locals }) => { | 13 | export const POST: RequestHandler = async ({ request, cookies, locals }) => { | ||
| 14 | - | if (!locals.user) { | 14 | + | if (!locals.user) { |
| 15 | - | log.warn('Authentication required'); | 15 | + | log.warn('Authentication required'); |
| 16 | - | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { | 16 | + | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { |
| 17 | - | status: 401, | 17 | + | status: 401, |
| 18 | - | headers: { 'Content-Type': 'application/json' } | 18 | + | headers: { 'Content-Type': 'application/json' } |
| 19 | - | }); | 19 | + | }); |
| 20 | - | } | 20 | + | } |
| 21 | 21 | ||||
| 22 | - | try { | 22 | + | try { |
| 23 | - | const { status } = await request.json(); | 23 | + | const { status } = await request.json(); |
| 24 | - | const validStatuses = ['online', 'away', 'busy', 'offline']; | 24 | + | const validStatuses = ['online', 'away', 'busy', 'offline']; |
| 25 | - | if (!validStatuses.includes(status)) { | 25 | + | if (!validStatuses.includes(status)) { |
| 26 | - | log.warn('Invalid status received', { status }); | 26 | + | log.warn('Invalid status received', { status }); |
| 27 | - | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { | 27 | + | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { |
| 28 | - | status: 400, | 28 | + | status: 400, |
| 29 | - | headers: { 'Content-Type': 'application/json' } | 29 | + | headers: { 'Content-Type': 'application/json' } |
| 30 | - | }); | 30 | + | }); |
| 31 | - | } | 31 | + | } |
| 32 | 32 | ||||
| 33 | - | const userId = locals.user.id; | 33 | + | const userId = locals.user.id; |
| 34 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | 34 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; |
| 35 | - | log.debug('Updating user status', { userId: maskedUserId, status }); | 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 | 39 | + | // Update the user status in the database |
| 40 | - | // Always update lastSeen to support timeout detection | 40 | + | // Always update lastSeen to support timeout detection |
| 41 | - | const updatedUser = await db.update(users) | 41 | + | const updatedUser = await db |
| 42 | - | .set({ | 42 | + | .update(users) |
| 43 | - | status, | 43 | + | .set({ |
| 44 | - | lastSeen: now // Always update lastSeen for proper timeout detection | 44 | + | status, |
| 45 | - | }) | 45 | + | lastSeen: now // Always update lastSeen for proper timeout detection |
| 46 | - | .where(eq(users.id, userId)) | 46 | + | }) |
| 47 | - | .returning() | 47 | + | .where(eq(users.id, userId)) |
| 48 | - | .get(); | 48 | + | .returning() |
| 49 | - | log.debug('Database updated successfully', { userId: maskedUserId, status }); | 49 | + | .get(); |
| 50 | - | 50 | + | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 51 | - | // Invalidate buddy list cache when status changes | |||
| 52 | - | buddyListCache.invalidate(); | |||
| 53 | 51 | ||||
| 54 | - | // Renew session if status is online and session expires in less than 1 day | 52 | + | // Invalidate buddy list cache when status changes |
| 55 | - | if (status === 'online') { | 53 | + | buddyListCache.invalidate(); |
| 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 | - | } | |||
| 74 | 54 | ||||
| 75 | - | return new Response(JSON.stringify({ | 55 | + | // Renew session if status is online and session expires in less than 1 day |
| 76 | - | success: true, | 56 | + | if (status === 'online') { |
| 77 | - | user: createSafeUser(updatedUser) | 57 | + | const token = cookies.get('session'); |
| 78 | - | }), { | 58 | + | if (token) { |
| 79 | - | status: 200, | 59 | + | const result = await validateSessionToken(token); |
| 80 | - | headers: { 'Content-Type': 'application/json' } | 60 | + | if (result.session) { |
| 81 | - | }); | 61 | + | const remaining = result.session.expiresAt - now; |
| 82 | - | } catch (error) { | 62 | + | const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds |
| 83 | - | return new Response( | 63 | + | if (remaining < threshold) { |
| 84 | - | JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }), | 64 | + | const newToken = generateSessionToken(); |
| 85 | - | { | 65 | + | const newSession = await createSession(newToken, userId); |
| 86 | - | status: 500, | 66 | + | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); |
| 87 | - | headers: { 'Content-Type': 'application/json' } | 67 | + | log.info('Session renewed', { |
| 88 | - | } | 68 | + | userId: maskedUserId, |
| 89 | - | ); | 69 | + | expiresAt: new Date(newSession.expiresAt).toISOString() |
| 90 | - | } | 70 | + | }); |
| 91 | - | };⏎̸ | 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 | const log = createLogger('users-server'); | 9 | const log = createLogger('users-server'); | ||
| 10 | 10 | ||||
| 11 | export const GET: RequestHandler = async ({ params }) => { | 11 | export const GET: RequestHandler = async ({ params }) => { | ||
| 12 | - | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | 12 | + | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; |
| 13 | - | log.debug('Fetching user data', { userId: maskedUserId }); | 13 | + | log.debug('Fetching user data', { userId: maskedUserId }); |
| 14 | 14 | ||||
| 15 | - | try { | 15 | + | try { |
| 16 | - | const user = await db.select() | 16 | + | const user = await db.select().from(users).where(eq(users.id, params.userId)).get(); |
| 17 | - | .from(users) | |||
| 18 | - | .where(eq(users.id, params.userId)) | |||
| 19 | - | .get(); | |||
| 20 | 17 | ||||
| 21 | - | if (!user) { | 18 | + | if (!user) { |
| 22 | - | log.warn('User not found', { userId: maskedUserId }); | 19 | + | log.warn('User not found', { userId: maskedUserId }); |
| 23 | - | throw error(404, 'User not found'); | 20 | + | throw error(404, 'User not found'); |
| 24 | - | } | 21 | + | } |
| 25 | 22 | ||||
| 26 | - | const safeUser = createSafeUser(user); | 23 | + | const safeUser = createSafeUser(user); |
| 27 | 24 | ||||
| 28 | - | log.debug('User data retrieved', { | 25 | + | log.debug('User data retrieved', { |
| 29 | - | userId: maskedUserId, | 26 | + | userId: maskedUserId, |
| 30 | - | status: safeUser.status, | 27 | + | status: safeUser.status, |
| 31 | - | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null | 28 | + | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null |
| 32 | - | }); | 29 | + | }); |
| 33 | - | 30 | + | ||
| 34 | - | return json({ | 31 | + | return json({ |
| 35 | - | success: true, | 32 | + | success: true, |
| 36 | - | user: safeUser | 33 | + | user: safeUser |
| 37 | - | }); | 34 | + | }); |
| 38 | - | } catch (err) { | 35 | + | } catch (err) { |
| 39 | - | log.error('Error fetching user data', { | 36 | + | log.error('Error fetching user data', { |
| 40 | - | userId: maskedUserId, | 37 | + | userId: maskedUserId, |
| 41 | - | error: err instanceof Error ? err.message : 'Unknown error' | 38 | + | error: err instanceof Error ? err.message : 'Unknown error' |
| 42 | - | }); | 39 | + | }); |
| 43 | - | throw error(500, 'Failed to fetch user information'); | 40 | + | throw error(500, 'Failed to fetch user information'); |
| 44 | - | } | 41 | + | } |
| 45 | - | };⏎̸ | 42 | + | }; |
M test-gradient-fixes.md +7 -2
| @@ -3,16 +3,20 @@ | |||||
| 3 | ## Issues Fixed: | 3 | ## Issues Fixed: | ||
| 4 | 4 | ||||
| 5 | ### 1. Color Picker Button Gradient Preview | 5 | ### 1. Color Picker Button Gradient Preview | ||
| 6 | + | ||||
| 6 | **Problem**: The color picker button wasn't showing gradient preview correctly. | 7 | **Problem**: The color picker button wasn't showing gradient preview correctly. | ||
| 7 | **Fix**: Updated `displayStyle` in `color-picker-256.svelte` to use consistent `background` property instead of `background-color`. | 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 | **Problem**: Text input only showed the first color of a gradient. | 12 | **Problem**: Text input only showed the first color of a gradient. | ||
| 11 | **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. | 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 | ### 3. Chat Messages Formatting | 15 | ### 3. Chat Messages Formatting | ||
| 16 | + | ||||
| 14 | **Problem**: Messages weren't displaying with user-specific fonts, styles and colors. | 17 | **Problem**: Messages weren't displaying with user-specific fonts, styles and colors. | ||
| 15 | **Fixes**: | 18 | **Fixes**: | ||
| 19 | + | ||||
| 16 | - Updated `sendMessage` function to accept and pass `textStyle` parameter | 20 | - Updated `sendMessage` function to accept and pass `textStyle` parameter | ||
| 17 | - Added `styleData` field to `SendMessageRequest` payload type | 21 | - Added `styleData` field to `SendMessageRequest` payload type | ||
| 18 | - Updated server-side message handler to save `styleData` and `hasFormatting` fields | 22 | - Updated server-side message handler to save `styleData` and `hasFormatting` fields | ||
| @@ -41,9 +45,10 @@ | |||||
| 41 | ## Testing: | 45 | ## Testing: | ||
| 42 | 46 | ||||
| 43 | To test the fixes: | 47 | To test the fixes: | ||
| 48 | + | ||||
| 44 | 1. Open the chat window | 49 | 1. Open the chat window | ||
| 45 | 2. Select a gradient using the color picker (add 2-3 colors) | 50 | 2. Select a gradient using the color picker (add 2-3 colors) | ||
| 46 | 3. Choose a font and adjust size | 51 | 3. Choose a font and adjust size | ||
| 47 | 4. Type a message - you should see the first gradient color in the input | 52 | 4. Type a message - you should see the first gradient color in the input | ||
| 48 | 5. Send the message - it should display with the full gradient effect | 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 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | 7 | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | ||
| 8 | 8 | ||||
| 9 | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | 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 | async function flushDatabase() { | 15 | async function flushDatabase() { | ||
| 14 | - | console.log('Starting database content flush...'); | 16 | + | console.log('Starting database content flush...'); |
| 15 | - | 17 | + | ||
| 16 | - | const client = createClient({ | 18 | + | const client = createClient({ |
| 17 | - | url: TURSO_DATABASE_URL as string, | 19 | + | url: TURSO_DATABASE_URL as string, |
| 18 | - | authToken: TURSO_AUTH_TOKEN as string | 20 | + | authToken: TURSO_AUTH_TOKEN as string |
| 19 | - | }); | 21 | + | }); |
| 20 | - | 22 | + | ||
| 21 | - | try { | 23 | + | try { |
| 22 | - | // Clear tables in correct order to respect foreign key constraints | 24 | + | // Clear tables in correct order to respect foreign key constraints |
| 23 | - | console.log('Clearing messages table...'); | 25 | + | console.log('Clearing messages table...'); |
| 24 | - | await client.execute('DELETE FROM messages'); | 26 | + | await client.execute('DELETE FROM messages'); |
| 25 | - | 27 | + | ||
| 26 | - | console.log('Clearing sessions table...'); | 28 | + | console.log('Clearing sessions table...'); |
| 27 | - | await client.execute('DELETE FROM sessions'); | 29 | + | await client.execute('DELETE FROM sessions'); |
| 28 | - | 30 | + | ||
| 29 | - | console.log('Clearing chat rooms table...'); | 31 | + | console.log('Clearing chat rooms table...'); |
| 30 | - | await client.execute('DELETE FROM chat_rooms'); | 32 | + | await client.execute('DELETE FROM chat_rooms'); |
| 31 | - | 33 | + | ||
| 32 | - | console.log('Clearing users table...'); | 34 | + | console.log('Clearing users table...'); |
| 33 | - | await client.execute('DELETE FROM users'); | 35 | + | await client.execute('DELETE FROM users'); |
| 34 | - | 36 | + | ||
| 35 | - | console.log('Database content flush completed successfully'); | 37 | + | console.log('Database content flush completed successfully'); |
| 36 | - | } catch (error) { | 38 | + | } catch (error) { |
| 37 | - | console.error('Error flushing database content:', error); | 39 | + | console.error('Error flushing database content:', error); |
| 38 | - | throw error; | 40 | + | throw error; |
| 39 | - | } finally { | 41 | + | } finally { |
| 40 | - | await client.close(); | 42 | + | await client.close(); |
| 41 | - | } | 43 | + | } |
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 44 | // Execute if this file is run directly | 46 | // Execute if this file is run directly | ||
| 45 | if (new URL(import.meta.url).pathname === process.argv[1]) { | 47 | if (new URL(import.meta.url).pathname === process.argv[1]) { | ||
| 46 | - | flushDatabase() | 48 | + | flushDatabase() |
| 47 | - | .then(() => { | 49 | + | .then(() => { |
| 48 | - | console.debug('[db-flush] Exiting process with code 0'); | 50 | + | console.debug('[db-flush] Exiting process with code 0'); |
| 49 | - | process.exit(0); | 51 | + | process.exit(0); |
| 50 | - | }) | 52 | + | }) |
| 51 | - | .catch((error) => { | 53 | + | .catch((error) => { |
| 52 | - | console.error('[db-flush] Failed to flush database content:', error); | 54 | + | console.error('[db-flush] Failed to flush database content:', error); |
| 53 | - | process.exit(1); | 55 | + | process.exit(1); |
| 54 | - | }); | 56 | + | }); |
| 55 | - | } ⏎̸ | 57 | + | } |
M vite.config.ts +3 -3
| @@ -8,7 +8,7 @@ import tailwindcss from '@tailwindcss/vite'; | |||||
| 8 | * This project uses two logging systems: | 8 | * This project uses two logging systems: | ||
| 9 | * 1. Console logging (development/client-side) - controlled by console stripping | 9 | * 1. Console logging (development/client-side) - controlled by console stripping | ||
| 10 | * 2. Pino logging (server-side) - controlled by LOG_LEVEL | 10 | * 2. Pino logging (server-side) - controlled by LOG_LEVEL | ||
| 11 | - | * | 11 | + | * |
| 12 | * LOG_LEVEL affects both systems: | 12 | * LOG_LEVEL affects both systems: | ||
| 13 | * - error: only error logs | 13 | * - error: only error logs | ||
| 14 | * - warn: warn and error logs | 14 | * - warn: warn and error logs | ||
| @@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => { | |||||
| 35 | // Load env file based on `mode` value | 35 | // Load env file based on `mode` value | ||
| 36 | const env = loadEnv(mode, process.cwd(), ''); | 36 | const env = loadEnv(mode, process.cwd(), ''); | ||
| 37 | const logLevel = env.LOG_LEVEL || (mode === 'development' ? 'debug' : 'info'); | 37 | const logLevel = env.LOG_LEVEL || (mode === 'development' ? 'debug' : 'info'); | ||
| 38 | - | 38 | + | ||
| 39 | console.log(`Building with LOG_LEVEL=${logLevel} in ${mode} mode`); | 39 | console.log(`Building with LOG_LEVEL=${logLevel} in ${mode} mode`); | ||
| 40 | 40 | ||||
| 41 | return { | 41 | return { | ||
| @@ -50,7 +50,7 @@ export default defineConfig(({ mode }) => { | |||||
| 50 | // Strip console methods based on LOG_LEVEL | 50 | // Strip console methods based on LOG_LEVEL | ||
| 51 | pure_funcs: getConsoleFuncsToStrip(logLevel) | 51 | pure_funcs: getConsoleFuncsToStrip(logLevel) | ||
| 52 | } | 52 | } | ||
| 53 | - | }, | 53 | + | } |
| 54 | }, | 54 | }, | ||
| 55 | // Make LOG_LEVEL available to client-side code | 55 | // Make LOG_LEVEL available to client-side code | ||
| 56 | define: { | 56 | define: { | ||