detached 9b5e945

Merge pull request #48 from kitpdr/feat/ci

ci: add GitHub Actions CI and dependabot auto-merge
commit
9b5e945de8ca0593ae24919685c47db546629e40
author
KiT · · 2025-12-15 09:22 UTC
committer
GitHub · · 2025-12-15 09:22 UTC
merge of
c1ee1ec + c40b6f5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
73 files · +9966 -9510
M .github/dependabot.yml +37 -37
@@ -1,67 +1,67 @@
1 version: 21 version: 2
2 updates:2 updates:
3 # npm dependencies via pnpm3 # 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 noise11 # Group updates to reduce PR noise
12 groups:12 groups:
13 # Group SvelteKit and related core framework updates13 # 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 updates21 # 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 packages34 # 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 packages43 # 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 team51 # Set PR limits to avoid overwhelming the team
52 open-pull-requests-limit: 852 open-pull-requests-limit: 8
53 # Ignore packages that require manual updates or are pinned for specific reasons53 # 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 updated55 # 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 changes57 # 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 format64 # 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 # Bienvenue1 # 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ées5 ## Technologies utilisées
6+
6 - SvelteKit (version 5)7 - SvelteKit (version 5)
7 - Node.js8 - Node.js
8 - TypeScript9 - TypeScript
9 - XP.css10 - XP.css
10 - Tailwind CSS11 - Tailwind CSS
11 12
12-
13 ## Prérequis13 ## Prérequis
14+
14 - Node.js (>=22.13.0)15 - Node.js (>=22.13.0)
15 - npm ou pnpm pour la gestion des packages16 - npm ou pnpm pour la gestion des packages
16 17
17 ## Installation18 ## 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 ```bash22 ```bash
@@ -24,6 +26,7 @@ pnpm install
24 ```26 ```
25 27
26 ## Lancement du projet28 ## Lancement du projet
29+
27 Lancez le projet en mode développement :30 Lancez le projet en mode développement :
28 31
29 ```bash32 ```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 ## Contribution40 ## 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ôt45 - Forkez ce dépôt
41 - Créez une branche pour vos modifications46 - Créez une branche pour vos modifications
42 - Envoyez une pull request en décrivant clairement vos changements47 - Envoyez une pull request en décrivant clairement vos changements
@@ -60,6 +65,7 @@ npm run db:push
60 # ou65 # ou
61 pnpm db:push66 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 ```bash71 ```bash
@@ -67,7 +73,8 @@ npx drizzle-kit studio
67 ```73 ```
68 74
69 ## Documentation et Ressources75 ## 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": false15+ "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": false22+ "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": false37+ "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": false54+ "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": false61+ "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": false68+ "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": false75+ "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": false90+ "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": false134+ },
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": false141+ },
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": false148+ },
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": false155+ "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": false170+ },
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": false177+ }
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": false184+ }
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": true193+ "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": false210+ "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": false217+ "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": false232+ "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": false246+ "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": false253+ "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": false268+ "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": false275+ "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": false282+ "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": false296+ "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": false303+ "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": false318+ "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": false325+ "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": false15+ "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": false22+ "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": false37+ "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": false54+ "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": false61+ "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": false68+ "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": false75+ "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": false90+ "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": false134+ },
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": false141+ },
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": false148+ }
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": false155+ "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": false186+ "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": false193+ "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": false200+ "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": false215+ "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": false222+ "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": false229+ "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": false247+ "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": false254+ "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": false269+ "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": false283+ "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": false290+ "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": false305+ "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": false312+ "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": false319+ "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": false333+ "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": false340+ "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": false355+ "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": false362+ "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": true10+ "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": true17+ "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.parser30 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.ts1 // See https://svelte.dev/docs/kit/types#app.d.ts
2 // for information about these interfaces2 // 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 = object16+ 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 start12 // 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 server22 // 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 exists38+ // 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 session41+ // 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 routes63+ 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 authenticated69+ // 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 endpoints79+ // 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 positioning32+ 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.top41+ 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 bar55+ 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 position63+
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 styling75+ 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 = 029+) => {
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 state38+ // 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) || 043+ y: parseInt(node.style.top) || 0
42- };44+ };
43-45+
44- // Set maximized state46+ // 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: padding63+ 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 state71+ 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- ...storedState82+ 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 node95+ maximize();
94- Object.assign(node, {96+ }
95- maximize,97+ }
96- restore,98+
97- toggleMaximize: toggle,99+ // Expose methods to the node
98- isMaximized: () => isMaximized100+ 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 needed108+ 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- onMinimize29+) => {
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 width39+ // 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 padding48+ // Get the width and add some padding
42- const width = temp.offsetWidth + 100; // Add 100px for buttons and padding49+ 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 400px52+ 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 state58+ // 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) || 063+ y: parseInt(node.style.top) || 0
57- };64+ };
58-65+
59- // Check if we're in mobile mode66+ // 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 minimization70+ // Desktop minimization
64- const minimizedWidth = calculateMinimizedWidth();71+ const minimizedWidth = calculateMinimizedWidth();
65- const minimizedHeight = 32; // Height of title bar72+ 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 screen81+ // 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 height85+ 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 handle91+ // 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 restoration109+
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 restoration115+ 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 mode125+ 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 visible131+ }
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 bar151+ 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 node157+ 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: () => isMinimized162+ 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.innerHeight29+ minWidth = 200,
30- } = params;30+ minHeight = 200,
31-31+ maxWidth = window.innerWidth,
32- // Create and append resize handle32+ 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 position46+ });
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 type8 // 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 users28+ // 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 limiting41 // 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 minutes44 // 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 localhost75+ 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 status82+ }
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 IP87+ 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 window90+ : 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 limit93+ // 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 request101+ 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 endpoints108+
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 routes143+ // 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 headers152+ // 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 message163+ // 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.points186+ rateLimitPoints: isAuthenticated
174- : RATE_LIMITS.sse.unauthenticated.points187+ ? 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- ...debugHeaders194+ '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 domain6 // 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 domain9+ // 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 cookie13+ // 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 domain17+ // For production, use the main domain
18- return '.pdraim.pages.dev';18+ return '.pdraim.pages.dev';
19- }19+ }
20- // Fallback for custom domain20+ // 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 expiry57+ 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 fetch10+ private isDirty: boolean = true; // Force initial fetch
11- 11+
12- // Mark cache as needing refresh12+ // 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 data18+ // 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 data23+ // 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: hasChanged36+ changed: hasChanged
37- });37+ });
38- }38+ }
39- 39+
40- return hasChanged;40+ return hasChanged;
41- }41+ }
42- 42+
43- // Get cached data43+ // 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 milliseconds48+ // 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 instance54 // 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 state23+ 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 state29+ 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 state38+ 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-room42+ 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 mount46+ onMount(() => {
47- onMount(() => {47+ if (window.innerWidth > 768) {
48- if (window.innerWidth > 768) { // Desktop mode48+ // 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 data68+ // 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: siPassword83+ 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 provided99+ 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 login101+
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 schemas116+ }
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: suTurnstileToken134+ 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 captcha154+
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: suTurnstileToken171+ 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 connecter303+ {/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'inscrire365+ 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-// Props4+ // 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-// State19+
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 palette24+
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 props28+
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/gradient37+
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 selection45+
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 reactivity49+ gradientColors[activeGradientSlot] = color;
50- 50+ gradientColors = [...gradientColors]; // Trigger reactivity
51- if (gradientColors.length === 1) {51+
52- // Single color mode52+ 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 mode57+ } 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 management65+
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 mode82+ 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 picker97+
98-function closePicker() {98+ // Close picker
99- showPicker = false;99+ function closePicker() {
100-}100+ showPicker = false;
101-101+ }
102-// Handle click outside to close102+
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 listener112+
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- <button147+ <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 = true12+ 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-// Props8+ 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-// State17+
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 formatting22+ 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 available33+ 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 message44+ '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 compatibility46+ '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 changes66+ } finally {
67-$effect(() => {67+ isProcessing = false;
68- processMessage();68+ }
69-});69+ }
70-70+
71-// Utility function to escape HTML71+ // 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 message78+ 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 messages88+
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 message120+ 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 class131+ }
132- classes.push(`retro-font-${style.fontFamily}`);132+
133- 133+ try {
134- // Add style classes134+ 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 applicable140+ 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 result16+ }>();
17- let validation = $derived(validatePasswordStrength(password, constraints));17+
18-18+ // Reactive validation result
19- // Get strength color and label19+ 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 FontFamily8+ 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-// Props12+ 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 = true18+ 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-// State27+ // 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 functions31+ // 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 handler58+ // 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 change78+ // 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- generateCSSStyle8+ } 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-// Props13+ 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 state26+ 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 changes32+ $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 selection55+ 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 selection60+ 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 selection66+ 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 changes72+ $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 options78+ 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 adjustment95+ 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 whitespace15+ // 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 string41+ // 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 error61+ 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 handler68+ // Add resize handler
69- const handleResize = () => scaleTurnstileToFit();69+ const handleResize = () => scaleTurnstileToFit();
70- window.addEventListener('resize', handleResize);70+ window.addEventListener('resize', handleResize);
71- // Initial scale71+ // 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 width87+
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 text17+ 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 decreasing26+ // 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 defaults11+
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 tooltips36+ 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-// Props11+ // Props
12-let { 12+ let { showModal = $bindable(false), onSave } = $props<{
13- showModal = $bindable(false),13+ showModal: boolean;
14- onSave14+ 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-// State20+ 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 changes26+ $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 changes31+ 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 mount36+ 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 preferences52+ 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 defaults68+ // 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 changes74+ 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 shortcuts79+ }
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 Preferences97+ <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 messages114+
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 Defaults192+ 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- Cancel200+ </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 data13 // 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 loaded27 // 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 constraints24+ // 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 directly46 // 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 object49+ senderId: t
51- hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag50+ .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 styles69+ 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 interfaces75 // 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 interfaces81 // 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.lastSeen94+ 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.createdAt105+ 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.name118+ 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 views126 // 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- messageView135+ 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 update18+ // Update last activity but don't trigger status update
19- existing.lastActivity = Date.now();19+ existing.lastActivity = Date.now();
20- return false; // Connection already exists20+ 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 connection33+ 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: staleUserIds81+ userIds: staleUserIds
82- });82+ });
83- }83+ }
84- 84+
85- return staleUserIds;85+ return staleUserIds;
86- }86+ }
87 }87 }
88 88
89 // Export singleton instance89 // 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 types17+ // 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 fallback12 // 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 second23+ 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 handlers36+ // 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 set57+ // 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 updates63+ // First set up SSE to receive status updates
62- this.setupSSE();64+ this.setupSSE();
63- // Then initialize messages and room users65+ // 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.sseRetryAfter77+
76- };78+ // User methods
77- }79+ getCurrentUser() {
78-80+ return this.currentUser;
79- // User methods81+ }
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 calls89+ 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, skip98+ 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 passed104+ // 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 connection107+ 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 user117+
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 user122+ // 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 user125+ // Cleanup when user is removed
124- await this.reinitialize();126+ if (this.eventSource) {
125- } else {127+ this.eventSource.close();
126- // Cleanup when user is removed128+ 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 offline163+ // 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 users165+
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 list168+ 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 users175+ 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 offline186+ 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': 3192+ 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 cache199+ 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 fetches204+ 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: null210+ 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) < 5000231+ 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 first237+
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 array240+ 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 first256+
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 array259+ 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 user262+ } 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 === status277+ .then((response) => response.json())
276- });278+ .then((data) => {
277- } else {279+ if (data.success && data.user) {
278- // If user not found anywhere, fetch from API280+ 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 array291+ 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 methods308+ }
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).length317+
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 cache325+ 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 parallel329+ 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 present341+ }
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 prepending353+ (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 provided358+ 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 access364+ 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 objects367+ 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 changed372+ 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 access387+ }
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 hash390+ 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 changed394+ 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 cache408+ 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 access417+ this.lastBuddyListHash = newHash;
416- updateMessages(messages: Message[]) {418+ this.lastBuddyListUpdate = now;
417- // Deduplicate the messages by their id in case duplicates exist419+ }
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 parameters428+ }
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 cache445+
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 parallel449+ }
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 present461+ 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 response476+ }
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.hasMoreMessages484+ // 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_ID508+ console.debug('Cannot send message: No current user');
507- };509+ return {
508- 510+ success: false,
509- // Add styleData if textStyle is provided511+ 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 timeouts555+ }
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 attempts566+ 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 connection591+ 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 connection600+ 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 handling612+ 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 messages637+
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 message642+ }
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 bottom644+ // 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 available646+ 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 handler653+ // 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 backoff700+ this.reconnectAttempts++;
699- // Ensure any existing connection is properly closed before reconnecting701+ 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: null720+ 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 details1 // 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 password4+ 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 timestamp9+ 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 conversations14+// 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 groups17+ 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 timestamp19+ createdAt: number; // Creation timestamp
20- }20+}
21- 21+
22- // Messages table: stores the conversation messages22+// 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 sent29+ timestamp: number; // Timestamp when the message was sent
30- styleData?: string; // JSON string of TextStyle object for formatting30+ styleData?: string; // JSON string of TextStyle object for formatting
31- hasFormatting?: boolean; // Flag to indicate if message has custom formatting31+ hasFormatting?: boolean; // Flag to indicate if message has custom formatting
32- }32+}
33-33+
34- // Safe user type for API responses - omits sensitive information34+// 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 object37+// 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 ?? 044+ 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 SafeUser49+}
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 // Login3 // Login
4 export interface LoginResponseSuccess {4 export interface LoginResponseSuccess {
@@ -40,36 +40,36 @@ export interface SessionResponseError {
40 40
41 // Messages41 // 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 TextStyle47+ 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 message58+ 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 users4 // Primary font is Pixelated MS Sans Serif from xp.css which works for all users
5 // Secondary fonts use local system fonts with proper fallbacks5 // 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 hue31 // 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 colors35+ // 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 hue38+ // Then add colors in a more logical order by hue
27- // Red hues39+ // 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 hues43+ // 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 hues47+ // 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 hues51+ // 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 hues56+ // 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 hues61+ // 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 hues66+ // 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 hues71+ // 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- // Browns75+ // 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 colors78+ // 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 compatibility96 // 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 faders118 // 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 options144 // Text styling options
120 export interface TextStyle {145 export interface TextStyle {
121- fontFamily: FontFamily;146+ fontFamily: FontFamily;
122- fontSize: number; // in pixels147+ fontSize: number; // in pixels
123- color?: string; // hex color string148+ color?: string; // hex color string
124- gradient?: string[]; // array of 1-3 hex colors for gradients149+ 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 style156 // 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: false164+ strikethrough: false
140 };165 };
141 166
142 // User preferences for text formatting167 // 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 preferences174 // 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: 500178+ maxMessageLength: 500
154 };179 };
155 180
156 // Hex color validation181 // 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 validation186 // 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 TextStyle209 // 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 family213+ // 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 size216+ // Font size
183- styles.push(`font-size: ${style.fontSize}px`);217+ styles.push(`font-size: ${style.fontSize}px`);
184- 218+
185- // Color or gradient219+ // 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 weight230+ // 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 style240+ // 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 decoration245+ // 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 size260+ // Font size
227- styles.push(`font-size: ${style.fontSize}px`);261+ styles.push(`font-size: ${style.fontSize}px`);
228- 262+
229- // Color handling for inputs263+ // 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 effect265+ // 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 array274+ // 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 set279+ // Fallback to black if no color is set
246- styles.push(`color: #000000`);280+ styles.push(`color: #000000`);
247- }281+ }
248- 282+
249- // Font weight283+ // 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 style293+ // 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 decoration298+ // 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 variable9 // 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 errors13 // 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 exists23 // 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 attempts27+ // 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 room49+ // 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 string4 * @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: false14+ 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 string23 * @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: false30+ 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 string39 * @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 string53 * @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 detail93 * @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 format123+ }
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 message132 * @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 valid142+ // 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 module52 // 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 Vectors4 // XSS Test Vectors
5 const XSS_TEST_VECTORS = [5 const XSS_TEST_VECTORS = [
6- // Script injection6+ // 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 handlers13+ // 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 injection19+ // 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 URIs24+ // 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 injection28+ // 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 attempts33+ // 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 attempts38+ // Markdown injection attempts
39- '![XSS](javascript:alert("XSS"))',39+ '![XSS](javascript:alert("XSS"))',
40- '[XSS](javascript:alert("XSS"))',40+ '[XSS](javascript:alert("XSS"))',
41- 41+
42- // Unicode and encoding bypasses42+ // Unicode and encoding bypasses
43- '<script>alert(&#34;XSS&#34;)</script>',43+ '<script>alert(&#34;XSS&#34;)</script>',
44- '<script>alert(String.fromCharCode(88,83,83))</script>',44+ '<script>alert(String.fromCharCode(88,83,83))</script>',
45- '<img src="x" onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">',45+ '<img src="x" onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">',
46- 46+
47- // CSS expression injection47+ // 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 attacks51+ // 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 Vectors57 // 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 Interface68 // 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 patterns90 // 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 tags94+ // 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 handlers99+ // 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 URLs104+ // 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/JS109+ // 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 expressions114+ // 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 URLs119+ // 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 src124+ // 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 tags129+ // 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 injection134+ // 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 tests142 // 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 input162+ 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 tests172 // 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 tests203 // 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 limits210+ 'Very long text '.repeat(200), // Test length limits
211- '', // Empty text211+ '', // 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 tests234 // 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 suite275 // 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 vulnerabilities288+ // 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 function321 // 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 acceptable328+ // Errors on malicious input are acceptable
329- return true;329+ return true;
330- }330+ }
331 }331 }
332 332
333 // Style validation tests333 // Style validation tests
334 export function testStyleValidation(): boolean {334 export function testStyleValidation(): boolean {
335- const testCases = [335+ const testCases = [
336- // Valid styles336+ // 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 values350+ // 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 utilities364 // 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 SecurityTestSuite370+ 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 security9 // 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 patterns28 // 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- // Bold33+ // Bold
51- { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },34+ { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },
52- 35+
53- // Italic36+ // Italic
54- { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },37+ { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },
55- 38+
56- // Underline39+ // Underline
57- { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },40+ { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
58- 41+
59- // Strikethrough42+ // Strikethrough
60- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },43+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
61- 44+
62- // Color45+ // Color
63- { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' },46+ {
64- 47+ pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,
65- // Size48+ 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- // Font51+ // 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- // Strikethrough77+ // Strikethrough
86- { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },78+ { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },
87- 79+
88- // Inline code80+ // 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 HTML84 // 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 first88+ // 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 tags95+ // 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 tags97+ // 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 markdown102+ // 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 function114 // 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 = true118+ allowRichFormatting: boolean = true
127 ): Promise<string> {119 ): Promise<string> {
128- try {120+ try {
129- // If text is empty, return empty string121+ // If text is empty, return empty string
130- if (!text) return '';122+ if (!text) return '';
131- 123+
132- // Validate and apply user style124+ // Validate user style (ensures safe values)
133- const validatedStyle = validateTextStyle(userStyle || {});125+ validateTextStyle(userStyle || {});
134- 126+
135- // Preprocess the text for formatting if allowed127+ // 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 text133+ // 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 processor138+ // 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 document141+ fragment: true // Parse as fragment, not full document
150- })142+ })
151- .use(remarkRehype, {143+ .use(remarkRehype, {
152- allowDangerousHtml: true // Allow preprocessed HTML to pass through144+ allowDangerousHtml: true // Allow preprocessed HTML to pass through
153- })145+ })
154- .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML146+ .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML
155- .use(rehypeStringify);147+ .use(rehypeStringify);
156- 148+
157- // Process the text149+ // 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 text153+ // 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 paragraph158+ // 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 text166+ // 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 fallback175+ return escapeHtml(text);
184- return escapeHtml(text);176+ }
185- }
186 }177 }
187 178
188 // Utility function to escape HTML179 // 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 size190+ // 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 weight198+ // 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 style203+ // 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 decoration208+ // 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 effect219 // 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?: TextStyle224+ 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 colors241+ // 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 '&nbsp;'; // Use non-breaking space to preserve spacing244+ return '&nbsp;'; // 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 between250+ // 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 colors256+ // 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 styles271+ 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 color274+ 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 spacing289+ });
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 RGB301 // 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 security313 // 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 length322+ // 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 patterns328+ // 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*=/gi336+ /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- errors349+ 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 mode17+ // 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` : undefined42+ 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 configuration3 // 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 constraints14 // 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 messages25 // 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 constraints37 // 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 requirements43+ .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 check60+ 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.common70+ 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 schema84 // 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 feedback122 // 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 checks127+ 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 checks140+ 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 check177+ });
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 minimum212+
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: false43+ strikethrough: false
44- };44+ };
45- 45+
46- // Validate font family46+ // 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 size51+ // 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 color56+ // 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 gradient61+ // 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 properties67+ // 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 input79+ // 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.session8+};
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_MS34 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: timestamp68 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 status163 // Set initial online status
164 updateUserStatus('online');164 updateUserStatus('online');
165- 165+
166 // Start monitoring for user activity166 // 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 timer171 // Initialize idle timer
172 resetIdleTimer();172 resetIdleTimer();
173 173
@@ -176,7 +176,7 @@
176 176
177 // Add visibility change listener177 // Add visibility change listener
178 document.addEventListener('visibilitychange', handleVisibilityChange);178 document.addEventListener('visibilitychange', handleVisibilityChange);
179- 179+
180 // Add beforeunload listener for cleanup180 // 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.id207+ 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 isVisible219 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 invalidation4+ // 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 details7+ // 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.id13+ 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- } : null19+ ? {
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 function26+ // 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_STYLE14+ 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_STYLE24+ lastTextStyle: DEFAULT_TEXT_STYLE
25- };25+ };
26- }26+ }
27-27+
28- // Get user's last used text styling from their most recent message28+ // 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: true33+ 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 properties42+ // Merge with default style to ensure all required properties
43- lastTextStyle = {43+ lastTextStyle = {
44- ...DEFAULT_TEXT_STYLE,44+ ...DEFAULT_TEXT_STYLE,
45- ...parsedStyle45+ ...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- lastTextStyle54+ 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_STYLE60+ 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 configuration22 // Rate limiting configuration
23 const INITIAL_COOLDOWN = 1000; // 1 second23 const INITIAL_COOLDOWN = 1000; // 1 second
24 const MAX_COOLDOWN = 30000; // 30 seconds24 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 message34+ 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 enough43+ };
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 backoff47+ 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 DB60 // 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- isPublic72+ 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 status84+ // Fetch messages with a limit based on authentication status
79- // Public requests are limited to 50 messages85+ // Public requests are limited to 50 messages
80- // Authenticated users get 100 messages per request86+ // 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 ?? false97+ ...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 messages103+ 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 SSE120 // 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 limiting129+ // 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: true138+ 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 existence169+
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 existence184+ // 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 point214+ 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 DB217+ 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 SSE221+
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: newMessage228+ 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 IP12 // 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 hour15 // 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 validation39 // 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 schema66 // Validate input using Zod schema
@@ -64,13 +73,15 @@ export const POST: RequestHandler = async ({ request }) => {
64 captchaAnswer,73 captchaAnswer,
65 turnstileToken74 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 captcha85 // 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 success98 // 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 minutes11 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 users17+ 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 out29+ // 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 offline32+ // 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 createSafeUser47+ // 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: sanitizedUsers57+ 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.length70+ }
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 IP16 // 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 hour21 // 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 validation46+ 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 schema74+
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 username88+ 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 password108+ }
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 success130+ } 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 session133+ status: 500
132- const token = generateSessionToken();134+ });
133- const session = await createSession(token, user.id);135+ }
134-136+
135- // Update user status to online137+ // 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 cookie145+ 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 code1 // At the top of the file, before any code
2 declare global {2 declare global {
3- // eslint-disable-next-line no-var3+ 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 users34 // 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: now52+ status: 'offline',
54- })53+ lastSeen: now
55- .where(54+ })
56- and(55+ .where(
57- ne(users.status, 'offline'), // Update any non-offline users56+ and(
58- lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently57+ 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.lastSeen63+ 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 timeout72+
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 update90+ // 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 exist92+ // 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 data97+ // 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 changed100+ // 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: safeBuddyList107+ data: safeBuddyList
108- });108+ });
109- }109+ }
110- } else {110+ } else {
111- // Use cached data111+ // 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: cachedData118+ 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 seconds127+ }, 1000); // Check every second but only broadcast every 10 seconds
128 }128 }
129 129
130 // Add periodic cleanup for stale connections130 // 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 offline141+ // 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 minute154+ }
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 reloaded169 // 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 SIGINT177+ // 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?.id196+ 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 connection216+ // 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 changes233+
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: now241+ 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 tracker294+ // 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 connection297+ // 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 offline304+
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 database39+ // Update the user status in the database
40- // Always update lastSeen to support timeout detection40+ // 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 detection44+ 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 day52+ // 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() : null28+ 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: safeUser33+ 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 Preview5 ### 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 Formatting15 ### 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` parameter20 - Updated `sendMessage` function to accept and pass `textStyle` parameter
17 - Added `styleData` field to `SendMessageRequest` payload type21 - Added `styleData` field to `SendMessageRequest` payload type
18 - Updated server-side message handler to save `styleData` and `hasFormatting` fields22 - 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 window49 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 size51 3. Choose a font and adjust size
47 4. Type a message - you should see the first gradient color in the input52 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 effect53 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 constraints24+ // 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 directly46 // 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 stripping9 * 1. Console logging (development/client-side) - controlled by console stripping
10 * 2. Pino logging (server-side) - controlled by LOG_LEVEL10 * 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 logs13 * - error: only error logs
14 * - warn: warn and error logs14 * - warn: warn and error logs
@@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => {
35 // Load env file based on `mode` value35 // 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_LEVEL50 // 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 code55 // Make LOG_LEVEL available to client-side code
56 define: {56 define: {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close