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 @@
11 version: 2
22 updates:
33 # npm dependencies via pnpm
4- - package-ecosystem: "npm"
5- directory: "/"
4+ - package-ecosystem: 'npm'
5+ directory: '/'
66 schedule:
7- interval: "weekly"
8- day: "monday"
9- time: "09:00"
10- timezone: "Europe/Paris"
7+ interval: 'weekly'
8+ day: 'monday'
9+ time: '09:00'
10+ timezone: 'Europe/Paris'
1111 # Group updates to reduce PR noise
1212 groups:
1313 # Group SvelteKit and related core framework updates
1414 sveltekit:
1515 patterns:
16- - "@sveltejs/*"
17- - "svelte*"
16+ - '@sveltejs/*'
17+ - 'svelte*'
1818 update-types:
19- - "minor"
20- - "patch"
19+ - 'minor'
20+ - 'patch'
2121 # Group development tooling updates
2222 dev-tools:
2323 patterns:
24- - "eslint*"
25- - "prettier*"
26- - "@eslint/*"
27- - "typescript*"
28- - "@types/*"
29- - "vite*"
30- - "vitest*"
24+ - 'eslint*'
25+ - 'prettier*'
26+ - '@eslint/*'
27+ - 'typescript*'
28+ - '@types/*'
29+ - 'vite*'
30+ - 'vitest*'
3131 update-types:
32- - "minor"
33- - "patch"
32+ - 'minor'
33+ - 'patch'
3434 # Group Tailwind CSS related packages
3535 tailwind:
3636 patterns:
37- - "tailwindcss*"
38- - "@tailwindcss/*"
39- - "autoprefixer"
37+ - 'tailwindcss*'
38+ - '@tailwindcss/*'
39+ - 'autoprefixer'
4040 update-types:
41- - "minor"
42- - "patch"
41+ - 'minor'
42+ - 'patch'
4343 # Group Drizzle ORM related packages
4444 drizzle:
4545 patterns:
46- - "drizzle-*"
47- - "@libsql/*"
46+ - 'drizzle-*'
47+ - '@libsql/*'
4848 update-types:
49- - "minor"
50- - "patch"
49+ - 'minor'
50+ - 'patch'
5151 # Set PR limits to avoid overwhelming the team
5252 open-pull-requests-limit: 8
5353 # Ignore packages that require manual updates or are pinned for specific reasons
5454 ignore:
5555 # Node.js version is managed via engines field and should be manually updated
56- - dependency-name: "node"
56+ - dependency-name: 'node'
5757 # Major framework versions should be manually reviewed due to breaking changes
58- - dependency-name: "@sveltejs/kit"
59- update-types: ["version-update:semver-major"]
60- - dependency-name: "svelte"
61- update-types: ["version-update:semver-major"]
62- - dependency-name: "typescript"
63- update-types: ["version-update:semver-major"]
58+ - dependency-name: '@sveltejs/kit'
59+ update-types: ['version-update:semver-major']
60+ - dependency-name: 'svelte'
61+ update-types: ['version-update:semver-major']
62+ - dependency-name: 'typescript'
63+ update-types: ['version-update:semver-major']
6464 # Configure commit message format
6565 commit-message:
66- prefix: "deps"
67- include: "scope"⏎̸
66+ prefix: 'deps'
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 @@
11 # Bienvenue
22
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.
44
55 ## Technologies utilisées
6+
67 - SvelteKit (version 5)
78 - Node.js
89 - TypeScript
910 - XP.css
1011 - Tailwind CSS
1112
12-
1313 ## Prérequis
14+
1415 - Node.js (>=22.13.0)
1516 - npm ou pnpm pour la gestion des packages
1617
1718 ## Installation
19+
1820 Clonez le dépôt et installez les dépendances :
1921
2022 ```bash
@@ -24,6 +26,7 @@ pnpm install
2426 ```
2527
2628 ## Lancement du projet
29+
2730 Lancez le projet en mode développement :
2831
2932 ```bash
@@ -35,8 +38,10 @@ pnpm dev
3538 Le site sera accessible via http://localhost:5173 par défaut.
3639
3740 ## Contribution
41+
3842 Toute contribution visant à améliorer le réalisme et la qualité du projet est la bienvenue !
3943 Pour contribuer :
44+
4045 - Forkez ce dépôt
4146 - Créez une branche pour vos modifications
4247 - Envoyez une pull request en décrivant clairement vos changements
@@ -60,6 +65,7 @@ npm run db:push
6065 # ou
6166 pnpm db:push
6267 ```
68+
6369 Vous pouvez avoir un aperçu du contenu de la base en utilisant :
6470
6571 ```bash
@@ -67,7 +73,8 @@ npx drizzle-kit studio
6773 ```
6874
6975 ## Documentation et Ressources
76+
7077 Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles :
78+
7179 - [Documentation de Svelte](https://svelte.dev/docs/)
7280 - [Documentation de SvelteKit](https://svelte.dev/docs/kit/@sveltejs-kit)
73-
M drizzle.config.ts +8 -8
@@ -2,11 +2,11 @@ import 'dotenv/config';
22 import { defineConfig } from 'drizzle-kit';
33
44 export default defineConfig({
5- out: './drizzle',
6- schema: './src/lib/db/schema.ts',
7- dialect: 'turso',
8- dbCredentials: {
9- url: process.env.TURSO_DATABASE_URL as string,
10- authToken: process.env.TURSO_AUTH_TOKEN as string,
11- },
12-});⏎̸
5+ out: './drizzle',
6+ schema: './src/lib/db/schema.ts',
7+ dialect: 'turso',
8+ dbCredentials: {
9+ url: process.env.TURSO_DATABASE_URL as string,
10+ authToken: process.env.TURSO_AUTH_TOKEN as string
11+ }
12+});
M drizzle/meta/0000_snapshot.json +331 -341
@@ -1,342 +1,332 @@
11 {
2- "version": "6",
3- "dialect": "sqlite",
4- "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
5- "prevId": "00000000-0000-0000-0000-000000000000",
6- "tables": {
7- "chat_rooms": {
8- "name": "chat_rooms",
9- "columns": {
10- "id": {
11- "name": "id",
12- "type": "text",
13- "primaryKey": true,
14- "notNull": true,
15- "autoincrement": false
16- },
17- "name": {
18- "name": "name",
19- "type": "text",
20- "primaryKey": false,
21- "notNull": false,
22- "autoincrement": false
23- },
24- "type": {
25- "name": "type",
26- "type": "text",
27- "primaryKey": false,
28- "notNull": true,
29- "autoincrement": false,
30- "default": "'direct'"
31- },
32- "created_at": {
33- "name": "created_at",
34- "type": "integer",
35- "primaryKey": false,
36- "notNull": true,
37- "autoincrement": false
38- }
39- },
40- "indexes": {},
41- "foreignKeys": {},
42- "compositePrimaryKeys": {},
43- "uniqueConstraints": {},
44- "checkConstraints": {}
45- },
46- "messages": {
47- "name": "messages",
48- "columns": {
49- "id": {
50- "name": "id",
51- "type": "text",
52- "primaryKey": true,
53- "notNull": true,
54- "autoincrement": false
55- },
56- "chat_room_id": {
57- "name": "chat_room_id",
58- "type": "text",
59- "primaryKey": false,
60- "notNull": true,
61- "autoincrement": false
62- },
63- "sender_id": {
64- "name": "sender_id",
65- "type": "text",
66- "primaryKey": false,
67- "notNull": true,
68- "autoincrement": false
69- },
70- "content": {
71- "name": "content",
72- "type": "text",
73- "primaryKey": false,
74- "notNull": true,
75- "autoincrement": false
76- },
77- "type": {
78- "name": "type",
79- "type": "text",
80- "primaryKey": false,
81- "notNull": true,
82- "autoincrement": false,
83- "default": "'chat'"
84- },
85- "timestamp": {
86- "name": "timestamp",
87- "type": "integer",
88- "primaryKey": false,
89- "notNull": true,
90- "autoincrement": false
91- }
92- },
93- "indexes": {},
94- "foreignKeys": {
95- "messages_chat_room_id_chat_rooms_id_fk": {
96- "name": "messages_chat_room_id_chat_rooms_id_fk",
97- "tableFrom": "messages",
98- "tableTo": "chat_rooms",
99- "columnsFrom": [
100- "chat_room_id"
101- ],
102- "columnsTo": [
103- "id"
104- ],
105- "onDelete": "no action",
106- "onUpdate": "no action"
107- },
108- "messages_sender_id_users_id_fk": {
109- "name": "messages_sender_id_users_id_fk",
110- "tableFrom": "messages",
111- "tableTo": "users",
112- "columnsFrom": [
113- "sender_id"
114- ],
115- "columnsTo": [
116- "id"
117- ],
118- "onDelete": "no action",
119- "onUpdate": "no action"
120- }
121- },
122- "compositePrimaryKeys": {},
123- "uniqueConstraints": {},
124- "checkConstraints": {}
125- },
126- "users": {
127- "name": "users",
128- "columns": {
129- "id": {
130- "name": "id",
131- "type": "text",
132- "primaryKey": true,
133- "notNull": true,
134- "autoincrement": false
135- },
136- "email": {
137- "name": "email",
138- "type": "text",
139- "primaryKey": false,
140- "notNull": true,
141- "autoincrement": false
142- },
143- "password": {
144- "name": "password",
145- "type": "text",
146- "primaryKey": false,
147- "notNull": true,
148- "autoincrement": false
149- },
150- "nickname": {
151- "name": "nickname",
152- "type": "text",
153- "primaryKey": false,
154- "notNull": true,
155- "autoincrement": false
156- },
157- "status": {
158- "name": "status",
159- "type": "text",
160- "primaryKey": false,
161- "notNull": false,
162- "autoincrement": false,
163- "default": "'offline'"
164- },
165- "avatar_url": {
166- "name": "avatar_url",
167- "type": "text",
168- "primaryKey": false,
169- "notNull": false,
170- "autoincrement": false
171- },
172- "created_at": {
173- "name": "created_at",
174- "type": "integer",
175- "primaryKey": false,
176- "notNull": true,
177- "autoincrement": false
178- },
179- "last_seen": {
180- "name": "last_seen",
181- "type": "integer",
182- "primaryKey": false,
183- "notNull": false,
184- "autoincrement": false
185- }
186- },
187- "indexes": {
188- "users_email_unique": {
189- "name": "users_email_unique",
190- "columns": [
191- "email"
192- ],
193- "isUnique": true
194- }
195- },
196- "foreignKeys": {},
197- "compositePrimaryKeys": {},
198- "uniqueConstraints": {},
199- "checkConstraints": {}
200- }
201- },
202- "views": {
203- "chat_room_view": {
204- "columns": {
205- "id": {
206- "name": "id",
207- "type": "text",
208- "primaryKey": true,
209- "notNull": true,
210- "autoincrement": false
211- },
212- "name": {
213- "name": "name",
214- "type": "text",
215- "primaryKey": false,
216- "notNull": false,
217- "autoincrement": false
218- },
219- "type": {
220- "name": "type",
221- "type": "text",
222- "primaryKey": false,
223- "notNull": true,
224- "autoincrement": false,
225- "default": "'direct'"
226- },
227- "created_at": {
228- "name": "created_at",
229- "type": "integer",
230- "primaryKey": false,
231- "notNull": true,
232- "autoincrement": false
233- }
234- },
235- "name": "chat_room_view",
236- "isExisting": false,
237- "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
238- },
239- "message_view": {
240- "columns": {
241- "id": {
242- "name": "id",
243- "type": "text",
244- "primaryKey": true,
245- "notNull": true,
246- "autoincrement": false
247- },
248- "content": {
249- "name": "content",
250- "type": "text",
251- "primaryKey": false,
252- "notNull": true,
253- "autoincrement": false
254- },
255- "type": {
256- "name": "type",
257- "type": "text",
258- "primaryKey": false,
259- "notNull": true,
260- "autoincrement": false,
261- "default": "'chat'"
262- },
263- "timestamp": {
264- "name": "timestamp",
265- "type": "integer",
266- "primaryKey": false,
267- "notNull": true,
268- "autoincrement": false
269- },
270- "nickname": {
271- "name": "nickname",
272- "type": "text",
273- "primaryKey": false,
274- "notNull": true,
275- "autoincrement": false
276- },
277- "name": {
278- "name": "name",
279- "type": "text",
280- "primaryKey": false,
281- "notNull": false,
282- "autoincrement": false
283- }
284- },
285- "name": "message_view",
286- "isExisting": false,
287- "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
288- },
289- "user_view": {
290- "columns": {
291- "id": {
292- "name": "id",
293- "type": "text",
294- "primaryKey": true,
295- "notNull": true,
296- "autoincrement": false
297- },
298- "nickname": {
299- "name": "nickname",
300- "type": "text",
301- "primaryKey": false,
302- "notNull": true,
303- "autoincrement": false
304- },
305- "status": {
306- "name": "status",
307- "type": "text",
308- "primaryKey": false,
309- "notNull": false,
310- "autoincrement": false,
311- "default": "'offline'"
312- },
313- "avatar_url": {
314- "name": "avatar_url",
315- "type": "text",
316- "primaryKey": false,
317- "notNull": false,
318- "autoincrement": false
319- },
320- "last_seen": {
321- "name": "last_seen",
322- "type": "integer",
323- "primaryKey": false,
324- "notNull": false,
325- "autoincrement": false
326- }
327- },
328- "name": "user_view",
329- "isExisting": false,
330- "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
331- }
332- },
333- "enums": {},
334- "_meta": {
335- "schemas": {},
336- "tables": {},
337- "columns": {}
338- },
339- "internal": {
340- "indexes": {}
341- }
342-}⏎̸
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
5+ "prevId": "00000000-0000-0000-0000-000000000000",
6+ "tables": {
7+ "chat_rooms": {
8+ "name": "chat_rooms",
9+ "columns": {
10+ "id": {
11+ "name": "id",
12+ "type": "text",
13+ "primaryKey": true,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "name": {
18+ "name": "name",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": false,
22+ "autoincrement": false
23+ },
24+ "type": {
25+ "name": "type",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false,
30+ "default": "'direct'"
31+ },
32+ "created_at": {
33+ "name": "created_at",
34+ "type": "integer",
35+ "primaryKey": false,
36+ "notNull": true,
37+ "autoincrement": false
38+ }
39+ },
40+ "indexes": {},
41+ "foreignKeys": {},
42+ "compositePrimaryKeys": {},
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "messages": {
47+ "name": "messages",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": true,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "chat_room_id": {
57+ "name": "chat_room_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "sender_id": {
64+ "name": "sender_id",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "content": {
71+ "name": "content",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": true,
75+ "autoincrement": false
76+ },
77+ "type": {
78+ "name": "type",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "autoincrement": false,
83+ "default": "'chat'"
84+ },
85+ "timestamp": {
86+ "name": "timestamp",
87+ "type": "integer",
88+ "primaryKey": false,
89+ "notNull": true,
90+ "autoincrement": false
91+ }
92+ },
93+ "indexes": {},
94+ "foreignKeys": {
95+ "messages_chat_room_id_chat_rooms_id_fk": {
96+ "name": "messages_chat_room_id_chat_rooms_id_fk",
97+ "tableFrom": "messages",
98+ "tableTo": "chat_rooms",
99+ "columnsFrom": ["chat_room_id"],
100+ "columnsTo": ["id"],
101+ "onDelete": "no action",
102+ "onUpdate": "no action"
103+ },
104+ "messages_sender_id_users_id_fk": {
105+ "name": "messages_sender_id_users_id_fk",
106+ "tableFrom": "messages",
107+ "tableTo": "users",
108+ "columnsFrom": ["sender_id"],
109+ "columnsTo": ["id"],
110+ "onDelete": "no action",
111+ "onUpdate": "no action"
112+ }
113+ },
114+ "compositePrimaryKeys": {},
115+ "uniqueConstraints": {},
116+ "checkConstraints": {}
117+ },
118+ "users": {
119+ "name": "users",
120+ "columns": {
121+ "id": {
122+ "name": "id",
123+ "type": "text",
124+ "primaryKey": true,
125+ "notNull": true,
126+ "autoincrement": false
127+ },
128+ "email": {
129+ "name": "email",
130+ "type": "text",
131+ "primaryKey": false,
132+ "notNull": true,
133+ "autoincrement": false
134+ },
135+ "password": {
136+ "name": "password",
137+ "type": "text",
138+ "primaryKey": false,
139+ "notNull": true,
140+ "autoincrement": false
141+ },
142+ "nickname": {
143+ "name": "nickname",
144+ "type": "text",
145+ "primaryKey": false,
146+ "notNull": true,
147+ "autoincrement": false
148+ },
149+ "status": {
150+ "name": "status",
151+ "type": "text",
152+ "primaryKey": false,
153+ "notNull": false,
154+ "autoincrement": false,
155+ "default": "'offline'"
156+ },
157+ "avatar_url": {
158+ "name": "avatar_url",
159+ "type": "text",
160+ "primaryKey": false,
161+ "notNull": false,
162+ "autoincrement": false
163+ },
164+ "created_at": {
165+ "name": "created_at",
166+ "type": "integer",
167+ "primaryKey": false,
168+ "notNull": true,
169+ "autoincrement": false
170+ },
171+ "last_seen": {
172+ "name": "last_seen",
173+ "type": "integer",
174+ "primaryKey": false,
175+ "notNull": false,
176+ "autoincrement": false
177+ }
178+ },
179+ "indexes": {
180+ "users_email_unique": {
181+ "name": "users_email_unique",
182+ "columns": ["email"],
183+ "isUnique": true
184+ }
185+ },
186+ "foreignKeys": {},
187+ "compositePrimaryKeys": {},
188+ "uniqueConstraints": {},
189+ "checkConstraints": {}
190+ }
191+ },
192+ "views": {
193+ "chat_room_view": {
194+ "columns": {
195+ "id": {
196+ "name": "id",
197+ "type": "text",
198+ "primaryKey": true,
199+ "notNull": true,
200+ "autoincrement": false
201+ },
202+ "name": {
203+ "name": "name",
204+ "type": "text",
205+ "primaryKey": false,
206+ "notNull": false,
207+ "autoincrement": false
208+ },
209+ "type": {
210+ "name": "type",
211+ "type": "text",
212+ "primaryKey": false,
213+ "notNull": true,
214+ "autoincrement": false,
215+ "default": "'direct'"
216+ },
217+ "created_at": {
218+ "name": "created_at",
219+ "type": "integer",
220+ "primaryKey": false,
221+ "notNull": true,
222+ "autoincrement": false
223+ }
224+ },
225+ "name": "chat_room_view",
226+ "isExisting": false,
227+ "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
228+ },
229+ "message_view": {
230+ "columns": {
231+ "id": {
232+ "name": "id",
233+ "type": "text",
234+ "primaryKey": true,
235+ "notNull": true,
236+ "autoincrement": false
237+ },
238+ "content": {
239+ "name": "content",
240+ "type": "text",
241+ "primaryKey": false,
242+ "notNull": true,
243+ "autoincrement": false
244+ },
245+ "type": {
246+ "name": "type",
247+ "type": "text",
248+ "primaryKey": false,
249+ "notNull": true,
250+ "autoincrement": false,
251+ "default": "'chat'"
252+ },
253+ "timestamp": {
254+ "name": "timestamp",
255+ "type": "integer",
256+ "primaryKey": false,
257+ "notNull": true,
258+ "autoincrement": false
259+ },
260+ "nickname": {
261+ "name": "nickname",
262+ "type": "text",
263+ "primaryKey": false,
264+ "notNull": true,
265+ "autoincrement": false
266+ },
267+ "name": {
268+ "name": "name",
269+ "type": "text",
270+ "primaryKey": false,
271+ "notNull": false,
272+ "autoincrement": false
273+ }
274+ },
275+ "name": "message_view",
276+ "isExisting": false,
277+ "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
278+ },
279+ "user_view": {
280+ "columns": {
281+ "id": {
282+ "name": "id",
283+ "type": "text",
284+ "primaryKey": true,
285+ "notNull": true,
286+ "autoincrement": false
287+ },
288+ "nickname": {
289+ "name": "nickname",
290+ "type": "text",
291+ "primaryKey": false,
292+ "notNull": true,
293+ "autoincrement": false
294+ },
295+ "status": {
296+ "name": "status",
297+ "type": "text",
298+ "primaryKey": false,
299+ "notNull": false,
300+ "autoincrement": false,
301+ "default": "'offline'"
302+ },
303+ "avatar_url": {
304+ "name": "avatar_url",
305+ "type": "text",
306+ "primaryKey": false,
307+ "notNull": false,
308+ "autoincrement": false
309+ },
310+ "last_seen": {
311+ "name": "last_seen",
312+ "type": "integer",
313+ "primaryKey": false,
314+ "notNull": false,
315+ "autoincrement": false
316+ }
317+ },
318+ "name": "user_view",
319+ "isExisting": false,
320+ "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
321+ }
322+ },
323+ "enums": {},
324+ "_meta": {
325+ "schemas": {},
326+ "tables": {},
327+ "columns": {}
328+ },
329+ "internal": {
330+ "indexes": {}
331+ }
332+}
M drizzle/meta/0001_snapshot.json +366 -378
@@ -1,379 +1,367 @@
11 {
2- "version": "6",
3- "dialect": "sqlite",
4- "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6",
5- "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
6- "tables": {
7- "chat_rooms": {
8- "name": "chat_rooms",
9- "columns": {
10- "id": {
11- "name": "id",
12- "type": "text",
13- "primaryKey": true,
14- "notNull": true,
15- "autoincrement": false
16- },
17- "name": {
18- "name": "name",
19- "type": "text",
20- "primaryKey": false,
21- "notNull": false,
22- "autoincrement": false
23- },
24- "type": {
25- "name": "type",
26- "type": "text",
27- "primaryKey": false,
28- "notNull": true,
29- "autoincrement": false,
30- "default": "'direct'"
31- },
32- "created_at": {
33- "name": "created_at",
34- "type": "integer",
35- "primaryKey": false,
36- "notNull": true,
37- "autoincrement": false
38- }
39- },
40- "indexes": {},
41- "foreignKeys": {},
42- "compositePrimaryKeys": {},
43- "uniqueConstraints": {},
44- "checkConstraints": {}
45- },
46- "messages": {
47- "name": "messages",
48- "columns": {
49- "id": {
50- "name": "id",
51- "type": "text",
52- "primaryKey": true,
53- "notNull": true,
54- "autoincrement": false
55- },
56- "chat_room_id": {
57- "name": "chat_room_id",
58- "type": "text",
59- "primaryKey": false,
60- "notNull": true,
61- "autoincrement": false
62- },
63- "sender_id": {
64- "name": "sender_id",
65- "type": "text",
66- "primaryKey": false,
67- "notNull": true,
68- "autoincrement": false
69- },
70- "content": {
71- "name": "content",
72- "type": "text",
73- "primaryKey": false,
74- "notNull": true,
75- "autoincrement": false
76- },
77- "type": {
78- "name": "type",
79- "type": "text",
80- "primaryKey": false,
81- "notNull": true,
82- "autoincrement": false,
83- "default": "'chat'"
84- },
85- "timestamp": {
86- "name": "timestamp",
87- "type": "integer",
88- "primaryKey": false,
89- "notNull": true,
90- "autoincrement": false
91- }
92- },
93- "indexes": {},
94- "foreignKeys": {
95- "messages_chat_room_id_chat_rooms_id_fk": {
96- "name": "messages_chat_room_id_chat_rooms_id_fk",
97- "tableFrom": "messages",
98- "tableTo": "chat_rooms",
99- "columnsFrom": [
100- "chat_room_id"
101- ],
102- "columnsTo": [
103- "id"
104- ],
105- "onDelete": "no action",
106- "onUpdate": "no action"
107- },
108- "messages_sender_id_users_id_fk": {
109- "name": "messages_sender_id_users_id_fk",
110- "tableFrom": "messages",
111- "tableTo": "users",
112- "columnsFrom": [
113- "sender_id"
114- ],
115- "columnsTo": [
116- "id"
117- ],
118- "onDelete": "no action",
119- "onUpdate": "no action"
120- }
121- },
122- "compositePrimaryKeys": {},
123- "uniqueConstraints": {},
124- "checkConstraints": {}
125- },
126- "sessions": {
127- "name": "sessions",
128- "columns": {
129- "id": {
130- "name": "id",
131- "type": "text",
132- "primaryKey": true,
133- "notNull": true,
134- "autoincrement": false
135- },
136- "user_id": {
137- "name": "user_id",
138- "type": "text",
139- "primaryKey": false,
140- "notNull": true,
141- "autoincrement": false
142- },
143- "expires_at": {
144- "name": "expires_at",
145- "type": "integer",
146- "primaryKey": false,
147- "notNull": true,
148- "autoincrement": false
149- },
150- "created_at": {
151- "name": "created_at",
152- "type": "integer",
153- "primaryKey": false,
154- "notNull": true,
155- "autoincrement": false
156- }
157- },
158- "indexes": {},
159- "foreignKeys": {
160- "sessions_user_id_users_id_fk": {
161- "name": "sessions_user_id_users_id_fk",
162- "tableFrom": "sessions",
163- "tableTo": "users",
164- "columnsFrom": [
165- "user_id"
166- ],
167- "columnsTo": [
168- "id"
169- ],
170- "onDelete": "no action",
171- "onUpdate": "no action"
172- }
173- },
174- "compositePrimaryKeys": {},
175- "uniqueConstraints": {},
176- "checkConstraints": {}
177- },
178- "users": {
179- "name": "users",
180- "columns": {
181- "id": {
182- "name": "id",
183- "type": "text",
184- "primaryKey": true,
185- "notNull": true,
186- "autoincrement": false
187- },
188- "password": {
189- "name": "password",
190- "type": "text",
191- "primaryKey": false,
192- "notNull": true,
193- "autoincrement": false
194- },
195- "nickname": {
196- "name": "nickname",
197- "type": "text",
198- "primaryKey": false,
199- "notNull": true,
200- "autoincrement": false
201- },
202- "status": {
203- "name": "status",
204- "type": "text",
205- "primaryKey": false,
206- "notNull": true,
207- "autoincrement": false,
208- "default": "'offline'"
209- },
210- "avatar_url": {
211- "name": "avatar_url",
212- "type": "text",
213- "primaryKey": false,
214- "notNull": false,
215- "autoincrement": false
216- },
217- "created_at": {
218- "name": "created_at",
219- "type": "integer",
220- "primaryKey": false,
221- "notNull": true,
222- "autoincrement": false
223- },
224- "last_seen": {
225- "name": "last_seen",
226- "type": "integer",
227- "primaryKey": false,
228- "notNull": false,
229- "autoincrement": false
230- }
231- },
232- "indexes": {},
233- "foreignKeys": {},
234- "compositePrimaryKeys": {},
235- "uniqueConstraints": {},
236- "checkConstraints": {}
237- }
238- },
239- "views": {
240- "chat_room_view": {
241- "columns": {
242- "id": {
243- "name": "id",
244- "type": "text",
245- "primaryKey": true,
246- "notNull": true,
247- "autoincrement": false
248- },
249- "name": {
250- "name": "name",
251- "type": "text",
252- "primaryKey": false,
253- "notNull": false,
254- "autoincrement": false
255- },
256- "type": {
257- "name": "type",
258- "type": "text",
259- "primaryKey": false,
260- "notNull": true,
261- "autoincrement": false,
262- "default": "'direct'"
263- },
264- "created_at": {
265- "name": "created_at",
266- "type": "integer",
267- "primaryKey": false,
268- "notNull": true,
269- "autoincrement": false
270- }
271- },
272- "name": "chat_room_view",
273- "isExisting": false,
274- "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
275- },
276- "message_view": {
277- "columns": {
278- "id": {
279- "name": "id",
280- "type": "text",
281- "primaryKey": true,
282- "notNull": true,
283- "autoincrement": false
284- },
285- "content": {
286- "name": "content",
287- "type": "text",
288- "primaryKey": false,
289- "notNull": true,
290- "autoincrement": false
291- },
292- "type": {
293- "name": "type",
294- "type": "text",
295- "primaryKey": false,
296- "notNull": true,
297- "autoincrement": false,
298- "default": "'chat'"
299- },
300- "timestamp": {
301- "name": "timestamp",
302- "type": "integer",
303- "primaryKey": false,
304- "notNull": true,
305- "autoincrement": false
306- },
307- "nickname": {
308- "name": "nickname",
309- "type": "text",
310- "primaryKey": false,
311- "notNull": true,
312- "autoincrement": false
313- },
314- "name": {
315- "name": "name",
316- "type": "text",
317- "primaryKey": false,
318- "notNull": false,
319- "autoincrement": false
320- }
321- },
322- "name": "message_view",
323- "isExisting": false,
324- "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
325- },
326- "user_view": {
327- "columns": {
328- "id": {
329- "name": "id",
330- "type": "text",
331- "primaryKey": true,
332- "notNull": true,
333- "autoincrement": false
334- },
335- "nickname": {
336- "name": "nickname",
337- "type": "text",
338- "primaryKey": false,
339- "notNull": true,
340- "autoincrement": false
341- },
342- "status": {
343- "name": "status",
344- "type": "text",
345- "primaryKey": false,
346- "notNull": true,
347- "autoincrement": false,
348- "default": "'offline'"
349- },
350- "avatar_url": {
351- "name": "avatar_url",
352- "type": "text",
353- "primaryKey": false,
354- "notNull": false,
355- "autoincrement": false
356- },
357- "last_seen": {
358- "name": "last_seen",
359- "type": "integer",
360- "primaryKey": false,
361- "notNull": false,
362- "autoincrement": false
363- }
364- },
365- "name": "user_view",
366- "isExisting": false,
367- "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
368- }
369- },
370- "enums": {},
371- "_meta": {
372- "schemas": {},
373- "tables": {},
374- "columns": {}
375- },
376- "internal": {
377- "indexes": {}
378- }
379-}⏎̸
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6",
5+ "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
6+ "tables": {
7+ "chat_rooms": {
8+ "name": "chat_rooms",
9+ "columns": {
10+ "id": {
11+ "name": "id",
12+ "type": "text",
13+ "primaryKey": true,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "name": {
18+ "name": "name",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": false,
22+ "autoincrement": false
23+ },
24+ "type": {
25+ "name": "type",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false,
30+ "default": "'direct'"
31+ },
32+ "created_at": {
33+ "name": "created_at",
34+ "type": "integer",
35+ "primaryKey": false,
36+ "notNull": true,
37+ "autoincrement": false
38+ }
39+ },
40+ "indexes": {},
41+ "foreignKeys": {},
42+ "compositePrimaryKeys": {},
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "messages": {
47+ "name": "messages",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": true,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "chat_room_id": {
57+ "name": "chat_room_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "sender_id": {
64+ "name": "sender_id",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "content": {
71+ "name": "content",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": true,
75+ "autoincrement": false
76+ },
77+ "type": {
78+ "name": "type",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "autoincrement": false,
83+ "default": "'chat'"
84+ },
85+ "timestamp": {
86+ "name": "timestamp",
87+ "type": "integer",
88+ "primaryKey": false,
89+ "notNull": true,
90+ "autoincrement": false
91+ }
92+ },
93+ "indexes": {},
94+ "foreignKeys": {
95+ "messages_chat_room_id_chat_rooms_id_fk": {
96+ "name": "messages_chat_room_id_chat_rooms_id_fk",
97+ "tableFrom": "messages",
98+ "tableTo": "chat_rooms",
99+ "columnsFrom": ["chat_room_id"],
100+ "columnsTo": ["id"],
101+ "onDelete": "no action",
102+ "onUpdate": "no action"
103+ },
104+ "messages_sender_id_users_id_fk": {
105+ "name": "messages_sender_id_users_id_fk",
106+ "tableFrom": "messages",
107+ "tableTo": "users",
108+ "columnsFrom": ["sender_id"],
109+ "columnsTo": ["id"],
110+ "onDelete": "no action",
111+ "onUpdate": "no action"
112+ }
113+ },
114+ "compositePrimaryKeys": {},
115+ "uniqueConstraints": {},
116+ "checkConstraints": {}
117+ },
118+ "sessions": {
119+ "name": "sessions",
120+ "columns": {
121+ "id": {
122+ "name": "id",
123+ "type": "text",
124+ "primaryKey": true,
125+ "notNull": true,
126+ "autoincrement": false
127+ },
128+ "user_id": {
129+ "name": "user_id",
130+ "type": "text",
131+ "primaryKey": false,
132+ "notNull": true,
133+ "autoincrement": false
134+ },
135+ "expires_at": {
136+ "name": "expires_at",
137+ "type": "integer",
138+ "primaryKey": false,
139+ "notNull": true,
140+ "autoincrement": false
141+ },
142+ "created_at": {
143+ "name": "created_at",
144+ "type": "integer",
145+ "primaryKey": false,
146+ "notNull": true,
147+ "autoincrement": false
148+ }
149+ },
150+ "indexes": {},
151+ "foreignKeys": {
152+ "sessions_user_id_users_id_fk": {
153+ "name": "sessions_user_id_users_id_fk",
154+ "tableFrom": "sessions",
155+ "tableTo": "users",
156+ "columnsFrom": ["user_id"],
157+ "columnsTo": ["id"],
158+ "onDelete": "no action",
159+ "onUpdate": "no action"
160+ }
161+ },
162+ "compositePrimaryKeys": {},
163+ "uniqueConstraints": {},
164+ "checkConstraints": {}
165+ },
166+ "users": {
167+ "name": "users",
168+ "columns": {
169+ "id": {
170+ "name": "id",
171+ "type": "text",
172+ "primaryKey": true,
173+ "notNull": true,
174+ "autoincrement": false
175+ },
176+ "password": {
177+ "name": "password",
178+ "type": "text",
179+ "primaryKey": false,
180+ "notNull": true,
181+ "autoincrement": false
182+ },
183+ "nickname": {
184+ "name": "nickname",
185+ "type": "text",
186+ "primaryKey": false,
187+ "notNull": true,
188+ "autoincrement": false
189+ },
190+ "status": {
191+ "name": "status",
192+ "type": "text",
193+ "primaryKey": false,
194+ "notNull": true,
195+ "autoincrement": false,
196+ "default": "'offline'"
197+ },
198+ "avatar_url": {
199+ "name": "avatar_url",
200+ "type": "text",
201+ "primaryKey": false,
202+ "notNull": false,
203+ "autoincrement": false
204+ },
205+ "created_at": {
206+ "name": "created_at",
207+ "type": "integer",
208+ "primaryKey": false,
209+ "notNull": true,
210+ "autoincrement": false
211+ },
212+ "last_seen": {
213+ "name": "last_seen",
214+ "type": "integer",
215+ "primaryKey": false,
216+ "notNull": false,
217+ "autoincrement": false
218+ }
219+ },
220+ "indexes": {},
221+ "foreignKeys": {},
222+ "compositePrimaryKeys": {},
223+ "uniqueConstraints": {},
224+ "checkConstraints": {}
225+ }
226+ },
227+ "views": {
228+ "chat_room_view": {
229+ "columns": {
230+ "id": {
231+ "name": "id",
232+ "type": "text",
233+ "primaryKey": true,
234+ "notNull": true,
235+ "autoincrement": false
236+ },
237+ "name": {
238+ "name": "name",
239+ "type": "text",
240+ "primaryKey": false,
241+ "notNull": false,
242+ "autoincrement": false
243+ },
244+ "type": {
245+ "name": "type",
246+ "type": "text",
247+ "primaryKey": false,
248+ "notNull": true,
249+ "autoincrement": false,
250+ "default": "'direct'"
251+ },
252+ "created_at": {
253+ "name": "created_at",
254+ "type": "integer",
255+ "primaryKey": false,
256+ "notNull": true,
257+ "autoincrement": false
258+ }
259+ },
260+ "name": "chat_room_view",
261+ "isExisting": false,
262+ "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
263+ },
264+ "message_view": {
265+ "columns": {
266+ "id": {
267+ "name": "id",
268+ "type": "text",
269+ "primaryKey": true,
270+ "notNull": true,
271+ "autoincrement": false
272+ },
273+ "content": {
274+ "name": "content",
275+ "type": "text",
276+ "primaryKey": false,
277+ "notNull": true,
278+ "autoincrement": false
279+ },
280+ "type": {
281+ "name": "type",
282+ "type": "text",
283+ "primaryKey": false,
284+ "notNull": true,
285+ "autoincrement": false,
286+ "default": "'chat'"
287+ },
288+ "timestamp": {
289+ "name": "timestamp",
290+ "type": "integer",
291+ "primaryKey": false,
292+ "notNull": true,
293+ "autoincrement": false
294+ },
295+ "nickname": {
296+ "name": "nickname",
297+ "type": "text",
298+ "primaryKey": false,
299+ "notNull": true,
300+ "autoincrement": false
301+ },
302+ "name": {
303+ "name": "name",
304+ "type": "text",
305+ "primaryKey": false,
306+ "notNull": false,
307+ "autoincrement": false
308+ }
309+ },
310+ "name": "message_view",
311+ "isExisting": false,
312+ "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
313+ },
314+ "user_view": {
315+ "columns": {
316+ "id": {
317+ "name": "id",
318+ "type": "text",
319+ "primaryKey": true,
320+ "notNull": true,
321+ "autoincrement": false
322+ },
323+ "nickname": {
324+ "name": "nickname",
325+ "type": "text",
326+ "primaryKey": false,
327+ "notNull": true,
328+ "autoincrement": false
329+ },
330+ "status": {
331+ "name": "status",
332+ "type": "text",
333+ "primaryKey": false,
334+ "notNull": true,
335+ "autoincrement": false,
336+ "default": "'offline'"
337+ },
338+ "avatar_url": {
339+ "name": "avatar_url",
340+ "type": "text",
341+ "primaryKey": false,
342+ "notNull": false,
343+ "autoincrement": false
344+ },
345+ "last_seen": {
346+ "name": "last_seen",
347+ "type": "integer",
348+ "primaryKey": false,
349+ "notNull": false,
350+ "autoincrement": false
351+ }
352+ },
353+ "name": "user_view",
354+ "isExisting": false,
355+ "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
356+ }
357+ },
358+ "enums": {},
359+ "_meta": {
360+ "schemas": {},
361+ "tables": {},
362+ "columns": {}
363+ },
364+ "internal": {
365+ "indexes": {}
366+ }
367+}
M drizzle/meta/_journal.json +19 -19
@@ -1,20 +1,20 @@
11 {
2- "version": "7",
3- "dialect": "sqlite",
4- "entries": [
5- {
6- "idx": 0,
7- "version": "6",
8- "when": 1739208055141,
9- "tag": "0000_shallow_ghost_rider",
10- "breakpoints": true
11- },
12- {
13- "idx": 1,
14- "version": "6",
15- "when": 1739432549447,
16- "tag": "0001_blushing_jean_grey",
17- "breakpoints": true
18- }
19- ]
20-}⏎̸
2+ "version": "7",
3+ "dialect": "sqlite",
4+ "entries": [
5+ {
6+ "idx": 0,
7+ "version": "6",
8+ "when": 1739208055141,
9+ "tag": "0000_shallow_ghost_rider",
10+ "breakpoints": true
11+ },
12+ {
13+ "idx": 1,
14+ "version": "6",
15+ "when": 1739432549447,
16+ "tag": "0001_blushing_jean_grey",
17+ "breakpoints": true
18+ }
19+ ]
20+}
M eslint.config.js +10 -0
@@ -30,5 +30,15 @@ export default ts.config(
3030 parser: ts.parser
3131 }
3232 }
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+ }
3343 }
3444 );
M package.json +2 -1
@@ -69,5 +69,6 @@
6969 "uuid": "^11.1.0",
7070 "xp.css": "^0.2.6",
7171 "zod": "^4.1.13"
72- }
72+ },
73+ "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
7374 }
M src/app.css +342 -325
@@ -1,116 +1,116 @@
1-@import "tailwindcss";
2-@import "tw-animate-css";
1+@import 'tailwindcss';
2+@import 'tw-animate-css';
33
44 @custom-variant dark (&:is(.dark *));
55
66 :root {
7- --background: hsl(0 0% 100%);
8- --foreground: hsl(240 10% 3.9%);
9- --muted: hsl(240 4.8% 95.9%);
10- --muted-foreground: hsl(240 3.8% 46.1%);
11- --popover: hsl(0 0% 100%);
12- --popover-foreground: hsl(240 10% 3.9%);
13- --card: hsl(0 0% 100%);
14- --card-foreground: hsl(240 10% 3.9%);
15- --border: hsl(240 5.9% 90%);
16- --input: hsl(240 5.9% 90%);
17- --primary: hsl(240 5.9% 10%);
18- --primary-foreground: hsl(0 0% 98%);
19- --secondary: hsl(240 4.8% 95.9%);
20- --secondary-foreground: hsl(240 5.9% 10%);
21- --accent: hsl(240 4.8% 95.9%);
22- --accent-foreground: hsl(240 5.9% 10%);
23- --destructive: hsl(0 72.2% 50.6%);
24- --destructive-foreground: hsl(0 0% 98%);
25- --ring: hsl(240 10% 3.9%);
26- --sidebar: hsl(0 0% 98%);
27- --sidebar-foreground: hsl(240 5.3% 26.1%);
28- --sidebar-primary: hsl(240 5.9% 10%);
29- --sidebar-primary-foreground: hsl(0 0% 98%);
30- --sidebar-accent: hsl(240 4.8% 95.9%);
31- --sidebar-accent-foreground: hsl(240 5.9% 10%);
32- --sidebar-border: hsl(220 13% 91%);
33- --sidebar-ring: hsl(217.2 91.2% 59.8%);
34-
35- --radius: 0.5rem;
7+ --background: hsl(0 0% 100%);
8+ --foreground: hsl(240 10% 3.9%);
9+ --muted: hsl(240 4.8% 95.9%);
10+ --muted-foreground: hsl(240 3.8% 46.1%);
11+ --popover: hsl(0 0% 100%);
12+ --popover-foreground: hsl(240 10% 3.9%);
13+ --card: hsl(0 0% 100%);
14+ --card-foreground: hsl(240 10% 3.9%);
15+ --border: hsl(240 5.9% 90%);
16+ --input: hsl(240 5.9% 90%);
17+ --primary: hsl(240 5.9% 10%);
18+ --primary-foreground: hsl(0 0% 98%);
19+ --secondary: hsl(240 4.8% 95.9%);
20+ --secondary-foreground: hsl(240 5.9% 10%);
21+ --accent: hsl(240 4.8% 95.9%);
22+ --accent-foreground: hsl(240 5.9% 10%);
23+ --destructive: hsl(0 72.2% 50.6%);
24+ --destructive-foreground: hsl(0 0% 98%);
25+ --ring: hsl(240 10% 3.9%);
26+ --sidebar: hsl(0 0% 98%);
27+ --sidebar-foreground: hsl(240 5.3% 26.1%);
28+ --sidebar-primary: hsl(240 5.9% 10%);
29+ --sidebar-primary-foreground: hsl(0 0% 98%);
30+ --sidebar-accent: hsl(240 4.8% 95.9%);
31+ --sidebar-accent-foreground: hsl(240 5.9% 10%);
32+ --sidebar-border: hsl(220 13% 91%);
33+ --sidebar-ring: hsl(217.2 91.2% 59.8%);
34+
35+ --radius: 0.5rem;
3636 }
3737
3838 .dark {
39- --background: hsl(240 10% 3.9%);
40- --foreground: hsl(0 0% 98%);
41- --muted: hsl(240 3.7% 15.9%);
42- --muted-foreground: hsl(240 5% 64.9%);
43- --popover: hsl(240 10% 3.9%);
44- --popover-foreground: hsl(0 0% 98%);
45- --card: hsl(240 10% 3.9%);
46- --card-foreground: hsl(0 0% 98%);
47- --border: hsl(240 3.7% 15.9%);
48- --input: hsl(240 3.7% 15.9%);
49- --primary: hsl(0 0% 98%);
50- --primary-foreground: hsl(240 5.9% 10%);
51- --secondary: hsl(240 3.7% 15.9%);
52- --secondary-foreground: hsl(0 0% 98%);
53- --accent: hsl(240 3.7% 15.9%);
54- --accent-foreground: hsl(0 0% 98%);
55- --destructive: hsl(0 62.8% 30.6%);
56- --destructive-foreground: hsl(0 0% 98%);
57- --ring: hsl(240 4.9% 83.9%);
58- --sidebar: hsl(240 5.9% 10%);
59- --sidebar-foreground: hsl(240 4.8% 95.9%);
60- --sidebar-primary: hsl(224.3 76.3% 48%);
61- --sidebar-primary-foreground: hsl(0 0% 100%);
62- --sidebar-accent: hsl(240 3.7% 15.9%);
63- --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
64- --sidebar-border: hsl(240 3.7% 15.9%);
65- --sidebar-ring: hsl(217.2 91.2% 59.8%);
39+ --background: hsl(240 10% 3.9%);
40+ --foreground: hsl(0 0% 98%);
41+ --muted: hsl(240 3.7% 15.9%);
42+ --muted-foreground: hsl(240 5% 64.9%);
43+ --popover: hsl(240 10% 3.9%);
44+ --popover-foreground: hsl(0 0% 98%);
45+ --card: hsl(240 10% 3.9%);
46+ --card-foreground: hsl(0 0% 98%);
47+ --border: hsl(240 3.7% 15.9%);
48+ --input: hsl(240 3.7% 15.9%);
49+ --primary: hsl(0 0% 98%);
50+ --primary-foreground: hsl(240 5.9% 10%);
51+ --secondary: hsl(240 3.7% 15.9%);
52+ --secondary-foreground: hsl(0 0% 98%);
53+ --accent: hsl(240 3.7% 15.9%);
54+ --accent-foreground: hsl(0 0% 98%);
55+ --destructive: hsl(0 62.8% 30.6%);
56+ --destructive-foreground: hsl(0 0% 98%);
57+ --ring: hsl(240 4.9% 83.9%);
58+ --sidebar: hsl(240 5.9% 10%);
59+ --sidebar-foreground: hsl(240 4.8% 95.9%);
60+ --sidebar-primary: hsl(224.3 76.3% 48%);
61+ --sidebar-primary-foreground: hsl(0 0% 100%);
62+ --sidebar-accent: hsl(240 3.7% 15.9%);
63+ --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
64+ --sidebar-border: hsl(240 3.7% 15.9%);
65+ --sidebar-ring: hsl(217.2 91.2% 59.8%);
6666 }
6767
6868 @theme inline {
69- /* Radius (for rounded-*) */
70- --radius-sm: calc(var(--radius) - 4px);
71- --radius-md: calc(var(--radius) - 2px);
72- --radius-lg: var(--radius);
73- --radius-xl: calc(var(--radius) + 4px);
74-
75- /* Colors */
76- --color-background: var(--background);
77- --color-foreground: var(--foreground);
78- --color-muted: var(--muted);
79- --color-muted-foreground: var(--muted-foreground);
80- --color-popover: var(--popover);
81- --color-popover-foreground: var(--popover-foreground);
82- --color-card: var(--card);
83- --color-card-foreground: var(--card-foreground);
84- --color-border: var(--border);
85- --color-input: var(--input);
86- --color-primary: var(--primary);
87- --color-primary-foreground: var(--primary-foreground);
88- --color-secondary: var(--secondary);
89- --color-secondary-foreground: var(--secondary-foreground);
90- --color-accent: var(--accent);
91- --color-accent-foreground: var(--accent-foreground);
92- --color-destructive: var(--destructive);
93- --color-destructive-foreground: var(--destructive-foreground);
94- --color-ring: var(--ring);
95- --color-radius: var(--radius);
96- --color-sidebar: var(--sidebar);
97- --color-sidebar-foreground: var(--sidebar-foreground);
98- --color-sidebar-primary: var(--sidebar-primary);
99- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
100- --color-sidebar-accent: var(--sidebar-accent);
101- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
102- --color-sidebar-border: var(--sidebar-border);
103- --color-sidebar-ring: var(--sidebar-ring);
69+ /* Radius (for rounded-*) */
70+ --radius-sm: calc(var(--radius) - 4px);
71+ --radius-md: calc(var(--radius) - 2px);
72+ --radius-lg: var(--radius);
73+ --radius-xl: calc(var(--radius) + 4px);
74+
75+ /* Colors */
76+ --color-background: var(--background);
77+ --color-foreground: var(--foreground);
78+ --color-muted: var(--muted);
79+ --color-muted-foreground: var(--muted-foreground);
80+ --color-popover: var(--popover);
81+ --color-popover-foreground: var(--popover-foreground);
82+ --color-card: var(--card);
83+ --color-card-foreground: var(--card-foreground);
84+ --color-border: var(--border);
85+ --color-input: var(--input);
86+ --color-primary: var(--primary);
87+ --color-primary-foreground: var(--primary-foreground);
88+ --color-secondary: var(--secondary);
89+ --color-secondary-foreground: var(--secondary-foreground);
90+ --color-accent: var(--accent);
91+ --color-accent-foreground: var(--accent-foreground);
92+ --color-destructive: var(--destructive);
93+ --color-destructive-foreground: var(--destructive-foreground);
94+ --color-ring: var(--ring);
95+ --color-radius: var(--radius);
96+ --color-sidebar: var(--sidebar);
97+ --color-sidebar-foreground: var(--sidebar-foreground);
98+ --color-sidebar-primary: var(--sidebar-primary);
99+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
100+ --color-sidebar-accent: var(--sidebar-accent);
101+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
102+ --color-sidebar-border: var(--sidebar-border);
103+ --color-sidebar-ring: var(--sidebar-ring);
104104 }
105105
106106 @layer base {
107- * {
108- @apply border-border;
109- }
107+ * {
108+ @apply border-border;
109+ }
110110
111- body {
112- @apply bg-background text-foreground;
113- }
111+ body {
112+ @apply bg-background text-foreground;
113+ }
114114 }
115115
116116 @import 'xp.css/dist/XP.css';
@@ -118,445 +118,462 @@
118118
119119 /* Local font faces with 30-day cache */
120120 @font-face {
121- font-family: 'Arial';
122- src: url('/fonts/Arial.TTF') format('truetype');
123- font-display: swap;
124- font-weight: normal;
125- font-style: normal;
121+ font-family: 'Arial';
122+ src: url('/fonts/Arial.TTF') format('truetype');
123+ font-display: swap;
124+ font-weight: normal;
125+ font-style: normal;
126126 }
127127
128128 @font-face {
129- font-family: 'Arial';
130- src: url('/fonts/Arialbd.TTF') format('truetype');
131- font-display: swap;
132- font-weight: bold;
133- font-style: normal;
129+ font-family: 'Arial';
130+ src: url('/fonts/Arialbd.TTF') format('truetype');
131+ font-display: swap;
132+ font-weight: bold;
133+ font-style: normal;
134134 }
135135
136136 @font-face {
137- font-family: 'Arial';
138- src: url('/fonts/Ariali.TTF') format('truetype');
139- font-display: swap;
140- font-weight: normal;
141- font-style: italic;
137+ font-family: 'Arial';
138+ src: url('/fonts/Ariali.TTF') format('truetype');
139+ font-display: swap;
140+ font-weight: normal;
141+ font-style: italic;
142142 }
143143
144144 @font-face {
145- font-family: 'Arial';
146- src: url('/fonts/Arialbi.TTF') format('truetype');
147- font-display: swap;
148- font-weight: bold;
149- font-style: italic;
145+ font-family: 'Arial';
146+ src: url('/fonts/Arialbi.TTF') format('truetype');
147+ font-display: swap;
148+ font-weight: bold;
149+ font-style: italic;
150150 }
151151
152152 @font-face {
153- font-family: 'Tahoma';
154- src: url('/fonts/tahoma.ttf') format('truetype');
155- font-display: swap;
156- font-weight: 80;
157- font-style: normal;
153+ font-family: 'Tahoma';
154+ src: url('/fonts/tahoma.ttf') format('truetype');
155+ font-display: swap;
156+ font-weight: 80;
157+ font-style: normal;
158158 }
159159
160160 @font-face {
161- font-family: 'Tahoma';
162- src: url('/fonts/tahomabd.ttf') format('truetype');
163- font-display: swap;
164- font-weight: 200;
165- font-style: normal;
161+ font-family: 'Tahoma';
162+ src: url('/fonts/tahomabd.ttf') format('truetype');
163+ font-display: swap;
164+ font-weight: 200;
165+ font-style: normal;
166166 }
167167
168168 @font-face {
169- font-family: 'Verdana';
170- src: url('/fonts/Verdana.TTF') format('truetype');
171- font-display: swap;
172- font-weight: normal;
173- font-style: normal;
169+ font-family: 'Verdana';
170+ src: url('/fonts/Verdana.TTF') format('truetype');
171+ font-display: swap;
172+ font-weight: normal;
173+ font-style: normal;
174174 }
175175
176176 @font-face {
177- font-family: 'Verdana';
178- src: url('/fonts/Verdanab.TTF') format('truetype');
179- font-display: swap;
180- font-weight: bold;
181- font-style: normal;
177+ font-family: 'Verdana';
178+ src: url('/fonts/Verdanab.TTF') format('truetype');
179+ font-display: swap;
180+ font-weight: bold;
181+ font-style: normal;
182182 }
183183
184184 @font-face {
185- font-family: 'Verdana';
186- src: url('/fonts/Verdanai.TTF') format('truetype');
187- font-display: swap;
188- font-weight: normal;
189- font-style: italic;
185+ font-family: 'Verdana';
186+ src: url('/fonts/Verdanai.TTF') format('truetype');
187+ font-display: swap;
188+ font-weight: normal;
189+ font-style: italic;
190190 }
191191
192192 @font-face {
193- font-family: 'Verdana';
194- src: url('/fonts/Verdanaz.TTF') format('truetype');
195- font-display: swap;
196- font-weight: bold;
197- font-style: italic;
193+ font-family: 'Verdana';
194+ src: url('/fonts/Verdanaz.TTF') format('truetype');
195+ font-display: swap;
196+ font-weight: bold;
197+ font-style: italic;
198198 }
199199
200200 @font-face {
201- font-family: 'Comic Sans MS';
202- src: url('/fonts/Comic.TTF') format('truetype');
203- font-display: swap;
204- font-weight: normal;
205- font-style: normal;
201+ font-family: 'Comic Sans MS';
202+ src: url('/fonts/Comic.TTF') format('truetype');
203+ font-display: swap;
204+ font-weight: normal;
205+ font-style: normal;
206206 }
207207
208208 @font-face {
209- font-family: 'Comic Sans MS';
210- src: url('/fonts/Comicbd.TTF') format('truetype');
211- font-display: swap;
212- font-weight: bold;
213- font-style: normal;
209+ font-family: 'Comic Sans MS';
210+ src: url('/fonts/Comicbd.TTF') format('truetype');
211+ font-display: swap;
212+ font-weight: bold;
213+ font-style: normal;
214214 }
215215
216216 @font-face {
217- font-family: 'Times New Roman';
218- src: url('/fonts/Times.TTF') format('truetype');
219- font-display: swap;
220- font-weight: normal;
221- font-style: normal;
217+ font-family: 'Times New Roman';
218+ src: url('/fonts/Times.TTF') format('truetype');
219+ font-display: swap;
220+ font-weight: normal;
221+ font-style: normal;
222222 }
223223
224224 @font-face {
225- font-family: 'Times New Roman';
226- src: url('/fonts/Timesbd.TTF') format('truetype');
227- font-display: swap;
228- font-weight: bold;
229- font-style: normal;
225+ font-family: 'Times New Roman';
226+ src: url('/fonts/Timesbd.TTF') format('truetype');
227+ font-display: swap;
228+ font-weight: bold;
229+ font-style: normal;
230230 }
231231
232232 @font-face {
233- font-family: 'Times New Roman';
234- src: url('/fonts/Timesi.TTF') format('truetype');
235- font-display: swap;
236- font-weight: normal;
237- font-style: italic;
233+ font-family: 'Times New Roman';
234+ src: url('/fonts/Timesi.TTF') format('truetype');
235+ font-display: swap;
236+ font-weight: normal;
237+ font-style: italic;
238238 }
239239
240240 @font-face {
241- font-family: 'Times New Roman';
242- src: url('/fonts/Timesbi.TTF') format('truetype');
243- font-display: swap;
244- font-weight: bold;
245- font-style: italic;
241+ font-family: 'Times New Roman';
242+ src: url('/fonts/Timesbi.TTF') format('truetype');
243+ font-display: swap;
244+ font-weight: bold;
245+ font-style: italic;
246246 }
247247
248248 @font-face {
249- font-family: 'Courier New';
250- src: url('/fonts/LiberationMono-Regular.ttf') format('truetype');
251- font-display: swap;
252- font-weight: normal;
253- font-style: normal;
249+ font-family: 'Courier New';
250+ src: url('/fonts/LiberationMono-Regular.ttf') format('truetype');
251+ font-display: swap;
252+ font-weight: normal;
253+ font-style: normal;
254254 }
255255
256256 @font-face {
257- font-family: 'Courier New';
258- src: url('/fonts/LiberationMono-Bold.ttf') format('truetype');
259- font-display: swap;
260- font-weight: bold;
261- font-style: normal;
257+ font-family: 'Courier New';
258+ src: url('/fonts/LiberationMono-Bold.ttf') format('truetype');
259+ font-display: swap;
260+ font-weight: bold;
261+ font-style: normal;
262262 }
263263
264264 @font-face {
265- font-family: 'Courier New';
266- src: url('/fonts/LiberationMono-Italic.ttf') format('truetype');
267- font-display: swap;
268- font-weight: normal;
269- font-style: italic;
265+ font-family: 'Courier New';
266+ src: url('/fonts/LiberationMono-Italic.ttf') format('truetype');
267+ font-display: swap;
268+ font-weight: normal;
269+ font-style: italic;
270270 }
271271
272272 @font-face {
273- font-family: 'Courier New';
274- src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype');
275- font-display: swap;
276- font-weight: bold;
277- font-style: italic;
273+ font-family: 'Courier New';
274+ src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype');
275+ font-display: swap;
276+ font-weight: bold;
277+ font-style: italic;
278278 }
279279
280280 /* ===== RETRO FONT STYLING ===== */
281281 /* Retro Windows fonts with proper fallbacks */
282282 .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;
284284 }
285285
286-.retro-font-tahoma {
287- font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif;
286+.retro-font-tahoma {
287+ font-family: Tahoma, 'Pixelated MS Sans Serif', Geneva, sans-serif;
288288 }
289289
290-.retro-font-verdana {
291- font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif;
290+.retro-font-verdana {
291+ font-family: Verdana, 'Pixelated MS Sans Serif', Geneva, sans-serif;
292292 }
293293
294-.retro-font-comicSans {
295- font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif;
294+.retro-font-comicSans {
295+ font-family: 'Comic Sans MS', 'Pixelated MS Sans Serif', cursive, sans-serif;
296296 }
297297
298-.retro-font-timesNewRoman {
299- font-family: "Times New Roman", Times, serif;
298+.retro-font-timesNewRoman {
299+ font-family: 'Times New Roman', Times, serif;
300300 }
301301
302-.retro-font-arial {
303- font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif;
302+.retro-font-arial {
303+ font-family: Arial, 'Pixelated MS Sans Serif', Helvetica, sans-serif;
304304 }
305305
306-.retro-font-courierNew {
307- font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace;
306+.retro-font-courierNew {
307+ font-family: 'Courier New', 'Perfect DOS VGA 437 Win', Courier, monospace;
308308 }
309309
310310 .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;
312312 }
313313
314314 /* ===== GRADIENT TEXT EFFECTS ===== */
315315 .gradient-text {
316- background-size: 200% 200%;
317- -webkit-background-clip: text;
318- -webkit-text-fill-color: transparent;
319- background-clip: text;
320- animation: gradientShift 3s ease infinite;
316+ background-size: 200% 200%;
317+ -webkit-background-clip: text;
318+ -webkit-text-fill-color: transparent;
319+ background-clip: text;
320+ animation: gradientShift 3s ease infinite;
321321 }
322322
323323 .gradient-text-static {
324- /* Remove text-fill-color to allow inline color styles to work */
325- /* Individual spans will have their own colors set inline */
326- letter-spacing: normal;
327- word-spacing: normal;
324+ /* Remove text-fill-color to allow inline color styles to work */
325+ /* Individual spans will have their own colors set inline */
326+ letter-spacing: normal;
327+ word-spacing: normal;
328328 }
329329
330330 /* Gradient text container uses flexbox */
331331 .gradient-text-static {
332- display: inline-flex !important;
333- flex-wrap: wrap !important;
334- align-items: baseline !important;
332+ display: inline-flex !important;
333+ flex-wrap: wrap !important;
334+ align-items: baseline !important;
335335 }
336336
337337 /* Ensure gradient character spans don't add spacing */
338338 .gradient-text-static > span {
339- display: inline !important;
340- margin: 0 !important;
341- padding: 0 !important;
342- letter-spacing: 0 !important;
343- line-height: inherit !important;
344- font-size: inherit !important;
339+ display: inline !important;
340+ margin: 0 !important;
341+ padding: 0 !important;
342+ letter-spacing: 0 !important;
343+ line-height: inherit !important;
344+ font-size: inherit !important;
345345 }
346346
347347 /* Ensure gradient character colors from inline styles are respected */
348348 .gradient-text-static .gradient-char {
349- -webkit-text-fill-color: initial !important;
349+ -webkit-text-fill-color: initial !important;
350350 }
351351
352352 /* Gradient text in input fields - preserve normal input background */
353-input[style*="background-clip: text"] {
354- /* Keep default input background while allowing gradient text */
353+input[style*='background-clip: text'] {
354+ /* Keep default input background while allowing gradient text */
355355 }
356356
357357 /* Gradient animation for AOL-style effects */
358358 @keyframes gradientShift {
359- 0% { background-position: 0% 50%; }
360- 50% { background-position: 100% 50%; }
361- 100% { background-position: 0% 50%; }
359+ 0% {
360+ background-position: 0% 50%;
361+ }
362+ 50% {
363+ background-position: 100% 50%;
364+ }
365+ 100% {
366+ background-position: 0% 50%;
367+ }
362368 }
363369
364370 /* Specific gradient presets */
365371 .gradient-rainbow {
366- background: linear-gradient(45deg,
367- #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80,
368- #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080);
372+ background: linear-gradient(
373+ 45deg,
374+ #ff0000,
375+ #ff8000,
376+ #ffff00,
377+ #80ff00,
378+ #00ff00,
379+ #00ff80,
380+ #00ffff,
381+ #0080ff,
382+ #0000ff,
383+ #8000ff,
384+ #ff00ff,
385+ #ff0080
386+ );
369387 }
370388
371389 .gradient-fire {
372- background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00);
390+ background: linear-gradient(45deg, #ff0000, #ff4000, #ff8000, #ffbf00, #ffff00);
373391 }
374392
375393 .gradient-ocean {
376- background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF);
394+ background: linear-gradient(45deg, #000080, #0040ff, #0080ff, #00bfff, #00ffff);
377395 }
378396
379397 .gradient-sunset {
380- background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00);
398+ background: linear-gradient(45deg, #ff4500, #ff6347, #ffd700, #ffff00);
381399 }
382400
383401 .gradient-neon {
384- background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF);
402+ background: linear-gradient(45deg, #ff00ff, #8000ff, #0080ff, #00ffff);
385403 }
386404
387405 .gradient-forest {
388- background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90);
406+ background: linear-gradient(45deg, #006400, #228b22, #32cd32, #90ee90);
389407 }
390408
391409 .gradient-royal {
392- background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD);
410+ background: linear-gradient(45deg, #4b0082, #6a5acd, #9370db, #dda0dd);
393411 }
394412
395413 /* ===== CHAT MESSAGE FORMATTING ===== */
396414 .formatted-message {
397- word-break: break-word;
398- line-height: 1.4;
399- max-width: 100%;
415+ word-break: break-word;
416+ line-height: 1.4;
417+ max-width: 100%;
400418 }
401419
402420 .formatted-message strong {
403- font-weight: bold;
421+ font-weight: bold;
404422 }
405423
406424 .formatted-message em {
407- font-style: italic;
425+ font-style: italic;
408426 }
409427
410428 .formatted-message u {
411- text-decoration: underline;
429+ text-decoration: underline;
412430 }
413431
414432 .formatted-message s {
415- text-decoration: line-through;
433+ text-decoration: line-through;
416434 }
417435
418436 .formatted-message code {
419- background: #f0f0f0;
420- padding: 1px 3px;
421- border-radius: 2px;
422- font-family: 'Courier New', Courier, monospace;
423- font-size: 0.9em;
437+ background: #f0f0f0;
438+ padding: 1px 3px;
439+ border-radius: 2px;
440+ font-family: 'Courier New', Courier, monospace;
441+ font-size: 0.9em;
424442 }
425443
426444 /* ===== INPUT STYLING FIXES ===== */
427445 /* Fix letter spacing issues with gradient text in inputs */
428-input[style*="background-clip: text"] {
429- letter-spacing: normal !important;
430- word-spacing: normal !important;
446+input[style*='background-clip: text'] {
447+ letter-spacing: normal !important;
448+ word-spacing: normal !important;
431449 }
432450
433451 /* Ensure gradient inputs don't have extra padding */
434452 input.styled-input {
435- letter-spacing: 0;
436- word-spacing: 0;
453+ letter-spacing: 0;
454+ word-spacing: 0;
437455 }
438456
439457 /* Ensure proper font weights are used */
440458 .retro-font-tahoma {
441- font-weight: 80;
459+ font-weight: 80;
442460 }
443461
444462 /* ===== TEXT STYLE SELECTOR STYLING ===== */
445463 .text-formatting-toolbar {
446- background: #f0f0f0;
447- border: 2px inset #dfdfdf;
448- padding: 4px;
449- margin-bottom: 8px;
450- display: flex;
451- flex-wrap: wrap;
452- gap: 4px;
453- align-items: center;
464+ background: #f0f0f0;
465+ border: 2px inset #dfdfdf;
466+ padding: 4px;
467+ margin-bottom: 8px;
468+ display: flex;
469+ flex-wrap: wrap;
470+ gap: 4px;
471+ align-items: center;
454472 }
455473
456474 .formatting-button {
457- width: 24px;
458- height: 24px;
459- border: 1px outset #dfdfdf;
460- background: #f0f0f0;
461- cursor: pointer;
462- display: flex;
463- align-items: center;
464- justify-content: center;
465- font-size: 12px;
466- font-weight: bold;
467- transition: all 0.1s ease;
475+ width: 24px;
476+ height: 24px;
477+ border: 1px outset #dfdfdf;
478+ background: #f0f0f0;
479+ cursor: pointer;
480+ display: flex;
481+ align-items: center;
482+ justify-content: center;
483+ font-size: 12px;
484+ font-weight: bold;
485+ transition: all 0.1s ease;
468486 }
469487
470488 .formatting-button:hover {
471- background: #e0e0e0;
489+ background: #e0e0e0;
472490 }
473491
474492 .formatting-button:active,
475493 .formatting-button.active {
476- border: 1px inset #dfdfdf;
477- background: #d0d0d0;
494+ border: 1px inset #dfdfdf;
495+ background: #d0d0d0;
478496 }
479497
480498 .font-selector {
481- padding: 2px 4px;
482- border: 1px inset #dfdfdf;
483- background: white;
484- font-size: 11px;
485- min-width: 80px;
499+ padding: 2px 4px;
500+ border: 1px inset #dfdfdf;
501+ background: white;
502+ font-size: 11px;
503+ min-width: 80px;
486504 }
487505
488506 .size-slider {
489- width: 60px;
507+ width: 60px;
490508 }
491509
492510 .color-indicator {
493- width: 16px;
494- height: 16px;
495- border: 1px solid #000;
496- cursor: pointer;
497- display: inline-block;
511+ width: 16px;
512+ height: 16px;
513+ border: 1px solid #000;
514+ cursor: pointer;
515+ display: inline-block;
498516 }
499517
500518 /* ===== ACCESSIBILITY IMPROVEMENTS ===== */
501519 @media (prefers-reduced-motion: reduce) {
502- .gradient-text {
503- animation: none;
504- }
520+ .gradient-text {
521+ animation: none;
522+ }
505523 }
506524
507525 /* High contrast mode support */
508526 @media (prefers-contrast: high) {
509- .gradient-text {
510- -webkit-text-fill-color: unset;
511- background: none;
512- color: inherit;
513- }
514-
515- .gradient-text-static {
516- -webkit-text-fill-color: unset;
517- background: none;
518- color: inherit;
519- }
527+ .gradient-text {
528+ -webkit-text-fill-color: unset;
529+ background: none;
530+ color: inherit;
531+ }
532+
533+ .gradient-text-static {
534+ -webkit-text-fill-color: unset;
535+ background: none;
536+ color: inherit;
537+ }
520538 }
521539
522540 /* ===== RETRO STYLING ENHANCEMENTS ===== */
523541 .retro-message {
524- font-family: inherit;
525- line-height: 1.3;
526- word-wrap: break-word;
542+ font-family: inherit;
543+ line-height: 1.3;
544+ word-wrap: break-word;
527545 }
528546
529547 .retro-message.style-bold {
530- font-weight: bold;
548+ font-weight: bold;
531549 }
532550
533551 .retro-message.style-italic {
534- font-style: italic;
552+ font-style: italic;
535553 }
536554
537555 .retro-message.style-underline {
538- text-decoration: underline;
556+ text-decoration: underline;
539557 }
540558
541559 .retro-message.style-strikethrough {
542- text-decoration: line-through;
560+ text-decoration: line-through;
543561 }
544562
545563 .retro-message.style-underline.style-strikethrough {
546- text-decoration: underline line-through;
564+ text-decoration: underline line-through;
547565 }
548566
549567 /* ===== PERFORMANCE OPTIMIZATIONS ===== */
550568 .gradient-text,
551569 .gradient-text-static {
552- will-change: background-position;
553- contain: layout style paint;
570+ will-change: background-position;
571+ contain: layout style paint;
554572 }
555573
556574 /* Limit gradient text length for performance */
557575 .gradient-text-long {
558- max-width: 500px;
559- overflow: hidden;
560- text-overflow: ellipsis;
576+ max-width: 500px;
577+ overflow: hidden;
578+ text-overflow: ellipsis;
561579 }
562-
M src/app.d.ts +2 -2
@@ -1,6 +1,6 @@
11 // See https://svelte.dev/docs/kit/types#app.d.ts
22 // for information about these interfaces
3-import type { User, Session } from "$lib/types/chat";
3+import type { User, Session } from '$lib/types/chat';
44
55 declare global {
66 namespace App {
@@ -13,7 +13,7 @@ declare global {
1313 // interface PageState {}
1414 // interface Platform {}
1515 }
16- type Window = object
16+ type Window = object;
1717 }
1818
1919 export {};
M src/hooks.server.ts +88 -87
@@ -1,105 +1,106 @@
1-import type { Handle } from "@sveltejs/kit";
2-import db from "$lib/db/db.server";
3-import { handleRateLimit } from "$lib/api/rate-limiter";
4-import { users } from "$lib/db/schema";
5-import { createLogger } from "$lib/utils/logger.server";
6-import { ensureDefaultChatRoom } from "$lib/utils/chat.server";
7-import { validateSessionToken } from "$lib/api/session.server";
8-import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";
1+import type { Handle } from '@sveltejs/kit';
2+import db from '$lib/db/db.server';
3+import { handleRateLimit } from '$lib/api/rate-limiter';
4+import { users } from '$lib/db/schema';
5+import { createLogger } from '$lib/utils/logger.server';
6+import { ensureDefaultChatRoom } from '$lib/utils/chat.server';
7+import { validateSessionToken } from '$lib/api/session.server';
8+import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/session.cookie';
99
1010 const log = createLogger('hooks-server');
1111
1212 // Set all users to offline on server start
1313 async function setAllUsersOffline() {
14- try {
15- await db.update(users)
16- .set({ status: 'offline'});
17- log.info('All users set to offline on server start');
18- } catch (error: unknown) {
19- log.error('Failed to set users offline on server start:', { error });
20- }
14+ try {
15+ await db.update(users).set({ status: 'offline' });
16+ log.info('All users set to offline on server start');
17+ } catch (error: unknown) {
18+ log.error('Failed to set users offline on server start:', { error });
19+ }
2120 }
2221
2322 // Initialize server
2423 Promise.resolve()
25- .then(async () => {
26- log.info('Initializing server...');
27- await ensureDefaultChatRoom(db);
28- await setAllUsersOffline();
29- log.info('Server initialized successfully');
30- })
31- .catch((error: unknown) => {
32- log.error('Failed to initialize server:', { error });
33- process.exit(1);
34- });
24+ .then(async () => {
25+ log.info('Initializing server...');
26+ await ensureDefaultChatRoom(db);
27+ await setAllUsersOffline();
28+ log.info('Server initialized successfully');
29+ })
30+ .catch((error: unknown) => {
31+ log.error('Failed to initialize server:', { error });
32+ process.exit(1);
33+ });
3534
3635 export const handle: Handle = async ({ event, resolve }) => {
37- log.debug("Handling request", { path: event.url.pathname });
38-
39- // Get session token from cookies
40- const token = event.cookies.get("session") ?? null;
36+ log.debug('Handling request', { path: event.url.pathname });
4137
42- // Validate session token if token exists
43- if (token) {
44- const { session, user } = await validateSessionToken(token);
45- if (session) {
46- log.debug("Valid session found", { user: user?.nickname });
47- setSessionTokenCookie(event, token, session.expiresAt);
48- event.locals.session = session;
49- event.locals.user = user;
50- } else {
51- log.debug("Invalid session token");
52- deleteSessionTokenCookie(event);
53- event.locals.session = null;
54- event.locals.user = null;
55- }
56- }
38+ // Get session token from cookies
39+ const token = event.cookies.get('session') ?? null;
5740
58- // Identify public chat and room requests if there is no valid session
59- const isPublicChatRequest = event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';
60- const isPublicRoomRequest = event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET';
41+ // Validate session token if token exists
42+ if (token) {
43+ const { session, user } = await validateSessionToken(token);
44+ if (session) {
45+ log.debug('Valid session found', { user: user?.nickname });
46+ setSessionTokenCookie(event, token, session.expiresAt);
47+ event.locals.session = session;
48+ event.locals.user = user;
49+ } else {
50+ log.debug('Invalid session token');
51+ deleteSessionTokenCookie(event);
52+ event.locals.session = null;
53+ event.locals.user = null;
54+ }
55+ }
6156
62- if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
63- log.debug("Public request accessed", { path: event.url.pathname });
64- const response = await resolve(event);
65- return response;
66- }
67-
68- // Public routes that don't require authentication
69- const publicRoutes = [
70- "/",
71- "/api/session/login",
72- "/api/session/validate",
73- "/api/register",
74- "/login",
75- "/register"
76- ];
57+ // Identify public chat and room requests if there is no valid session
58+ const isPublicChatRequest =
59+ event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';
60+ const isPublicRoomRequest =
61+ event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET';
7762
78- // Handle public routes
79- if (publicRoutes.includes(event.url.pathname)) {
80- log.debug("Public route accessed", { path: event.url.pathname });
81- const response = await resolve(event);
82- return response;
83- }
63+ if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
64+ log.debug('Public request accessed', { path: event.url.pathname });
65+ const response = await resolve(event);
66+ return response;
67+ }
8468
85- // Skip rate limiting for SSE endpoint when authenticated
86- if (event.url.pathname.startsWith('/api/sse')) {
87- return await resolve(event);
88- }
69+ // Public routes that don't require authentication
70+ const publicRoutes = [
71+ '/',
72+ '/api/session/login',
73+ '/api/session/validate',
74+ '/api/register',
75+ '/login',
76+ '/register'
77+ ];
8978
90- // Apply rate limiting for non-public endpoints
91- if (!isPublicChatRequest && !isPublicRoomRequest) {
92- const rateLimitResult = await handleRateLimit(event);
93- if (rateLimitResult?.status === 429) {
94- return new Response('Too Many Requests', {
95- status: 429,
96- headers: {
97- 'Retry-After': '60'
98- }
99- });
100- }
101- }
79+ // Handle public routes
80+ if (publicRoutes.includes(event.url.pathname)) {
81+ log.debug('Public route accessed', { path: event.url.pathname });
82+ const response = await resolve(event);
83+ return response;
84+ }
10285
103- const response = await resolve(event);
104- return response;
105-}; ⏎̸
86+ // Skip rate limiting for SSE endpoint when authenticated
87+ if (event.url.pathname.startsWith('/api/sse')) {
88+ return await resolve(event);
89+ }
90+
91+ // Apply rate limiting for non-public endpoints
92+ if (!isPublicChatRequest && !isPublicRoomRequest) {
93+ const rateLimitResult = await handleRateLimit(event);
94+ if (rateLimitResult?.status === 429) {
95+ return new Response('Too Many Requests', {
96+ status: 429,
97+ headers: {
98+ 'Retry-After': '60'
99+ }
100+ });
101+ }
102+ }
103+
104+ const response = await resolve(event);
105+ return response;
106+};
M src/lib/actions/draggable.ts +133 -124
@@ -1,132 +1,141 @@
11 import type { Action } from 'svelte/action';
22
33 interface DraggableParameters {
4- handle?: string;
5- bounds?: 'parent' | 'window';
6- enabled?: boolean;
4+ handle?: string;
5+ bounds?: 'parent' | 'window';
6+ enabled?: boolean;
77 }
88
99 interface DraggableAttributes {
10- ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void;
11- ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void;
12- ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void;
10+ ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void;
11+ ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void;
12+ ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void;
1313 }
1414
15-export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (node, params = {}) => {
16- let x = 0;
17- let y = 0;
18- let startX = 0;
19- let startY = 0;
20- let isDragging = false;
21-
22- let isEnabled = params.enabled !== undefined ? params.enabled : true;
23- const handleSelector = params.handle ?? null;
24- const bounds = params.bounds ?? 'window';
25-
26- const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node;
27- if (!handleElement) {
28- console.warn('Draggable handle element not found');
29- return;
30- }
31-
32- // Ensure proper positioning
33- if (getComputedStyle(node).position === 'static') {
34- node.style.position = 'fixed';
35- }
36-
37- function getNodePosition() {
38- const rect = node.getBoundingClientRect();
39- return {
40- x: rect.left,
41- y: rect.top
42- };
43- }
44-
45- function updatePosition(newX: number, newY: number) {
46- x = newX;
47- y = newY;
48- node.style.left = `${x}px`;
49- node.style.top = `${y}px`;
50- }
51-
52- function onMouseDown(event: MouseEvent) {
53- if (!isEnabled || event.button !== 0) return;
54-
55- // Check if we're clicking a button in the title bar
56- const target = event.target as HTMLElement;
57- if (target.tagName === 'BUTTON' || target.closest('button')) {
58- return;
59- }
60-
61- isDragging = true;
62-
63- // Get current position
64- const pos = getNodePosition();
65- x = pos.x;
66- y = pos.y;
67-
68- startX = event.clientX - x;
69- startY = event.clientY - y;
70-
71- node.dispatchEvent(new CustomEvent('dragstart', {
72- detail: { x, y }
73- }));
74-
75- // Add dragging class for styling
76- node.classList.add('dragging');
77-
78- event.preventDefault();
79- }
80-
81- function onMouseMove(event: MouseEvent) {
82- if (!isDragging || !isEnabled) return;
83-
84- let newX = event.clientX - startX;
85- let newY = event.clientY - startY;
86-
87- if (bounds === 'window') {
88- newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
89- newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
90- } else if (bounds === 'parent' && node.parentElement) {
91- const parent = node.parentElement;
92- newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
93- newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
94- }
95-
96- requestAnimationFrame(() => {
97- updatePosition(newX, newY);
98- node.dispatchEvent(new CustomEvent('dragmove', {
99- detail: { x: newX, y: newY }
100- }));
101- });
102- }
103-
104- function onMouseUp() {
105- if (!isDragging) return;
106- isDragging = false;
107-
108- node.classList.remove('dragging');
109-
110- node.dispatchEvent(new CustomEvent('dragend', {
111- detail: { x, y }
112- }));
113- }
114-
115- handleElement.style.cursor = isEnabled ? 'move' : 'default';
116- handleElement.addEventListener('mousedown', onMouseDown);
117- window.addEventListener('mousemove', onMouseMove, { passive: true });
118- window.addEventListener('mouseup', onMouseUp);
119-
120- return {
121- update(newParams: DraggableParameters) {
122- isEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
123- handleElement.style.cursor = isEnabled ? 'move' : 'default';
124- Object.assign(params, newParams);
125- },
126- destroy() {
127- handleElement.removeEventListener('mousedown', onMouseDown);
128- window.removeEventListener('mousemove', onMouseMove);
129- window.removeEventListener('mouseup', onMouseUp);
130- }
131- };
132-}; ⏎̸
15+export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (
16+ node,
17+ params = {}
18+) => {
19+ let x = 0;
20+ let y = 0;
21+ let startX = 0;
22+ let startY = 0;
23+ let isDragging = false;
24+
25+ let isEnabled = params.enabled !== undefined ? params.enabled : true;
26+ const handleSelector = params.handle ?? null;
27+ const bounds = params.bounds ?? 'window';
28+
29+ const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node;
30+ if (!handleElement) {
31+ console.warn('Draggable handle element not found');
32+ return;
33+ }
34+
35+ // Ensure proper positioning
36+ if (getComputedStyle(node).position === 'static') {
37+ node.style.position = 'fixed';
38+ }
39+
40+ function getNodePosition() {
41+ const rect = node.getBoundingClientRect();
42+ return {
43+ x: rect.left,
44+ y: rect.top
45+ };
46+ }
47+
48+ function updatePosition(newX: number, newY: number) {
49+ x = newX;
50+ y = newY;
51+ node.style.left = `${x}px`;
52+ node.style.top = `${y}px`;
53+ }
54+
55+ function onMouseDown(event: MouseEvent) {
56+ if (!isEnabled || event.button !== 0) return;
57+
58+ // Check if we're clicking a button in the title bar
59+ const target = event.target as HTMLElement;
60+ if (target.tagName === 'BUTTON' || target.closest('button')) {
61+ return;
62+ }
63+
64+ isDragging = true;
65+
66+ // Get current position
67+ const pos = getNodePosition();
68+ x = pos.x;
69+ y = pos.y;
70+
71+ startX = event.clientX - x;
72+ startY = event.clientY - y;
73+
74+ node.dispatchEvent(
75+ new CustomEvent('dragstart', {
76+ detail: { x, y }
77+ })
78+ );
79+
80+ // Add dragging class for styling
81+ node.classList.add('dragging');
82+
83+ event.preventDefault();
84+ }
85+
86+ function onMouseMove(event: MouseEvent) {
87+ if (!isDragging || !isEnabled) return;
88+
89+ let newX = event.clientX - startX;
90+ let newY = event.clientY - startY;
91+
92+ if (bounds === 'window') {
93+ newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
94+ newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
95+ } else if (bounds === 'parent' && node.parentElement) {
96+ const parent = node.parentElement;
97+ newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
98+ newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
99+ }
100+
101+ requestAnimationFrame(() => {
102+ updatePosition(newX, newY);
103+ node.dispatchEvent(
104+ new CustomEvent('dragmove', {
105+ detail: { x: newX, y: newY }
106+ })
107+ );
108+ });
109+ }
110+
111+ function onMouseUp() {
112+ if (!isDragging) return;
113+ isDragging = false;
114+
115+ node.classList.remove('dragging');
116+
117+ node.dispatchEvent(
118+ new CustomEvent('dragend', {
119+ detail: { x, y }
120+ })
121+ );
122+ }
123+
124+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
125+ handleElement.addEventListener('mousedown', onMouseDown);
126+ window.addEventListener('mousemove', onMouseMove, { passive: true });
127+ window.addEventListener('mouseup', onMouseUp);
128+
129+ return {
130+ update(newParams: DraggableParameters) {
131+ isEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
132+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
133+ Object.assign(params, newParams);
134+ },
135+ destroy() {
136+ handleElement.removeEventListener('mousedown', onMouseDown);
137+ window.removeEventListener('mousemove', onMouseMove);
138+ window.removeEventListener('mouseup', onMouseUp);
139+ }
140+ };
141+};
M src/lib/actions/maximizable.ts +105 -99
@@ -1,109 +1,115 @@
11 import type { Action } from 'svelte/action';
22 interface MaximizableParameters {
3- enabled?: boolean;
4- padding?: number;
3+ enabled?: boolean;
4+ padding?: number;
55 }
66
77 interface MaximizableAttributes {
8- onmaximize?: (e: CustomEvent<{
9- isMaximized: boolean;
10- width: number;
11- height: number;
12- x: number;
13- y: number;
14- }>) => void;
8+ onmaximize?: (
9+ e: CustomEvent<{
10+ isMaximized: boolean;
11+ width: number;
12+ height: number;
13+ x: number;
14+ y: number;
15+ }>
16+ ) => void;
1517 }
1618
1719 interface StoredState {
18- width: number;
19- height: number;
20- x: number;
21- y: number;
20+ width: number;
21+ height: number;
22+ x: number;
23+ y: number;
2224 }
2325
24-export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (node, params = {}) => {
25- const {
26- enabled = true,
27- padding = 0
28- } = params;
29-
30- let isMaximized = false;
31- let storedState: StoredState | null = null;
32-
33- function maximize() {
34- if (!enabled || isMaximized) return;
35-
36- // Store current state
37- storedState = {
38- width: node.offsetWidth,
39- height: node.offsetHeight,
40- x: parseInt(node.style.left) || 0,
41- y: parseInt(node.style.top) || 0
42- };
43-
44- // Set maximized state
45- const maxWidth = window.innerWidth - (padding * 2);
46- const maxHeight = window.innerHeight - (padding * 2);
47-
48- node.style.width = `${maxWidth}px`;
49- node.style.height = `${maxHeight}px`;
50- node.style.left = `${padding}px`;
51- node.style.top = `${padding}px`;
52-
53- isMaximized = true;
54-
55- node.dispatchEvent(new CustomEvent('maximize', {
56- detail: {
57- isMaximized: true,
58- width: maxWidth,
59- height: maxHeight,
60- x: padding,
61- y: padding
62- }
63- }));
64- }
65-
66- function restore() {
67- if (!enabled || !isMaximized || !storedState) return;
68-
69- // Restore previous state
70- node.style.width = `${storedState.width}px`;
71- node.style.height = `${storedState.height}px`;
72- node.style.left = `${storedState.x}px`;
73- node.style.top = `${storedState.y}px`;
74-
75- isMaximized = false;
76-
77- node.dispatchEvent(new CustomEvent('maximize', {
78- detail: {
79- isMaximized: false,
80- ...storedState
81- }
82- }));
83- }
84-
85- function toggle() {
86- if (isMaximized) {
87- restore();
88- } else {
89- maximize();
90- }
91- }
92-
93- // Expose methods to the node
94- Object.assign(node, {
95- maximize,
96- restore,
97- toggleMaximize: toggle,
98- isMaximized: () => isMaximized
99- });
100-
101- return {
102- update(newParams: MaximizableParameters) {
103- Object.assign(params, newParams);
104- },
105- destroy() {
106- // Clean up if needed
107- }
108- };
109-}; ⏎̸
26+export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (
27+ node,
28+ params = {}
29+) => {
30+ const { enabled = true, padding = 0 } = params;
31+
32+ let isMaximized = false;
33+ let storedState: StoredState | null = null;
34+
35+ function maximize() {
36+ if (!enabled || isMaximized) return;
37+
38+ // Store current state
39+ storedState = {
40+ width: node.offsetWidth,
41+ height: node.offsetHeight,
42+ x: parseInt(node.style.left) || 0,
43+ y: parseInt(node.style.top) || 0
44+ };
45+
46+ // Set maximized state
47+ const maxWidth = window.innerWidth - padding * 2;
48+ const maxHeight = window.innerHeight - padding * 2;
49+
50+ node.style.width = `${maxWidth}px`;
51+ node.style.height = `${maxHeight}px`;
52+ node.style.left = `${padding}px`;
53+ node.style.top = `${padding}px`;
54+
55+ isMaximized = true;
56+
57+ node.dispatchEvent(
58+ new CustomEvent('maximize', {
59+ detail: {
60+ isMaximized: true,
61+ width: maxWidth,
62+ height: maxHeight,
63+ x: padding,
64+ y: padding
65+ }
66+ })
67+ );
68+ }
69+
70+ function restore() {
71+ if (!enabled || !isMaximized || !storedState) return;
72+
73+ // Restore previous state
74+ node.style.width = `${storedState.width}px`;
75+ node.style.height = `${storedState.height}px`;
76+ node.style.left = `${storedState.x}px`;
77+ node.style.top = `${storedState.y}px`;
78+
79+ isMaximized = false;
80+
81+ node.dispatchEvent(
82+ new CustomEvent('maximize', {
83+ detail: {
84+ isMaximized: false,
85+ ...storedState
86+ }
87+ })
88+ );
89+ }
90+
91+ function toggle() {
92+ if (isMaximized) {
93+ restore();
94+ } else {
95+ maximize();
96+ }
97+ }
98+
99+ // Expose methods to the node
100+ Object.assign(node, {
101+ maximize,
102+ restore,
103+ toggleMaximize: toggle,
104+ isMaximized: () => isMaximized
105+ });
106+
107+ return {
108+ update(newParams: MaximizableParameters) {
109+ Object.assign(params, newParams);
110+ },
111+ destroy() {
112+ // Clean up if needed
113+ }
114+ };
115+};
M src/lib/actions/minimizable.ts +168 -157
@@ -1,168 +1,179 @@
11 import type { Action } from 'svelte/action';
22
33 interface MinimizableParameters {
4- enabled?: boolean;
5- onMinimize?: (isMinimized: boolean) => void;
4+ enabled?: boolean;
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;
613 }
714
815 interface MinimizableAttributes {
9- onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void;
16+ onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void;
1017 }
1118
1219 interface StoredState {
13- width: number;
14- height: number;
15- x: number;
16- y: number;
20+ width: number;
21+ height: number;
22+ x: number;
23+ y: number;
1724 }
1825
19-export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (node, params = {}) => {
20- const {
21- enabled = true,
22- onMinimize
23- } = params;
24-
25- let isMinimized = false;
26- let storedState: StoredState | null = null;
27-
28- function calculateMinimizedWidth() {
29- const titleBar = node.querySelector('.title-bar-text');
30- if (!titleBar) return 200;
31-
32- // Create a temporary element to measure text width
33- const temp = document.createElement('div');
34- temp.style.position = 'absolute';
35- temp.style.visibility = 'hidden';
36- temp.style.whiteSpace = 'nowrap';
37- temp.style.font = window.getComputedStyle(titleBar).font;
38- temp.textContent = titleBar.textContent;
39- document.body.appendChild(temp);
40-
41- // Get the width and add some padding
42- const width = temp.offsetWidth + 100; // Add 100px for buttons and padding
43- document.body.removeChild(temp);
44-
45- return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px
46- }
47-
48- function minimize() {
49- if (!enabled || isMinimized) return;
50-
51- // Store current state
52- storedState = {
53- width: node.offsetWidth,
54- height: node.offsetHeight,
55- x: parseInt(node.style.left) || 0,
56- y: parseInt(node.style.top) || 0
57- };
58-
59- // Check if we're in mobile mode
60- const isMobile = window.innerWidth <= 768;
61-
62- if (!isMobile) {
63- // Desktop minimization
64- const minimizedWidth = calculateMinimizedWidth();
65- const minimizedHeight = 32; // Height of title bar
66- const minimizedX = 10;
67- const minimizedY = window.innerHeight - minimizedHeight - 10;
68-
69- node.style.width = `${minimizedWidth}px`;
70- node.style.height = `${minimizedHeight}px`;
71- node.style.left = `${minimizedX}px`;
72- node.style.top = `${minimizedY}px`;
73- } else {
74- // Mobile minimization - position at bottom of screen
75- node.style.width = '100%';
76- node.style.height = '100%';
77- node.style.left = '0';
78- node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height
79- }
80-
81- isMinimized = true;
82- node.classList.add('minimized');
83-
84- // Hide resize handle
85- const resizeHandle = node.querySelector('.resize-handle');
86- if (resizeHandle) {
87- (resizeHandle as HTMLElement).style.display = 'none';
88- }
89-
90- onMinimize?.(true);
91- node.dispatchEvent(new CustomEvent('minimize', {
92- detail: { isMinimized: true }
93- }));
94- }
95-
96- function restore() {
97- if (!enabled || !isMinimized || !storedState) return;
98-
99- const isMobile = window.innerWidth <= 768;
100-
101- if (!isMobile) {
102- // Desktop restoration
103- node.style.width = `${storedState.width}px`;
104- node.style.height = `${storedState.height}px`;
105- node.style.left = `${storedState.x}px`;
106- node.style.top = `${storedState.y}px`;
107- } else {
108- // Mobile restoration
109- node.style.width = '100%';
110- node.style.height = '100%';
111- node.style.left = '0';
112- node.style.top = '0';
113- }
114-
115- isMinimized = false;
116- node.classList.remove('minimized');
117-
118- // Show resize handle if not in mobile mode
119- const resizeHandle = node.querySelector('.resize-handle');
120- if (resizeHandle) {
121- (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none';
122- }
123-
124- // Ensure window body is visible
125- const windowBody = node.querySelector('.window-body');
126- if (windowBody) {
127- (windowBody as HTMLElement).style.display = 'flex';
128- }
129-
130- onMinimize?.(false);
131- node.dispatchEvent(new CustomEvent('minimize', {
132- detail: { isMinimized: false }
133- }));
134- }
135-
136- function toggle() {
137- if (isMinimized) {
138- restore();
139- } else {
140- minimize();
141- }
142- }
143-
144- // Handle double click on title bar
145- const titleBar = node.querySelector('.title-bar');
146- if (titleBar) {
147- titleBar.addEventListener('dblclick', toggle);
148- }
149-
150- // Expose methods to the node
151- Object.assign(node, {
152- minimize,
153- restore,
154- toggleMinimize: toggle,
155- isMinimized: () => isMinimized
156- });
157-
158- return {
159- update(newParams: MinimizableParameters) {
160- Object.assign(params, newParams);
161- },
162- destroy() {
163- if (titleBar) {
164- titleBar.removeEventListener('dblclick', toggle);
165- }
166- }
167- };
168-}; ⏎̸
26+export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (
27+ node,
28+ params = {}
29+) => {
30+ const { enabled = true, onMinimize } = params;
31+
32+ let isMinimized = false;
33+ let storedState: StoredState | null = null;
34+
35+ function calculateMinimizedWidth() {
36+ const titleBar = node.querySelector('.title-bar-text');
37+ if (!titleBar) return 200;
38+
39+ // Create a temporary element to measure text width
40+ const temp = document.createElement('div');
41+ temp.style.position = 'absolute';
42+ temp.style.visibility = 'hidden';
43+ temp.style.whiteSpace = 'nowrap';
44+ temp.style.font = window.getComputedStyle(titleBar).font;
45+ temp.textContent = titleBar.textContent;
46+ document.body.appendChild(temp);
47+
48+ // Get the width and add some padding
49+ const width = temp.offsetWidth + 100; // Add 100px for buttons and padding
50+ document.body.removeChild(temp);
51+
52+ return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px
53+ }
54+
55+ function minimize() {
56+ if (!enabled || isMinimized) return;
57+
58+ // Store current state
59+ storedState = {
60+ width: node.offsetWidth,
61+ height: node.offsetHeight,
62+ x: parseInt(node.style.left) || 0,
63+ y: parseInt(node.style.top) || 0
64+ };
65+
66+ // Check if we're in mobile mode
67+ const isMobile = window.innerWidth <= 768;
68+
69+ if (!isMobile) {
70+ // Desktop minimization
71+ const minimizedWidth = calculateMinimizedWidth();
72+ const minimizedHeight = 32; // Height of title bar
73+ const minimizedX = 10;
74+ const minimizedY = window.innerHeight - minimizedHeight - 10;
75+
76+ node.style.width = `${minimizedWidth}px`;
77+ node.style.height = `${minimizedHeight}px`;
78+ node.style.left = `${minimizedX}px`;
79+ node.style.top = `${minimizedY}px`;
80+ } else {
81+ // Mobile minimization - position at bottom of screen
82+ node.style.width = '100%';
83+ node.style.height = '100%';
84+ node.style.left = '0';
85+ node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height
86+ }
87+
88+ isMinimized = true;
89+ node.classList.add('minimized');
90+
91+ // Hide resize handle
92+ const resizeHandle = node.querySelector('.resize-handle');
93+ if (resizeHandle) {
94+ (resizeHandle as HTMLElement).style.display = 'none';
95+ }
96+
97+ onMinimize?.(true);
98+ node.dispatchEvent(
99+ new CustomEvent('minimize', {
100+ detail: { isMinimized: true }
101+ })
102+ );
103+ }
104+
105+ function restore() {
106+ if (!enabled || !isMinimized || !storedState) return;
107+
108+ const isMobile = window.innerWidth <= 768;
109+
110+ if (!isMobile) {
111+ // Desktop restoration
112+ node.style.width = `${storedState.width}px`;
113+ node.style.height = `${storedState.height}px`;
114+ node.style.left = `${storedState.x}px`;
115+ node.style.top = `${storedState.y}px`;
116+ } else {
117+ // Mobile restoration
118+ node.style.width = '100%';
119+ node.style.height = '100%';
120+ node.style.left = '0';
121+ node.style.top = '0';
122+ }
123+
124+ isMinimized = false;
125+ node.classList.remove('minimized');
126+
127+ // Show resize handle if not in mobile mode
128+ const resizeHandle = node.querySelector('.resize-handle');
129+ if (resizeHandle) {
130+ (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none';
131+ }
132+
133+ // Ensure window body is visible
134+ const windowBody = node.querySelector('.window-body');
135+ if (windowBody) {
136+ (windowBody as HTMLElement).style.display = 'flex';
137+ }
138+
139+ onMinimize?.(false);
140+ node.dispatchEvent(
141+ new CustomEvent('minimize', {
142+ detail: { isMinimized: false }
143+ })
144+ );
145+ }
146+
147+ function toggle() {
148+ if (isMinimized) {
149+ restore();
150+ } else {
151+ minimize();
152+ }
153+ }
154+
155+ // Handle double click on title bar
156+ const titleBar = node.querySelector('.title-bar');
157+ if (titleBar) {
158+ titleBar.addEventListener('dblclick', toggle);
159+ }
160+
161+ // Expose methods to the node
162+ Object.assign(node, {
163+ minimize,
164+ restore,
165+ toggleMinimize: toggle,
166+ isMinimized: () => isMinimized
167+ });
168+
169+ return {
170+ update(newParams: MinimizableParameters) {
171+ Object.assign(params, newParams);
172+ },
173+ destroy() {
174+ if (titleBar) {
175+ titleBar.removeEventListener('dblclick', toggle);
176+ }
177+ }
178+ };
179+};
M src/lib/actions/resizable.ts +112 -103
@@ -1,111 +1,120 @@
11 import type { Action } from 'svelte/action';
22
33 interface ResizableParameters {
4- enabled?: boolean;
5- minWidth?: number;
6- minHeight?: number;
7- maxWidth?: number;
8- maxHeight?: number;
4+ enabled?: boolean;
5+ minWidth?: number;
6+ minHeight?: number;
7+ maxWidth?: number;
8+ maxHeight?: number;
99 }
1010
1111 interface ResizableAttributes {
12- onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void;
13- onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void;
14- onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void;
12+ onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void;
13+ onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void;
14+ onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void;
1515 }
1616
17-export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = (node, params = {}) => {
18- let isResizing = false;
19- let startWidth = 0;
20- let startHeight = 0;
21- let startX = 0;
22- let startY = 0;
23-
24- const {
25- enabled = true,
26- minWidth = 200,
27- minHeight = 200,
28- maxWidth = window.innerWidth,
29- maxHeight = window.innerHeight
30- } = params;
31-
32- // Create and append resize handle
33- const handle = document.createElement('div');
34- handle.className = 'resize-handle';
35- Object.assign(handle.style, {
36- position: 'absolute',
37- bottom: '0',
38- right: '0',
39- width: '15px',
40- height: '15px',
41- cursor: 'se-resize',
42- zIndex: '1000'
43- });
44- node.appendChild(handle);
45-
46- // Ensure parent element has position
47- if (getComputedStyle(node).position === 'static') {
48- node.style.position = 'relative';
49- }
50-
51- function onMouseDown(event: MouseEvent) {
52- if (!enabled) return;
53-
54- isResizing = true;
55- startX = event.clientX;
56- startY = event.clientY;
57- startWidth = node.offsetWidth;
58- startHeight = node.offsetHeight;
59-
60- node.dispatchEvent(new CustomEvent('resizestart', {
61- detail: { width: startWidth, height: startHeight }
62- }));
63-
64- event.preventDefault();
65- }
66-
67- function onMouseMove(event: MouseEvent) {
68- if (!isResizing || !enabled) return;
69-
70- const deltaX = event.clientX - startX;
71- const deltaY = event.clientY - startY;
72-
73- const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
74- const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY));
75-
76- node.style.width = `${newWidth}px`;
77- node.style.height = `${newHeight}px`;
78-
79- node.dispatchEvent(new CustomEvent('resizemove', {
80- detail: { width: newWidth, height: newHeight }
81- }));
82- }
83-
84- function onMouseUp() {
85- if (!isResizing) return;
86-
87- isResizing = false;
88- node.dispatchEvent(new CustomEvent('resizeend', {
89- detail: {
90- width: node.offsetWidth,
91- height: node.offsetHeight
92- }
93- }));
94- }
95-
96- handle.addEventListener('mousedown', onMouseDown);
97- window.addEventListener('mousemove', onMouseMove);
98- window.addEventListener('mouseup', onMouseUp);
99-
100- return {
101- update(newParams: ResizableParameters) {
102- Object.assign(params, newParams);
103- },
104- destroy() {
105- handle.removeEventListener('mousedown', onMouseDown);
106- window.removeEventListener('mousemove', onMouseMove);
107- window.removeEventListener('mouseup', onMouseUp);
108- node.removeChild(handle);
109- }
110- };
111-}; ⏎̸
17+export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = (
18+ node,
19+ params = {}
20+) => {
21+ let isResizing = false;
22+ let startWidth = 0;
23+ let startHeight = 0;
24+ let startX = 0;
25+ let startY = 0;
26+
27+ const {
28+ enabled = true,
29+ minWidth = 200,
30+ minHeight = 200,
31+ maxWidth = window.innerWidth,
32+ maxHeight = window.innerHeight
33+ } = params;
34+
35+ // Create and append resize handle
36+ const handle = document.createElement('div');
37+ handle.className = 'resize-handle';
38+ Object.assign(handle.style, {
39+ position: 'absolute',
40+ bottom: '0',
41+ right: '0',
42+ width: '15px',
43+ height: '15px',
44+ cursor: 'se-resize',
45+ zIndex: '1000'
46+ });
47+ node.appendChild(handle);
48+
49+ // Ensure parent element has position
50+ if (getComputedStyle(node).position === 'static') {
51+ node.style.position = 'relative';
52+ }
53+
54+ function onMouseDown(event: MouseEvent) {
55+ if (!enabled) return;
56+
57+ isResizing = true;
58+ startX = event.clientX;
59+ startY = event.clientY;
60+ startWidth = node.offsetWidth;
61+ startHeight = node.offsetHeight;
62+
63+ node.dispatchEvent(
64+ new CustomEvent('resizestart', {
65+ detail: { width: startWidth, height: startHeight }
66+ })
67+ );
68+
69+ event.preventDefault();
70+ }
71+
72+ function onMouseMove(event: MouseEvent) {
73+ if (!isResizing || !enabled) return;
74+
75+ const deltaX = event.clientX - startX;
76+ const deltaY = event.clientY - startY;
77+
78+ const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
79+ const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY));
80+
81+ node.style.width = `${newWidth}px`;
82+ node.style.height = `${newHeight}px`;
83+
84+ node.dispatchEvent(
85+ new CustomEvent('resizemove', {
86+ detail: { width: newWidth, height: newHeight }
87+ })
88+ );
89+ }
90+
91+ function onMouseUp() {
92+ if (!isResizing) return;
93+
94+ isResizing = false;
95+ node.dispatchEvent(
96+ new CustomEvent('resizeend', {
97+ detail: {
98+ width: node.offsetWidth,
99+ height: node.offsetHeight
100+ }
101+ })
102+ );
103+ }
104+
105+ handle.addEventListener('mousedown', onMouseDown);
106+ window.addEventListener('mousemove', onMouseMove);
107+ window.addEventListener('mouseup', onMouseUp);
108+
109+ return {
110+ update(newParams: ResizableParameters) {
111+ Object.assign(params, newParams);
112+ },
113+ destroy() {
114+ handle.removeEventListener('mousedown', onMouseDown);
115+ window.removeEventListener('mousemove', onMouseMove);
116+ window.removeEventListener('mouseup', onMouseUp);
117+ node.removeChild(handle);
118+ }
119+ };
120+};
M src/lib/api/rate-limiter.ts +181 -165
@@ -7,181 +7,197 @@ const isDev = import.meta.env.DEV;
77
88 // Rate limit configuration by endpoint type
99 const RATE_LIMITS = {
10- // Authentication endpoints (login, register)
11- auth: {
12- points: isDev ? 1000 : 20,
13- durationMs: 5 * 60 * 1000
14- },
15-
16- // Public endpoints (public chat, rooms)
17- public: {
18- points: isDev ? 1000 : 30,
19- durationMs: 60 * 1000
20- },
21-
22- // Protected endpoints (authenticated API calls)
23- protected: {
24- points: isDev ? 1000 : 100,
25- durationMs: 60 * 1000
26- },
27-
28- // SSE endpoints - separate limits for authenticated and unauthenticated users
29- sse: {
30- authenticated: {
31- points: isDev ? 1000 : 500,
32- durationMs: 60 * 1000
33- },
34- unauthenticated: {
35- points: 0,
36- durationMs: 60 * 1000
37- }
38- }
10+ // Authentication endpoints (login, register)
11+ auth: {
12+ points: isDev ? 1000 : 20,
13+ durationMs: 5 * 60 * 1000
14+ },
15+
16+ // Public endpoints (public chat, rooms)
17+ public: {
18+ points: isDev ? 1000 : 30,
19+ durationMs: 60 * 1000
20+ },
21+
22+ // Protected endpoints (authenticated API calls)
23+ protected: {
24+ points: isDev ? 1000 : 100,
25+ durationMs: 60 * 1000
26+ },
27+
28+ // SSE endpoints - separate limits for authenticated and unauthenticated users
29+ sse: {
30+ authenticated: {
31+ points: isDev ? 1000 : 500,
32+ durationMs: 60 * 1000
33+ },
34+ unauthenticated: {
35+ points: 0,
36+ durationMs: 60 * 1000
37+ }
38+ }
3939 } as const;
4040
4141 // In-memory store for rate limiting
4242 const ipRequests = new Map<string, Array<{ timestamp: number }>>();
4343
4444 // Cleanup old entries every 5 minutes
45-setInterval(() => {
46- const now = Date.now();
47- for (const [ip, timestamps] of ipRequests.entries()) {
48- // Remove entries older than the maximum duration we care about (5 minutes)
49- const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000);
50- if (filtered.length === 0) {
51- ipRequests.delete(ip);
52- } else {
53- ipRequests.set(ip, filtered);
54- }
55- }
56-}, 5 * 60 * 1000);
45+setInterval(
46+ () => {
47+ const now = Date.now();
48+ for (const [ip, timestamps] of ipRequests.entries()) {
49+ // Remove entries older than the maximum duration we care about (5 minutes)
50+ const filtered = timestamps.filter((t) => now - t.timestamp < 5 * 60 * 1000);
51+ if (filtered.length === 0) {
52+ ipRequests.delete(ip);
53+ } else {
54+ ipRequests.set(ip, filtered);
55+ }
56+ }
57+ },
58+ 5 * 60 * 1000
59+);
5760
5861 function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS {
59- if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
60- return 'auth';
61- }
62- if (pathname.startsWith('/api/sse')) {
63- return 'sse';
64- }
65- if (isPublic) {
66- return 'public';
67- }
68- return 'protected';
62+ if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
63+ return 'auth';
64+ }
65+ if (pathname.startsWith('/api/sse')) {
66+ return 'sse';
67+ }
68+ if (isPublic) {
69+ return 'public';
70+ }
71+ return 'protected';
6972 }
7073
71-function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
72- // Skip rate limiting completely in development for localhost
73- if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
74- return { limited: false };
75- }
76-
77- const now = Date.now();
78-
79- // Handle SSE endpoints differently based on authentication status
80- if (endpointType === 'sse') {
81- const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated;
82- const { points, durationMs } = config;
83-
84- // Get or initialize request history for this IP
85- const requests = ipRequests.get(ip) || [];
86-
87- // Clean old requests outside the window
88- const validRequests = requests.filter(req => now - req.timestamp < durationMs);
89-
90- // Check if we're over the limit
91- if (validRequests.length >= points) {
92- log.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`);
93- const oldestRequest = validRequests[0];
94- const retryAfter = durationMs - (now - oldestRequest.timestamp);
95- return { limited: true, retryAfter };
96- }
97-
98- // Add this request
99- validRequests.push({ timestamp: now });
100- ipRequests.set(ip, validRequests);
101-
102- return { limited: false };
103- }
104-
105- // Handle other endpoints
106- const { points, durationMs } = RATE_LIMITS[endpointType];
107- const requests = ipRequests.get(ip) || [];
108- const validRequests = requests.filter(req => now - req.timestamp < durationMs);
109-
110- log.debug(`Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs/1000}s`);
111-
112- if (validRequests.length >= points) {
113- const oldestRequest = validRequests[0];
114- const retryAfter = durationMs - (now - oldestRequest.timestamp);
115- return { limited: true, retryAfter };
116- }
117-
118- validRequests.push({ timestamp: now });
119- ipRequests.set(ip, validRequests);
120-
121- return { limited: false };
74+function isRateLimited(
75+ ip: string,
76+ endpointType: keyof typeof RATE_LIMITS,
77+ isAuthenticated: boolean = false
78+): { limited: boolean; retryAfter?: number } {
79+ // Skip rate limiting completely in development for localhost
80+ if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
81+ return { limited: false };
82+ }
83+
84+ const now = Date.now();
85+
86+ // Handle SSE endpoints differently based on authentication status
87+ if (endpointType === 'sse') {
88+ const config = isAuthenticated
89+ ? RATE_LIMITS.sse.authenticated
90+ : RATE_LIMITS.sse.unauthenticated;
91+ const { points, durationMs } = config;
92+
93+ // Get or initialize request history for this IP
94+ const requests = ipRequests.get(ip) || [];
95+
96+ // Clean old requests outside the window
97+ const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
98+
99+ // Check if we're over the limit
100+ if (validRequests.length >= points) {
101+ log.debug(
102+ `Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`
103+ );
104+ const oldestRequest = validRequests[0];
105+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
106+ return { limited: true, retryAfter };
107+ }
108+
109+ // Add this request
110+ validRequests.push({ timestamp: now });
111+ ipRequests.set(ip, validRequests);
112+
113+ return { limited: false };
114+ }
115+
116+ // Handle other endpoints
117+ const { points, durationMs } = RATE_LIMITS[endpointType];
118+ const requests = ipRequests.get(ip) || [];
119+ const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
120+
121+ log.debug(
122+ `Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs / 1000}s`
123+ );
124+
125+ if (validRequests.length >= points) {
126+ const oldestRequest = validRequests[0];
127+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
128+ return { limited: true, retryAfter };
129+ }
130+
131+ validRequests.push({ timestamp: now });
132+ ipRequests.set(ip, validRequests);
133+
134+ return { limited: false };
122135 }
123136
124137 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
125- const ip = event.getClientAddress();
126- const url = new URL(event.request.url);
127- const isPublic = url.searchParams.get('public') === 'true';
128- const isAuthenticated = Boolean(event.locals.user);
129-
130- // Skip rate limiting for non-API routes
131- if (!url.pathname.startsWith('/api')) {
132- return null;
133- }
134-
135- const endpointType = getEndpointType(url.pathname, isPublic);
136- const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
137-
138- if (limited) {
139- // Create debug headers
140- const debugHeaders = {
141- 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
142- 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
143- 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
144- 'X-Debug-Endpoint-Type': endpointType,
145- 'X-Debug-Rate-Limit-Points': isAuthenticated
146- ? RATE_LIMITS.sse.authenticated.points.toString()
147- : RATE_LIMITS.sse.unauthenticated.points.toString(),
148- };
149-
150- // Construct a user-friendly error message
151- let errorMessage = 'Too many requests. ';
152- if (endpointType === 'sse') {
153- errorMessage += isAuthenticated
154- ? 'Please wait before attempting to reconnect to the chat.'
155- : 'Please wait before attempting to view the public chat again.';
156- } else if (endpointType === 'auth') {
157- errorMessage += 'Please wait before attempting to log in again.';
158- } else {
159- errorMessage += 'Please try again later.';
160- }
161-
162- return new Response(JSON.stringify({
163- error: errorMessage,
164- retryAfter: Math.ceil((retryAfter || 0) / 1000),
165- endpointType,
166- isAuthenticated,
167- debug: {
168- isAuthenticated,
169- hasUser: Boolean(event.locals.user),
170- hasSession: Boolean(event.locals.session),
171- endpointType,
172- rateLimitPoints: isAuthenticated
173- ? RATE_LIMITS.sse.authenticated.points
174- : RATE_LIMITS.sse.unauthenticated.points
175- }
176- }), {
177- status: 429,
178- headers: {
179- 'Content-Type': 'application/json',
180- 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),
181- ...debugHeaders
182- }
183- });
184- }
185-
186- return null;
187-} ⏎̸
138+ const ip = event.getClientAddress();
139+ const url = new URL(event.request.url);
140+ const isPublic = url.searchParams.get('public') === 'true';
141+ const isAuthenticated = Boolean(event.locals.user);
142+
143+ // Skip rate limiting for non-API routes
144+ if (!url.pathname.startsWith('/api')) {
145+ return null;
146+ }
147+
148+ const endpointType = getEndpointType(url.pathname, isPublic);
149+ const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
150+
151+ if (limited) {
152+ // Create debug headers
153+ const debugHeaders = {
154+ 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
155+ 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
156+ 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
157+ 'X-Debug-Endpoint-Type': endpointType,
158+ 'X-Debug-Rate-Limit-Points': isAuthenticated
159+ ? RATE_LIMITS.sse.authenticated.points.toString()
160+ : RATE_LIMITS.sse.unauthenticated.points.toString()
161+ };
162+
163+ // Construct a user-friendly error message
164+ let errorMessage = 'Too many requests. ';
165+ if (endpointType === 'sse') {
166+ errorMessage += isAuthenticated
167+ ? 'Please wait before attempting to reconnect to the chat.'
168+ : 'Please wait before attempting to view the public chat again.';
169+ } else if (endpointType === 'auth') {
170+ errorMessage += 'Please wait before attempting to log in again.';
171+ } else {
172+ errorMessage += 'Please try again later.';
173+ }
174+
175+ return new Response(
176+ JSON.stringify({
177+ error: errorMessage,
178+ retryAfter: Math.ceil((retryAfter || 0) / 1000),
179+ endpointType,
180+ isAuthenticated,
181+ debug: {
182+ isAuthenticated,
183+ hasUser: Boolean(event.locals.user),
184+ hasSession: Boolean(event.locals.session),
185+ endpointType,
186+ rateLimitPoints: isAuthenticated
187+ ? RATE_LIMITS.sse.authenticated.points
188+ : RATE_LIMITS.sse.unauthenticated.points
189+ }
190+ }),
191+ {
192+ status: 429,
193+ headers: {
194+ 'Content-Type': 'application/json',
195+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),
196+ ...debugHeaders
197+ }
198+ }
199+ );
200+ }
201+
202+ return null;
203+}
M src/lib/api/session.cookie.ts +52 -48
@@ -1,61 +1,65 @@
1-import type { Cookies } from "@sveltejs/kit";
2-import { createLogger } from "$lib/utils/logger.server";
1+import type { Cookies } from '@sveltejs/kit';
2+import { createLogger } from '$lib/utils/logger.server';
33
44 const log = createLogger('session-cookie');
55
66 // Helper function to determine cookie domain
77 function getCookieDomain(): string | undefined {
8- if (process.env.NODE_ENV === 'production') {
9- // Check for Cloudflare Pages domain
10- if (process.env.CF_PAGES_URL) {
11- const url = process.env.CF_PAGES_URL;
12- log.debug("Cloudflare Pages URL:", { url });
13- // For preview deployments, use host-only cookie
14- if (process.env.CF_PAGES_BRANCH !== 'main') {
15- return undefined;
16- }
17- // For production, use the main domain
18- return '.pdraim.pages.dev';
19- }
20- // Fallback for custom domain
21- return '.pdraim.org';
22- }
23- return undefined;
8+ if (process.env.NODE_ENV === 'production') {
9+ // Check for Cloudflare Pages domain
10+ if (process.env.CF_PAGES_URL) {
11+ const url = process.env.CF_PAGES_URL;
12+ log.debug('Cloudflare Pages URL:', { url });
13+ // For preview deployments, use host-only cookie
14+ if (process.env.CF_PAGES_BRANCH !== 'main') {
15+ return undefined;
16+ }
17+ // For production, use the main domain
18+ return '.pdraim.pages.dev';
19+ }
20+ // Fallback for custom domain
21+ return '.pdraim.org';
22+ }
23+ return undefined;
2424 }
2525
26-export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void {
27- log.debug("Setting session cookie with expiry:", new Date(expiresAt));
28-
29- const domain = getCookieDomain();
30- log.debug("Setting cookie for domain:", { domain });
31-
32- cookies.set("session", token, {
33- httpOnly: true,
34- sameSite: "lax",
35- secure: process.env.NODE_ENV === 'production',
36- domain,
37- expires: new Date(expiresAt),
38- path: "/"
39- });
26+export function setSessionTokenCookie(
27+ { cookies }: { cookies: Cookies },
28+ token: string,
29+ expiresAt: number
30+): void {
31+ log.debug('Setting session cookie with expiry:', new Date(expiresAt));
32+
33+ const domain = getCookieDomain();
34+ log.debug('Setting cookie for domain:', { domain });
35+
36+ cookies.set('session', token, {
37+ httpOnly: true,
38+ sameSite: 'lax',
39+ secure: process.env.NODE_ENV === 'production',
40+ domain,
41+ expires: new Date(expiresAt),
42+ path: '/'
43+ });
4044 }
4145
4246 export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void {
43- log.debug("Deleting session cookie");
44-
45- const domain = getCookieDomain();
46- log.debug("Deleting cookie for domain:", { domain });
47-
48- cookies.set("session", "", {
49- httpOnly: true,
50- sameSite: "lax",
51- secure: process.env.NODE_ENV === 'production',
52- domain,
53- maxAge: 0,
54- path: "/"
55- });
47+ log.debug('Deleting session cookie');
48+
49+ const domain = getCookieDomain();
50+ log.debug('Deleting cookie for domain:', { domain });
51+
52+ cookies.set('session', '', {
53+ httpOnly: true,
54+ sameSite: 'lax',
55+ secure: process.env.NODE_ENV === 'production',
56+ domain,
57+ maxAge: 0,
58+ path: '/'
59+ });
5660 }
5761
5862 export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined {
59- log.debug("Getting session cookie");
60- return cookies.get("session");
61-} ⏎̸
63+ log.debug('Getting session cookie');
64+ return cookies.get('session');
65+}
M src/lib/api/session.server.ts +73 -73
@@ -8,61 +8,61 @@ const log = createLogger('session-server');
88
99 // Helper: Compute SHA-256 hash of a message and encode it as a hex string.
1010 export async function sha256(message: string): Promise<string> {
11- const encoder = new TextEncoder();
12- const data = encoder.encode(message);
13- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
14- const hashArray = Array.from(new Uint8Array(hashBuffer));
15- const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
16- return hashHex;
11+ const encoder = new TextEncoder();
12+ const data = encoder.encode(message);
13+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
14+ const hashArray = Array.from(new Uint8Array(hashBuffer));
15+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
16+ return hashHex;
1717 }
1818
1919 // Helper: Custom Base32 encoder using the standard alphabet.
2020 // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility.
21-const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
21+const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
2222 function base32Encode(data: Uint8Array): string {
23- let bits = 0;
24- let value = 0;
25- let output = "";
26- for (let i = 0; i < data.length; i++) {
27- value = (value << 8) | data[i];
28- bits += 8;
29- while (bits >= 5) {
30- const index = (value >>> (bits - 5)) & 0x1f;
31- output += base32Alphabet[index];
32- bits -= 5;
33- }
34- }
35- if (bits > 0) {
36- const index = (value << (5 - bits)) & 0x1f;
37- output += base32Alphabet[index];
38- }
39- return output;
23+ let bits = 0;
24+ let value = 0;
25+ let output = '';
26+ for (let i = 0; i < data.length; i++) {
27+ value = (value << 8) | data[i];
28+ bits += 8;
29+ while (bits >= 5) {
30+ const index = (value >>> (bits - 5)) & 0x1f;
31+ output += base32Alphabet[index];
32+ bits -= 5;
33+ }
34+ }
35+ if (bits > 0) {
36+ const index = (value << (5 - bits)) & 0x1f;
37+ output += base32Alphabet[index];
38+ }
39+ return output;
4040 }
4141
4242 // Generate a secure session token by generating 20 random bytes and Base32 encoding them.
4343 export function generateSessionToken(): string {
44- const randomBytesArray = new Uint8Array(20);
45- crypto.getRandomValues(randomBytesArray);
46- const token = base32Encode(randomBytesArray);
47- log.debug("Generated session token", { token });
48- return token;
44+ const randomBytesArray = new Uint8Array(20);
45+ crypto.getRandomValues(randomBytesArray);
46+ const token = base32Encode(randomBytesArray);
47+ log.debug('Generated session token', { token });
48+ return token;
4949 }
5050
5151 // Create a session record once a valid token is available.
5252 // The session ID is the SHA-256 hash of the token.
5353 export async function createSession(token: string, userId: string): Promise<Session> {
54- log.debug("Creating session", { userId });
55- const sessionId = await sha256(token);
56- const createdAt = Date.now();
57- const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry
58- await db.insert(sessions).values({
59- id: sessionId,
60- userId,
61- createdAt,
62- expiresAt,
63- });
64- log.debug("Session created", { sessionId });
65- return { id: sessionId, userId, createdAt, expiresAt };
54+ log.debug('Creating session', { userId });
55+ const sessionId = await sha256(token);
56+ const createdAt = Date.now();
57+ const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry
58+ await db.insert(sessions).values({
59+ id: sessionId,
60+ userId,
61+ createdAt,
62+ expiresAt
63+ });
64+ log.debug('Session created', { sessionId });
65+ return { id: sessionId, userId, createdAt, expiresAt };
6666 }
6767
6868 /**
@@ -71,46 +71,46 @@ export async function createSession(token: string, userId: string): Promise<Sess
7171 * Note: This returns the full User type since it's used server-side in hooks.
7272 */
7373 export type SessionValidationResult =
74- | { session: Session; user: User }
75- | { session: null; user: null };
74+ | { session: Session; user: User }
75+ | { session: null; user: null };
7676
7777 // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user.
7878 export async function validateSessionToken(token: string): Promise<SessionValidationResult> {
79- log.debug("Validating session token");
80- const sessionId = await sha256(token);
81- const session = await db.query.sessions.findFirst({
82- where: eq(sessions.id, sessionId)
83- });
84- if (!session) {
85- log.debug("Session not found", { sessionId });
86- return { session: null, user: null };
87- }
88- const now = Date.now();
89- if (session.expiresAt < now) {
90- log.debug("Session expired", { sessionId });
91- return { session: null, user: null };
92- }
93- const user = await db.query.users.findFirst({
94- where: eq(users.id, session.userId)
95- });
96- if (!user) {
97- log.debug("User not found for session", { sessionId });
98- return { session: null, user: null };
99- }
100- log.debug("Session validated", { sessionId, userId: user.id });
101- return { session, user };
79+ log.debug('Validating session token');
80+ const sessionId = await sha256(token);
81+ const session = await db.query.sessions.findFirst({
82+ where: eq(sessions.id, sessionId)
83+ });
84+ if (!session) {
85+ log.debug('Session not found', { sessionId });
86+ return { session: null, user: null };
87+ }
88+ const now = Date.now();
89+ if (session.expiresAt < now) {
90+ log.debug('Session expired', { sessionId });
91+ return { session: null, user: null };
92+ }
93+ const user = await db.query.users.findFirst({
94+ where: eq(users.id, session.userId)
95+ });
96+ if (!user) {
97+ log.debug('User not found for session', { sessionId });
98+ return { session: null, user: null };
99+ }
100+ log.debug('Session validated', { sessionId, userId: user.id });
101+ return { session, user };
102102 }
103103
104104 // Invalidate a specific session by deleting it from the database.
105105 export async function invalidateSession(sessionId: string): Promise<void> {
106- log.debug("Invalidating session", { sessionId });
107- await db.delete(sessions).where(eq(sessions.id, sessionId));
108- log.debug("Session invalidated", { sessionId });
106+ log.debug('Invalidating session', { sessionId });
107+ await db.delete(sessions).where(eq(sessions.id, sessionId));
108+ log.debug('Session invalidated', { sessionId });
109109 }
110110
111111 // Invalidate all sessions for a given user.
112112 export async function invalidateAllSessions(userId: string): Promise<void> {
113- log.debug("Invalidating all sessions for user", { userId });
114- await db.delete(sessions).where(eq(sessions.userId, userId));
115- log.debug("All sessions invalidated", { userId });
116-} ⏎̸
113+ log.debug('Invalidating all sessions for user', { userId });
114+ await db.delete(sessions).where(eq(sessions.userId, userId));
115+ log.debug('All sessions invalidated', { userId });
116+}
M src/lib/buddyListCache.ts +46 -46
@@ -4,52 +4,52 @@ import type { SafeUser } from './types/chat';
44 const log = createLogger('buddy-list-cache');
55
66 class BuddyListCache {
7- private cache: SafeUser[] | null = null;
8- private lastHash: string = '';
9- private lastFetch: number = 0;
10- private isDirty: boolean = true; // Force initial fetch
11-
12- // Mark cache as needing refresh
13- invalidate(): void {
14- this.isDirty = true;
15- log.debug('Buddy list cache invalidated');
16- }
17-
18- // Check if we need to fetch new data
19- needsRefresh(): boolean {
20- return this.isDirty || this.cache === null;
21- }
22-
23- // Update cache with new data
24- update(users: SafeUser[]): boolean {
25- const newHash = JSON.stringify(users);
26- const hasChanged = newHash !== this.lastHash;
27-
28- if (hasChanged) {
29- this.cache = users;
30- this.lastHash = newHash;
31- this.lastFetch = Date.now();
32- this.isDirty = false;
33-
34- log.debug('Buddy list cache updated', {
35- userCount: users.length,
36- changed: hasChanged
37- });
38- }
39-
40- return hasChanged;
41- }
42-
43- // Get cached data
44- get(): SafeUser[] | null {
45- return this.cache;
46- }
47-
48- // Get cache age in milliseconds
49- getAge(): number {
50- return this.cache ? Date.now() - this.lastFetch : Infinity;
51- }
7+ private cache: SafeUser[] | null = null;
8+ private lastHash: string = '';
9+ private lastFetch: number = 0;
10+ private isDirty: boolean = true; // Force initial fetch
11+
12+ // Mark cache as needing refresh
13+ invalidate(): void {
14+ this.isDirty = true;
15+ log.debug('Buddy list cache invalidated');
16+ }
17+
18+ // Check if we need to fetch new data
19+ needsRefresh(): boolean {
20+ return this.isDirty || this.cache === null;
21+ }
22+
23+ // Update cache with new data
24+ update(users: SafeUser[]): boolean {
25+ const newHash = JSON.stringify(users);
26+ const hasChanged = newHash !== this.lastHash;
27+
28+ if (hasChanged) {
29+ this.cache = users;
30+ this.lastHash = newHash;
31+ this.lastFetch = Date.now();
32+ this.isDirty = false;
33+
34+ log.debug('Buddy list cache updated', {
35+ userCount: users.length,
36+ changed: hasChanged
37+ });
38+ }
39+
40+ return hasChanged;
41+ }
42+
43+ // Get cached data
44+ get(): SafeUser[] | null {
45+ return this.cache;
46+ }
47+
48+ // Get cache age in milliseconds
49+ getAge(): number {
50+ return this.cache ? Date.now() - this.lastFetch : Infinity;
51+ }
5252 }
5353
5454 // 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 @@
11 <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
22 <script lang="ts">
3- import { draggable } from '$lib/actions/draggable';
4- import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5- import type { User, SafeUser } from '$lib/types/chat';
6- import { invalidateAll } from '$app/navigation';
7- import { chatState } from '$lib/states/chat.svelte';
8- import { createSafeUser } from '$lib/types/chat';
9- import { onMount } from 'svelte';
10- import PasswordStrengthIndicator from './password-strength-indicator.svelte';
11- import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password';
12-
13- interface $$Props {
14- showAuth: boolean;
15- onLoginSuccess?: (user: SafeUser | null) => void;
16- activeTab?: 'signin' | 'signup';
17- }
18-
19- let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props();
20-
21- let activeTab = $state(initialTab);
22-
23- // Sign In state
24- let siUsername = $state('');
25- let siPassword = $state('');
26- let error = $state('');
27- let loginStatus = $state('idle');
28-
29- // Sign Up state
30- let suUsername = $state('');
31- let suPassword = $state('');
32- let suConfirmPassword = $state('');
33- let captchaAnswer = $state('');
34- let signupError = $state('');
35- let signupStatus = $state('idle');
36- let suTurnstileToken = $state('');
37-
38- // Password validation state
39- let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
40- let showPasswordStrength = $state(false);
41-
42- // Added dragging state and handler similar to chat-room
43- let windowX = $state(0);
44- let windowY = $state(0);
45-
46- // Initialize window position on mount
47- onMount(() => {
48- if (window.innerWidth > 768) { // Desktop mode
49- windowX = window.innerWidth * 0.2;
50- windowY = window.innerHeight * 0.2;
51- }
52- });
53-
54- function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
55- windowX = event.detail.x;
56- windowY = event.detail.y;
57- }
58-
59- function handleClose() {
60- console.debug("Closing AIM Login component.");
61- showAuth = false;
62- }
63-
64- async function handleSigninSubmit() {
65- error = '';
66- loginStatus = 'loading';
67-
68- // Validate login data
69- const validationError = validateLoginData();
70- if (validationError) {
71- error = validationError;
72- loginStatus = 'error';
73- return;
74- }
75-
76- console.debug("Attempting login for", siUsername);
77- try {
78- const res = await fetch('/api/session/login', {
79- method: 'POST',
80- headers: { 'Content-Type': 'application/json' },
81- body: JSON.stringify({
82- username: siUsername,
83- password: siPassword
84- })
85- });
86-
87- const data = await res.json();
88- if (!res.ok) {
89- error = data.error || "Login failed.";
90- console.debug("Login failed:", data.error);
91- loginStatus = 'error';
92- return;
93- }
94-
95- console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null });
96- await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
97- loginStatus = 'success';
98-
99- // Call the callback prop if provided
100- onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
101- // Auto close login window after 3 seconds on successful login
102- setTimeout(() => {
103- console.debug("Auto closing AIM Login component after successful login");
104- invalidateAll();
105- handleClose();
106- }, 3000);
107-
108- return;
109- } catch (err) {
110- console.debug("Login error:", err);
111- error = "Login error. Please try again.";
112- loginStatus = 'error';
113- }
114- }
115-
116- // Validation using Zod schemas
117- function validateLoginData() {
118- try {
119- loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
120- return null;
121- } catch (err: any) {
122- return err.errors?.[0]?.message || 'Invalid input';
123- }
124- }
125-
126- function validateSignupData() {
127- try {
128- const registrationSchema = createRegistrationSchema(passwordConstraints);
129- registrationSchema.parse({
130- suUsername: suUsername.trim(),
131- suPassword: suPassword.trim(),
132- suConfirmPassword: suConfirmPassword.trim(),
133- captchaAnswer: captchaAnswer.trim(),
134- turnstileToken: suTurnstileToken
135- });
136- return null;
137- } catch (err: any) {
138- return err.errors?.[0]?.message || 'Invalid input';
139- }
140- }
141-
142- async function handleSignupSubmit() {
143- signupError = '';
144- signupStatus = 'loading';
145-
146- // Client-side validations before submitting the form.
147- const validationError = validateSignupData();
148- if (validationError) {
149- signupError = validationError;
150- signupStatus = 'error';
151- return;
152- }
153-
154- // Additional check for captcha
155- if (!captchaAnswer) {
156- signupError = 'Please complete all fields including the PDR question';
157- signupStatus = 'error';
158- return;
159- }
160-
161- console.debug("Attempting registration for", suUsername);
162- try {
163- const res = await fetch('/api/register', {
164- method: 'POST',
165- headers: { 'Content-Type': 'application/json' },
166- body: JSON.stringify({
167- suUsername,
168- suPassword,
169- suConfirmPassword,
170- captchaAnswer,
171- turnstileToken: suTurnstileToken
172- })
173- });
174-
175- const data = await res.json() as RegisterResponse;
176- if (!res.ok) {
177- const errorData = data as RegisterResponseError;
178- signupError = errorData.error || "Registration failed.";
179- signupStatus = 'error';
180- return;
181- }
182- console.debug("Registration succeeded:", data);
183- signupStatus = 'success';
184-
185- // Auto login after registration.
186- await handleAutoLoginSignup();
187- return;
188- } catch (err) {
189- console.debug("Registration error", err);
190- signupError = "Registration error. Please try again.";
191- signupStatus = 'error';
192- }
193- }
194-
195- async function handleAutoLoginSignup() {
196- try {
197- const res = await fetch('/api/session/login', {
198- method: 'POST',
199- headers: { 'Content-Type': 'application/json' },
200- body: JSON.stringify({ username: suUsername, password: suPassword })
201- });
202- const data = await res.json();
203- if (!res.ok) {
204- signupError = data.error || "Login failed after registration.";
205- signupStatus = 'error';
206- return;
207- }
208- console.debug("Auto login after registration succeeded:", data);
209- await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
210- onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
211- setTimeout(() => {
212- console.debug("Auto closing AIM Login component after auto login on registration");
213- invalidateAll();
214- handleClose();
215- }, 3000);
216-
217- } catch (err) {
218- console.debug("Auto login error:", err);
219- signupError = "Auto login error. Please try to login manually.";
220- signupStatus = 'error';
221- }
222- }
223-
224- function switchTab(tab: string) {
225- activeTab = tab;
226- error = '';
227- signupError = '';
228- }
229-
230- function handleKeydown(event: KeyboardEvent) {
231- if (event.key === 'Escape') {
232- console.debug("Escape key pressed, closing AIM Login component.");
233- handleClose();
234- }
235- }
3+ import { draggable } from '$lib/actions/draggable';
4+ import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5+ import { invalidateAll } from '$app/navigation';
6+ import { chatState } from '$lib/states/chat.svelte';
7+ import { createSafeUser } from '$lib/types/chat';
8+ import { onMount } from 'svelte';
9+ import PasswordStrengthIndicator from './password-strength-indicator.svelte';
10+ import {
11+ loginSchema,
12+ createRegistrationSchema,
13+ DEFAULT_PASSWORD_CONSTRAINTS,
14+ type PasswordConstraints
15+ } from '../validation/password';
16+ import { z } from 'zod';
17+
18+ let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props();
19+
20+ let activeTab = $state(initialTab);
21+
22+ // Sign In state
23+ let siUsername = $state('');
24+ let siPassword = $state('');
25+ let error = $state('');
26+ let loginStatus = $state('idle');
27+
28+ // Sign Up state
29+ let suUsername = $state('');
30+ let suPassword = $state('');
31+ let suConfirmPassword = $state('');
32+ let captchaAnswer = $state('');
33+ let signupError = $state('');
34+ let signupStatus = $state('idle');
35+ let suTurnstileToken = $state('');
36+
37+ // Password validation state
38+ let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
39+ let showPasswordStrength = $state(false);
40+
41+ // Added dragging state and handler similar to chat-room
42+ let windowX = $state(0);
43+ let windowY = $state(0);
44+
45+ // Initialize window position on mount
46+ onMount(() => {
47+ if (window.innerWidth > 768) {
48+ // Desktop mode
49+ windowX = window.innerWidth * 0.2;
50+ windowY = window.innerHeight * 0.2;
51+ }
52+ });
53+
54+ function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
55+ windowX = event.detail.x;
56+ windowY = event.detail.y;
57+ }
58+
59+ function handleClose() {
60+ console.debug('Closing AIM Login component.');
61+ showAuth = false;
62+ }
63+
64+ async function handleSigninSubmit() {
65+ error = '';
66+ loginStatus = 'loading';
67+
68+ // Validate login data
69+ const validationError = validateLoginData();
70+ if (validationError) {
71+ error = validationError;
72+ loginStatus = 'error';
73+ return;
74+ }
75+
76+ console.debug('Attempting login for', siUsername);
77+ try {
78+ const res = await fetch('/api/session/login', {
79+ method: 'POST',
80+ headers: { 'Content-Type': 'application/json' },
81+ body: JSON.stringify({
82+ username: siUsername,
83+ password: siPassword
84+ })
85+ });
86+
87+ const data = await res.json();
88+ if (!res.ok) {
89+ error = data.error || 'Login failed.';
90+ console.debug('Login failed:', data.error);
91+ loginStatus = 'error';
92+ return;
93+ }
94+
95+ console.debug('Login succeeded:', {
96+ ...data,
97+ user: data.user ? { ...data.user, password: '[REDACTED]' } : null
98+ });
99+ await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
100+ loginStatus = 'success';
101+
102+ // Call the callback prop if provided
103+ onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
104+ // Auto close login window after 3 seconds on successful login
105+ setTimeout(() => {
106+ console.debug('Auto closing AIM Login component after successful login');
107+ invalidateAll();
108+ handleClose();
109+ }, 3000);
110+
111+ return;
112+ } catch (err) {
113+ console.debug('Login error:', err);
114+ error = 'Login error. Please try again.';
115+ loginStatus = 'error';
116+ }
117+ }
118+
119+ // Validation using Zod schemas
120+ function validateLoginData() {
121+ try {
122+ loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
123+ return null;
124+ } catch (err) {
125+ if (err instanceof z.ZodError) {
126+ return err.issues?.[0]?.message || 'Invalid input';
127+ }
128+ return 'Invalid input';
129+ }
130+ }
131+
132+ function validateSignupData() {
133+ try {
134+ const registrationSchema = createRegistrationSchema(passwordConstraints);
135+ registrationSchema.parse({
136+ suUsername: suUsername.trim(),
137+ suPassword: suPassword.trim(),
138+ suConfirmPassword: suConfirmPassword.trim(),
139+ captchaAnswer: captchaAnswer.trim(),
140+ turnstileToken: suTurnstileToken
141+ });
142+ return null;
143+ } catch (err) {
144+ if (err instanceof z.ZodError) {
145+ return err.issues?.[0]?.message || 'Invalid input';
146+ }
147+ return 'Invalid input';
148+ }
149+ }
150+
151+ async function handleSignupSubmit() {
152+ signupError = '';
153+ signupStatus = 'loading';
154+
155+ // Client-side validations before submitting the form.
156+ const validationError = validateSignupData();
157+ if (validationError) {
158+ signupError = validationError;
159+ signupStatus = 'error';
160+ return;
161+ }
162+
163+ // Additional check for captcha
164+ if (!captchaAnswer) {
165+ signupError = 'Please complete all fields including the PDR question';
166+ signupStatus = 'error';
167+ return;
168+ }
169+
170+ console.debug('Attempting registration for', suUsername);
171+ try {
172+ const res = await fetch('/api/register', {
173+ method: 'POST',
174+ headers: { 'Content-Type': 'application/json' },
175+ body: JSON.stringify({
176+ suUsername,
177+ suPassword,
178+ suConfirmPassword,
179+ captchaAnswer,
180+ turnstileToken: suTurnstileToken
181+ })
182+ });
183+
184+ const data = (await res.json()) as RegisterResponse;
185+ if (!res.ok) {
186+ const errorData = data as RegisterResponseError;
187+ signupError = errorData.error || 'Registration failed.';
188+ signupStatus = 'error';
189+ return;
190+ }
191+ console.debug('Registration succeeded:', data);
192+ signupStatus = 'success';
193+
194+ // Auto login after registration.
195+ await handleAutoLoginSignup();
196+ return;
197+ } catch (err) {
198+ console.debug('Registration error', err);
199+ signupError = 'Registration error. Please try again.';
200+ signupStatus = 'error';
201+ }
202+ }
203+
204+ async function handleAutoLoginSignup() {
205+ try {
206+ const res = await fetch('/api/session/login', {
207+ method: 'POST',
208+ headers: { 'Content-Type': 'application/json' },
209+ body: JSON.stringify({ username: suUsername, password: suPassword })
210+ });
211+ const data = await res.json();
212+ if (!res.ok) {
213+ signupError = data.error || 'Login failed after registration.';
214+ signupStatus = 'error';
215+ return;
216+ }
217+ console.debug('Auto login after registration succeeded:', data);
218+ await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
219+ onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
220+ setTimeout(() => {
221+ console.debug('Auto closing AIM Login component after auto login on registration');
222+ invalidateAll();
223+ handleClose();
224+ }, 3000);
225+ } catch (err) {
226+ console.debug('Auto login error:', err);
227+ signupError = 'Auto login error. Please try to login manually.';
228+ signupStatus = 'error';
229+ }
230+ }
231+
232+ function switchTab(tab: string) {
233+ activeTab = tab;
234+ error = '';
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+ }
236244 </script>
237245
238246 <svelte:window onkeydown={handleKeydown} />
239247
240248 <!-- Updated outer div to handle dragging with inline style and drag event -->
241-<div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
242- <div class="title-bar">
243- <div class="title-bar-text">
244- <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" />
245- {activeTab === 'signin' ? 'Connexion' : 'Inscription'}
246- </div>
247- <div class="title-bar-controls">
248- <button aria-label="Close" onclick={handleClose}>×</button>
249- </div>
250- </div>
251-
252- <div class="tabs">
253- <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Connexion</button>
254- <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Inscription</button>
255- </div>
256-
257- <div class="window-content">
258- {#if activeTab === 'signin'}
259- <form onsubmit={handleSigninSubmit}>
260- <div class="form-group">
261- <label for="si-username">Nom d'utilisateur</label>
262- <input type="text" id="si-username" bind:value={siUsername} placeholder="Nom d'utilisateur" />
263- </div>
264- <div class="form-group">
265- <label for="si-password">Mot de passe</label>
266- <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
267- </div>
268- {#if error}
269- <div class="error">{error}</div>
270- {/if}
271- <div class="button-bar">
272- <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}">
273- {#if loginStatus === 'loading'}
274- <svg width="24" height="24" viewBox="0 0 24 24">
275- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
276- <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
277- <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>
278- </path>
279- </svg>
280- {:else if loginStatus === 'success'}
281- <svg width="24" height="24" viewBox="0 0 24 24">
282- <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
283- </svg>
284- {:else if loginStatus === 'error'}
285- <svg width="24" height="24" viewBox="0 0 24 24">
286- <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/>
287- <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/>
288- <circle cx="12" cy="17" r="1" fill="red"/>
289- </svg>
290- {:else}
291- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
292- <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
293- </svg>
294- {/if}
295- Se connecter
296- </button>
297- </div>
298- </form>
299- {:else}
300- <form onsubmit={handleSignupSubmit}>
301- <div class="form-group">
302- <label for="su-username">Nom d'utilisateur</label>
303- <input type="text" id="su-username" bind:value={suUsername} placeholder="Choisissez un nom d'utilisateur" />
304- </div>
305- <div class="form-group">
306- <label for="su-password">Mot de passe</label>
307- <input
308- type="password"
309- id="su-password"
310- bind:value={suPassword}
311- placeholder="Mot de passe"
312- onfocus={() => showPasswordStrength = true}
313- />
314- {#if showPasswordStrength}
315- <PasswordStrengthIndicator
316- password={suPassword}
317- constraints={passwordConstraints}
318- showDetails={true}
319- />
320- {/if}
321- </div>
322- <div class="form-group">
323- <label for="su-confirm-password">Confirmer le mot de passe</label>
324- <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirmer le mot de passe" />
325- </div>
326- <div class="form-group">
327- <label for="captcha">Quelle est la signification de PDR ?</label>
328- <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
329- </div>
330- {#if signupError}
331- <div class="error">{signupError}</div>
332- {/if}
333- <div class="button-bar">
334- <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}">
335- {#if signupStatus === 'loading'}
336- <svg width="24" height="24" viewBox="0 0 24 24">
337- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
338- <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
339- <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform>
340- </path>
341- </svg>
342- {:else if signupStatus === 'success'}
343- <svg width="24" height="24" viewBox="0 0 24 24">
344- <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
345- </svg>
346- {:else if signupStatus === 'error'}
347- <svg width="24" height="24" viewBox="0 0 24 24">
348- <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle>
349- <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line>
350- <circle cx="12" cy="17" r="1" fill="red"></circle>
351- </svg>
352- {:else}
353- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
354- <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path>
355- </svg>
356- {/if}
357- S'inscrire
358- </button>
359- </div>
360- </form>
361- {/if}
362- </div>
249+<div
250+ class="login-window window"
251+ use:draggable={{ handle: '.title-bar' }}
252+ ondragmove={handleDragMove}
253+ style="left: {windowX}px; top: {windowY}px;"
254+>
255+ <div class="title-bar">
256+ <div class="title-bar-text">
257+ <img
258+ src="/desktop/aim-desktop-icon.png"
259+ alt="AIM"
260+ class="title-icon"
261+ width="16"
262+ height="16"
263+ />
264+ {activeTab === 'signin' ? 'Connexion' : 'Inscription'}
265+ </div>
266+ <div class="title-bar-controls">
267+ <button aria-label="Close" onclick={handleClose}>×</button>
268+ </div>
269+ </div>
270+
271+ <div class="tabs">
272+ <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}
273+ >Connexion</button
274+ >
275+ <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}
276+ >Inscription</button
277+ >
278+ </div>
279+
280+ <div class="window-content">
281+ {#if activeTab === 'signin'}
282+ <form onsubmit={handleSigninSubmit}>
283+ <div class="form-group">
284+ <label for="si-username">Nom d'utilisateur</label>
285+ <input
286+ type="text"
287+ id="si-username"
288+ bind:value={siUsername}
289+ placeholder="Nom d'utilisateur"
290+ />
291+ </div>
292+ <div class="form-group">
293+ <label for="si-password">Mot de passe</label>
294+ <input
295+ type="password"
296+ id="si-password"
297+ bind:value={siPassword}
298+ placeholder="Mot de passe"
299+ />
300+ </div>
301+ {#if error}
302+ <div class="error">{error}</div>
303+ {/if}
304+ <div class="button-bar">
305+ <button
306+ type="submit"
307+ class="icon-button {loginStatus === 'success'
308+ ? 'success-border'
309+ : loginStatus === 'error'
310+ ? 'error-border'
311+ : ''}"
312+ >
313+ {#if loginStatus === 'loading'}
314+ <svg width="24" height="24" viewBox="0 0 24 24">
315+ <circle
316+ cx="12"
317+ cy="12"
318+ r="10"
319+ stroke="currentColor"
320+ stroke-width="2"
321+ fill="none"
322+ opacity="0.25"
323+ ></circle>
324+ <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
325+ <animateTransform
326+ attributeName="transform"
327+ type="rotate"
328+ values="0 12 12;360 12 12"
329+ dur="1s"
330+ repeatCount="indefinite"
331+ />
332+ </path>
333+ </svg>
334+ {:else if loginStatus === 'success'}
335+ <svg width="24" height="24" viewBox="0 0 24 24">
336+ <path
337+ d="M20 6L9 17l-5-5"
338+ stroke="green"
339+ stroke-width="2"
340+ fill="none"
341+ stroke-linecap="round"
342+ stroke-linejoin="round"
343+ />
344+ </svg>
345+ {:else if loginStatus === 'error'}
346+ <svg width="24" height="24" viewBox="0 0 24 24">
347+ <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none" />
348+ <line
349+ x1="12"
350+ y1="8"
351+ x2="12"
352+ y2="13"
353+ stroke="red"
354+ stroke-width="2"
355+ stroke-linecap="round"
356+ />
357+ <circle cx="12" cy="17" r="1" fill="red" />
358+ </svg>
359+ {:else}
360+ <svg
361+ width="24"
362+ height="24"
363+ viewBox="0 0 24 24"
364+ fill="none"
365+ xmlns="http://www.w3.org/2000/svg"
366+ >
367+ <path
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"
369+ fill="currentColor"
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>
363501 </div>
364502
365503 <style>
366- .login-window {
367- width: 300px;
368- background: #ECE9D8;
369- /* Changed to fixed positioning for correct dragging */
370- position: fixed;
371- }
372-
373- @media (max-width: 768px) {
374- .login-window {
375- width: 100% !important;
376- height: 100% !important;
377- top: 0 !important;
378- left: 0 !important;
379- right: 0 !important;
380- bottom: 0 !important;
381- margin: 0 !important;
382- border-radius: 0 !important;
383- transform: none !important;
384- }
385-
386- .window-content {
387- height: calc(100% - 80px); /* Account for title bar and tabs */
388- overflow-y: auto;
389- }
390-
391- form {
392- max-width: 400px;
393- margin: 0 auto;
394- padding: 1rem;
395- }
396- }
397-
398- .title-bar {
399- background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
400- padding: 4px 6px;
401- display: flex;
402- justify-content: space-between;
403- align-items: center;
404- color: white;
405- font-weight: bold;
406- user-select: none;
407- position: relative;
408- }
409- .title-bar-text {
410- display: flex;
411- align-items: center;
412- gap: 4px;
413- font-size: 0.875rem;
414- }
415-
416- .tabs {
417- padding: 0 3px;
418- display: flex;
419- border-bottom: 1px solid #0054E3;
420- }
421- .tabs button {
422- flex: 1;
423- padding: 8px;
424- border: none;
425- background: #ECE9D8;
426- font-size: 1rem;
427- cursor: pointer;
428- }
429- .tabs button.active {
430- background: #fff;
431- border-bottom: 2px solid #0054E3;
432- font-weight: bold;
433- }
434- .window-content {
435- padding: 16px;
436- }
437- .form-group {
438- margin-bottom: 16px;
439- }
440- .form-group label {
441- display: block;
442- margin-bottom: 4px;
443- font-size: 1rem;
444- }
445- .form-group input {
446- width: 100%;
447- padding: 4px;
448- font-size: 1rem;
449- font-family: Arial, Verdana, Tahoma, sans-serif;
450- }
451- .button-bar {
452- display: flex;
453- justify-content: center;
454- margin-top: 20px;
455- }
456- .icon-button {
457- display: flex;
458- flex-direction: column;
459- align-items: center;
460- gap: 4px;
461- background: none;
462- border: none;
463- cursor: pointer;
464- font-size: 1rem;
465- color: black;
466- }
467- .error {
468- color: red;
469- font-size: 1rem;
470- margin-top: 8px;
471- }
472- .success-border {
473- border: 2px solid green;
474- }
475- .error-border {
476- border: 2px solid red;
477- }
478-</style> ⏎̸
504+ .login-window {
505+ width: 300px;
506+ background: #ece9d8;
507+ /* Changed to fixed positioning for correct dragging */
508+ position: fixed;
509+ }
510+
511+ @media (max-width: 768px) {
512+ .login-window {
513+ width: 100% !important;
514+ height: 100% !important;
515+ top: 0 !important;
516+ left: 0 !important;
517+ right: 0 !important;
518+ bottom: 0 !important;
519+ margin: 0 !important;
520+ border-radius: 0 !important;
521+ transform: none !important;
522+ }
523+
524+ .window-content {
525+ height: calc(100% - 80px); /* Account for title bar and tabs */
526+ overflow-y: auto;
527+ }
528+
529+ form {
530+ max-width: 400px;
531+ margin: 0 auto;
532+ padding: 1rem;
533+ }
534+ }
535+
536+ .title-bar {
537+ background: linear-gradient(180deg, #0054e3 0%, #0047ab 100%);
538+ padding: 4px 6px;
539+ display: flex;
540+ justify-content: space-between;
541+ align-items: center;
542+ color: white;
543+ font-weight: bold;
544+ user-select: none;
545+ position: relative;
546+ }
547+ .title-bar-text {
548+ display: flex;
549+ align-items: center;
550+ gap: 4px;
551+ font-size: 0.875rem;
552+ }
553+
554+ .tabs {
555+ padding: 0 3px;
556+ display: flex;
557+ border-bottom: 1px solid #0054e3;
558+ }
559+ .tabs button {
560+ flex: 1;
561+ padding: 8px;
562+ border: none;
563+ background: #ece9d8;
564+ font-size: 1rem;
565+ cursor: pointer;
566+ }
567+ .tabs button.active {
568+ background: #fff;
569+ border-bottom: 2px solid #0054e3;
570+ font-weight: bold;
571+ }
572+ .window-content {
573+ padding: 16px;
574+ }
575+ .form-group {
576+ margin-bottom: 16px;
577+ }
578+ .form-group label {
579+ display: block;
580+ margin-bottom: 4px;
581+ font-size: 1rem;
582+ }
583+ .form-group input {
584+ width: 100%;
585+ padding: 4px;
586+ font-size: 1rem;
587+ font-family: Arial, Verdana, Tahoma, sans-serif;
588+ }
589+ .button-bar {
590+ display: flex;
591+ justify-content: center;
592+ margin-top: 20px;
593+ }
594+ .icon-button {
595+ display: flex;
596+ flex-direction: column;
597+ align-items: center;
598+ gap: 4px;
599+ background: none;
600+ border: none;
601+ cursor: pointer;
602+ font-size: 1rem;
603+ color: black;
604+ }
605+ .error {
606+ color: red;
607+ font-size: 1rem;
608+ margin-top: 8px;
609+ }
610+ .success-border {
611+ border: 2px solid green;
612+ }
613+ .error-border {
614+ border: 2px solid red;
615+ }
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 @@
11 <script lang="ts">
2-import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting';
3-
4-// Props
5-let {
6- selectedColor = $bindable('#000000'),
7- selectedGradient = $bindable(undefined),
8- onColorChange,
9- onGradientChange,
10- compact = true
11-} = $props<{
12- selectedColor?: string;
13- selectedGradient?: string[];
14- onColorChange?: (color: string) => void;
15- onGradientChange?: (gradient: string[]) => void;
16- compact?: boolean;
17-}>();
18-
19-// State
20-let showPicker = $state(false);
21-let gradientColors = $state<string[]>([selectedColor || '#000000']);
22-let activeGradientSlot = $state(0);
23-
24-// Generate color palette
25-const colors256 = generate256Colors();
26-const classicColors = Object.values(CLASSIC_COLORS);
27-
28-// Initialize gradient colors from props
29-$effect(() => {
30- if (selectedGradient && selectedGradient.length > 0) {
31- gradientColors = [...selectedGradient];
32- } else if (selectedColor) {
33- gradientColors = [selectedColor];
34- }
35-});
36-
37-// Get current display color/gradient
38-let displayStyle = $derived(() => {
39- if (gradientColors.length > 1) {
40- return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`;
41- }
42- return `background: ${gradientColors[0] || '#000000'}`;
43-});
44-
45-// Handle color selection
46-function selectColor(color: string) {
47- if (activeGradientSlot < gradientColors.length) {
48- gradientColors[activeGradientSlot] = color;
49- gradientColors = [...gradientColors]; // Trigger reactivity
50-
51- if (gradientColors.length === 1) {
52- // Single color mode
53- selectedColor = color;
54- selectedGradient = undefined;
55- onColorChange?.(color);
56- } else {
57- // Gradient mode
58- selectedGradient = [...gradientColors];
59- selectedColor = gradientColors[0];
60- onGradientChange?.(gradientColors);
61- }
62- }
63-}
64-
65-// Gradient management
66-function addGradientColor() {
67- if (gradientColors.length < 3) {
68- const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF';
69- gradientColors = [...gradientColors, newColor];
70- activeGradientSlot = gradientColors.length - 1;
71- selectedGradient = [...gradientColors];
72- onGradientChange?.(gradientColors);
73- }
74-}
75-
76-function removeGradientColor(index: number) {
77- if (gradientColors.length > 1) {
78- gradientColors = gradientColors.filter((_, i) => i !== index);
79- activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1);
80-
81- if (gradientColors.length === 1) {
82- // Back to single color mode
83- selectedColor = gradientColors[0];
84- selectedGradient = undefined;
85- onColorChange?.(gradientColors[0]);
86- } else {
87- selectedGradient = [...gradientColors];
88- onGradientChange?.(gradientColors);
89- }
90- }
91-}
92-
93-function setActiveSlot(index: number) {
94- activeGradientSlot = index;
95-}
96-
97-// Close picker
98-function closePicker() {
99- showPicker = false;
100-}
101-
102-// Handle click outside to close
103-function handleClickOutside(event: MouseEvent) {
104- const target = event.target as HTMLElement;
105- const colorPickerContainer = document.querySelector('.color-picker-container');
106-
107- if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) {
108- closePicker();
109- }
110-}
111-
112-// Add/remove click outside listener
113-$effect(() => {
114- if (showPicker) {
115- document.addEventListener('click', handleClickOutside);
116- return () => {
117- document.removeEventListener('click', handleClickOutside);
118- };
119- }
120-});
2+ import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting';
3+
4+ // Props
5+ let {
6+ selectedColor = $bindable('#000000'),
7+ selectedGradient = $bindable(undefined),
8+ onColorChange,
9+ onGradientChange,
10+ compact: _compact = true
11+ } = $props<{
12+ selectedColor?: string;
13+ selectedGradient?: string[];
14+ onColorChange?: (color: string) => void;
15+ onGradientChange?: (gradient: string[]) => void;
16+ compact?: boolean;
17+ }>();
18+ void _compact; // Reserved for future use
19+
20+ // State
21+ let showPicker = $state(false);
22+ let gradientColors = $state<string[]>([selectedColor || '#000000']);
23+ let activeGradientSlot = $state(0);
24+
25+ // Generate color palette
26+ const colors256 = generate256Colors();
27+ const classicColors = Object.values(CLASSIC_COLORS);
28+
29+ // Initialize gradient colors from props
30+ $effect(() => {
31+ if (selectedGradient && selectedGradient.length > 0) {
32+ gradientColors = [...selectedGradient];
33+ } else if (selectedColor) {
34+ gradientColors = [selectedColor];
35+ }
36+ });
37+
38+ // Get current display color/gradient
39+ let displayStyle = $derived(() => {
40+ if (gradientColors.length > 1) {
41+ return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`;
42+ }
43+ return `background: ${gradientColors[0] || '#000000'}`;
44+ });
45+
46+ // Handle color selection
47+ function selectColor(color: string) {
48+ if (activeGradientSlot < gradientColors.length) {
49+ gradientColors[activeGradientSlot] = color;
50+ gradientColors = [...gradientColors]; // Trigger reactivity
51+
52+ if (gradientColors.length === 1) {
53+ // Single color mode
54+ selectedColor = color;
55+ selectedGradient = undefined;
56+ onColorChange?.(color);
57+ } else {
58+ // Gradient mode
59+ selectedGradient = [...gradientColors];
60+ selectedColor = gradientColors[0];
61+ onGradientChange?.(gradientColors);
62+ }
63+ }
64+ }
65+
66+ // Gradient management
67+ function addGradientColor() {
68+ if (gradientColors.length < 3) {
69+ const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF';
70+ gradientColors = [...gradientColors, newColor];
71+ activeGradientSlot = gradientColors.length - 1;
72+ selectedGradient = [...gradientColors];
73+ onGradientChange?.(gradientColors);
74+ }
75+ }
76+
77+ function removeGradientColor(index: number) {
78+ if (gradientColors.length > 1) {
79+ gradientColors = gradientColors.filter((_, i) => i !== index);
80+ activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1);
81+
82+ if (gradientColors.length === 1) {
83+ // Back to single color mode
84+ selectedColor = gradientColors[0];
85+ selectedGradient = undefined;
86+ onColorChange?.(gradientColors[0]);
87+ } else {
88+ selectedGradient = [...gradientColors];
89+ onGradientChange?.(gradientColors);
90+ }
91+ }
92+ }
93+
94+ function setActiveSlot(index: number) {
95+ activeGradientSlot = index;
96+ }
97+
98+ // Close picker
99+ function closePicker() {
100+ showPicker = false;
101+ }
102+
103+ // Handle click outside to close
104+ function handleClickOutside(event: MouseEvent) {
105+ const target = event.target as HTMLElement;
106+ const colorPickerContainer = document.querySelector('.color-picker-container');
107+
108+ if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) {
109+ closePicker();
110+ }
111+ }
112+
113+ // Add/remove click outside listener
114+ $effect(() => {
115+ if (showPicker) {
116+ document.addEventListener('click', handleClickOutside);
117+ return () => {
118+ document.removeEventListener('click', handleClickOutside);
119+ };
120+ }
121+ });
121122 </script>
122123
123124 <div class="color-picker-container">
124- <!-- Color picker button -->
125- <button
126- onclick={(e) => {
127- e.stopPropagation();
128- showPicker = !showPicker;
129- }}
130- title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}
131- style="width: 20px; height: 20px; padding: 0;"
132- >
133- <div
134- class="color-indicator"
135- style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;"
136- ></div>
137- </button>
138-
139- <!-- Color picker dropdown -->
140- {#if showPicker}
141- <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}>
142- <!-- Gradient controls -->
143- <div class="gradient-controls">
144- <div class="gradient-slots">
145- {#each gradientColors as color, index}
146- <button
147- class="gradient-slot"
148- class:active={activeGradientSlot === index}
149- style="background-color: {color} !important;"
150- onclick={() => setActiveSlot(index)}
151- title="Color {index + 1} - Click to select this color slot"
152- >
153- {#if gradientColors.length > 1}
154- <span
155- class="remove-slot"
156- onclick={(e) => {
157- e.stopPropagation();
158- removeGradientColor(index);
159- }}
160- title="Remove this color"
161- role="button"
162- tabindex="0"
163- >×</span>
164- {/if}
165- </button>
166- {/each}
167-
168- {#if gradientColors.length < 3}
169- <button
170- class="add-slot"
171- onclick={addGradientColor}
172- title="Add another color to create gradient"
173- >+</button>
174- {/if}
175- </div>
176-
177- {#if gradientColors.length > 1}
178- <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div>
179- {/if}
180- </div>
181-
182- <!-- Classic colors (first row) -->
183- <div class="color-section">
184- <div class="section-title">Classic Colors</div>
185- <div class="color-row">
186- {#each classicColors as color}
187- <button
188- class="color-swatch classic-swatch"
189- style="--swatch-color: {color}; background-color: {color} !important;"
190- onclick={() => selectColor(color)}
191- title={color}
192- ></button>
193- {/each}
194- </div>
195- </div>
196-
197- <!-- 256-color palette -->
198- <div class="color-section">
199- <div class="section-title">Full Palette</div>
200- <div class="color-grid">
201- {#each colors256 as color}
202- <button
203- class="color-swatch"
204- style="--swatch-color: {color}; background-color: {color} !important;"
205- onclick={() => selectColor(color)}
206- title={color}
207- ></button>
208- {/each}
209- </div>
210- </div>
211-
212- <!-- Close button -->
213- <div class="picker-footer">
214- <button onclick={closePicker}>Close</button>
215- </div>
216- </div>
217- {/if}
125+ <!-- Color picker button -->
126+ <button
127+ onclick={(e) => {
128+ e.stopPropagation();
129+ showPicker = !showPicker;
130+ }}
131+ title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}
132+ style="width: 20px; height: 20px; padding: 0;"
133+ >
134+ <div
135+ class="color-indicator"
136+ style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;"
137+ ></div>
138+ </button>
139+
140+ <!-- Color picker dropdown -->
141+ {#if showPicker}
142+ <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}>
143+ <!-- Gradient controls -->
144+ <div class="gradient-controls">
145+ <div class="gradient-slots">
146+ {#each gradientColors as color, index (index)}
147+ <button
148+ class="gradient-slot"
149+ class:active={activeGradientSlot === index}
150+ style="background-color: {color} !important;"
151+ onclick={() => setActiveSlot(index)}
152+ title="Color {index + 1} - Click to select this color slot"
153+ >
154+ {#if gradientColors.length > 1}
155+ <span
156+ class="remove-slot"
157+ onclick={(e) => {
158+ e.stopPropagation();
159+ removeGradientColor(index);
160+ }}
161+ title="Remove this color"
162+ role="button"
163+ tabindex="0">×</span
164+ >
165+ {/if}
166+ </button>
167+ {/each}
168+
169+ {#if gradientColors.length < 3}
170+ <button
171+ class="add-slot"
172+ onclick={addGradientColor}
173+ title="Add another color to create gradient">+</button
174+ >
175+ {/if}
176+ </div>
177+
178+ {#if gradientColors.length > 1}
179+ <div
180+ class="gradient-preview"
181+ style="background: linear-gradient(90deg, {gradientColors.join(', ')});"
182+ ></div>
183+ {/if}
184+ </div>
185+
186+ <!-- Classic colors (first row) -->
187+ <div class="color-section">
188+ <div class="section-title">Classic Colors</div>
189+ <div class="color-row">
190+ {#each classicColors as color (color)}
191+ <button
192+ class="color-swatch classic-swatch"
193+ style="--swatch-color: {color}; background-color: {color} !important;"
194+ onclick={() => selectColor(color)}
195+ title={color}
196+ ></button>
197+ {/each}
198+ </div>
199+ </div>
200+
201+ <!-- 256-color palette -->
202+ <div class="color-section">
203+ <div class="section-title">Full Palette</div>
204+ <div class="color-grid">
205+ {#each colors256 as color (color)}
206+ <button
207+ class="color-swatch"
208+ style="--swatch-color: {color}; background-color: {color} !important;"
209+ onclick={() => selectColor(color)}
210+ title={color}
211+ ></button>
212+ {/each}
213+ </div>
214+ </div>
215+
216+ <!-- Close button -->
217+ <div class="picker-footer">
218+ <button onclick={closePicker}>Close</button>
219+ </div>
220+ </div>
221+ {/if}
218222 </div>
219223
220224 <style>
221- .color-picker-container {
222- position: relative;
223- display: inline-block;
224- }
225-
226- .picker-dropdown {
227- position: absolute;
228- top: 100%;
229- right: 0; /* Changed from left: 0 to prevent cutoff */
230- z-index: 1000;
231- padding: 4px;
232- margin-top: 2px;
233- background: #ece9d8; /* Authentic XP dialog background */
234- width: 420px;
235- max-height: 380px;
236- overflow-y: auto;
237- border: 2px outset #c0c0c0;
238- box-shadow: 2px 2px 4px rgba(0,0,0,0.3);
239- box-sizing: border-box;
240- }
241-
242- /* Responsive positioning for mobile/small screens */
243- @media (max-width: 768px) {
244- .picker-dropdown {
245- position: fixed;
246- top: 50%;
247- left: 50%;
248- transform: translate(-50%, -50%);
249- right: auto;
250- width: 90vw;
251- max-width: 420px;
252- max-height: 85vh;
253- }
254- }
255-
256- .gradient-controls {
257- margin-bottom: 6px;
258- padding: 4px;
259- border: 1px inset #dfdfdf;
260- background: #ece9d8;
261- }
262-
263- .gradient-slots {
264- display: flex;
265- gap: 4px;
266- margin-bottom: 4px;
267- align-items: center;
268- flex-wrap: wrap;
269- }
270-
271- .gradient-slot {
272- width: 24px;
273- height: 24px;
274- border: 2px outset #c0c0c0 !important;
275- cursor: pointer;
276- position: relative;
277- padding: 0 !important;
278- margin: 0 !important;
279- background-image: none !important;
280- min-width: 24px !important;
281- min-height: 24px !important;
282- max-width: 24px !important;
283- max-height: 24px !important;
284- box-sizing: border-box !important;
285- flex-shrink: 0;
286- transition: all 0.2s ease;
287- }
288-
289- .gradient-slot.active {
290- border: 2px inset #c0c0c0 !important;
291- box-shadow: inset 0 0 0 1px #000 !important;
292- }
293-
294- .gradient-slot:hover {
295- transform: scale(1.1);
296- }
297-
298- .gradient-slot .remove-slot {
299- position: absolute;
300- top: -4px;
301- right: -4px;
302- width: 12px !important;
303- height: 12px !important;
304- min-width: 12px !important;
305- min-height: 12px !important;
306- max-width: 12px !important;
307- max-height: 12px !important;
308- background-color: #808080 !important;
309- color: white !important;
310- border: 1px solid #000 !important;
311- font-size: 9px !important;
312- line-height: 1 !important;
313- cursor: pointer !important;
314- display: inline-flex !important;
315- align-items: center !important;
316- justify-content: center !important;
317- border-radius: 0 !important;
318- padding: 0 !important;
319- margin: 0 !important;
320- font-weight: normal !important;
321- font-family: 'MS Sans Serif', sans-serif !important;
322- z-index: 10;
323- opacity: 0;
324- transition: opacity 0.15s ease;
325- text-align: center !important;
326- box-sizing: border-box !important;
327- user-select: none;
328- }
329-
330- .gradient-slot:hover .remove-slot {
331- opacity: 0.8;
332- }
333-
334- .gradient-slot .remove-slot:hover {
335- background-color: #000 !important;
336- opacity: 1;
337- }
338-
339- .add-slot {
340- width: 24px;
341- height: 24px;
342- border: 2px dashed #666 !important;
343- background: #f0f0f0 !important;
344- cursor: pointer;
345- font-weight: bold;
346- padding: 0 !important;
347- margin: 0 !important;
348- min-width: 24px !important;
349- min-height: 24px !important;
350- max-width: 24px !important;
351- max-height: 24px !important;
352- background-image: none !important;
353- box-sizing: border-box !important;
354- flex-shrink: 0;
355- display: flex !important;
356- align-items: center !important;
357- justify-content: center !important;
358- font-size: 14px !important;
359- color: #666 !important;
360- transition: all 0.2s ease;
361- }
362-
363- .add-slot:hover {
364- background: #e0e0e0 !important;
365- border-color: #333 !important;
366- transform: scale(1.05);
367- }
368-
369- .gradient-preview {
370- height: 12px;
371- border: 1px inset #dfdfdf;
372- }
373-
374- .color-section {
375- margin-bottom: 6px;
376- }
377-
378- .section-title {
379- font-size: 10px;
380- font-weight: bold;
381- margin-bottom: 2px;
382- color: #000;
383- }
384-
385- .color-row {
386- display: flex;
387- flex-wrap: wrap;
388- gap: 2px;
389- margin-bottom: 4px;
390- padding: 3px;
391- background: #ece9d8;
392- border: 1px inset #dfdfdf;
393- }
394-
395- .classic-swatch {
396- width: 18px !important;
397- height: 18px !important;
398- min-width: 18px !important;
399- min-height: 18px !important;
400- }
401-
402- .color-grid {
403- display: grid;
404- grid-template-columns: repeat(16, 1fr);
405- gap: 2px;
406- width: 100%;
407- padding: 3px;
408- background: #ece9d8;
409- border: 1px inset #dfdfdf;
410- box-sizing: border-box;
411- }
412-
413- .color-swatch {
414- width: 18px !important;
415- height: 18px !important;
416- min-width: 18px !important;
417- min-height: 18px !important;
418- border: 1px solid #808080 !important; /* Override xp.css */
419- cursor: pointer;
420- padding: 0 !important;
421- margin: 0 !important;
422- box-sizing: border-box !important;
423- outline: none !important;
424- /* Remove any xp.css button styling */
425- background-image: none !important;
426- background-color: var(--swatch-color, #000000) !important;
427- font-size: 0 !important; /* Hide any text */
428- line-height: 0 !important;
429- }
430-
431- .color-swatch:hover {
432- border: 2px solid #000000 !important;
433- transform: scale(1.1) !important;
434- z-index: 5;
435- position: relative;
436- }
437-
438- .color-swatch:active {
439- border: 2px solid #ffffff !important;
440- transform: scale(0.95) !important;
441- }
442-
443- .picker-footer {
444- text-align: center;
445- padding-top: 4px;
446- border-top: 1px solid #808080;
447- margin-top: 4px;
448- }
449-
450- .color-indicator {
451- border-radius: 1px;
452- }
453-</style>⏎̸
225+ .color-picker-container {
226+ position: relative;
227+ display: inline-block;
228+ }
229+
230+ .picker-dropdown {
231+ position: absolute;
232+ top: 100%;
233+ right: 0; /* Changed from left: 0 to prevent cutoff */
234+ z-index: 1000;
235+ padding: 4px;
236+ margin-top: 2px;
237+ background: #ece9d8; /* Authentic XP dialog background */
238+ width: 420px;
239+ max-height: 380px;
240+ overflow-y: auto;
241+ border: 2px outset #c0c0c0;
242+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
243+ box-sizing: border-box;
244+ }
245+
246+ /* Responsive positioning for mobile/small screens */
247+ @media (max-width: 768px) {
248+ .picker-dropdown {
249+ position: fixed;
250+ top: 50%;
251+ left: 50%;
252+ transform: translate(-50%, -50%);
253+ right: auto;
254+ width: 90vw;
255+ max-width: 420px;
256+ max-height: 85vh;
257+ }
258+ }
259+
260+ .gradient-controls {
261+ margin-bottom: 6px;
262+ padding: 4px;
263+ border: 1px inset #dfdfdf;
264+ background: #ece9d8;
265+ }
266+
267+ .gradient-slots {
268+ display: flex;
269+ gap: 4px;
270+ margin-bottom: 4px;
271+ align-items: center;
272+ flex-wrap: wrap;
273+ }
274+
275+ .gradient-slot {
276+ width: 24px;
277+ height: 24px;
278+ border: 2px outset #c0c0c0 !important;
279+ cursor: pointer;
280+ position: relative;
281+ padding: 0 !important;
282+ margin: 0 !important;
283+ background-image: none !important;
284+ min-width: 24px !important;
285+ min-height: 24px !important;
286+ max-width: 24px !important;
287+ max-height: 24px !important;
288+ box-sizing: border-box !important;
289+ flex-shrink: 0;
290+ transition: all 0.2s ease;
291+ }
292+
293+ .gradient-slot.active {
294+ border: 2px inset #c0c0c0 !important;
295+ box-shadow: inset 0 0 0 1px #000 !important;
296+ }
297+
298+ .gradient-slot:hover {
299+ transform: scale(1.1);
300+ }
301+
302+ .gradient-slot .remove-slot {
303+ position: absolute;
304+ top: -4px;
305+ right: -4px;
306+ width: 12px !important;
307+ height: 12px !important;
308+ min-width: 12px !important;
309+ min-height: 12px !important;
310+ max-width: 12px !important;
311+ max-height: 12px !important;
312+ background-color: #808080 !important;
313+ color: white !important;
314+ border: 1px solid #000 !important;
315+ font-size: 9px !important;
316+ line-height: 1 !important;
317+ cursor: pointer !important;
318+ display: inline-flex !important;
319+ align-items: center !important;
320+ justify-content: center !important;
321+ border-radius: 0 !important;
322+ padding: 0 !important;
323+ margin: 0 !important;
324+ font-weight: normal !important;
325+ font-family: 'MS Sans Serif', sans-serif !important;
326+ z-index: 10;
327+ opacity: 0;
328+ transition: opacity 0.15s ease;
329+ text-align: center !important;
330+ box-sizing: border-box !important;
331+ user-select: none;
332+ }
333+
334+ .gradient-slot:hover .remove-slot {
335+ opacity: 0.8;
336+ }
337+
338+ .gradient-slot .remove-slot:hover {
339+ background-color: #000 !important;
340+ opacity: 1;
341+ }
342+
343+ .add-slot {
344+ width: 24px;
345+ height: 24px;
346+ border: 2px dashed #666 !important;
347+ background: #f0f0f0 !important;
348+ cursor: pointer;
349+ font-weight: bold;
350+ padding: 0 !important;
351+ margin: 0 !important;
352+ min-width: 24px !important;
353+ min-height: 24px !important;
354+ max-width: 24px !important;
355+ max-height: 24px !important;
356+ background-image: none !important;
357+ box-sizing: border-box !important;
358+ flex-shrink: 0;
359+ display: flex !important;
360+ align-items: center !important;
361+ justify-content: center !important;
362+ font-size: 14px !important;
363+ color: #666 !important;
364+ transition: all 0.2s ease;
365+ }
366+
367+ .add-slot:hover {
368+ background: #e0e0e0 !important;
369+ border-color: #333 !important;
370+ transform: scale(1.05);
371+ }
372+
373+ .gradient-preview {
374+ height: 12px;
375+ border: 1px inset #dfdfdf;
376+ }
377+
378+ .color-section {
379+ margin-bottom: 6px;
380+ }
381+
382+ .section-title {
383+ font-size: 10px;
384+ font-weight: bold;
385+ margin-bottom: 2px;
386+ color: #000;
387+ }
388+
389+ .color-row {
390+ display: flex;
391+ flex-wrap: wrap;
392+ gap: 2px;
393+ margin-bottom: 4px;
394+ padding: 3px;
395+ background: #ece9d8;
396+ border: 1px inset #dfdfdf;
397+ }
398+
399+ .classic-swatch {
400+ width: 18px !important;
401+ height: 18px !important;
402+ min-width: 18px !important;
403+ min-height: 18px !important;
404+ }
405+
406+ .color-grid {
407+ display: grid;
408+ grid-template-columns: repeat(16, 1fr);
409+ gap: 2px;
410+ width: 100%;
411+ padding: 3px;
412+ background: #ece9d8;
413+ border: 1px inset #dfdfdf;
414+ box-sizing: border-box;
415+ }
416+
417+ .color-swatch {
418+ width: 18px !important;
419+ height: 18px !important;
420+ min-width: 18px !important;
421+ min-height: 18px !important;
422+ border: 1px solid #808080 !important; /* Override xp.css */
423+ cursor: pointer;
424+ padding: 0 !important;
425+ margin: 0 !important;
426+ box-sizing: border-box !important;
427+ outline: none !important;
428+ /* Remove any xp.css button styling */
429+ background-image: none !important;
430+ background-color: var(--swatch-color, #000000) !important;
431+ font-size: 0 !important; /* Hide any text */
432+ line-height: 0 !important;
433+ }
434+
435+ .color-swatch:hover {
436+ border: 2px solid #000000 !important;
437+ transform: scale(1.1) !important;
438+ z-index: 5;
439+ position: relative;
440+ }
441+
442+ .color-swatch:active {
443+ border: 2px solid #ffffff !important;
444+ transform: scale(0.95) !important;
445+ }
446+
447+ .picker-footer {
448+ text-align: center;
449+ padding-top: 4px;
450+ border-top: 1px solid #808080;
451+ margin-top: 4px;
452+ }
453+
454+ .color-indicator {
455+ border-radius: 1px;
456+ }
457+</style>
M src/lib/components/desktop-icons.svelte +80 -79
@@ -1,94 +1,95 @@
11 <script lang="ts">
2- let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();
2+ let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();
33
4- function handleKeydown(event: KeyboardEvent, action: () => void) {
5- if (event.key === 'Enter' || event.key === ' ') {
6- event.preventDefault();
7- action();
8- }
9- }
4+ function handleKeydown(event: KeyboardEvent, action: () => void) {
5+ if (event.key === 'Enter' || event.key === ' ') {
6+ event.preventDefault();
7+ action();
8+ }
9+ }
1010
11- function handleChatRoomClick() {
12- showChatRoom = true
13- }
11+ function handleChatRoomClick() {
12+ showChatRoom = true;
13+ }
1414
15- function handleAuthClick() {
16- showAuth = true;
17- }
15+ function handleAuthClick() {
16+ showAuth = true;
17+ }
1818 </script>
1919
2020 <div class="desktop-icons">
21- <div
22- class="desktop-icon"
23- ondblclick={handleChatRoomClick}
24- onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}
25- role="button"
26- tabindex="0"
27- >
28- <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" />
29- <span class="icon-label">Pdr Aim</span>
30- </div>
31-
32- <div
33- class="desktop-icon"
34- ondblclick={handleAuthClick}
35- onkeydown={(e) => handleKeydown(e, handleAuthClick)}
36- role="button"
37- tabindex="0"
38- >
39- <img src="/desktop/application-x-shellscript.png" alt="Connexion" />
40- <span class="icon-label">Connexion</span>
41- </div>
21+ <div
22+ class="desktop-icon"
23+ ondblclick={handleChatRoomClick}
24+ onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}
25+ role="button"
26+ tabindex="0"
27+ >
28+ <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" />
29+ <span class="icon-label">Pdr Aim</span>
30+ </div>
31+
32+ <div
33+ class="desktop-icon"
34+ ondblclick={handleAuthClick}
35+ onkeydown={(e) => handleKeydown(e, handleAuthClick)}
36+ role="button"
37+ tabindex="0"
38+ >
39+ <img src="/desktop/application-x-shellscript.png" alt="Connexion" />
40+ <span class="icon-label">Connexion</span>
41+ </div>
4242 </div>
4343
4444 <style>
45- .desktop-icons {
46- position: fixed;
47- top: 20px;
48- left: 20px;
49- display: flex;
50- flex-direction: column;
51- gap: 20px;
52- }
45+ .desktop-icons {
46+ position: fixed;
47+ top: 20px;
48+ left: 20px;
49+ display: flex;
50+ flex-direction: column;
51+ gap: 20px;
52+ }
5353
54- .desktop-icon {
55- display: flex;
56- flex-direction: column;
57- align-items: center;
58- cursor: pointer;
59- width: 75px;
60- text-align: center;
61- padding: 8px;
62- border: 1px dotted transparent;
63- border-radius: 2px;
64- margin: 2px;
65- }
54+ .desktop-icon {
55+ display: flex;
56+ flex-direction: column;
57+ align-items: center;
58+ cursor: pointer;
59+ width: 75px;
60+ text-align: center;
61+ padding: 8px;
62+ border: 1px dotted transparent;
63+ border-radius: 2px;
64+ margin: 2px;
65+ }
6666
67- .desktop-icon:hover, .desktop-icon:focus {
68- background: rgba(49, 106, 197, 0.2);
69- border: 1px dotted rgba(255, 255, 255, 0.8);
70- outline: 1px solid rgba(49, 106, 197, 0.6);
71- outline-offset: -1px;
72- }
67+ .desktop-icon:hover,
68+ .desktop-icon:focus {
69+ background: rgba(49, 106, 197, 0.2);
70+ border: 1px dotted rgba(255, 255, 255, 0.8);
71+ outline: 1px solid rgba(49, 106, 197, 0.6);
72+ outline-offset: -1px;
73+ }
7374
74- .desktop-icon:active {
75- background: rgba(49, 106, 197, 0.4);
76- }
75+ .desktop-icon:active {
76+ background: rgba(49, 106, 197, 0.4);
77+ }
7778
78- .desktop-icon img {
79- width: 48px;
80- height: 48px;
81- margin-bottom: 5px;
82- pointer-events: none; /* Prevents image from interfering with hover */
83- }
79+ .desktop-icon img {
80+ width: 48px;
81+ height: 48px;
82+ margin-bottom: 5px;
83+ pointer-events: none; /* Prevents image from interfering with hover */
84+ }
8485
85- .icon-label {
86- color: white;
87- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);
88- font-size: 12px;
89- user-select: none;
90- pointer-events: none; /* Prevents text from interfering with hover */
91- max-width: 100%;
92- overflow-wrap: break-word;
93- }
94-</style> ⏎̸
86+ .icon-label {
87+ color: white;
88+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);
89+ font-size: 12px;
90+ user-select: none;
91+ pointer-events: none; /* Prevents text from interfering with hover */
92+ max-width: 100%;
93+ overflow-wrap: break-word;
94+ }
95+</style>
M src/lib/components/formatted-message.svelte +252 -240
@@ -1,249 +1,261 @@
11 <script lang="ts">
2-import { onMount } from 'svelte';
3-import type { EnrichedMessage } from '../types/chat';
4-import type { TextStyle } from '../types/text-formatting';
5-import { formatText, createGradientText } from '../utils/text-formatter';
6-import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting';
7-
8-// Props
9-let {
10- message,
11- allowFormatting = true
12-} = $props<{
13- message: EnrichedMessage;
14- allowFormatting?: boolean;
15-}>();
16-
17-// State
18-let formattedContent = $state('');
19-let isProcessing = $state(false);
20-let hasError = $state(false);
21-
22-// Process message formatting
23-async function processMessage() {
24- if (!allowFormatting || !message.hasFormatting) {
25- formattedContent = escapeHtml(message.content);
26- return;
27- }
28-
29- isProcessing = true;
30- hasError = false;
31-
32- try {
33- // Parse style data if available
34- let style: TextStyle = DEFAULT_TEXT_STYLE;
35- if (message.styleData) {
36- try {
37- style = JSON.parse(message.styleData);
38- console.debug('Parsed message style:', style, 'for message:', message.content);
39- } catch (parseError) {
40- console.warn('Failed to parse message style data:', parseError);
41- }
42- }
43-
44- // Check if this is a gradient message
45- if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
46- // Use character-by-character gradient for better compatibility
47- console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content);
48- formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style);
49- console.debug('Generated gradient HTML:', formattedContent);
50- return;
51- }
52-
53- // Regular formatted text (no gradient)
54- const styleWithoutGradient = { ...style, gradient: undefined };
55- formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);
56-
57- } catch (error) {
58- console.error('Message formatting error:', error);
59- hasError = true;
60- formattedContent = escapeHtml(message.content);
61- } finally {
62- isProcessing = false;
63- }
64-}
65-
66-// Process message when it changes
67-$effect(() => {
68- processMessage();
69-});
70-
71-// Utility function to escape HTML
72-function escapeHtml(text: string): string {
73- const div = document.createElement('div');
74- div.textContent = text;
75- return div.innerHTML;
76-}
77-
78-// Generate inline styles for the message
79-function getMessageStyles(): string {
80- if (!message.hasFormatting || !message.styleData) {
81- return '';
82- }
83-
84- try {
85- const style: TextStyle = JSON.parse(message.styleData);
86- // For gradient messages, we still need some styles but not font-family (use class instead)
87- if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
88- // Return only non-color and non-font-family styles for gradient messages
89- const styles: string[] = [];
90- styles.push(`font-size: ${style.fontSize}px`);
91- if (style.bold) styles.push(`font-weight: bold`);
92- if (style.italic) styles.push(`font-style: italic`);
93- const decorations: string[] = [];
94- if (style.underline) decorations.push('underline');
95- if (style.strikethrough) decorations.push('line-through');
96- if (decorations.length > 0) {
97- styles.push(`text-decoration: ${decorations.join(' ')}`);
98- }
99- return styles.join('; ');
100- }
101- // For non-gradient messages, exclude font-family (use class instead)
102- const styles: string[] = [];
103- styles.push(`font-size: ${style.fontSize}px`);
104- if (style.color) styles.push(`color: ${style.color}`);
105- if (style.bold) styles.push(`font-weight: bold`);
106- if (style.italic) styles.push(`font-style: italic`);
107- const decorations: string[] = [];
108- if (style.underline) decorations.push('underline');
109- if (style.strikethrough) decorations.push('line-through');
110- if (decorations.length > 0) {
111- styles.push(`text-decoration: ${decorations.join(' ')}`);
112- }
113- return styles.join('; ');
114- } catch (error) {
115- console.warn('Failed to generate message styles:', error);
116- return '';
117- }
118-}
119-
120-// Get CSS classes for the message
121-function getMessageClasses(): string {
122- const classes = ['formatted-message'];
123-
124- if (!message.hasFormatting || !message.styleData) {
125- return classes.join(' ');
126- }
127-
128- try {
129- const style: TextStyle = JSON.parse(message.styleData);
130-
131- // Add font family class
132- classes.push(`retro-font-${style.fontFamily}`);
133-
134- // Add style classes
135- if (style.bold) classes.push('style-bold');
136- if (style.italic) classes.push('style-italic');
137- if (style.underline) classes.push('style-underline');
138- if (style.strikethrough) classes.push('style-strikethrough');
139-
140- // Add gradient class if applicable
141- if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
142- classes.push('gradient-text-static');
143- }
144-
145- } catch (error) {
146- console.warn('Failed to parse message style for classes:', error);
147- }
148-
149- return classes.join(' ');
150-}
2+ import type { EnrichedMessage } from '../types/chat';
3+ import type { TextStyle } from '../types/text-formatting';
4+ import { formatText, createGradientText } from '../utils/text-formatter';
5+ import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6+
7+ // Props
8+ let { message, allowFormatting = true } = $props<{
9+ message: EnrichedMessage;
10+ allowFormatting?: boolean;
11+ }>();
12+
13+ // State
14+ let formattedContent = $state('');
15+ let isProcessing = $state(false);
16+ let hasError = $state(false);
17+
18+ // Process message formatting
19+ async function processMessage() {
20+ if (!allowFormatting || !message.hasFormatting) {
21+ formattedContent = escapeHtml(message.content);
22+ return;
23+ }
24+
25+ isProcessing = true;
26+ hasError = false;
27+
28+ try {
29+ // Parse style data if available
30+ let style: TextStyle = DEFAULT_TEXT_STYLE;
31+ if (message.styleData) {
32+ try {
33+ style = JSON.parse(message.styleData);
34+ console.debug('Parsed message style:', style, 'for message:', message.content);
35+ } catch (parseError) {
36+ console.warn('Failed to parse message style data:', parseError);
37+ }
38+ }
39+
40+ // Check if this is a gradient message
41+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
42+ // Use character-by-character gradient for better compatibility
43+ console.debug(
44+ 'Creating gradient text with colors:',
45+ style.gradient,
46+ 'for text:',
47+ message.content
48+ );
49+ formattedContent = createGradientText(
50+ message.content,
51+ style.gradient,
52+ 'gradient-text-static',
53+ style
54+ );
55+ console.debug('Generated gradient HTML:', formattedContent);
56+ return;
57+ }
58+
59+ // Regular formatted text (no gradient)
60+ const styleWithoutGradient = { ...style, gradient: undefined };
61+ formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);
62+ } catch (error) {
63+ console.error('Message formatting error:', error);
64+ hasError = true;
65+ formattedContent = escapeHtml(message.content);
66+ } finally {
67+ isProcessing = false;
68+ }
69+ }
70+
71+ // Process message when it changes
72+ $effect(() => {
73+ processMessage();
74+ });
75+
76+ // Utility function to escape HTML
77+ function escapeHtml(text: string): string {
78+ const div = document.createElement('div');
79+ div.textContent = text;
80+ return div.innerHTML;
81+ }
82+
83+ // Generate inline styles for the message
84+ function getMessageStyles(): string {
85+ if (!message.hasFormatting || !message.styleData) {
86+ return '';
87+ }
88+
89+ try {
90+ const style: TextStyle = JSON.parse(message.styleData);
91+ // For gradient messages, we still need some styles but not font-family (use class instead)
92+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
93+ // Return only non-color and non-font-family styles for gradient messages
94+ const styles: string[] = [];
95+ styles.push(`font-size: ${style.fontSize}px`);
96+ if (style.bold) styles.push(`font-weight: bold`);
97+ if (style.italic) styles.push(`font-style: italic`);
98+ const decorations: string[] = [];
99+ if (style.underline) decorations.push('underline');
100+ if (style.strikethrough) decorations.push('line-through');
101+ if (decorations.length > 0) {
102+ styles.push(`text-decoration: ${decorations.join(' ')}`);
103+ }
104+ return styles.join('; ');
105+ }
106+ // For non-gradient messages, exclude font-family (use class instead)
107+ const styles: string[] = [];
108+ styles.push(`font-size: ${style.fontSize}px`);
109+ if (style.color) styles.push(`color: ${style.color}`);
110+ if (style.bold) styles.push(`font-weight: bold`);
111+ if (style.italic) styles.push(`font-style: italic`);
112+ const decorations: string[] = [];
113+ if (style.underline) decorations.push('underline');
114+ if (style.strikethrough) decorations.push('line-through');
115+ if (decorations.length > 0) {
116+ styles.push(`text-decoration: ${decorations.join(' ')}`);
117+ }
118+ return styles.join('; ');
119+ } catch (error) {
120+ console.warn('Failed to generate message styles:', error);
121+ return '';
122+ }
123+ }
124+
125+ // Get CSS classes for the message
126+ function getMessageClasses(): string {
127+ const classes = ['formatted-message'];
128+
129+ if (!message.hasFormatting || !message.styleData) {
130+ return classes.join(' ');
131+ }
132+
133+ try {
134+ const style: TextStyle = JSON.parse(message.styleData);
135+
136+ // Add font family class
137+ classes.push(`retro-font-${style.fontFamily}`);
138+
139+ // Add style classes
140+ if (style.bold) classes.push('style-bold');
141+ if (style.italic) classes.push('style-italic');
142+ if (style.underline) classes.push('style-underline');
143+ if (style.strikethrough) classes.push('style-strikethrough');
144+
145+ // Add gradient class if applicable
146+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
147+ classes.push('gradient-text-static');
148+ }
149+ } catch (error) {
150+ console.warn('Failed to parse message style for classes:', error);
151+ }
152+
153+ return classes.join(' ');
154+ }
151155 </script>
152156
153157 {#if isProcessing}
154- <span class={getMessageClasses()} style={getMessageStyles()}>
155- <span class="processing">...</span>
156- </span>
158+ <span class={getMessageClasses()} style={getMessageStyles()}>
159+ <span class="processing">...</span>
160+ </span>
157161 {:else if hasError}
158- <span class={getMessageClasses()} style={getMessageStyles()}>
159- <span class="error" title="Formatting error - showing plain text">{message.content}</span>
160- </span>
162+ <span class={getMessageClasses()} style={getMessageStyles()}>
163+ <span class="error" title="Formatting error - showing plain text">{message.content}</span>
164+ </span>
161165 {:else}
162- <!-- For gradient messages, render without wrapper to avoid conflicts -->
163- {#if message.hasFormatting && message.styleData}
164- {@const parsedStyle = (() => {
165- try {
166- return JSON.parse(message.styleData);
167- } catch {
168- return null;
169- }
170- })()}
171- {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1}
172- <!-- Gradient messages are already wrapped properly -->
173- {@html formattedContent}
174- {:else}
175- <!-- Regular formatted messages -->
176- <span class={getMessageClasses()} style={getMessageStyles()}>
177- {@html formattedContent}
178- </span>
179- {/if}
180- {:else}
181- <!-- Plain messages -->
182- <span class={getMessageClasses()}>
183- {@html formattedContent}
184- </span>
185- {/if}
166+ <!-- For gradient messages, render without wrapper to avoid conflicts -->
167+ {#if message.hasFormatting && message.styleData}
168+ {@const parsedStyle = (() => {
169+ try {
170+ return JSON.parse(message.styleData);
171+ } catch {
172+ return null;
173+ }
174+ })()}
175+ {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1}
176+ <!-- Gradient messages are already wrapped properly -->
177+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by createGradientText -->
178+ {@html formattedContent}
179+ {:else}
180+ <!-- Regular formatted messages -->
181+ <span class={getMessageClasses()} style={getMessageStyles()}>
182+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by formatText -->
183+ {@html formattedContent}
184+ </span>
185+ {/if}
186+ {:else}
187+ <!-- Plain messages -->
188+ <span class={getMessageClasses()}>
189+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by escapeHtml -->
190+ {@html formattedContent}
191+ </span>
192+ {/if}
186193 {/if}
187194
188195 <style>
189- .processing {
190- color: #666;
191- font-style: italic;
192- animation: pulse 1s infinite;
193- }
194-
195- .error {
196- color: #cc0000;
197- border-bottom: 1px dotted #cc0000;
198- }
199-
200- @keyframes pulse {
201- 0%, 100% { opacity: 1; }
202- 50% { opacity: 0.5; }
203- }
204-
205- /* Ensure formatted messages don't break layout */
206- :global(.formatted-message) {
207- word-break: break-word;
208- overflow-wrap: break-word;
209- hyphens: auto;
210- }
211-
212- /* Improve gradient text readability */
213- :global(.formatted-message.gradient-text-static) {
214- font-weight: bold;
215- }
216-
217- /* Ensure gradient spans are visible and preserve their colors */
218- :global(.gradient-text-static .gradient-char) {
219- display: inline;
220- opacity: 1 !important;
221- margin: 0 !important;
222- padding: 0 !important;
223- letter-spacing: 0 !important;
224- -webkit-text-fill-color: initial !important;
225- }
226-
227- /* Don't override inline styles for gradient chars - removed to let inline styles work */
228-
229- /* Remove any blur or gray effects */
230- :global(.formatted-message) {
231- opacity: 1 !important;
232- filter: none !important;
233- }
234-
235- /* Don't inherit color for gradient text spans */
236- :global(.formatted-message:not(.gradient-text-static)) {
237- color: inherit;
238- }
239-
240- /* Ensure accessibility for screen readers */
241- :global(.formatted-message[aria-label]) {
242- position: relative;
243- }
244-
245- /* Performance optimization for complex messages */
246- :global(.formatted-message) {
247- contain: layout style;
248- }
249-</style>⏎̸
196+ .processing {
197+ color: #666;
198+ font-style: italic;
199+ animation: pulse 1s infinite;
200+ }
201+
202+ .error {
203+ color: #cc0000;
204+ border-bottom: 1px dotted #cc0000;
205+ }
206+
207+ @keyframes pulse {
208+ 0%,
209+ 100% {
210+ opacity: 1;
211+ }
212+ 50% {
213+ opacity: 0.5;
214+ }
215+ }
216+
217+ /* Ensure formatted messages don't break layout */
218+ :global(.formatted-message) {
219+ word-break: break-word;
220+ overflow-wrap: break-word;
221+ hyphens: auto;
222+ }
223+
224+ /* Improve gradient text readability */
225+ :global(.formatted-message.gradient-text-static) {
226+ font-weight: bold;
227+ }
228+
229+ /* Ensure gradient spans are visible and preserve their colors */
230+ :global(.gradient-text-static .gradient-char) {
231+ display: inline;
232+ opacity: 1 !important;
233+ margin: 0 !important;
234+ padding: 0 !important;
235+ letter-spacing: 0 !important;
236+ -webkit-text-fill-color: initial !important;
237+ }
238+
239+ /* Don't override inline styles for gradient chars - removed to let inline styles work */
240+
241+ /* Remove any blur or gray effects */
242+ :global(.formatted-message) {
243+ opacity: 1 !important;
244+ filter: none !important;
245+ }
246+
247+ /* Don't inherit color for gradient text spans */
248+ :global(.formatted-message:not(.gradient-text-static)) {
249+ color: inherit;
250+ }
251+
252+ /* Ensure accessibility for screen readers */
253+ :global(.formatted-message[aria-label]) {
254+ position: relative;
255+ }
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 @@
11 <script lang="ts">
2- import { validatePasswordStrength, type PasswordConstraints, DEFAULT_PASSWORD_CONSTRAINTS } from '../validation/password';
3-
4- interface $$Props {
5- password: string;
6- constraints?: PasswordConstraints;
7- showDetails?: boolean;
8- }
9-
10- let {
11- password = '',
12- constraints = DEFAULT_PASSWORD_CONSTRAINTS,
13- showDetails = true
14- } = $props();
15-
16- // Reactive validation result
17- let validation = $derived(validatePasswordStrength(password, constraints));
18-
19- // Get strength color and label
20- function getStrengthInfo(strength: number) {
21- if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' };
22- if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' };
23- if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' };
24- return { color: '#00aa00', label: 'Strong', class: 'strong' };
25- }
26-
27- let strengthInfo = $derived(getStrengthInfo(validation.strength));
2+ import {
3+ validatePasswordStrength,
4+ type PasswordConstraints,
5+ DEFAULT_PASSWORD_CONSTRAINTS
6+ } from '../validation/password';
7+
8+ let {
9+ password = '',
10+ constraints = DEFAULT_PASSWORD_CONSTRAINTS,
11+ showDetails = true
12+ } = $props<{
13+ password: string;
14+ constraints?: PasswordConstraints;
15+ showDetails?: boolean;
16+ }>();
17+
18+ // Reactive validation result
19+ let validation = $derived(validatePasswordStrength(password, constraints));
20+
21+ // Get strength color and label
22+ function getStrengthInfo(strength: number) {
23+ if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' };
24+ if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' };
25+ if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' };
26+ return { color: '#00aa00', label: 'Strong', class: 'strong' };
27+ }
28+
29+ let strengthInfo = $derived(getStrengthInfo(validation.strength));
2830 </script>
2931
3032 <div class="password-strength-indicator">
31- {#if password.length > 0}
32- <!-- Strength bar -->
33- <div class="strength-bar">
34- <div class="strength-bar-bg">
35- <div
36- class="strength-bar-fill {strengthInfo.class}"
37- style="width: {validation.strength}%; background-color: {strengthInfo.color};"
38- ></div>
39- </div>
40- <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span>
41- </div>
42-
43- {#if showDetails}
44- <!-- Requirements checklist -->
45- <div class="requirements-list">
46- {#each validation.requirements as requirement}
47- <div class="requirement {requirement.met ? 'met' : 'unmet'}">
48- <span class="requirement-icon">
49- {requirement.met ? '✓' : '✗'}
50- </span>
51- <span class="requirement-text">{requirement.message}</span>
52- </div>
53- {/each}
54- </div>
55-
56- <!-- Error messages -->
57- {#if validation.errors.length > 0}
58- <div class="error-messages">
59- {#each validation.errors as error}
60- <div class="error-message">{error}</div>
61- {/each}
62- </div>
63- {/if}
64- {/if}
65- {/if}
33+ {#if password.length > 0}
34+ <!-- Strength bar -->
35+ <div class="strength-bar">
36+ <div class="strength-bar-bg">
37+ <div
38+ class="strength-bar-fill {strengthInfo.class}"
39+ style="width: {validation.strength}%; background-color: {strengthInfo.color};"
40+ ></div>
41+ </div>
42+ <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span>
43+ </div>
44+
45+ {#if showDetails}
46+ <!-- Requirements checklist -->
47+ <div class="requirements-list">
48+ {#each validation.requirements as requirement (requirement.message)}
49+ <div class="requirement {requirement.met ? 'met' : 'unmet'}">
50+ <span class="requirement-icon">
51+ {requirement.met ? '✓' : '✗'}
52+ </span>
53+ <span class="requirement-text">{requirement.message}</span>
54+ </div>
55+ {/each}
56+ </div>
57+
58+ <!-- Error messages -->
59+ {#if validation.errors.length > 0}
60+ <div class="error-messages">
61+ {#each validation.errors as error (error)}
62+ <div class="error-message">{error}</div>
63+ {/each}
64+ </div>
65+ {/if}
66+ {/if}
67+ {/if}
6668 </div>
6769
6870 <style>
69- .password-strength-indicator {
70- margin-top: 8px;
71- font-size: 0.875rem;
72- }
73-
74- .strength-bar {
75- display: flex;
76- align-items: center;
77- gap: 8px;
78- margin-bottom: 8px;
79- }
80-
81- .strength-bar-bg {
82- flex: 1;
83- height: 6px;
84- background-color: #ddd;
85- border-radius: 3px;
86- overflow: hidden;
87- }
88-
89- .strength-bar-fill {
90- height: 100%;
91- transition: width 0.3s ease, background-color 0.3s ease;
92- border-radius: 3px;
93- }
94-
95- .strength-label {
96- min-width: 50px;
97- font-weight: bold;
98- font-size: 0.75rem;
99- }
100-
101- .strength-label.weak { color: #ff4444; }
102- .strength-label.fair { color: #ffaa00; }
103- .strength-label.good { color: #44aa44; }
104- .strength-label.strong { color: #00aa00; }
105-
106- .requirements-list {
107- display: flex;
108- flex-direction: column;
109- gap: 4px;
110- margin-bottom: 8px;
111- }
112-
113- .requirement {
114- display: flex;
115- align-items: center;
116- gap: 6px;
117- font-size: 0.75rem;
118- }
119-
120- .requirement-icon {
121- width: 12px;
122- text-align: center;
123- font-weight: bold;
124- }
125-
126- .requirement.met {
127- color: #00aa00;
128- }
129-
130- .requirement.met .requirement-icon {
131- color: #00aa00;
132- }
133-
134- .requirement.unmet {
135- color: #888;
136- }
137-
138- .requirement.unmet .requirement-icon {
139- color: #ff4444;
140- }
141-
142- .error-messages {
143- display: flex;
144- flex-direction: column;
145- gap: 4px;
146- }
147-
148- .error-message {
149- color: #ff4444;
150- font-size: 0.75rem;
151- }
152-
153- /* Compact mode styles */
154- :global(.compact) .password-strength-indicator {
155- font-size: 0.75rem;
156- }
157-
158- :global(.compact) .requirements-list {
159- display: none;
160- }
161-
162- :global(.compact) .strength-bar {
163- margin-bottom: 4px;
164- }
165-</style>⏎̸
71+ .password-strength-indicator {
72+ margin-top: 8px;
73+ font-size: 0.875rem;
74+ }
75+
76+ .strength-bar {
77+ display: flex;
78+ align-items: center;
79+ gap: 8px;
80+ margin-bottom: 8px;
81+ }
82+
83+ .strength-bar-bg {
84+ flex: 1;
85+ height: 6px;
86+ background-color: #ddd;
87+ border-radius: 3px;
88+ overflow: hidden;
89+ }
90+
91+ .strength-bar-fill {
92+ height: 100%;
93+ transition:
94+ width 0.3s ease,
95+ background-color 0.3s ease;
96+ border-radius: 3px;
97+ }
98+
99+ .strength-label {
100+ min-width: 50px;
101+ font-weight: bold;
102+ font-size: 0.75rem;
103+ }
104+
105+ .strength-label.weak {
106+ color: #ff4444;
107+ }
108+ .strength-label.fair {
109+ color: #ffaa00;
110+ }
111+ .strength-label.good {
112+ color: #44aa44;
113+ }
114+ .strength-label.strong {
115+ color: #00aa00;
116+ }
117+
118+ .requirements-list {
119+ display: flex;
120+ flex-direction: column;
121+ gap: 4px;
122+ margin-bottom: 8px;
123+ }
124+
125+ .requirement {
126+ display: flex;
127+ align-items: center;
128+ gap: 6px;
129+ font-size: 0.75rem;
130+ }
131+
132+ .requirement-icon {
133+ width: 12px;
134+ text-align: center;
135+ font-weight: bold;
136+ }
137+
138+ .requirement.met {
139+ color: #00aa00;
140+ }
141+
142+ .requirement.met .requirement-icon {
143+ color: #00aa00;
144+ }
145+
146+ .requirement.unmet {
147+ color: #888;
148+ }
149+
150+ .requirement.unmet .requirement-icon {
151+ color: #ff4444;
152+ }
153+
154+ .error-messages {
155+ display: flex;
156+ flex-direction: column;
157+ gap: 4px;
158+ }
159+
160+ .error-message {
161+ color: #ff4444;
162+ font-size: 0.75rem;
163+ }
164+
165+ /* Compact mode styles */
166+ :global(.compact) .password-strength-indicator {
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 @@
11 <script lang="ts">
2-import {
3- RETRO_FONTS,
4- CLASSIC_COLORS,
5- GRADIENT_PRESETS,
6- DEFAULT_TEXT_STYLE,
7- type TextStyle,
8- type FontFamily
9-} from '../types/text-formatting';
10-import ColorPicker256 from './color-picker-256.svelte';
11-
12-// Props
13-let {
14- style = $bindable(DEFAULT_TEXT_STYLE),
15- onPreviewText,
16- compact = true,
17- showFontSelector = false,
18- showGradients = true
19-} = $props<{
20- style?: TextStyle;
21- onPreviewText?: (text: string) => void;
22- compact?: boolean;
23- showFontSelector?: boolean;
24- showGradients?: boolean;
25-}>();
26-
27-// State
28-let currentColor = $state(style.color || '#000000');
29-let currentGradient = $state(style.gradient);
30-
31-// Toggle functions
32-function toggleBold() {
33- style = { ...style, bold: !style.bold };
34-}
35-
36-function toggleItalic() {
37- style = { ...style, italic: !style.italic };
38-}
39-
40-function toggleUnderline() {
41- style = { ...style, underline: !style.underline };
42-}
43-
44-function handleColorChange(color: string) {
45- currentColor = color;
46- style = { ...style, color, gradient: undefined };
47-}
48-
49-function handleGradientChange(gradient: string[]) {
50- currentGradient = gradient;
51- style = { ...style, gradient, color: gradient[0] };
52-}
53-
54-function selectFont(fontFamily: FontFamily) {
55- style = { ...style, fontFamily };
56-}
57-
58-// Keyboard shortcuts handler
59-function handleKeydown(event: KeyboardEvent) {
60- if (event.ctrlKey || event.metaKey) {
61- switch (event.key.toLowerCase()) {
62- case 'b':
63- event.preventDefault();
64- toggleBold();
65- break;
66- case 'i':
67- event.preventDefault();
68- toggleItalic();
69- break;
70- case 'u':
71- event.preventDefault();
72- toggleUnderline();
73- break;
74- }
75- }
76-}
77-
78-// Preview text change
79-function previewTextChanged() {
80- if (onPreviewText) {
81- onPreviewText('Sample formatted text');
82- }
83-}
84-
85-// Update internal state when style prop changes
86-$effect(() => {
87- currentColor = style.color || '#000000';
88- currentGradient = style.gradient;
89-});
2+ import {
3+ RETRO_FONTS,
4+ DEFAULT_TEXT_STYLE,
5+ type TextStyle,
6+ type FontFamily
7+ } from '../types/text-formatting';
8+ import ColorPicker256 from './color-picker-256.svelte';
9+
10+ // Props
11+ let {
12+ style = $bindable(DEFAULT_TEXT_STYLE),
13+ onPreviewText: _onPreviewText,
14+ compact = true,
15+ showFontSelector = false,
16+ showGradients: _showGradients = true
17+ } = $props<{
18+ style?: TextStyle;
19+ onPreviewText?: (text: string) => void;
20+ compact?: boolean;
21+ showFontSelector?: boolean;
22+ showGradients?: boolean;
23+ }>();
24+ void _showGradients; // Reserved for future use
25+ void _onPreviewText; // Reserved for future use
26+
27+ // State
28+ let currentColor = $state(style.color || '#000000');
29+ let currentGradient = $state(style.gradient);
30+
31+ // Toggle functions
32+ function toggleBold() {
33+ style = { ...style, bold: !style.bold };
34+ }
35+
36+ function toggleItalic() {
37+ style = { ...style, italic: !style.italic };
38+ }
39+
40+ function toggleUnderline() {
41+ style = { ...style, underline: !style.underline };
42+ }
43+
44+ function handleColorChange(color: string) {
45+ currentColor = color;
46+ style = { ...style, color, gradient: undefined };
47+ }
48+
49+ function handleGradientChange(gradient: string[]) {
50+ currentGradient = gradient;
51+ style = { ...style, gradient, color: gradient[0] };
52+ }
53+
54+ function selectFont(fontFamily: FontFamily) {
55+ style = { ...style, fontFamily };
56+ }
57+
58+ // Keyboard shortcuts handler
59+ function handleKeydown(event: KeyboardEvent) {
60+ if (event.ctrlKey || event.metaKey) {
61+ switch (event.key.toLowerCase()) {
62+ case 'b':
63+ event.preventDefault();
64+ toggleBold();
65+ break;
66+ case 'i':
67+ event.preventDefault();
68+ toggleItalic();
69+ break;
70+ case 'u':
71+ event.preventDefault();
72+ toggleUnderline();
73+ break;
74+ }
75+ }
76+ }
77+
78+ // Update internal state when style prop changes
79+ $effect(() => {
80+ currentColor = style.color || '#000000';
81+ currentGradient = style.gradient;
82+ });
9083 </script>
9184
9285 <svelte:window onkeydown={handleKeydown} />
9386
9487 <div class="text-formatting-toolbar sunken-panel" class:compact>
95-
96- <!-- Font selector (if enabled) -->
97- {#if showFontSelector}
98- <select
99- value={style.fontFamily}
100- onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
101- title="Font Family"
102- >
103- {#each Object.entries(RETRO_FONTS) as [key, font]}
104- <option value={key} style="font-family: {font.stack};">{font.name}</option>
105- {/each}
106- </select>
107- {/if}
108-
109- <!-- Size slider -->
110- <div class="size-control">
111- <input
112- type="range"
113- min="8"
114- max="18"
115- bind:value={style.fontSize}
116- class="size-slider"
117- title="Font Size: {style.fontSize}px"
118- />
119- <span class="size-display">{style.fontSize}</span>
120- </div>
121-
122- <!-- Style toggle buttons -->
123- <div class="style-toggles">
124- <button
125- onclick={toggleBold}
126- title="Bold (Ctrl+B)"
127- style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
128- >
129- <strong>B</strong>
130- </button>
131-
132- <button
133- onclick={toggleItalic}
134- title="Italic (Ctrl+I)"
135- style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
136- >
137- <em>I</em>
138- </button>
139-
140- <button
141- onclick={toggleUnderline}
142- title="Underline (Ctrl+U)"
143- style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
144- >
145- <u>U</u>
146- </button>
147- </div>
148-
149- <!-- Color controls -->
150- <div class="color-controls">
151- <ColorPicker256
152- bind:selectedColor={currentColor}
153- bind:selectedGradient={currentGradient}
154- onColorChange={handleColorChange}
155- onGradientChange={handleGradientChange}
156- {compact}
157- />
158- </div>
159-
160-
88+ <!-- Font selector (if enabled) -->
89+ {#if showFontSelector}
90+ <select
91+ value={style.fontFamily}
92+ onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
93+ title="Font Family"
94+ >
95+ {#each Object.entries(RETRO_FONTS) as [key, font] (key)}
96+ <option value={key} style="font-family: {font.stack};">{font.name}</option>
97+ {/each}
98+ </select>
99+ {/if}
100+
101+ <!-- Size slider -->
102+ <div class="size-control">
103+ <input
104+ type="range"
105+ min="8"
106+ max="18"
107+ bind:value={style.fontSize}
108+ class="size-slider"
109+ title="Font Size: {style.fontSize}px"
110+ />
111+ <span class="size-display">{style.fontSize}</span>
112+ </div>
113+
114+ <!-- Style toggle buttons -->
115+ <div class="style-toggles">
116+ <button
117+ onclick={toggleBold}
118+ title="Bold (Ctrl+B)"
119+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
120+ >
121+ <strong>B</strong>
122+ </button>
123+
124+ <button
125+ onclick={toggleItalic}
126+ title="Italic (Ctrl+I)"
127+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
128+ >
129+ <em>I</em>
130+ </button>
131+
132+ <button
133+ onclick={toggleUnderline}
134+ title="Underline (Ctrl+U)"
135+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
136+ >
137+ <u>U</u>
138+ </button>
139+ </div>
140+
141+ <!-- Color controls -->
142+ <div class="color-controls">
143+ <ColorPicker256
144+ bind:selectedColor={currentColor}
145+ bind:selectedGradient={currentGradient}
146+ onColorChange={handleColorChange}
147+ onGradientChange={handleGradientChange}
148+ {compact}
149+ />
150+ </div>
161151 </div>
162152
163153 <style>
164- .text-formatting-toolbar {
165- display: flex;
166- align-items: center;
167- gap: 4px;
168- padding: 2px;
169- font-size: 11px;
170- position: relative;
171- flex-wrap: wrap;
172- }
173-
174- .size-control {
175- display: flex;
176- align-items: center;
177- gap: 2px;
178- }
179-
180- .size-slider {
181- width: 50px;
182- height: 12px;
183- }
184-
185- .size-display {
186- font-size: 9px;
187- color: #666;
188- min-width: 18px;
189- text-align: center;
190- }
191-
192- .style-toggles {
193- display: flex;
194- gap: 1px;
195- }
196-
197- .color-controls {
198- display: flex;
199- gap: 1px;
200- }
201-
202- /* Fix font dropdown text alignment */
203- select {
204- height: 20px;
205- line-height: 1;
206- vertical-align: middle;
207- padding: 1px 4px;
208- font-size: 11px;
209- }
210-
211- select option {
212- padding: 2px 4px;
213- line-height: normal;
214- }
215-
216-</style>⏎̸
154+ .text-formatting-toolbar {
155+ display: flex;
156+ align-items: center;
157+ gap: 4px;
158+ padding: 2px;
159+ font-size: 11px;
160+ position: relative;
161+ flex-wrap: wrap;
162+ }
163+
164+ .size-control {
165+ display: flex;
166+ align-items: center;
167+ gap: 2px;
168+ }
169+
170+ .size-slider {
171+ width: 50px;
172+ height: 12px;
173+ }
174+
175+ .size-display {
176+ font-size: 9px;
177+ color: #666;
178+ min-width: 18px;
179+ text-align: center;
180+ }
181+
182+ .style-toggles {
183+ display: flex;
184+ gap: 1px;
185+ }
186+
187+ .color-controls {
188+ display: flex;
189+ gap: 1px;
190+ }
191+
192+ /* Fix font dropdown text alignment */
193+ select {
194+ height: 20px;
195+ line-height: 1;
196+ vertical-align: middle;
197+ padding: 1px 4px;
198+ font-size: 11px;
199+ }
200+
201+ select option {
202+ padding: 2px 4px;
203+ line-height: normal;
204+ }
205+</style>
M src/lib/components/text-style-selector.svelte +312 -316
@@ -1,323 +1,319 @@
11 <script lang="ts">
2-import {
3- RETRO_FONTS,
4- CLASSIC_COLORS,
5- DEFAULT_TEXT_STYLE,
6- type TextStyle,
7- type FontFamily,
8- generateCSSStyle
9-} from '../types/text-formatting';
10-import ColorPicker256 from './color-picker-256.svelte';
11-import { formatText, createGradientText } from '../utils/text-formatter';
12-
13-// Props
14-let {
15- style = $bindable(DEFAULT_TEXT_STYLE),
16- previewText = "Sample text preview",
17- showPreview = true,
18- compact = false
19-} = $props<{
20- style?: TextStyle;
21- previewText?: string;
22- showPreview?: boolean;
23- compact?: boolean;
24-}>();
25-
26-// Local state
27-let currentColor = $state(style.color || '#000000');
28-let currentGradient = $state(style.gradient);
29-let formattedPreview = $state('');
30-let isProcessing = $state(false);
31-
32-// Update preview when style changes
33-$effect(() => {
34- if (!showPreview) return;
35-
36- async function updatePreview() {
37- isProcessing = true;
38- try {
39- if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
40- formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient');
41- } else {
42- formattedPreview = await formatText(previewText, style, false);
43- }
44- } catch (error) {
45- console.error('Preview formatting error:', error);
46- formattedPreview = previewText;
47- } finally {
48- isProcessing = false;
49- }
50- }
51-
52- updatePreview();
53-});
54-
55-// Font family selection
56-function selectFont(fontFamily: FontFamily) {
57- style = { ...style, fontFamily };
58-}
59-
60-// Color selection
61-function handleColorChange(color: string) {
62- currentColor = color;
63- style = { ...style, color, gradient: undefined };
64-}
65-
66-// Gradient selection
67-function handleGradientChange(gradient: string[]) {
68- currentGradient = gradient;
69- style = { ...style, gradient, color: gradient[0] };
70-}
71-
72-// Update internal state when style prop changes
73-$effect(() => {
74- currentColor = style.color || '#000000';
75- currentGradient = style.gradient;
76-});
77-
78-// Toggle style options
79-function toggleBold() {
80- style = { ...style, bold: !style.bold };
81-}
82-
83-function toggleItalic() {
84- style = { ...style, italic: !style.italic };
85-}
86-
87-function toggleUnderline() {
88- style = { ...style, underline: !style.underline };
89-}
90-
91-function toggleStrikethrough() {
92- style = { ...style, strikethrough: !style.strikethrough };
93-}
94-
95-// Font size adjustment
96-function updateFontSize(size: number) {
97- style = { ...style, fontSize: Math.max(8, Math.min(18, size)) };
98-}
99-
2+ import {
3+ RETRO_FONTS,
4+ DEFAULT_TEXT_STYLE,
5+ type TextStyle,
6+ type FontFamily,
7+ generateCSSStyle
8+ } from '../types/text-formatting';
9+ import ColorPicker256 from './color-picker-256.svelte';
10+ import { formatText, createGradientText } from '../utils/text-formatter';
11+
12+ // Props
13+ let {
14+ style = $bindable(DEFAULT_TEXT_STYLE),
15+ previewText = 'Sample text preview',
16+ showPreview = true,
17+ compact = false
18+ } = $props<{
19+ style?: TextStyle;
20+ previewText?: string;
21+ showPreview?: boolean;
22+ compact?: boolean;
23+ }>();
24+
25+ // Local state
26+ let currentColor = $state(style.color || '#000000');
27+ let currentGradient = $state(style.gradient);
28+ let formattedPreview = $state('');
29+ let isProcessing = $state(false);
30+
31+ // Update preview when style changes
32+ $effect(() => {
33+ if (!showPreview) return;
34+
35+ async function updatePreview() {
36+ isProcessing = true;
37+ try {
38+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
39+ formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient');
40+ } else {
41+ formattedPreview = await formatText(previewText, style, false);
42+ }
43+ } catch (error) {
44+ console.error('Preview formatting error:', error);
45+ formattedPreview = previewText;
46+ } finally {
47+ isProcessing = false;
48+ }
49+ }
50+
51+ updatePreview();
52+ });
53+
54+ // Font family selection
55+ function selectFont(fontFamily: FontFamily) {
56+ style = { ...style, fontFamily };
57+ }
58+
59+ // Color selection
60+ function handleColorChange(color: string) {
61+ currentColor = color;
62+ style = { ...style, color, gradient: undefined };
63+ }
64+
65+ // Gradient selection
66+ function handleGradientChange(gradient: string[]) {
67+ currentGradient = gradient;
68+ style = { ...style, gradient, color: gradient[0] };
69+ }
70+
71+ // Update internal state when style prop changes
72+ $effect(() => {
73+ currentColor = style.color || '#000000';
74+ currentGradient = style.gradient;
75+ });
76+
77+ // Toggle style options
78+ function toggleBold() {
79+ style = { ...style, bold: !style.bold };
80+ }
81+
82+ function toggleItalic() {
83+ style = { ...style, italic: !style.italic };
84+ }
85+
86+ function toggleUnderline() {
87+ style = { ...style, underline: !style.underline };
88+ }
89+
90+ function toggleStrikethrough() {
91+ style = { ...style, strikethrough: !style.strikethrough };
92+ }
93+
94+ // Font size adjustment
95+ function updateFontSize(size: number) {
96+ style = { ...style, fontSize: Math.max(8, Math.min(18, size)) };
97+ }
10098 </script>
10199
102100 <div class="text-style-selector" class:compact>
103- <!-- Font Family Selection -->
104- <div class="style-group">
105- <label>Font:</label>
106- <select
107- bind:value={style.fontFamily}
108- onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
109- >
110- {#each Object.entries(RETRO_FONTS) as [key, font]}
111- <option value={key} style="font-family: {font.stack}">
112- {font.name}
113- </option>
114- {/each}
115- </select>
116- </div>
117-
118- <!-- Font Size -->
119- <div class="style-group">
120- <label>Size:</label>
121- <input
122- type="range"
123- min="8"
124- max="18"
125- bind:value={style.fontSize}
126- oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))}
127- />
128- <span class="size-display">{style.fontSize}px</span>
129- </div>
130-
131- <!-- Style Toggles -->
132- <div class="style-group toggles">
133- <button
134- class="style-toggle"
135- class:active={style.bold}
136- onclick={toggleBold}
137- title="Bold (Ctrl+B)"
138- >
139- <strong>B</strong>
140- </button>
141- <button
142- class="style-toggle"
143- class:active={style.italic}
144- onclick={toggleItalic}
145- title="Italic (Ctrl+I)"
146- >
147- <em>I</em>
148- </button>
149- <button
150- class="style-toggle"
151- class:active={style.underline}
152- onclick={toggleUnderline}
153- title="Underline (Ctrl+U)"
154- >
155- <u>U</u>
156- </button>
157- <button
158- class="style-toggle"
159- class:active={style.strikethrough}
160- onclick={toggleStrikethrough}
161- title="Strikethrough"
162- >
163- <s>S</s>
164- </button>
165- </div>
166-
167- <!-- Color & Gradient Selection -->
168- <div class="style-group">
169- <label>Color:</label>
170- <div class="color-controls">
171- <ColorPicker256
172- bind:selectedColor={currentColor}
173- bind:selectedGradient={currentGradient}
174- onColorChange={handleColorChange}
175- onGradientChange={handleGradientChange}
176- compact={false}
177- />
178- </div>
179- </div>
180-
181-
182- <!-- Preview -->
183- {#if showPreview}
184- <div class="preview-section">
185- <label>Preview:</label>
186- <div
187- class="preview-text"
188- style={generateCSSStyle(style)}
189- >
190- {#if isProcessing}
191- <span class="loading">Processing...</span>
192- {:else}
193- {@html formattedPreview}
194- {/if}
195- </div>
196- </div>
197- {/if}
101+ <!-- Font Family Selection -->
102+ <div class="style-group">
103+ <label>Font:</label>
104+ <select
105+ bind:value={style.fontFamily}
106+ onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
107+ >
108+ {#each Object.entries(RETRO_FONTS) as [key, font] (key)}
109+ <option value={key} style="font-family: {font.stack}">
110+ {font.name}
111+ </option>
112+ {/each}
113+ </select>
114+ </div>
115+
116+ <!-- Font Size -->
117+ <div class="style-group">
118+ <label>Size:</label>
119+ <input
120+ type="range"
121+ min="8"
122+ max="18"
123+ bind:value={style.fontSize}
124+ oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))}
125+ />
126+ <span class="size-display">{style.fontSize}px</span>
127+ </div>
128+
129+ <!-- Style Toggles -->
130+ <div class="style-group toggles">
131+ <button
132+ class="style-toggle"
133+ class:active={style.bold}
134+ onclick={toggleBold}
135+ title="Bold (Ctrl+B)"
136+ >
137+ <strong>B</strong>
138+ </button>
139+ <button
140+ class="style-toggle"
141+ class:active={style.italic}
142+ onclick={toggleItalic}
143+ title="Italic (Ctrl+I)"
144+ >
145+ <em>I</em>
146+ </button>
147+ <button
148+ class="style-toggle"
149+ class:active={style.underline}
150+ onclick={toggleUnderline}
151+ title="Underline (Ctrl+U)"
152+ >
153+ <u>U</u>
154+ </button>
155+ <button
156+ class="style-toggle"
157+ class:active={style.strikethrough}
158+ onclick={toggleStrikethrough}
159+ title="Strikethrough"
160+ >
161+ <s>S</s>
162+ </button>
163+ </div>
164+
165+ <!-- Color & Gradient Selection -->
166+ <div class="style-group">
167+ <label>Color:</label>
168+ <div class="color-controls">
169+ <ColorPicker256
170+ bind:selectedColor={currentColor}
171+ bind:selectedGradient={currentGradient}
172+ onColorChange={handleColorChange}
173+ onGradientChange={handleGradientChange}
174+ compact={false}
175+ />
176+ </div>
177+ </div>
178+
179+ <!-- Preview -->
180+ {#if showPreview}
181+ <div class="preview-section">
182+ <label>Preview:</label>
183+ <div class="preview-text" style={generateCSSStyle(style)}>
184+ {#if isProcessing}
185+ <span class="loading">Processing...</span>
186+ {:else}
187+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedPreview is sanitized by formatText/createGradientText -->
188+ {@html formattedPreview}
189+ {/if}
190+ </div>
191+ </div>
192+ {/if}
198193 </div>
199194
200195 <style>
201- .text-style-selector {
202- background: #f0f0f0;
203- border: 2px inset #dfdfdf;
204- padding: 8px;
205- font-family: 'MS Sans Serif', sans-serif;
206- font-size: 11px;
207- }
208-
209- .text-style-selector.compact {
210- padding: 4px;
211- }
212-
213- .style-group {
214- display: flex;
215- align-items: center;
216- gap: 8px;
217- margin-bottom: 8px;
218- }
219-
220- .style-group label {
221- font-weight: bold;
222- min-width: 50px;
223- }
224-
225- .style-group select {
226- flex: 1;
227- padding: 2px;
228- border: 1px inset #dfdfdf;
229- background: white;
230- }
231-
232- .style-group input[type="range"] {
233- flex: 1;
234- }
235-
236- .size-display {
237- min-width: 35px;
238- font-size: 10px;
239- color: #666;
240- }
241-
242- .toggles {
243- gap: 4px;
244- }
245-
246- .style-toggle {
247- width: 24px;
248- height: 24px;
249- border: 1px outset #dfdfdf;
250- background: #f0f0f0;
251- cursor: pointer;
252- display: flex;
253- align-items: center;
254- justify-content: center;
255- font-size: 12px;
256- font-weight: bold;
257- }
258-
259- .style-toggle:hover {
260- background: #e0e0e0;
261- }
262-
263- .style-toggle.active {
264- border: 1px inset #dfdfdf;
265- background: #d0d0d0;
266- }
267-
268- .color-controls {
269- display: flex;
270- gap: 4px;
271- }
272-
273- .preview-section {
274- margin-top: 12px;
275- padding-top: 8px;
276- border-top: 1px solid #ccc;
277- }
278-
279- .preview-section label {
280- display: block;
281- margin-bottom: 4px;
282- font-weight: bold;
283- }
284-
285- .preview-text {
286- padding: 8px;
287- border: 1px inset #dfdfdf;
288- background: white;
289- min-height: 24px;
290- line-height: 1.4;
291- }
292-
293- .loading {
294- color: #666;
295- font-style: italic;
296- }
297-
298- /* Gradient text support */
299- :global(.preview-gradient) {
300- display: inline;
301- }
302-
303- /* Compact mode adjustments */
304- .compact .style-group {
305- margin-bottom: 4px;
306- }
307-
308- .compact .style-group label {
309- min-width: 40px;
310- font-size: 10px;
311- }
312-
313- .compact .style-toggle {
314- width: 20px;
315- height: 20px;
316- font-size: 10px;
317- }
318-
319- .compact .color-button, .compact .gradient-button {
320- font-size: 9px;
321- padding: 1px 4px;
322- }
323-</style>⏎̸
196+ .text-style-selector {
197+ background: #f0f0f0;
198+ border: 2px inset #dfdfdf;
199+ padding: 8px;
200+ font-family: 'MS Sans Serif', sans-serif;
201+ font-size: 11px;
202+ }
203+
204+ .text-style-selector.compact {
205+ padding: 4px;
206+ }
207+
208+ .style-group {
209+ display: flex;
210+ align-items: center;
211+ gap: 8px;
212+ margin-bottom: 8px;
213+ }
214+
215+ .style-group label {
216+ font-weight: bold;
217+ min-width: 50px;
218+ }
219+
220+ .style-group select {
221+ flex: 1;
222+ padding: 2px;
223+ border: 1px inset #dfdfdf;
224+ background: white;
225+ }
226+
227+ .style-group input[type='range'] {
228+ flex: 1;
229+ }
230+
231+ .size-display {
232+ min-width: 35px;
233+ font-size: 10px;
234+ color: #666;
235+ }
236+
237+ .toggles {
238+ gap: 4px;
239+ }
240+
241+ .style-toggle {
242+ width: 24px;
243+ height: 24px;
244+ border: 1px outset #dfdfdf;
245+ background: #f0f0f0;
246+ cursor: pointer;
247+ display: flex;
248+ align-items: center;
249+ justify-content: center;
250+ font-size: 12px;
251+ font-weight: bold;
252+ }
253+
254+ .style-toggle:hover {
255+ background: #e0e0e0;
256+ }
257+
258+ .style-toggle.active {
259+ border: 1px inset #dfdfdf;
260+ background: #d0d0d0;
261+ }
262+
263+ .color-controls {
264+ display: flex;
265+ gap: 4px;
266+ }
267+
268+ .preview-section {
269+ margin-top: 12px;
270+ padding-top: 8px;
271+ border-top: 1px solid #ccc;
272+ }
273+
274+ .preview-section label {
275+ display: block;
276+ margin-bottom: 4px;
277+ font-weight: bold;
278+ }
279+
280+ .preview-text {
281+ padding: 8px;
282+ border: 1px inset #dfdfdf;
283+ background: white;
284+ min-height: 24px;
285+ line-height: 1.4;
286+ }
287+
288+ .loading {
289+ color: #666;
290+ font-style: italic;
291+ }
292+
293+ /* Gradient text support */
294+ :global(.preview-gradient) {
295+ display: inline;
296+ }
297+
298+ /* Compact mode adjustments */
299+ .compact .style-group {
300+ margin-bottom: 4px;
301+ }
302+
303+ .compact .style-group label {
304+ min-width: 40px;
305+ font-size: 10px;
306+ }
307+
308+ .compact .style-toggle {
309+ width: 20px;
310+ height: 20px;
311+ font-size: 10px;
312+ }
313+
314+ .compact .color-button,
315+ .compact .gradient-button {
316+ font-size: 9px;
317+ padding: 1px 4px;
318+ }
319+</style>
M src/lib/components/turnstile.svelte +112 -116
@@ -1,123 +1,119 @@
11 <script lang="ts">
2- import { onMount } from 'svelte';
3- import { fade } from 'svelte/transition';
4- import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
5- import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile';
6-
7- let isVerified = $state(false);
8-
9- type OnVerifyCallback = (token: string) => void;
10- let { onVerify } = $props<{ onVerify: OnVerifyCallback }>();
11-
12- let turnstileElement = $state<HTMLDivElement | null>(null);
13- let widgetId = '';
14-
15- // Ensure site key is a string and trim any whitespace
16- const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim();
17-
18- onMount(() => {
19- if (!window.turnstile) {
20- const script = document.createElement('script');
21- script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
22- script.async = true;
23- script.defer = true;
24- document.head.appendChild(script);
25-
26- script.onload = initTurnstile;
27- } else {
28- initTurnstile();
29- }
30-
31- return () => {
32- if (widgetId) {
33- window.turnstile?.remove(widgetId);
34- }
35- };
36- });
37-
38- function initTurnstile() {
39- if (!turnstileElement || !window.turnstile) return;
40-
41- // Validate site key is a non-empty string
42- if (!siteKey) {
43- console.error('Turnstile site key is missing');
44- return;
45- }
46-
47- const options: TurnstileOptions = {
48- sitekey: siteKey,
49- callback: (token: string) => {
50- if (typeof onVerify === 'function') {
51- onVerify(token);
52- isVerified = true;
53- } else {
54- console.error('onVerify callback is not a function');
55- }
56- },
57- 'refresh-expired': 'auto',
58- size: 'compact',
59- 'error-callback': (error: unknown) => {
60- console.error('Turnstile error:', error);
61- isVerified = false; // Reset verification state on error
62- }
63- };
64-
65- try {
66- widgetId = window.turnstile.render(turnstileElement, options);
67-
68- // Add resize handler
69- const handleResize = () => scaleTurnstileToFit();
70- window.addEventListener('resize', handleResize);
71- // Initial scale
72- scaleTurnstileToFit();
73-
74- } catch (error) {
75- console.error('Failed to render Turnstile:', error);
76- }
77- }
78-
79- function scaleTurnstileToFit() {
80- const container = turnstileElement;
81- if (!container) return;
82-
83- const iframe = container.querySelector('iframe');
84- if (!iframe) return;
85-
86- const containerWidth = container.offsetWidth;
87- const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width
88-
89- iframe.style.transform = `scale(${scale})`;
90- iframe.style.transformOrigin = 'center center';
91- }
2+ import { onMount } from 'svelte';
3+ import { fade } from 'svelte/transition';
4+ import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
5+ import type { TurnstileOptions } from '$lib/types/turnstile';
6+
7+ let isVerified = $state(false);
8+
9+ type OnVerifyCallback = (token: string) => void;
10+ let { onVerify } = $props<{ onVerify: OnVerifyCallback }>();
11+
12+ let turnstileElement = $state<HTMLDivElement | null>(null);
13+ let widgetId = '';
14+
15+ // Ensure site key is a string and trim any whitespace
16+ const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim();
17+
18+ onMount(() => {
19+ if (!window.turnstile) {
20+ const script = document.createElement('script');
21+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
22+ script.async = true;
23+ script.defer = true;
24+ document.head.appendChild(script);
25+
26+ script.onload = initTurnstile;
27+ } else {
28+ initTurnstile();
29+ }
30+
31+ return () => {
32+ if (widgetId) {
33+ window.turnstile?.remove(widgetId);
34+ }
35+ };
36+ });
37+
38+ function initTurnstile() {
39+ if (!turnstileElement || !window.turnstile) return;
40+
41+ // Validate site key is a non-empty string
42+ if (!siteKey) {
43+ console.error('Turnstile site key is missing');
44+ return;
45+ }
46+
47+ const options: TurnstileOptions = {
48+ sitekey: siteKey,
49+ callback: (token: string) => {
50+ if (typeof onVerify === 'function') {
51+ onVerify(token);
52+ isVerified = true;
53+ } else {
54+ console.error('onVerify callback is not a function');
55+ }
56+ },
57+ 'refresh-expired': 'auto',
58+ size: 'compact',
59+ 'error-callback': (error: unknown) => {
60+ console.error('Turnstile error:', error);
61+ isVerified = false; // Reset verification state on error
62+ }
63+ };
64+
65+ try {
66+ widgetId = window.turnstile.render(turnstileElement, options);
67+
68+ // Add resize handler
69+ const handleResize = () => scaleTurnstileToFit();
70+ window.addEventListener('resize', handleResize);
71+ // Initial scale
72+ scaleTurnstileToFit();
73+ } catch (error) {
74+ console.error('Failed to render Turnstile:', error);
75+ }
76+ }
77+
78+ function scaleTurnstileToFit() {
79+ const container = turnstileElement;
80+ if (!container) return;
81+
82+ const iframe = container.querySelector('iframe');
83+ if (!iframe) return;
84+
85+ const containerWidth = container.offsetWidth;
86+ const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width
87+
88+ iframe.style.transform = `scale(${scale})`;
89+ iframe.style.transformOrigin = 'center center';
90+ }
9291 </script>
9392
9493 {#if !isVerified}
95- <div
96- class="turnstile-wrapper"
97- transition:fade={{ duration: 300 }}
98- >
99- <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div>
100- </div>
94+ <div class="turnstile-wrapper" transition:fade={{ duration: 300 }}>
95+ <div bind:this={turnstileElement} class="turnstile-container" data-theme="light"></div>
96+ </div>
10197 {/if}
10298
10399 <style>
104- .turnstile-wrapper {
105- width: 100%;
106- display: flex;
107- justify-content: center;
108- align-items: center;
109- min-height: 65px; /* Approximate height for compact size */
110- overflow: hidden;
111- }
112-
113- .turnstile-container {
114- display: flex;
115- justify-content: center;
116- align-items: center;
117- }
118-
119- /* Handle iframe scaling */
120- :global(.turnstile-wrapper iframe) {
121- transition: transform 0.2s ease-in-out;
122- }
123-</style> ⏎̸
100+ .turnstile-wrapper {
101+ width: 100%;
102+ display: flex;
103+ justify-content: center;
104+ align-items: center;
105+ min-height: 65px; /* Approximate height for compact size */
106+ overflow: hidden;
107+ }
108+
109+ .turnstile-container {
110+ display: flex;
111+ justify-content: center;
112+ align-items: center;
113+ }
114+
115+ /* Handle iframe scaling */
116+ :global(.turnstile-wrapper iframe) {
117+ transition: transform 0.2s ease-in-out;
118+ }
119+</style>
M src/lib/components/ui/button-loading.svelte +73 -67
@@ -1,77 +1,83 @@
11 <script lang="ts">
2- import { onDestroy } from 'svelte';
2+ import { onDestroy } from 'svelte';
33
4- // Props with defaults:
5- // - text: button label when not loading (default "Envoyer")
6- // - loading: indicates if the button should show a spinner (default false)
7- // - spinnerChar: the character to use for the spinner animation (default '.')
8- let { onclick, disabled = $bindable(false), text = 'Envoyer', loading = $bindable(false), spinnerChar = $bindable('.') } = $props();
4+ // Props with defaults:
5+ // - text: button label when not loading (default "Envoyer")
6+ // - loading: indicates if the button should show a spinner (default false)
7+ // - spinnerChar: the character to use for the spinner animation (default '.')
8+ let {
9+ onclick,
10+ disabled = $bindable(false),
11+ text = 'Envoyer',
12+ loading = $bindable(false),
13+ spinnerChar = $bindable('.')
14+ } = $props();
915
10- // Using $state for internal reactive variables.
11- let animatedDots = $state(''); // will hold the animated spinner text
12- let count = $state(1);
13- let isDecreasing = $state(true);
14- let intervalID = $state<number | null>(null);
16+ // Using $state for internal reactive variables.
17+ let animatedDots = $state(''); // will hold the animated spinner text
18+ let count = $state(1);
19+ let isDecreasing = $state(true);
20+ let intervalID = $state<number | null>(null);
1521
16- // Effect that starts/stops the spinner based on loading state.
17- $effect(() => {
18- if (loading) {
19- console.debug("LoadingButton: Spinner started");
20- // Immediately update spinner text to show 3 dots and start decreasing
21- animatedDots = spinnerChar.repeat(3);
22- count = 3;
23- isDecreasing = true;
24-
25- if (!intervalID) {
26- intervalID = window.setInterval(() => {
27- // Ping-pong logic: 3,2,1,2,3...
28- count = count + (isDecreasing ? -1 : 1);
29- if (count <= 1) {
30- count = 1;
31- isDecreasing = false;
32- } else if (count >= 3) {
33- count = 3;
34- isDecreasing = true;
35- }
36- animatedDots = spinnerChar.repeat(count);
37- console.debug("LoadingButton: Animated dots updated to", animatedDots);
38- }, 500);
39- }
40- } else {
41- console.debug("LoadingButton: Spinner stopped");
42- if (intervalID) {
43- clearInterval(intervalID);
44- intervalID = null;
45- }
46- // When not loading, display the default text.
47- animatedDots = text;
48- }
49- });
22+ // Effect that starts/stops the spinner based on loading state.
23+ $effect(() => {
24+ if (loading) {
25+ console.debug('LoadingButton: Spinner started');
26+ // Immediately update spinner text to show 3 dots and start decreasing
27+ animatedDots = spinnerChar.repeat(3);
28+ count = 3;
29+ isDecreasing = true;
5030
51- onDestroy(() => {
52- if (intervalID) {
53- console.debug("LoadingButton: onDestroy clearing interval");
54- clearInterval(intervalID);
55- }
56- });
31+ if (!intervalID) {
32+ intervalID = window.setInterval(() => {
33+ // Ping-pong logic: 3,2,1,2,3...
34+ count = count + (isDecreasing ? -1 : 1);
35+ if (count <= 1) {
36+ count = 1;
37+ isDecreasing = false;
38+ } else if (count >= 3) {
39+ count = 3;
40+ isDecreasing = true;
41+ }
42+ animatedDots = spinnerChar.repeat(count);
43+ console.debug('LoadingButton: Animated dots updated to', animatedDots);
44+ }, 500);
45+ }
46+ } else {
47+ console.debug('LoadingButton: Spinner stopped');
48+ if (intervalID) {
49+ clearInterval(intervalID);
50+ intervalID = null;
51+ }
52+ // When not loading, display the default text.
53+ animatedDots = text;
54+ }
55+ });
56+
57+ onDestroy(() => {
58+ if (intervalID) {
59+ console.debug('LoadingButton: onDestroy clearing interval');
60+ clearInterval(intervalID);
61+ }
62+ });
5763 </script>
5864
59-<button onclick={onclick} disabled={disabled}>
60- {#if loading}
61- <span>{animatedDots}</span>
62- {:else}
63- <span>{text}</span>
64- {/if}
65+<button {onclick} {disabled}>
66+ {#if loading}
67+ <span>{animatedDots}</span>
68+ {:else}
69+ <span>{text}</span>
70+ {/if}
6571 </button>
6672
6773 <style>
68- button {
69- padding: 0.5rem 1rem;
70- font-size: 1rem;
71- cursor: pointer;
72- }
73- button:disabled {
74- cursor: not-allowed;
75- opacity: 0.7;
76- }
77-</style> ⏎̸
74+ button {
75+ padding: 0.5rem 1rem;
76+ font-size: 1rem;
77+ cursor: pointer;
78+ }
79+ button:disabled {
80+ cursor: not-allowed;
81+ opacity: 0.7;
82+ }
83+</style>
M src/lib/components/ui/loading-dots.svelte +42 -42
@@ -1,50 +1,50 @@
11 <!-- A reusable loading dots component with animated dots -->
22 <script lang="ts">
3- let { text = 'Chargement', color = 'inherit' } = $props();
3+ let { text = 'Chargement', color = 'inherit' } = $props();
44 </script>
55
66 <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>
88 </div>
99
1010 <style>
11- .loading-text {
12- display: inline-flex;
13- align-items: center;
14- font-size: inherit;
15- }
16-
17- .dots {
18- display: inline-flex;
19- margin-left: 2px;
20- }
21-
22- .dots span {
23- animation: dots 1.5s infinite;
24- opacity: 0;
25- }
26-
27- .dots span:nth-child(1) {
28- animation-delay: 0s;
29- }
30-
31- .dots span:nth-child(2) {
32- animation-delay: 0.5s;
33- }
34-
35- .dots span:nth-child(3) {
36- animation-delay: 1s;
37- }
38-
39- @keyframes dots {
40- 0% {
41- opacity: 0;
42- }
43- 50% {
44- opacity: 1;
45- }
46- 100% {
47- opacity: 0;
48- }
49- }
50-</style> ⏎̸
11+ .loading-text {
12+ display: inline-flex;
13+ align-items: center;
14+ font-size: inherit;
15+ }
16+
17+ .dots {
18+ display: inline-flex;
19+ margin-left: 2px;
20+ }
21+
22+ .dots span {
23+ animation: dots 1.5s infinite;
24+ opacity: 0;
25+ }
26+
27+ .dots span:nth-child(1) {
28+ animation-delay: 0s;
29+ }
30+
31+ .dots span:nth-child(2) {
32+ animation-delay: 0.5s;
33+ }
34+
35+ .dots span:nth-child(3) {
36+ animation-delay: 1s;
37+ }
38+
39+ @keyframes dots {
40+ 0% {
41+ opacity: 0;
42+ }
43+ 50% {
44+ opacity: 1;
45+ }
46+ 100% {
47+ opacity: 0;
48+ }
49+ }
50+</style>
M src/lib/components/ui/tooltip.svelte +191 -190
@@ -1,197 +1,198 @@
11 <script lang="ts">
2- type $$Props = {
3- data: {
4- text: string,
5- direction?: 'top' | 'bottom' | 'left' | 'right'
6- }
7- };
8-
9- let { children, data } = $props();
10-
11- // Derive values from data with defaults
12- let text = $derived(data.text);
13- let direction = $derived(data.direction ?? 'top');
14-
15- let wrapper: HTMLButtonElement;
16- let tooltipDiv = $state<HTMLDivElement | null>(null);
17- let coords = { x: 0, y: 0 };
18-
19- function updateCoords() {
20- if (wrapper) {
21- const rect = wrapper.getBoundingClientRect();
22- if (direction === 'top') {
23- coords = { x: rect.left + rect.width / 2, y: rect.top - 8 };
24- } else if (direction === 'bottom') {
25- coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 };
26- } else if (direction === 'left') {
27- coords = { x: rect.left - 8, y: rect.top + rect.height / 2 };
28- } else if (direction === 'right') {
29- coords = { x: rect.right + 8, y: rect.top + rect.height / 2 };
30- }
31- }
32- }
33-
34- function showTooltip() {
35- updateCoords();
36- if (tooltipDiv) return; // Prevent multiple tooltips
37- tooltipDiv = document.createElement('div');
38- tooltipDiv.className = `tooltip ${direction}`;
39- tooltipDiv.style.position = 'fixed';
40- tooltipDiv.style.zIndex = '1000000';
41- tooltipDiv.textContent = text;
42- tooltipDiv.setAttribute('role', 'tooltip');
43- tooltipDiv.setAttribute('aria-hidden', 'false');
44- document.body.appendChild(tooltipDiv);
45-
46- const tooltipRect = tooltipDiv.getBoundingClientRect();
47- let finalLeft = coords.x;
48- let finalTop = coords.y;
49- if (direction === 'top') {
50- finalLeft = coords.x - tooltipRect.width / 2;
51- finalTop = coords.y - tooltipRect.height;
52- } else if (direction === 'bottom') {
53- finalLeft = coords.x - tooltipRect.width / 2;
54- finalTop = coords.y;
55- } else if (direction === 'left') {
56- finalLeft = coords.x - tooltipRect.width;
57- finalTop = coords.y - tooltipRect.height / 2;
58- } else if (direction === 'right') {
59- finalLeft = coords.x;
60- finalTop = coords.y - tooltipRect.height / 2;
61- }
62-
63- if (finalLeft < 0) {
64- finalLeft = 0;
65- } else if (finalLeft + tooltipRect.width > window.innerWidth) {
66- finalLeft = window.innerWidth - tooltipRect.width;
67- }
68- if (finalTop < 0) {
69- finalTop = 0;
70- } else if (finalTop + tooltipRect.height > window.innerHeight) {
71- finalTop = window.innerHeight - tooltipRect.height;
72- }
73-
74- tooltipDiv.style.left = `${finalLeft}px`;
75- tooltipDiv.style.top = `${finalTop}px`;
76- }
77-
78- function hideTooltip() {
79- if (tooltipDiv && tooltipDiv.parentNode) {
80- tooltipDiv.parentNode.removeChild(tooltipDiv);
81- tooltipDiv = null;
82- }
83- }
84-
85- function handleKeyDown(e: KeyboardEvent) {
86- if (e.key === 'Escape') {
87- hideTooltip();
88- }
89- }
2+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- $$Props is used for Svelte type inference
3+ type $$Props = {
4+ data: {
5+ text: string;
6+ direction?: 'top' | 'bottom' | 'left' | 'right';
7+ };
8+ };
9+
10+ let { children, data } = $props();
11+
12+ // Derive values from data with defaults
13+ let text = $derived(data.text);
14+ let direction = $derived(data.direction ?? 'top');
15+
16+ let wrapper: HTMLButtonElement;
17+ let tooltipDiv = $state<HTMLDivElement | null>(null);
18+ let coords = { x: 0, y: 0 };
19+
20+ function updateCoords() {
21+ if (wrapper) {
22+ const rect = wrapper.getBoundingClientRect();
23+ if (direction === 'top') {
24+ coords = { x: rect.left + rect.width / 2, y: rect.top - 8 };
25+ } else if (direction === 'bottom') {
26+ coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 };
27+ } else if (direction === 'left') {
28+ coords = { x: rect.left - 8, y: rect.top + rect.height / 2 };
29+ } else if (direction === 'right') {
30+ coords = { x: rect.right + 8, y: rect.top + rect.height / 2 };
31+ }
32+ }
33+ }
34+
35+ function showTooltip() {
36+ updateCoords();
37+ if (tooltipDiv) return; // Prevent multiple tooltips
38+ tooltipDiv = document.createElement('div');
39+ tooltipDiv.className = `tooltip ${direction}`;
40+ tooltipDiv.style.position = 'fixed';
41+ tooltipDiv.style.zIndex = '1000000';
42+ tooltipDiv.textContent = text;
43+ tooltipDiv.setAttribute('role', 'tooltip');
44+ tooltipDiv.setAttribute('aria-hidden', 'false');
45+ document.body.appendChild(tooltipDiv);
46+
47+ const tooltipRect = tooltipDiv.getBoundingClientRect();
48+ let finalLeft = coords.x;
49+ let finalTop = coords.y;
50+ if (direction === 'top') {
51+ finalLeft = coords.x - tooltipRect.width / 2;
52+ finalTop = coords.y - tooltipRect.height;
53+ } else if (direction === 'bottom') {
54+ finalLeft = coords.x - tooltipRect.width / 2;
55+ finalTop = coords.y;
56+ } else if (direction === 'left') {
57+ finalLeft = coords.x - tooltipRect.width;
58+ finalTop = coords.y - tooltipRect.height / 2;
59+ } else if (direction === 'right') {
60+ finalLeft = coords.x;
61+ finalTop = coords.y - tooltipRect.height / 2;
62+ }
63+
64+ if (finalLeft < 0) {
65+ finalLeft = 0;
66+ } else if (finalLeft + tooltipRect.width > window.innerWidth) {
67+ finalLeft = window.innerWidth - tooltipRect.width;
68+ }
69+ if (finalTop < 0) {
70+ finalTop = 0;
71+ } else if (finalTop + tooltipRect.height > window.innerHeight) {
72+ finalTop = window.innerHeight - tooltipRect.height;
73+ }
74+
75+ tooltipDiv.style.left = `${finalLeft}px`;
76+ tooltipDiv.style.top = `${finalTop}px`;
77+ }
78+
79+ function hideTooltip() {
80+ if (tooltipDiv && tooltipDiv.parentNode) {
81+ tooltipDiv.parentNode.removeChild(tooltipDiv);
82+ tooltipDiv = null;
83+ }
84+ }
85+
86+ function handleKeyDown(e: KeyboardEvent) {
87+ if (e.key === 'Escape') {
88+ hideTooltip();
89+ }
90+ }
9091 </script>
9192
92-<button
93- class="tooltip-wrapper"
94- bind:this={wrapper}
95- onmouseover={showTooltip}
96- onmouseout={hideTooltip}
97- onfocus={showTooltip}
98- onblur={hideTooltip}
99- onkeydown={handleKeyDown}
100- type="button"
101- aria-describedby={tooltipDiv?.id}
93+<button
94+ class="tooltip-wrapper"
95+ bind:this={wrapper}
96+ onmouseover={showTooltip}
97+ onmouseout={hideTooltip}
98+ onfocus={showTooltip}
99+ onblur={hideTooltip}
100+ onkeydown={handleKeyDown}
101+ type="button"
102+ aria-describedby={tooltipDiv?.id}
102103 >
103- {@render children()}
104+ {@render children()}
104105 </button>
105106
106107 <style>
107- .tooltip-wrapper {
108- display: inline;
109- width: auto;
110- min-width: 0;
111- background: none !important;
112- border: none !important;
113- padding: 0 !important;
114- margin: 0 !important;
115- font: inherit !important;
116- color: inherit !important;
117- cursor: inherit !important;
118- text-align: inherit !important;
119- box-shadow: none !important;
120- text-shadow: inherit !important;
121- border-radius: 0 !important;
122- -webkit-appearance: none !important;
123- appearance: none !important;
124- white-space: pre;
125- }
126- .tooltip-wrapper:hover,
127- .tooltip-wrapper:active {
128- background: none !important;
129- border: none !important;
130- box-shadow: none !important;
131- }
132- .tooltip-wrapper:focus {
133- outline: none !important;
134- box-shadow: none !important;
135- }
136- .tooltip-wrapper:focus-visible {
137- outline: 2px solid #003C74 !important;
138- outline-offset: 2px !important;
139- }
140- /* Use :global for styles that need to apply to elements outside the component */
141- :global(.tooltip) {
142- white-space: nowrap;
143- padding: 0.5rem;
144- background: #ECE9D8;
145- color: #000;
146- border: 1px solid #003C74;
147- box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
148- border-radius: 2px;
149- font-size: 0.875rem;
150- font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;
151- }
152-
153- /* Direction-specific styles for the tooltip arrow */
154- :global(.tooltip.top)::before {
155- content: '';
156- position: absolute;
157- bottom: -6px;
158- left: 50%;
159- transform: translateX(-50%);
160- border-left: 6px solid transparent;
161- border-right: 6px solid transparent;
162- border-top: 6px solid #003C74;
163- }
164-
165- :global(.tooltip.bottom)::before {
166- content: '';
167- position: absolute;
168- top: -6px;
169- left: 50%;
170- transform: translateX(-50%);
171- border-left: 6px solid transparent;
172- border-right: 6px solid transparent;
173- border-bottom: 6px solid #003C74;
174- }
175-
176- :global(.tooltip.left)::before {
177- content: '';
178- position: absolute;
179- right: -6px;
180- top: 50%;
181- transform: translateY(-50%);
182- border-top: 6px solid transparent;
183- border-bottom: 6px solid transparent;
184- border-left: 6px solid #003C74;
185- }
186-
187- :global(.tooltip.right)::before {
188- content: '';
189- position: absolute;
190- left: -6px;
191- top: 50%;
192- transform: translateY(-50%);
193- border-top: 6px solid transparent;
194- border-bottom: 6px solid transparent;
195- border-right: 6px solid #003C74;
196- }
197-</style>⏎̸
108+ .tooltip-wrapper {
109+ display: inline;
110+ width: auto;
111+ min-width: 0;
112+ background: none !important;
113+ border: none !important;
114+ padding: 0 !important;
115+ margin: 0 !important;
116+ font: inherit !important;
117+ color: inherit !important;
118+ cursor: inherit !important;
119+ text-align: inherit !important;
120+ box-shadow: none !important;
121+ text-shadow: inherit !important;
122+ border-radius: 0 !important;
123+ -webkit-appearance: none !important;
124+ appearance: none !important;
125+ white-space: pre;
126+ }
127+ .tooltip-wrapper:hover,
128+ .tooltip-wrapper:active {
129+ background: none !important;
130+ border: none !important;
131+ box-shadow: none !important;
132+ }
133+ .tooltip-wrapper:focus {
134+ outline: none !important;
135+ box-shadow: none !important;
136+ }
137+ .tooltip-wrapper:focus-visible {
138+ outline: 2px solid #003c74 !important;
139+ outline-offset: 2px !important;
140+ }
141+ /* Use :global for styles that need to apply to elements outside the component */
142+ :global(.tooltip) {
143+ white-space: nowrap;
144+ padding: 0.5rem;
145+ background: #ece9d8;
146+ color: #000;
147+ border: 1px solid #003c74;
148+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
149+ border-radius: 2px;
150+ font-size: 0.875rem;
151+ font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;
152+ }
153+
154+ /* Direction-specific styles for the tooltip arrow */
155+ :global(.tooltip.top)::before {
156+ content: '';
157+ position: absolute;
158+ bottom: -6px;
159+ left: 50%;
160+ transform: translateX(-50%);
161+ border-left: 6px solid transparent;
162+ border-right: 6px solid transparent;
163+ border-top: 6px solid #003c74;
164+ }
165+
166+ :global(.tooltip.bottom)::before {
167+ content: '';
168+ position: absolute;
169+ top: -6px;
170+ left: 50%;
171+ transform: translateX(-50%);
172+ border-left: 6px solid transparent;
173+ border-right: 6px solid transparent;
174+ border-bottom: 6px solid #003c74;
175+ }
176+
177+ :global(.tooltip.left)::before {
178+ content: '';
179+ position: absolute;
180+ right: -6px;
181+ top: 50%;
182+ transform: translateY(-50%);
183+ border-top: 6px solid transparent;
184+ border-bottom: 6px solid transparent;
185+ border-left: 6px solid #003c74;
186+ }
187+
188+ :global(.tooltip.right)::before {
189+ content: '';
190+ position: absolute;
191+ left: -6px;
192+ top: 50%;
193+ transform: translateY(-50%);
194+ border-top: 6px solid transparent;
195+ border-bottom: 6px solid transparent;
196+ border-right: 6px solid #003c74;
197+ }
198+</style>
M src/lib/components/user-preferences-modal.svelte +466 -485
@@ -1,492 +1,473 @@
11 <script lang="ts">
2-import { onMount } from 'svelte';
3-import TextStyleSelector from './text-style-selector.svelte';
4-import {
5- DEFAULT_USER_PREFERENCES,
6- DEFAULT_TEXT_STYLE,
7- type UserTextPreferences,
8- type TextStyle
9-} from '../types/text-formatting';
10-
11-// Props
12-let {
13- showModal = $bindable(false),
14- onSave
15-} = $props<{
16- showModal: boolean;
17- onSave?: (preferences: UserTextPreferences) => void;
18-}>();
19-
20-// State
21-let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES });
22-let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
23-let isSaving = $state(false);
24-let hasChanges = $state(false);
25-
26-// Track changes
27-$effect(() => {
28- hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES);
29-});
30-
31-// Update preferences when style changes
32-$effect(() => {
33- preferences.defaultStyle = { ...currentStyle };
34-});
35-
36-// Load user preferences on mount
37-onMount(async () => {
38- try {
39- const response = await fetch('/api/user/text-preferences');
40- if (response.ok) {
41- const data = await response.json();
42- if (data.success && data.preferences) {
43- preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences };
44- currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle };
45- }
46- }
47- } catch (error) {
48- console.error('Failed to load user preferences:', error);
49- }
50-});
51-
52-// Save preferences
53-async function savePreferences() {
54- if (!onSave) return;
55-
56- isSaving = true;
57- try {
58- await onSave(preferences);
59- showModal = false;
60- } catch (error) {
61- console.error('Failed to save preferences:', error);
62- alert('Failed to save preferences. Please try again.');
63- } finally {
64- isSaving = false;
65- }
66-}
67-
68-// Reset to defaults
69-function resetToDefaults() {
70- preferences = { ...DEFAULT_USER_PREFERENCES };
71- currentStyle = { ...DEFAULT_TEXT_STYLE };
72-}
73-
74-// Cancel changes
75-function cancel() {
76- showModal = false;
77-}
78-
79-// Handle keyboard shortcuts
80-function handleKeydown(event: KeyboardEvent) {
81- if (event.key === 'Escape') {
82- cancel();
83- } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
84- savePreferences();
85- }
86-}
2+ import { onMount } from 'svelte';
3+ import TextStyleSelector from './text-style-selector.svelte';
4+ import {
5+ DEFAULT_USER_PREFERENCES,
6+ DEFAULT_TEXT_STYLE,
7+ type UserTextPreferences,
8+ type TextStyle
9+ } from '../types/text-formatting';
10+
11+ // Props
12+ let { showModal = $bindable(false), onSave } = $props<{
13+ showModal: boolean;
14+ onSave?: (preferences: UserTextPreferences) => void;
15+ }>();
16+
17+ // State
18+ let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES });
19+ let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
20+ let isSaving = $state(false);
21+ let hasChanges = $derived(
22+ JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES)
23+ );
24+
25+ // Update preferences when style changes
26+ $effect(() => {
27+ preferences.defaultStyle = { ...currentStyle };
28+ });
29+
30+ // Load user preferences on mount
31+ onMount(async () => {
32+ try {
33+ const response = await fetch('/api/user/text-preferences');
34+ if (response.ok) {
35+ const data = await response.json();
36+ if (data.success && data.preferences) {
37+ preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences };
38+ currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle };
39+ }
40+ }
41+ } catch (error) {
42+ console.error('Failed to load user preferences:', error);
43+ }
44+ });
45+
46+ // Save preferences
47+ async function savePreferences() {
48+ if (!onSave) return;
49+
50+ isSaving = true;
51+ try {
52+ await onSave(preferences);
53+ showModal = false;
54+ } catch (error) {
55+ console.error('Failed to save preferences:', error);
56+ alert('Failed to save preferences. Please try again.');
57+ } finally {
58+ isSaving = false;
59+ }
60+ }
61+
62+ // Reset to defaults
63+ function resetToDefaults() {
64+ preferences = { ...DEFAULT_USER_PREFERENCES };
65+ currentStyle = { ...DEFAULT_TEXT_STYLE };
66+ }
67+
68+ // Cancel changes
69+ function cancel() {
70+ showModal = false;
71+ }
72+
73+ // Handle keyboard shortcuts
74+ function handleKeydown(event: KeyboardEvent) {
75+ if (event.key === 'Escape') {
76+ cancel();
77+ } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
78+ savePreferences();
79+ }
80+ }
8781 </script>
8882
8983 {#if showModal}
90- <!-- Modal backdrop -->
91- <div
92- class="modal-backdrop"
93- onclick={cancel}
94- onkeydown={handleKeydown}
95- role="dialog"
96- aria-modal="true"
97- aria-labelledby="preferences-title"
98- >
99- <!-- Modal content -->
100- <div class="modal-window window" onclick={(e) => e.stopPropagation()}>
101- <div class="title-bar">
102- <div class="title-bar-text" id="preferences-title">
103- Text Formatting Preferences
104- </div>
105- <div class="title-bar-controls">
106- <button onclick={cancel} aria-label="Close"></button>
107- </div>
108- </div>
109-
110- <div class="window-body">
111- <div class="preferences-content">
112-
113- <!-- Enable/Disable Formatting -->
114- <div class="preference-group">
115- <label class="checkbox-label">
116- <input
117- type="checkbox"
118- bind:checked={preferences.allowFormatting}
119- />
120- Enable text formatting in messages
121- </label>
122- <small class="help-text">
123- When enabled, you can use formatting options like bold, italic, colors, and gradients.
124- </small>
125- </div>
126-
127- <!-- Default Text Style -->
128- <div class="preference-group">
129- <h3>Default Text Style</h3>
130- <p class="description">
131- These settings will be used as defaults when you send new messages.
132- </p>
133-
134- <TextStyleSelector
135- bind:style={currentStyle}
136- previewText="This is how your messages will look by default"
137- showPreview={true}
138- compact={false}
139- />
140- </div>
141-
142- <!-- Message Settings -->
143- <div class="preference-group">
144- <h3>Message Settings</h3>
145-
146- <div class="setting-row">
147- <label for="max-length">Maximum message length:</label>
148- <input
149- id="max-length"
150- type="number"
151- min="100"
152- max="2000"
153- bind:value={preferences.maxMessageLength}
154- class="length-input"
155- />
156- <span class="unit">characters</span>
157- </div>
158-
159- <small class="help-text">
160- Longer messages may take more time to process and display.
161- </small>
162- </div>
163-
164- <!-- Format Help -->
165- <div class="preference-group">
166- <details class="format-help">
167- <summary>Formatting Guide</summary>
168- <div class="format-examples">
169- <h4>BBCode Style:</h4>
170- <ul>
171- <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li>
172- <li><code>[i]italic text[/i]</code> → <em>italic text</em></li>
173- <li><code>[u]underlined[/u]</code> → <u>underlined</u></li>
174- <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li>
175- <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li>
176- </ul>
177-
178- <h4>Markdown Style:</h4>
179- <ul>
180- <li><code>**bold text**</code> → <strong>bold text</strong></li>
181- <li><code>*italic text*</code> → <em>italic text</em></li>
182- <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li>
183- <li><code>`code text`</code> → <code>code text</code></li>
184- </ul>
185- </div>
186- </details>
187- </div>
188-
189- </div>
190-
191- <!-- Modal buttons -->
192- <div class="modal-buttons">
193- <button
194- class="reset-button"
195- onclick={resetToDefaults}
196- disabled={isSaving}
197- >
198- Reset to Defaults
199- </button>
200-
201- <div class="main-buttons">
202- <button
203- onclick={cancel}
204- disabled={isSaving}
205- >
206- Cancel
207- </button>
208-
209- <button
210- class="save-button"
211- onclick={savePreferences}
212- disabled={isSaving || !hasChanges}
213- >
214- {#if isSaving}
215- Saving...
216- {:else}
217- Save Changes
218- {/if}
219- </button>
220- </div>
221- </div>
222- </div>
223- </div>
224- </div>
84+ <!-- Modal backdrop -->
85+ <div
86+ class="modal-backdrop"
87+ onclick={cancel}
88+ onkeydown={handleKeydown}
89+ role="dialog"
90+ aria-modal="true"
91+ aria-labelledby="preferences-title"
92+ >
93+ <!-- Modal content -->
94+ <div class="modal-window window" onclick={(e) => e.stopPropagation()}>
95+ <div class="title-bar">
96+ <div class="title-bar-text" id="preferences-title">Text Formatting Preferences</div>
97+ <div class="title-bar-controls">
98+ <button onclick={cancel} aria-label="Close"></button>
99+ </div>
100+ </div>
101+
102+ <div class="window-body">
103+ <div class="preferences-content">
104+ <!-- Enable/Disable Formatting -->
105+ <div class="preference-group">
106+ <label class="checkbox-label">
107+ <input type="checkbox" bind:checked={preferences.allowFormatting} />
108+ Enable text formatting in messages
109+ </label>
110+ <small class="help-text">
111+ When enabled, you can use formatting options like bold, italic, colors, and gradients.
112+ </small>
113+ </div>
114+
115+ <!-- Default Text Style -->
116+ <div class="preference-group">
117+ <h3>Default Text Style</h3>
118+ <p class="description">
119+ These settings will be used as defaults when you send new messages.
120+ </p>
121+
122+ <TextStyleSelector
123+ bind:style={currentStyle}
124+ previewText="This is how your messages will look by default"
125+ showPreview={true}
126+ compact={false}
127+ />
128+ </div>
129+
130+ <!-- Message Settings -->
131+ <div class="preference-group">
132+ <h3>Message Settings</h3>
133+
134+ <div class="setting-row">
135+ <label for="max-length">Maximum message length:</label>
136+ <input
137+ id="max-length"
138+ type="number"
139+ min="100"
140+ max="2000"
141+ bind:value={preferences.maxMessageLength}
142+ class="length-input"
143+ />
144+ <span class="unit">characters</span>
145+ </div>
146+
147+ <small class="help-text">
148+ Longer messages may take more time to process and display.
149+ </small>
150+ </div>
151+
152+ <!-- Format Help -->
153+ <div class="preference-group">
154+ <details class="format-help">
155+ <summary>Formatting Guide</summary>
156+ <div class="format-examples">
157+ <h4>BBCode Style:</h4>
158+ <ul>
159+ <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li>
160+ <li><code>[i]italic text[/i]</code> → <em>italic text</em></li>
161+ <li><code>[u]underlined[/u]</code> → <u>underlined</u></li>
162+ <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li>
163+ <li>
164+ <code>[color=red]red text[/color]</code> →
165+ <span style="color: red">red text</span>
166+ </li>
167+ </ul>
168+
169+ <h4>Markdown Style:</h4>
170+ <ul>
171+ <li><code>**bold text**</code> → <strong>bold text</strong></li>
172+ <li><code>*italic text*</code> → <em>italic text</em></li>
173+ <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li>
174+ <li><code>`code text`</code> → <code>code text</code></li>
175+ </ul>
176+ </div>
177+ </details>
178+ </div>
179+ </div>
180+
181+ <!-- Modal buttons -->
182+ <div class="modal-buttons">
183+ <button class="reset-button" onclick={resetToDefaults} disabled={isSaving}>
184+ Reset to Defaults
185+ </button>
186+
187+ <div class="main-buttons">
188+ <button onclick={cancel} disabled={isSaving}> Cancel </button>
189+
190+ <button
191+ class="save-button"
192+ onclick={savePreferences}
193+ disabled={isSaving || !hasChanges}
194+ >
195+ {#if isSaving}
196+ Saving...
197+ {:else}
198+ Save Changes
199+ {/if}
200+ </button>
201+ </div>
202+ </div>
203+ </div>
204+ </div>
205+ </div>
225206 {/if}
226207
227208 <style>
228- .modal-backdrop {
229- position: fixed;
230- top: 0;
231- left: 0;
232- right: 0;
233- bottom: 0;
234- background: rgba(0, 0, 0, 0.5);
235- display: flex;
236- align-items: center;
237- justify-content: center;
238- z-index: 1000;
239- padding: 20px;
240- }
241-
242- .modal-window {
243- width: 100%;
244- max-width: 600px;
245- max-height: 90vh;
246- overflow: hidden;
247- box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
248- }
249-
250- .window-body {
251- display: flex;
252- flex-direction: column;
253- height: 70vh;
254- max-height: 600px;
255- }
256-
257- .preferences-content {
258- flex: 1;
259- overflow-y: auto;
260- padding: 16px;
261- }
262-
263- .preference-group {
264- margin-bottom: 24px;
265- padding-bottom: 16px;
266- border-bottom: 1px solid #ddd;
267- }
268-
269- .preference-group:last-child {
270- border-bottom: none;
271- margin-bottom: 0;
272- }
273-
274- .preference-group h3 {
275- margin: 0 0 8px 0;
276- font-size: 14px;
277- font-weight: bold;
278- color: #2d31a6;
279- }
280-
281- .description {
282- margin: 0 0 12px 0;
283- font-size: 12px;
284- color: #666;
285- }
286-
287- .checkbox-label {
288- display: flex;
289- align-items: center;
290- gap: 8px;
291- font-size: 12px;
292- cursor: pointer;
293- }
294-
295- .help-text {
296- display: block;
297- margin-top: 4px;
298- font-size: 11px;
299- color: #666;
300- font-style: italic;
301- }
302-
303- .setting-row {
304- display: flex;
305- align-items: center;
306- gap: 8px;
307- margin-bottom: 8px;
308- }
309-
310- .setting-row label {
311- font-size: 12px;
312- min-width: 140px;
313- }
314-
315- .length-input {
316- width: 80px;
317- padding: 2px 4px;
318- border: 1px inset #dfdfdf;
319- background: white;
320- font-size: 11px;
321- }
322-
323- .unit {
324- font-size: 11px;
325- color: #666;
326- }
327-
328- .format-help {
329- margin-top: 8px;
330- }
331-
332- .format-help summary {
333- font-size: 12px;
334- font-weight: bold;
335- cursor: pointer;
336- padding: 4px 0;
337- }
338-
339- .format-examples {
340- padding: 8px 0;
341- font-size: 11px;
342- }
343-
344- .format-examples h4 {
345- margin: 12px 0 4px 0;
346- font-size: 11px;
347- color: #2d31a6;
348- }
349-
350- .format-examples ul {
351- margin: 0;
352- padding-left: 16px;
353- }
354-
355- .format-examples li {
356- margin-bottom: 2px;
357- }
358-
359- .format-examples code {
360- background: #f0f0f0;
361- padding: 1px 3px;
362- border-radius: 2px;
363- font-family: 'Courier New', monospace;
364- font-size: 10px;
365- }
366-
367- .modal-buttons {
368- display: flex;
369- justify-content: space-between;
370- align-items: center;
371- padding: 12px 16px;
372- border-top: 1px solid #ddd;
373- background: #f8f8f8;
374- }
375-
376- .main-buttons {
377- display: flex;
378- gap: 8px;
379- }
380-
381- .reset-button {
382- padding: 4px 12px;
383- font-size: 11px;
384- border: 1px outset #dfdfdf;
385- background: #f0f0f0;
386- cursor: pointer;
387- }
388-
389- .reset-button:hover {
390- background: #e0e0e0;
391- }
392-
393- .reset-button:disabled {
394- opacity: 0.6;
395- cursor: not-allowed;
396- }
397-
398- .save-button {
399- padding: 4px 16px;
400- font-size: 11px;
401- font-weight: bold;
402- border: 1px outset #dfdfdf;
403- background: #0078d4;
404- color: white;
405- cursor: pointer;
406- }
407-
408- .save-button:hover:not(:disabled) {
409- background: #106ebe;
410- }
411-
412- .save-button:disabled {
413- opacity: 0.6;
414- cursor: not-allowed;
415- background: #999;
416- }
417-
418- button {
419- padding: 4px 12px;
420- font-size: 11px;
421- border: 1px outset #dfdfdf;
422- background: #f0f0f0;
423- cursor: pointer;
424- }
425-
426- button:hover:not(:disabled) {
427- background: #e0e0e0;
428- }
429-
430- button:disabled {
431- opacity: 0.6;
432- cursor: not-allowed;
433- }
434-
435- /* Scrollbar styling */
436- .preferences-content::-webkit-scrollbar {
437- width: 12px;
438- }
439-
440- .preferences-content::-webkit-scrollbar-track {
441- background: #f0f0f0;
442- border: 1px inset #dfdfdf;
443- }
444-
445- .preferences-content::-webkit-scrollbar-thumb {
446- background: #c0c0c0;
447- border: 1px outset #dfdfdf;
448- }
449-
450- .preferences-content::-webkit-scrollbar-thumb:hover {
451- background: #a0a0a0;
452- }
453-
454- /* Mobile responsive */
455- @media (max-width: 768px) {
456- .modal-backdrop {
457- padding: 10px;
458- }
459-
460- .modal-window {
461- max-width: none;
462- max-height: 95vh;
463- }
464-
465- .window-body {
466- height: 80vh;
467- }
468-
469- .preferences-content {
470- padding: 12px;
471- }
472-
473- .setting-row {
474- flex-direction: column;
475- align-items: flex-start;
476- gap: 4px;
477- }
478-
479- .setting-row label {
480- min-width: auto;
481- }
482-
483- .modal-buttons {
484- flex-direction: column;
485- gap: 8px;
486- }
487-
488- .main-buttons {
489- order: -1;
490- }
491- }
492-</style>⏎̸
209+ .modal-backdrop {
210+ position: fixed;
211+ top: 0;
212+ left: 0;
213+ right: 0;
214+ bottom: 0;
215+ background: rgba(0, 0, 0, 0.5);
216+ display: flex;
217+ align-items: center;
218+ justify-content: center;
219+ z-index: 1000;
220+ padding: 20px;
221+ }
222+
223+ .modal-window {
224+ width: 100%;
225+ max-width: 600px;
226+ max-height: 90vh;
227+ overflow: hidden;
228+ box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
229+ }
230+
231+ .window-body {
232+ display: flex;
233+ flex-direction: column;
234+ height: 70vh;
235+ max-height: 600px;
236+ }
237+
238+ .preferences-content {
239+ flex: 1;
240+ overflow-y: auto;
241+ padding: 16px;
242+ }
243+
244+ .preference-group {
245+ margin-bottom: 24px;
246+ padding-bottom: 16px;
247+ border-bottom: 1px solid #ddd;
248+ }
249+
250+ .preference-group:last-child {
251+ border-bottom: none;
252+ margin-bottom: 0;
253+ }
254+
255+ .preference-group h3 {
256+ margin: 0 0 8px 0;
257+ font-size: 14px;
258+ font-weight: bold;
259+ color: #2d31a6;
260+ }
261+
262+ .description {
263+ margin: 0 0 12px 0;
264+ font-size: 12px;
265+ color: #666;
266+ }
267+
268+ .checkbox-label {
269+ display: flex;
270+ align-items: center;
271+ gap: 8px;
272+ font-size: 12px;
273+ cursor: pointer;
274+ }
275+
276+ .help-text {
277+ display: block;
278+ margin-top: 4px;
279+ font-size: 11px;
280+ color: #666;
281+ font-style: italic;
282+ }
283+
284+ .setting-row {
285+ display: flex;
286+ align-items: center;
287+ gap: 8px;
288+ margin-bottom: 8px;
289+ }
290+
291+ .setting-row label {
292+ font-size: 12px;
293+ min-width: 140px;
294+ }
295+
296+ .length-input {
297+ width: 80px;
298+ padding: 2px 4px;
299+ border: 1px inset #dfdfdf;
300+ background: white;
301+ font-size: 11px;
302+ }
303+
304+ .unit {
305+ font-size: 11px;
306+ color: #666;
307+ }
308+
309+ .format-help {
310+ margin-top: 8px;
311+ }
312+
313+ .format-help summary {
314+ font-size: 12px;
315+ font-weight: bold;
316+ cursor: pointer;
317+ padding: 4px 0;
318+ }
319+
320+ .format-examples {
321+ padding: 8px 0;
322+ font-size: 11px;
323+ }
324+
325+ .format-examples h4 {
326+ margin: 12px 0 4px 0;
327+ font-size: 11px;
328+ color: #2d31a6;
329+ }
330+
331+ .format-examples ul {
332+ margin: 0;
333+ padding-left: 16px;
334+ }
335+
336+ .format-examples li {
337+ margin-bottom: 2px;
338+ }
339+
340+ .format-examples code {
341+ background: #f0f0f0;
342+ padding: 1px 3px;
343+ border-radius: 2px;
344+ font-family: 'Courier New', monospace;
345+ font-size: 10px;
346+ }
347+
348+ .modal-buttons {
349+ display: flex;
350+ justify-content: space-between;
351+ align-items: center;
352+ padding: 12px 16px;
353+ border-top: 1px solid #ddd;
354+ background: #f8f8f8;
355+ }
356+
357+ .main-buttons {
358+ display: flex;
359+ gap: 8px;
360+ }
361+
362+ .reset-button {
363+ padding: 4px 12px;
364+ font-size: 11px;
365+ border: 1px outset #dfdfdf;
366+ background: #f0f0f0;
367+ cursor: pointer;
368+ }
369+
370+ .reset-button:hover {
371+ background: #e0e0e0;
372+ }
373+
374+ .reset-button:disabled {
375+ opacity: 0.6;
376+ cursor: not-allowed;
377+ }
378+
379+ .save-button {
380+ padding: 4px 16px;
381+ font-size: 11px;
382+ font-weight: bold;
383+ border: 1px outset #dfdfdf;
384+ background: #0078d4;
385+ color: white;
386+ cursor: pointer;
387+ }
388+
389+ .save-button:hover:not(:disabled) {
390+ background: #106ebe;
391+ }
392+
393+ .save-button:disabled {
394+ opacity: 0.6;
395+ cursor: not-allowed;
396+ background: #999;
397+ }
398+
399+ button {
400+ padding: 4px 12px;
401+ font-size: 11px;
402+ border: 1px outset #dfdfdf;
403+ background: #f0f0f0;
404+ cursor: pointer;
405+ }
406+
407+ button:hover:not(:disabled) {
408+ background: #e0e0e0;
409+ }
410+
411+ button:disabled {
412+ opacity: 0.6;
413+ cursor: not-allowed;
414+ }
415+
416+ /* Scrollbar styling */
417+ .preferences-content::-webkit-scrollbar {
418+ width: 12px;
419+ }
420+
421+ .preferences-content::-webkit-scrollbar-track {
422+ background: #f0f0f0;
423+ border: 1px inset #dfdfdf;
424+ }
425+
426+ .preferences-content::-webkit-scrollbar-thumb {
427+ background: #c0c0c0;
428+ border: 1px outset #dfdfdf;
429+ }
430+
431+ .preferences-content::-webkit-scrollbar-thumb:hover {
432+ background: #a0a0a0;
433+ }
434+
435+ /* Mobile responsive */
436+ @media (max-width: 768px) {
437+ .modal-backdrop {
438+ padding: 10px;
439+ }
440+
441+ .modal-window {
442+ max-width: none;
443+ max-height: 95vh;
444+ }
445+
446+ .window-body {
447+ height: 80vh;
448+ }
449+
450+ .preferences-content {
451+ padding: 12px;
452+ }
453+
454+ .setting-row {
455+ flex-direction: column;
456+ align-items: flex-start;
457+ gap: 4px;
458+ }
459+
460+ .setting-row label {
461+ min-width: auto;
462+ }
463+
464+ .modal-buttons {
465+ flex-direction: column;
466+ gap: 8px;
467+ }
468+
469+ .main-buttons {
470+ order: -1;
471+ }
472+ }
473+</style>
M src/lib/db/db.server.ts +17 -13
@@ -12,24 +12,28 @@ const db = drizzle(client, { schema });
1212
1313 // Initialize database with required data
1414 export const initializeDatabase = async () => {
15- log.debug('Initializing database...');
16- try {
17- await ensureDefaultChatRoom(db);
18- log.debug('Database initialization complete');
19- } catch (error) {
20- log.error('Error initializing database:', { error: error instanceof Error ? error.message : 'Unknown error' });
21- throw error;
22- }
15+ log.debug('Initializing database...');
16+ try {
17+ await ensureDefaultChatRoom(db);
18+ log.debug('Database initialization complete');
19+ } catch (error) {
20+ log.error('Error initializing database:', {
21+ error: error instanceof Error ? error.message : 'Unknown error'
22+ });
23+ throw error;
24+ }
2325 };
2426
2527 // Call initialization when the module is loaded
26-initializeDatabase().catch(error => {
27- log.error('Failed to initialize database:', { error: error instanceof Error ? error.message : 'Unknown error' });
28+initializeDatabase().catch((error) => {
29+ log.error('Failed to initialize database:', {
30+ error: error instanceof Error ? error.message : 'Unknown error'
31+ });
2832 });
2933
3034 export const testConnection = async () => {
31- const result = await db.query.users.findMany();
32- log.debug('Test connection result:', result);
35+ const result = await db.query.users.findMany();
36+ log.debug('Test connection result:', result);
3337 };
3438
35-export default db;⏎̸
39+export default db;
M src/lib/db/flush.ts +38 -36
@@ -7,46 +7,48 @@ dotenv.config();
77 const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env;
88
99 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+ );
1113 }
1214
1315 async function flushDatabase() {
14- console.debug('Starting database content flush...');
15-
16- const client = createClient({
17- url: TURSO_DATABASE_URL as string,
18- authToken: TURSO_AUTH_TOKEN as string
19- });
20-
21- try {
22- // Clear tables in correct order to respect foreign key constraints
23- console.debug('Clearing messages table...');
24- await client.execute('DELETE FROM messages');
25-
26- console.debug('Clearing sessions table...');
27- await client.execute('DELETE FROM sessions');
28-
29- console.debug('Clearing chat rooms table...');
30- await client.execute('DELETE FROM chat_rooms');
31-
32- console.debug('Clearing users table...');
33- await client.execute('DELETE FROM users');
34-
35- console.debug('Database content flush completed successfully');
36- } catch (error) {
37- console.error('Error flushing database content:', error);
38- throw error;
39- } finally {
40- await client.close();
41- }
16+ console.debug('Starting database content flush...');
17+
18+ const client = createClient({
19+ url: TURSO_DATABASE_URL as string,
20+ authToken: TURSO_AUTH_TOKEN as string
21+ });
22+
23+ try {
24+ // Clear tables in correct order to respect foreign key constraints
25+ console.debug('Clearing messages table...');
26+ await client.execute('DELETE FROM messages');
27+
28+ console.debug('Clearing sessions table...');
29+ await client.execute('DELETE FROM sessions');
30+
31+ console.debug('Clearing chat rooms table...');
32+ await client.execute('DELETE FROM chat_rooms');
33+
34+ console.debug('Clearing users table...');
35+ await client.execute('DELETE FROM users');
36+
37+ console.debug('Database content flush completed successfully');
38+ } catch (error) {
39+ console.error('Error flushing database content:', error);
40+ throw error;
41+ } finally {
42+ await client.close();
43+ }
4244 }
4345
4446 // Execute if this file is run directly
4547 if (process.argv[1] === import.meta.url) {
46- flushDatabase()
47- .then(() => process.exit(0))
48- .catch((error) => {
49- console.error('Failed to flush database content:', error);
50- process.exit(1);
51- });
52-} ⏎̸
48+ flushDatabase()
49+ .then(() => process.exit(0))
50+ .catch((error) => {
51+ console.error('Failed to flush database content:', error);
52+ process.exit(1);
53+ });
54+}
M src/lib/db/schema.ts +110 -101
@@ -1,71 +1,76 @@
1-import { sqliteTable as table } from "drizzle-orm/sqlite-core";
2-import * as t from "drizzle-orm/sqlite-core";
3-import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat";
4-import { v4 as uuidv4 } from "uuid";
5-import { eq } from "drizzle-orm";
1+import { sqliteTable as table } from 'drizzle-orm/sqlite-core';
2+import * as t from 'drizzle-orm/sqlite-core';
3+import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from '../types/chat';
4+import { v4 as uuidv4 } from 'uuid';
5+import { eq } from 'drizzle-orm';
66
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+});
719
8-export const users = table(
9- "users",
10- {
11- id: t.text("id").primaryKey().$default(() => uuidv4()),
12- password: t.text("password").notNull(),
13- nickname: t.text("nickname").notNull(),
14- status: t.text("status").notNull().$type<UserStatus>().default("offline"),
15- avatarUrl: t.text("avatar_url"),
16- createdAt: t.integer("created_at").notNull(),
17- lastSeen: t.integer("last_seen"),
18- }
19-);
20-
21-export const sessions = table(
22- "sessions",
23- {
24- id: t.text("id").primaryKey(),
25- userId: t.text("user_id").references(() => users.id).notNull(),
26- expiresAt: t.integer("expires_at").notNull(),
27- createdAt: t.integer("created_at").notNull(),
28- }
29-);
20+export const sessions = table('sessions', {
21+ id: t.text('id').primaryKey(),
22+ userId: t
23+ .text('user_id')
24+ .references(() => users.id)
25+ .notNull(),
26+ expiresAt: t.integer('expires_at').notNull(),
27+ createdAt: t.integer('created_at').notNull()
28+});
3029
31-export const chatRooms = table(
32- "chat_rooms",
33- {
34- id: t.text("id").primaryKey().$default(() => uuidv4()),
35- name: t.text("name"),
36- type: t.text("type").$type<"direct" | "group">().notNull().default("direct"),
37- createdAt: t.integer("created_at").notNull(),
38- }
39-);
30+export const chatRooms = table('chat_rooms', {
31+ id: t
32+ .text('id')
33+ .primaryKey()
34+ .$default(() => uuidv4()),
35+ name: t.text('name'),
36+ type: t.text('type').$type<'direct' | 'group'>().notNull().default('direct'),
37+ createdAt: t.integer('created_at').notNull()
38+});
4039
41-export const messages = table(
42- "messages",
43- {
44- id: t.text("id").primaryKey().$default(() => uuidv4()),
45- chatRoomId: t.text("chat_room_id").references(() => chatRooms.id).notNull(),
46- senderId: t.text("sender_id").references(() => users.id).notNull(),
47- content: t.text("content").notNull(),
48- type: t.text("type").$type<MessageType>().notNull().default("chat"),
49- timestamp: t.integer("timestamp").notNull(),
50- styleData: t.text("style_data"), // JSON string of TextStyle object
51- hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag
52- }
53-);
40+export const messages = table('messages', {
41+ id: t
42+ .text('id')
43+ .primaryKey()
44+ .$default(() => uuidv4()),
45+ chatRoomId: t
46+ .text('chat_room_id')
47+ .references(() => chatRooms.id)
48+ .notNull(),
49+ senderId: t
50+ .text('sender_id')
51+ .references(() => users.id)
52+ .notNull(),
53+ content: t.text('content').notNull(),
54+ type: t.text('type').$type<MessageType>().notNull().default('chat'),
55+ timestamp: t.integer('timestamp').notNull(),
56+ styleData: t.text('style_data'), // JSON string of TextStyle object
57+ hasFormatting: t.integer('has_formatting', { mode: 'boolean' }).default(false) // Boolean flag
58+});
5459
55-export const userTextPreferences = table(
56- "user_text_preferences",
57- {
58- userId: t.text("user_id").references(() => users.id).primaryKey(),
59- defaultFontFamily: t.text("default_font_family").default("tahoma"),
60- defaultFontSize: t.integer("default_font_size").default(14),
61- defaultColor: t.text("default_color").default("black"),
62- allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true),
63- maxMessageLength: t.integer("max_message_length").default(500),
64- stylePresets: t.text("style_presets"), // JSON array of saved styles
65- createdAt: t.integer("created_at").notNull(),
66- updatedAt: t.integer("updated_at").notNull(),
67- }
68-);
60+export const userTextPreferences = table('user_text_preferences', {
61+ userId: t
62+ .text('user_id')
63+ .references(() => users.id)
64+ .primaryKey(),
65+ defaultFontFamily: t.text('default_font_family').default('tahoma'),
66+ defaultFontSize: t.integer('default_font_size').default(14),
67+ defaultColor: t.text('default_color').default('black'),
68+ allowFormatting: t.integer('allow_formatting', { mode: 'boolean' }).default(true),
69+ maxMessageLength: t.integer('max_message_length').default(500),
70+ stylePresets: t.text('style_presets'), // JSON array of saved styles
71+ createdAt: t.integer('created_at').notNull(),
72+ updatedAt: t.integer('updated_at').notNull()
73+});
6974
7075 // Validate schema types against interfaces
7176 export type Users = typeof users.$inferSelect;
@@ -75,55 +80,59 @@ export type Sessions = typeof sessions.$inferSelect;
7580 export type UserTextPreferences = typeof userTextPreferences.$inferSelect;
7681 // These type assertions will fail if the schema doesn't match the interfaces
7782 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>;
7985 export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>;
8086 export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>;
8187
82-export const userView = t.sqliteView("user_view").as((qb) =>
83- qb.select({
84- id: users.id,
85- nickname: users.nickname,
86- status: users.status,
87- avatarUrl: users.avatarUrl,
88- lastSeen: users.lastSeen
89- }).from(users)
88+export const userView = t.sqliteView('user_view').as((qb) =>
89+ qb
90+ .select({
91+ id: users.id,
92+ nickname: users.nickname,
93+ status: users.status,
94+ avatarUrl: users.avatarUrl,
95+ lastSeen: users.lastSeen
96+ })
97+ .from(users)
9098 );
9199
92-export const chatRoomView = t.sqliteView("chat_room_view").as((qb) =>
93- qb.select({
94- id: chatRooms.id,
95- name: chatRooms.name,
96- type: chatRooms.type,
97- createdAt: chatRooms.createdAt
98- }).from(chatRooms)
100+export const chatRoomView = t.sqliteView('chat_room_view').as((qb) =>
101+ qb
102+ .select({
103+ id: chatRooms.id,
104+ name: chatRooms.name,
105+ type: chatRooms.type,
106+ createdAt: chatRooms.createdAt
107+ })
108+ .from(chatRooms)
99109 );
100110
101-export const messageView = t.sqliteView("message_view").as((qb) =>
102- qb.select({
103- id: messages.id,
104- content: messages.content,
105- type: messages.type,
106- timestamp: messages.timestamp,
107- sender: users.nickname,
108- chatRoom: chatRooms.name
109- })
110- .from(messages)
111- .leftJoin(users, eq(messages.senderId, users.id))
112- .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
111+export const messageView = t.sqliteView('message_view').as((qb) =>
112+ qb
113+ .select({
114+ id: messages.id,
115+ content: messages.content,
116+ type: messages.type,
117+ timestamp: messages.timestamp,
118+ sender: users.nickname,
119+ chatRoom: chatRooms.name
120+ })
121+ .from(messages)
122+ .leftJoin(users, eq(messages.senderId, users.id))
123+ .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
113124 );
114125
115126 // Create a schema object with all our tables and views
116127 const schema = {
117- users,
118- sessions,
119- chatRooms,
120- messages,
121- userTextPreferences,
122- userView,
123- chatRoomView,
124- messageView
128+ users,
129+ sessions,
130+ chatRooms,
131+ messages,
132+ userTextPreferences,
133+ userView,
134+ chatRoomView,
135+ messageView
125136 };
126137
127-
128138 export default schema;
129-
M src/lib/sseConnectionTracker.ts +79 -79
@@ -3,88 +3,88 @@ import { createLogger } from './utils/logger.server';
33 const log = createLogger('sse-connection-tracker');
44
55 interface ConnectionInfo {
6- userId: string;
7- connectedAt: number;
8- lastActivity: number;
6+ userId: string;
7+ connectedAt: number;
8+ lastActivity: number;
99 }
1010
1111 class SSEConnectionTracker {
12- private connections = new Map<string, ConnectionInfo>();
13-
14- addConnection(userId: string): boolean {
15- const existing = this.connections.get(userId);
16- if (existing) {
17- log.debug('User already has active connection', { userId });
18- // Update last activity but don't trigger status update
19- existing.lastActivity = Date.now();
20- return false; // Connection already exists
21- }
22-
23- this.connections.set(userId, {
24- userId,
25- connectedAt: Date.now(),
26- lastActivity: Date.now()
27- });
28-
29- log.debug('New SSE connection tracked', {
30- userId,
31- totalConnections: this.connections.size
32- });
33- return true; // New connection
34- }
35-
36- removeConnection(userId: string): boolean {
37- const deleted = this.connections.delete(userId);
38- if (deleted) {
39- log.debug('SSE connection removed', {
40- userId,
41- remainingConnections: this.connections.size
42- });
43- }
44- return deleted;
45- }
46-
47- hasConnection(userId: string): boolean {
48- return this.connections.has(userId);
49- }
50-
51- updateActivity(userId: string): void {
52- const connection = this.connections.get(userId);
53- if (connection) {
54- connection.lastActivity = Date.now();
55- }
56- }
57-
58- getActiveUserIds(): string[] {
59- return Array.from(this.connections.keys());
60- }
61-
62- getConnectionCount(): number {
63- return this.connections.size;
64- }
65-
66- // Clean up stale connections (e.g., if server didn't properly close them)
67- cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68- const now = Date.now();
69- const staleUserIds: string[] = [];
70-
71- for (const [userId, info] of this.connections.entries()) {
72- if (now - info.lastActivity > maxInactivityMs) {
73- this.connections.delete(userId);
74- staleUserIds.push(userId);
75- }
76- }
77-
78- if (staleUserIds.length > 0) {
79- log.info('Cleaned up stale connections', {
80- count: staleUserIds.length,
81- userIds: staleUserIds
82- });
83- }
84-
85- return staleUserIds;
86- }
12+ private connections = new Map<string, ConnectionInfo>();
13+
14+ addConnection(userId: string): boolean {
15+ const existing = this.connections.get(userId);
16+ if (existing) {
17+ log.debug('User already has active connection', { userId });
18+ // Update last activity but don't trigger status update
19+ existing.lastActivity = Date.now();
20+ return false; // Connection already exists
21+ }
22+
23+ this.connections.set(userId, {
24+ userId,
25+ connectedAt: Date.now(),
26+ lastActivity: Date.now()
27+ });
28+
29+ log.debug('New SSE connection tracked', {
30+ userId,
31+ totalConnections: this.connections.size
32+ });
33+ return true; // New connection
34+ }
35+
36+ removeConnection(userId: string): boolean {
37+ const deleted = this.connections.delete(userId);
38+ if (deleted) {
39+ log.debug('SSE connection removed', {
40+ userId,
41+ remainingConnections: this.connections.size
42+ });
43+ }
44+ return deleted;
45+ }
46+
47+ hasConnection(userId: string): boolean {
48+ return this.connections.has(userId);
49+ }
50+
51+ updateActivity(userId: string): void {
52+ const connection = this.connections.get(userId);
53+ if (connection) {
54+ connection.lastActivity = Date.now();
55+ }
56+ }
57+
58+ getActiveUserIds(): string[] {
59+ return Array.from(this.connections.keys());
60+ }
61+
62+ getConnectionCount(): number {
63+ return this.connections.size;
64+ }
65+
66+ // Clean up stale connections (e.g., if server didn't properly close them)
67+ cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68+ const now = Date.now();
69+ const staleUserIds: string[] = [];
70+
71+ for (const [userId, info] of this.connections.entries()) {
72+ if (now - info.lastActivity > maxInactivityMs) {
73+ this.connections.delete(userId);
74+ staleUserIds.push(userId);
75+ }
76+ }
77+
78+ if (staleUserIds.length > 0) {
79+ log.info('Cleaned up stale connections', {
80+ count: staleUserIds.length,
81+ userIds: staleUserIds
82+ });
83+ }
84+
85+ return staleUserIds;
86+ }
8787 }
8888
8989 // 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 @@
11 type SSEEventData = {
2- type: string;
3- data: unknown;
2+ type: string;
3+ data: unknown;
44 };
55
66 class CustomEventEmitter {
7- private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map();
8- private maxListeners: number = 100;
9-
10- constructor() {
11- console.log('[SSE-Emitter] Initialized');
12- }
13-
14- emit(event: string, data: SSEEventData) {
15- const eventListeners = this.listeners.get(event);
16- if (eventListeners) {
17- // Only log non-sensitive event types
18- if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) {
19- console.log('[SSE-Emitter] Broadcasting event:', { type: data.type, listenersCount: eventListeners.length });
20- }
21- eventListeners.forEach(listener => listener(data));
22- }
23- }
24-
25- addListener(event: string, listener: (data: SSEEventData) => void) {
26- if (!this.listeners.has(event)) {
27- this.listeners.set(event, []);
28- }
29- const eventListeners = this.listeners.get(event)!;
30-
31- if (eventListeners.length >= this.maxListeners) {
32- console.log(`[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`);
33- }
34-
35- eventListeners.push(listener);
36- console.log('[SSE-Emitter] New listener added:', { event, totalListeners: eventListeners.length });
37- }
38-
39- removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) {
40- const eventListeners = this.listeners.get(event);
41- if (eventListeners) {
42- const newListeners = eventListeners.filter(listener => listener !== listenerToRemove);
43- this.listeners.set(event, newListeners);
44- console.log('[SSE-Emitter] Listener removed:', { event, remainingListeners: newListeners.length });
45- }
46- }
47-
48- setMaxListeners(n: number) {
49- this.maxListeners = n;
50- console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
51- }
7+ private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map();
8+ private maxListeners: number = 100;
9+
10+ constructor() {
11+ console.log('[SSE-Emitter] Initialized');
12+ }
13+
14+ emit(event: string, data: SSEEventData) {
15+ const eventListeners = this.listeners.get(event);
16+ if (eventListeners) {
17+ // Only log non-sensitive event types
18+ if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) {
19+ console.log('[SSE-Emitter] Broadcasting event:', {
20+ type: data.type,
21+ listenersCount: eventListeners.length
22+ });
23+ }
24+ eventListeners.forEach((listener) => listener(data));
25+ }
26+ }
27+
28+ addListener(event: string, listener: (data: SSEEventData) => void) {
29+ if (!this.listeners.has(event)) {
30+ this.listeners.set(event, []);
31+ }
32+ const eventListeners = this.listeners.get(event)!;
33+
34+ if (eventListeners.length >= this.maxListeners) {
35+ console.log(
36+ `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`
37+ );
38+ }
39+
40+ eventListeners.push(listener);
41+ console.log('[SSE-Emitter] New listener added:', {
42+ event,
43+ totalListeners: eventListeners.length
44+ });
45+ }
46+
47+ removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) {
48+ const eventListeners = this.listeners.get(event);
49+ if (eventListeners) {
50+ const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove);
51+ this.listeners.set(event, newListeners);
52+ console.log('[SSE-Emitter] Listener removed:', {
53+ event,
54+ remainingListeners: newListeners.length
55+ });
56+ }
57+ }
58+
59+ setMaxListeners(n: number) {
60+ this.maxListeners = n;
61+ console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
62+ }
5263 }
5364
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 @@
11 import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
22 import { createSafeUser } from '../types/chat';
3-import type {
4- SendMessageRequest,
5- SendMessageResponse,
6- GetMessagesResponse
3+import type {
4+ SendMessageRequest,
5+ SendMessageResponse,
6+ GetMessagesResponse
77 } from '../types/payloads';
8+import type { TextStyle } from '../types/text-formatting';
89 import { invalidate } from '$app/navigation';
910 import { env } from '$env/dynamic/public';
1011
1112 // 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';
1315
1416 class ChatState {
15- private users = $state<SafeUser[]>([]);
16- private messages = $state<Message[]>([]);
17- private currentUser = $state<SafeUser | null>(null);
18- private eventSource: EventSource | null = null;
19- private reconnectAttempts = 0;
20- private maxReconnectAttempts = 5;
21- private reconnectDelay = 1000; // Start with 1 second
22- private isInitializing = $state(false);
23- private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
24- private userCache = $state<Record<string, SafeUser>>({});
25- private sseError = $state<string | null>(null);
26- private sseRetryAfter = $state<number | null>(null);
27- private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
28- private isReconnecting = $state(false);
29- private isSettingUser = $state(false);
30- private isConnecting = $state(false);
31- private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
32- private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
33- private _lastEnrichmentKey: string = '';
34- // Flag to avoid re-registering SSE event handlers
35- private sseHandlersRegistered = false;
36- private lastBuddyListUpdate = 0;
37- private lastBuddyListHash = '';
38- private hasMoreMessages = $state(false);
39- private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
40-
41- public async reinitialize() {
42- if (this.isInitializing) {
43- console.debug('Reinitialize already in progress, skipping.');
44- return;
45- }
46- this.isInitializing = true;
47- console.debug('Reinitializing chat state...');
48- if (this.eventSource) {
49- this.eventSource.close();
50- this.eventSource = null;
51- }
52- this.reconnectAttempts = 0;
53- this.reconnectDelay = 1000;
54- this.messages = [];
55- // Ensure currentRoomId is set
56- this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
57-
58- if (this.currentUser) {
59- console.debug('Waiting for session cookie update before setting up SSE...');
60- await new Promise(resolve => setTimeout(resolve, 1000));
61- // First set up SSE to receive status updates
62- this.setupSSE();
63- // Then initialize messages and room users
64- await Promise.all([
65- this.initializeMessages(),
66- this.initializeRoomUsers()
67- ]);
68- }
69- this.isInitializing = false;
70- }
71-
72- getSSEError() {
73- return {
74- error: this.sseError,
75- retryAfter: this.sseRetryAfter
76- };
77- }
78-
79- // User methods
80- getCurrentUser() {
81- return this.currentUser;
82- }
83-
84- async setCurrentUser(user: User | SafeUser | null) {
85- console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
86-
87- // Prevent concurrent setCurrentUser calls
88- if (this.isSettingUser) {
89- console.debug('Already setting user, skipping');
90- return;
91- }
92-
93- this.isSettingUser = true;
94-
95- try {
96- // If user is null and we already have no user, skip
97- if (!user && !this.currentUser) {
98- console.debug('No user to set and no current user, skipping');
99- return;
100- }
101-
102- // Convert to SafeUser if full User object is passed
103- const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
104-
105- // If same user is being set, check SSE connection
106- if (safeUser?.id === this.currentUser?.id) {
107- if (!this.eventSource) {
108- console.debug('Same user being set but no active SSE connection, reinitializing.');
109- } else {
110- console.debug('Same user being set with active connection, skipping.');
111- return;
112- }
113- }
114-
115- // Clear existing messages before setting new user
116- this.messages = [];
117- this.currentUser = safeUser;
118-
119- if (safeUser) {
120- // Update user cache with current user
121- this.userCache[safeUser.id] = safeUser;
122-
123- // Only reinitialize if we have a valid user
124- await this.reinitialize();
125- } else {
126- // Cleanup when user is removed
127- if (this.eventSource) {
128- this.eventSource.close();
129- this.eventSource = null;
130- }
131- this.messages = [];
132- this.users = [];
133- this.userCache = {};
134- }
135- } finally {
136- this.isSettingUser = false;
137- }
138- }
139-
140- private async initializeRoomUsers() {
141- console.debug('Initializing room users for room:', this.currentRoomId);
142- try {
143- const isPublic = !this.currentUser;
144- const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
145- console.debug('Fetching room users from:', url);
146-
147- const response = await fetch(url, {
148- credentials: 'include'
149- });
150-
151- if (!response.ok) {
152- throw new Error('Failed to fetch room users');
153- }
154-
155- const data = await response.json();
156- if (!data.success) {
157- throw new Error(data.error);
158- }
159-
160- console.debug('Fetched room buddy list:', data.buddyList);
161- // Merge fetched buddy list with existing cached users to preserve user details even if offline
162- const mergedUsers = new Map<string, typeof data.buddyList[0]>();
163- // Add existing cached users
164- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
165-
166- // Overwrite/add with fetched buddy list
167- data.buddyList.forEach((user: typeof data.buddyList[0]) => {
168- mergedUsers.set(user.id, user);
169- });
170-
171- this.users = Array.from(mergedUsers.values());
172-
173- // Update cache with merged users
174- this.users.forEach(user => {
175- this.userCache[user.id] = user;
176- });
177- } catch (error) {
178- console.debug('Error fetching room users:', error);
179- this.users = [];
180- }
181- }
182-
183- getOnlineUsers() {
184- // Sort users by status: online first, then away, then busy, then offline
185- return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186- const statusOrder: Record<SafeUser['status'], number> = {
187- 'online': 0,
188- 'away': 1,
189- 'busy': 2,
190- 'offline': 3
191- };
192- return statusOrder[a.status] - statusOrder[b.status];
193- });
194- }
195-
196- getUserById(userId: string) {
197- // First check the cache
198- if (this.userCache[userId]) {
199- return this.userCache[userId];
200- }
201-
202- // Return a fallback object and cache it to avoid repeated fetches
203- const fallback: SafeUser = {
204- id: userId,
205- nickname: 'Unknown User',
206- status: 'offline',
207- lastSeen: null,
208- avatarUrl: null
209- };
210- this.userCache[userId] = fallback;
211- console.debug('getUserById: returning fallback for missing user', { userId, fallback });
212- return fallback;
213- }
214-
215- updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216- console.debug('Updating user status with:', {
217- userId,
218- status,
219- lastSeen,
220- currentCache: this.userCache[userId],
221- currentUsers: this.users.find(u => u.id === userId)
222- });
223-
224- const existingUser = this.userCache[userId];
225- if (
226- existingUser &&
227- existingUser.status === status &&
228- lastSeen &&
229- Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
230- ) {
231- console.debug('Skipping redundant status update for user:', userId);
232- return;
233- }
234-
235- // Try to get the user from cache first
236- let user: SafeUser | undefined = this.userCache[userId];
237-
238- // If not in cache, try to find in users array
239- if (!user) {
240- const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241- if (foundUser) {
242- user = foundUser;
243- }
244- }
245-
246- const now = Date.now();
247- if (user) {
248- const updatedUser: SafeUser = {
249- ...user,
250- status,
251- lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252- };
253-
254- // Update cache first
255- this.userCache[userId] = updatedUser;
256-
257- // Then update or add to users array
258- const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259- if (index !== -1) {
260- // Create new array with updated user
261- this.users = [
262- ...this.users.slice(0, index),
263- updatedUser,
264- ...this.users.slice(index + 1)
265- ];
266- } else {
267- this.users = [...this.users, updatedUser];
268- }
269-
270- console.debug('User status updated:', {
271- userId,
272- newStatus: status,
273- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),
274- updatedInCache: this.userCache[userId].status === status,
275- updatedInList: this.users.find(u => u.id === userId)?.status === status
276- });
277- } else {
278- // If user not found anywhere, fetch from API
279- console.debug('User not found in cache or list, fetching from API:', userId);
280- fetch(`/api/users/${userId}`, { credentials: 'include' })
281- .then(response => response.json())
282- .then(data => {
283- if (data.success && data.user) {
284- const newUser: SafeUser = {
285- ...data.user,
286- status,
287- lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
288- };
289- // Update both cache and users array
290- this.userCache[userId] = newUser;
291- this.users = [...this.users, newUser];
292-
293- console.debug('User fetched and status updated:', {
294- userId,
295- status: newUser.status,
296- lastSeen: new Date(newUser.lastSeen || now).toISOString(),
297- addedToCache: !!this.userCache[userId],
298- addedToList: this.users.some(u => u.id === userId)
299- });
300- }
301- })
302- .catch(error => console.error('Error fetching user data:', error));
303- }
304- }
305-
306- // Message methods
307- getMessages() {
308- const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
309- if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
310- return this._memoizedEnrichedMessages;
311- }
312- this._lastEnrichmentKey = key;
313- console.debug('Enriching messages with user data', {
314- messagesCount: this.messages.length,
315- userCacheSize: Object.keys(this.userCache).length
316- });
317- this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
318- return this._memoizedEnrichedMessages;
319- }
320-
321- public prependMessages(newMessages: Message[], hasMore?: boolean) {
322- console.debug('Prepending messages:', { count: newMessages.length });
323- // Get unique user IDs from new messages that aren't in the cache
324- const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId));
325- const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
326-
327- // Fetch missing user data in parallel
328- if (missingUserIds.length > 0) {
329- console.debug('Fetching missing user data for IDs:', missingUserIds);
330- Promise.all(missingUserIds.map(async (userId) => {
331- try {
332- const userResponse = await fetch(`/api/users/${userId}`, {
333- credentials: 'include'
334- });
335- if (userResponse.ok) {
336- const userData = await userResponse.json();
337- if (userData.success && userData.user) {
338- this.userCache[userId] = userData.user;
339- // Only add to users array if not already present
340- if (!this.users.some(u => u.id === userId)) {
341- this.users = [...this.users, userData.user];
342- }
343- }
344- }
345- } catch (error) {
346- console.debug('Error fetching user data:', { userId, error });
347- }
348- }));
349- }
350-
351- // Ensure incoming messages are sorted in ascending order before prepending
352- const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
353- const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id));
354- this.messages = [...uniqueNewMessages, ...this.messages];
355-
356- // Update hasMore if provided
357- if (hasMore !== undefined) {
358- this.hasMoreMessages = hasMore;
359- }
360- }
361-
362- // Add method to update user cache for public access
363- updateUserCache(users: (User | SafeUser)[]) {
364- console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' })));
365- // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
366- users.forEach(user => {
367- const safeUser = 'password' in user ? createSafeUser(user) : user;
368- if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
369- this.userCache[user.id] = safeUser;
370- // Update users array if not already present or if status changed
371- const existingIndex = this.users.findIndex(u => u.id === user.id);
372- if (existingIndex === -1) {
373- this.users = [...this.users, safeUser];
374- } else if (safeUser.status !== this.users[existingIndex].status) {
375- this.users = [
376- ...this.users.slice(0, existingIndex),
377- safeUser,
378- ...this.users.slice(existingIndex + 1)
379- ];
380- }
381- }
382- });
383- }
384-
385- // Add method to update online users for public access
386- updateOnlineUsers(users: (User | SafeUser)[]) {
387- const now = Date.now();
388- // Convert all users to SafeUser and create a hash
389- const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user);
390- const newHash = JSON.stringify(safeUsers);
391-
392- // Only update if the data has changed
393- if (newHash !== this.lastBuddyListHash) {
394- const statusCounts = safeUsers.reduce((acc, user) => {
395- acc[user.status] = (acc[user.status] || 0) + 1;
396- return acc;
397- }, {} as Record<string, number>);
398-
399- console.debug('Updating online users (data changed):', {
400- total: safeUsers.length,
401- byStatus: statusCounts,
402- timestamp: new Date(now).toISOString()
403- });
404-
405- this.users = safeUsers;
406- // Also update cache
407- safeUsers.forEach(user => {
408- this.userCache[user.id] = user;
409- });
410- this.lastBuddyListHash = newHash;
411- this.lastBuddyListUpdate = now;
412- }
413- }
414-
415- // Add method to update messages for public access
416- updateMessages(messages: Message[]) {
417- // Deduplicate the messages by their id in case duplicates exist
418- const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values());
419- // Sort messages in ascending order (oldest first, newest at bottom)
420- this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
421- }
422-
423- // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE.
424- async initializeMessages() {
425- try {
426- // Build the URL with appropriate parameters
427- const params = new URLSearchParams();
428- if (!this.currentUser) {
429- params.append('public', 'true');
430- }
431- params.append('roomId', this.currentRoomId);
432-
433- const response = await fetch(`/api/chat/messages?${params}`, {
434- credentials: 'include'
435- });
436- if (!response.ok) throw new Error('Failed to fetch messages');
437-
438- const data = await response.json() as GetMessagesResponse;
439- if (!data.success) {
440- throw new Error(data.error);
441- }
442-
443- // Get unique user IDs from messages that aren't in the cache
444- const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
445- const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
446-
447- // Fetch missing user data in parallel
448- if (missingUserIds.length > 0) {
449- console.debug('Fetching missing user data for IDs:', missingUserIds);
450- await Promise.all(missingUserIds.map(async (userId) => {
451- try {
452- const userResponse = await fetch(`/api/users/${userId}`, {
453- credentials: 'include'
454- });
455- if (userResponse.ok) {
456- const userData = await userResponse.json();
457- if (userData.success && userData.user) {
458- this.userCache[userId] = userData.user;
459- // Only add to users array if not already present
460- if (!this.users.some(u => u.id === userId)) {
461- this.users = [...this.users, userData.user];
462- }
463- }
464- }
465- } catch (error) {
466- console.debug('Error fetching user data:', { userId, error });
467- }
468- }));
469- }
470-
471- // Sort messages in ascending order (oldest first, most recent at the bottom)
472- this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
473-
474- // Update hasMore based on response
475- if ('hasMore' in data) {
476- this.hasMoreMessages = data.hasMore;
477- }
478-
479- console.debug('Messages initialized:', {
480- count: data.messages.length,
481- isAuthenticated: !!this.currentUser,
482- hasMore: this.hasMoreMessages
483- });
484- } catch (error) {
485- console.debug('Error fetching initial messages:', error);
486- this.messages = [];
487- this.hasMoreMessages = false;
488- }
489- }
490-
491- async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> {
492- const user = this.getCurrentUser();
493- if (!user) {
494- console.debug('Cannot send message: No current user');
495- return {
496- success: false,
497- error: 'Not logged in'
498- };
499- }
500-
501- try {
502- const payload: SendMessageRequest = {
503- content,
504- type,
505- userId: user.id,
506- chatRoomId: DEFAULT_CHAT_ROOM_ID
507- };
508-
509- // Add styleData if textStyle is provided
510- if (textStyle) {
511- (payload as any).styleData = JSON.stringify(textStyle);
512- }
513-
514- console.debug('Sending message with payload:', payload);
515-
516- const response = await fetch('/api/chat/messages', {
517- method: 'POST',
518- headers: {
519- 'Content-Type': 'application/json',
520- },
521- credentials: 'include',
522- body: JSON.stringify(payload)
523- });
524-
525- if (!response.ok) {
526- const errorData = await response.json();
527- throw new Error(errorData.error || 'Failed to save message');
528- }
529-
530- const data = await response.json() as SendMessageResponse;
531- if (!data.success) {
532- throw new Error(data.error);
533- }
534-
535- console.debug('Message sent successfully:', data.message);
536- await invalidate('chat:messages');
537- return data;
538- } catch (error) {
539- console.debug('Error sending message:', error);
540- return {
541- success: false,
542- error: 'Failed to send message'
543- };
544- }
545- }
546-
547- private setupSSE() {
548- // Prevent creating multiple SSE connections if one is already active.
549- if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
550- console.debug('setupSSE: Active SSE connection exists, skipping setup.');
551- return;
552- }
553- // Clear any existing timeouts
554- if (this.connectionTimeout) {
555- clearTimeout(this.connectionTimeout);
556- this.connectionTimeout = null;
557- }
558-
559- if (this.connectionAttemptTimeout) {
560- clearTimeout(this.connectionAttemptTimeout);
561- this.connectionAttemptTimeout = null;
562- }
563-
564- // Debounce connection attempts
565- this.connectionAttemptTimeout = setTimeout(() => {
566- if (!this.currentUser) {
567- console.debug('No current user, skipping SSE connection');
568- return;
569- }
570-
571- if (this.isConnecting) {
572- console.debug('Already connecting to SSE, skipping');
573- return;
574- }
575-
576- this.connectSSE();
577- }, 1000);
578- }
579-
580- private async connectSSE() {
581- if (this.isConnecting) {
582- console.debug('Already connecting to SSE, skipping');
583- return;
584- }
585-
586- this.isConnecting = true;
587-
588- try {
589- // Ensure clean disconnect of any existing connection
590- if (this.eventSource) {
591- console.debug('Closing existing SSE connection');
592- this.eventSource.close();
593- this.eventSource = null;
594- }
595-
596- console.debug('Connecting to SSE...');
597- this.eventSource = new EventSource('/api/sse', { withCredentials: true });
598- // Reset the handlers flag for the new connection
599- this.sseHandlersRegistered = false;
600-
601- this.eventSource.onopen = () => {
602- console.debug('SSE connection established');
603- this.reconnectAttempts = 0;
604- this.reconnectDelay = 1000;
605- this.sseError = null;
606- this.sseRetryAfter = null;
607- this.isReconnecting = false;
608- };
609-
610- // Set up error handling
611- this.eventSource.onerror = (error) => {
612- console.debug('SSE connection error:', error);
613- if (this.eventSource?.readyState === EventSource.CLOSED) {
614- this.handleDisconnection();
615- }
616- };
617-
618- // Register event handlers (only once)
619- this.setupMessageHandlers();
620-
621- } catch (error) {
622- console.debug('Error setting up SSE connection:', error);
623- this.handleDisconnection();
624- } finally {
625- this.isConnecting = false;
626- }
627- }
628-
629- // Register SSE event handlers only once per connection.
630- private setupMessageHandlers() {
631- if (this.sseHandlersRegistered || !this.eventSource) {
632- return;
633- }
634-
635- // Listen for chat messages
636- this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
637- try {
638- const messageData = JSON.parse(event.data) as Message;
639- console.debug('Received chat message via SSE:', messageData);
640- // Deduplicate and update messages array with the new message
641- this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values());
642- // Sort messages so the newest are at the bottom
643- this.messages.sort((a, b) => a.timestamp - b.timestamp);
644- // Ensure the sender's data is available
645- await this.ensureUserData(messageData.senderId);
646- } catch (error) {
647- console.debug('Error handling SSE chat message:', error);
648- }
649- });
650-
651- // Modify buddy list update handler
652- this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
653- try {
654- const buddyList = JSON.parse(event.data) as SafeUser[];
655- this.updateOnlineUsers(buddyList);
656- } catch (error) {
657- console.debug('Error handling buddy list update via SSE:', error);
658- }
659- });
660-
661- this.sseHandlersRegistered = true;
662- }
663-
664- private async ensureUserData(userId: string) {
665- if (this.userCache[userId]) return;
666-
667- console.debug('Fetching user data for:', userId);
668- try {
669- const response = await fetch(`/api/users/${userId}`, {
670- credentials: 'include'
671- });
672- if (response.ok) {
673- const userData = await response.json();
674- if (userData.success && userData.user) {
675- this.userCache[userId] = userData.user;
676- if (!this.users.some(u => u.id === userId)) {
677- this.users = [...this.users, userData.user];
678- }
679- }
680- }
681- } catch (error) {
682- console.debug('Error fetching user data:', error);
683- }
684- }
685-
686- private handleDisconnection() {
687- console.debug('SSE connection closed, reconnecting...');
688- this.isReconnecting = true;
689- this.reconnectAttempts++;
690- if (this.reconnectAttempts > this.maxReconnectAttempts) {
691- console.debug('Max reconnect attempts reached, giving up');
692- if (this.eventSource) {
693- this.eventSource.close();
694- }
695- this.eventSource = null;
696- return;
697- }
698- this.reconnectDelay *= 2; // Exponential backoff
699- // Ensure any existing connection is properly closed before reconnecting
700- if (this.eventSource) {
701- this.eventSource.close();
702- this.eventSource = null;
703- }
704- // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
705- this.sseHandlersRegistered = false;
706- this.setupSSE();
707- }
708-
709- enrichMessages(messages: Message[]): EnrichedMessage[] {
710- console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length });
711- return messages.map(message => ({
712- ...message,
713- user: this.userCache[message.senderId] || {
714- id: message.senderId,
715- nickname: 'Unknown User',
716- status: 'offline',
717- avatarUrl: null,
718- lastSeen: null
719- }
720- }));
721- }
722-
723- getDefaultChatRoomId() {
724- return DEFAULT_CHAT_ROOM_ID;
725- }
17+ private users = $state<SafeUser[]>([]);
18+ private messages = $state<Message[]>([]);
19+ private currentUser = $state<SafeUser | null>(null);
20+ private eventSource: EventSource | null = null;
21+ private reconnectAttempts = 0;
22+ private maxReconnectAttempts = 5;
23+ private reconnectDelay = 1000; // Start with 1 second
24+ private isInitializing = $state(false);
25+ private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
26+ private userCache = $state<Record<string, SafeUser>>({});
27+ private sseError = $state<string | null>(null);
28+ private sseRetryAfter = $state<number | null>(null);
29+ private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
30+ private isReconnecting = $state(false);
31+ private isSettingUser = $state(false);
32+ private isConnecting = $state(false);
33+ private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
34+ private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
35+ private _lastEnrichmentKey: string = '';
36+ // Flag to avoid re-registering SSE event handlers
37+ private sseHandlersRegistered = false;
38+ private lastBuddyListUpdate = 0;
39+ private lastBuddyListHash = '';
40+ private hasMoreMessages = $state(false);
41+ private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
42+
43+ public async reinitialize() {
44+ if (this.isInitializing) {
45+ console.debug('Reinitialize already in progress, skipping.');
46+ return;
47+ }
48+ this.isInitializing = true;
49+ console.debug('Reinitializing chat state...');
50+ if (this.eventSource) {
51+ this.eventSource.close();
52+ this.eventSource = null;
53+ }
54+ this.reconnectAttempts = 0;
55+ this.reconnectDelay = 1000;
56+ this.messages = [];
57+ // Ensure currentRoomId is set
58+ this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
59+
60+ if (this.currentUser) {
61+ console.debug('Waiting for session cookie update before setting up SSE...');
62+ await new Promise((resolve) => setTimeout(resolve, 1000));
63+ // First set up SSE to receive status updates
64+ this.setupSSE();
65+ // Then initialize messages and room users
66+ await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
67+ }
68+ this.isInitializing = false;
69+ }
70+
71+ getSSEError() {
72+ return {
73+ error: this.sseError,
74+ retryAfter: this.sseRetryAfter
75+ };
76+ }
77+
78+ // User methods
79+ getCurrentUser() {
80+ return this.currentUser;
81+ }
82+
83+ async setCurrentUser(user: User | SafeUser | null) {
84+ console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
85+
86+ // Prevent concurrent setCurrentUser calls
87+ if (this.isSettingUser) {
88+ console.debug('Already setting user, skipping');
89+ return;
90+ }
91+
92+ this.isSettingUser = true;
93+
94+ try {
95+ // If user is null and we already have no user, skip
96+ if (!user && !this.currentUser) {
97+ console.debug('No user to set and no current user, skipping');
98+ return;
99+ }
100+
101+ // Convert to SafeUser if full User object is passed
102+ const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
103+
104+ // If same user is being set, check SSE connection
105+ if (safeUser?.id === this.currentUser?.id) {
106+ if (!this.eventSource) {
107+ console.debug('Same user being set but no active SSE connection, reinitializing.');
108+ } else {
109+ console.debug('Same user being set with active connection, skipping.');
110+ return;
111+ }
112+ }
113+
114+ // Clear existing messages before setting new user
115+ this.messages = [];
116+ this.currentUser = safeUser;
117+
118+ if (safeUser) {
119+ // Update user cache with current user
120+ this.userCache[safeUser.id] = safeUser;
121+
122+ // Only reinitialize if we have a valid user
123+ await this.reinitialize();
124+ } else {
125+ // Cleanup when user is removed
126+ if (this.eventSource) {
127+ this.eventSource.close();
128+ this.eventSource = null;
129+ }
130+ this.messages = [];
131+ this.users = [];
132+ this.userCache = {};
133+ }
134+ } finally {
135+ this.isSettingUser = false;
136+ }
137+ }
138+
139+ private async initializeRoomUsers() {
140+ console.debug('Initializing room users for room:', this.currentRoomId);
141+ try {
142+ const isPublic = !this.currentUser;
143+ const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
144+ console.debug('Fetching room users from:', url);
145+
146+ const response = await fetch(url, {
147+ credentials: 'include'
148+ });
149+
150+ if (!response.ok) {
151+ throw new Error('Failed to fetch room users');
152+ }
153+
154+ const data = await response.json();
155+ if (!data.success) {
156+ throw new Error(data.error);
157+ }
158+
159+ console.debug('Fetched room buddy list:', data.buddyList);
160+ // Merge fetched buddy list with existing cached users to preserve user details even if offline
161+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state
162+ const mergedUsers = new Map<string, (typeof data.buddyList)[0]>();
163+ // Add existing cached users
164+ Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
165+
166+ // Overwrite/add with fetched buddy list
167+ data.buddyList.forEach((user: (typeof data.buddyList)[0]) => {
168+ mergedUsers.set(user.id, user);
169+ });
170+
171+ this.users = Array.from(mergedUsers.values());
172+
173+ // Update cache with merged users
174+ this.users.forEach((user) => {
175+ this.userCache[user.id] = user;
176+ });
177+ } catch (error) {
178+ console.debug('Error fetching room users:', error);
179+ this.users = [];
180+ }
181+ }
182+
183+ getOnlineUsers() {
184+ // Sort users by status: online first, then away, then busy, then offline
185+ return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186+ const statusOrder: Record<SafeUser['status'], number> = {
187+ online: 0,
188+ away: 1,
189+ busy: 2,
190+ offline: 3
191+ };
192+ return statusOrder[a.status] - statusOrder[b.status];
193+ });
194+ }
195+
196+ getUserById(userId: string) {
197+ // First check the cache
198+ if (this.userCache[userId]) {
199+ return this.userCache[userId];
200+ }
201+
202+ // Return a fallback object and cache it to avoid repeated fetches
203+ const fallback: SafeUser = {
204+ id: userId,
205+ nickname: 'Unknown User',
206+ status: 'offline',
207+ lastSeen: null,
208+ avatarUrl: null
209+ };
210+ this.userCache[userId] = fallback;
211+ console.debug('getUserById: returning fallback for missing user', { userId, fallback });
212+ return fallback;
213+ }
214+
215+ updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216+ console.debug('Updating user status with:', {
217+ userId,
218+ status,
219+ lastSeen,
220+ currentCache: this.userCache[userId],
221+ currentUsers: this.users.find((u) => u.id === userId)
222+ });
223+
224+ const existingUser = this.userCache[userId];
225+ if (
226+ existingUser &&
227+ existingUser.status === status &&
228+ lastSeen &&
229+ Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
230+ ) {
231+ console.debug('Skipping redundant status update for user:', userId);
232+ return;
233+ }
234+
235+ // Try to get the user from cache first
236+ let user: SafeUser | undefined = this.userCache[userId];
237+
238+ // If not in cache, try to find in users array
239+ if (!user) {
240+ const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241+ if (foundUser) {
242+ user = foundUser;
243+ }
244+ }
245+
246+ const now = Date.now();
247+ if (user) {
248+ const updatedUser: SafeUser = {
249+ ...user,
250+ status,
251+ lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252+ };
253+
254+ // Update cache first
255+ this.userCache[userId] = updatedUser;
256+
257+ // Then update or add to users array
258+ const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259+ if (index !== -1) {
260+ // Create new array with updated user
261+ this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
262+ } else {
263+ this.users = [...this.users, updatedUser];
264+ }
265+
266+ console.debug('User status updated:', {
267+ userId,
268+ newStatus: status,
269+ newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),
270+ updatedInCache: this.userCache[userId].status === status,
271+ updatedInList: this.users.find((u) => u.id === userId)?.status === status
272+ });
273+ } else {
274+ // If user not found anywhere, fetch from API
275+ console.debug('User not found in cache or list, fetching from API:', userId);
276+ fetch(`/api/users/${userId}`, { credentials: 'include' })
277+ .then((response) => response.json())
278+ .then((data) => {
279+ if (data.success && data.user) {
280+ const newUser: SafeUser = {
281+ ...data.user,
282+ status,
283+ lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
284+ };
285+ // Update both cache and users array
286+ this.userCache[userId] = newUser;
287+ this.users = [...this.users, newUser];
288+
289+ console.debug('User fetched and status updated:', {
290+ userId,
291+ status: newUser.status,
292+ lastSeen: new Date(newUser.lastSeen || now).toISOString(),
293+ addedToCache: !!this.userCache[userId],
294+ addedToList: this.users.some((u) => u.id === userId)
295+ });
296+ }
297+ })
298+ .catch((error) => console.error('Error fetching user data:', error));
299+ }
300+ }
301+
302+ // Message methods
303+ getMessages() {
304+ const key =
305+ this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
306+ if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
307+ return this._memoizedEnrichedMessages;
308+ }
309+ this._lastEnrichmentKey = key;
310+ console.debug('Enriching messages with user data', {
311+ messagesCount: this.messages.length,
312+ userCacheSize: Object.keys(this.userCache).length
313+ });
314+ this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
315+ return this._memoizedEnrichedMessages;
316+ }
317+
318+ public prependMessages(newMessages: Message[], hasMore?: boolean) {
319+ console.debug('Prepending messages:', { count: newMessages.length });
320+ // Get unique user IDs from new messages that aren't in the cache
321+ const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
322+ const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
323+
324+ // Fetch missing user data in parallel
325+ if (missingUserIds.length > 0) {
326+ console.debug('Fetching missing user data for IDs:', missingUserIds);
327+ Promise.all(
328+ missingUserIds.map(async (userId) => {
329+ try {
330+ const userResponse = await fetch(`/api/users/${userId}`, {
331+ credentials: 'include'
332+ });
333+ if (userResponse.ok) {
334+ const userData = await userResponse.json();
335+ if (userData.success && userData.user) {
336+ this.userCache[userId] = userData.user;
337+ // Only add to users array if not already present
338+ if (!this.users.some((u) => u.id === userId)) {
339+ this.users = [...this.users, userData.user];
340+ }
341+ }
342+ }
343+ } catch (error) {
344+ console.debug('Error fetching user data:', { userId, error });
345+ }
346+ })
347+ );
348+ }
349+
350+ // Ensure incoming messages are sorted in ascending order before prepending
351+ const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
352+ const uniqueNewMessages = sortedNewMessages.filter(
353+ (msg) => !this.messages.some((m) => m.id === msg.id)
354+ );
355+ this.messages = [...uniqueNewMessages, ...this.messages];
356+
357+ // Update hasMore if provided
358+ if (hasMore !== undefined) {
359+ this.hasMoreMessages = hasMore;
360+ }
361+ }
362+
363+ // Add method to update user cache for public access
364+ updateUserCache(users: (User | SafeUser)[]) {
365+ console.debug(
366+ 'Updating user cache with users:',
367+ users.map((u) => ({ ...u, password: '[REDACTED]' }))
368+ );
369+ // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
370+ users.forEach((user) => {
371+ const safeUser = 'password' in user ? createSafeUser(user) : user;
372+ if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
373+ this.userCache[user.id] = safeUser;
374+ // Update users array if not already present or if status changed
375+ const existingIndex = this.users.findIndex((u) => u.id === user.id);
376+ if (existingIndex === -1) {
377+ this.users = [...this.users, safeUser];
378+ } else if (safeUser.status !== this.users[existingIndex].status) {
379+ this.users = [
380+ ...this.users.slice(0, existingIndex),
381+ safeUser,
382+ ...this.users.slice(existingIndex + 1)
383+ ];
384+ }
385+ }
386+ });
387+ }
388+
389+ // Add method to update online users for public access
390+ updateOnlineUsers(users: (User | SafeUser)[]) {
391+ const now = Date.now();
392+ // Convert all users to SafeUser and create a hash
393+ const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
394+ const newHash = JSON.stringify(safeUsers);
395+
396+ // Only update if the data has changed
397+ if (newHash !== this.lastBuddyListHash) {
398+ const statusCounts = safeUsers.reduce(
399+ (acc, user) => {
400+ acc[user.status] = (acc[user.status] || 0) + 1;
401+ return acc;
402+ },
403+ {} as Record<string, number>
404+ );
405+
406+ console.debug('Updating online users (data changed):', {
407+ total: safeUsers.length,
408+ byStatus: statusCounts,
409+ timestamp: new Date(now).toISOString()
410+ });
411+
412+ this.users = safeUsers;
413+ // Also update cache
414+ safeUsers.forEach((user) => {
415+ this.userCache[user.id] = user;
416+ });
417+ this.lastBuddyListHash = newHash;
418+ this.lastBuddyListUpdate = now;
419+ }
420+ }
421+
422+ // Add method to update messages for public access
423+ updateMessages(messages: Message[]) {
424+ // Deduplicate the messages by their id in case duplicates exist
425+ const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
426+ // Sort messages in ascending order (oldest first, newest at bottom)
427+ this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
428+ }
429+
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.
431+ async initializeMessages() {
432+ try {
433+ // Build the URL with appropriate parameters
434+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state
435+ const params = new URLSearchParams();
436+ if (!this.currentUser) {
437+ params.append('public', 'true');
438+ }
439+ params.append('roomId', this.currentRoomId);
440+
441+ const response = await fetch(`/api/chat/messages?${params}`, {
442+ credentials: 'include'
443+ });
444+ if (!response.ok) throw new Error('Failed to fetch messages');
445+
446+ const data = (await response.json()) as GetMessagesResponse;
447+ if (!data.success) {
448+ throw new Error(data.error);
449+ }
450+
451+ // Get unique user IDs from messages that aren't in the cache
452+ const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
453+ const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
454+
455+ // Fetch missing user data in parallel
456+ if (missingUserIds.length > 0) {
457+ console.debug('Fetching missing user data for IDs:', missingUserIds);
458+ await Promise.all(
459+ missingUserIds.map(async (userId) => {
460+ try {
461+ const userResponse = await fetch(`/api/users/${userId}`, {
462+ credentials: 'include'
463+ });
464+ if (userResponse.ok) {
465+ const userData = await userResponse.json();
466+ if (userData.success && userData.user) {
467+ this.userCache[userId] = userData.user;
468+ // Only add to users array if not already present
469+ if (!this.users.some((u) => u.id === userId)) {
470+ this.users = [...this.users, userData.user];
471+ }
472+ }
473+ }
474+ } catch (error) {
475+ console.debug('Error fetching user data:', { userId, error });
476+ }
477+ })
478+ );
479+ }
480+
481+ // Sort messages in ascending order (oldest first, most recent at the bottom)
482+ this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
483+
484+ // Update hasMore based on response
485+ if ('hasMore' in data) {
486+ this.hasMoreMessages = data.hasMore;
487+ }
488+
489+ console.debug('Messages initialized:', {
490+ count: data.messages.length,
491+ isAuthenticated: !!this.currentUser,
492+ hasMore: this.hasMoreMessages
493+ });
494+ } catch (error) {
495+ console.debug('Error fetching initial messages:', error);
496+ this.messages = [];
497+ this.hasMoreMessages = false;
498+ }
499+ }
500+
501+ async sendMessage(
502+ content: string,
503+ type: Message['type'] = 'chat',
504+ textStyle?: TextStyle
505+ ): Promise<SendMessageResponse> {
506+ const user = this.getCurrentUser();
507+ if (!user) {
508+ console.debug('Cannot send message: No current user');
509+ return {
510+ success: false,
511+ error: 'Not logged in'
512+ };
513+ }
514+
515+ try {
516+ const payload: SendMessageRequest = {
517+ content,
518+ type,
519+ userId: user.id,
520+ chatRoomId: DEFAULT_CHAT_ROOM_ID,
521+ styleData: textStyle ? JSON.stringify(textStyle) : undefined
522+ };
523+
524+ console.debug('Sending message with payload:', payload);
525+
526+ const response = await fetch('/api/chat/messages', {
527+ method: 'POST',
528+ headers: {
529+ 'Content-Type': 'application/json'
530+ },
531+ credentials: 'include',
532+ body: JSON.stringify(payload)
533+ });
534+
535+ if (!response.ok) {
536+ const errorData = await response.json();
537+ throw new Error(errorData.error || 'Failed to save message');
538+ }
539+
540+ const data = (await response.json()) as SendMessageResponse;
541+ if (!data.success) {
542+ throw new Error(data.error);
543+ }
544+
545+ console.debug('Message sent successfully:', data.message);
546+ await invalidate('chat:messages');
547+ return data;
548+ } catch (error) {
549+ console.debug('Error sending message:', error);
550+ return {
551+ success: false,
552+ error: 'Failed to send message'
553+ };
554+ }
555+ }
556+
557+ private setupSSE() {
558+ // Prevent creating multiple SSE connections if one is already active.
559+ if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
560+ console.debug('setupSSE: Active SSE connection exists, skipping setup.');
561+ return;
562+ }
563+ // Clear any existing timeouts
564+ if (this.connectionTimeout) {
565+ clearTimeout(this.connectionTimeout);
566+ this.connectionTimeout = null;
567+ }
568+
569+ if (this.connectionAttemptTimeout) {
570+ clearTimeout(this.connectionAttemptTimeout);
571+ this.connectionAttemptTimeout = null;
572+ }
573+
574+ // Debounce connection attempts
575+ this.connectionAttemptTimeout = setTimeout(() => {
576+ if (!this.currentUser) {
577+ console.debug('No current user, skipping SSE connection');
578+ return;
579+ }
580+
581+ if (this.isConnecting) {
582+ console.debug('Already connecting to SSE, skipping');
583+ return;
584+ }
585+
586+ this.connectSSE();
587+ }, 1000);
588+ }
589+
590+ private async connectSSE() {
591+ if (this.isConnecting) {
592+ console.debug('Already connecting to SSE, skipping');
593+ return;
594+ }
595+
596+ this.isConnecting = true;
597+
598+ try {
599+ // Ensure clean disconnect of any existing connection
600+ if (this.eventSource) {
601+ console.debug('Closing existing SSE connection');
602+ this.eventSource.close();
603+ this.eventSource = null;
604+ }
605+
606+ console.debug('Connecting to SSE...');
607+ this.eventSource = new EventSource('/api/sse', { withCredentials: true });
608+ // Reset the handlers flag for the new connection
609+ this.sseHandlersRegistered = false;
610+
611+ this.eventSource.onopen = () => {
612+ console.debug('SSE connection established');
613+ this.reconnectAttempts = 0;
614+ this.reconnectDelay = 1000;
615+ this.sseError = null;
616+ this.sseRetryAfter = null;
617+ this.isReconnecting = false;
618+ };
619+
620+ // Set up error handling
621+ this.eventSource.onerror = (error) => {
622+ console.debug('SSE connection error:', error);
623+ if (this.eventSource?.readyState === EventSource.CLOSED) {
624+ this.handleDisconnection();
625+ }
626+ };
627+
628+ // Register event handlers (only once)
629+ this.setupMessageHandlers();
630+ } catch (error) {
631+ console.debug('Error setting up SSE connection:', error);
632+ this.handleDisconnection();
633+ } finally {
634+ this.isConnecting = false;
635+ }
636+ }
637+
638+ // Register SSE event handlers only once per connection.
639+ private setupMessageHandlers() {
640+ if (this.sseHandlersRegistered || !this.eventSource) {
641+ return;
642+ }
643+
644+ // Listen for chat messages
645+ this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
646+ try {
647+ const messageData = JSON.parse(event.data) as Message;
648+ console.debug('Received chat message via SSE:', messageData);
649+ // Deduplicate and update messages array with the new message
650+ this.messages = Array.from(
651+ new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
652+ );
653+ // Sort messages so the newest are at the bottom
654+ this.messages.sort((a, b) => a.timestamp - b.timestamp);
655+ // Ensure the sender's data is available
656+ await this.ensureUserData(messageData.senderId);
657+ } catch (error) {
658+ console.debug('Error handling SSE chat message:', error);
659+ }
660+ });
661+
662+ // Modify buddy list update handler
663+ this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
664+ try {
665+ const buddyList = JSON.parse(event.data) as SafeUser[];
666+ this.updateOnlineUsers(buddyList);
667+ } catch (error) {
668+ console.debug('Error handling buddy list update via SSE:', error);
669+ }
670+ });
671+
672+ this.sseHandlersRegistered = true;
673+ }
674+
675+ private async ensureUserData(userId: string) {
676+ if (this.userCache[userId]) return;
677+
678+ console.debug('Fetching user data for:', userId);
679+ try {
680+ const response = await fetch(`/api/users/${userId}`, {
681+ credentials: 'include'
682+ });
683+ if (response.ok) {
684+ const userData = await response.json();
685+ if (userData.success && userData.user) {
686+ this.userCache[userId] = userData.user;
687+ if (!this.users.some((u) => u.id === userId)) {
688+ this.users = [...this.users, userData.user];
689+ }
690+ }
691+ }
692+ } catch (error) {
693+ console.debug('Error fetching user data:', error);
694+ }
695+ }
696+
697+ private handleDisconnection() {
698+ console.debug('SSE connection closed, reconnecting...');
699+ this.isReconnecting = true;
700+ this.reconnectAttempts++;
701+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
702+ console.debug('Max reconnect attempts reached, giving up');
703+ if (this.eventSource) {
704+ this.eventSource.close();
705+ }
706+ this.eventSource = null;
707+ return;
708+ }
709+ this.reconnectDelay *= 2; // Exponential backoff
710+ // Ensure any existing connection is properly closed before reconnecting
711+ if (this.eventSource) {
712+ this.eventSource.close();
713+ this.eventSource = null;
714+ }
715+ // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
716+ this.sseHandlersRegistered = false;
717+ this.setupSSE();
718+ }
719+
720+ enrichMessages(messages: Message[]): EnrichedMessage[] {
721+ console.debug('Enriching messages with user data', {
722+ messagesCount: messages.length,
723+ userCacheSize: Object.keys(this.userCache).length
724+ });
725+ return messages.map((message) => ({
726+ ...message,
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+ }
726740 }
727741
728-export const chatState = new ChatState();⏎̸
742+export const chatState = new ChatState();
M src/lib/styles/fonts.css +50 -50
@@ -6,95 +6,95 @@
66
77 /* Tahoma - Primary UI font */
88 @font-face {
9- font-family: 'Tahoma Web';
10- src: local('Tahoma'), local('Tahoma Regular');
11- font-weight: normal;
12- font-style: normal;
9+ font-family: 'Tahoma Web';
10+ src: local('Tahoma'), local('Tahoma Regular');
11+ font-weight: normal;
12+ font-style: normal;
1313 }
1414
1515 @font-face {
16- font-family: 'Tahoma Web';
17- src: local('Tahoma Bold');
18- font-weight: bold;
19- font-style: normal;
16+ font-family: 'Tahoma Web';
17+ src: local('Tahoma Bold');
18+ font-weight: bold;
19+ font-style: normal;
2020 }
2121
2222 /* Verdana - Secondary UI font */
2323 @font-face {
24- font-family: 'Verdana Web';
25- src: local('Verdana'), local('Verdana Regular');
26- font-weight: normal;
27- font-style: normal;
24+ font-family: 'Verdana Web';
25+ src: local('Verdana'), local('Verdana Regular');
26+ font-weight: normal;
27+ font-style: normal;
2828 }
2929
3030 @font-face {
31- font-family: 'Verdana Web';
32- src: local('Verdana Bold');
33- font-weight: bold;
34- font-style: normal;
31+ font-family: 'Verdana Web';
32+ src: local('Verdana Bold');
33+ font-weight: bold;
34+ font-style: normal;
3535 }
3636
3737 /* Arial - General purpose */
3838 @font-face {
39- font-family: 'Arial Web';
40- src: local('Arial'), local('Arial Regular');
41- font-weight: normal;
42- font-style: normal;
39+ font-family: 'Arial Web';
40+ src: local('Arial'), local('Arial Regular');
41+ font-weight: normal;
42+ font-style: normal;
4343 }
4444
4545 @font-face {
46- font-family: 'Arial Web';
47- src: local('Arial Bold');
48- font-weight: bold;
49- font-style: normal;
46+ font-family: 'Arial Web';
47+ src: local('Arial Bold');
48+ font-weight: bold;
49+ font-style: normal;
5050 }
5151
5252 /* Times New Roman - Serif font */
5353 @font-face {
54- font-family: 'Times New Roman Web';
55- src: local('Times New Roman'), local('Times New Roman Regular');
56- font-weight: normal;
57- font-style: normal;
54+ font-family: 'Times New Roman Web';
55+ src: local('Times New Roman'), local('Times New Roman Regular');
56+ font-weight: normal;
57+ font-style: normal;
5858 }
5959
6060 @font-face {
61- font-family: 'Times New Roman Web';
62- src: local('Times New Roman Bold');
63- font-weight: bold;
64- font-style: normal;
61+ font-family: 'Times New Roman Web';
62+ src: local('Times New Roman Bold');
63+ font-weight: bold;
64+ font-style: normal;
6565 }
6666
6767 /* Courier New - Monospace */
6868 @font-face {
69- font-family: 'Courier New Web';
70- src: local('Courier New'), local('Courier New Regular');
71- font-weight: normal;
72- font-style: normal;
69+ font-family: 'Courier New Web';
70+ src: local('Courier New'), local('Courier New Regular');
71+ font-weight: normal;
72+ font-style: normal;
7373 }
7474
7575 @font-face {
76- font-family: 'Courier New Web';
77- src: local('Courier New Bold');
78- font-weight: bold;
79- font-style: normal;
76+ font-family: 'Courier New Web';
77+ src: local('Courier New Bold');
78+ font-weight: bold;
79+ font-style: normal;
8080 }
8181
8282 /* Comic Sans MS - Fun font */
8383 @font-face {
84- font-family: 'Comic Sans MS Web';
85- src: local('Comic Sans MS'), local('Comic Sans MS Regular');
86- font-weight: normal;
87- font-style: normal;
84+ font-family: 'Comic Sans MS Web';
85+ src: local('Comic Sans MS'), local('Comic Sans MS Regular');
86+ font-weight: normal;
87+ font-style: normal;
8888 }
8989
9090 @font-face {
91- font-family: 'Comic Sans MS Web';
92- src: local('Comic Sans MS Bold');
93- font-weight: bold;
94- font-style: normal;
91+ font-family: 'Comic Sans MS Web';
92+ src: local('Comic Sans MS Bold');
93+ font-weight: bold;
94+ font-style: normal;
9595 }
9696
9797 /* Ensure font-display for better performance */
9898 @font-face {
99- font-display: swap;
100-}⏎̸
99+ font-display: swap;
100+}
M src/lib/types/chat.ts +59 -58
@@ -1,62 +1,63 @@
11 // Users table: holds authentication and profile details
22 export interface User {
3- id: string;
4- password: string; // hashed password
5- nickname: string;
6- status: UserStatus; // default is 'offline'
7- avatarUrl?: string | null;
8- createdAt: number; // Timestamp (e.g., Unix timestamp)
9- lastSeen?: number | null; // Optional last active timestamp
10- }
3+ id: string;
4+ password: string; // hashed password
5+ nickname: string;
6+ status: UserStatus; // default is 'offline'
7+ avatarUrl?: string | null;
8+ createdAt: number; // Timestamp (e.g., Unix timestamp)
9+ lastSeen?: number | null; // Optional last active timestamp
10+}
1111
1212 export type UserStatus = 'offline' | 'online' | 'away' | string;
13-
14- // Chat rooms table: supports both direct (private) and group chat conversations
15- export interface ChatRoom {
16- id: string;
17- name?: string; // Optional for direct chats, required for groups
18- type: 'direct' | 'group' | string; // Default is 'direct'
19- createdAt: number; // Creation timestamp
20- }
21-
22- // Messages table: stores the conversation messages
23- export interface Message {
24- id: string;
25- chatRoomId: string; // Reference to the chat room (ChatRoom.id)
26- senderId: string; // Reference to the sender (User.id)
27- content: string;
28- type: MessageType; // Default is 'chat'
29- timestamp: number; // Timestamp when the message was sent
30- styleData?: string; // JSON string of TextStyle object for formatting
31- hasFormatting?: boolean; // Flag to indicate if message has custom formatting
32- }
33-
34- // Safe user type for API responses - omits sensitive information
35- export type SafeUser = Omit<User, 'password' | 'createdAt'>;
36-
37- // Helper function to create a safe user object
38- export function createSafeUser(user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'>): SafeUser {
39- const safeUser: SafeUser = {
40- id: user.id,
41- nickname: user.nickname,
42- status: user.status,
43- avatarUrl: user.avatarUrl,
44- lastSeen: user.lastSeen ?? 0
45- };
46- return safeUser;
47- }
48-
49- // Update EnrichedMessage to use SafeUser
50- export interface EnrichedMessage extends Message {
51- user: SafeUser;
52- }
53-
54- export type MessageType = 'chat' | 'emote' | 'system' | string;
55-
56- export interface Session {
57- id: string;
58- userId: string;
59- expiresAt: number;
60- createdAt: number;
61- }
62-
13+
14+// Chat rooms table: supports both direct (private) and group chat conversations
15+export interface ChatRoom {
16+ id: string;
17+ name?: string; // Optional for direct chats, required for groups
18+ type: 'direct' | 'group' | string; // Default is 'direct'
19+ createdAt: number; // Creation timestamp
20+}
21+
22+// Messages table: stores the conversation messages
23+export interface Message {
24+ id: string;
25+ chatRoomId: string; // Reference to the chat room (ChatRoom.id)
26+ senderId: string; // Reference to the sender (User.id)
27+ content: string;
28+ type: MessageType; // Default is 'chat'
29+ timestamp: number; // Timestamp when the message was sent
30+ styleData?: string; // JSON string of TextStyle object for formatting
31+ hasFormatting?: boolean; // Flag to indicate if message has custom formatting
32+}
33+
34+// Safe user type for API responses - omits sensitive information
35+export type SafeUser = Omit<User, 'password' | 'createdAt'>;
36+
37+// Helper function to create a safe user object
38+export function createSafeUser(
39+ user: Partial<User> & Pick<User, 'id' | 'nickname' | 'status'>
40+): SafeUser {
41+ const safeUser: SafeUser = {
42+ id: user.id,
43+ nickname: user.nickname,
44+ status: user.status,
45+ avatarUrl: user.avatarUrl,
46+ lastSeen: user.lastSeen ?? 0
47+ };
48+ return safeUser;
49+}
50+
51+// Update EnrichedMessage to use SafeUser
52+export interface EnrichedMessage extends Message {
53+ user: SafeUser;
54+}
55+
56+export type MessageType = 'chat' | 'emote' | 'system' | string;
57+
58+export interface Session {
59+ id: string;
60+ userId: string;
61+ expiresAt: number;
62+ createdAt: number;
63+}
M src/lib/types/payloads.ts +22 -22
@@ -1,4 +1,4 @@
1-import type { SafeUser, Message } from "./chat";
1+import type { SafeUser, Message } from './chat';
22
33 // Login
44 export interface LoginResponseSuccess {
@@ -40,36 +40,36 @@ export interface SessionResponseError {
4040
4141 // Messages
4242 export interface SendMessageRequest {
43- content: string;
44- type?: Message['type'];
45- userId: string;
46- chatRoomId?: string;
47- styleData?: string; // JSON stringified TextStyle
43+ content: string;
44+ type?: Message['type'];
45+ userId: string;
46+ chatRoomId?: string;
47+ styleData?: string; // JSON stringified TextStyle
4848 }
4949
5050 export interface SendMessageResponseSuccess {
51- success: true;
52- message: Message;
51+ success: true;
52+ message: Message;
5353 }
5454
5555 export interface SendMessageResponseError {
56- success: false;
57- error: string;
58- retryAfter?: number; // Time in ms until next allowed message
59- isRateLimited?: boolean;
56+ success: false;
57+ error: string;
58+ retryAfter?: number; // Time in ms until next allowed message
59+ isRateLimited?: boolean;
6060 }
6161
6262 export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;
6363
6464 export interface GetMessagesResponseSuccess {
65- success: true;
66- messages: Message[];
67- hasMore: boolean;
65+ success: true;
66+ messages: Message[];
67+ hasMore: boolean;
6868 }
6969
7070 export interface GetMessagesResponseError {
71- success: false;
72- error: string;
71+ success: false;
72+ error: string;
7373 }
7474
7575 export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError;
@@ -77,13 +77,13 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon
7777 // ----- Public Room Payloads -----
7878
7979 export interface PublicRoomResponseSuccess {
80- success: true;
81- buddyList: SafeUser[];
80+ success: true;
81+ buddyList: SafeUser[];
8282 }
8383
8484 export interface PublicRoomResponseError {
85- success: false;
86- error: string;
85+ success: false;
86+ error: string;
8787 }
8888
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 @@
44 // Primary font is Pixelated MS Sans Serif from xp.css which works for all users
55 // Secondary fonts use local system fonts with proper fallbacks
66 export const RETRO_FONTS = {
7- pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' },
8- tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' },
9- verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' },
10- comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' },
11- timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' },
12- arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' },
13- courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' },
14- dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' }
7+ pixelated: {
8+ name: 'Pixelated MS Sans Serif',
9+ stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif'
10+ },
11+ tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' },
12+ verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' },
13+ comicSans: {
14+ name: 'Comic Sans MS',
15+ stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif'
16+ },
17+ timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' },
18+ arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' },
19+ courierNew: {
20+ name: 'Courier New',
21+ stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace'
22+ },
23+ dos: {
24+ name: 'Perfect DOS VGA 437',
25+ stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace'
26+ }
1527 } as const;
1628
1729 export type FontFamily = keyof typeof RETRO_FONTS;
1830
1931 // Generate 256-color web-safe palette ordered by hue
2032 export function generate256Colors(): string[] {
21- const colors: string[] = [];
22-
23- // Start with grayscale colors
24- colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF');
25-
26- // Then add colors in a more logical order by hue
27- // Red hues
28- colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000');
29- colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC');
30-
31- // Orange hues
32- colors.push('#663300', '#993300', '#CC3300', '#FF3300');
33- colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99');
34-
35- // Yellow hues
36- colors.push('#666600', '#999900', '#CCCC00', '#FFFF00');
37- colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC');
38-
39- // Green hues
40- colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00');
41- colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC');
42- colors.push('#336633', '#669966', '#99CC99', '#CCFFCC');
43-
44- // Cyan hues
45- colors.push('#006666', '#009999', '#00CCCC', '#00FFFF');
46- colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF');
47- colors.push('#336666', '#669999', '#99CCCC');
48-
49- // Blue hues
50- colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF');
51- colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF');
52- colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF');
53-
54- // Purple hues
55- colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF');
56- colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF');
57- colors.push('#663366', '#996699', '#CC99CC');
58-
59- // Pink hues
60- colors.push('#660033', '#990066', '#CC0099', '#FF0099');
61- colors.push('#FF3399', '#FF66CC', '#FF99CC');
62-
63- // Browns
64- colors.push('#663333', '#996633', '#CC9966', '#996666');
65-
66- // Now fill in any missing combinations to get full 216 web-safe colors
67- const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF'];
68- const existingColors = new Set(colors);
69-
70- for (const r of webSafeValues) {
71- for (const g of webSafeValues) {
72- for (const b of webSafeValues) {
73- const color = `#${r}${g}${b}`.toUpperCase();
74- if (!existingColors.has(color)) {
75- colors.push(color);
76- }
77- }
78- }
79- }
80-
81- return colors;
33+ const colors: string[] = [];
34+
35+ // Start with grayscale colors
36+ colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF');
37+
38+ // Then add colors in a more logical order by hue
39+ // Red hues
40+ colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000');
41+ colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC');
42+
43+ // Orange hues
44+ colors.push('#663300', '#993300', '#CC3300', '#FF3300');
45+ colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99');
46+
47+ // Yellow hues
48+ colors.push('#666600', '#999900', '#CCCC00', '#FFFF00');
49+ colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC');
50+
51+ // Green hues
52+ colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00');
53+ colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC');
54+ colors.push('#336633', '#669966', '#99CC99', '#CCFFCC');
55+
56+ // Cyan hues
57+ colors.push('#006666', '#009999', '#00CCCC', '#00FFFF');
58+ colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF');
59+ colors.push('#336666', '#669999', '#99CCCC');
60+
61+ // Blue hues
62+ colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF');
63+ colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF');
64+ colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF');
65+
66+ // Purple hues
67+ colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF');
68+ colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF');
69+ colors.push('#663366', '#996699', '#CC99CC');
70+
71+ // Pink hues
72+ colors.push('#660033', '#990066', '#CC0099', '#FF0099');
73+ colors.push('#FF3399', '#FF66CC', '#FF99CC');
74+
75+ // Browns
76+ colors.push('#663333', '#996633', '#CC9966', '#996666');
77+
78+ // Now fill in any missing combinations to get full 216 web-safe colors
79+ const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF'];
80+ const existingColors = new Set(colors);
81+
82+ for (const r of webSafeValues) {
83+ for (const g of webSafeValues) {
84+ for (const b of webSafeValues) {
85+ const color = `#${r}${g}${b}`.toUpperCase();
86+ if (!existingColors.has(color)) {
87+ colors.push(color);
88+ }
89+ }
90+ }
91+ }
92+
93+ return colors;
8294 }
8395
8496 // Classic retro colors for backward compatibility
8597 export const CLASSIC_COLORS = {
86- black: '#000000',
87- darkBlue: '#000080',
88- green: '#008000',
89- teal: '#008080',
90- darkRed: '#800000',
91- purple: '#800080',
92- brown: '#808000',
93- silver: '#C0C0C0',
94- gray: '#808080',
95- blue: '#0000FF',
96- lime: '#00FF00',
97- aqua: '#00FFFF',
98- red: '#FF0000',
99- fuchsia: '#FF00FF',
100- yellow: '#FFFF00',
101- white: '#FFFFFF'
98+ black: '#000000',
99+ darkBlue: '#000080',
100+ green: '#008000',
101+ teal: '#008080',
102+ darkRed: '#800000',
103+ purple: '#800080',
104+ brown: '#808000',
105+ silver: '#C0C0C0',
106+ gray: '#808080',
107+ blue: '#0000FF',
108+ lime: '#00FF00',
109+ aqua: '#00FFFF',
110+ red: '#FF0000',
111+ fuchsia: '#FF00FF',
112+ yellow: '#FFFF00',
113+ white: '#FFFFFF'
102114 } as const;
103115
104116 export type ClassicColor = keyof typeof CLASSIC_COLORS;
105117
106118 // Gradient presets inspired by AOL faders
107119 export const GRADIENT_PRESETS = {
108- rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'],
109- fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'],
110- ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'],
111- sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'],
112- neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'],
113- forest: ['#006400', '#228B22', '#32CD32', '#90EE90'],
114- royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD']
120+ rainbow: [
121+ '#FF0000',
122+ '#FF8000',
123+ '#FFFF00',
124+ '#80FF00',
125+ '#00FF00',
126+ '#00FF80',
127+ '#00FFFF',
128+ '#0080FF',
129+ '#0000FF',
130+ '#8000FF',
131+ '#FF00FF',
132+ '#FF0080'
133+ ],
134+ fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'],
135+ ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'],
136+ sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'],
137+ neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'],
138+ forest: ['#006400', '#228B22', '#32CD32', '#90EE90'],
139+ royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD']
115140 } as const;
116141
117142 export type GradientPreset = keyof typeof GRADIENT_PRESETS;
118143
119144 // Text styling options
120145 export interface TextStyle {
121- fontFamily: FontFamily;
122- fontSize: number; // in pixels
123- color?: string; // hex color string
124- gradient?: string[]; // array of 1-3 hex colors for gradients
125- bold: boolean;
126- italic: boolean;
127- underline: boolean;
128- strikethrough: boolean;
146+ fontFamily: FontFamily;
147+ fontSize: number; // in pixels
148+ color?: string; // hex color string
149+ gradient?: string[]; // array of 1-3 hex colors for gradients
150+ bold: boolean;
151+ italic: boolean;
152+ underline: boolean;
153+ strikethrough: boolean;
129154 }
130155
131156 // Default text style
132157 export const DEFAULT_TEXT_STYLE: TextStyle = {
133- fontFamily: 'pixelated',
134- fontSize: 14,
135- color: '#000000',
136- bold: false,
137- italic: false,
138- underline: false,
139- strikethrough: false
158+ fontFamily: 'pixelated',
159+ fontSize: 14,
160+ color: '#000000',
161+ bold: false,
162+ italic: false,
163+ underline: false,
164+ strikethrough: false
140165 };
141166
142167 // User preferences for text formatting
143168 export interface UserTextPreferences {
144- defaultStyle: TextStyle;
145- allowFormatting: boolean;
146- maxMessageLength: number;
169+ defaultStyle: TextStyle;
170+ allowFormatting: boolean;
171+ maxMessageLength: number;
147172 }
148173
149174 // Default user preferences
150175 export const DEFAULT_USER_PREFERENCES: UserTextPreferences = {
151- defaultStyle: DEFAULT_TEXT_STYLE,
152- allowFormatting: true,
153- maxMessageLength: 500
176+ defaultStyle: DEFAULT_TEXT_STYLE,
177+ allowFormatting: true,
178+ maxMessageLength: 500
154179 };
155180
156181 // Hex color validation
157182 export function isValidHexColor(color: string): boolean {
158- return /^#[0-9A-Fa-f]{6}$/.test(color);
183+ return /^#[0-9A-Fa-f]{6}$/.test(color);
159184 }
160185
161186 // Formatting validation
162187 export function validateTextStyle(style: Partial<TextStyle>): TextStyle {
163- return {
164- fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily,
165- fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)),
166- color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color,
167- gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined,
168- bold: Boolean(style.bold),
169- italic: Boolean(style.italic),
170- underline: Boolean(style.underline),
171- strikethrough: Boolean(style.strikethrough)
172- };
188+ return {
189+ fontFamily:
190+ style.fontFamily && style.fontFamily in RETRO_FONTS
191+ ? style.fontFamily
192+ : DEFAULT_TEXT_STYLE.fontFamily,
193+ fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)),
194+ color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color,
195+ gradient:
196+ style.gradient &&
197+ Array.isArray(style.gradient) &&
198+ style.gradient.length <= 3 &&
199+ style.gradient.every(isValidHexColor)
200+ ? style.gradient
201+ : undefined,
202+ bold: Boolean(style.bold),
203+ italic: Boolean(style.italic),
204+ underline: Boolean(style.underline),
205+ strikethrough: Boolean(style.strikethrough)
206+ };
173207 }
174208
175209 // Generate CSS style string from TextStyle
176210 export function generateCSSStyle(style: TextStyle): string {
177- const styles: string[] = [];
178-
179- // Font family
180- styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`);
181-
182- // Font size
183- styles.push(`font-size: ${style.fontSize}px`);
184-
185- // Color or gradient
186- if (style.gradient && style.gradient.length > 1) {
187- const gradientStops = style.gradient.join(', ');
188- styles.push(`background: linear-gradient(45deg, ${gradientStops})`);
189- styles.push(`-webkit-background-clip: text`);
190- styles.push(`-webkit-text-fill-color: transparent`);
191- styles.push(`background-clip: text`);
192- } else if (style.color) {
193- styles.push(`color: ${style.color}`);
194- }
195-
196- // Font weight
197- if (style.bold) {
198- // Use correct weight for Tahoma (200) vs standard bold (700)
199- if (style.fontFamily === 'tahoma') {
200- styles.push(`font-weight: 200`);
201- } else {
202- styles.push(`font-weight: 700`);
203- }
204- }
205-
206- // Font style
207- if (style.italic) {
208- styles.push(`font-style: italic`);
209- }
210-
211- // Text decoration
212- const decorations: string[] = [];
213- if (style.underline) decorations.push('underline');
214- if (style.strikethrough) decorations.push('line-through');
215- if (decorations.length > 0) {
216- styles.push(`text-decoration: ${decorations.join(' ')}`);
217- }
218-
219- return styles.join('; ');
211+ const styles: string[] = [];
212+
213+ // Font family
214+ styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`);
215+
216+ // Font size
217+ styles.push(`font-size: ${style.fontSize}px`);
218+
219+ // Color or gradient
220+ if (style.gradient && style.gradient.length > 1) {
221+ const gradientStops = style.gradient.join(', ');
222+ styles.push(`background: linear-gradient(45deg, ${gradientStops})`);
223+ styles.push(`-webkit-background-clip: text`);
224+ styles.push(`-webkit-text-fill-color: transparent`);
225+ styles.push(`background-clip: text`);
226+ } else if (style.color) {
227+ styles.push(`color: ${style.color}`);
228+ }
229+
230+ // Font weight
231+ if (style.bold) {
232+ // Use correct weight for Tahoma (200) vs standard bold (700)
233+ if (style.fontFamily === 'tahoma') {
234+ styles.push(`font-weight: 200`);
235+ } else {
236+ styles.push(`font-weight: 700`);
237+ }
238+ }
239+
240+ // Font style
241+ if (style.italic) {
242+ styles.push(`font-style: italic`);
243+ }
244+
245+ // Text decoration
246+ const decorations: string[] = [];
247+ if (style.underline) decorations.push('underline');
248+ if (style.strikethrough) decorations.push('line-through');
249+ if (decorations.length > 0) {
250+ styles.push(`text-decoration: ${decorations.join(' ')}`);
251+ }
252+
253+ return styles.join('; ');
220254 }
221255
222256 // Generate CSS style string for input fields (without font-family which should be handled by classes)
223257 export function generateInputCSSStyle(style: TextStyle): string {
224- const styles: string[] = [];
225-
226- // Font size
227- styles.push(`font-size: ${style.fontSize}px`);
228-
229- // Color handling for inputs
230- if (style.gradient && style.gradient.length > 1) {
231- // Apply gradient background with text clipping for gradient effect
232- const gradientStops = style.gradient.join(', ');
233- styles.push(`background: linear-gradient(to right, ${gradientStops})`);
234- styles.push(`-webkit-background-clip: text`);
235- styles.push(`-webkit-text-fill-color: transparent`);
236- styles.push(`background-clip: text`);
237- styles.push(`letter-spacing: 0`);
238- styles.push(`word-spacing: 0`);
239- } else if (style.gradient && style.gradient.length === 1) {
240- // Single color from gradient array
241- styles.push(`color: ${style.gradient[0]}`);
242- } else if (style.color) {
243- styles.push(`color: ${style.color}`);
244- } else {
245- // Fallback to black if no color is set
246- styles.push(`color: #000000`);
247- }
248-
249- // Font weight
250- if (style.bold) {
251- // Use correct weight for Tahoma (200) vs standard bold (700)
252- if (style.fontFamily === 'tahoma') {
253- styles.push(`font-weight: 200`);
254- } else {
255- styles.push(`font-weight: 700`);
256- }
257- }
258-
259- // Font style
260- if (style.italic) {
261- styles.push(`font-style: italic`);
262- }
263-
264- // Text decoration
265- const decorations: string[] = [];
266- if (style.underline) decorations.push('underline');
267- if (style.strikethrough) decorations.push('line-through');
268- if (decorations.length > 0) {
269- styles.push(`text-decoration: ${decorations.join(' ')}`);
270- }
271-
272- return styles.join('; ');
273-}⏎̸
258+ const styles: string[] = [];
259+
260+ // Font size
261+ styles.push(`font-size: ${style.fontSize}px`);
262+
263+ // Color handling for inputs
264+ if (style.gradient && style.gradient.length > 1) {
265+ // Apply gradient background with text clipping for gradient effect
266+ const gradientStops = style.gradient.join(', ');
267+ styles.push(`background: linear-gradient(to right, ${gradientStops})`);
268+ styles.push(`-webkit-background-clip: text`);
269+ styles.push(`-webkit-text-fill-color: transparent`);
270+ styles.push(`background-clip: text`);
271+ styles.push(`letter-spacing: 0`);
272+ styles.push(`word-spacing: 0`);
273+ } else if (style.gradient && style.gradient.length === 1) {
274+ // Single color from gradient array
275+ styles.push(`color: ${style.gradient[0]}`);
276+ } else if (style.color) {
277+ styles.push(`color: ${style.color}`);
278+ } else {
279+ // Fallback to black if no color is set
280+ styles.push(`color: #000000`);
281+ }
282+
283+ // Font weight
284+ if (style.bold) {
285+ // Use correct weight for Tahoma (200) vs standard bold (700)
286+ if (style.fontFamily === 'tahoma') {
287+ styles.push(`font-weight: 200`);
288+ } else {
289+ styles.push(`font-weight: 700`);
290+ }
291+ }
292+
293+ // Font style
294+ if (style.italic) {
295+ styles.push(`font-style: italic`);
296+ }
297+
298+ // Text decoration
299+ const decorations: string[] = [];
300+ if (style.underline) decorations.push('underline');
301+ if (style.strikethrough) decorations.push('line-through');
302+ if (decorations.length > 0) {
303+ styles.push(`text-decoration: ${decorations.join(' ')}`);
304+ }
305+
306+ return styles.join('; ');
307+}
M src/lib/types/turnstile.d.ts +14 -14
@@ -1,23 +1,23 @@
11 interface Turnstile {
2- render: (element: HTMLElement, options: TurnstileOptions) => string;
3- remove: (widgetId: string) => void;
2+ render: (element: HTMLElement, options: TurnstileOptions) => string;
3+ remove: (widgetId: string) => void;
44 }
55
66 interface TurnstileOptions {
7- sitekey: string;
8- callback: (token: string) => void;
9- "refresh-expired"?: "auto" | "manual";
10- size?: "invisible" | "normal" | "compact";
11- "error-callback"?: (error: unknown) => void;
12- "expired-callback"?: () => void;
13- theme?: "light" | "dark" | "auto";
14- language?: string;
7+ sitekey: string;
8+ callback: (token: string) => void;
9+ 'refresh-expired'?: 'auto' | 'manual';
10+ size?: 'invisible' | 'normal' | 'compact';
11+ 'error-callback'?: (error: unknown) => void;
12+ 'expired-callback'?: () => void;
13+ theme?: 'light' | 'dark' | 'auto';
14+ language?: string;
1515 }
1616
1717 declare global {
18- interface Window {
19- turnstile?: Turnstile;
20- }
18+ interface Window {
19+ turnstile?: Turnstile;
20+ }
2121 }
2222
23-export type { Turnstile, TurnstileOptions }; ⏎̸
23+export type { Turnstile, TurnstileOptions };
M src/lib/utils/chat.server.ts +44 -40
@@ -1,55 +1,59 @@
1-import type { LibSQLDatabase } from "drizzle-orm/libsql";
2-import { createLogger } from "$lib/utils/logger.server";
3-import schema, { chatRooms } from "$lib/db/schema";
4-import { eq } from "drizzle-orm";
5-import { env } from "$env/dynamic/public";
1+import type { LibSQLDatabase } from 'drizzle-orm/libsql';
2+import { createLogger } from '$lib/utils/logger.server';
3+import schema, { chatRooms } from '$lib/db/schema';
4+import { eq } from 'drizzle-orm';
5+import { env } from '$env/dynamic/public';
66
77 const log = createLogger('chat-utils');
88
99 // 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';
1112
1213 // Type guard for LibSQL errors
1314 interface LibSQLError {
14- code: string;
15- message: string;
15+ code: string;
16+ message: string;
1617 }
1718
1819 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;
2021 }
2122
2223 // Function to ensure default chat room exists
2324 export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) {
24- log.debug('Ensuring default chat room exists...');
25- try {
26- // Use a transaction to handle concurrent creation attempts
27- await db.transaction(async (tx) => {
28- const defaultRoom = await tx.select()
29- .from(chatRooms)
30- .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID))
31- .get();
25+ log.debug('Ensuring default chat room exists...');
26+ try {
27+ // Use a transaction to handle concurrent creation attempts
28+ await db.transaction(async (tx) => {
29+ const defaultRoom = await tx
30+ .select()
31+ .from(chatRooms)
32+ .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID))
33+ .get();
3234
33- if (!defaultRoom) {
34- log.debug('Creating default chat room...');
35- await tx.insert(chatRooms).values({
36- id: DEFAULT_CHAT_ROOM_ID,
37- name: 'General',
38- type: 'group',
39- createdAt: Date.now()
40- });
41- log.debug('Default chat room created successfully');
42- } else {
43- log.debug('Default chat room already exists');
44- }
45- });
46- } catch (error: unknown) {
47- // If the error is a unique constraint violation, another process probably created the room
48- if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') {
49- log.debug('Default chat room was created by another process');
50- return;
51- }
52- log.error('Error ensuring default chat room:', { error: error instanceof Error ? error.message : 'Unknown error' });
53- throw error;
54- }
55-} ⏎̸
35+ if (!defaultRoom) {
36+ log.debug('Creating default chat room...');
37+ await tx.insert(chatRooms).values({
38+ id: DEFAULT_CHAT_ROOM_ID,
39+ name: 'General',
40+ type: 'group',
41+ createdAt: Date.now()
42+ });
43+ log.debug('Default chat room created successfully');
44+ } else {
45+ log.debug('Default chat room already exists');
46+ }
47+ });
48+ } catch (error: unknown) {
49+ // If the error is a unique constraint violation, another process probably created the room
50+ if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') {
51+ log.debug('Default chat room was created by another process');
52+ return;
53+ }
54+ log.error('Error ensuring default chat room:', {
55+ error: error instanceof Error ? error.message : 'Unknown error'
56+ });
57+ throw error;
58+ }
59+}
M src/lib/utils/date-format.ts +101 -95
@@ -4,15 +4,17 @@
44 * @returns The formatted date string
55 */
66 export function formatFrenchDateTime(date: Date): string {
7- console.debug('Formatting date to French style:', date);
8- return date.toLocaleDateString('fr-FR', {
9- day: '2-digit',
10- month: '2-digit',
11- year: 'numeric',
12- hour: '2-digit',
13- minute: '2-digit',
14- hour12: false
15- }).replace(' à ', ' à '); // Ensure proper spacing around 'à'
7+ console.debug('Formatting date to French style:', date);
8+ return date
9+ .toLocaleDateString('fr-FR', {
10+ day: '2-digit',
11+ month: '2-digit',
12+ year: 'numeric',
13+ hour: '2-digit',
14+ minute: '2-digit',
15+ hour12: false
16+ })
17+ .replace(' à ', ' à '); // Ensure proper spacing around 'à'
1618 }
1719
1820 /**
@@ -21,12 +23,14 @@ export function formatFrenchDateTime(date: Date): string {
2123 * @returns The formatted time string
2224 */
2325 export function formatFrenchTime(date: Date): string {
24- console.debug('Formatting time to French style:', date);
25- return date.toLocaleTimeString('fr-FR', {
26- hour: '2-digit',
27- minute: '2-digit',
28- hour12: false
29- }).replace(':', 'h');
26+ console.debug('Formatting time to French style:', date);
27+ return date
28+ .toLocaleTimeString('fr-FR', {
29+ hour: '2-digit',
30+ minute: '2-digit',
31+ hour12: false
32+ })
33+ .replace(':', 'h');
3034 }
3135
3236 /**
@@ -35,12 +39,12 @@ export function formatFrenchTime(date: Date): string {
3539 * @returns The formatted date string
3640 */
3741 export function formatFrenchDate(date: Date): string {
38- console.debug('Formatting date to French style:', date);
39- return date.toLocaleDateString('fr-FR', {
40- day: '2-digit',
41- month: '2-digit',
42- year: 'numeric'
43- });
42+ console.debug('Formatting date to French style:', date);
43+ return date.toLocaleDateString('fr-FR', {
44+ day: '2-digit',
45+ month: '2-digit',
46+ year: 'numeric'
47+ });
4448 }
4549
4650 /**
@@ -49,37 +53,37 @@ export function formatFrenchDate(date: Date): string {
4953 * @returns The formatted relative time string
5054 */
5155 export function formatFrenchRelativeTime(date: Date): string {
52- console.debug('Formatting relative time to French style:', date);
53- const now = new Date();
54- const diff = now.getTime() - date.getTime();
55- const seconds = Math.floor(diff / 1000);
56- const minutes = Math.floor(seconds / 60);
57- const hours = Math.floor(minutes / 60);
58- const days = Math.floor(hours / 24);
59- const months = Math.floor(days / 30);
60- const years = Math.floor(days / 365);
61-
62- if (seconds < 60) {
63- return "à l'instant";
64- }
65-
66- if (minutes < 60) {
67- return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
68- }
69-
70- if (hours < 24) {
71- return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
72- }
73-
74- if (days < 30) {
75- return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`;
76- }
77-
78- if (months < 12) {
79- return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`;
80- }
81-
82- return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`;
56+ console.debug('Formatting relative time to French style:', date);
57+ const now = new Date();
58+ const diff = now.getTime() - date.getTime();
59+ const seconds = Math.floor(diff / 1000);
60+ const minutes = Math.floor(seconds / 60);
61+ const hours = Math.floor(minutes / 60);
62+ const days = Math.floor(hours / 24);
63+ const months = Math.floor(days / 30);
64+ const years = Math.floor(days / 365);
65+
66+ if (seconds < 60) {
67+ return "à l'instant";
68+ }
69+
70+ if (minutes < 60) {
71+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
72+ }
73+
74+ if (hours < 24) {
75+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
76+ }
77+
78+ if (days < 30) {
79+ return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`;
80+ }
81+
82+ if (months < 12) {
83+ return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`;
84+ }
85+
86+ return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`;
8387 }
8488
8589 /**
@@ -89,35 +93,37 @@ export function formatFrenchRelativeTime(date: Date): string {
8993 * @returns The formatted relative time string with more detail
9094 */
9195 export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string {
92- console.debug('Formatting detailed relative time to French style:', date);
93- const now = new Date();
94- const diff = now.getTime() - date.getTime();
95- const seconds = Math.floor(diff / 1000);
96- const minutes = Math.floor(seconds / 60);
97- const hours = Math.floor(minutes / 60);
98-
99- if (seconds < 60) {
100- return withSeconds ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` : "à l'instant";
101- }
102-
103- if (minutes < 60) {
104- const remainingSeconds = seconds % 60;
105- if (withSeconds && remainingSeconds > 0) {
106- return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`;
107- }
108- return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
109- }
110-
111- if (hours < 24) {
112- const remainingMinutes = minutes % 60;
113- if (remainingMinutes > 0) {
114- return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`;
115- }
116- return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
117- }
118-
119- // For anything longer than 24 hours, use the simple format
120- return formatFrenchRelativeTime(date);
96+ console.debug('Formatting detailed relative time to French style:', date);
97+ const now = new Date();
98+ const diff = now.getTime() - date.getTime();
99+ const seconds = Math.floor(diff / 1000);
100+ const minutes = Math.floor(seconds / 60);
101+ const hours = Math.floor(minutes / 60);
102+
103+ if (seconds < 60) {
104+ return withSeconds
105+ ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}`
106+ : "à l'instant";
107+ }
108+
109+ if (minutes < 60) {
110+ const remainingSeconds = seconds % 60;
111+ if (withSeconds && remainingSeconds > 0) {
112+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`;
113+ }
114+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
115+ }
116+
117+ if (hours < 24) {
118+ const remainingMinutes = minutes % 60;
119+ if (remainingMinutes > 0) {
120+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`;
121+ }
122+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
123+ }
124+
125+ // For anything longer than 24 hours, use the simple format
126+ return formatFrenchRelativeTime(date);
121127 }
122128
123129 /**
@@ -126,17 +132,17 @@ export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false
126132 * @returns The formatted relative time string or a fallback message
127133 */
128134 export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string {
129- console.debug('Formatting safe relative time to French style:', timestamp);
130- if (!timestamp) {
131- return "jamais";
132- }
133-
134- const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
135-
136- // Check if the date is valid
137- if (isNaN(date.getTime())) {
138- return "date invalide";
139- }
140-
141- return formatFrenchRelativeTime(date);
142-} ⏎̸
135+ console.debug('Formatting safe relative time to French style:', timestamp);
136+ if (!timestamp) {
137+ return 'jamais';
138+ }
139+
140+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
141+
142+ // Check if the date is valid
143+ if (isNaN(date.getTime())) {
144+ return 'date invalide';
145+ }
146+
147+ return formatFrenchRelativeTime(date);
148+}
M src/lib/utils/logger.server.ts +40 -40
@@ -3,53 +3,53 @@ import pino from 'pino';
33 const logLevel = process.env.NODE_ENV === 'development' ? 'debug' : 'info';
44
55 class LoggerService {
6- private static instance: LoggerService;
7- private logger: pino.Logger;
8- private moduleLoggers: Map<string, Logger>;
6+ private static instance: LoggerService;
7+ private logger: pino.Logger;
8+ private moduleLoggers: Map<string, Logger>;
99
10- private constructor() {
11- this.logger = pino({
12- transport: {
13- target: 'pino-pretty',
14- options: {
15- colorize: true,
16- translateTime: 'HH:MM:ss',
17- ignore: 'pid,hostname',
18- },
19- },
20- level: logLevel,
21- });
22- this.moduleLoggers = new Map();
23- }
10+ private constructor() {
11+ this.logger = pino({
12+ transport: {
13+ target: 'pino-pretty',
14+ options: {
15+ colorize: true,
16+ translateTime: 'HH:MM:ss',
17+ ignore: 'pid,hostname'
18+ }
19+ },
20+ level: logLevel
21+ });
22+ this.moduleLoggers = new Map();
23+ }
2424
25- public static getInstance(): LoggerService {
26- if (!LoggerService.instance) {
27- LoggerService.instance = new LoggerService();
28- }
29- return LoggerService.instance;
30- }
25+ public static getInstance(): LoggerService {
26+ if (!LoggerService.instance) {
27+ LoggerService.instance = new LoggerService();
28+ }
29+ return LoggerService.instance;
30+ }
3131
32- public getLogger(module: string): Logger {
33- if (!this.moduleLoggers.has(module)) {
34- this.moduleLoggers.set(module, {
35- debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg),
36- info: (msg: string, obj = {}) => this.logger.info({ module, ...obj }, msg),
37- warn: (msg: string, obj = {}) => this.logger.warn({ module, ...obj }, msg),
38- error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg)
39- });
40- }
41- return this.moduleLoggers.get(module)!;
42- }
32+ public getLogger(module: string): Logger {
33+ if (!this.moduleLoggers.has(module)) {
34+ this.moduleLoggers.set(module, {
35+ debug: (msg: string, obj = {}) => this.logger.debug({ module, ...obj }, msg),
36+ info: (msg: string, obj = {}) => this.logger.info({ module, ...obj }, msg),
37+ warn: (msg: string, obj = {}) => this.logger.warn({ module, ...obj }, msg),
38+ error: (msg: string, obj = {}) => this.logger.error({ module, ...obj }, msg)
39+ });
40+ }
41+ return this.moduleLoggers.get(module)!;
42+ }
4343 }
4444
4545 export interface Logger {
46- debug: (msg: string, obj?: object) => void;
47- info: (msg: string, obj?: object) => void;
48- warn: (msg: string, obj?: object) => void;
49- error: (msg: string, obj?: object) => void;
46+ debug: (msg: string, obj?: object) => void;
47+ info: (msg: string, obj?: object) => void;
48+ warn: (msg: string, obj?: object) => void;
49+ error: (msg: string, obj?: object) => void;
5050 }
5151
5252 // Export a function to get a logger for a specific module
5353 export const createLogger = (module: string): Logger => {
54- return LoggerService.getInstance().getLogger(module);
55-}; ⏎̸
54+ return LoggerService.getInstance().getLogger(module);
55+};
M src/lib/utils/password.ts +41 -45
@@ -4,23 +4,23 @@
44 * Helper function that converts an ArrayBuffer to a hex string.
55 */
66 function bufferToHex(buffer: ArrayBuffer): string {
7- return Array.from(new Uint8Array(buffer))
8- .map(b => b.toString(16).padStart(2, '0'))
9- .join('');
7+ return Array.from(new Uint8Array(buffer))
8+ .map((b) => b.toString(16).padStart(2, '0'))
9+ .join('');
1010 }
1111
1212 /**
1313 * Helper function that converts a hex string to a Uint8Array.
1414 */
1515 function hexToBuffer(hex: string): Uint8Array {
16- if (hex.length % 2 !== 0) {
17- throw new Error('Invalid hex string');
18- }
19- const arr = new Uint8Array(hex.length / 2);
20- for (let i = 0; i < hex.length; i += 2) {
21- arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
22- }
23- return arr;
16+ if (hex.length % 2 !== 0) {
17+ throw new Error('Invalid hex string');
18+ }
19+ const arr = new Uint8Array(hex.length / 2);
20+ for (let i = 0; i < hex.length; i += 2) {
21+ arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
22+ }
23+ return arr;
2424 }
2525
2626 /**
@@ -30,28 +30,24 @@ function hexToBuffer(hex: string): Uint8Array {
3030 * @returns The derived key in hex format.
3131 */
3232 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
33- const enc = new TextEncoder();
34- const keyMaterial = enc.encode(password);
35- console.debug("$state: Key material for password derivation", keyMaterial);
36- const key = await crypto.subtle.importKey(
37- "raw",
38- keyMaterial,
39- { name: "PBKDF2" },
40- false,
41- ["deriveBits"]
42- );
43- const derivedBits = await crypto.subtle.deriveBits(
44- {
45- name: "PBKDF2",
46- salt: salt,
47- iterations: 100000,
48- hash: "SHA-512"
49- },
50- key,
51- 512
52- );
53- console.debug("$state: Derived bits", derivedBits);
54- return bufferToHex(derivedBits);
33+ const enc = new TextEncoder();
34+ const keyMaterial = enc.encode(password);
35+ console.debug('$state: Key material for password derivation', keyMaterial);
36+ const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
37+ 'deriveBits'
38+ ]);
39+ const derivedBits = await crypto.subtle.deriveBits(
40+ {
41+ name: 'PBKDF2',
42+ salt: salt.buffer as ArrayBuffer,
43+ iterations: 100000,
44+ hash: 'SHA-512'
45+ },
46+ key,
47+ 512
48+ );
49+ console.debug('$state: Derived bits', derivedBits);
50+ return bufferToHex(derivedBits);
5551 }
5652
5753 /**
@@ -60,11 +56,11 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
6056 * @returns A promise that resolves to the hashed password in the format 'salt:hash'.
6157 */
6258 export async function hashPassword(password: string): Promise<string> {
63- const salt = crypto.getRandomValues(new Uint8Array(16));
64- console.debug("$state: Generated salt", salt);
65- const saltHex = bufferToHex(salt.buffer);
66- const hash = await derivePasswordHash(password, salt);
67- return `${saltHex}:${hash}`;
59+ const salt = crypto.getRandomValues(new Uint8Array(16));
60+ console.debug('$state: Generated salt', salt);
61+ const saltHex = bufferToHex(salt.buffer);
62+ const hash = await derivePasswordHash(password, salt);
63+ return `${saltHex}:${hash}`;
6864 }
6965
7066 /**
@@ -74,10 +70,10 @@ export async function hashPassword(password: string): Promise<string> {
7470 * @returns A promise that resolves to true if the password matches, false otherwise.
7571 */
7672 export async function verifyPassword(password: string, hash: string): Promise<boolean> {
77- const [saltHex, hashValue] = hash.split(':');
78- const salt = hexToBuffer(saltHex);
79- console.debug("$state: Salt extracted from stored hash", salt);
80- const derivedHash = await derivePasswordHash(password, salt);
81- console.debug("$state: Derived hash for verification", derivedHash);
82- return derivedHash === hashValue;
83-} ⏎̸
73+ const [saltHex, hashValue] = hash.split(':');
74+ const salt = hexToBuffer(saltHex);
75+ console.debug('$state: Salt extracted from stored hash', salt);
76+ const derivedHash = await derivePasswordHash(password, salt);
77+ console.debug('$state: Derived hash for verification', derivedHash);
78+ return derivedHash === hashValue;
79+}
M src/lib/utils/security-tests.ts +320 -322
@@ -1,373 +1,371 @@
11 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';
33
44 // XSS Test Vectors
55 const XSS_TEST_VECTORS = [
6- // Script injection
7- '<script>alert("XSS")</script>',
8- '<script src="malicious.js"></script>',
9- '<img src="x" onerror="alert(\'XSS\')">',
10- '<svg onload="alert(\'XSS\')">',
11- '<iframe src="javascript:alert(\'XSS\')"></iframe>',
12-
13- // Event handlers
14- '<div onclick="alert(\'XSS\')">Click me</div>',
15- '<input onfocus="alert(\'XSS\')" autofocus>',
16- '<body onload="alert(\'XSS\')">',
17- '<a href="javascript:alert(\'XSS\')">Link</a>',
18-
19- // CSS injection
20- '<style>body{background:url("javascript:alert(\'XSS\')")}</style>',
21- '<div style="background-image:url(javascript:alert(\'XSS\'))">',
22- '<div style="expression(alert(\'XSS\'))">',
23-
24- // Data URIs
25- '<img src="data:text/html,<script>alert(\'XSS\')</script>">',
26- '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">',
27-
28- // HTML injection
29- '<object data="malicious.swf">',
30- '<embed src="malicious.swf">',
31- '<applet code="malicious.class">',
32-
33- // BBCode injection attempts
34- '[script]alert("XSS")[/script]',
35- '[img=javascript:alert("XSS")]',
36- '[url=javascript:alert("XSS")]Link[/url]',
37-
38- // Markdown injection attempts
39- '![XSS](javascript:alert("XSS"))',
40- '[XSS](javascript:alert("XSS"))',
41-
42- // Unicode and encoding bypasses
43- '<script>alert(&#34;XSS&#34;)</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;">',
46-
47- // CSS expression injection
48- '<div style="width:expression(alert(\'XSS\'))">',
49- '<div style="background:url(vbscript:alert(\'XSS\'))">',
50-
51- // Mixed content attacks
52- 'Normal text <script>alert("XSS")</script> more text',
53- '[b]Bold <script>alert("XSS")</script> text[/b]',
54- '**Bold <img src="x" onerror="alert(\'XSS\')"> text**'
6+ // Script injection
7+ '<script>alert("XSS")</script>',
8+ '<script src="malicious.js"></script>',
9+ '<img src="x" onerror="alert(\'XSS\')">',
10+ '<svg onload="alert(\'XSS\')">',
11+ '<iframe src="javascript:alert(\'XSS\')"></iframe>',
12+
13+ // Event handlers
14+ '<div onclick="alert(\'XSS\')">Click me</div>',
15+ '<input onfocus="alert(\'XSS\')" autofocus>',
16+ '<body onload="alert(\'XSS\')">',
17+ '<a href="javascript:alert(\'XSS\')">Link</a>',
18+
19+ // CSS injection
20+ '<style>body{background:url("javascript:alert(\'XSS\')")}</style>',
21+ '<div style="background-image:url(javascript:alert(\'XSS\'))">',
22+ '<div style="expression(alert(\'XSS\'))">',
23+
24+ // Data URIs
25+ '<img src="data:text/html,<script>alert(\'XSS\')</script>">',
26+ '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">',
27+
28+ // HTML injection
29+ '<object data="malicious.swf">',
30+ '<embed src="malicious.swf">',
31+ '<applet code="malicious.class">',
32+
33+ // BBCode injection attempts
34+ '[script]alert("XSS")[/script]',
35+ '[img=javascript:alert("XSS")]',
36+ '[url=javascript:alert("XSS")]Link[/url]',
37+
38+ // Markdown injection attempts
39+ '![XSS](javascript:alert("XSS"))',
40+ '[XSS](javascript:alert("XSS"))',
41+
42+ // Unicode and encoding bypasses
43+ '<script>alert(&#34;XSS&#34;)</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;">',
46+
47+ // CSS expression injection
48+ '<div style="width:expression(alert(\'XSS\'))">',
49+ '<div style="background:url(vbscript:alert(\'XSS\'))">',
50+
51+ // Mixed content attacks
52+ 'Normal text <script>alert("XSS")</script> more text',
53+ '[b]Bold <script>alert("XSS")</script> text[/b]',
54+ '**Bold <img src="x" onerror="alert(\'XSS\')"> text**'
5555 ];
5656
5757 // CSS Injection Test Vectors
5858 const CSS_INJECTION_VECTORS = [
59- 'color: red; background: url(javascript:alert("XSS"))',
60- 'font-family: "Arial"; } body { background: red; .fake {',
61- 'font-size: expression(alert("XSS"))',
62- 'background: url("data:text/html,<script>alert(\'XSS\')</script>")',
63- 'color: red; } .injected { background: red',
64- '@import url("malicious.css")',
65- 'behavior: url("malicious.htc")'
59+ 'color: red; background: url(javascript:alert("XSS"))',
60+ 'font-family: "Arial"; } body { background: red; .fake {',
61+ 'font-size: expression(alert("XSS"))',
62+ 'background: url("data:text/html,<script>alert(\'XSS\')</script>")',
63+ 'color: red; } .injected { background: red',
64+ '@import url("malicious.css")',
65+ 'behavior: url("malicious.htc")'
6666 ];
6767
6868 // Test Results Interface
6969 interface SecurityTestResult {
70- input: string;
71- output: string;
72- passed: boolean;
73- vulnerabilities: string[];
74- testType: string;
70+ input: string;
71+ output: string;
72+ passed: boolean;
73+ vulnerabilities: string[];
74+ testType: string;
7575 }
7676
7777 interface SecurityTestSuite {
78- xssTests: SecurityTestResult[];
79- cssInjectionTests: SecurityTestResult[];
80- validationTests: SecurityTestResult[];
81- gradientTests: SecurityTestResult[];
82- summary: {
83- totalTests: number;
84- passed: number;
85- failed: number;
86- vulnerabilities: string[];
87- };
78+ xssTests: SecurityTestResult[];
79+ cssInjectionTests: SecurityTestResult[];
80+ validationTests: SecurityTestResult[];
81+ gradientTests: SecurityTestResult[];
82+ summary: {
83+ totalTests: number;
84+ passed: number;
85+ failed: number;
86+ vulnerabilities: string[];
87+ };
8888 }
8989
9090 // Check if output contains dangerous patterns
9191 function detectVulnerabilities(output: string): string[] {
92- const vulnerabilities: string[] = [];
93-
94- // Script tags
95- if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) {
96- vulnerabilities.push('Script tag injection');
97- }
98-
99- // Event handlers
100- if (/on\w+\s*=/gi.test(output)) {
101- vulnerabilities.push('Event handler injection');
102- }
103-
104- // JavaScript URLs
105- if (/javascript\s*:/gi.test(output)) {
106- vulnerabilities.push('JavaScript URL injection');
107- }
108-
109- // Data URLs with HTML/JS
110- if (/data\s*:\s*text\/html/gi.test(output)) {
111- vulnerabilities.push('Data URL HTML injection');
112- }
113-
114- // CSS expressions
115- if (/expression\s*\(/gi.test(output)) {
116- vulnerabilities.push('CSS expression injection');
117- }
118-
119- // Dangerous CSS URLs
120- if (/url\s*\(\s*['"]*javascript:/gi.test(output)) {
121- vulnerabilities.push('CSS JavaScript URL injection');
122- }
123-
124- // Iframe with dangerous src
125- if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) {
126- vulnerabilities.push('Dangerous iframe injection');
127- }
128-
129- // Object/embed tags
130- if (/<(?:object|embed|applet)\b/gi.test(output)) {
131- vulnerabilities.push('Dangerous object/embed tag');
132- }
133-
134- // Style tag injection
135- if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) {
136- vulnerabilities.push('Style tag injection');
137- }
138-
139- return vulnerabilities;
92+ const vulnerabilities: string[] = [];
93+
94+ // Script tags
95+ if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) {
96+ vulnerabilities.push('Script tag injection');
97+ }
98+
99+ // Event handlers
100+ if (/on\w+\s*=/gi.test(output)) {
101+ vulnerabilities.push('Event handler injection');
102+ }
103+
104+ // JavaScript URLs
105+ if (/javascript\s*:/gi.test(output)) {
106+ vulnerabilities.push('JavaScript URL injection');
107+ }
108+
109+ // Data URLs with HTML/JS
110+ if (/data\s*:\s*text\/html/gi.test(output)) {
111+ vulnerabilities.push('Data URL HTML injection');
112+ }
113+
114+ // CSS expressions
115+ if (/expression\s*\(/gi.test(output)) {
116+ vulnerabilities.push('CSS expression injection');
117+ }
118+
119+ // Dangerous CSS URLs
120+ if (/url\s*\(\s*['"]*javascript:/gi.test(output)) {
121+ vulnerabilities.push('CSS JavaScript URL injection');
122+ }
123+
124+ // Iframe with dangerous src
125+ if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) {
126+ vulnerabilities.push('Dangerous iframe injection');
127+ }
128+
129+ // Object/embed tags
130+ if (/<(?:object|embed|applet)\b/gi.test(output)) {
131+ vulnerabilities.push('Dangerous object/embed tag');
132+ }
133+
134+ // Style tag injection
135+ if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) {
136+ vulnerabilities.push('Style tag injection');
137+ }
138+
139+ return vulnerabilities;
140140 }
141141
142142 // Run XSS tests
143143 async function runXSSTests(): Promise<SecurityTestResult[]> {
144- const results: SecurityTestResult[] = [];
145-
146- for (const vector of XSS_TEST_VECTORS) {
147- try {
148- const output = await formatText(vector, DEFAULT_TEXT_STYLE, true);
149- const vulnerabilities = detectVulnerabilities(output);
150-
151- results.push({
152- input: vector,
153- output,
154- passed: vulnerabilities.length === 0,
155- vulnerabilities,
156- testType: 'XSS'
157- });
158- } catch (error) {
159- results.push({
160- input: vector,
161- output: `Error: ${error}`,
162- passed: true, // Errors are acceptable for malicious input
163- vulnerabilities: [],
164- testType: 'XSS'
165- });
166- }
167- }
168-
169- return results;
144+ const results: SecurityTestResult[] = [];
145+
146+ for (const vector of XSS_TEST_VECTORS) {
147+ try {
148+ const output = await formatText(vector, DEFAULT_TEXT_STYLE, true);
149+ const vulnerabilities = detectVulnerabilities(output);
150+
151+ results.push({
152+ input: vector,
153+ output,
154+ passed: vulnerabilities.length === 0,
155+ vulnerabilities,
156+ testType: 'XSS'
157+ });
158+ } catch (error) {
159+ results.push({
160+ input: vector,
161+ output: `Error: ${error}`,
162+ passed: true, // Errors are acceptable for malicious input
163+ vulnerabilities: [],
164+ testType: 'XSS'
165+ });
166+ }
167+ }
168+
169+ return results;
170170 }
171171
172172 // Run CSS injection tests
173173 async function runCSSInjectionTests(): Promise<SecurityTestResult[]> {
174- const results: SecurityTestResult[] = [];
175-
176- for (const vector of CSS_INJECTION_VECTORS) {
177- try {
178- const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any };
179- const output = await formatText(`Test with style`, testStyle, true);
180- const vulnerabilities = detectVulnerabilities(output);
181-
182- results.push({
183- input: vector,
184- output,
185- passed: vulnerabilities.length === 0,
186- vulnerabilities,
187- testType: 'CSS Injection'
188- });
189- } catch (error) {
190- results.push({
191- input: vector,
192- output: `Error: ${error}`,
193- passed: true,
194- vulnerabilities: [],
195- testType: 'CSS Injection'
196- });
197- }
198- }
199-
200- return results;
174+ const results: SecurityTestResult[] = [];
175+
176+ for (const vector of CSS_INJECTION_VECTORS) {
177+ try {
178+ const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' };
179+ const output = await formatText(`Test with style`, testStyle, true);
180+ const vulnerabilities = detectVulnerabilities(output);
181+
182+ results.push({
183+ input: vector,
184+ output,
185+ passed: vulnerabilities.length === 0,
186+ vulnerabilities,
187+ testType: 'CSS Injection'
188+ });
189+ } catch (error) {
190+ results.push({
191+ input: vector,
192+ output: `Error: ${error}`,
193+ passed: true,
194+ vulnerabilities: [],
195+ testType: 'CSS Injection'
196+ });
197+ }
198+ }
199+
200+ return results;
201201 }
202202
203203 // Run validation tests
204204 function runValidationTests(): SecurityTestResult[] {
205- const results: SecurityTestResult[] = [];
206-
207- const testCases = [
208- 'Normal text',
209- 'Text with <script>alert("XSS")</script>',
210- 'Very long text '.repeat(200), // Test length limits
211- '', // Empty text
212- 'Text with\nnewlines\nand\ttabs',
213- 'Special chars: <>&"\'`',
214- 'Unicode: 你好世界 🌍 🎉',
215- 'Mixed: Normal **bold** and <script>alert("XSS")</script>'
216- ];
217-
218- for (const testCase of testCases) {
219- const validation = validateMessageContent(testCase);
220- const vulnerabilities = detectVulnerabilities(validation.sanitizedContent);
221-
222- results.push({
223- input: testCase,
224- output: validation.sanitizedContent,
225- passed: validation.isValid && vulnerabilities.length === 0,
226- vulnerabilities: [...validation.errors, ...vulnerabilities],
227- testType: 'Validation'
228- });
229- }
230-
231- return results;
205+ const results: SecurityTestResult[] = [];
206+
207+ const testCases = [
208+ 'Normal text',
209+ 'Text with <script>alert("XSS")</script>',
210+ 'Very long text '.repeat(200), // Test length limits
211+ '', // Empty text
212+ 'Text with\nnewlines\nand\ttabs',
213+ 'Special chars: <>&"\'`',
214+ 'Unicode: 你好世界 🌍 🎉',
215+ 'Mixed: Normal **bold** and <script>alert("XSS")</script>'
216+ ];
217+
218+ for (const testCase of testCases) {
219+ const validation = validateMessageContent(testCase);
220+ const vulnerabilities = detectVulnerabilities(validation.sanitizedContent);
221+
222+ results.push({
223+ input: testCase,
224+ output: validation.sanitizedContent,
225+ passed: validation.isValid && vulnerabilities.length === 0,
226+ vulnerabilities: [...validation.errors, ...vulnerabilities],
227+ testType: 'Validation'
228+ });
229+ }
230+
231+ return results;
232232 }
233233
234234 // Run gradient text security tests
235235 function runGradientTests(): SecurityTestResult[] {
236- const results: SecurityTestResult[] = [];
237-
238- const testCases = [
239- 'Normal gradient text',
240- '<script>alert("XSS")</script>',
241- 'Text with "quotes" and \'apostrophes\'',
242- 'Very long gradient text '.repeat(50),
243- 'Special chars: <>&\'"',
244- ''
245- ];
246-
247- const testColors = ['#FF0000', '#00FF00', '#0000FF'];
248-
249- for (const testCase of testCases) {
250- try {
251- const output = createGradientText(testCase, testColors);
252- const vulnerabilities = detectVulnerabilities(output);
253-
254- results.push({
255- input: testCase,
256- output,
257- passed: vulnerabilities.length === 0,
258- vulnerabilities,
259- testType: 'Gradient'
260- });
261- } catch (error) {
262- results.push({
263- input: testCase,
264- output: `Error: ${error}`,
265- passed: true,
266- vulnerabilities: [],
267- testType: 'Gradient'
268- });
269- }
270- }
271-
272- return results;
236+ const results: SecurityTestResult[] = [];
237+
238+ const testCases = [
239+ 'Normal gradient text',
240+ '<script>alert("XSS")</script>',
241+ 'Text with "quotes" and \'apostrophes\'',
242+ 'Very long gradient text '.repeat(50),
243+ 'Special chars: <>&\'"',
244+ ''
245+ ];
246+
247+ const testColors = ['#FF0000', '#00FF00', '#0000FF'];
248+
249+ for (const testCase of testCases) {
250+ try {
251+ const output = createGradientText(testCase, testColors);
252+ const vulnerabilities = detectVulnerabilities(output);
253+
254+ results.push({
255+ input: testCase,
256+ output,
257+ passed: vulnerabilities.length === 0,
258+ vulnerabilities,
259+ testType: 'Gradient'
260+ });
261+ } catch (error) {
262+ results.push({
263+ input: testCase,
264+ output: `Error: ${error}`,
265+ passed: true,
266+ vulnerabilities: [],
267+ testType: 'Gradient'
268+ });
269+ }
270+ }
271+
272+ return results;
273273 }
274274
275275 // Run complete security test suite
276276 export async function runSecurityTestSuite(): Promise<SecurityTestSuite> {
277- console.log('🔒 Running comprehensive security test suite...');
278-
279- const xssTests = await runXSSTests();
280- const cssInjectionTests = await runCSSInjectionTests();
281- const validationTests = runValidationTests();
282- const gradientTests = runGradientTests();
283-
284- const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests];
285- const passed = allTests.filter(test => test.passed).length;
286- const failed = allTests.length - passed;
287-
288- // Collect all unique vulnerabilities
289- const allVulnerabilities = new Set<string>();
290- allTests.forEach(test => {
291- test.vulnerabilities.forEach(vuln => allVulnerabilities.add(vuln));
292- });
293-
294- const results: SecurityTestSuite = {
295- xssTests,
296- cssInjectionTests,
297- validationTests,
298- gradientTests,
299- summary: {
300- totalTests: allTests.length,
301- passed,
302- failed,
303- vulnerabilities: Array.from(allVulnerabilities)
304- }
305- };
306-
307- console.log(`✅ Security tests completed:`);
308- console.log(` Total tests: ${results.summary.totalTests}`);
309- console.log(` Passed: ${results.summary.passed}`);
310- console.log(` Failed: ${results.summary.failed}`);
311-
312- if (results.summary.vulnerabilities.length > 0) {
313- console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities);
314- } else {
315- console.log(`🛡️ No vulnerabilities detected!`);
316- }
317-
318- return results;
277+ console.log('🔒 Running comprehensive security test suite...');
278+
279+ const xssTests = await runXSSTests();
280+ const cssInjectionTests = await runCSSInjectionTests();
281+ const validationTests = runValidationTests();
282+ const gradientTests = runGradientTests();
283+
284+ const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests];
285+ const passed = allTests.filter((test) => test.passed).length;
286+ const failed = allTests.length - passed;
287+
288+ // Collect all unique vulnerabilities
289+ const allVulnerabilities = new Set<string>();
290+ allTests.forEach((test) => {
291+ test.vulnerabilities.forEach((vuln) => allVulnerabilities.add(vuln));
292+ });
293+
294+ const results: SecurityTestSuite = {
295+ xssTests,
296+ cssInjectionTests,
297+ validationTests,
298+ gradientTests,
299+ summary: {
300+ totalTests: allTests.length,
301+ passed,
302+ failed,
303+ vulnerabilities: Array.from(allVulnerabilities)
304+ }
305+ };
306+
307+ console.log(`✅ Security tests completed:`);
308+ console.log(` Total tests: ${results.summary.totalTests}`);
309+ console.log(` Passed: ${results.summary.passed}`);
310+ console.log(` Failed: ${results.summary.failed}`);
311+
312+ if (results.summary.vulnerabilities.length > 0) {
313+ console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities);
314+ } else {
315+ console.log(`🛡️ No vulnerabilities detected!`);
316+ }
317+
318+ return results;
319319 }
320320
321321 // Quick security check function
322322 export async function quickSecurityCheck(input: string): Promise<boolean> {
323- try {
324- const output = await formatText(input, DEFAULT_TEXT_STYLE, true);
325- const vulnerabilities = detectVulnerabilities(output);
326- return vulnerabilities.length === 0;
327- } catch (error) {
328- // Errors on malicious input are acceptable
329- return true;
330- }
323+ try {
324+ const output = await formatText(input, DEFAULT_TEXT_STYLE, true);
325+ const vulnerabilities = detectVulnerabilities(output);
326+ return vulnerabilities.length === 0;
327+ } catch {
328+ // Errors on malicious input are acceptable
329+ return true;
330+ }
331331 }
332332
333333 // Style validation tests
334334 export function testStyleValidation(): boolean {
335- const testCases = [
336- // Valid styles
337- { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true },
338- { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true },
339-
340- // Invalid styles (should be sanitized)
341- { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' },
342- { fontFamily: 'tahoma', fontSize: -10, color: 'red' },
343- { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' }
344- ];
345-
346- for (const testCase of testCases) {
347- try {
348- const validated = validateTextStyle(testCase as Partial<TextStyle>);
349-
350- // Check that validated style has safe values
351- if (!validated.fontFamily ||
352- validated.fontSize < 8 ||
353- validated.fontSize > 72) {
354- console.error('Style validation failed for:', testCase);
355- return false;
356- }
357- } catch (error) {
358- console.error('Style validation error:', error);
359- return false;
360- }
361- }
362-
363- return true;
335+ const testCases = [
336+ // Valid styles
337+ { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true },
338+ { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true },
339+
340+ // Invalid styles (should be sanitized)
341+ { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' },
342+ { fontFamily: 'tahoma', fontSize: -10, color: 'red' },
343+ { fontFamily: 'tahoma', fontSize: 'invalid' as unknown as number, color: 'red' }
344+ ];
345+
346+ for (const testCase of testCases) {
347+ try {
348+ const validated = validateTextStyle(testCase as Partial<TextStyle>);
349+
350+ // Check that validated style has safe values
351+ if (!validated.fontFamily || validated.fontSize < 8 || validated.fontSize > 72) {
352+ console.error('Style validation failed for:', testCase);
353+ return false;
354+ }
355+ } catch (error) {
356+ console.error('Style validation error:', error);
357+ return false;
358+ }
359+ }
360+
361+ return true;
364362 }
365363
366364 // Export test utilities
367365 export {
368- XSS_TEST_VECTORS,
369- CSS_INJECTION_VECTORS,
370- detectVulnerabilities,
371- type SecurityTestResult,
372- type SecurityTestSuite
373-};⏎̸
366+ XSS_TEST_VECTORS,
367+ CSS_INJECTION_VECTORS,
368+ detectVulnerabilities,
369+ type SecurityTestResult,
370+ type SecurityTestSuite
371+};
M src/lib/utils/sseClient.ts +27 -21
@@ -3,24 +3,30 @@
33 * This helps avoid unauthenticated SSE requests that trigger rate limiting on the server.
44 */
55
6-export function initSSE(url: string, maxRetries: number = 5, retryDelay: number = 500): Promise<EventSource> {
7- return new Promise((resolve, reject) => {
8- let attempt = 0;
9- const tryConnect = () => {
10- if (document.cookie.includes('session=')) {
11- console.debug('Session cookie found, initializing SSE connection');
12- const eventSource = new EventSource(url);
13- resolve(eventSource);
14- } else {
15- attempt++;
16- console.debug(`Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`);
17- if (attempt >= maxRetries) {
18- reject(new Error('Session cookie not found after maximum retries'));
19- } else {
20- setTimeout(tryConnect, retryDelay);
21- }
22- }
23- };
24- tryConnect();
25- });
26-} ⏎̸
6+export function initSSE(
7+ url: string,
8+ maxRetries: number = 5,
9+ retryDelay: number = 500
10+): Promise<EventSource> {
11+ return new Promise((resolve, reject) => {
12+ let attempt = 0;
13+ const tryConnect = () => {
14+ if (document.cookie.includes('session=')) {
15+ console.debug('Session cookie found, initializing SSE connection');
16+ const eventSource = new EventSource(url);
17+ resolve(eventSource);
18+ } else {
19+ attempt++;
20+ console.debug(
21+ `Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`
22+ );
23+ if (attempt >= maxRetries) {
24+ reject(new Error('Session cookie not found after maximum retries'));
25+ } else {
26+ setTimeout(tryConnect, retryDelay);
27+ }
28+ }
29+ };
30+ tryConnect();
31+ });
32+}
M src/lib/utils/text-formatter.ts +302 -305
@@ -4,351 +4,348 @@ import remarkRehype from 'remark-rehype';
44 import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
55 import rehypeStringify from 'rehype-stringify';
66 import type { TextStyle } from '../types/text-formatting';
7-import { generateCSSStyle, validateTextStyle } from '../types/text-formatting';
7+import { validateTextStyle } from '../types/text-formatting';
88
99 // Define allowed HTML tags and attributes for security
1010 const ALLOWED_SCHEMA = {
11- ...defaultSchema,
12- tagNames: [
13- ...(defaultSchema.tagNames || []),
14- 'span',
15- 'strong',
16- 'em',
17- 'u',
18- 's',
19- 'mark'
20- ],
21- attributes: {
22- ...defaultSchema.attributes,
23- span: ['style', 'class', 'data-gradient'],
24- strong: ['style', 'class'],
25- em: ['style', 'class'],
26- u: ['style', 'class'],
27- s: ['style', 'class'],
28- mark: ['style', 'class']
29- },
30- protocols: {
31- ...defaultSchema.protocols,
32- style: []
33- }
11+ ...defaultSchema,
12+ tagNames: [...(defaultSchema.tagNames || []), 'span', 'strong', 'em', 'u', 's', 'mark'],
13+ attributes: {
14+ ...defaultSchema.attributes,
15+ span: ['style', 'class', 'data-gradient'],
16+ strong: ['style', 'class'],
17+ em: ['style', 'class'],
18+ u: ['style', 'class'],
19+ s: ['style', 'class'],
20+ mark: ['style', 'class']
21+ },
22+ protocols: {
23+ ...defaultSchema.protocols,
24+ style: []
25+ }
3426 };
3527
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-
4528 // BBCode-style formatting patterns
4629 const BB_CODE_PATTERNS: Array<{
47- pattern: RegExp;
48- replacement: string;
30+ pattern: RegExp;
31+ replacement: string;
4932 }> = [
50- // Bold
51- { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },
52-
53- // Italic
54- { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },
55-
56- // Underline
57- { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
58-
59- // Strikethrough
60- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
61-
62- // Color
63- { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' },
64-
65- // Size
66- { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' },
67-
68- // Font
69- { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' }
33+ // Bold
34+ { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },
35+
36+ // Italic
37+ { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },
38+
39+ // Underline
40+ { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
41+
42+ // Strikethrough
43+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
44+
45+ // Color
46+ {
47+ pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,
48+ replacement: '<span style="color: $1">$2</span>'
49+ },
50+
51+ // Size
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+ }
7062 ];
7163
72-// Markdown-style formatting patterns
64+// Markdown-style formatting patterns
7365 const MARKDOWN_PATTERNS: Array<{
74- pattern: RegExp;
75- replacement: string;
66+ pattern: RegExp;
67+ replacement: string;
7668 }> = [
77- // Bold (double asterisk or double underscore)
78- { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' },
79- { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' },
80-
81- // Italic (single asterisk or single underscore)
82- { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' },
83- { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' },
84-
85- // Strikethrough
86- { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },
87-
88- // Inline code
89- { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' }
69+ // Bold (double asterisk or double underscore)
70+ { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' },
71+ { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' },
72+
73+ // Italic (single asterisk or single underscore)
74+ { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' },
75+ { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' },
76+
77+ // Strikethrough
78+ { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },
79+
80+ // Inline code
81+ { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' }
9082 ];
9183
9284 // Preprocess text to convert BBCode and Markdown to HTML
9385 function preprocessFormatting(text: string): string {
94- let processedText = text;
95-
96- // Apply BBCode patterns first
97- BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {
98- processedText = processedText.replace(pattern, replacement);
99- });
100-
101- // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
102- MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
103- // Only apply if not already inside HTML tags
104- processedText = processedText.replace(pattern, (match, content) => {
105- // Simple check to avoid replacing inside existing HTML tags
106- const beforeMatch = processedText.substring(0, processedText.indexOf(match));
107- const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length;
108- const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length;
109-
110- // If we're inside an unclosed tag, don't apply markdown
111- if (openTags > closeTags) {
112- return match;
113- }
114-
115- return replacement.replace('$1', content);
116- });
117- });
118-
119- return processedText;
86+ let processedText = text;
87+
88+ // Apply BBCode patterns first
89+ BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {
90+ processedText = processedText.replace(pattern, replacement);
91+ });
92+
93+ // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
94+ MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
95+ // Only apply if not already inside HTML tags
96+ processedText = processedText.replace(pattern, (match, content) => {
97+ // Simple check to avoid replacing inside existing HTML tags
98+ const beforeMatch = processedText.substring(0, processedText.indexOf(match));
99+ const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length;
100+ const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length;
101+
102+ // If we're inside an unclosed tag, don't apply markdown
103+ if (openTags > closeTags) {
104+ return match;
105+ }
106+
107+ return replacement.replace('$1', content);
108+ });
109+ });
110+
111+ return processedText;
120112 }
121113
122114 // Main text formatting function
123115 export async function formatText(
124- text: string,
125- userStyle?: Partial<TextStyle>,
126- allowRichFormatting: boolean = true
116+ text: string,
117+ userStyle?: Partial<TextStyle>,
118+ allowRichFormatting: boolean = true
127119 ): Promise<string> {
128- try {
129- // If text is empty, return empty string
130- if (!text) return '';
131-
132- // Validate and apply user style
133- const validatedStyle = validateTextStyle(userStyle || {});
134-
135- // Preprocess the text for formatting if allowed
136- let processedText = text;
137- if (allowRichFormatting) {
138- processedText = preprocessFormatting(text);
139- }
140-
141- // If no rich formatting, just return escaped text
142- if (!allowRichFormatting && !processedText.includes('<')) {
143- return escapeHtml(text);
144- }
145-
146- // Create the unified processor
147- const processor = unified()
148- .use(remarkParse, {
149- fragment: true // Parse as fragment, not full document
150- })
151- .use(remarkRehype, {
152- allowDangerousHtml: true // Allow preprocessed HTML to pass through
153- })
154- .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML
155- .use(rehypeStringify);
156-
157- // Process the text
158- const result = await processor.process(processedText);
159- let formattedHtml = String(result).trim();
160-
161- // If we got an empty result, return the escaped text
162- if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') {
163- return escapeHtml(text);
164- }
165-
166- // Remove wrapping <p> tags if it's just a single paragraph
167- if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) {
168- const pCount = (formattedHtml.match(/<p>/g) || []).length;
169- if (pCount === 1) {
170- formattedHtml = formattedHtml.slice(3, -4).trim();
171- }
172- }
173-
174- // Final check - if we ended up with empty content, return escaped text
175- if (!formattedHtml) {
176- return escapeHtml(text);
177- }
178-
179- return formattedHtml;
180-
181- } catch (error) {
182- console.error('Text formatting error:', error);
183- // Return escaped plain text as fallback
184- return escapeHtml(text);
185- }
120+ try {
121+ // If text is empty, return empty string
122+ if (!text) return '';
123+
124+ // Validate user style (ensures safe values)
125+ validateTextStyle(userStyle || {});
126+
127+ // Preprocess the text for formatting if allowed
128+ let processedText = text;
129+ if (allowRichFormatting) {
130+ processedText = preprocessFormatting(text);
131+ }
132+
133+ // If no rich formatting, just return escaped text
134+ if (!allowRichFormatting && !processedText.includes('<')) {
135+ return escapeHtml(text);
136+ }
137+
138+ // Create the unified processor
139+ const processor = unified()
140+ .use(remarkParse, {
141+ fragment: true // Parse as fragment, not full document
142+ })
143+ .use(remarkRehype, {
144+ allowDangerousHtml: true // Allow preprocessed HTML to pass through
145+ })
146+ .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML
147+ .use(rehypeStringify);
148+
149+ // Process the text
150+ const result = await processor.process(processedText);
151+ let formattedHtml = String(result).trim();
152+
153+ // If we got an empty result, return the escaped text
154+ if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') {
155+ return escapeHtml(text);
156+ }
157+
158+ // Remove wrapping <p> tags if it's just a single paragraph
159+ if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) {
160+ const pCount = (formattedHtml.match(/<p>/g) || []).length;
161+ if (pCount === 1) {
162+ formattedHtml = formattedHtml.slice(3, -4).trim();
163+ }
164+ }
165+
166+ // Final check - if we ended up with empty content, return escaped text
167+ if (!formattedHtml) {
168+ return escapeHtml(text);
169+ }
170+
171+ return formattedHtml;
172+ } catch (error) {
173+ console.error('Text formatting error:', error);
174+ // Return escaped plain text as fallback
175+ return escapeHtml(text);
176+ }
186177 }
187178
188179 // Utility function to escape HTML
189180 function escapeHtml(text: string): string {
190- const div = document.createElement('div');
191- div.textContent = text;
192- return div.innerHTML;
181+ const div = document.createElement('div');
182+ div.textContent = text;
183+ return div.innerHTML;
193184 }
194185
195186 // Generate CSS style string excluding font-family (which should be handled by classes)
196187 function generateCSSStyleWithoutFont(style: TextStyle): string {
197- const styles: string[] = [];
198-
199- // Font size
200- styles.push(`font-size: ${style.fontSize}px`);
201-
202- // Color (not for gradients)
203- if (style.color && !style.gradient) {
204- styles.push(`color: ${style.color}`);
205- }
206-
207- // Font weight
208- if (style.bold) {
209- styles.push(`font-weight: bold`);
210- }
211-
212- // Font style
213- if (style.italic) {
214- styles.push(`font-style: italic`);
215- }
216-
217- // Text decoration
218- const decorations: string[] = [];
219- if (style.underline) decorations.push('underline');
220- if (style.strikethrough) decorations.push('line-through');
221- if (decorations.length > 0) {
222- styles.push(`text-decoration: ${decorations.join(' ')}`);
223- }
224-
225- return styles.join('; ');
188+ const styles: string[] = [];
189+
190+ // Font size
191+ styles.push(`font-size: ${style.fontSize}px`);
192+
193+ // Color (not for gradients)
194+ if (style.color && !style.gradient) {
195+ styles.push(`color: ${style.color}`);
196+ }
197+
198+ // Font weight
199+ if (style.bold) {
200+ styles.push(`font-weight: bold`);
201+ }
202+
203+ // Font style
204+ if (style.italic) {
205+ styles.push(`font-style: italic`);
206+ }
207+
208+ // Text decoration
209+ const decorations: string[] = [];
210+ if (style.underline) decorations.push('underline');
211+ if (style.strikethrough) decorations.push('line-through');
212+ if (decorations.length > 0) {
213+ styles.push(`text-decoration: ${decorations.join(' ')}`);
214+ }
215+
216+ return styles.join('; ');
226217 }
227218
228219 // Create gradient text effect
229220 export function createGradientText(
230- text: string,
231- colors: string[],
232- className: string = 'gradient-text',
233- style?: TextStyle
221+ text: string,
222+ colors: string[],
223+ className: string = 'gradient-text',
224+ style?: TextStyle
234225 ): string {
235- console.debug('createGradientText called with:', { text, colors, className, style });
236-
237- if (colors.length < 2) {
238- const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
239- const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : '';
240- const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
241- console.debug('Single color result:', result);
242- return result;
243- }
244-
245- const chars = Array.from(text);
246- const totalChars = chars.length;
247-
248- if (totalChars === 0) return '';
249-
250- // Create spans for each character with interpolated colors
251- const gradientSpans = chars.map((char, index) => {
252- if (char === ' ') {
253- return '&nbsp;'; // Use non-breaking space to preserve spacing
254- }
255-
256- // Calculate color position (0 to 1)
257- const position = totalChars === 1 ? 0 : index / (totalChars - 1);
258-
259- // Find the two colors to interpolate between
260- const colorIndex = position * (colors.length - 1);
261- const lowerIndex = Math.floor(colorIndex);
262- const upperIndex = Math.min(lowerIndex + 1, colors.length - 1);
263- const t = colorIndex - lowerIndex;
264-
265- // Interpolate between colors
266- const color1 = hexToRgb(colors[lowerIndex]);
267- const color2 = hexToRgb(colors[upperIndex]);
268-
269- if (!color1 || !color2) {
270- const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : '';
271- return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`;
272- }
273-
274- const r = Math.round(color1.r + t * (color2.r - color1.r));
275- const g = Math.round(color1.g + t * (color2.g - color1.g));
276- const b = Math.round(color1.b + t * (color2.b - color1.b));
277-
278- const interpolatedColor = `rgb(${r}, ${g}, ${b})`;
279-
280- // Combine interpolated color with other styles
281- let inlineStyle = `color: ${interpolatedColor}`;
282- if (style) {
283- // Get styles without font-family and color
284- if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`;
285- if (style.bold) inlineStyle += `; font-weight: bold`;
286- if (style.italic) inlineStyle += `; font-style: italic`;
287- const decorations: string[] = [];
288- if (style.underline) decorations.push('underline');
289- if (style.strikethrough) decorations.push('line-through');
290- if (decorations.length > 0) {
291- inlineStyle += `; text-decoration: ${decorations.join(' ')}`;
292- }
293- }
294-
295- return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`;
296- });
297-
298- // Wrap all spans in a container that uses flexbox to eliminate spacing
299- const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
300- const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : '';
301- const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`;
302- console.debug('Final gradient HTML:', result);
303- return result;
226+ console.debug('createGradientText called with:', { text, colors, className, style });
227+
228+ if (colors.length < 2) {
229+ const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
230+ const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
231+ const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
232+ console.debug('Single color result:', result);
233+ return result;
234+ }
235+
236+ const chars = Array.from(text);
237+ const totalChars = chars.length;
238+
239+ if (totalChars === 0) return '';
240+
241+ // Create spans for each character with interpolated colors
242+ const gradientSpans = chars.map((char, index) => {
243+ if (char === ' ') {
244+ return '&nbsp;'; // Use non-breaking space to preserve spacing
245+ }
246+
247+ // Calculate color position (0 to 1)
248+ const position = totalChars === 1 ? 0 : index / (totalChars - 1);
249+
250+ // Find the two colors to interpolate between
251+ const colorIndex = position * (colors.length - 1);
252+ const lowerIndex = Math.floor(colorIndex);
253+ const upperIndex = Math.min(lowerIndex + 1, colors.length - 1);
254+ const t = colorIndex - lowerIndex;
255+
256+ // Interpolate between colors
257+ const color1 = hexToRgb(colors[lowerIndex]);
258+ const color2 = hexToRgb(colors[upperIndex]);
259+
260+ if (!color1 || !color2) {
261+ const fallbackStyle = style
262+ ? generateCSSStyleWithoutFont({ ...style, gradient: undefined })
263+ : '';
264+ return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`;
265+ }
266+
267+ const r = Math.round(color1.r + t * (color2.r - color1.r));
268+ const g = Math.round(color1.g + t * (color2.g - color1.g));
269+ const b = Math.round(color1.b + t * (color2.b - color1.b));
270+
271+ const interpolatedColor = `rgb(${r}, ${g}, ${b})`;
272+
273+ // Combine interpolated color with other styles
274+ let inlineStyle = `color: ${interpolatedColor}`;
275+ if (style) {
276+ // Get styles without font-family and color
277+ if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`;
278+ if (style.bold) inlineStyle += `; font-weight: bold`;
279+ if (style.italic) inlineStyle += `; font-style: italic`;
280+ const decorations: string[] = [];
281+ if (style.underline) decorations.push('underline');
282+ if (style.strikethrough) decorations.push('line-through');
283+ if (decorations.length > 0) {
284+ inlineStyle += `; text-decoration: ${decorations.join(' ')}`;
285+ }
286+ }
287+
288+ return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`;
289+ });
290+
291+ // Wrap all spans in a container that uses flexbox to eliminate spacing
292+ const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
293+ const containerStyle = style
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;
304299 }
305300
306301 // Helper function to convert hex to RGB
307302 function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
308- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
309- return result ? {
310- r: parseInt(result[1], 16),
311- g: parseInt(result[2], 16),
312- b: parseInt(result[3], 16)
313- } : null;
303+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
304+ return result
305+ ? {
306+ r: parseInt(result[1], 16),
307+ g: parseInt(result[2], 16),
308+ b: parseInt(result[3], 16)
309+ }
310+ : null;
314311 }
315312
316313 // Validate message content for security
317314 export function validateMessageContent(content: string): {
318- isValid: boolean;
319- sanitizedContent: string;
320- errors: string[];
315+ isValid: boolean;
316+ sanitizedContent: string;
317+ errors: string[];
321318 } {
322- const errors: string[] = [];
323- let sanitizedContent = content;
324-
325- // Check length
326- if (content.length > 2000) {
327- errors.push('Message too long (max 2000 characters)');
328- sanitizedContent = content.substring(0, 2000);
329- }
330-
331- // Check for potential XSS patterns
332- const dangerousPatterns = [
333- /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
334- /javascript:/gi,
335- /vbscript:/gi,
336- /onload\s*=/gi,
337- /onerror\s*=/gi,
338- /onclick\s*=/gi,
339- /onmouseover\s*=/gi
340- ];
341-
342- dangerousPatterns.forEach(pattern => {
343- if (pattern.test(content)) {
344- errors.push('Potentially dangerous content detected');
345- sanitizedContent = sanitizedContent.replace(pattern, '');
346- }
347- });
348-
349- return {
350- isValid: errors.length === 0,
351- sanitizedContent,
352- errors
353- };
354-}⏎̸
319+ const errors: string[] = [];
320+ let sanitizedContent = content;
321+
322+ // Check length
323+ if (content.length > 2000) {
324+ errors.push('Message too long (max 2000 characters)');
325+ sanitizedContent = content.substring(0, 2000);
326+ }
327+
328+ // Check for potential XSS patterns
329+ const dangerousPatterns = [
330+ /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
331+ /javascript:/gi,
332+ /vbscript:/gi,
333+ /onload\s*=/gi,
334+ /onerror\s*=/gi,
335+ /onclick\s*=/gi,
336+ /onmouseover\s*=/gi
337+ ];
338+
339+ dangerousPatterns.forEach((pattern) => {
340+ if (pattern.test(content)) {
341+ errors.push('Potentially dangerous content detected');
342+ sanitizedContent = sanitizedContent.replace(pattern, '');
343+ }
344+ });
345+
346+ return {
347+ isValid: errors.length === 0,
348+ sanitizedContent,
349+ errors
350+ };
351+}
M src/lib/utils/turnstile.server.ts +41 -41
@@ -5,47 +5,47 @@ const log = createLogger('turnstile');
55 const isDev = process.env.NODE_ENV === 'development';
66
77 interface TurnstileVerifyResponse {
8- "success": boolean;
9- "error-codes"?: string[];
10- "challenge_ts"?: string;
11- "hostname"?: string;
12- "action"?: string;
13- "cdata"?: string;
8+ success: boolean;
9+ 'error-codes'?: string[];
10+ challenge_ts?: string;
11+ hostname?: string;
12+ action?: string;
13+ cdata?: string;
1414 }
1515
1616 export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> {
17- // Always return true in development mode
18- if (isDev) {
19- log.debug('Development mode: bypassing Turnstile validation');
20- return true;
21- }
22-
23- try {
24- const formData = new FormData();
25- formData.append('secret', TURNSTILE_SECRET_KEY);
26- formData.append('response', token);
27- if (remoteip) {
28- formData.append('remoteip', remoteip);
29- }
30-
31- const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
32- const result = await fetch(url, {
33- body: formData,
34- method: 'POST',
35- });
36-
37- const outcome: TurnstileVerifyResponse = await result.json();
38-
39- if (!outcome.success) {
40- log.warn('Turnstile validation failed', {
41- errors: outcome["error-codes"],
42- remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined
43- });
44- }
45-
46- return outcome.success;
47- } catch (error) {
48- log.error('Error validating Turnstile token', { error });
49- return false;
50- }
51-} ⏎̸
17+ // Always return true in development mode
18+ if (isDev) {
19+ log.debug('Development mode: bypassing Turnstile validation');
20+ return true;
21+ }
22+
23+ try {
24+ const formData = new FormData();
25+ formData.append('secret', TURNSTILE_SECRET_KEY);
26+ formData.append('response', token);
27+ if (remoteip) {
28+ formData.append('remoteip', remoteip);
29+ }
30+
31+ const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
32+ const result = await fetch(url, {
33+ body: formData,
34+ method: 'POST'
35+ });
36+
37+ const outcome: TurnstileVerifyResponse = await result.json();
38+
39+ if (!outcome.success) {
40+ log.warn('Turnstile validation failed', {
41+ errors: outcome['error-codes'],
42+ remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined
43+ });
44+ }
45+
46+ return outcome.success;
47+ } catch (error) {
48+ log.error('Error validating Turnstile token', { error });
49+ return false;
50+ }
51+}
M src/lib/validation/password.ts +192 -171
@@ -2,198 +2,219 @@ import { z } from 'zod';
22
33 // Dynamic password constraints configuration
44 export interface PasswordConstraints {
5- minLength: number;
6- maxLength: number;
7- requireUppercase: boolean;
8- requireLowercase: boolean;
9- requireNumbers: boolean;
10- requireSpecialChars: boolean;
11- forbiddenPatterns: string[];
5+ minLength: number;
6+ maxLength: number;
7+ requireUppercase: boolean;
8+ requireLowercase: boolean;
9+ requireNumbers: boolean;
10+ requireSpecialChars: boolean;
11+ forbiddenPatterns: string[];
1212 }
1313
1414 // Default password constraints
1515 export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = {
16- minLength: 8,
17- maxLength: 64,
18- requireUppercase: true,
19- requireLowercase: true,
20- requireNumbers: true,
21- requireSpecialChars: false,
22- forbiddenPatterns: ['password', '123456', 'qwerty', 'admin']
16+ minLength: 8,
17+ maxLength: 64,
18+ requireUppercase: true,
19+ requireLowercase: true,
20+ requireNumbers: true,
21+ requireSpecialChars: false,
22+ forbiddenPatterns: ['password', '123456', 'qwerty', 'admin']
2323 };
2424
2525 // Validation error messages
2626 export const PASSWORD_ERROR_MESSAGES = {
27- minLength: (min: number) => `Password must be at least ${min} characters long`,
28- maxLength: (max: number) => `Password must be at most ${max} characters long`,
29- requireUppercase: 'Password must contain at least one uppercase letter',
30- requireLowercase: 'Password must contain at least one lowercase letter',
31- requireNumbers: 'Password must contain at least one number',
32- requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)',
33- forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`,
34- common: 'Password is too common or weak'
27+ minLength: (min: number) => `Password must be at least ${min} characters long`,
28+ maxLength: (max: number) => `Password must be at most ${max} characters long`,
29+ requireUppercase: 'Password must contain at least one uppercase letter',
30+ requireLowercase: 'Password must contain at least one lowercase letter',
31+ requireNumbers: 'Password must contain at least one number',
32+ requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)',
33+ forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`,
34+ common: 'Password is too common or weak'
3535 };
3636
3737 // Create dynamic password schema based on constraints
38-export function createPasswordSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
39- let schema = z.string()
40- .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength))
41- .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
42-
43- // Add character type requirements
44- if (constraints.requireUppercase) {
45- schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase);
46- }
47-
48- if (constraints.requireLowercase) {
49- schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase);
50- }
51-
52- if (constraints.requireNumbers) {
53- schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers);
54- }
55-
56- if (constraints.requireSpecialChars) {
57- schema = schema.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, PASSWORD_ERROR_MESSAGES.requireSpecialChars);
58- }
59-
60- // Add forbidden patterns check
61- if (constraints.forbiddenPatterns.length > 0) {
62- return schema.refine(
63- (password) => {
64- const lowerPassword = password.toLowerCase();
65- return !constraints.forbiddenPatterns.some(pattern =>
66- lowerPassword.includes(pattern.toLowerCase())
67- );
68- },
69- {
70- message: PASSWORD_ERROR_MESSAGES.common
71- }
72- );
73- }
74-
75- return schema;
38+export function createPasswordSchema(
39+ constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
40+) {
41+ let schema = z
42+ .string()
43+ .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength))
44+ .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
45+
46+ // Add character type requirements
47+ if (constraints.requireUppercase) {
48+ schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase);
49+ }
50+
51+ if (constraints.requireLowercase) {
52+ schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase);
53+ }
54+
55+ if (constraints.requireNumbers) {
56+ schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers);
57+ }
58+
59+ if (constraints.requireSpecialChars) {
60+ schema = schema.regex(
61+ /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/,
62+ PASSWORD_ERROR_MESSAGES.requireSpecialChars
63+ );
64+ }
65+
66+ // Add forbidden patterns check
67+ if (constraints.forbiddenPatterns.length > 0) {
68+ return schema.refine(
69+ (password) => {
70+ const lowerPassword = password.toLowerCase();
71+ return !constraints.forbiddenPatterns.some((pattern) =>
72+ lowerPassword.includes(pattern.toLowerCase())
73+ );
74+ },
75+ {
76+ message: PASSWORD_ERROR_MESSAGES.common
77+ }
78+ );
79+ }
80+
81+ return schema;
7682 }
7783
7884 // Login validation schema
7985 export const loginSchema = z.object({
80- username: z.string()
81- .min(3, 'Username must be at least 3 characters')
82- .max(32, 'Username must be at most 32 characters')
83- .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'),
84- password: z.string().min(1, 'Password is required')
86+ username: z
87+ .string()
88+ .min(3, 'Username must be at least 3 characters')
89+ .max(32, 'Username must be at most 32 characters')
90+ .regex(
91+ /^[a-zA-Z0-9_-]+$/,
92+ 'Username can only contain letters, numbers, underscores, and dashes'
93+ ),
94+ password: z.string().min(1, 'Password is required')
8595 });
8696
8797 // Registration validation schema (using dynamic password validation)
88-export function createRegistrationSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
89- return z.object({
90- suUsername: z.string()
91- .min(3, 'Username must be at least 3 characters')
92- .max(32, 'Username must be at most 32 characters')
93- .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'),
94- suPassword: createPasswordSchema(constraints),
95- suConfirmPassword: z.string(),
96- captchaAnswer: z.string().min(1, 'Please answer the PDR question'),
97- turnstileToken: z.string().optional()
98- }).refine(
99- (data) => data.suPassword === data.suConfirmPassword,
100- {
101- message: 'Passwords do not match',
102- path: ['suConfirmPassword']
103- }
104- );
98+export function createRegistrationSchema(
99+ constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
100+) {
101+ return z
102+ .object({
103+ suUsername: z
104+ .string()
105+ .min(3, 'Username must be at least 3 characters')
106+ .max(32, 'Username must be at most 32 characters')
107+ .regex(
108+ /^[a-zA-Z0-9_-]+$/,
109+ 'Username can only contain letters, numbers, underscores, and dashes'
110+ ),
111+ suPassword: createPasswordSchema(constraints),
112+ suConfirmPassword: z.string(),
113+ captchaAnswer: z.string().min(1, 'Please answer the PDR question'),
114+ turnstileToken: z.string().optional()
115+ })
116+ .refine((data) => data.suPassword === data.suConfirmPassword, {
117+ message: 'Passwords do not match',
118+ path: ['suConfirmPassword']
119+ });
105120 }
106121
107122 // Utility function to validate password strength and return detailed feedback
108-export function validatePasswordStrength(password: string, constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
109- const errors: string[] = [];
110- const requirements: { met: boolean; message: string }[] = [];
111-
112- // Length checks
113- if (password.length < constraints.minLength) {
114- errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength));
115- }
116- requirements.push({
117- met: password.length >= constraints.minLength,
118- message: `At least ${constraints.minLength} characters`
119- });
120-
121- if (password.length > constraints.maxLength) {
122- errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
123- }
124-
125- // Character type checks
126- if (constraints.requireUppercase) {
127- const hasUppercase = /[A-Z]/.test(password);
128- if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase);
129- requirements.push({
130- met: hasUppercase,
131- message: 'At least one uppercase letter'
132- });
133- }
134-
135- if (constraints.requireLowercase) {
136- const hasLowercase = /[a-z]/.test(password);
137- if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase);
138- requirements.push({
139- met: hasLowercase,
140- message: 'At least one lowercase letter'
141- });
142- }
143-
144- if (constraints.requireNumbers) {
145- const hasNumbers = /\d/.test(password);
146- if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers);
147- requirements.push({
148- met: hasNumbers,
149- message: 'At least one number'
150- });
151- }
152-
153- if (constraints.requireSpecialChars) {
154- const hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
155- if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars);
156- requirements.push({
157- met: hasSpecialChars,
158- message: 'At least one special character'
159- });
160- }
161-
162- // Forbidden patterns check
163- if (constraints.forbiddenPatterns.length > 0) {
164- const lowerPassword = password.toLowerCase();
165- const hasForbiddenPattern = constraints.forbiddenPatterns.some(pattern =>
166- lowerPassword.includes(pattern.toLowerCase())
167- );
168- if (hasForbiddenPattern) {
169- errors.push(PASSWORD_ERROR_MESSAGES.common);
170- }
171- requirements.push({
172- met: !hasForbiddenPattern,
173- message: 'Not a common or weak password'
174- });
175- }
176-
177- return {
178- isValid: errors.length === 0,
179- errors,
180- requirements,
181- strength: calculatePasswordStrength(password, requirements)
182- };
123+export function validatePasswordStrength(
124+ password: string,
125+ constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
126+) {
127+ const errors: string[] = [];
128+ const requirements: { met: boolean; message: string }[] = [];
129+
130+ // Length checks
131+ if (password.length < constraints.minLength) {
132+ errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength));
133+ }
134+ requirements.push({
135+ met: password.length >= constraints.minLength,
136+ message: `At least ${constraints.minLength} characters`
137+ });
138+
139+ if (password.length > constraints.maxLength) {
140+ errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
141+ }
142+
143+ // Character type checks
144+ if (constraints.requireUppercase) {
145+ const hasUppercase = /[A-Z]/.test(password);
146+ if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase);
147+ requirements.push({
148+ met: hasUppercase,
149+ message: 'At least one uppercase letter'
150+ });
151+ }
152+
153+ if (constraints.requireLowercase) {
154+ const hasLowercase = /[a-z]/.test(password);
155+ if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase);
156+ requirements.push({
157+ met: hasLowercase,
158+ message: 'At least one lowercase letter'
159+ });
160+ }
161+
162+ if (constraints.requireNumbers) {
163+ const hasNumbers = /\d/.test(password);
164+ if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers);
165+ requirements.push({
166+ met: hasNumbers,
167+ message: 'At least one number'
168+ });
169+ }
170+
171+ if (constraints.requireSpecialChars) {
172+ const hasSpecialChars = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password);
173+ if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars);
174+ requirements.push({
175+ met: hasSpecialChars,
176+ message: 'At least one special character'
177+ });
178+ }
179+
180+ // Forbidden patterns check
181+ if (constraints.forbiddenPatterns.length > 0) {
182+ const lowerPassword = password.toLowerCase();
183+ const hasForbiddenPattern = constraints.forbiddenPatterns.some((pattern) =>
184+ lowerPassword.includes(pattern.toLowerCase())
185+ );
186+ if (hasForbiddenPattern) {
187+ errors.push(PASSWORD_ERROR_MESSAGES.common);
188+ }
189+ requirements.push({
190+ met: !hasForbiddenPattern,
191+ message: 'Not a common or weak password'
192+ });
193+ }
194+
195+ return {
196+ isValid: errors.length === 0,
197+ errors,
198+ requirements,
199+ strength: calculatePasswordStrength(password, requirements)
200+ };
183201 }
184202
185203 // Calculate password strength score (0-100)
186-function calculatePasswordStrength(password: string, requirements: { met: boolean; message: string }[]): number {
187- const metRequirements = requirements.filter(req => req.met).length;
188- const totalRequirements = requirements.length;
189-
190- if (totalRequirements === 0) return 0;
191-
192- let baseScore = (metRequirements / totalRequirements) * 80;
193-
194- // Bonus points for length beyond minimum
195- if (password.length > 12) baseScore += 10;
196- if (password.length > 16) baseScore += 10;
197-
198- return Math.min(100, Math.round(baseScore));
199-}⏎̸
204+function calculatePasswordStrength(
205+ password: string,
206+ requirements: { met: boolean; message: string }[]
207+): number {
208+ const metRequirements = requirements.filter((req) => req.met).length;
209+ const totalRequirements = requirements.length;
210+
211+ if (totalRequirements === 0) return 0;
212+
213+ let baseScore = (metRequirements / totalRequirements) * 80;
214+
215+ // Bonus points for length beyond minimum
216+ if (password.length > 12) baseScore += 10;
217+ if (password.length > 16) baseScore += 10;
218+
219+ return Math.min(100, Math.round(baseScore));
220+}
M src/lib/validation/text-formatting.ts +68 -68
@@ -7,86 +7,86 @@ const MAX_GRADIENT_COLORS = 3;
77 const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
88
99 export function validateHexColor(color: string): boolean {
10- return HEX_COLOR_REGEX.test(color);
10+ return HEX_COLOR_REGEX.test(color);
1111 }
1212
1313 export function validateFontFamily(fontFamily: string): boolean {
14- return fontFamily in RETRO_FONTS;
14+ return fontFamily in RETRO_FONTS;
1515 }
1616
1717 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));
1919 }
2020
2121 export function validateGradient(gradient: unknown): string[] | undefined {
22- if (!Array.isArray(gradient)) return undefined;
23- if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined;
24-
25- const validColors = gradient.filter(color =>
26- typeof color === 'string' && validateHexColor(color)
27- );
28-
29- return validColors.length === gradient.length ? validColors : undefined;
22+ if (!Array.isArray(gradient)) return undefined;
23+ if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined;
24+
25+ const validColors = gradient.filter(
26+ (color) => typeof color === 'string' && validateHexColor(color)
27+ );
28+
29+ return validColors.length === gradient.length ? validColors : undefined;
3030 }
3131
3232 export function validateTextStyle(style: unknown): TextStyle | null {
33- if (!style || typeof style !== 'object') return null;
34-
35- const rawStyle = style as Record<string, unknown>;
36-
37- const validated: TextStyle = {
38- fontFamily: 'pixelated',
39- fontSize: 14,
40- bold: false,
41- italic: false,
42- underline: false,
43- strikethrough: false
44- };
45-
46- // Validate font family
47- if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) {
48- validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS;
49- }
50-
51- // Validate font size
52- if (typeof rawStyle.fontSize === 'number') {
53- validated.fontSize = validateFontSize(rawStyle.fontSize);
54- }
55-
56- // Validate color
57- if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) {
58- validated.color = rawStyle.color;
59- }
60-
61- // Validate gradient
62- const validatedGradient = validateGradient(rawStyle.gradient);
63- if (validatedGradient) {
64- validated.gradient = validatedGradient;
65- }
66-
67- // Validate boolean properties
68- validated.bold = Boolean(rawStyle.bold);
69- validated.italic = Boolean(rawStyle.italic);
70- validated.underline = Boolean(rawStyle.underline);
71- validated.strikethrough = Boolean(rawStyle.strikethrough);
72-
73- return validated;
33+ if (!style || typeof style !== 'object') return null;
34+
35+ const rawStyle = style as Record<string, unknown>;
36+
37+ const validated: TextStyle = {
38+ fontFamily: 'pixelated',
39+ fontSize: 14,
40+ bold: false,
41+ italic: false,
42+ underline: false,
43+ strikethrough: false
44+ };
45+
46+ // Validate font family
47+ if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) {
48+ validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS;
49+ }
50+
51+ // Validate font size
52+ if (typeof rawStyle.fontSize === 'number') {
53+ validated.fontSize = validateFontSize(rawStyle.fontSize);
54+ }
55+
56+ // Validate color
57+ if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) {
58+ validated.color = rawStyle.color;
59+ }
60+
61+ // Validate gradient
62+ const validatedGradient = validateGradient(rawStyle.gradient);
63+ if (validatedGradient) {
64+ validated.gradient = validatedGradient;
65+ }
66+
67+ // Validate boolean properties
68+ validated.bold = Boolean(rawStyle.bold);
69+ validated.italic = Boolean(rawStyle.italic);
70+ validated.underline = Boolean(rawStyle.underline);
71+ validated.strikethrough = Boolean(rawStyle.strikethrough);
72+
73+ return validated;
7474 }
7575
7676 export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
77- if (!styleData) return undefined;
78-
79- // Handle JSON string input
80- let parsedData: unknown = styleData;
81- if (typeof styleData === 'string') {
82- try {
83- parsedData = JSON.parse(styleData);
84- } catch (error) {
85- console.warn('Failed to parse styleData JSON:', error);
86- return undefined;
87- }
88- }
89-
90- const validated = validateTextStyle(parsedData);
91- return validated || undefined;
92-}⏎̸
77+ if (!styleData) return undefined;
78+
79+ // Handle JSON string input
80+ let parsedData: unknown = styleData;
81+ if (typeof styleData === 'string') {
82+ try {
83+ parsedData = JSON.parse(styleData);
84+ } catch (error) {
85+ console.warn('Failed to parse styleData JSON:', error);
86+ return undefined;
87+ }
88+ }
89+
90+ const validated = validateTextStyle(parsedData);
91+ return validated || undefined;
92+}
M src/routes/+layout.server.ts +6 -8
@@ -1,10 +1,8 @@
11 import type { LayoutServerLoad } from './$types';
22
3-export const load: LayoutServerLoad = async ({ locals }) => {
4-
5-
6- return {
7- user: locals.user,
8- session: locals.session
9- };
10-}; ⏎̸
3+export const load: LayoutServerLoad = async ({ locals }) => {
4+ return {
5+ user: locals.user,
6+ session: locals.session
7+ };
8+};
M src/routes/+layout.svelte +42 -38
@@ -1,7 +1,7 @@
11 <script lang="ts">
2- import '../app.css';;
2+ import '../app.css';
33 import { chatState } from '$lib/states/chat.svelte';
4- import { onMount, onDestroy } from 'svelte';
4+ import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
66
77 let { children, data } = $props();
@@ -24,12 +24,12 @@
2424
2525 async function updateUserStatus(status: UserStatus) {
2626 if (!data.user?.id || updateInProgress) return;
27-
27+
2828 const now = Date.now();
2929 const timeSinceLastUpdate = now - lastStatusSentAt;
30-
30+
3131 if (
32- status === lastSentStatus &&
32+ status === lastSentStatus &&
3333 timeSinceLastUpdate < FORCE_UPDATE_INTERVAL &&
3434 timeSinceLastUpdate < THROTTLE_TIME_MS
3535 ) {
@@ -57,18 +57,18 @@
5757 try {
5858 if (status === 'online' && !document.cookie.includes('session=')) {
5959 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));
6161 }
6262
6363 const response = await fetch('/api/status', {
6464 method: 'POST',
6565 headers: { 'Content-Type': 'application/json' },
66- body: JSON.stringify({
66+ body: JSON.stringify({
6767 status,
6868 lastSeen: timestamp
6969 })
7070 });
71-
71+
7272 if (!response.ok) {
7373 console.error('Failed to update user status:', await response.json());
7474 } else {
@@ -91,13 +91,13 @@
9191 if (idleTimeout) {
9292 window.clearTimeout(idleTimeout);
9393 }
94-
94+
9595 lastUserActivity = Date.now();
96-
96+
9797 if (lastSentStatus === 'idle') {
9898 updateUserStatus('online');
9999 }
100-
100+
101101 idleTimeout = window.setTimeout(() => {
102102 if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) {
103103 updateUserStatus('idle');
@@ -111,7 +111,7 @@
111111
112112 function startStatusTicker() {
113113 if (statusInterval) return;
114-
114+
115115 console.debug('Starting status ticker');
116116 const tickHandler = () => {
117117 if (isVisible && lastSentStatus !== 'idle') {
@@ -132,7 +132,7 @@
132132 function handleVisibilityChange() {
133133 if (!browser) return;
134134 isVisible = document.visibilityState === 'visible';
135-
135+
136136 if (isVisible) {
137137 resetIdleTimer();
138138 updateUserStatus('online');
@@ -162,12 +162,12 @@
162162
163163 // Set initial online status
164164 updateUserStatus('online');
165-
165+
166166 // Start monitoring for user activity
167167 window.addEventListener('mousemove', handleUserActivity);
168168 window.addEventListener('keydown', handleUserActivity);
169169 window.addEventListener('click', handleUserActivity);
170-
170+
171171 // Initialize idle timer
172172 resetIdleTimer();
173173
@@ -176,7 +176,7 @@
176176
177177 // Add visibility change listener
178178 document.addEventListener('visibilitychange', handleVisibilityChange);
179-
179+
180180 // Add beforeunload listener for cleanup
181181 window.addEventListener('beforeunload', cleanup);
182182
@@ -189,32 +189,36 @@
189189
190190 $effect(() => {
191191 if (!browser) return;
192-
192+
193193 const currentUserJson = data.user ? JSON.stringify(data.user) : null;
194194 if (currentUserJson === lastUserUpdate) {
195195 console.debug('Skipping duplicate user update');
196196 return;
197197 }
198-
198+
199199 lastUserUpdate = currentUserJson;
200-
200+
201201 console.debug('Layout reactive update - Session state:', {
202202 timestamp: new Date().toISOString(),
203203 hasUser: !!data.user,
204- userDetails: data.user ? {
205- nickname: data.user.nickname,
206- status: data.user.status,
207- id: data.user.id
208- } : null,
204+ userDetails: data.user
205+ ? {
206+ nickname: data.user.nickname,
207+ status: data.user.status,
208+ id: data.user.id
209+ }
210+ : null,
209211 hasSession: !!data.session,
210- sessionDetails: data.session ? {
211- id: data.session.id,
212- expiresAt: new Date(data.session.expiresAt)
213- } : null,
212+ sessionDetails: data.session
213+ ? {
214+ id: data.session.id,
215+ expiresAt: new Date(data.session.expiresAt)
216+ }
217+ : null,
214218 cookiePresent: document.cookie.includes('session='),
215219 isVisible
216220 });
217-
221+
218222 if (data.session?.id) {
219223 console.debug('Updating global chatState with data.user:', data.user);
220224 chatState.setCurrentUser(data.user);
@@ -227,7 +231,7 @@
227231 <meta name="description" content={data.meta.description} />
228232 <meta name="keywords" content={data.meta.keywords} />
229233 <meta name="language" content="fr" />
230-
234+
231235 <!-- Open Graph / Facebook -->
232236 <meta property="og:locale" content={data.meta.locale} />
233237 <meta property="og:type" content={data.meta.type} />
@@ -235,14 +239,14 @@
235239 <meta property="og:title" content={data.meta.title} />
236240 <meta property="og:description" content={data.meta.description} />
237241 <meta property="og:image" content={data.meta.ogImage} />
238-
242+
239243 <!-- Twitter -->
240244 <meta property="twitter:card" content="summary_large_image" />
241245 <meta property="twitter:url" content={data.meta.url} />
242246 <meta property="twitter:title" content={data.meta.title} />
243247 <meta property="twitter:description" content={data.meta.description} />
244248 <meta property="twitter:image" content={data.meta.ogImage} />
245-
249+
246250 <!-- Additional Meta Tags -->
247251 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
248252 <meta name="theme-color" content="#2195f2" />
@@ -266,11 +270,11 @@
266270 overflow: hidden;
267271 background: linear-gradient(
268272 to bottom,
269- #2195f2 0%, /* Lighter blue at the top */
270- #4aa6e2 25%, /* Mid-light blue */
271- #7ab8e0 50%, /* Mid blue */
272- #86c55c 75%, /* Blue-green transition */
273- #90d047 100% /* Bright green at the bottom */
273+ #2195f2 0%,
274+ /* Lighter blue at the top */ #4aa6e2 25%,
275+ /* Mid-light blue */ #7ab8e0 50%,
276+ /* Mid blue */ #86c55c 75%,
277+ /* Blue-green transition */ #90d047 100% /* Bright green at the bottom */
274278 );
275279 }
276-</style>⏎̸
280+</style>
M src/routes/+layout.ts +38 -32
@@ -1,36 +1,42 @@
11 import type { LayoutLoad } from './$types';
22
33 export const load: LayoutLoad = async ({ data, depends }) => {
4- // Register client-side dependencies for invalidation
5- depends('app:session');
6- depends('app:chat');
7- // Debug log for session data with more details
8- console.debug('Layout load - Client session data:', {
9- hasUser: !!data.user,
10- userDetails: data.user ? {
11- nickname: data.user.nickname,
12- status: data.user.status,
13- id: data.user.id
14- } : null,
15- hasSession: !!data.session,
16- sessionDetails: data.session ? {
17- id: data.session.id,
18- expiresAt: new Date(data.session.expiresAt)
19- } : null
20- });
4+ // Register client-side dependencies for invalidation
5+ depends('app:session');
6+ depends('app:chat');
7+ // Debug log for session data with more details
8+ console.debug('Layout load - Client session data:', {
9+ hasUser: !!data.user,
10+ userDetails: data.user
11+ ? {
12+ nickname: data.user.nickname,
13+ status: data.user.status,
14+ id: data.user.id
15+ }
16+ : null,
17+ hasSession: !!data.session,
18+ sessionDetails: data.session
19+ ? {
20+ id: data.session.id,
21+ expiresAt: new Date(data.session.expiresAt)
22+ }
23+ : null
24+ });
2125
22- // Return the data from the server load function
23- return {
24- user: data.user,
25- session: data.session,
26- meta: {
27- title: 'PDR AIM - Sandbox',
28- description: 'Un projet sandbox non lucratif qui recrée l\'ambiance des salons de discussion de l\'époque AOL/AIM. Une expérience nostalgique dans un environnement Windows XP.',
29- keywords: 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro',
30- ogImage: '/og-image.jpg',
31- type: 'website',
32- url: 'https://pdraim.org',
33- locale: 'fr_FR'
34- }
35- };
36-}; ⏎̸
26+ // Return the data from the server load function
27+ return {
28+ user: data.user,
29+ session: data.session,
30+ meta: {
31+ title: 'PDR AIM - Sandbox',
32+ description:
33+ "Un projet sandbox non lucratif qui recrée l'ambiance des salons de discussion de l'époque AOL/AIM. Une expérience nostalgique dans un environnement Windows XP.",
34+ keywords:
35+ 'sandbox chat, AOL, AIM, salon de discussion, Windows XP, projet non lucratif, rétro',
36+ ogImage: '/og-image.jpg',
37+ type: 'website',
38+ url: 'https://pdraim.org',
39+ locale: 'fr_FR'
40+ }
41+ };
42+};
M src/routes/+page.server.ts +56 -56
@@ -1,63 +1,63 @@
11 import type { PageServerLoad } from './$types';
22 import { validateSessionToken } from '$lib/api/session.server';
33 import db from '$lib/db/db.server';
4-import { userTextPreferences, users, messages } from '$lib/db/schema';
4+import { messages } from '$lib/db/schema';
55 import { eq, desc } from 'drizzle-orm';
66 import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
77
88 export const load: PageServerLoad = async ({ cookies }) => {
9- const token = cookies.get('session');
10-
11- if (!token) {
12- return {
13- user: null,
14- lastTextStyle: DEFAULT_TEXT_STYLE
15- };
16- }
17-
18- try {
19- const { user } = await validateSessionToken(token);
20-
21- if (!user) {
22- return {
23- user: null,
24- lastTextStyle: DEFAULT_TEXT_STYLE
25- };
26- }
27-
28- // Get user's last used text styling from their most recent message
29- const lastMessage = await db.query.messages.findFirst({
30- where: eq(messages.senderId, user.id),
31- orderBy: [desc(messages.timestamp)],
32- columns: {
33- styleData: true
34- }
35- });
36-
37- let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
38-
39- if (lastMessage?.styleData) {
40- try {
41- const parsedStyle = JSON.parse(lastMessage.styleData);
42- // Merge with default style to ensure all required properties
43- lastTextStyle = {
44- ...DEFAULT_TEXT_STYLE,
45- ...parsedStyle
46- };
47- } catch (error) {
48- console.warn('Failed to parse last text style:', error);
49- }
50- }
51-
52- return {
53- user,
54- lastTextStyle
55- };
56- } catch (error) {
57- console.error('Error loading page data:', error);
58- return {
59- user: null,
60- lastTextStyle: DEFAULT_TEXT_STYLE
61- };
62- }
63-};⏎̸
9+ const token = cookies.get('session');
10+
11+ if (!token) {
12+ return {
13+ user: null,
14+ lastTextStyle: DEFAULT_TEXT_STYLE
15+ };
16+ }
17+
18+ try {
19+ const { user } = await validateSessionToken(token);
20+
21+ if (!user) {
22+ return {
23+ user: null,
24+ lastTextStyle: DEFAULT_TEXT_STYLE
25+ };
26+ }
27+
28+ // Get user's last used text styling from their most recent message
29+ const lastMessage = await db.query.messages.findFirst({
30+ where: eq(messages.senderId, user.id),
31+ orderBy: [desc(messages.timestamp)],
32+ columns: {
33+ styleData: true
34+ }
35+ });
36+
37+ let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
38+
39+ if (lastMessage?.styleData) {
40+ try {
41+ const parsedStyle = JSON.parse(lastMessage.styleData);
42+ // Merge with default style to ensure all required properties
43+ lastTextStyle = {
44+ ...DEFAULT_TEXT_STYLE,
45+ ...parsedStyle
46+ };
47+ } catch (error) {
48+ console.warn('Failed to parse last text style:', error);
49+ }
50+ }
51+
52+ return {
53+ user,
54+ lastTextStyle
55+ };
56+ } catch (error) {
57+ console.error('Error loading page data:', error);
58+ return {
59+ user: null,
60+ lastTextStyle: DEFAULT_TEXT_STYLE
61+ };
62+ }
63+};
M src/routes/+page.svelte +25 -29
@@ -1,42 +1,38 @@
11 <script lang="ts">
2- import ChatRoom from '$lib/components/chat-room.svelte';
3- import DesktopIcons from '$lib/components/desktop-icons.svelte';
4- import AimLogin from '$lib/components/aim-login.svelte';
5- import type { PageData } from './$types';
2+ import ChatRoom from '$lib/components/chat-room.svelte';
3+ import DesktopIcons from '$lib/components/desktop-icons.svelte';
4+ import AimLogin from '$lib/components/aim-login.svelte';
5+ import type { PageData } from './$types';
66
7- let { data }: { data: PageData } = $props();
8-
9- let showChatRoom = $state(true);
10- let showAuth = $state(false);
7+ let { data }: { data: PageData } = $props();
118
12- function handleLoginSuccess() {
13- showAuth = false;
14- }
9+ let showChatRoom = $state(true);
10+ let showAuth = $state(false);
11+
12+ function handleLoginSuccess() {
13+ showAuth = false;
14+ }
1515 </script>
16-
16+
1717 <div class="desktop content-wrapper">
18- <DesktopIcons bind:showChatRoom bind:showAuth />
19-
18+ <DesktopIcons bind:showChatRoom bind:showAuth />
19+
2020 {#if showChatRoom}
2121 <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
2222 {/if}
2323
2424 {#if showAuth}
25- <AimLogin
26- bind:showAuth
27- onLoginSuccess={handleLoginSuccess}
28- />
25+ <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} />
2926 {/if}
3027 </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- }
4128
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';
22 import db from '$lib/db/db.server';
33 import { messages } from '$lib/db/schema';
44 import type { Message } from '$lib/types/chat';
5-import type {
6- SendMessageRequest,
7- SendMessageResponse,
8- GetMessagesResponse
5+import type {
6+ SendMessageRequest,
7+ SendMessageResponse,
8+ GetMessagesResponse
99 } from '$lib/types/payloads';
1010 import { and, desc, eq, lt } from 'drizzle-orm/sql';
1111 import type { SQL } from 'drizzle-orm/sql';
@@ -22,222 +22,232 @@ const log = createLogger('chat-server');
2222 // Rate limiting configuration
2323 const INITIAL_COOLDOWN = 1000; // 1 second
2424 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+>();
2629
2730 function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
28- const now = Date.now();
29- const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN };
30-
31- // Check if enough time has passed since the last message
32- const timeSinceLastMessage = now - userState.lastMessageTime;
33- if (timeSinceLastMessage < userState.currentCooldown) {
34- return {
35- canSend: false,
36- retryAfter: userState.currentCooldown - timeSinceLastMessage
37- };
38- }
39-
40- // Reset cooldown if it's been long enough
41- if (timeSinceLastMessage > userState.currentCooldown * 2) {
42- userState.currentCooldown = INITIAL_COOLDOWN;
43- } else {
44- // Exponential backoff
45- userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
46- }
47-
48- userState.lastMessageTime = now;
49- userMessageTimestamps.set(userId, userState);
50-
51- return { canSend: true };
31+ const now = Date.now();
32+ const userState = userMessageTimestamps.get(userId) || {
33+ lastMessageTime: 0,
34+ currentCooldown: INITIAL_COOLDOWN
35+ };
36+
37+ // Check if enough time has passed since the last message
38+ const timeSinceLastMessage = now - userState.lastMessageTime;
39+ if (timeSinceLastMessage < userState.currentCooldown) {
40+ return {
41+ canSend: false,
42+ retryAfter: userState.currentCooldown - timeSinceLastMessage
43+ };
44+ }
45+
46+ // Reset cooldown if it's been long enough
47+ if (timeSinceLastMessage > userState.currentCooldown * 2) {
48+ userState.currentCooldown = INITIAL_COOLDOWN;
49+ } else {
50+ // Exponential backoff
51+ userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
52+ }
53+
54+ userState.lastMessageTime = now;
55+ userMessageTimestamps.set(userId, userState);
56+
57+ return { canSend: true };
5258 }
5359
5460 // GET endpoint: fetch messages directly from DB
5561 export async function GET({ request, locals }) {
56- const url = new URL(request.url);
57- const beforeTimestamp = url.searchParams.get('before');
58- const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
59- const isPublic = url.searchParams.get('public') === 'true';
60-
61- try {
62- log.debug('Fetching messages from database', {
63- beforeTimestamp,
64- roomId,
65- isAuthenticated: !!locals.session,
66- isPublic
67- });
68-
69- let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
70-
71- if (beforeTimestamp) {
72- conditions = and(
73- conditions,
74- lt(messages.timestamp, parseInt(beforeTimestamp))
75- ) as SQL<unknown>;
76- }
77-
78- // Fetch messages with a limit based on authentication status
79- // Public requests are limited to 50 messages
80- // Authenticated users get 100 messages per request
81- const fetchLimit = (isPublic || !locals.session) ? 50 : 100;
82-
83- const fetchedMessages = await db.select()
84- .from(messages)
85- .where(conditions)
86- .orderBy(desc(messages.timestamp))
87- .limit(fetchLimit)
88- .then(rows => rows.map(row => ({
89- ...row,
90- styleData: row.styleData ?? undefined,
91- hasFormatting: row.hasFormatting ?? false
92- })));
93-
94- const response: GetMessagesResponse = {
95- success: true,
96- messages: fetchedMessages,
97- hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
98- };
99-
100- return new Response(JSON.stringify(response), {
101- headers: {
102- 'Content-Type': 'application/json'
103- }
104- });
105- } catch (err) {
106- log.error('Error fetching messages:', { error: err });
107- throw error(500, 'Failed to fetch messages');
108- }
62+ const url = new URL(request.url);
63+ const beforeTimestamp = url.searchParams.get('before');
64+ const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
65+ const isPublic = url.searchParams.get('public') === 'true';
66+
67+ try {
68+ log.debug('Fetching messages from database', {
69+ beforeTimestamp,
70+ roomId,
71+ isAuthenticated: !!locals.session,
72+ isPublic
73+ });
74+
75+ let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
76+
77+ if (beforeTimestamp) {
78+ conditions = and(
79+ conditions,
80+ lt(messages.timestamp, parseInt(beforeTimestamp))
81+ ) as SQL<unknown>;
82+ }
83+
84+ // Fetch messages with a limit based on authentication status
85+ // Public requests are limited to 50 messages
86+ // Authenticated users get 100 messages per request
87+ const fetchLimit = isPublic || !locals.session ? 50 : 100;
88+
89+ const fetchedMessages = await db
90+ .select()
91+ .from(messages)
92+ .where(conditions)
93+ .orderBy(desc(messages.timestamp))
94+ .limit(fetchLimit)
95+ .then((rows) =>
96+ rows.map((row) => ({
97+ ...row,
98+ styleData: row.styleData ?? undefined,
99+ hasFormatting: row.hasFormatting ?? false
100+ }))
101+ );
102+
103+ const response: GetMessagesResponse = {
104+ success: true,
105+ messages: fetchedMessages,
106+ hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
107+ };
108+
109+ return new Response(JSON.stringify(response), {
110+ headers: {
111+ 'Content-Type': 'application/json'
112+ }
113+ });
114+ } catch (err) {
115+ log.error('Error fetching messages:', { error: err });
116+ throw error(500, 'Failed to fetch messages');
117+ }
109118 }
110119
111120 // POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
112-export async function POST({ request, locals }: { request: Request, locals: App.Locals }) {
113- if (!locals.user) {
114- log.warn('Authentication required');
115- throw error(401, 'Authentication required');
116- }
117-
118- log.debug('New message received');
119- try {
120- // Check rate limiting
121- const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
122- if (!canSend) {
123- const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
124- log.warn('Rate limited', { userId: maskedUserId, retryAfter });
125- const errorResponse: SendMessageResponse = {
126- success: false,
127- error: 'Please wait before sending another message',
128- retryAfter,
129- isRateLimited: true
130- };
131- return new Response(JSON.stringify(errorResponse), {
132- status: 429,
133- headers: {
134- 'Content-Type': 'application/json',
135- 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
136- }
137- });
138- }
139-
140- const data = await request.json() as SendMessageRequest;
141- if (!data.content || !data.userId) {
142- log.warn('Invalid payload received', { hasContent: Boolean(data.content), hasUserId: Boolean(data.userId) });
143- const errorResponse: SendMessageResponse = {
144- success: false,
145- error: 'Invalid message payload'
146- };
147- return new Response(JSON.stringify(errorResponse), { status: 400 });
148- }
149-
150- if (data.userId !== locals.user.id) {
151- log.warn('Unauthorized message attempt', {
152- requestedUserId: data.userId,
153- actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
154- });
155- throw error(403, 'Cannot post messages as another user');
156- }
157-
158- const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
159-
160- // Check chat room existence
161- const chatRoom = await db.select()
162- .from(chatRooms)
163- .where(eq(chatRooms.id, chatRoomId))
164- .get();
165-
166- if (!chatRoom) {
167- log.warn('Chat room not found', { chatRoomId });
168- const errorResponse: SendMessageResponse = {
169- success: false,
170- error: 'Chat room not found'
171- };
172- return new Response(JSON.stringify(errorResponse), { status: 404 });
173- }
174-
175- // Check user existence
176- const user = await db.select()
177- .from(users)
178- .where(eq(users.id, data.userId))
179- .get();
180-
181- if (!user) {
182- const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
183- log.warn('User not found', { maskedUserId });
184- const errorResponse: SendMessageResponse = {
185- success: false,
186- error: 'User not found'
187- };
188- return new Response(JSON.stringify(errorResponse), { status: 404 });
189- }
190-
191- const validatedStyleData = sanitizeStyleData(data.styleData);
192-
193- const newMessage: Message = {
194- id: uuidv4(),
195- chatRoomId,
196- senderId: data.userId,
197- content: data.content,
198- type: data.type || 'chat',
199- timestamp: Date.now(),
200- styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
201- hasFormatting: Boolean(validatedStyleData)
202- };
203-
204-
205- // debug style data content at this point
206- log.debug('Style data content', { styleData: validatedStyleData });
207-
208- // Save to DB
209- await db.insert(messages).values(newMessage);
210- log.debug('Message saved in DB', { messageId: newMessage.id, chatRoomId: newMessage.chatRoomId, type: newMessage.type, timestamp: newMessage.timestamp });
211-
212- // Broadcast the new message via SSE
213- sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
214-
215- log.debug('Message processed successfully', { messageId: newMessage.id, userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, roomId: newMessage.chatRoomId });
216-
217- const successResponse: SendMessageResponse = {
218- success: true,
219- message: newMessage
220- };
221-
222- return new Response(
223- JSON.stringify(successResponse),
224- {
225- status: 201,
226- headers: { 'Content-Type': 'application/json' }
227- }
228- );
229- } catch (error) {
230- log.error('Error processing message', { error: error instanceof Error ? error.message : 'Unknown error' });
231- const errorResponse: SendMessageResponse = {
232- success: false,
233- error: 'Failed to save message'
234- };
235- return new Response(
236- JSON.stringify(errorResponse),
237- {
238- status: 500,
239- headers: { 'Content-Type': 'application/json' }
240- }
241- );
242- }
243-}⏎̸
121+export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {
122+ if (!locals.user) {
123+ log.warn('Authentication required');
124+ throw error(401, 'Authentication required');
125+ }
126+
127+ log.debug('New message received');
128+ try {
129+ // Check rate limiting
130+ const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
131+ if (!canSend) {
132+ const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
133+ log.warn('Rate limited', { userId: maskedUserId, retryAfter });
134+ const errorResponse: SendMessageResponse = {
135+ success: false,
136+ error: 'Please wait before sending another message',
137+ retryAfter,
138+ isRateLimited: true
139+ };
140+ return new Response(JSON.stringify(errorResponse), {
141+ status: 429,
142+ headers: {
143+ 'Content-Type': 'application/json',
144+ 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
145+ }
146+ });
147+ }
148+
149+ const data = (await request.json()) as SendMessageRequest;
150+ if (!data.content || !data.userId) {
151+ log.warn('Invalid payload received', {
152+ hasContent: Boolean(data.content),
153+ hasUserId: Boolean(data.userId)
154+ });
155+ const errorResponse: SendMessageResponse = {
156+ success: false,
157+ error: 'Invalid message payload'
158+ };
159+ return new Response(JSON.stringify(errorResponse), { status: 400 });
160+ }
161+
162+ if (data.userId !== locals.user.id) {
163+ log.warn('Unauthorized message attempt', {
164+ requestedUserId: data.userId,
165+ actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
166+ });
167+ throw error(403, 'Cannot post messages as another user');
168+ }
169+
170+ const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
171+
172+ // Check chat room existence
173+ const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get();
174+
175+ if (!chatRoom) {
176+ log.warn('Chat room not found', { chatRoomId });
177+ const errorResponse: SendMessageResponse = {
178+ success: false,
179+ error: 'Chat room not found'
180+ };
181+ return new Response(JSON.stringify(errorResponse), { status: 404 });
182+ }
183+
184+ // Check user existence
185+ const user = await db.select().from(users).where(eq(users.id, data.userId)).get();
186+
187+ if (!user) {
188+ const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
189+ log.warn('User not found', { maskedUserId });
190+ const errorResponse: SendMessageResponse = {
191+ success: false,
192+ error: 'User not found'
193+ };
194+ return new Response(JSON.stringify(errorResponse), { status: 404 });
195+ }
196+
197+ const validatedStyleData = sanitizeStyleData(data.styleData);
198+
199+ const newMessage: Message = {
200+ id: uuidv4(),
201+ chatRoomId,
202+ senderId: data.userId,
203+ content: data.content,
204+ type: data.type || 'chat',
205+ timestamp: Date.now(),
206+ styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
207+ hasFormatting: Boolean(validatedStyleData)
208+ };
209+
210+ // debug style data content at this point
211+ log.debug('Style data content', { styleData: validatedStyleData });
212+
213+ // Save to DB
214+ await db.insert(messages).values(newMessage);
215+ log.debug('Message saved in DB', {
216+ messageId: newMessage.id,
217+ chatRoomId: newMessage.chatRoomId,
218+ type: newMessage.type,
219+ timestamp: newMessage.timestamp
220+ });
221+
222+ // Broadcast the new message via SSE
223+ sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
224+
225+ log.debug('Message processed successfully', {
226+ messageId: newMessage.id,
227+ userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
228+ roomId: newMessage.chatRoomId
229+ });
230+
231+ const successResponse: SendMessageResponse = {
232+ success: true,
233+ message: newMessage
234+ };
235+
236+ return new Response(JSON.stringify(successResponse), {
237+ status: 201,
238+ headers: { 'Content-Type': 'application/json' }
239+ });
240+ } catch (error) {
241+ log.error('Error processing message', {
242+ error: error instanceof Error ? error.message : 'Unknown error'
243+ });
244+ const errorResponse: SendMessageResponse = {
245+ success: false,
246+ error: 'Failed to save message'
247+ };
248+ return new Response(JSON.stringify(errorResponse), {
249+ status: 500,
250+ headers: { 'Content-Type': 'application/json' }
251+ });
252+ }
253+}
M src/routes/api/register/+server.ts +55 -28
@@ -8,33 +8,40 @@ import { createLogger } from '$lib/utils/logger.server';
88 import { eq } from 'drizzle-orm/sql';
99
1010 const log = createLogger('register-server');
11-const isDev = process.env.NODE_ENV === 'development';
1211
1312 // 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 }>();
1514
1615 // Cleanup old entries every hour
17-setInterval(() => {
18- const now = Date.now();
19- const ONE_HOUR = 60 * 60 * 1000;
20- for (const [ip, data] of captchaAttempts.entries()) {
21- if (now - data.lastAttempt > ONE_HOUR) {
22- captchaAttempts.delete(ip);
16+setInterval(
17+ () => {
18+ const now = Date.now();
19+ const ONE_HOUR = 60 * 60 * 1000;
20+ for (const [ip, data] of captchaAttempts.entries()) {
21+ if (now - data.lastAttempt > ONE_HOUR) {
22+ captchaAttempts.delete(ip);
23+ }
2324 }
24- }
25-}, 60 * 60 * 1000);
25+ },
26+ 60 * 60 * 1000
27+);
2628
2729 export const POST: RequestHandler = async ({ request }) => {
2830 log.debug('New registration attempt received');
2931
3032 if (request.method !== 'POST') {
3133 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+ });
3337 }
3438
3539 // Get the IP address for rate limiting and Turnstile validation
3640 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('.');
3845 const now = Date.now();
3946 const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
4047
@@ -43,15 +50,17 @@ export const POST: RequestHandler = async ({ request }) => {
4350 body = await request.json();
4451 } catch {
4552 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+ });
4756 }
4857
49- const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
50- suUsername: string,
51- suPassword: string,
52- suConfirmPassword: string,
53- captchaAnswer: string,
54- turnstileToken?: string
58+ const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
59+ suUsername: string;
60+ suPassword: string;
61+ suConfirmPassword: string;
62+ captchaAnswer: string;
63+ turnstileToken?: string;
5564 };
5665
5766 // Validate input using Zod schema
@@ -64,13 +73,15 @@ export const POST: RequestHandler = async ({ request }) => {
6473 captchaAnswer,
6574 turnstileToken
6675 });
67- } catch (err: any) {
68- const errorMessage = err.errors?.[0]?.message || 'Invalid input data';
76+ } catch (err: unknown) {
77+ const zodError = err as { errors?: Array<{ message?: string }> };
78+ const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data';
6979 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+ });
7183 }
7284
73-
7485 // Validate PDR captcha
7586 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
7687 if (normalizedAnswer !== 'point de rencontre') {
@@ -78,7 +89,10 @@ export const POST: RequestHandler = async ({ request }) => {
7889 attemptData.lastAttempt = now;
7990 captchaAttempts.set(ip, attemptData);
8091 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+ );
8296 }
8397
8498 // Reset captcha attempts on success
@@ -92,7 +106,10 @@ export const POST: RequestHandler = async ({ request }) => {
92106 const existingUser = await db.select().from(users).where(eq(users.nickname, username));
93107 if (existingUser.length > 0) {
94108 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+ );
96113 }
97114
98115 // Securely hash the user's password.
@@ -101,7 +118,10 @@ export const POST: RequestHandler = async ({ request }) => {
101118 hashedPassword = await hashPassword(password);
102119 } catch {
103120 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+ );
105125 }
106126 // Insert the new user into the database.
107127 try {
@@ -114,8 +134,15 @@ export const POST: RequestHandler = async ({ request }) => {
114134 } catch (err: unknown) {
115135 log.error('Database insertion error', { error: err as object });
116136 // 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+ );
118143 }
119144
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+ });
121148 };
M src/routes/api/rooms/[roomId]/+server.ts +85 -89
@@ -11,101 +11,97 @@ const log = createLogger('rooms-server');
1111 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
1212
1313 export async function GET({ params, url, locals }): Promise<Response> {
14- const { roomId } = params;
15- const isPublic = !locals.user || url.searchParams.get('public') === 'true';
16-
17- try {
18- log.debug('Fetching room data', {
19- roomId,
20- isPublic,
21- requestedBy: locals.user ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}` : 'public'
22- });
23-
24- // Calculate timeout threshold
25- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
14+ const { roomId } = params;
15+ const isPublic = !locals.user || url.searchParams.get('public') === 'true';
2616
27- // Fetch buddy list: get all users
28- const fetchedUsers = await db.select()
29- .from(users)
30- .execute();
17+ try {
18+ log.debug('Fetching room data', {
19+ roomId,
20+ isPublic,
21+ requestedBy: locals.user
22+ ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
23+ : 'public'
24+ });
3125
32- // Normalize users to ensure that lastSeen is always a number (using 0 if null)
33- const normalizedUsers = fetchedUsers.map(
34- (user: SafeUser & { lastSeen: number | null }) => ({
35- ...user,
36- lastSeen: user.lastSeen ?? 0
37- })
38- );
26+ // Calculate timeout threshold
27+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
3928
40- // Process users to mark them as offline if they've timed out
41- const processedUsers = normalizedUsers.map(
42- (user: SafeUser & { lastSeen: number }) => {
43- const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
44- return {
45- ...user,
46- status: shouldBeOffline ? 'offline' : user.status
47- };
48- }
49- );
29+ // Fetch buddy list: get all users
30+ const fetchedUsers = await db.select().from(users).execute();
5031
51- // Find users who were originally non-offline but now marked offline
52- const usersToUpdate = processedUsers.filter(
53- (user: SafeUser & { lastSeen: number }) =>
54- user.status === 'offline' &&
55- normalizedUsers.find(
56- (u: SafeUser & { lastSeen: number }) => u.id === user.id
57- )?.status !== 'offline'
58- );
32+ // Normalize users to ensure that lastSeen is always a number (using 0 if null)
33+ const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({
34+ ...user,
35+ lastSeen: user.lastSeen ?? 0
36+ }));
5937
60- if (usersToUpdate.length > 0) {
61- await Promise.all(
62- usersToUpdate.map((user: SafeUser & { lastSeen: number }) =>
63- db.update(users)
64- .set({ status: 'offline', lastSeen: Date.now() })
65- .where(eq(users.id, user.id))
66- .execute()
67- )
68- );
69-
70- log.debug('Updated offline status for users', {
71- count: usersToUpdate.length,
72- userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id)
73- });
74- }
38+ // Process users to mark them as offline if they've timed out
39+ const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => {
40+ const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
41+ return {
42+ ...user,
43+ status: shouldBeOffline ? 'offline' : user.status
44+ };
45+ });
7546
76- // Sanitize user data using createSafeUser
77- const sanitizedUsers = processedUsers.map(
78- (user: SafeUser & { lastSeen: number }) => createSafeUser(user)
79- );
47+ // Find users who were originally non-offline but now marked offline
48+ const usersToUpdate = processedUsers.filter(
49+ (user: SafeUser & { lastSeen: number }) =>
50+ user.status === 'offline' &&
51+ normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !==
52+ 'offline'
53+ );
8054
81- const responseData: PublicRoomResponse = {
82- success: true,
83- buddyList: sanitizedUsers
84- };
55+ if (usersToUpdate.length > 0) {
56+ await Promise.all(
57+ usersToUpdate.map((user: SafeUser & { lastSeen: number }) =>
58+ db
59+ .update(users)
60+ .set({ status: 'offline', lastSeen: Date.now() })
61+ .where(eq(users.id, user.id))
62+ .execute()
63+ )
64+ );
8565
86- log.debug('Successfully fetched room data', {
87- roomId,
88- userCount: sanitizedUsers.length,
89- isPublic,
90- offlineUpdates: usersToUpdate.length
91- });
66+ log.debug('Updated offline status for users', {
67+ count: usersToUpdate.length,
68+ userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id)
69+ });
70+ }
9271
93- return new Response(JSON.stringify(responseData), {
94- status: 200,
95- headers: { 'Content-Type': 'application/json' }
96- });
97- } catch (error) {
98- log.error('Error fetching room data', {
99- roomId,
100- error: error instanceof Error ? error.message : 'Unknown error'
101- });
102- const errorResponse: PublicRoomResponse = {
103- success: false,
104- error: 'Failed to fetch room data'
105- };
106- return new Response(JSON.stringify(errorResponse), {
107- status: 500,
108- headers: { 'Content-Type': 'application/json' }
109- });
110- }
111-} ⏎̸
72+ // Sanitize user data using createSafeUser
73+ const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) =>
74+ createSafeUser(user)
75+ );
76+
77+ const responseData: PublicRoomResponse = {
78+ success: true,
79+ buddyList: sanitizedUsers
80+ };
81+
82+ log.debug('Successfully fetched room data', {
83+ roomId,
84+ userCount: sanitizedUsers.length,
85+ isPublic,
86+ offlineUpdates: usersToUpdate.length
87+ });
88+
89+ return new Response(JSON.stringify(responseData), {
90+ status: 200,
91+ headers: { 'Content-Type': 'application/json' }
92+ });
93+ } catch (error) {
94+ log.error('Error fetching room data', {
95+ roomId,
96+ error: error instanceof Error ? error.message : 'Unknown error'
97+ });
98+ const errorResponse: PublicRoomResponse = {
99+ success: false,
100+ error: 'Failed to fetch room data'
101+ };
102+ return new Response(JSON.stringify(errorResponse), {
103+ status: 500,
104+ headers: { 'Content-Type': 'application/json' }
105+ });
106+ }
107+}
M src/routes/api/session/login/+server.ts +149 -136
@@ -14,144 +14,157 @@ import { z } from 'zod';
1414 const log = createLogger('login-server');
1515
1616 // 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 }>();
1818 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));
2020
2121 // Cleanup old entries every hour
22-setInterval(() => {
23- const now = Date.now();
24- const ONE_HOUR = 60 * 60 * 1000;
25- for (const [ip, data] of loginAttempts.entries()) {
26- if (now - data.lastAttempt > ONE_HOUR) {
27- loginAttempts.delete(ip);
28- }
29- }
30-}, 60 * 60 * 1000);
31-
22+setInterval(
23+ () => {
24+ const now = Date.now();
25+ const ONE_HOUR = 60 * 60 * 1000;
26+ for (const [ip, data] of loginAttempts.entries()) {
27+ if (now - data.lastAttempt > ONE_HOUR) {
28+ loginAttempts.delete(ip);
29+ }
30+ }
31+ },
32+ 60 * 60 * 1000
33+);
3234
3335 export const POST: RequestHandler = async ({ request, cookies }) => {
34- log.debug('New login attempt received');
35-
36- if (request.method !== 'POST') {
37- log.warn('Invalid method used', { method: request.method });
38- return new Response(
39- JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError),
40- { status: 405 }
41- );
42- }
43-
44- // Get the IP address for rate limiting and Turnstile validation
45- const ip = request.headers.get('x-forwarded-for') || 'unknown';
46- const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.');
47- const now = Date.now();
48- const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
49-
50- if (attemptData.count >= MAX_ATTEMPTS) {
51- const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
52- log.warn('Rate limit exceeded', { maskedIp, delay });
53- await sleep(delay);
54- }
55-
56- let body: unknown;
57- try {
58- body = await request.json();
59- } catch {
60- log.warn('Invalid JSON payload received');
61- return new Response(
62- JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError),
63- { status: 400 }
64- );
65- }
66-
67- const { username, password } = body as {
68- username: string;
69- password: string;
70- };
71-
72- // Validate input using Zod schema
73- try {
74- loginSchema.parse({ username, password });
75- } catch (err) {
76- const zodError = err as z.ZodError;
77- const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data';
78- log.warn('Login validation failed', { error: errorMessage });
79- return new Response(
80- JSON.stringify({ error: errorMessage } as LoginResponseError),
81- { status: 400 }
82- );
83- }
84-
85-
86- // Find user by username
87- const user = await db.query.users.findFirst({
88- where: eq(users.nickname, username.trim())
89- });
90-
91- if (!user) {
92- attemptData.count++;
93- attemptData.lastAttempt = now;
94- loginAttempts.set(ip, attemptData);
95- log.warn('Login failed - user not found', {
96- maskedIp,
97- attemptCount: attemptData.count,
98- username: username.trim()
99- });
100- const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
101- return new Response(JSON.stringify({
102- error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
103- } as LoginResponseError), { status: 401 });
104- }
105-
106- // Verify password
107- try {
108- const isValid = await verifyPassword(password.trim(), user.password);
109- if (!isValid) {
110- attemptData.count++;
111- attemptData.lastAttempt = now;
112- loginAttempts.set(ip, attemptData);
113- log.warn('Login failed - invalid password', {
114- maskedIp,
115- attemptCount: attemptData.count,
116- userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
117- });
118- const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
119- return new Response(JSON.stringify({
120- error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
121- } as LoginResponseError), { status: 401 });
122- }
123- } catch (error) {
124- log.error('Error verifying password', { error });
125- return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
126- }
127-
128- // Reset login attempt data on success
129- loginAttempts.delete(ip);
130-
131- // Generate session token and create session
132- const token = generateSessionToken();
133- const session = await createSession(token, user.id);
134-
135- // Update user status to online
136- await db.update(users)
137- .set({
138- status: 'online',
139- lastSeen: Date.now()
140- })
141- .where(eq(users.id, user.id));
142-
143- // Set session cookie
144- setSessionTokenCookie({ cookies }, token, session.expiresAt);
145-
146- log.info('Login successful', {
147- userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
148- expiresAt: new Date(session.expiresAt).toISOString()
149- });
150-
151- return new Response(JSON.stringify({
152- success: true,
153- user: createSafeUser(user)
154- } as LoginResponseSuccess), {
155- status: 200
156- });
157-}; ⏎̸
36+ log.debug('New login attempt received');
37+
38+ if (request.method !== 'POST') {
39+ log.warn('Invalid method used', { method: request.method });
40+ return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), {
41+ status: 405
42+ });
43+ }
44+
45+ // Get the IP address for rate limiting and Turnstile validation
46+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
47+ const maskedIp = ip
48+ .split('.')
49+ .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
50+ .join('.');
51+ const now = Date.now();
52+ const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
53+
54+ if (attemptData.count >= MAX_ATTEMPTS) {
55+ const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
56+ log.warn('Rate limit exceeded', { maskedIp, delay });
57+ await sleep(delay);
58+ }
59+
60+ let body: unknown;
61+ try {
62+ body = await request.json();
63+ } catch {
64+ log.warn('Invalid JSON payload received');
65+ return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), {
66+ status: 400
67+ });
68+ }
69+
70+ const { username, password } = body as {
71+ username: string;
72+ password: string;
73+ };
74+
75+ // Validate input using Zod schema
76+ try {
77+ loginSchema.parse({ username, password });
78+ } catch (err) {
79+ const zodError = err as z.ZodError;
80+ const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data';
81+ log.warn('Login validation failed', { error: errorMessage });
82+ return new Response(JSON.stringify({ error: errorMessage } as LoginResponseError), {
83+ status: 400
84+ });
85+ }
86+
87+ // Find user by username
88+ const user = await db.query.users.findFirst({
89+ where: eq(users.nickname, username.trim())
90+ });
91+
92+ if (!user) {
93+ attemptData.count++;
94+ attemptData.lastAttempt = now;
95+ loginAttempts.set(ip, attemptData);
96+ log.warn('Login failed - user not found', {
97+ maskedIp,
98+ attemptCount: attemptData.count,
99+ username: username.trim()
100+ });
101+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
102+ return new Response(
103+ JSON.stringify({
104+ error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
105+ } as LoginResponseError),
106+ { status: 401 }
107+ );
108+ }
109+
110+ // Verify password
111+ try {
112+ const isValid = await verifyPassword(password.trim(), user.password);
113+ if (!isValid) {
114+ attemptData.count++;
115+ attemptData.lastAttempt = now;
116+ loginAttempts.set(ip, attemptData);
117+ log.warn('Login failed - invalid password', {
118+ maskedIp,
119+ attemptCount: attemptData.count,
120+ userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
121+ });
122+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
123+ return new Response(
124+ JSON.stringify({
125+ error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
126+ } as LoginResponseError),
127+ { status: 401 }
128+ );
129+ }
130+ } catch (error) {
131+ log.error('Error verifying password', { error });
132+ return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), {
133+ status: 500
134+ });
135+ }
136+
137+ // Reset login attempt data on success
138+ loginAttempts.delete(ip);
139+
140+ // Generate session token and create session
141+ const token = generateSessionToken();
142+ const session = await createSession(token, user.id);
143+
144+ // Update user status to online
145+ await db
146+ .update(users)
147+ .set({
148+ status: 'online',
149+ lastSeen: Date.now()
150+ })
151+ .where(eq(users.id, user.id));
152+
153+ // Set session cookie
154+ setSessionTokenCookie({ cookies }, token, session.expiresAt);
155+
156+ log.info('Login successful', {
157+ userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
158+ expiresAt: new Date(session.expiresAt).toISOString()
159+ });
160+
161+ return new Response(
162+ JSON.stringify({
163+ success: true,
164+ user: createSafeUser(user)
165+ } as LoginResponseSuccess),
166+ {
167+ status: 200
168+ }
169+ );
170+};
M src/routes/api/session/logout/+server.ts +15 -13
@@ -6,16 +6,18 @@ import { createLogger } from '$lib/utils/logger.server';
66 const log = createLogger('logout-server');
77
88 export async function POST({ cookies }) {
9- const token = cookies.get('session');
10-
11- if (token) {
12- const sessionId = await sha256(token);
13- await invalidateSession(sessionId);
14- cookies.delete('session', { path: '/' });
15- log.info('Session invalidated', { sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` });
16- } else {
17- log.debug('No session token found in cookies');
18- }
19-
20- return json({ success: true });
21-} ⏎̸
9+ const token = cookies.get('session');
10+
11+ if (token) {
12+ const sessionId = await sha256(token);
13+ await invalidateSession(sessionId);
14+ cookies.delete('session', { path: '/' });
15+ log.info('Session invalidated', {
16+ sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
17+ });
18+ } else {
19+ log.debug('No session token found in cookies');
20+ }
21+
22+ return json({ success: true });
23+}
M src/routes/api/session/validate/+server.ts +20 -20
@@ -5,23 +5,23 @@ import { createLogger } from '$lib/utils/logger.server';
55 const log = createLogger('validate-server');
66
77 export async function GET({ cookies }) {
8- const token = cookies.get('session');
9-
10- if (!token) {
11- log.debug('No session token found in cookies');
12- return json({ session: null, user: null }, { status: 401 });
13- }
14-
15- const result = await validateSessionToken(token);
16-
17- if (result.session && result.user) {
18- log.debug('Session validated', {
19- userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,
20- sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`
21- });
22- } else {
23- log.debug('Invalid session token');
24- }
25-
26- return json(result);
27-} ⏎̸
8+ const token = cookies.get('session');
9+
10+ if (!token) {
11+ log.debug('No session token found in cookies');
12+ return json({ session: null, user: null }, { status: 401 });
13+ }
14+
15+ const result = await validateSessionToken(token);
16+
17+ if (result.session && result.user) {
18+ log.debug('Session validated', {
19+ userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,
20+ sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`
21+ });
22+ } else {
23+ log.debug('Invalid session token');
24+ }
25+
26+ return json(result);
27+}
M src/routes/api/sse/+server.ts +273 -271
@@ -1,7 +1,6 @@
11 // At the top of the file, before any code
22 declare global {
3- // eslint-disable-next-line no-var
4- var __sseIntervalsStarted: boolean;
3+ var __sseIntervalsStarted: boolean;
54 }
65
76 import type { RequestHandler } from './$types';
@@ -34,292 +33,295 @@ let lastBuddyListData: unknown[] = [];
3433
3534 // Initialize periodic check for timed out users
3635 function startTimeoutCheck() {
37- if (timeoutInterval) {
38- clearInterval(timeoutInterval);
39- }
40-
41- timeoutInterval = setInterval(async () => {
42- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
43- log.debug('Checking for timed out users', {
44- checkTime: new Date().toISOString(),
45- timeoutThreshold: new Date(timeoutThreshold).toISOString()
46- });
47-
48- try {
49- const now = Date.now();
50- const result = await db.update(users)
51- .set({
52- status: 'offline',
53- lastSeen: now
54- })
55- .where(
56- and(
57- ne(users.status, 'offline'), // Update any non-offline users
58- lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
59- )
60- )
61- .returning({
62- id: users.id,
63- nickname: users.nickname,
64- lastSeen: users.lastSeen
65- });
66-
67- if (result.length > 0) {
68- log.info('User status updated to offline', {
69- usersUpdated: result.length,
70- timeoutThreshold: new Date(timeoutThreshold).toISOString()
71- });
72-
73- // Invalidate buddy list cache when users timeout
74- buddyListCache.invalidate();
75- }
76- } catch (error) {
77- log.error('Error during timeout check', { error });
78- }
79- }, CHECK_INTERVAL_MS);
36+ if (timeoutInterval) {
37+ clearInterval(timeoutInterval);
38+ }
39+
40+ timeoutInterval = setInterval(async () => {
41+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
42+ log.debug('Checking for timed out users', {
43+ checkTime: new Date().toISOString(),
44+ timeoutThreshold: new Date(timeoutThreshold).toISOString()
45+ });
46+
47+ try {
48+ const now = Date.now();
49+ const result = await db
50+ .update(users)
51+ .set({
52+ status: 'offline',
53+ lastSeen: now
54+ })
55+ .where(
56+ and(
57+ ne(users.status, 'offline'), // Update any non-offline users
58+ lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
59+ )
60+ )
61+ .returning({
62+ id: users.id,
63+ nickname: users.nickname,
64+ lastSeen: users.lastSeen
65+ });
66+
67+ if (result.length > 0) {
68+ log.info('User status updated to offline', {
69+ usersUpdated: result.length,
70+ timeoutThreshold: new Date(timeoutThreshold).toISOString()
71+ });
72+
73+ // Invalidate buddy list cache when users timeout
74+ buddyListCache.invalidate();
75+ }
76+ } catch (error) {
77+ log.error('Error during timeout check', { error });
78+ }
79+ }, CHECK_INTERVAL_MS);
8080 }
8181
8282 function startBuddyListUpdateInterval() {
83- if (buddyListInterval) {
84- clearInterval(buddyListInterval);
85- }
86-
87- buddyListInterval = setInterval(async () => {
88- try {
89- const now = Date.now();
90- // Only fetch and broadcast if 10 seconds have passed since last update
91- if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
92- // Only query database if cache is invalid or doesn't exist
93- if (buddyListCache.needsRefresh()) {
94- log.debug('Fetching buddy list from database (cache miss)...');
95- // Query the complete buddy list from the database.
96- const buddyList = await db.select().from(users);
97- // Use createSafeUser to properly sanitize user data
98- const safeBuddyList = buddyList.map(user => createSafeUser(user));
99-
100- // Update cache and check if data changed
101- const hasChanged = buddyListCache.update(safeBuddyList);
102-
103- if (hasChanged) {
104- log.debug('Broadcasting buddy list update (data changed)...');
105- sseEmitter.emit('sse', {
106- type: 'buddyListUpdate',
107- data: safeBuddyList
108- });
109- }
110- } else {
111- // Use cached data
112- const cachedData = buddyListCache.get();
113- if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114- log.debug('Broadcasting buddy list update from cache...');
115- lastBuddyListData = cachedData;
116- sseEmitter.emit('sse', {
117- type: 'buddyListUpdate',
118- data: cachedData
119- });
120- }
121- }
122- lastBuddyListUpdate = now;
123- }
124- } catch (error) {
125- log.error('Error fetching buddy list for update', { error });
126- }
127- }, 1000); // Check every second but only broadcast every 10 seconds
83+ if (buddyListInterval) {
84+ clearInterval(buddyListInterval);
85+ }
86+
87+ buddyListInterval = setInterval(async () => {
88+ try {
89+ const now = Date.now();
90+ // Only fetch and broadcast if 10 seconds have passed since last update
91+ if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
92+ // Only query database if cache is invalid or doesn't exist
93+ if (buddyListCache.needsRefresh()) {
94+ log.debug('Fetching buddy list from database (cache miss)...');
95+ // Query the complete buddy list from the database.
96+ const buddyList = await db.select().from(users);
97+ // Use createSafeUser to properly sanitize user data
98+ const safeBuddyList = buddyList.map((user) => createSafeUser(user));
99+
100+ // Update cache and check if data changed
101+ const hasChanged = buddyListCache.update(safeBuddyList);
102+
103+ if (hasChanged) {
104+ log.debug('Broadcasting buddy list update (data changed)...');
105+ sseEmitter.emit('sse', {
106+ type: 'buddyListUpdate',
107+ data: safeBuddyList
108+ });
109+ }
110+ } else {
111+ // Use cached data
112+ const cachedData = buddyListCache.get();
113+ if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114+ log.debug('Broadcasting buddy list update from cache...');
115+ lastBuddyListData = cachedData;
116+ sseEmitter.emit('sse', {
117+ type: 'buddyListUpdate',
118+ data: cachedData
119+ });
120+ }
121+ }
122+ lastBuddyListUpdate = now;
123+ }
124+ } catch (error) {
125+ log.error('Error fetching buddy list for update', { error });
126+ }
127+ }, 1000); // Check every second but only broadcast every 10 seconds
128128 }
129129
130130 // Add periodic cleanup for stale connections
131131 let connectionCleanupInterval: ReturnType<typeof setInterval>;
132132
133133 function startConnectionCleanup() {
134- if (connectionCleanupInterval) {
135- clearInterval(connectionCleanupInterval);
136- }
137-
138- connectionCleanupInterval = setInterval(() => {
139- const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140- if (staleUserIds.length > 0) {
141- // Mark stale connections as offline
142- staleUserIds.forEach(async (userId) => {
143- try {
144- await db.update(users)
145- .set({ status: 'offline', lastSeen: Date.now() })
146- .where(eq(users.id, userId));
147-
148- buddyListCache.invalidate();
149- } catch (error) {
150- log.error('Error updating stale user status', { userId, error });
151- }
152- });
153- }
154- }, 60000); // Run every minute
134+ if (connectionCleanupInterval) {
135+ clearInterval(connectionCleanupInterval);
136+ }
137+
138+ connectionCleanupInterval = setInterval(() => {
139+ const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140+ if (staleUserIds.length > 0) {
141+ // Mark stale connections as offline
142+ staleUserIds.forEach(async (userId) => {
143+ try {
144+ await db
145+ .update(users)
146+ .set({ status: 'offline', lastSeen: Date.now() })
147+ .where(eq(users.id, userId));
148+
149+ buddyListCache.invalidate();
150+ } catch (error) {
151+ log.error('Error updating stale user status', { userId, error });
152+ }
153+ });
154+ }
155+ }, 60000); // Run every minute
155156 }
156157
157158 // <<< NEW: Ensure intervals are started only once >>>
158159 if (!globalThis.__sseIntervalsStarted) {
159- startTimeoutCheck();
160- startBuddyListUpdateInterval();
161- startConnectionCleanup();
162- globalThis.__sseIntervalsStarted = true;
163- log.info('Started global intervals for timeout and buddy list updates');
160+ startTimeoutCheck();
161+ startBuddyListUpdateInterval();
162+ startConnectionCleanup();
163+ globalThis.__sseIntervalsStarted = true;
164+ log.info('Started global intervals for timeout and buddy list updates');
164165 } else {
165- log.debug('Global intervals already started, skipping initialization');
166+ log.debug('Global intervals already started, skipping initialization');
166167 }
167168
168169 // Ensure the interval is cleared if the module is reloaded
169170 if (typeof process !== 'undefined') {
170- process.on('beforeExit', () => {
171- if (timeoutInterval) clearInterval(timeoutInterval);
172- if (buddyListInterval) clearInterval(buddyListInterval);
173- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
174- });
175-
176- // Also handle SIGTERM and SIGINT
177- process.on('SIGTERM', () => {
178- if (timeoutInterval) clearInterval(timeoutInterval);
179- if (buddyListInterval) clearInterval(buddyListInterval);
180- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
181- process.exit(0);
182- });
183-
184- process.on('SIGINT', () => {
185- if (timeoutInterval) clearInterval(timeoutInterval);
186- if (buddyListInterval) clearInterval(buddyListInterval);
187- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
188- process.exit(0);
189- });
171+ process.on('beforeExit', () => {
172+ if (timeoutInterval) clearInterval(timeoutInterval);
173+ if (buddyListInterval) clearInterval(buddyListInterval);
174+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
175+ });
176+
177+ // Also handle SIGTERM and SIGINT
178+ process.on('SIGTERM', () => {
179+ if (timeoutInterval) clearInterval(timeoutInterval);
180+ if (buddyListInterval) clearInterval(buddyListInterval);
181+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
182+ process.exit(0);
183+ });
184+
185+ process.on('SIGINT', () => {
186+ if (timeoutInterval) clearInterval(timeoutInterval);
187+ if (buddyListInterval) clearInterval(buddyListInterval);
188+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
189+ process.exit(0);
190+ });
190191 }
191192
192-export const GET: RequestHandler = async ({ request, locals, }) => {
193- log.debug('Processing SSE request', {
194- url: request.url,
195- userId: locals.user?.id
196- });
197-
198- if (!locals.user) {
199- log.warn('Authentication failed - no user in locals');
200- return new Response('Unauthorized', {
201- status: 401,
202- headers: {
203- 'Content-Type': 'application/json'
204- }
205- });
206- }
207-
208- const userId = locals.user.id;
209- const encoder = new TextEncoder();
210- let keepAliveInterval: ReturnType<typeof setInterval>;
211- let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
212-
213- const now = Date.now();
214-
215- // Only update status if this is a new connection
216- const isNewConnection = sseConnectionTracker.addConnection(userId);
217-
218- if (isNewConnection) {
219- try {
220- await db.update(users)
221- .set({
222- status: 'online',
223- lastSeen: now
224- })
225- .where(eq(users.id, userId));
226-
227- log.info('User status updated to online', {
228- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
229- timestamp: new Date(now).toISOString()
230- });
231-
232- // Invalidate buddy list cache when user status changes
233- buddyListCache.invalidate();
234-
235- sseEmitter.emit('sse', {
236- type: 'userStatusUpdate',
237- data: {
238- userId,
239- status: 'online',
240- lastSeen: now
241- }
242- });
243- } catch (error) {
244- log.error('Error updating user status', { error });
245- sseConnectionTracker.removeConnection(userId);
246- }
247- } else {
248- log.debug('User already has active SSE connection, skipping status update', {
249- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
250- });
251- }
252-
253- const stream = new ReadableStream({
254- start(controller) {
255- const sendEvent = (event: { type: string; data: unknown }) => {
256- try {
257- const payload = `event: ${event.type}\n` +
258- `data: ${JSON.stringify(event.data)}\n\n`;
259- controller.enqueue(encoder.encode(payload));
260- if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {
261- log.info('Event sent', { type: event.type });
262- }
263- } catch (error) {
264- log.error('Error sending event', { error });
265- }
266- };
267-
268- onSSE = (event: { type: string; data: unknown }) => {
269- sendEvent(event);
270- };
271-
272- sseEmitter.addListener('sse', onSSE);
273- request.signal.addEventListener('abort', () => {
274- if (onSSE) {
275- sseEmitter.removeListener('sse', onSSE);
276- log.info('Removed listener due to abort signal');
277- }
278- });
279- log.info('Added new listener');
280-
281- controller.enqueue(encoder.encode('data: Connected\n\n'));
282-
283- keepAliveInterval = setInterval(() => {
284- controller.enqueue(encoder.encode(':\n\n'));
285- }, 20000);
286- },
287- async cancel(reason) {
288- if (keepAliveInterval) clearInterval(keepAliveInterval);
289- if (onSSE) sseEmitter.removeListener('sse', onSSE);
290- const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
291- log.info('Connection closed', { userId: maskedUserId, reason });
292-
293- // Remove from connection tracker
294- const wasTracked = sseConnectionTracker.removeConnection(userId);
295-
296- // Only update database if this was the last connection
297- if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
298- const now = Date.now();
299- await db.update(users)
300- .set({ status: 'offline', lastSeen: now })
301- .where(eq(users.id, userId));
302-
303- // Invalidate buddy list cache when user goes offline
304- buddyListCache.invalidate();
305-
306- sseEmitter.emit('sse', {
307- type: 'userStatusUpdate',
308- data: {
309- userId,
310- status: 'offline',
311- lastSeen: now
312- }
313- });
314- }
315- }
316- });
317-
318- return new Response(stream, {
319- headers: {
320- 'Content-Type': 'text/event-stream',
321- 'Cache-Control': 'no-cache',
322- 'Connection': 'keep-alive'
323- }
324- });
325-}; ⏎̸
193+export const GET: RequestHandler = async ({ request, locals }) => {
194+ log.debug('Processing SSE request', {
195+ url: request.url,
196+ userId: locals.user?.id
197+ });
198+
199+ if (!locals.user) {
200+ log.warn('Authentication failed - no user in locals');
201+ return new Response('Unauthorized', {
202+ status: 401,
203+ headers: {
204+ 'Content-Type': 'application/json'
205+ }
206+ });
207+ }
208+
209+ const userId = locals.user.id;
210+ const encoder = new TextEncoder();
211+ let keepAliveInterval: ReturnType<typeof setInterval>;
212+ let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
213+
214+ const now = Date.now();
215+
216+ // Only update status if this is a new connection
217+ const isNewConnection = sseConnectionTracker.addConnection(userId);
218+
219+ if (isNewConnection) {
220+ try {
221+ await db
222+ .update(users)
223+ .set({
224+ status: 'online',
225+ lastSeen: now
226+ })
227+ .where(eq(users.id, userId));
228+
229+ log.info('User status updated to online', {
230+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
231+ timestamp: new Date(now).toISOString()
232+ });
233+
234+ // Invalidate buddy list cache when user status changes
235+ buddyListCache.invalidate();
236+
237+ sseEmitter.emit('sse', {
238+ type: 'userStatusUpdate',
239+ data: {
240+ userId,
241+ status: 'online',
242+ lastSeen: now
243+ }
244+ });
245+ } catch (error) {
246+ log.error('Error updating user status', { error });
247+ sseConnectionTracker.removeConnection(userId);
248+ }
249+ } else {
250+ log.debug('User already has active SSE connection, skipping status update', {
251+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
252+ });
253+ }
254+
255+ const stream = new ReadableStream({
256+ start(controller) {
257+ const sendEvent = (event: { type: string; data: unknown }) => {
258+ try {
259+ const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`;
260+ controller.enqueue(encoder.encode(payload));
261+ if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {
262+ log.info('Event sent', { type: event.type });
263+ }
264+ } catch (error) {
265+ log.error('Error sending event', { error });
266+ }
267+ };
268+
269+ onSSE = (event: { type: string; data: unknown }) => {
270+ sendEvent(event);
271+ };
272+
273+ sseEmitter.addListener('sse', onSSE);
274+ request.signal.addEventListener('abort', () => {
275+ if (onSSE) {
276+ sseEmitter.removeListener('sse', onSSE);
277+ log.info('Removed listener due to abort signal');
278+ }
279+ });
280+ log.info('Added new listener');
281+
282+ controller.enqueue(encoder.encode('data: Connected\n\n'));
283+
284+ keepAliveInterval = setInterval(() => {
285+ controller.enqueue(encoder.encode(':\n\n'));
286+ }, 20000);
287+ },
288+ async cancel(reason) {
289+ if (keepAliveInterval) clearInterval(keepAliveInterval);
290+ if (onSSE) sseEmitter.removeListener('sse', onSSE);
291+ const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
292+ log.info('Connection closed', { userId: maskedUserId, reason });
293+
294+ // Remove from connection tracker
295+ const wasTracked = sseConnectionTracker.removeConnection(userId);
296+
297+ // Only update database if this was the last connection
298+ if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
299+ const now = Date.now();
300+ await db
301+ .update(users)
302+ .set({ status: 'offline', lastSeen: now })
303+ .where(eq(users.id, userId));
304+
305+ // Invalidate buddy list cache when user goes offline
306+ buddyListCache.invalidate();
307+
308+ sseEmitter.emit('sse', {
309+ type: 'userStatusUpdate',
310+ data: {
311+ userId,
312+ status: 'offline',
313+ lastSeen: now
314+ }
315+ });
316+ }
317+ }
318+ });
319+
320+ return new Response(stream, {
321+ headers: {
322+ 'Content-Type': 'text/event-stream',
323+ 'Cache-Control': 'no-cache',
324+ Connection: 'keep-alive'
325+ }
326+ });
327+};
M src/routes/api/status/+server.ts +79 -72
@@ -11,81 +11,88 @@ import type { RequestHandler } from './$types';
1111 const log = createLogger('status-server');
1212
1313 export const POST: RequestHandler = async ({ request, cookies, locals }) => {
14- if (!locals.user) {
15- log.warn('Authentication required');
16- return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), {
17- status: 401,
18- headers: { 'Content-Type': 'application/json' }
19- });
20- }
14+ if (!locals.user) {
15+ log.warn('Authentication required');
16+ return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), {
17+ status: 401,
18+ headers: { 'Content-Type': 'application/json' }
19+ });
20+ }
2121
22- try {
23- const { status } = await request.json();
24- const validStatuses = ['online', 'away', 'busy', 'offline'];
25- if (!validStatuses.includes(status)) {
26- log.warn('Invalid status received', { status });
27- return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), {
28- status: 400,
29- headers: { 'Content-Type': 'application/json' }
30- });
31- }
22+ try {
23+ const { status } = await request.json();
24+ const validStatuses = ['online', 'away', 'busy', 'offline'];
25+ if (!validStatuses.includes(status)) {
26+ log.warn('Invalid status received', { status });
27+ return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), {
28+ status: 400,
29+ headers: { 'Content-Type': 'application/json' }
30+ });
31+ }
3232
33- const userId = locals.user.id;
34- const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
35- log.debug('Updating user status', { userId: maskedUserId, status });
33+ const userId = locals.user.id;
34+ const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
35+ log.debug('Updating user status', { userId: maskedUserId, status });
3636
37- const now = Date.now();
37+ const now = Date.now();
3838
39- // Update the user status in the database
40- // Always update lastSeen to support timeout detection
41- const updatedUser = await db.update(users)
42- .set({
43- status,
44- lastSeen: now // Always update lastSeen for proper timeout detection
45- })
46- .where(eq(users.id, userId))
47- .returning()
48- .get();
49- log.debug('Database updated successfully', { userId: maskedUserId, status });
50-
51- // Invalidate buddy list cache when status changes
52- buddyListCache.invalidate();
39+ // Update the user status in the database
40+ // Always update lastSeen to support timeout detection
41+ const updatedUser = await db
42+ .update(users)
43+ .set({
44+ status,
45+ lastSeen: now // Always update lastSeen for proper timeout detection
46+ })
47+ .where(eq(users.id, userId))
48+ .returning()
49+ .get();
50+ log.debug('Database updated successfully', { userId: maskedUserId, status });
5351
54- // Renew session if status is online and session expires in less than 1 day
55- if (status === 'online') {
56- const token = cookies.get('session');
57- if (token) {
58- const result = await validateSessionToken(token);
59- if (result.session) {
60- const remaining = result.session.expiresAt - now;
61- const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
62- if (remaining < threshold) {
63- const newToken = generateSessionToken();
64- const newSession = await createSession(newToken, userId);
65- setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
66- log.info('Session renewed', {
67- userId: maskedUserId,
68- expiresAt: new Date(newSession.expiresAt).toISOString()
69- });
70- }
71- }
72- }
73- }
52+ // Invalidate buddy list cache when status changes
53+ buddyListCache.invalidate();
7454
75- return new Response(JSON.stringify({
76- success: true,
77- user: createSafeUser(updatedUser)
78- }), {
79- status: 200,
80- headers: { 'Content-Type': 'application/json' }
81- });
82- } catch (error) {
83- return new Response(
84- JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
85- {
86- status: 500,
87- headers: { 'Content-Type': 'application/json' }
88- }
89- );
90- }
91-};⏎̸
55+ // Renew session if status is online and session expires in less than 1 day
56+ if (status === 'online') {
57+ const token = cookies.get('session');
58+ if (token) {
59+ const result = await validateSessionToken(token);
60+ if (result.session) {
61+ const remaining = result.session.expiresAt - now;
62+ const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
63+ if (remaining < threshold) {
64+ const newToken = generateSessionToken();
65+ const newSession = await createSession(newToken, userId);
66+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
67+ log.info('Session renewed', {
68+ userId: maskedUserId,
69+ expiresAt: new Date(newSession.expiresAt).toISOString()
70+ });
71+ }
72+ }
73+ }
74+ }
75+
76+ return new Response(
77+ JSON.stringify({
78+ success: true,
79+ user: createSafeUser(updatedUser)
80+ }),
81+ {
82+ status: 200,
83+ headers: { 'Content-Type': 'application/json' }
84+ }
85+ );
86+ } catch (error) {
87+ return new Response(
88+ JSON.stringify({
89+ success: false,
90+ error: error instanceof Error ? error.message : 'Unknown error'
91+ }),
92+ {
93+ status: 500,
94+ headers: { 'Content-Type': 'application/json' }
95+ }
96+ );
97+ }
98+};
M src/routes/api/users/[userId]/+server.ts +27 -30
@@ -9,37 +9,34 @@ import { createLogger } from '$lib/utils/logger.server';
99 const log = createLogger('users-server');
1010
1111 export const GET: RequestHandler = async ({ params }) => {
12- const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
13- log.debug('Fetching user data', { userId: maskedUserId });
12+ const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
13+ log.debug('Fetching user data', { userId: maskedUserId });
1414
15- try {
16- const user = await db.select()
17- .from(users)
18- .where(eq(users.id, params.userId))
19- .get();
15+ try {
16+ const user = await db.select().from(users).where(eq(users.id, params.userId)).get();
2017
21- if (!user) {
22- log.warn('User not found', { userId: maskedUserId });
23- throw error(404, 'User not found');
24- }
18+ if (!user) {
19+ log.warn('User not found', { userId: maskedUserId });
20+ throw error(404, 'User not found');
21+ }
2522
26- const safeUser = createSafeUser(user);
23+ const safeUser = createSafeUser(user);
2724
28- log.debug('User data retrieved', {
29- userId: maskedUserId,
30- status: safeUser.status,
31- lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
32- });
33-
34- return json({
35- success: true,
36- user: safeUser
37- });
38- } catch (err) {
39- log.error('Error fetching user data', {
40- userId: maskedUserId,
41- error: err instanceof Error ? err.message : 'Unknown error'
42- });
43- throw error(500, 'Failed to fetch user information');
44- }
45-};⏎̸
25+ log.debug('User data retrieved', {
26+ userId: maskedUserId,
27+ status: safeUser.status,
28+ lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
29+ });
30+
31+ return json({
32+ success: true,
33+ user: safeUser
34+ });
35+ } catch (err) {
36+ log.error('Error fetching user data', {
37+ userId: maskedUserId,
38+ error: err instanceof Error ? err.message : 'Unknown error'
39+ });
40+ throw error(500, 'Failed to fetch user information');
41+ }
42+};
M test-gradient-fixes.md +7 -2
@@ -3,16 +3,20 @@
33 ## Issues Fixed:
44
55 ### 1. Color Picker Button Gradient Preview
6+
67 **Problem**: The color picker button wasn't showing gradient preview correctly.
78 **Fix**: Updated `displayStyle` in `color-picker-256.svelte` to use consistent `background` property instead of `background-color`.
89
9-### 2. Text Input Gradient Display
10+### 2. Text Input Gradient Display
11+
1012 **Problem**: Text input only showed the first color of a gradient.
1113 **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.
1214
1315 ### 3. Chat Messages Formatting
16+
1417 **Problem**: Messages weren't displaying with user-specific fonts, styles and colors.
1518 **Fixes**:
19+
1620 - Updated `sendMessage` function to accept and pass `textStyle` parameter
1721 - Added `styleData` field to `SendMessageRequest` payload type
1822 - Updated server-side message handler to save `styleData` and `hasFormatting` fields
@@ -41,9 +45,10 @@
4145 ## Testing:
4246
4347 To test the fixes:
48+
4449 1. Open the chat window
4550 2. Select a gradient using the color picker (add 2-3 colors)
4651 3. Choose a font and adjust size
4752 4. Type a message - you should see the first gradient color in the input
4853 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();
77 const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env;
88
99 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+ );
1113 }
1214
1315 async function flushDatabase() {
14- console.log('Starting database content flush...');
15-
16- const client = createClient({
17- url: TURSO_DATABASE_URL as string,
18- authToken: TURSO_AUTH_TOKEN as string
19- });
20-
21- try {
22- // Clear tables in correct order to respect foreign key constraints
23- console.log('Clearing messages table...');
24- await client.execute('DELETE FROM messages');
25-
26- console.log('Clearing sessions table...');
27- await client.execute('DELETE FROM sessions');
28-
29- console.log('Clearing chat rooms table...');
30- await client.execute('DELETE FROM chat_rooms');
31-
32- console.log('Clearing users table...');
33- await client.execute('DELETE FROM users');
34-
35- console.log('Database content flush completed successfully');
36- } catch (error) {
37- console.error('Error flushing database content:', error);
38- throw error;
39- } finally {
40- await client.close();
41- }
16+ console.log('Starting database content flush...');
17+
18+ const client = createClient({
19+ url: TURSO_DATABASE_URL as string,
20+ authToken: TURSO_AUTH_TOKEN as string
21+ });
22+
23+ try {
24+ // Clear tables in correct order to respect foreign key constraints
25+ console.log('Clearing messages table...');
26+ await client.execute('DELETE FROM messages');
27+
28+ console.log('Clearing sessions table...');
29+ await client.execute('DELETE FROM sessions');
30+
31+ console.log('Clearing chat rooms table...');
32+ await client.execute('DELETE FROM chat_rooms');
33+
34+ console.log('Clearing users table...');
35+ await client.execute('DELETE FROM users');
36+
37+ console.log('Database content flush completed successfully');
38+ } catch (error) {
39+ console.error('Error flushing database content:', error);
40+ throw error;
41+ } finally {
42+ await client.close();
43+ }
4244 }
4345
4446 // Execute if this file is run directly
4547 if (new URL(import.meta.url).pathname === process.argv[1]) {
46- flushDatabase()
47- .then(() => {
48- console.debug('[db-flush] Exiting process with code 0');
49- process.exit(0);
50- })
51- .catch((error) => {
52- console.error('[db-flush] Failed to flush database content:', error);
53- process.exit(1);
54- });
55-} ⏎̸
48+ flushDatabase()
49+ .then(() => {
50+ console.debug('[db-flush] Exiting process with code 0');
51+ process.exit(0);
52+ })
53+ .catch((error) => {
54+ console.error('[db-flush] Failed to flush database content:', error);
55+ process.exit(1);
56+ });
57+}
M vite.config.ts +3 -3
@@ -8,7 +8,7 @@ import tailwindcss from '@tailwindcss/vite';
88 * This project uses two logging systems:
99 * 1. Console logging (development/client-side) - controlled by console stripping
1010 * 2. Pino logging (server-side) - controlled by LOG_LEVEL
11- *
11+ *
1212 * LOG_LEVEL affects both systems:
1313 * - error: only error logs
1414 * - warn: warn and error logs
@@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => {
3535 // Load env file based on `mode` value
3636 const env = loadEnv(mode, process.cwd(), '');
3737 const logLevel = env.LOG_LEVEL || (mode === 'development' ? 'debug' : 'info');
38-
38+
3939 console.log(`Building with LOG_LEVEL=${logLevel} in ${mode} mode`);
4040
4141 return {
@@ -50,7 +50,7 @@ export default defineConfig(({ mode }) => {
5050 // Strip console methods based on LOG_LEVEL
5151 pure_funcs: getConsoleFuncsToStrip(logLevel)
5252 }
53- },
53+ }
5454 },
5555 // Make LOG_LEVEL available to client-side code
5656 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