Merge pull request #1 from pdraim/staging
v0.2.0: Major text formatting, validation, and UI enhancement release
54 files · +6288 -1121
M .gitignore +3 -1
| @@ -22,4 +22,6 @@ Thumbs.db | |||
| 22 | 22 | vite.config.js.timestamp-* | |
| 23 | 23 | vite.config.ts.timestamp-* | |
| 24 | 24 | ||
| 25 | - | docs/⏎̸ | |
| 25 | + | docs/ | |
| 26 | + | ||
| 27 | + | .serena/⏎̸ | |
M package.json +35 -26
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "pdraim", | |
| 3 | 3 | "private": true, | |
| 4 | - | "version": "0.1.5", | |
| 4 | + | "version": "0.2.0", | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "engines": { | |
| 7 | 7 | "node": ">=22.13.0" | |
| @@ -19,44 +19,53 @@ | |||
| 19 | 19 | "test": "npm run test:unit -- --run", | |
| 20 | 20 | "db:generate": "npx drizzle-kit generate", | |
| 21 | 21 | "db:push": "npx drizzle-kit push", | |
| 22 | + | "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'", | |
| 22 | 23 | "db:flush": "tsx utils/db-flush.ts" | |
| 23 | 24 | }, | |
| 24 | 25 | "devDependencies": { | |
| 25 | - | "@eslint/compat": "^1.2.6", | |
| 26 | - | "@eslint/js": "^9.20.0", | |
| 27 | - | "@sveltejs/adapter-cloudflare": "^5.0.3", | |
| 28 | - | "@sveltejs/kit": "^2.17.2", | |
| 29 | - | "@sveltejs/vite-plugin-svelte": "^5.0.3", | |
| 26 | + | "@eslint/compat": "^1.3.1", | |
| 27 | + | "@eslint/js": "^9.30.1", | |
| 28 | + | "@sveltejs/adapter-cloudflare": "^5.1.0", | |
| 29 | + | "@sveltejs/kit": "^2.22.2", | |
| 30 | + | "@sveltejs/vite-plugin-svelte": "^5.1.0", | |
| 30 | 31 | "@tailwindcss/container-queries": "^0.1.1", | |
| 31 | 32 | "@tailwindcss/forms": "^0.5.10", | |
| 32 | 33 | "@tailwindcss/typography": "^0.5.16", | |
| 33 | - | "@types/node": "^22.13.4", | |
| 34 | - | "autoprefixer": "^10.4.20", | |
| 35 | - | "drizzle-kit": "^0.30.4", | |
| 36 | - | "eslint": "^9.20.1", | |
| 37 | - | "eslint-config-prettier": "^10.0.1", | |
| 34 | + | "@types/node": "^22.16.0", | |
| 35 | + | "autoprefixer": "^10.4.21", | |
| 36 | + | "drizzle-kit": "^0.30.6", | |
| 37 | + | "eslint": "^9.30.1", | |
| 38 | + | "eslint-config-prettier": "^10.1.5", | |
| 38 | 39 | "eslint-plugin-svelte": "^2.46.1", | |
| 39 | 40 | "globals": "^15.15.0", | |
| 40 | - | "prettier": "^3.5.1", | |
| 41 | - | "prettier-plugin-svelte": "^3.3.3", | |
| 42 | - | "prettier-plugin-tailwindcss": "^0.6.11", | |
| 43 | - | "svelte": "^5.20.1", | |
| 44 | - | "svelte-check": "^4.1.4", | |
| 41 | + | "jsdom": "^26.1.0", | |
| 42 | + | "prettier": "^3.6.2", | |
| 43 | + | "prettier-plugin-svelte": "^3.4.0", | |
| 44 | + | "prettier-plugin-tailwindcss": "^0.6.13", | |
| 45 | + | "svelte": "^5.35.2", | |
| 46 | + | "svelte-check": "^4.2.2", | |
| 45 | 47 | "tailwindcss": "^3.4.17", | |
| 46 | - | "terser": "^5.39.0", | |
| 47 | - | "tsx": "^4.19.2", | |
| 48 | - | "typescript": "^5.7.3", | |
| 49 | - | "typescript-eslint": "^8.24.0", | |
| 50 | - | "vite": "^6.1.0", | |
| 51 | - | "vitest": "^3.0.5" | |
| 48 | + | "terser": "^5.43.1", | |
| 49 | + | "tsx": "^4.20.3", | |
| 50 | + | "typescript": "^5.8.3", | |
| 51 | + | "typescript-eslint": "^8.35.1", | |
| 52 | + | "vite": "^6.3.5", | |
| 53 | + | "vitest": "^3.2.4" | |
| 52 | 54 | }, | |
| 53 | 55 | "dependencies": { | |
| 54 | 56 | "@libsql/client": "^0.14.0", | |
| 55 | - | "dotenv": "^16.4.7", | |
| 57 | + | "dotenv": "^16.6.1", | |
| 56 | 58 | "drizzle-orm": "^0.39.3", | |
| 57 | - | "pino": "^9.6.0", | |
| 59 | + | "pino": "^9.7.0", | |
| 58 | 60 | "pino-pretty": "^13.0.0", | |
| 59 | - | "uuid": "^11.0.5", | |
| 60 | - | "xp.css": "^0.2.6" | |
| 61 | + | "rehype": "^13.0.2", | |
| 62 | + | "rehype-sanitize": "^6.0.0", | |
| 63 | + | "rehype-stringify": "^10.0.1", | |
| 64 | + | "remark-parse": "^11.0.0", | |
| 65 | + | "remark-rehype": "^11.1.2", | |
| 66 | + | "unified": "^11.0.5", | |
| 67 | + | "uuid": "^11.1.0", | |
| 68 | + | "xp.css": "^0.2.6", | |
| 69 | + | "zod": "^3.25.71" | |
| 61 | 70 | } | |
| 62 | 71 | } | |
M pnpm-lock.yaml +1921 -874 generated file: collapsed
M src/app.css +445 -0
| @@ -2,4 +2,449 @@ | |||
| 2 | 2 | @import 'tailwindcss/components'; | |
| 3 | 3 | @import 'tailwindcss/utilities'; | |
| 4 | 4 | @import 'xp.css'; | |
| 5 | + | @import './lib/styles/fonts.css'; | |
| 6 | + | ||
| 7 | + | /* Local font faces with 30-day cache */ | |
| 8 | + | @font-face { | |
| 9 | + | font-family: 'Arial'; | |
| 10 | + | src: url('/fonts/Arial.TTF') format('truetype'); | |
| 11 | + | font-display: swap; | |
| 12 | + | font-weight: normal; | |
| 13 | + | font-style: normal; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | @font-face { | |
| 17 | + | font-family: 'Arial'; | |
| 18 | + | src: url('/fonts/Arialbd.TTF') format('truetype'); | |
| 19 | + | font-display: swap; | |
| 20 | + | font-weight: bold; | |
| 21 | + | font-style: normal; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | @font-face { | |
| 25 | + | font-family: 'Arial'; | |
| 26 | + | src: url('/fonts/Ariali.TTF') format('truetype'); | |
| 27 | + | font-display: swap; | |
| 28 | + | font-weight: normal; | |
| 29 | + | font-style: italic; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | @font-face { | |
| 33 | + | font-family: 'Arial'; | |
| 34 | + | src: url('/fonts/Arialbi.TTF') format('truetype'); | |
| 35 | + | font-display: swap; | |
| 36 | + | font-weight: bold; | |
| 37 | + | font-style: italic; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | @font-face { | |
| 41 | + | font-family: 'Tahoma'; | |
| 42 | + | src: url('/fonts/tahoma.ttf') format('truetype'); | |
| 43 | + | font-display: swap; | |
| 44 | + | font-weight: 80; | |
| 45 | + | font-style: normal; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | @font-face { | |
| 49 | + | font-family: 'Tahoma'; | |
| 50 | + | src: url('/fonts/tahomabd.ttf') format('truetype'); | |
| 51 | + | font-display: swap; | |
| 52 | + | font-weight: 200; | |
| 53 | + | font-style: normal; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | @font-face { | |
| 57 | + | font-family: 'Verdana'; | |
| 58 | + | src: url('/fonts/Verdana.TTF') format('truetype'); | |
| 59 | + | font-display: swap; | |
| 60 | + | font-weight: normal; | |
| 61 | + | font-style: normal; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | @font-face { | |
| 65 | + | font-family: 'Verdana'; | |
| 66 | + | src: url('/fonts/Verdanab.TTF') format('truetype'); | |
| 67 | + | font-display: swap; | |
| 68 | + | font-weight: bold; | |
| 69 | + | font-style: normal; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | @font-face { | |
| 73 | + | font-family: 'Verdana'; | |
| 74 | + | src: url('/fonts/Verdanai.TTF') format('truetype'); | |
| 75 | + | font-display: swap; | |
| 76 | + | font-weight: normal; | |
| 77 | + | font-style: italic; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | @font-face { | |
| 81 | + | font-family: 'Verdana'; | |
| 82 | + | src: url('/fonts/Verdanaz.TTF') format('truetype'); | |
| 83 | + | font-display: swap; | |
| 84 | + | font-weight: bold; | |
| 85 | + | font-style: italic; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | @font-face { | |
| 89 | + | font-family: 'Comic Sans MS'; | |
| 90 | + | src: url('/fonts/Comic.TTF') format('truetype'); | |
| 91 | + | font-display: swap; | |
| 92 | + | font-weight: normal; | |
| 93 | + | font-style: normal; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | @font-face { | |
| 97 | + | font-family: 'Comic Sans MS'; | |
| 98 | + | src: url('/fonts/Comicbd.TTF') format('truetype'); | |
| 99 | + | font-display: swap; | |
| 100 | + | font-weight: bold; | |
| 101 | + | font-style: normal; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | @font-face { | |
| 105 | + | font-family: 'Times New Roman'; | |
| 106 | + | src: url('/fonts/Times.TTF') format('truetype'); | |
| 107 | + | font-display: swap; | |
| 108 | + | font-weight: normal; | |
| 109 | + | font-style: normal; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | @font-face { | |
| 113 | + | font-family: 'Times New Roman'; | |
| 114 | + | src: url('/fonts/Timesbd.TTF') format('truetype'); | |
| 115 | + | font-display: swap; | |
| 116 | + | font-weight: bold; | |
| 117 | + | font-style: normal; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | @font-face { | |
| 121 | + | font-family: 'Times New Roman'; | |
| 122 | + | src: url('/fonts/Timesi.TTF') format('truetype'); | |
| 123 | + | font-display: swap; | |
| 124 | + | font-weight: normal; | |
| 125 | + | font-style: italic; | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | @font-face { | |
| 129 | + | font-family: 'Times New Roman'; | |
| 130 | + | src: url('/fonts/Timesbi.TTF') format('truetype'); | |
| 131 | + | font-display: swap; | |
| 132 | + | font-weight: bold; | |
| 133 | + | font-style: italic; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | @font-face { | |
| 137 | + | font-family: 'Courier New'; | |
| 138 | + | src: url('/fonts/LiberationMono-Regular.ttf') format('truetype'); | |
| 139 | + | font-display: swap; | |
| 140 | + | font-weight: normal; | |
| 141 | + | font-style: normal; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | @font-face { | |
| 145 | + | font-family: 'Courier New'; | |
| 146 | + | src: url('/fonts/LiberationMono-Bold.ttf') format('truetype'); | |
| 147 | + | font-display: swap; | |
| 148 | + | font-weight: bold; | |
| 149 | + | font-style: normal; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | @font-face { | |
| 153 | + | font-family: 'Courier New'; | |
| 154 | + | src: url('/fonts/LiberationMono-Italic.ttf') format('truetype'); | |
| 155 | + | font-display: swap; | |
| 156 | + | font-weight: normal; | |
| 157 | + | font-style: italic; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | @font-face { | |
| 161 | + | font-family: 'Courier New'; | |
| 162 | + | src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype'); | |
| 163 | + | font-display: swap; | |
| 164 | + | font-weight: bold; | |
| 165 | + | font-style: italic; | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /* ===== RETRO FONT STYLING ===== */ | |
| 169 | + | /* Retro Windows fonts with proper fallbacks */ | |
| 170 | + | .retro-font-pixelated { | |
| 171 | + | font-family: "Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | .retro-font-tahoma { | |
| 175 | + | font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif; | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | .retro-font-verdana { | |
| 179 | + | font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | .retro-font-comicSans { | |
| 183 | + | font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | .retro-font-timesNewRoman { | |
| 187 | + | font-family: "Times New Roman", Times, serif; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | .retro-font-arial { | |
| 191 | + | font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | .retro-font-courierNew { | |
| 195 | + | font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | .retro-font-dos { | |
| 199 | + | font-family: "Perfect DOS VGA 437 Win", "Courier New", Courier, monospace; | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /* ===== GRADIENT TEXT EFFECTS ===== */ | |
| 203 | + | .gradient-text { | |
| 204 | + | background-size: 200% 200%; | |
| 205 | + | -webkit-background-clip: text; | |
| 206 | + | -webkit-text-fill-color: transparent; | |
| 207 | + | background-clip: text; | |
| 208 | + | animation: gradientShift 3s ease infinite; | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | .gradient-text-static { | |
| 212 | + | /* Remove text-fill-color to allow inline color styles to work */ | |
| 213 | + | /* Individual spans will have their own colors set inline */ | |
| 214 | + | letter-spacing: normal; | |
| 215 | + | word-spacing: normal; | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /* Gradient text container uses flexbox */ | |
| 219 | + | .gradient-text-static { | |
| 220 | + | display: inline-flex !important; | |
| 221 | + | flex-wrap: wrap !important; | |
| 222 | + | align-items: baseline !important; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /* Ensure gradient character spans don't add spacing */ | |
| 226 | + | .gradient-text-static > span { | |
| 227 | + | display: inline !important; | |
| 228 | + | margin: 0 !important; | |
| 229 | + | padding: 0 !important; | |
| 230 | + | letter-spacing: 0 !important; | |
| 231 | + | line-height: inherit !important; | |
| 232 | + | font-size: inherit !important; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /* Ensure gradient character colors from inline styles are respected */ | |
| 236 | + | .gradient-text-static .gradient-char { | |
| 237 | + | -webkit-text-fill-color: initial !important; | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /* Gradient text in input fields - preserve normal input background */ | |
| 241 | + | input[style*="background-clip: text"] { | |
| 242 | + | /* Keep default input background while allowing gradient text */ | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /* Gradient animation for AOL-style effects */ | |
| 246 | + | @keyframes gradientShift { | |
| 247 | + | 0% { background-position: 0% 50%; } | |
| 248 | + | 50% { background-position: 100% 50%; } | |
| 249 | + | 100% { background-position: 0% 50%; } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /* Specific gradient presets */ | |
| 253 | + | .gradient-rainbow { | |
| 254 | + | background: linear-gradient(45deg, | |
| 255 | + | #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80, | |
| 256 | + | #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | .gradient-fire { | |
| 260 | + | background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | .gradient-ocean { | |
| 264 | + | background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | .gradient-sunset { | |
| 268 | + | background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | .gradient-neon { | |
| 272 | + | background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | .gradient-forest { | |
| 276 | + | background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | .gradient-royal { | |
| 280 | + | background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /* ===== CHAT MESSAGE FORMATTING ===== */ | |
| 284 | + | .formatted-message { | |
| 285 | + | word-break: break-word; | |
| 286 | + | line-height: 1.4; | |
| 287 | + | max-width: 100%; | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | .formatted-message strong { | |
| 291 | + | font-weight: bold; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | .formatted-message em { | |
| 295 | + | font-style: italic; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | .formatted-message u { | |
| 299 | + | text-decoration: underline; | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | .formatted-message s { | |
| 303 | + | text-decoration: line-through; | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | .formatted-message code { | |
| 307 | + | background: #f0f0f0; | |
| 308 | + | padding: 1px 3px; | |
| 309 | + | border-radius: 2px; | |
| 310 | + | font-family: 'Courier New', Courier, monospace; | |
| 311 | + | font-size: 0.9em; | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | /* ===== INPUT STYLING FIXES ===== */ | |
| 315 | + | /* Fix letter spacing issues with gradient text in inputs */ | |
| 316 | + | input[style*="background-clip: text"] { | |
| 317 | + | letter-spacing: normal !important; | |
| 318 | + | word-spacing: normal !important; | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /* Ensure gradient inputs don't have extra padding */ | |
| 322 | + | input.styled-input { | |
| 323 | + | letter-spacing: 0; | |
| 324 | + | word-spacing: 0; | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /* Ensure proper font weights are used */ | |
| 328 | + | .retro-font-tahoma { | |
| 329 | + | font-weight: 80; | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /* ===== TEXT STYLE SELECTOR STYLING ===== */ | |
| 333 | + | .text-formatting-toolbar { | |
| 334 | + | background: #f0f0f0; | |
| 335 | + | border: 2px inset #dfdfdf; | |
| 336 | + | padding: 4px; | |
| 337 | + | margin-bottom: 8px; | |
| 338 | + | display: flex; | |
| 339 | + | flex-wrap: wrap; | |
| 340 | + | gap: 4px; | |
| 341 | + | align-items: center; | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | .formatting-button { | |
| 345 | + | width: 24px; | |
| 346 | + | height: 24px; | |
| 347 | + | border: 1px outset #dfdfdf; | |
| 348 | + | background: #f0f0f0; | |
| 349 | + | cursor: pointer; | |
| 350 | + | display: flex; | |
| 351 | + | align-items: center; | |
| 352 | + | justify-content: center; | |
| 353 | + | font-size: 12px; | |
| 354 | + | font-weight: bold; | |
| 355 | + | transition: all 0.1s ease; | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | .formatting-button:hover { | |
| 359 | + | background: #e0e0e0; | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | .formatting-button:active, | |
| 363 | + | .formatting-button.active { | |
| 364 | + | border: 1px inset #dfdfdf; | |
| 365 | + | background: #d0d0d0; | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | .font-selector { | |
| 369 | + | padding: 2px 4px; | |
| 370 | + | border: 1px inset #dfdfdf; | |
| 371 | + | background: white; | |
| 372 | + | font-size: 11px; | |
| 373 | + | min-width: 80px; | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | .size-slider { | |
| 377 | + | width: 60px; | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | .color-indicator { | |
| 381 | + | width: 16px; | |
| 382 | + | height: 16px; | |
| 383 | + | border: 1px solid #000; | |
| 384 | + | cursor: pointer; | |
| 385 | + | display: inline-block; | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | /* ===== ACCESSIBILITY IMPROVEMENTS ===== */ | |
| 389 | + | @media (prefers-reduced-motion: reduce) { | |
| 390 | + | .gradient-text { | |
| 391 | + | animation: none; | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /* High contrast mode support */ | |
| 396 | + | @media (prefers-contrast: high) { | |
| 397 | + | .gradient-text { | |
| 398 | + | -webkit-text-fill-color: unset; | |
| 399 | + | background: none; | |
| 400 | + | color: inherit; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | .gradient-text-static { | |
| 404 | + | -webkit-text-fill-color: unset; | |
| 405 | + | background: none; | |
| 406 | + | color: inherit; | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | /* ===== RETRO STYLING ENHANCEMENTS ===== */ | |
| 411 | + | .retro-message { | |
| 412 | + | font-family: inherit; | |
| 413 | + | line-height: 1.3; | |
| 414 | + | word-wrap: break-word; | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | .retro-message.style-bold { | |
| 418 | + | font-weight: bold; | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | .retro-message.style-italic { | |
| 422 | + | font-style: italic; | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | .retro-message.style-underline { | |
| 426 | + | text-decoration: underline; | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | .retro-message.style-strikethrough { | |
| 430 | + | text-decoration: line-through; | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | .retro-message.style-underline.style-strikethrough { | |
| 434 | + | text-decoration: underline line-through; | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | /* ===== PERFORMANCE OPTIMIZATIONS ===== */ | |
| 438 | + | .gradient-text, | |
| 439 | + | .gradient-text-static { | |
| 440 | + | will-change: background-position; | |
| 441 | + | contain: layout style paint; | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /* Limit gradient text length for performance */ | |
| 445 | + | .gradient-text-long { | |
| 446 | + | max-width: 500px; | |
| 447 | + | overflow: hidden; | |
| 448 | + | text-overflow: ellipsis; | |
| 449 | + | } | |
| 5 | 450 | ||
A src/lib/buddyListCache.ts +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | import { createLogger } from './utils/logger.server'; | |
| 2 | + | import type { SafeUser } from './types/chat'; | |
| 3 | + | ||
| 4 | + | const log = createLogger('buddy-list-cache'); | |
| 5 | + | ||
| 6 | + | class BuddyListCache { | |
| 7 | + | private cache: SafeUser[] | null = null; | |
| 8 | + | private lastHash: string = ''; | |
| 9 | + | private lastFetch: number = 0; | |
| 10 | + | private isDirty: boolean = true; // Force initial fetch | |
| 11 | + | ||
| 12 | + | // Mark cache as needing refresh | |
| 13 | + | invalidate(): void { | |
| 14 | + | this.isDirty = true; | |
| 15 | + | log.debug('Buddy list cache invalidated'); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Check if we need to fetch new data | |
| 19 | + | needsRefresh(): boolean { | |
| 20 | + | return this.isDirty || this.cache === null; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Update cache with new data | |
| 24 | + | update(users: SafeUser[]): boolean { | |
| 25 | + | const newHash = JSON.stringify(users); | |
| 26 | + | const hasChanged = newHash !== this.lastHash; | |
| 27 | + | ||
| 28 | + | if (hasChanged) { | |
| 29 | + | this.cache = users; | |
| 30 | + | this.lastHash = newHash; | |
| 31 | + | this.lastFetch = Date.now(); | |
| 32 | + | this.isDirty = false; | |
| 33 | + | ||
| 34 | + | log.debug('Buddy list cache updated', { | |
| 35 | + | userCount: users.length, | |
| 36 | + | changed: hasChanged | |
| 37 | + | }); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return hasChanged; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Get cached data | |
| 44 | + | get(): SafeUser[] | null { | |
| 45 | + | return this.cache; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Get cache age in milliseconds | |
| 49 | + | getAge(): number { | |
| 50 | + | return this.cache ? Date.now() - this.lastFetch : Infinity; | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Export singleton instance | |
| 55 | + | export const buddyListCache = new BuddyListCache();⏎̸ | |
M src/lib/components/aim-login.svelte +50 -45
| @@ -5,9 +5,10 @@ | |||
| 5 | 5 | import type { User, SafeUser } from '$lib/types/chat'; | |
| 6 | 6 | import { invalidateAll } from '$app/navigation'; | |
| 7 | 7 | import { chatState } from '$lib/states/chat.svelte'; | |
| 8 | - | import Turnstile from './turnstile.svelte'; | |
| 9 | 8 | import { createSafeUser } from '$lib/types/chat'; | |
| 10 | 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'; | |
| 11 | 12 | ||
| 12 | 13 | interface $$Props { | |
| 13 | 14 | showAuth: boolean; | |
| @@ -24,7 +25,6 @@ | |||
| 24 | 25 | let siPassword = $state(''); | |
| 25 | 26 | let error = $state(''); | |
| 26 | 27 | let loginStatus = $state('idle'); | |
| 27 | - | let siTurnstileToken = $state(''); | |
| 28 | 28 | ||
| 29 | 29 | // Sign Up state | |
| 30 | 30 | let suUsername = $state(''); | |
| @@ -35,6 +35,10 @@ | |||
| 35 | 35 | let signupStatus = $state('idle'); | |
| 36 | 36 | let suTurnstileToken = $state(''); | |
| 37 | 37 | ||
| 38 | + | // Password validation state | |
| 39 | + | let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS; | |
| 40 | + | let showPasswordStrength = $state(false); | |
| 41 | + | ||
| 38 | 42 | // Added dragging state and handler similar to chat-room | |
| 39 | 43 | let windowX = $state(0); | |
| 40 | 44 | let windowY = $state(0); | |
| @@ -60,8 +64,11 @@ | |||
| 60 | 64 | async function handleSigninSubmit() { | |
| 61 | 65 | error = ''; | |
| 62 | 66 | loginStatus = 'loading'; | |
| 63 | - | if (!siUsername || !siPassword || !siTurnstileToken) { | |
| 64 | - | error = 'Please complete all fields including the security check'; | |
| 67 | + | ||
| 68 | + | // Validate login data | |
| 69 | + | const validationError = validateLoginData(); | |
| 70 | + | if (validationError) { | |
| 71 | + | error = validationError; | |
| 65 | 72 | loginStatus = 'error'; | |
| 66 | 73 | return; | |
| 67 | 74 | } | |
| @@ -73,8 +80,7 @@ | |||
| 73 | 80 | headers: { 'Content-Type': 'application/json' }, | |
| 74 | 81 | body: JSON.stringify({ | |
| 75 | 82 | username: siUsername, | |
| 76 | - | password: siPassword, | |
| 77 | - | turnstileToken: siTurnstileToken | |
| 83 | + | password: siPassword | |
| 78 | 84 | }) | |
| 79 | 85 | }); | |
| 80 | 86 | ||
| @@ -107,41 +113,30 @@ | |||
| 107 | 113 | } | |
| 108 | 114 | } | |
| 109 | 115 | ||
| 110 | - | // New: Create a helper function for client-side validations based on server rules. | |
| 111 | - | function validateSignupData(): string | null { | |
| 112 | - | // Trim values | |
| 113 | - | const username = suUsername.trim(); | |
| 114 | - | const password = suPassword.trim(); | |
| 115 | - | const confirmPasswordValue = suConfirmPassword.trim(); | |
| 116 | - | ||
| 117 | - | if (!username || !password || !confirmPasswordValue) { | |
| 118 | - | return 'All fields must be filled'; | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | if (username.length < 3) { | |
| 122 | - | return 'Username must be at least 3 characters'; | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | if (username.length > 32) { | |
| 126 | - | return 'Username must be at most 32 characters'; | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // Only allow letters, numbers, underscores, and dashes in the username. | |
| 130 | - | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |
| 131 | - | if (!usernameRegex.test(username)) { | |
| 132 | - | return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.'; | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | if (password !== confirmPasswordValue) { | |
| 136 | - | return 'Passwords do not match'; | |
| 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'; | |
| 137 | 123 | } | |
| 124 | + | } | |
| 138 | 125 | ||
| 139 | - | if (password.length < 8 || password.length > 64) { | |
| 140 | - | return 'Password must be between 8 and 64 characters'; | |
| 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'; | |
| 141 | 139 | } | |
| 142 | - | ||
| 143 | - | // All checks passed. | |
| 144 | - | return null; | |
| 145 | 140 | } | |
| 146 | 141 | ||
| 147 | 142 | async function handleSignupSubmit() { | |
| @@ -156,10 +151,9 @@ | |||
| 156 | 151 | return; | |
| 157 | 152 | } | |
| 158 | 153 | ||
| 159 | - | // Additional check for captcha and Turnstile token, | |
| 160 | - | // in case those fields are not filled. | |
| 161 | - | if (!captchaAnswer || !suTurnstileToken) { | |
| 162 | - | signupError = 'Please complete all fields including the security check'; | |
| 154 | + | // Additional check for captcha | |
| 155 | + | if (!captchaAnswer) { | |
| 156 | + | signupError = 'Please complete all fields including the PDR question'; | |
| 163 | 157 | signupStatus = 'error'; | |
| 164 | 158 | return; | |
| 165 | 159 | } | |
| @@ -271,7 +265,6 @@ | |||
| 271 | 265 | <label for="si-password">Mot de passe</label> | |
| 272 | 266 | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | |
| 273 | 267 | </div> | |
| 274 | - | <Turnstile onVerify={(token: string) => siTurnstileToken = token} /> | |
| 275 | 268 | {#if error} | |
| 276 | 269 | <div class="error">{error}</div> | |
| 277 | 270 | {/if} | |
| @@ -311,7 +304,20 @@ | |||
| 311 | 304 | </div> | |
| 312 | 305 | <div class="form-group"> | |
| 313 | 306 | <label for="su-password">Mot de passe</label> | |
| 314 | - | <input type="password" id="su-password" bind:value={suPassword} placeholder="Mot de passe" /> | |
| 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} | |
| 315 | 321 | </div> | |
| 316 | 322 | <div class="form-group"> | |
| 317 | 323 | <label for="su-confirm-password">Confirmer le mot de passe</label> | |
| @@ -321,7 +327,6 @@ | |||
| 321 | 327 | <label for="captcha">Quelle est la signification de PDR ?</label> | |
| 322 | 328 | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | |
| 323 | 329 | </div> | |
| 324 | - | <Turnstile onVerify={(token: string) => suTurnstileToken = token} /> | |
| 325 | 330 | {#if signupError} | |
| 326 | 331 | <div class="error">{signupError}</div> | |
| 327 | 332 | {/if} | |
M src/lib/components/chat-room.svelte +82 -15
| @@ -11,8 +11,14 @@ import LoadingButton from './ui/button-loading.svelte'; | |||
| 11 | 11 | import Tooltip from './ui/tooltip.svelte'; | |
| 12 | 12 | import LoadingDots from './ui/loading-dots.svelte'; | |
| 13 | 13 | import AimLogin from './aim-login.svelte'; | |
| 14 | + | import FormattedMessage from './formatted-message.svelte'; | |
| 15 | + | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | |
| 16 | + | import { DEFAULT_TEXT_STYLE, type TextStyle, generateInputCSSStyle } from '../types/text-formatting'; | |
| 14 | 17 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | |
| 15 | 18 | ||
| 19 | + | // Props destructuring must come first | |
| 20 | + | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | |
| 21 | + | ||
| 16 | 22 | // Initialize with default values for SSR | |
| 17 | 23 | let windowWidth = $state(400); | |
| 18 | 24 | let windowHeight = $state(500); | |
| @@ -26,6 +32,14 @@ let isInitialLoading = $state(true); | |||
| 26 | 32 | let showAuth = $state(false); | |
| 27 | 33 | let isMinimized = $state(false); | |
| 28 | 34 | ||
| 35 | + | // Text formatting state - initialize directly with merged initial style | |
| 36 | + | let currentTextStyle = $state<TextStyle>({ | |
| 37 | + | ...DEFAULT_TEXT_STYLE, | |
| 38 | + | ...initialTextStyle, | |
| 39 | + | color: initialTextStyle.color || '#000000' // Ensure we always have a valid color | |
| 40 | + | }); | |
| 41 | + | let showFormattingToolbar = $state(true); | |
| 42 | + | ||
| 29 | 43 | // Rate limiting state | |
| 30 | 44 | let cooldownEndTime = $state<number | null>(null); | |
| 31 | 45 | let cooldownProgress = $state(0); | |
| @@ -60,8 +74,6 @@ function startCooldownTimer(retryAfter: number) { | |||
| 60 | 74 | updateCooldownProgress(); | |
| 61 | 75 | } | |
| 62 | 76 | ||
| 63 | - | let { showChatRoom = $bindable() } = $props(); | |
| 64 | - | ||
| 65 | 77 | function handleClose() { | |
| 66 | 78 | showChatRoom = false; | |
| 67 | 79 | } | |
| @@ -239,7 +251,7 @@ async function handleSubmit() { | |||
| 239 | 251 | ||
| 240 | 252 | isSendingMessage = true; | |
| 241 | 253 | try { | |
| 242 | - | const response = await chatState.sendMessage(currentMessage); | |
| 254 | + | const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle); | |
| 243 | 255 | if (!response.success && response.isRateLimited && response.retryAfter) { | |
| 244 | 256 | startCooldownTimer(response.retryAfter); | |
| 245 | 257 | rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...'; | |
| @@ -459,7 +471,7 @@ function handleLoginSuccess() { | |||
| 459 | 471 | ondragmove={handleDragMove} | |
| 460 | 472 | > | |
| 461 | 473 | <div class="title-bar"> | |
| 462 | - | <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"> | |
| 474 | + | <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"> | |
| 463 | 475 | Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | |
| 464 | 476 | {#if sseError && !isMinimized} | |
| 465 | 477 | <span class="connection-error">⚠️ Erreur de connexion</span> | |
| @@ -563,21 +575,49 @@ function handleLoginSuccess() { | |||
| 563 | 575 | }}> | |
| 564 | 576 | <span class="nickname pointer-events-none">{message.user.nickname}:</span> | |
| 565 | 577 | </Tooltip> | |
| 566 | - | <span class="content">{message.content}</span> | |
| 578 | + | <FormattedMessage {message} allowFormatting={true} /> | |
| 567 | 579 | {/if} | |
| 568 | 580 | </div> | |
| 569 | 581 | {/each} | |
| 570 | 582 | </div> | |
| 571 | 583 | ||
| 584 | + | <!-- Text Formatting Toolbar --> | |
| 585 | + | {#if currentUser} | |
| 586 | + | <div style="margin-bottom: 0.25rem;"> | |
| 587 | + | <TextFormattingToolbar | |
| 588 | + | bind:style={currentTextStyle} | |
| 589 | + | compact={true} | |
| 590 | + | showFontSelector={true} | |
| 591 | + | showGradients={true} | |
| 592 | + | /> | |
| 593 | + | </div> | |
| 594 | + | {/if} | |
| 595 | + | ||
| 596 | + | ||
| 572 | 597 | <div class="field-row input-container" style="margin: 0;"> | |
| 573 | - | <input | |
| 574 | - | type="text" | |
| 575 | - | bind:value={currentMessage} | |
| 576 | - | style="flex: 1;" | |
| 577 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 578 | - | placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."} | |
| 579 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 580 | - | /> | |
| 598 | + | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | |
| 599 | + | <div class="gradient-input-wrapper" style="flex: 1; position: relative; background: white;"> | |
| 600 | + | <input | |
| 601 | + | type="text" | |
| 602 | + | bind:value={currentMessage} | |
| 603 | + | class="styled-input retro-font-{currentTextStyle.fontFamily}" | |
| 604 | + | style="width: 100%; background: transparent; {generateInputCSSStyle(currentTextStyle)}" | |
| 605 | + | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 606 | + | placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."} | |
| 607 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 608 | + | /> | |
| 609 | + | </div> | |
| 610 | + | {:else} | |
| 611 | + | <input | |
| 612 | + | type="text" | |
| 613 | + | bind:value={currentMessage} | |
| 614 | + | class="styled-input retro-font-{currentTextStyle.fontFamily}" | |
| 615 | + | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | |
| 616 | + | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 617 | + | placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."} | |
| 618 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 619 | + | /> | |
| 620 | + | {/if} | |
| 581 | 621 | <LoadingButton | |
| 582 | 622 | onclick={handleSubmit} | |
| 583 | 623 | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| @@ -632,9 +672,14 @@ function handleLoginSuccess() { | |||
| 632 | 672 | {/if} | |
| 633 | 673 | ||
| 634 | 674 | <style> | |
| 635 | - | .chat-area, .users-list, input { | |
| 675 | + | .chat-area, .users-list { | |
| 636 | 676 | font-size: 1rem; | |
| 637 | - | font-family: Arial, Verdana, Tahoma, sans-serif; | |
| 677 | + | font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif; | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | input:not(.styled-input) { | |
| 681 | + | font-size: 1rem; | |
| 682 | + | font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif; | |
| 638 | 683 | } | |
| 639 | 684 | ||
| 640 | 685 | .title-bar { | |
| @@ -674,6 +719,16 @@ function handleLoginSuccess() { | |||
| 674 | 719 | gap: 0.5rem; | |
| 675 | 720 | align-items: flex-start; | |
| 676 | 721 | } | |
| 722 | + | ||
| 723 | + | /* Fix gradient text spacing in messages */ | |
| 724 | + | .message :global(.gradient-text-static) { | |
| 725 | + | letter-spacing: normal !important; | |
| 726 | + | word-spacing: normal !important; | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | .message :global(.gradient-text-static span) { | |
| 730 | + | letter-spacing: 0 !important; | |
| 731 | + | } | |
| 677 | 732 | ||
| 678 | 733 | .message .nickname { | |
| 679 | 734 | font-weight: bold; | |
| @@ -1036,4 +1091,16 @@ function handleLoginSuccess() { | |||
| 1036 | 1091 | .pointer-events-none { | |
| 1037 | 1092 | pointer-events: none; | |
| 1038 | 1093 | } | |
| 1094 | + | ||
| 1095 | + | .gradient-input-wrapper { | |
| 1096 | + | border: 1px inset #dfdfdf; | |
| 1097 | + | padding: 2px; | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | .gradient-input-wrapper input { | |
| 1101 | + | border: none !important; | |
| 1102 | + | outline: none !important; | |
| 1103 | + | padding: 1px 3px; | |
| 1104 | + | } | |
| 1105 | + | ||
| 1039 | 1106 | </style>⏎̸ | |
A src/lib/components/color-picker-256.svelte +453 -0
| @@ -0,0 +1,453 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | |
| 3 | + | ||
| 4 | + | // Props | |
| 5 | + | let { | |
| 6 | + | selectedColor = $bindable('#000000'), | |
| 7 | + | selectedGradient = $bindable(undefined), | |
| 8 | + | onColorChange, | |
| 9 | + | onGradientChange, | |
| 10 | + | compact = true | |
| 11 | + | } = $props<{ | |
| 12 | + | selectedColor?: string; | |
| 13 | + | selectedGradient?: string[]; | |
| 14 | + | onColorChange?: (color: string) => void; | |
| 15 | + | onGradientChange?: (gradient: string[]) => void; | |
| 16 | + | compact?: boolean; | |
| 17 | + | }>(); | |
| 18 | + | ||
| 19 | + | // State | |
| 20 | + | let showPicker = $state(false); | |
| 21 | + | let gradientColors = $state<string[]>([selectedColor || '#000000']); | |
| 22 | + | let activeGradientSlot = $state(0); | |
| 23 | + | ||
| 24 | + | // Generate color palette | |
| 25 | + | const colors256 = generate256Colors(); | |
| 26 | + | const classicColors = Object.values(CLASSIC_COLORS); | |
| 27 | + | ||
| 28 | + | // Initialize gradient colors from props | |
| 29 | + | $effect(() => { | |
| 30 | + | if (selectedGradient && selectedGradient.length > 0) { | |
| 31 | + | gradientColors = [...selectedGradient]; | |
| 32 | + | } else if (selectedColor) { | |
| 33 | + | gradientColors = [selectedColor]; | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // Get current display color/gradient | |
| 38 | + | let displayStyle = $derived(() => { | |
| 39 | + | if (gradientColors.length > 1) { | |
| 40 | + | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |
| 41 | + | } | |
| 42 | + | return `background: ${gradientColors[0] || '#000000'}`; | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | // Handle color selection | |
| 46 | + | function selectColor(color: string) { | |
| 47 | + | if (activeGradientSlot < gradientColors.length) { | |
| 48 | + | gradientColors[activeGradientSlot] = color; | |
| 49 | + | gradientColors = [...gradientColors]; // Trigger reactivity | |
| 50 | + | ||
| 51 | + | if (gradientColors.length === 1) { | |
| 52 | + | // Single color mode | |
| 53 | + | selectedColor = color; | |
| 54 | + | selectedGradient = undefined; | |
| 55 | + | onColorChange?.(color); | |
| 56 | + | } else { | |
| 57 | + | // Gradient mode | |
| 58 | + | selectedGradient = [...gradientColors]; | |
| 59 | + | selectedColor = gradientColors[0]; | |
| 60 | + | onGradientChange?.(gradientColors); | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Gradient management | |
| 66 | + | function addGradientColor() { | |
| 67 | + | if (gradientColors.length < 3) { | |
| 68 | + | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | |
| 69 | + | gradientColors = [...gradientColors, newColor]; | |
| 70 | + | activeGradientSlot = gradientColors.length - 1; | |
| 71 | + | selectedGradient = [...gradientColors]; | |
| 72 | + | onGradientChange?.(gradientColors); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function removeGradientColor(index: number) { | |
| 77 | + | if (gradientColors.length > 1) { | |
| 78 | + | gradientColors = gradientColors.filter((_, i) => i !== index); | |
| 79 | + | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |
| 80 | + | ||
| 81 | + | if (gradientColors.length === 1) { | |
| 82 | + | // Back to single color mode | |
| 83 | + | selectedColor = gradientColors[0]; | |
| 84 | + | selectedGradient = undefined; | |
| 85 | + | onColorChange?.(gradientColors[0]); | |
| 86 | + | } else { | |
| 87 | + | selectedGradient = [...gradientColors]; | |
| 88 | + | onGradientChange?.(gradientColors); | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function setActiveSlot(index: number) { | |
| 94 | + | activeGradientSlot = index; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Close picker | |
| 98 | + | function closePicker() { | |
| 99 | + | showPicker = false; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Handle click outside to close | |
| 103 | + | function handleClickOutside(event: MouseEvent) { | |
| 104 | + | const target = event.target as HTMLElement; | |
| 105 | + | const colorPickerContainer = document.querySelector('.color-picker-container'); | |
| 106 | + | ||
| 107 | + | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | |
| 108 | + | closePicker(); | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Add/remove click outside listener | |
| 113 | + | $effect(() => { | |
| 114 | + | if (showPicker) { | |
| 115 | + | document.addEventListener('click', handleClickOutside); | |
| 116 | + | return () => { | |
| 117 | + | document.removeEventListener('click', handleClickOutside); | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | }); | |
| 121 | + | </script> | |
| 122 | + | ||
| 123 | + | <div class="color-picker-container"> | |
| 124 | + | <!-- Color picker button --> | |
| 125 | + | <button | |
| 126 | + | onclick={(e) => { | |
| 127 | + | e.stopPropagation(); | |
| 128 | + | showPicker = !showPicker; | |
| 129 | + | }} | |
| 130 | + | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | |
| 131 | + | style="width: 20px; height: 20px; padding: 0;" | |
| 132 | + | > | |
| 133 | + | <div | |
| 134 | + | class="color-indicator" | |
| 135 | + | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | |
| 136 | + | ></div> | |
| 137 | + | </button> | |
| 138 | + | ||
| 139 | + | <!-- Color picker dropdown --> | |
| 140 | + | {#if showPicker} | |
| 141 | + | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | |
| 142 | + | <!-- Gradient controls --> | |
| 143 | + | <div class="gradient-controls"> | |
| 144 | + | <div class="gradient-slots"> | |
| 145 | + | {#each gradientColors as color, index} | |
| 146 | + | <button | |
| 147 | + | class="gradient-slot" | |
| 148 | + | class:active={activeGradientSlot === index} | |
| 149 | + | style="background-color: {color} !important;" | |
| 150 | + | onclick={() => setActiveSlot(index)} | |
| 151 | + | title="Color {index + 1} - Click to select this color slot" | |
| 152 | + | > | |
| 153 | + | {#if gradientColors.length > 1} | |
| 154 | + | <span | |
| 155 | + | class="remove-slot" | |
| 156 | + | onclick={(e) => { | |
| 157 | + | e.stopPropagation(); | |
| 158 | + | removeGradientColor(index); | |
| 159 | + | }} | |
| 160 | + | title="Remove this color" | |
| 161 | + | role="button" | |
| 162 | + | tabindex="0" | |
| 163 | + | >×</span> | |
| 164 | + | {/if} | |
| 165 | + | </button> | |
| 166 | + | {/each} | |
| 167 | + | ||
| 168 | + | {#if gradientColors.length < 3} | |
| 169 | + | <button | |
| 170 | + | class="add-slot" | |
| 171 | + | onclick={addGradientColor} | |
| 172 | + | title="Add another color to create gradient" | |
| 173 | + | >+</button> | |
| 174 | + | {/if} | |
| 175 | + | </div> | |
| 176 | + | ||
| 177 | + | {#if gradientColors.length > 1} | |
| 178 | + | <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div> | |
| 179 | + | {/if} | |
| 180 | + | </div> | |
| 181 | + | ||
| 182 | + | <!-- Classic colors (first row) --> | |
| 183 | + | <div class="color-section"> | |
| 184 | + | <div class="section-title">Classic Colors</div> | |
| 185 | + | <div class="color-row"> | |
| 186 | + | {#each classicColors as color} | |
| 187 | + | <button | |
| 188 | + | class="color-swatch classic-swatch" | |
| 189 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 190 | + | onclick={() => selectColor(color)} | |
| 191 | + | title={color} | |
| 192 | + | ></button> | |
| 193 | + | {/each} | |
| 194 | + | </div> | |
| 195 | + | </div> | |
| 196 | + | ||
| 197 | + | <!-- 256-color palette --> | |
| 198 | + | <div class="color-section"> | |
| 199 | + | <div class="section-title">Full Palette</div> | |
| 200 | + | <div class="color-grid"> | |
| 201 | + | {#each colors256 as color} | |
| 202 | + | <button | |
| 203 | + | class="color-swatch" | |
| 204 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 205 | + | onclick={() => selectColor(color)} | |
| 206 | + | title={color} | |
| 207 | + | ></button> | |
| 208 | + | {/each} | |
| 209 | + | </div> | |
| 210 | + | </div> | |
| 211 | + | ||
| 212 | + | <!-- Close button --> | |
| 213 | + | <div class="picker-footer"> | |
| 214 | + | <button onclick={closePicker}>Close</button> | |
| 215 | + | </div> | |
| 216 | + | </div> | |
| 217 | + | {/if} | |
| 218 | + | </div> | |
| 219 | + | ||
| 220 | + | <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>⏎̸ | |
A src/lib/components/formatted-message.svelte +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import type { EnrichedMessage } from '../types/chat'; | |
| 4 | + | import type { TextStyle } from '../types/text-formatting'; | |
| 5 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 6 | + | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | |
| 7 | + | ||
| 8 | + | // Props | |
| 9 | + | let { | |
| 10 | + | message, | |
| 11 | + | allowFormatting = true | |
| 12 | + | } = $props<{ | |
| 13 | + | message: EnrichedMessage; | |
| 14 | + | allowFormatting?: boolean; | |
| 15 | + | }>(); | |
| 16 | + | ||
| 17 | + | // State | |
| 18 | + | let formattedContent = $state(''); | |
| 19 | + | let isProcessing = $state(false); | |
| 20 | + | let hasError = $state(false); | |
| 21 | + | ||
| 22 | + | // Process message formatting | |
| 23 | + | async function processMessage() { | |
| 24 | + | if (!allowFormatting || !message.hasFormatting) { | |
| 25 | + | formattedContent = escapeHtml(message.content); | |
| 26 | + | return; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | isProcessing = true; | |
| 30 | + | hasError = false; | |
| 31 | + | ||
| 32 | + | try { | |
| 33 | + | // Parse style data if available | |
| 34 | + | let style: TextStyle = DEFAULT_TEXT_STYLE; | |
| 35 | + | if (message.styleData) { | |
| 36 | + | try { | |
| 37 | + | style = JSON.parse(message.styleData); | |
| 38 | + | console.debug('Parsed message style:', style, 'for message:', message.content); | |
| 39 | + | } catch (parseError) { | |
| 40 | + | console.warn('Failed to parse message style data:', parseError); | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Check if this is a gradient message | |
| 45 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 46 | + | // Use character-by-character gradient for better compatibility | |
| 47 | + | console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content); | |
| 48 | + | formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style); | |
| 49 | + | console.debug('Generated gradient HTML:', formattedContent); | |
| 50 | + | return; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Regular formatted text (no gradient) | |
| 54 | + | const styleWithoutGradient = { ...style, gradient: undefined }; | |
| 55 | + | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |
| 56 | + | ||
| 57 | + | } catch (error) { | |
| 58 | + | console.error('Message formatting error:', error); | |
| 59 | + | hasError = true; | |
| 60 | + | formattedContent = escapeHtml(message.content); | |
| 61 | + | } finally { | |
| 62 | + | isProcessing = false; | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Process message when it changes | |
| 67 | + | $effect(() => { | |
| 68 | + | processMessage(); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | // Utility function to escape HTML | |
| 72 | + | function escapeHtml(text: string): string { | |
| 73 | + | const div = document.createElement('div'); | |
| 74 | + | div.textContent = text; | |
| 75 | + | return div.innerHTML; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Generate inline styles for the message | |
| 79 | + | function getMessageStyles(): string { | |
| 80 | + | if (!message.hasFormatting || !message.styleData) { | |
| 81 | + | return ''; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | try { | |
| 85 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 86 | + | // For gradient messages, we still need some styles but not font-family (use class instead) | |
| 87 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 88 | + | // Return only non-color and non-font-family styles for gradient messages | |
| 89 | + | const styles: string[] = []; | |
| 90 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 91 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 92 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 93 | + | const decorations: string[] = []; | |
| 94 | + | if (style.underline) decorations.push('underline'); | |
| 95 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 96 | + | if (decorations.length > 0) { | |
| 97 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 98 | + | } | |
| 99 | + | return styles.join('; '); | |
| 100 | + | } | |
| 101 | + | // For non-gradient messages, exclude font-family (use class instead) | |
| 102 | + | const styles: string[] = []; | |
| 103 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 104 | + | if (style.color) styles.push(`color: ${style.color}`); | |
| 105 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 106 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 107 | + | const decorations: string[] = []; | |
| 108 | + | if (style.underline) decorations.push('underline'); | |
| 109 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 110 | + | if (decorations.length > 0) { | |
| 111 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 112 | + | } | |
| 113 | + | return styles.join('; '); | |
| 114 | + | } catch (error) { | |
| 115 | + | console.warn('Failed to generate message styles:', error); | |
| 116 | + | return ''; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Get CSS classes for the message | |
| 121 | + | function getMessageClasses(): string { | |
| 122 | + | const classes = ['formatted-message']; | |
| 123 | + | ||
| 124 | + | if (!message.hasFormatting || !message.styleData) { | |
| 125 | + | return classes.join(' '); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | try { | |
| 129 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 130 | + | ||
| 131 | + | // Add font family class | |
| 132 | + | classes.push(`retro-font-${style.fontFamily}`); | |
| 133 | + | ||
| 134 | + | // Add style classes | |
| 135 | + | if (style.bold) classes.push('style-bold'); | |
| 136 | + | if (style.italic) classes.push('style-italic'); | |
| 137 | + | if (style.underline) classes.push('style-underline'); | |
| 138 | + | if (style.strikethrough) classes.push('style-strikethrough'); | |
| 139 | + | ||
| 140 | + | // Add gradient class if applicable | |
| 141 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 142 | + | classes.push('gradient-text-static'); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | } catch (error) { | |
| 146 | + | console.warn('Failed to parse message style for classes:', error); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | return classes.join(' '); | |
| 150 | + | } | |
| 151 | + | </script> | |
| 152 | + | ||
| 153 | + | {#if isProcessing} | |
| 154 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 155 | + | <span class="processing">...</span> | |
| 156 | + | </span> | |
| 157 | + | {:else if hasError} | |
| 158 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 159 | + | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | |
| 160 | + | </span> | |
| 161 | + | {: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} | |
| 186 | + | {/if} | |
| 187 | + | ||
| 188 | + | <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>⏎̸ | |
A src/lib/components/password-strength-indicator.svelte +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { validatePasswordStrength, type PasswordConstraints, DEFAULT_PASSWORD_CONSTRAINTS } from '../validation/password'; | |
| 3 | + | ||
| 4 | + | interface $$Props { | |
| 5 | + | password: string; | |
| 6 | + | constraints?: PasswordConstraints; | |
| 7 | + | showDetails?: boolean; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | let { | |
| 11 | + | password = '', | |
| 12 | + | constraints = DEFAULT_PASSWORD_CONSTRAINTS, | |
| 13 | + | showDetails = true | |
| 14 | + | } = $props(); | |
| 15 | + | ||
| 16 | + | // Reactive validation result | |
| 17 | + | let validation = $derived(validatePasswordStrength(password, constraints)); | |
| 18 | + | ||
| 19 | + | // Get strength color and label | |
| 20 | + | function getStrengthInfo(strength: number) { | |
| 21 | + | if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' }; | |
| 22 | + | if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' }; | |
| 23 | + | if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' }; | |
| 24 | + | return { color: '#00aa00', label: 'Strong', class: 'strong' }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | let strengthInfo = $derived(getStrengthInfo(validation.strength)); | |
| 28 | + | </script> | |
| 29 | + | ||
| 30 | + | <div class="password-strength-indicator"> | |
| 31 | + | {#if password.length > 0} | |
| 32 | + | <!-- Strength bar --> | |
| 33 | + | <div class="strength-bar"> | |
| 34 | + | <div class="strength-bar-bg"> | |
| 35 | + | <div | |
| 36 | + | class="strength-bar-fill {strengthInfo.class}" | |
| 37 | + | style="width: {validation.strength}%; background-color: {strengthInfo.color};" | |
| 38 | + | ></div> | |
| 39 | + | </div> | |
| 40 | + | <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span> | |
| 41 | + | </div> | |
| 42 | + | ||
| 43 | + | {#if showDetails} | |
| 44 | + | <!-- Requirements checklist --> | |
| 45 | + | <div class="requirements-list"> | |
| 46 | + | {#each validation.requirements as requirement} | |
| 47 | + | <div class="requirement {requirement.met ? 'met' : 'unmet'}"> | |
| 48 | + | <span class="requirement-icon"> | |
| 49 | + | {requirement.met ? '✓' : '✗'} | |
| 50 | + | </span> | |
| 51 | + | <span class="requirement-text">{requirement.message}</span> | |
| 52 | + | </div> | |
| 53 | + | {/each} | |
| 54 | + | </div> | |
| 55 | + | ||
| 56 | + | <!-- Error messages --> | |
| 57 | + | {#if validation.errors.length > 0} | |
| 58 | + | <div class="error-messages"> | |
| 59 | + | {#each validation.errors as error} | |
| 60 | + | <div class="error-message">{error}</div> | |
| 61 | + | {/each} | |
| 62 | + | </div> | |
| 63 | + | {/if} | |
| 64 | + | {/if} | |
| 65 | + | {/if} | |
| 66 | + | </div> | |
| 67 | + | ||
| 68 | + | <style> | |
| 69 | + | .password-strength-indicator { | |
| 70 | + | margin-top: 8px; | |
| 71 | + | font-size: 0.875rem; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .strength-bar { | |
| 75 | + | display: flex; | |
| 76 | + | align-items: center; | |
| 77 | + | gap: 8px; | |
| 78 | + | margin-bottom: 8px; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | .strength-bar-bg { | |
| 82 | + | flex: 1; | |
| 83 | + | height: 6px; | |
| 84 | + | background-color: #ddd; | |
| 85 | + | border-radius: 3px; | |
| 86 | + | overflow: hidden; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | .strength-bar-fill { | |
| 90 | + | height: 100%; | |
| 91 | + | transition: width 0.3s ease, background-color 0.3s ease; | |
| 92 | + | border-radius: 3px; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | .strength-label { | |
| 96 | + | min-width: 50px; | |
| 97 | + | font-weight: bold; | |
| 98 | + | font-size: 0.75rem; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | .strength-label.weak { color: #ff4444; } | |
| 102 | + | .strength-label.fair { color: #ffaa00; } | |
| 103 | + | .strength-label.good { color: #44aa44; } | |
| 104 | + | .strength-label.strong { color: #00aa00; } | |
| 105 | + | ||
| 106 | + | .requirements-list { | |
| 107 | + | display: flex; | |
| 108 | + | flex-direction: column; | |
| 109 | + | gap: 4px; | |
| 110 | + | margin-bottom: 8px; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | .requirement { | |
| 114 | + | display: flex; | |
| 115 | + | align-items: center; | |
| 116 | + | gap: 6px; | |
| 117 | + | font-size: 0.75rem; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | .requirement-icon { | |
| 121 | + | width: 12px; | |
| 122 | + | text-align: center; | |
| 123 | + | font-weight: bold; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | .requirement.met { | |
| 127 | + | color: #00aa00; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | .requirement.met .requirement-icon { | |
| 131 | + | color: #00aa00; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | .requirement.unmet { | |
| 135 | + | color: #888; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | .requirement.unmet .requirement-icon { | |
| 139 | + | color: #ff4444; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | .error-messages { | |
| 143 | + | display: flex; | |
| 144 | + | flex-direction: column; | |
| 145 | + | gap: 4px; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | .error-message { | |
| 149 | + | color: #ff4444; | |
| 150 | + | font-size: 0.75rem; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /* Compact mode styles */ | |
| 154 | + | :global(.compact) .password-strength-indicator { | |
| 155 | + | font-size: 0.75rem; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | :global(.compact) .requirements-list { | |
| 159 | + | display: none; | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | :global(.compact) .strength-bar { | |
| 163 | + | margin-bottom: 4px; | |
| 164 | + | } | |
| 165 | + | </style>⏎̸ | |
A src/lib/components/text-formatting-toolbar.svelte +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | GRADIENT_PRESETS, | |
| 6 | + | DEFAULT_TEXT_STYLE, | |
| 7 | + | type TextStyle, | |
| 8 | + | type FontFamily | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | ||
| 12 | + | // Props | |
| 13 | + | let { | |
| 14 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 15 | + | onPreviewText, | |
| 16 | + | compact = true, | |
| 17 | + | showFontSelector = false, | |
| 18 | + | showGradients = true | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | onPreviewText?: (text: string) => void; | |
| 22 | + | compact?: boolean; | |
| 23 | + | showFontSelector?: boolean; | |
| 24 | + | showGradients?: boolean; | |
| 25 | + | }>(); | |
| 26 | + | ||
| 27 | + | // State | |
| 28 | + | let currentColor = $state(style.color || '#000000'); | |
| 29 | + | let currentGradient = $state(style.gradient); | |
| 30 | + | ||
| 31 | + | // Toggle functions | |
| 32 | + | function toggleBold() { | |
| 33 | + | style = { ...style, bold: !style.bold }; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function toggleItalic() { | |
| 37 | + | style = { ...style, italic: !style.italic }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function toggleUnderline() { | |
| 41 | + | style = { ...style, underline: !style.underline }; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function handleColorChange(color: string) { | |
| 45 | + | currentColor = color; | |
| 46 | + | style = { ...style, color, gradient: undefined }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | function handleGradientChange(gradient: string[]) { | |
| 50 | + | currentGradient = gradient; | |
| 51 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function selectFont(fontFamily: FontFamily) { | |
| 55 | + | style = { ...style, fontFamily }; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Keyboard shortcuts handler | |
| 59 | + | function handleKeydown(event: KeyboardEvent) { | |
| 60 | + | if (event.ctrlKey || event.metaKey) { | |
| 61 | + | switch (event.key.toLowerCase()) { | |
| 62 | + | case 'b': | |
| 63 | + | event.preventDefault(); | |
| 64 | + | toggleBold(); | |
| 65 | + | break; | |
| 66 | + | case 'i': | |
| 67 | + | event.preventDefault(); | |
| 68 | + | toggleItalic(); | |
| 69 | + | break; | |
| 70 | + | case 'u': | |
| 71 | + | event.preventDefault(); | |
| 72 | + | toggleUnderline(); | |
| 73 | + | break; | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Preview text change | |
| 79 | + | function previewTextChanged() { | |
| 80 | + | if (onPreviewText) { | |
| 81 | + | onPreviewText('Sample formatted text'); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Update internal state when style prop changes | |
| 86 | + | $effect(() => { | |
| 87 | + | currentColor = style.color || '#000000'; | |
| 88 | + | currentGradient = style.gradient; | |
| 89 | + | }); | |
| 90 | + | </script> | |
| 91 | + | ||
| 92 | + | <svelte:window onkeydown={handleKeydown} /> | |
| 93 | + | ||
| 94 | + | <div class="text-formatting-toolbar sunken-panel" class:compact> | |
| 95 | + | ||
| 96 | + | <!-- Font selector (if enabled) --> | |
| 97 | + | {#if showFontSelector} | |
| 98 | + | <select | |
| 99 | + | value={style.fontFamily} | |
| 100 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 101 | + | title="Font Family" | |
| 102 | + | > | |
| 103 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 104 | + | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |
| 105 | + | {/each} | |
| 106 | + | </select> | |
| 107 | + | {/if} | |
| 108 | + | ||
| 109 | + | <!-- Size slider --> | |
| 110 | + | <div class="size-control"> | |
| 111 | + | <input | |
| 112 | + | type="range" | |
| 113 | + | min="8" | |
| 114 | + | max="18" | |
| 115 | + | bind:value={style.fontSize} | |
| 116 | + | class="size-slider" | |
| 117 | + | title="Font Size: {style.fontSize}px" | |
| 118 | + | /> | |
| 119 | + | <span class="size-display">{style.fontSize}</span> | |
| 120 | + | </div> | |
| 121 | + | ||
| 122 | + | <!-- Style toggle buttons --> | |
| 123 | + | <div class="style-toggles"> | |
| 124 | + | <button | |
| 125 | + | onclick={toggleBold} | |
| 126 | + | title="Bold (Ctrl+B)" | |
| 127 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 128 | + | > | |
| 129 | + | <strong>B</strong> | |
| 130 | + | </button> | |
| 131 | + | ||
| 132 | + | <button | |
| 133 | + | onclick={toggleItalic} | |
| 134 | + | title="Italic (Ctrl+I)" | |
| 135 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 136 | + | > | |
| 137 | + | <em>I</em> | |
| 138 | + | </button> | |
| 139 | + | ||
| 140 | + | <button | |
| 141 | + | onclick={toggleUnderline} | |
| 142 | + | title="Underline (Ctrl+U)" | |
| 143 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 144 | + | > | |
| 145 | + | <u>U</u> | |
| 146 | + | </button> | |
| 147 | + | </div> | |
| 148 | + | ||
| 149 | + | <!-- Color controls --> | |
| 150 | + | <div class="color-controls"> | |
| 151 | + | <ColorPicker256 | |
| 152 | + | bind:selectedColor={currentColor} | |
| 153 | + | bind:selectedGradient={currentGradient} | |
| 154 | + | onColorChange={handleColorChange} | |
| 155 | + | onGradientChange={handleGradientChange} | |
| 156 | + | {compact} | |
| 157 | + | /> | |
| 158 | + | </div> | |
| 159 | + | ||
| 160 | + | ||
| 161 | + | </div> | |
| 162 | + | ||
| 163 | + | <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>⏎̸ | |
A src/lib/components/text-style-selector.svelte +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | DEFAULT_TEXT_STYLE, | |
| 6 | + | type TextStyle, | |
| 7 | + | type FontFamily, | |
| 8 | + | generateCSSStyle | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 12 | + | ||
| 13 | + | // Props | |
| 14 | + | let { | |
| 15 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 16 | + | previewText = "Sample text preview", | |
| 17 | + | showPreview = true, | |
| 18 | + | compact = false | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | previewText?: string; | |
| 22 | + | showPreview?: boolean; | |
| 23 | + | compact?: boolean; | |
| 24 | + | }>(); | |
| 25 | + | ||
| 26 | + | // Local state | |
| 27 | + | let currentColor = $state(style.color || '#000000'); | |
| 28 | + | let currentGradient = $state(style.gradient); | |
| 29 | + | let formattedPreview = $state(''); | |
| 30 | + | let isProcessing = $state(false); | |
| 31 | + | ||
| 32 | + | // Update preview when style changes | |
| 33 | + | $effect(() => { | |
| 34 | + | if (!showPreview) return; | |
| 35 | + | ||
| 36 | + | async function updatePreview() { | |
| 37 | + | isProcessing = true; | |
| 38 | + | try { | |
| 39 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 40 | + | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | |
| 41 | + | } else { | |
| 42 | + | formattedPreview = await formatText(previewText, style, false); | |
| 43 | + | } | |
| 44 | + | } catch (error) { | |
| 45 | + | console.error('Preview formatting error:', error); | |
| 46 | + | formattedPreview = previewText; | |
| 47 | + | } finally { | |
| 48 | + | isProcessing = false; | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | updatePreview(); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | // Font family selection | |
| 56 | + | function selectFont(fontFamily: FontFamily) { | |
| 57 | + | style = { ...style, fontFamily }; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Color selection | |
| 61 | + | function handleColorChange(color: string) { | |
| 62 | + | currentColor = color; | |
| 63 | + | style = { ...style, color, gradient: undefined }; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Gradient selection | |
| 67 | + | function handleGradientChange(gradient: string[]) { | |
| 68 | + | currentGradient = gradient; | |
| 69 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Update internal state when style prop changes | |
| 73 | + | $effect(() => { | |
| 74 | + | currentColor = style.color || '#000000'; | |
| 75 | + | currentGradient = style.gradient; | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | // Toggle style options | |
| 79 | + | function toggleBold() { | |
| 80 | + | style = { ...style, bold: !style.bold }; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | function toggleItalic() { | |
| 84 | + | style = { ...style, italic: !style.italic }; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function toggleUnderline() { | |
| 88 | + | style = { ...style, underline: !style.underline }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | function toggleStrikethrough() { | |
| 92 | + | style = { ...style, strikethrough: !style.strikethrough }; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Font size adjustment | |
| 96 | + | function updateFontSize(size: number) { | |
| 97 | + | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | </script> | |
| 101 | + | ||
| 102 | + | <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} | |
| 198 | + | </div> | |
| 199 | + | ||
| 200 | + | <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>⏎̸ | |
A src/lib/components/user-preferences-modal.svelte +492 -0
| @@ -0,0 +1,492 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import TextStyleSelector from './text-style-selector.svelte'; | |
| 4 | + | import { | |
| 5 | + | DEFAULT_USER_PREFERENCES, | |
| 6 | + | DEFAULT_TEXT_STYLE, | |
| 7 | + | type UserTextPreferences, | |
| 8 | + | type TextStyle | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | ||
| 11 | + | // Props | |
| 12 | + | let { | |
| 13 | + | showModal = $bindable(false), | |
| 14 | + | onSave | |
| 15 | + | } = $props<{ | |
| 16 | + | showModal: boolean; | |
| 17 | + | onSave?: (preferences: UserTextPreferences) => void; | |
| 18 | + | }>(); | |
| 19 | + | ||
| 20 | + | // State | |
| 21 | + | let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES }); | |
| 22 | + | let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 23 | + | let isSaving = $state(false); | |
| 24 | + | let hasChanges = $state(false); | |
| 25 | + | ||
| 26 | + | // Track changes | |
| 27 | + | $effect(() => { | |
| 28 | + | hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | // Update preferences when style changes | |
| 32 | + | $effect(() => { | |
| 33 | + | preferences.defaultStyle = { ...currentStyle }; | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | // Load user preferences on mount | |
| 37 | + | onMount(async () => { | |
| 38 | + | try { | |
| 39 | + | const response = await fetch('/api/user/text-preferences'); | |
| 40 | + | if (response.ok) { | |
| 41 | + | const data = await response.json(); | |
| 42 | + | if (data.success && data.preferences) { | |
| 43 | + | preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences }; | |
| 44 | + | currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle }; | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } catch (error) { | |
| 48 | + | console.error('Failed to load user preferences:', error); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | // Save preferences | |
| 53 | + | async function savePreferences() { | |
| 54 | + | if (!onSave) return; | |
| 55 | + | ||
| 56 | + | isSaving = true; | |
| 57 | + | try { | |
| 58 | + | await onSave(preferences); | |
| 59 | + | showModal = false; | |
| 60 | + | } catch (error) { | |
| 61 | + | console.error('Failed to save preferences:', error); | |
| 62 | + | alert('Failed to save preferences. Please try again.'); | |
| 63 | + | } finally { | |
| 64 | + | isSaving = false; | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Reset to defaults | |
| 69 | + | function resetToDefaults() { | |
| 70 | + | preferences = { ...DEFAULT_USER_PREFERENCES }; | |
| 71 | + | currentStyle = { ...DEFAULT_TEXT_STYLE }; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Cancel changes | |
| 75 | + | function cancel() { | |
| 76 | + | showModal = false; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Handle keyboard shortcuts | |
| 80 | + | function handleKeydown(event: KeyboardEvent) { | |
| 81 | + | if (event.key === 'Escape') { | |
| 82 | + | cancel(); | |
| 83 | + | } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { | |
| 84 | + | savePreferences(); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | </script> | |
| 88 | + | ||
| 89 | + | {#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> | |
| 225 | + | {/if} | |
| 226 | + | ||
| 227 | + | <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>⏎̸ | |
M src/lib/db/schema.ts +19 -0
| @@ -47,6 +47,23 @@ export const messages = table( | |||
| 47 | 47 | content: t.text("content").notNull(), | |
| 48 | 48 | type: t.text("type").$type<MessageType>().notNull().default("chat"), | |
| 49 | 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 | + | ); | |
| 54 | + | ||
| 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(), | |
| 50 | 67 | } | |
| 51 | 68 | ); | |
| 52 | 69 | ||
| @@ -55,6 +72,7 @@ export type Users = typeof users.$inferSelect; | |||
| 55 | 72 | export type ChatRooms = typeof chatRooms.$inferSelect; | |
| 56 | 73 | export type Messages = typeof messages.$inferSelect; | |
| 57 | 74 | export type Sessions = typeof sessions.$inferSelect; | |
| 75 | + | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | |
| 58 | 76 | // These type assertions will fail if the schema doesn't match the interfaces | |
| 59 | 77 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | |
| 60 | 78 | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | |
| @@ -100,6 +118,7 @@ const schema = { | |||
| 100 | 118 | sessions, | |
| 101 | 119 | chatRooms, | |
| 102 | 120 | messages, | |
| 121 | + | userTextPreferences, | |
| 103 | 122 | userView, | |
| 104 | 123 | chatRoomView, | |
| 105 | 124 | messageView | |
A src/lib/sseConnectionTracker.ts +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | import { createLogger } from './utils/logger.server'; | |
| 2 | + | ||
| 3 | + | const log = createLogger('sse-connection-tracker'); | |
| 4 | + | ||
| 5 | + | interface ConnectionInfo { | |
| 6 | + | userId: string; | |
| 7 | + | connectedAt: number; | |
| 8 | + | lastActivity: number; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | class SSEConnectionTracker { | |
| 12 | + | private connections = new Map<string, ConnectionInfo>(); | |
| 13 | + | ||
| 14 | + | addConnection(userId: string): boolean { | |
| 15 | + | const existing = this.connections.get(userId); | |
| 16 | + | if (existing) { | |
| 17 | + | log.debug('User already has active connection', { userId }); | |
| 18 | + | // Update last activity but don't trigger status update | |
| 19 | + | existing.lastActivity = Date.now(); | |
| 20 | + | return false; // Connection already exists | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | this.connections.set(userId, { | |
| 24 | + | userId, | |
| 25 | + | connectedAt: Date.now(), | |
| 26 | + | lastActivity: Date.now() | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | log.debug('New SSE connection tracked', { | |
| 30 | + | userId, | |
| 31 | + | totalConnections: this.connections.size | |
| 32 | + | }); | |
| 33 | + | return true; // New connection | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | removeConnection(userId: string): boolean { | |
| 37 | + | const deleted = this.connections.delete(userId); | |
| 38 | + | if (deleted) { | |
| 39 | + | log.debug('SSE connection removed', { | |
| 40 | + | userId, | |
| 41 | + | remainingConnections: this.connections.size | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | return deleted; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | hasConnection(userId: string): boolean { | |
| 48 | + | return this.connections.has(userId); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | updateActivity(userId: string): void { | |
| 52 | + | const connection = this.connections.get(userId); | |
| 53 | + | if (connection) { | |
| 54 | + | connection.lastActivity = Date.now(); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | getActiveUserIds(): string[] { | |
| 59 | + | return Array.from(this.connections.keys()); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | getConnectionCount(): number { | |
| 63 | + | return this.connections.size; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Clean up stale connections (e.g., if server didn't properly close them) | |
| 67 | + | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | |
| 68 | + | const now = Date.now(); | |
| 69 | + | const staleUserIds: string[] = []; | |
| 70 | + | ||
| 71 | + | for (const [userId, info] of this.connections.entries()) { | |
| 72 | + | if (now - info.lastActivity > maxInactivityMs) { | |
| 73 | + | this.connections.delete(userId); | |
| 74 | + | staleUserIds.push(userId); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if (staleUserIds.length > 0) { | |
| 79 | + | log.info('Cleaned up stale connections', { | |
| 80 | + | count: staleUserIds.length, | |
| 81 | + | userIds: staleUserIds | |
| 82 | + | }); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return staleUserIds; | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Export singleton instance | |
| 90 | + | export const sseConnectionTracker = new SSEConnectionTracker();⏎̸ | |
M src/lib/states/chat.svelte.ts +6 -1
| @@ -488,7 +488,7 @@ class ChatState { | |||
| 488 | 488 | } | |
| 489 | 489 | } | |
| 490 | 490 | ||
| 491 | - | async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> { | |
| 491 | + | async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> { | |
| 492 | 492 | const user = this.getCurrentUser(); | |
| 493 | 493 | if (!user) { | |
| 494 | 494 | console.debug('Cannot send message: No current user'); | |
| @@ -505,6 +505,11 @@ class ChatState { | |||
| 505 | 505 | userId: user.id, | |
| 506 | 506 | chatRoomId: DEFAULT_CHAT_ROOM_ID | |
| 507 | 507 | }; | |
| 508 | + | ||
| 509 | + | // Add styleData if textStyle is provided | |
| 510 | + | if (textStyle) { | |
| 511 | + | (payload as any).styleData = JSON.stringify(textStyle); | |
| 512 | + | } | |
| 508 | 513 | ||
| 509 | 514 | console.debug('Sending message with payload:', payload); | |
| 510 | 515 | ||
A src/lib/styles/fonts.css +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | /* Windows Classic Font Fallbacks */ | |
| 2 | + | /* | |
| 3 | + | * These @font-face declarations ensure consistent font rendering | |
| 4 | + | * by providing proper fallbacks for classic Windows fonts | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | /* Tahoma - Primary UI font */ | |
| 8 | + | @font-face { | |
| 9 | + | font-family: 'Tahoma Web'; | |
| 10 | + | src: local('Tahoma'), local('Tahoma Regular'); | |
| 11 | + | font-weight: normal; | |
| 12 | + | font-style: normal; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | @font-face { | |
| 16 | + | font-family: 'Tahoma Web'; | |
| 17 | + | src: local('Tahoma Bold'); | |
| 18 | + | font-weight: bold; | |
| 19 | + | font-style: normal; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /* Verdana - Secondary UI font */ | |
| 23 | + | @font-face { | |
| 24 | + | font-family: 'Verdana Web'; | |
| 25 | + | src: local('Verdana'), local('Verdana Regular'); | |
| 26 | + | font-weight: normal; | |
| 27 | + | font-style: normal; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | @font-face { | |
| 31 | + | font-family: 'Verdana Web'; | |
| 32 | + | src: local('Verdana Bold'); | |
| 33 | + | font-weight: bold; | |
| 34 | + | font-style: normal; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /* Arial - General purpose */ | |
| 38 | + | @font-face { | |
| 39 | + | font-family: 'Arial Web'; | |
| 40 | + | src: local('Arial'), local('Arial Regular'); | |
| 41 | + | font-weight: normal; | |
| 42 | + | font-style: normal; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | @font-face { | |
| 46 | + | font-family: 'Arial Web'; | |
| 47 | + | src: local('Arial Bold'); | |
| 48 | + | font-weight: bold; | |
| 49 | + | font-style: normal; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /* Times New Roman - Serif font */ | |
| 53 | + | @font-face { | |
| 54 | + | font-family: 'Times New Roman Web'; | |
| 55 | + | src: local('Times New Roman'), local('Times New Roman Regular'); | |
| 56 | + | font-weight: normal; | |
| 57 | + | font-style: normal; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | @font-face { | |
| 61 | + | font-family: 'Times New Roman Web'; | |
| 62 | + | src: local('Times New Roman Bold'); | |
| 63 | + | font-weight: bold; | |
| 64 | + | font-style: normal; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /* Courier New - Monospace */ | |
| 68 | + | @font-face { | |
| 69 | + | font-family: 'Courier New Web'; | |
| 70 | + | src: local('Courier New'), local('Courier New Regular'); | |
| 71 | + | font-weight: normal; | |
| 72 | + | font-style: normal; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | @font-face { | |
| 76 | + | font-family: 'Courier New Web'; | |
| 77 | + | src: local('Courier New Bold'); | |
| 78 | + | font-weight: bold; | |
| 79 | + | font-style: normal; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /* Comic Sans MS - Fun font */ | |
| 83 | + | @font-face { | |
| 84 | + | font-family: 'Comic Sans MS Web'; | |
| 85 | + | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | |
| 86 | + | font-weight: normal; | |
| 87 | + | font-style: normal; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | @font-face { | |
| 91 | + | font-family: 'Comic Sans MS Web'; | |
| 92 | + | src: local('Comic Sans MS Bold'); | |
| 93 | + | font-weight: bold; | |
| 94 | + | font-style: normal; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /* Ensure font-display for better performance */ | |
| 98 | + | @font-face { | |
| 99 | + | font-display: swap; | |
| 100 | + | }⏎̸ | |
M src/lib/types/chat.ts +2 -0
| @@ -27,6 +27,8 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||
| 27 | 27 | content: string; | |
| 28 | 28 | type: MessageType; // Default is 'chat' | |
| 29 | 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 | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | // Safe user type for API responses - omits sensitive information | |
M src/lib/types/payloads.ts +1 -0
| @@ -44,6 +44,7 @@ export interface SendMessageRequest { | |||
| 44 | 44 | type?: Message['type']; | |
| 45 | 45 | userId: string; | |
| 46 | 46 | chatRoomId?: string; | |
| 47 | + | styleData?: string; // JSON stringified TextStyle | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | export interface SendMessageResponseSuccess { | |
A src/lib/types/text-formatting.ts +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // Font and styling configuration for chat messages | |
| 2 | + | ||
| 3 | + | // Retro Windows fonts | |
| 4 | + | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | |
| 5 | + | // Secondary fonts use local system fonts with proper fallbacks | |
| 6 | + | export const RETRO_FONTS = { | |
| 7 | + | pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' }, | |
| 8 | + | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 9 | + | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 10 | + | comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' }, | |
| 11 | + | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |
| 12 | + | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |
| 13 | + | courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' }, | |
| 14 | + | dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' } | |
| 15 | + | } as const; | |
| 16 | + | ||
| 17 | + | export type FontFamily = keyof typeof RETRO_FONTS; | |
| 18 | + | ||
| 19 | + | // Generate 256-color web-safe palette ordered by hue | |
| 20 | + | 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; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Classic retro colors for backward compatibility | |
| 85 | + | 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' | |
| 102 | + | } as const; | |
| 103 | + | ||
| 104 | + | export type ClassicColor = keyof typeof CLASSIC_COLORS; | |
| 105 | + | ||
| 106 | + | // Gradient presets inspired by AOL faders | |
| 107 | + | 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'] | |
| 115 | + | } as const; | |
| 116 | + | ||
| 117 | + | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | |
| 118 | + | ||
| 119 | + | // Text styling options | |
| 120 | + | 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; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Default text style | |
| 132 | + | 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 | |
| 140 | + | }; | |
| 141 | + | ||
| 142 | + | // User preferences for text formatting | |
| 143 | + | export interface UserTextPreferences { | |
| 144 | + | defaultStyle: TextStyle; | |
| 145 | + | allowFormatting: boolean; | |
| 146 | + | maxMessageLength: number; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Default user preferences | |
| 150 | + | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | |
| 151 | + | defaultStyle: DEFAULT_TEXT_STYLE, | |
| 152 | + | allowFormatting: true, | |
| 153 | + | maxMessageLength: 500 | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | // Hex color validation | |
| 157 | + | export function isValidHexColor(color: string): boolean { | |
| 158 | + | return /^#[0-9A-Fa-f]{6}$/.test(color); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Formatting validation | |
| 162 | + | 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 | + | }; | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Generate CSS style string from TextStyle | |
| 176 | + | 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('; '); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Generate CSS style string for input fields (without font-family which should be handled by classes) | |
| 223 | + | 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 | + | }⏎̸ | |
A src/lib/utils/security-tests.ts +373 -0
| @@ -0,0 +1,373 @@ | |||
| 1 | + | import { formatText, validateMessageContent, createGradientText } from './text-formatter'; | |
| 2 | + | import { validateTextStyle, DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | |
| 3 | + | ||
| 4 | + | // XSS Test Vectors | |
| 5 | + | const XSS_TEST_VECTORS = [ | |
| 6 | + | // Script injection | |
| 7 | + | '<script>alert("XSS")</script>', | |
| 8 | + | '<script src="malicious.js"></script>', | |
| 9 | + | '<img src="x" onerror="alert(\'XSS\')">', | |
| 10 | + | '<svg onload="alert(\'XSS\')">', | |
| 11 | + | '<iframe src="javascript:alert(\'XSS\')"></iframe>', | |
| 12 | + | ||
| 13 | + | // Event handlers | |
| 14 | + | '<div onclick="alert(\'XSS\')">Click me</div>', | |
| 15 | + | '<input onfocus="alert(\'XSS\')" autofocus>', | |
| 16 | + | '<body onload="alert(\'XSS\')">', | |
| 17 | + | '<a href="javascript:alert(\'XSS\')">Link</a>', | |
| 18 | + | ||
| 19 | + | // CSS injection | |
| 20 | + | '<style>body{background:url("javascript:alert(\'XSS\')")}</style>', | |
| 21 | + | '<div style="background-image:url(javascript:alert(\'XSS\'))">', | |
| 22 | + | '<div style="expression(alert(\'XSS\'))">', | |
| 23 | + | ||
| 24 | + | // Data URIs | |
| 25 | + | '<img src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 26 | + | '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">', | |
| 27 | + | ||
| 28 | + | // HTML injection | |
| 29 | + | '<object data="malicious.swf">', | |
| 30 | + | '<embed src="malicious.swf">', | |
| 31 | + | '<applet code="malicious.class">', | |
| 32 | + | ||
| 33 | + | // BBCode injection attempts | |
| 34 | + | '[script]alert("XSS")[/script]', | |
| 35 | + | '[img=javascript:alert("XSS")]', | |
| 36 | + | '[url=javascript:alert("XSS")]Link[/url]', | |
| 37 | + | ||
| 38 | + | // Markdown injection attempts | |
| 39 | + | ')', | |
| 40 | + | '[XSS](javascript:alert("XSS"))', | |
| 41 | + | ||
| 42 | + | // Unicode and encoding bypasses | |
| 43 | + | '<script>alert("XSS")</script>', | |
| 44 | + | '<script>alert(String.fromCharCode(88,83,83))</script>', | |
| 45 | + | '<img src="x" onerror="alert('XSS')">', | |
| 46 | + | ||
| 47 | + | // CSS expression injection | |
| 48 | + | '<div style="width:expression(alert(\'XSS\'))">', | |
| 49 | + | '<div style="background:url(vbscript:alert(\'XSS\'))">', | |
| 50 | + | ||
| 51 | + | // Mixed content attacks | |
| 52 | + | 'Normal text <script>alert("XSS")</script> more text', | |
| 53 | + | '[b]Bold <script>alert("XSS")</script> text[/b]', | |
| 54 | + | '**Bold <img src="x" onerror="alert(\'XSS\')"> text**' | |
| 55 | + | ]; | |
| 56 | + | ||
| 57 | + | // CSS Injection Test Vectors | |
| 58 | + | const CSS_INJECTION_VECTORS = [ | |
| 59 | + | 'color: red; background: url(javascript:alert("XSS"))', | |
| 60 | + | 'font-family: "Arial"; } body { background: red; .fake {', | |
| 61 | + | 'font-size: expression(alert("XSS"))', | |
| 62 | + | 'background: url("data:text/html,<script>alert(\'XSS\')</script>")', | |
| 63 | + | 'color: red; } .injected { background: red', | |
| 64 | + | '@import url("malicious.css")', | |
| 65 | + | 'behavior: url("malicious.htc")' | |
| 66 | + | ]; | |
| 67 | + | ||
| 68 | + | // Test Results Interface | |
| 69 | + | interface SecurityTestResult { | |
| 70 | + | input: string; | |
| 71 | + | output: string; | |
| 72 | + | passed: boolean; | |
| 73 | + | vulnerabilities: string[]; | |
| 74 | + | testType: string; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | interface SecurityTestSuite { | |
| 78 | + | xssTests: SecurityTestResult[]; | |
| 79 | + | cssInjectionTests: SecurityTestResult[]; | |
| 80 | + | validationTests: SecurityTestResult[]; | |
| 81 | + | gradientTests: SecurityTestResult[]; | |
| 82 | + | summary: { | |
| 83 | + | totalTests: number; | |
| 84 | + | passed: number; | |
| 85 | + | failed: number; | |
| 86 | + | vulnerabilities: string[]; | |
| 87 | + | }; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Check if output contains dangerous patterns | |
| 91 | + | function detectVulnerabilities(output: string): string[] { | |
| 92 | + | const vulnerabilities: string[] = []; | |
| 93 | + | ||
| 94 | + | // Script tags | |
| 95 | + | if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) { | |
| 96 | + | vulnerabilities.push('Script tag injection'); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Event handlers | |
| 100 | + | if (/on\w+\s*=/gi.test(output)) { | |
| 101 | + | vulnerabilities.push('Event handler injection'); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // JavaScript URLs | |
| 105 | + | if (/javascript\s*:/gi.test(output)) { | |
| 106 | + | vulnerabilities.push('JavaScript URL injection'); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Data URLs with HTML/JS | |
| 110 | + | if (/data\s*:\s*text\/html/gi.test(output)) { | |
| 111 | + | vulnerabilities.push('Data URL HTML injection'); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // CSS expressions | |
| 115 | + | if (/expression\s*\(/gi.test(output)) { | |
| 116 | + | vulnerabilities.push('CSS expression injection'); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Dangerous CSS URLs | |
| 120 | + | if (/url\s*\(\s*['"]*javascript:/gi.test(output)) { | |
| 121 | + | vulnerabilities.push('CSS JavaScript URL injection'); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Iframe with dangerous src | |
| 125 | + | if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) { | |
| 126 | + | vulnerabilities.push('Dangerous iframe injection'); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Object/embed tags | |
| 130 | + | if (/<(?:object|embed|applet)\b/gi.test(output)) { | |
| 131 | + | vulnerabilities.push('Dangerous object/embed tag'); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Style tag injection | |
| 135 | + | if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) { | |
| 136 | + | vulnerabilities.push('Style tag injection'); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return vulnerabilities; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Run XSS tests | |
| 143 | + | async function runXSSTests(): Promise<SecurityTestResult[]> { | |
| 144 | + | const results: SecurityTestResult[] = []; | |
| 145 | + | ||
| 146 | + | for (const vector of XSS_TEST_VECTORS) { | |
| 147 | + | try { | |
| 148 | + | const output = await formatText(vector, DEFAULT_TEXT_STYLE, true); | |
| 149 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 150 | + | ||
| 151 | + | results.push({ | |
| 152 | + | input: vector, | |
| 153 | + | output, | |
| 154 | + | passed: vulnerabilities.length === 0, | |
| 155 | + | vulnerabilities, | |
| 156 | + | testType: 'XSS' | |
| 157 | + | }); | |
| 158 | + | } catch (error) { | |
| 159 | + | results.push({ | |
| 160 | + | input: vector, | |
| 161 | + | output: `Error: ${error}`, | |
| 162 | + | passed: true, // Errors are acceptable for malicious input | |
| 163 | + | vulnerabilities: [], | |
| 164 | + | testType: 'XSS' | |
| 165 | + | }); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | return results; | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Run CSS injection tests | |
| 173 | + | async function runCSSInjectionTests(): Promise<SecurityTestResult[]> { | |
| 174 | + | const results: SecurityTestResult[] = []; | |
| 175 | + | ||
| 176 | + | for (const vector of CSS_INJECTION_VECTORS) { | |
| 177 | + | try { | |
| 178 | + | const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any }; | |
| 179 | + | const output = await formatText(`Test with style`, testStyle, true); | |
| 180 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 181 | + | ||
| 182 | + | results.push({ | |
| 183 | + | input: vector, | |
| 184 | + | output, | |
| 185 | + | passed: vulnerabilities.length === 0, | |
| 186 | + | vulnerabilities, | |
| 187 | + | testType: 'CSS Injection' | |
| 188 | + | }); | |
| 189 | + | } catch (error) { | |
| 190 | + | results.push({ | |
| 191 | + | input: vector, | |
| 192 | + | output: `Error: ${error}`, | |
| 193 | + | passed: true, | |
| 194 | + | vulnerabilities: [], | |
| 195 | + | testType: 'CSS Injection' | |
| 196 | + | }); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return results; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // Run validation tests | |
| 204 | + | function runValidationTests(): SecurityTestResult[] { | |
| 205 | + | const results: SecurityTestResult[] = []; | |
| 206 | + | ||
| 207 | + | const testCases = [ | |
| 208 | + | 'Normal text', | |
| 209 | + | 'Text with <script>alert("XSS")</script>', | |
| 210 | + | 'Very long text '.repeat(200), // Test length limits | |
| 211 | + | '', // Empty text | |
| 212 | + | 'Text with\nnewlines\nand\ttabs', | |
| 213 | + | 'Special chars: <>&"\'`', | |
| 214 | + | 'Unicode: 你好世界 🌍 🎉', | |
| 215 | + | 'Mixed: Normal **bold** and <script>alert("XSS")</script>' | |
| 216 | + | ]; | |
| 217 | + | ||
| 218 | + | for (const testCase of testCases) { | |
| 219 | + | const validation = validateMessageContent(testCase); | |
| 220 | + | const vulnerabilities = detectVulnerabilities(validation.sanitizedContent); | |
| 221 | + | ||
| 222 | + | results.push({ | |
| 223 | + | input: testCase, | |
| 224 | + | output: validation.sanitizedContent, | |
| 225 | + | passed: validation.isValid && vulnerabilities.length === 0, | |
| 226 | + | vulnerabilities: [...validation.errors, ...vulnerabilities], | |
| 227 | + | testType: 'Validation' | |
| 228 | + | }); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | return results; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // Run gradient text security tests | |
| 235 | + | function runGradientTests(): SecurityTestResult[] { | |
| 236 | + | const results: SecurityTestResult[] = []; | |
| 237 | + | ||
| 238 | + | const testCases = [ | |
| 239 | + | 'Normal gradient text', | |
| 240 | + | '<script>alert("XSS")</script>', | |
| 241 | + | 'Text with "quotes" and \'apostrophes\'', | |
| 242 | + | 'Very long gradient text '.repeat(50), | |
| 243 | + | 'Special chars: <>&\'"', | |
| 244 | + | '' | |
| 245 | + | ]; | |
| 246 | + | ||
| 247 | + | const testColors = ['#FF0000', '#00FF00', '#0000FF']; | |
| 248 | + | ||
| 249 | + | for (const testCase of testCases) { | |
| 250 | + | try { | |
| 251 | + | const output = createGradientText(testCase, testColors); | |
| 252 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 253 | + | ||
| 254 | + | results.push({ | |
| 255 | + | input: testCase, | |
| 256 | + | output, | |
| 257 | + | passed: vulnerabilities.length === 0, | |
| 258 | + | vulnerabilities, | |
| 259 | + | testType: 'Gradient' | |
| 260 | + | }); | |
| 261 | + | } catch (error) { | |
| 262 | + | results.push({ | |
| 263 | + | input: testCase, | |
| 264 | + | output: `Error: ${error}`, | |
| 265 | + | passed: true, | |
| 266 | + | vulnerabilities: [], | |
| 267 | + | testType: 'Gradient' | |
| 268 | + | }); | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | return results; | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Run complete security test suite | |
| 276 | + | export async function runSecurityTestSuite(): Promise<SecurityTestSuite> { | |
| 277 | + | console.log('🔒 Running comprehensive security test suite...'); | |
| 278 | + | ||
| 279 | + | const xssTests = await runXSSTests(); | |
| 280 | + | const cssInjectionTests = await runCSSInjectionTests(); | |
| 281 | + | const validationTests = runValidationTests(); | |
| 282 | + | const gradientTests = runGradientTests(); | |
| 283 | + | ||
| 284 | + | const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests]; | |
| 285 | + | const passed = allTests.filter(test => test.passed).length; | |
| 286 | + | const failed = allTests.length - passed; | |
| 287 | + | ||
| 288 | + | // Collect all unique vulnerabilities | |
| 289 | + | const allVulnerabilities = new Set<string>(); | |
| 290 | + | allTests.forEach(test => { | |
| 291 | + | test.vulnerabilities.forEach(vuln => allVulnerabilities.add(vuln)); | |
| 292 | + | }); | |
| 293 | + | ||
| 294 | + | const results: SecurityTestSuite = { | |
| 295 | + | xssTests, | |
| 296 | + | cssInjectionTests, | |
| 297 | + | validationTests, | |
| 298 | + | gradientTests, | |
| 299 | + | summary: { | |
| 300 | + | totalTests: allTests.length, | |
| 301 | + | passed, | |
| 302 | + | failed, | |
| 303 | + | vulnerabilities: Array.from(allVulnerabilities) | |
| 304 | + | } | |
| 305 | + | }; | |
| 306 | + | ||
| 307 | + | console.log(`✅ Security tests completed:`); | |
| 308 | + | console.log(` Total tests: ${results.summary.totalTests}`); | |
| 309 | + | console.log(` Passed: ${results.summary.passed}`); | |
| 310 | + | console.log(` Failed: ${results.summary.failed}`); | |
| 311 | + | ||
| 312 | + | if (results.summary.vulnerabilities.length > 0) { | |
| 313 | + | console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities); | |
| 314 | + | } else { | |
| 315 | + | console.log(`🛡️ No vulnerabilities detected!`); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | return results; | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // Quick security check function | |
| 322 | + | export async function quickSecurityCheck(input: string): Promise<boolean> { | |
| 323 | + | try { | |
| 324 | + | const output = await formatText(input, DEFAULT_TEXT_STYLE, true); | |
| 325 | + | const vulnerabilities = detectVulnerabilities(output); | |
| 326 | + | return vulnerabilities.length === 0; | |
| 327 | + | } catch (error) { | |
| 328 | + | // Errors on malicious input are acceptable | |
| 329 | + | return true; | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // Style validation tests | |
| 334 | + | export function testStyleValidation(): boolean { | |
| 335 | + | const testCases = [ | |
| 336 | + | // Valid styles | |
| 337 | + | { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true }, | |
| 338 | + | { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true }, | |
| 339 | + | ||
| 340 | + | // Invalid styles (should be sanitized) | |
| 341 | + | { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' }, | |
| 342 | + | { fontFamily: 'tahoma', fontSize: -10, color: 'red' }, | |
| 343 | + | { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' } | |
| 344 | + | ]; | |
| 345 | + | ||
| 346 | + | for (const testCase of testCases) { | |
| 347 | + | try { | |
| 348 | + | const validated = validateTextStyle(testCase as Partial<TextStyle>); | |
| 349 | + | ||
| 350 | + | // Check that validated style has safe values | |
| 351 | + | if (!validated.fontFamily || | |
| 352 | + | validated.fontSize < 8 || | |
| 353 | + | validated.fontSize > 72) { | |
| 354 | + | console.error('Style validation failed for:', testCase); | |
| 355 | + | return false; | |
| 356 | + | } | |
| 357 | + | } catch (error) { | |
| 358 | + | console.error('Style validation error:', error); | |
| 359 | + | return false; | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | return true; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // Export test utilities | |
| 367 | + | export { | |
| 368 | + | XSS_TEST_VECTORS, | |
| 369 | + | CSS_INJECTION_VECTORS, | |
| 370 | + | detectVulnerabilities, | |
| 371 | + | type SecurityTestResult, | |
| 372 | + | type SecurityTestSuite | |
| 373 | + | };⏎̸ | |
A src/lib/utils/text-formatter.ts +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | import { unified } from 'unified'; | |
| 2 | + | import remarkParse from 'remark-parse'; | |
| 3 | + | import remarkRehype from 'remark-rehype'; | |
| 4 | + | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | |
| 5 | + | import rehypeStringify from 'rehype-stringify'; | |
| 6 | + | import type { TextStyle } from '../types/text-formatting'; | |
| 7 | + | import { generateCSSStyle, validateTextStyle } from '../types/text-formatting'; | |
| 8 | + | ||
| 9 | + | // Define allowed HTML tags and attributes for security | |
| 10 | + | const ALLOWED_SCHEMA = { | |
| 11 | + | ...defaultSchema, | |
| 12 | + | tagNames: [ | |
| 13 | + | ...(defaultSchema.tagNames || []), | |
| 14 | + | 'span', | |
| 15 | + | 'strong', | |
| 16 | + | 'em', | |
| 17 | + | 'u', | |
| 18 | + | 's', | |
| 19 | + | 'mark' | |
| 20 | + | ], | |
| 21 | + | attributes: { | |
| 22 | + | ...defaultSchema.attributes, | |
| 23 | + | span: ['style', 'class', 'data-gradient'], | |
| 24 | + | strong: ['style', 'class'], | |
| 25 | + | em: ['style', 'class'], | |
| 26 | + | u: ['style', 'class'], | |
| 27 | + | s: ['style', 'class'], | |
| 28 | + | mark: ['style', 'class'] | |
| 29 | + | }, | |
| 30 | + | protocols: { | |
| 31 | + | ...defaultSchema.protocols, | |
| 32 | + | style: [] | |
| 33 | + | } | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | // Custom rehype plugin to apply text styles | |
| 37 | + | function rehypeApplyStyles(userStyle: TextStyle) { | |
| 38 | + | return function(tree: any) { | |
| 39 | + | // We don't need to apply styles here anymore | |
| 40 | + | // The formatted-message component handles all styling | |
| 41 | + | return tree; | |
| 42 | + | }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // BBCode-style formatting patterns | |
| 46 | + | const BB_CODE_PATTERNS: Array<{ | |
| 47 | + | pattern: RegExp; | |
| 48 | + | replacement: string; | |
| 49 | + | }> = [ | |
| 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>' } | |
| 70 | + | ]; | |
| 71 | + | ||
| 72 | + | // Markdown-style formatting patterns | |
| 73 | + | const MARKDOWN_PATTERNS: Array<{ | |
| 74 | + | pattern: RegExp; | |
| 75 | + | replacement: string; | |
| 76 | + | }> = [ | |
| 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>' } | |
| 90 | + | ]; | |
| 91 | + | ||
| 92 | + | // Preprocess text to convert BBCode and Markdown to HTML | |
| 93 | + | 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; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Main text formatting function | |
| 123 | + | export async function formatText( | |
| 124 | + | text: string, | |
| 125 | + | userStyle?: Partial<TextStyle>, | |
| 126 | + | allowRichFormatting: boolean = true | |
| 127 | + | ): 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 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Utility function to escape HTML | |
| 189 | + | function escapeHtml(text: string): string { | |
| 190 | + | const div = document.createElement('div'); | |
| 191 | + | div.textContent = text; | |
| 192 | + | return div.innerHTML; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Generate CSS style string excluding font-family (which should be handled by classes) | |
| 196 | + | function generateCSSStyleWithoutFont(style: TextStyle): string { | |
| 197 | + | const styles: string[] = []; | |
| 198 | + | ||
| 199 | + | // Font size | |
| 200 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 201 | + | ||
| 202 | + | // Color (not for gradients) | |
| 203 | + | if (style.color && !style.gradient) { | |
| 204 | + | styles.push(`color: ${style.color}`); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Font weight | |
| 208 | + | if (style.bold) { | |
| 209 | + | styles.push(`font-weight: bold`); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Font style | |
| 213 | + | if (style.italic) { | |
| 214 | + | styles.push(`font-style: italic`); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Text decoration | |
| 218 | + | const decorations: string[] = []; | |
| 219 | + | if (style.underline) decorations.push('underline'); | |
| 220 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 221 | + | if (decorations.length > 0) { | |
| 222 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return styles.join('; '); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Create gradient text effect | |
| 229 | + | export function createGradientText( | |
| 230 | + | text: string, | |
| 231 | + | colors: string[], | |
| 232 | + | className: string = 'gradient-text', | |
| 233 | + | style?: TextStyle | |
| 234 | + | ): string { | |
| 235 | + | console.debug('createGradientText called with:', { text, colors, className, style }); | |
| 236 | + | ||
| 237 | + | if (colors.length < 2) { | |
| 238 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 239 | + | const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 240 | + | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | |
| 241 | + | console.debug('Single color result:', result); | |
| 242 | + | return result; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | const chars = Array.from(text); | |
| 246 | + | const totalChars = chars.length; | |
| 247 | + | ||
| 248 | + | if (totalChars === 0) return ''; | |
| 249 | + | ||
| 250 | + | // Create spans for each character with interpolated colors | |
| 251 | + | const gradientSpans = chars.map((char, index) => { | |
| 252 | + | if (char === ' ') { | |
| 253 | + | return ' '; // Use non-breaking space to preserve spacing | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Calculate color position (0 to 1) | |
| 257 | + | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | |
| 258 | + | ||
| 259 | + | // Find the two colors to interpolate between | |
| 260 | + | const colorIndex = position * (colors.length - 1); | |
| 261 | + | const lowerIndex = Math.floor(colorIndex); | |
| 262 | + | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | |
| 263 | + | const t = colorIndex - lowerIndex; | |
| 264 | + | ||
| 265 | + | // Interpolate between colors | |
| 266 | + | const color1 = hexToRgb(colors[lowerIndex]); | |
| 267 | + | const color2 = hexToRgb(colors[upperIndex]); | |
| 268 | + | ||
| 269 | + | if (!color1 || !color2) { | |
| 270 | + | const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 271 | + | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | const r = Math.round(color1.r + t * (color2.r - color1.r)); | |
| 275 | + | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |
| 276 | + | const b = Math.round(color1.b + t * (color2.b - color1.b)); | |
| 277 | + | ||
| 278 | + | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | |
| 279 | + | ||
| 280 | + | // Combine interpolated color with other styles | |
| 281 | + | let inlineStyle = `color: ${interpolatedColor}`; | |
| 282 | + | if (style) { | |
| 283 | + | // Get styles without font-family and color | |
| 284 | + | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | |
| 285 | + | if (style.bold) inlineStyle += `; font-weight: bold`; | |
| 286 | + | if (style.italic) inlineStyle += `; font-style: italic`; | |
| 287 | + | const decorations: string[] = []; | |
| 288 | + | if (style.underline) decorations.push('underline'); | |
| 289 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 290 | + | if (decorations.length > 0) { | |
| 291 | + | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 296 | + | }); | |
| 297 | + | ||
| 298 | + | // Wrap all spans in a container that uses flexbox to eliminate spacing | |
| 299 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 300 | + | const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : ''; | |
| 301 | + | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 302 | + | console.debug('Final gradient HTML:', result); | |
| 303 | + | return result; | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Helper function to convert hex to RGB | |
| 307 | + | 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; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Validate message content for security | |
| 317 | + | export function validateMessageContent(content: string): { | |
| 318 | + | isValid: boolean; | |
| 319 | + | sanitizedContent: string; | |
| 320 | + | errors: string[]; | |
| 321 | + | } { | |
| 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 | + | }⏎̸ | |
A src/lib/validation/password.ts +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | import { z } from 'zod'; | |
| 2 | + | ||
| 3 | + | // Dynamic password constraints configuration | |
| 4 | + | export interface PasswordConstraints { | |
| 5 | + | minLength: number; | |
| 6 | + | maxLength: number; | |
| 7 | + | requireUppercase: boolean; | |
| 8 | + | requireLowercase: boolean; | |
| 9 | + | requireNumbers: boolean; | |
| 10 | + | requireSpecialChars: boolean; | |
| 11 | + | forbiddenPatterns: string[]; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // Default password constraints | |
| 15 | + | export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = { | |
| 16 | + | minLength: 8, | |
| 17 | + | maxLength: 64, | |
| 18 | + | requireUppercase: true, | |
| 19 | + | requireLowercase: true, | |
| 20 | + | requireNumbers: true, | |
| 21 | + | requireSpecialChars: false, | |
| 22 | + | forbiddenPatterns: ['password', '123456', 'qwerty', 'admin'] | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | // Validation error messages | |
| 26 | + | export const PASSWORD_ERROR_MESSAGES = { | |
| 27 | + | minLength: (min: number) => `Password must be at least ${min} characters long`, | |
| 28 | + | maxLength: (max: number) => `Password must be at most ${max} characters long`, | |
| 29 | + | requireUppercase: 'Password must contain at least one uppercase letter', | |
| 30 | + | requireLowercase: 'Password must contain at least one lowercase letter', | |
| 31 | + | requireNumbers: 'Password must contain at least one number', | |
| 32 | + | requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)', | |
| 33 | + | forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`, | |
| 34 | + | common: 'Password is too common or weak' | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | // Create dynamic password schema based on constraints | |
| 38 | + | export function createPasswordSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) { | |
| 39 | + | let schema = z.string() | |
| 40 | + | .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength)) | |
| 41 | + | .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength)); | |
| 42 | + | ||
| 43 | + | // Add character type requirements | |
| 44 | + | if (constraints.requireUppercase) { | |
| 45 | + | schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if (constraints.requireLowercase) { | |
| 49 | + | schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if (constraints.requireNumbers) { | |
| 53 | + | schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | if (constraints.requireSpecialChars) { | |
| 57 | + | schema = schema.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, PASSWORD_ERROR_MESSAGES.requireSpecialChars); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Add forbidden patterns check | |
| 61 | + | if (constraints.forbiddenPatterns.length > 0) { | |
| 62 | + | return schema.refine( | |
| 63 | + | (password) => { | |
| 64 | + | const lowerPassword = password.toLowerCase(); | |
| 65 | + | return !constraints.forbiddenPatterns.some(pattern => | |
| 66 | + | lowerPassword.includes(pattern.toLowerCase()) | |
| 67 | + | ); | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | message: PASSWORD_ERROR_MESSAGES.common | |
| 71 | + | } | |
| 72 | + | ); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return schema; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Login validation schema | |
| 79 | + | 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') | |
| 85 | + | }); | |
| 86 | + | ||
| 87 | + | // 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 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // 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 | + | }; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // 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 | + | }⏎̸ | |
A src/lib/validation/text-formatting.ts +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | import type { TextStyle } from '$lib/types/text-formatting'; | |
| 2 | + | import { RETRO_FONTS } from '$lib/types/text-formatting'; | |
| 3 | + | ||
| 4 | + | const MIN_FONT_SIZE = 8; | |
| 5 | + | const MAX_FONT_SIZE = 18; | |
| 6 | + | const MAX_GRADIENT_COLORS = 3; | |
| 7 | + | const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/; | |
| 8 | + | ||
| 9 | + | export function validateHexColor(color: string): boolean { | |
| 10 | + | return HEX_COLOR_REGEX.test(color); | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export function validateFontFamily(fontFamily: string): boolean { | |
| 14 | + | return fontFamily in RETRO_FONTS; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export function validateFontSize(size: number): number { | |
| 18 | + | return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export function validateGradient(gradient: unknown): string[] | undefined { | |
| 22 | + | if (!Array.isArray(gradient)) return undefined; | |
| 23 | + | if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined; | |
| 24 | + | ||
| 25 | + | const validColors = gradient.filter(color => | |
| 26 | + | typeof color === 'string' && validateHexColor(color) | |
| 27 | + | ); | |
| 28 | + | ||
| 29 | + | return validColors.length === gradient.length ? validColors : undefined; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function validateTextStyle(style: unknown): TextStyle | null { | |
| 33 | + | if (!style || typeof style !== 'object') return null; | |
| 34 | + | ||
| 35 | + | const rawStyle = style as Record<string, unknown>; | |
| 36 | + | ||
| 37 | + | const validated: TextStyle = { | |
| 38 | + | fontFamily: 'pixelated', | |
| 39 | + | fontSize: 14, | |
| 40 | + | bold: false, | |
| 41 | + | italic: false, | |
| 42 | + | underline: false, | |
| 43 | + | strikethrough: false | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | // Validate font family | |
| 47 | + | if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) { | |
| 48 | + | validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Validate font size | |
| 52 | + | if (typeof rawStyle.fontSize === 'number') { | |
| 53 | + | validated.fontSize = validateFontSize(rawStyle.fontSize); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Validate color | |
| 57 | + | if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) { | |
| 58 | + | validated.color = rawStyle.color; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Validate gradient | |
| 62 | + | const validatedGradient = validateGradient(rawStyle.gradient); | |
| 63 | + | if (validatedGradient) { | |
| 64 | + | validated.gradient = validatedGradient; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Validate boolean properties | |
| 68 | + | validated.bold = Boolean(rawStyle.bold); | |
| 69 | + | validated.italic = Boolean(rawStyle.italic); | |
| 70 | + | validated.underline = Boolean(rawStyle.underline); | |
| 71 | + | validated.strikethrough = Boolean(rawStyle.strikethrough); | |
| 72 | + | ||
| 73 | + | return validated; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | export function sanitizeStyleData(styleData: unknown): TextStyle | undefined { | |
| 77 | + | if (!styleData) return undefined; | |
| 78 | + | ||
| 79 | + | const validated = validateTextStyle(styleData); | |
| 80 | + | return validated || undefined; | |
| 81 | + | }⏎̸ | |
A src/routes/+page.server.ts +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | import type { PageServerLoad } from './$types'; | |
| 2 | + | import { validateSessionToken } from '$lib/api/session.server'; | |
| 3 | + | import db from '$lib/db/db.server'; | |
| 4 | + | import { userTextPreferences, users, messages } from '$lib/db/schema'; | |
| 5 | + | import { eq, desc } from 'drizzle-orm'; | |
| 6 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |
| 7 | + | ||
| 8 | + | export const load: PageServerLoad = async ({ cookies }) => { | |
| 9 | + | const token = cookies.get('session'); | |
| 10 | + | ||
| 11 | + | if (!token) { | |
| 12 | + | return { | |
| 13 | + | user: null, | |
| 14 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 15 | + | }; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | try { | |
| 19 | + | const { user } = await validateSessionToken(token); | |
| 20 | + | ||
| 21 | + | if (!user) { | |
| 22 | + | return { | |
| 23 | + | user: null, | |
| 24 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 25 | + | }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Get user's last used text styling from their most recent message | |
| 29 | + | const lastMessage = await db.query.messages.findFirst({ | |
| 30 | + | where: eq(messages.senderId, user.id), | |
| 31 | + | orderBy: [desc(messages.timestamp)], | |
| 32 | + | columns: { | |
| 33 | + | styleData: true | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |
| 38 | + | ||
| 39 | + | if (lastMessage?.styleData) { | |
| 40 | + | try { | |
| 41 | + | const parsedStyle = JSON.parse(lastMessage.styleData); | |
| 42 | + | // Merge with default style to ensure all required properties | |
| 43 | + | lastTextStyle = { | |
| 44 | + | ...DEFAULT_TEXT_STYLE, | |
| 45 | + | ...parsedStyle | |
| 46 | + | }; | |
| 47 | + | } catch (error) { | |
| 48 | + | console.warn('Failed to parse last text style:', error); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | return { | |
| 53 | + | user, | |
| 54 | + | lastTextStyle | |
| 55 | + | }; | |
| 56 | + | } catch (error) { | |
| 57 | + | console.error('Error loading page data:', error); | |
| 58 | + | return { | |
| 59 | + | user: null, | |
| 60 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 61 | + | }; | |
| 62 | + | } | |
| 63 | + | };⏎̸ | |
M src/routes/+page.svelte +4 -1
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 3 | 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | 4 | import AimLogin from '$lib/components/aim-login.svelte'; | |
| 5 | + | import type { PageData } from './$types'; | |
| 5 | 6 | ||
| 7 | + | let { data }: { data: PageData } = $props(); | |
| 8 | + | ||
| 6 | 9 | let showChatRoom = $state(true); | |
| 7 | 10 | let showAuth = $state(false); | |
| 8 | 11 | ||
| @@ -15,7 +18,7 @@ | |||
| 15 | 18 | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 16 | 19 | ||
| 17 | 20 | {#if showChatRoom} | |
| 18 | - | <ChatRoom bind:showChatRoom /> | |
| 21 | + | <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} /> | |
| 19 | 22 | {/if} | |
| 20 | 23 | ||
| 21 | 24 | {#if showAuth} | |
M src/routes/api/chat/messages/+server.ts +12 -2
| @@ -15,6 +15,7 @@ import { chatRooms } from '$lib/db/schema'; | |||
| 15 | 15 | import { users } from '$lib/db/schema'; | |
| 16 | 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | |
| 17 | 17 | import { createLogger } from '$lib/utils/logger.server'; | |
| 18 | + | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |
| 18 | 19 | ||
| 19 | 20 | const log = createLogger('chat-server'); | |
| 20 | 21 | ||
| @@ -83,7 +84,12 @@ export async function GET({ request, locals }) { | |||
| 83 | 84 | .from(messages) | |
| 84 | 85 | .where(conditions) | |
| 85 | 86 | .orderBy(desc(messages.timestamp)) | |
| 86 | - | .limit(fetchLimit); | |
| 87 | + | .limit(fetchLimit) | |
| 88 | + | .then(rows => rows.map(row => ({ | |
| 89 | + | ...row, | |
| 90 | + | styleData: row.styleData ?? undefined, | |
| 91 | + | hasFormatting: row.hasFormatting ?? false | |
| 92 | + | }))); | |
| 87 | 93 | ||
| 88 | 94 | const response: GetMessagesResponse = { | |
| 89 | 95 | success: true, | |
| @@ -182,13 +188,17 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||
| 182 | 188 | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 183 | 189 | } | |
| 184 | 190 | ||
| 191 | + | const validatedStyleData = sanitizeStyleData(data.styleData); | |
| 192 | + | ||
| 185 | 193 | const newMessage: Message = { | |
| 186 | 194 | id: uuidv4(), | |
| 187 | 195 | chatRoomId, | |
| 188 | 196 | senderId: data.userId, | |
| 189 | 197 | content: data.content, | |
| 190 | 198 | type: data.type || 'chat', | |
| 191 | - | timestamp: Date.now() | |
| 199 | + | timestamp: Date.now(), | |
| 200 | + | styleData: validatedStyleData, | |
| 201 | + | hasFormatting: Boolean(validatedStyleData) | |
| 192 | 202 | }; | |
| 193 | 203 | ||
| 194 | 204 | // Save to DB | |
M src/routes/api/register/+server.ts +15 -57
| @@ -3,7 +3,7 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/ | |||
| 3 | 3 | import db from '$lib/db/db.server'; | |
| 4 | 4 | import { users } from '$lib/db/schema'; | |
| 5 | 5 | import { hashPassword } from '$lib/utils/password'; | |
| 6 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 6 | + | import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password'; | |
| 7 | 7 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | 8 | import { eq } from 'drizzle-orm/sql'; | |
| 9 | 9 | ||
| @@ -54,24 +54,22 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 54 | 54 | turnstileToken?: string | |
| 55 | 55 | }; | |
| 56 | 56 | ||
| 57 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || (!isDev && !turnstileToken)) { | |
| 58 | - | log.warn('Missing required fields', { | |
| 59 | - | hasUsername: Boolean(suUsername), | |
| 60 | - | hasPassword: Boolean(suPassword), | |
| 61 | - | hasConfirmPassword: Boolean(suConfirmPassword), | |
| 62 | - | hasCaptcha: Boolean(captchaAnswer), | |
| 63 | - | hasTurnstileToken: Boolean(turnstileToken), | |
| 64 | - | isDev | |
| 57 | + | // Validate input using Zod schema | |
| 58 | + | try { | |
| 59 | + | const registrationSchema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS); | |
| 60 | + | registrationSchema.parse({ | |
| 61 | + | suUsername, | |
| 62 | + | suPassword, | |
| 63 | + | suConfirmPassword, | |
| 64 | + | captchaAnswer, | |
| 65 | + | turnstileToken | |
| 65 | 66 | }); | |
| 66 | - | return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 }); | |
| 67 | + | } catch (err: any) { | |
| 68 | + | const errorMessage = err.errors?.[0]?.message || 'Invalid input data'; | |
| 69 | + | log.warn('Registration validation failed', { error: errorMessage }); | |
| 70 | + | return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { status: 400 }); | |
| 67 | 71 | } | |
| 68 | 72 | ||
| 69 | - | // Validate Turnstile token (will be automatically bypassed in dev mode) | |
| 70 | - | const isValidTurnstile = await validateTurnstileToken(turnstileToken || '', ip); | |
| 71 | - | if (!isValidTurnstile) { | |
| 72 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 73 | - | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 }); | |
| 74 | - | } | |
| 75 | 73 | ||
| 76 | 74 | // Validate PDR captcha | |
| 77 | 75 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |
| @@ -86,49 +84,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 86 | 84 | // Reset captcha attempts on success | |
| 87 | 85 | captchaAttempts.delete(ip); | |
| 88 | 86 | ||
| 89 | - | // Destructure and validate required fields from the payload. | |
| 90 | - | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | |
| 91 | - | typeof suConfirmPassword !== 'string') { | |
| 92 | - | log.warn('Missing or invalid input fields'); | |
| 93 | - | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Trim input values. | |
| 87 | + | // Trim input values (validation already done by Zod) | |
| 97 | 88 | const username = suUsername.trim(); | |
| 98 | 89 | const password = suPassword.trim(); | |
| 99 | - | const confirmPassword = suConfirmPassword.trim(); | |
| 100 | - | ||
| 101 | - | // Basic validations. | |
| 102 | - | if (!username || !password || !confirmPassword) { | |
| 103 | - | log.warn('All fields must be filled'); | |
| 104 | - | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | if (username.length < 3) { | |
| 108 | - | log.warn('Username must be at least 3 characters'); | |
| 109 | - | return new Response(JSON.stringify({ error: 'Username must be at least 3 characters' } as RegisterResponseError), { status: 400 }); | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | if (username.length > 32) { | |
| 113 | - | log.warn('Username must be at most 32 characters'); | |
| 114 | - | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | |
| 115 | - | } | |
| 116 | - | // Only allow letters, numbers, underscores, and dashes in the username. | |
| 117 | - | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |
| 118 | - | if (!usernameRegex.test(username)) { | |
| 119 | - | log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.'); | |
| 120 | - | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | if (password !== confirmPassword) { | |
| 124 | - | log.warn('Passwords do not match'); | |
| 125 | - | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | if (password.length < 8 || password.length > 64) { | |
| 129 | - | log.warn('Password must be between 8 and 64 characters'); | |
| 130 | - | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | |
| 131 | - | } | |
| 132 | 90 | ||
| 133 | 91 | // Check if nickname already exists | |
| 134 | 92 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | |
M src/routes/api/rooms/[roomId]/+server.ts +3 -3
| @@ -40,7 +40,7 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||
| 40 | 40 | // Process users to mark them as offline if they've timed out | |
| 41 | 41 | const processedUsers = normalizedUsers.map( | |
| 42 | 42 | (user: SafeUser & { lastSeen: number }) => { | |
| 43 | - | const shouldBeOffline = user.status === 'online' && user.lastSeen < timeoutThreshold; | |
| 43 | + | const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold; | |
| 44 | 44 | return { | |
| 45 | 45 | ...user, | |
| 46 | 46 | status: shouldBeOffline ? 'offline' : user.status | |
| @@ -48,13 +48,13 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||
| 48 | 48 | } | |
| 49 | 49 | ); | |
| 50 | 50 | ||
| 51 | - | // Find users who were originally online but now marked offline | |
| 51 | + | // Find users who were originally non-offline but now marked offline | |
| 52 | 52 | const usersToUpdate = processedUsers.filter( | |
| 53 | 53 | (user: SafeUser & { lastSeen: number }) => | |
| 54 | 54 | user.status === 'offline' && | |
| 55 | 55 | normalizedUsers.find( | |
| 56 | 56 | (u: SafeUser & { lastSeen: number }) => u.id === user.id | |
| 57 | - | )?.status === 'online' | |
| 57 | + | )?.status !== 'offline' | |
| 58 | 58 | ); | |
| 59 | 59 | ||
| 60 | 60 | if (usersToUpdate.length > 0) { | |
M src/routes/api/session/login/+server.ts +11 -48
| @@ -8,10 +8,10 @@ import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||
| 8 | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 9 | 9 | import { createSafeUser } from '$lib/types/chat'; | |
| 10 | 10 | import { createLogger } from '$lib/utils/logger.server'; | |
| 11 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 11 | + | import { loginSchema } from '$lib/validation/password'; | |
| 12 | + | import { z } from 'zod'; | |
| 12 | 13 | ||
| 13 | 14 | const log = createLogger('login-server'); | |
| 14 | - | const isDev = process.env.NODE_ENV === 'development'; | |
| 15 | 15 | ||
| 16 | 16 | // In-memory map to track failed login attempts per IP | |
| 17 | 17 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| @@ -29,14 +29,6 @@ setInterval(() => { | |||
| 29 | 29 | } | |
| 30 | 30 | }, 60 * 60 * 1000); | |
| 31 | 31 | ||
| 32 | - | // Helper to parse clearance cookie from headers | |
| 33 | - | function getClearanceCookie(cookieHeader: string | null): string | null { | |
| 34 | - | if (!cookieHeader) return null; | |
| 35 | - | // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance" | |
| 36 | - | const regex = /cf[-_]clearance=([^;]+)/i; | |
| 37 | - | const match = cookieHeader.match(regex); | |
| 38 | - | return match ? match[1] : null; | |
| 39 | - | } | |
| 40 | 32 | ||
| 41 | 33 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 42 | 34 | log.debug('New login attempt received'); | |
| @@ -72,53 +64,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 72 | 64 | ); | |
| 73 | 65 | } | |
| 74 | 66 | ||
| 75 | - | // Extract cookie header and clearance cookie BEFORE validating input. | |
| 76 | - | const cookieHeader = request.headers.get('cookie'); | |
| 77 | - | const clearanceCookie = getClearanceCookie(cookieHeader); | |
| 78 | - | ||
| 79 | - | // Note: Mark turnstileToken as optional if a valid clearance cookie exists or in dev mode. | |
| 80 | - | const { username, password, turnstileToken } = body as { | |
| 67 | + | const { username, password } = body as { | |
| 81 | 68 | username: string; | |
| 82 | 69 | password: string; | |
| 83 | - | turnstileToken?: string; | |
| 84 | 70 | }; | |
| 85 | 71 | ||
| 86 | - | if ( | |
| 87 | - | typeof username !== 'string' || | |
| 88 | - | typeof password !== 'string' || | |
| 89 | - | (!isDev && !clearanceCookie && typeof turnstileToken !== 'string') | |
| 90 | - | ) { | |
| 91 | - | log.warn('Missing or invalid input fields', { | |
| 92 | - | hasUsername: typeof username === 'string', | |
| 93 | - | hasPassword: typeof password === 'string', | |
| 94 | - | hasTurnstileToken: typeof turnstileToken === 'string', | |
| 95 | - | isDev | |
| 96 | - | }); | |
| 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 }); | |
| 97 | 79 | return new Response( | |
| 98 | - | JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), | |
| 80 | + | JSON.stringify({ error: errorMessage } as LoginResponseError), | |
| 99 | 81 | { status: 400 } | |
| 100 | 82 | ); | |
| 101 | 83 | } | |
| 102 | 84 | ||
| 103 | - | // Check for dev mode or clearance cookie before validating Turnstile token. | |
| 104 | - | let isValidTurnstile = false; | |
| 105 | - | if (isDev) { | |
| 106 | - | log.debug('Development mode: bypassing token challenge.'); | |
| 107 | - | isValidTurnstile = true; | |
| 108 | - | } else if (clearanceCookie) { | |
| 109 | - | log.debug('Clearance cookie detected; bypassing token challenge.'); | |
| 110 | - | isValidTurnstile = true; | |
| 111 | - | } else { | |
| 112 | - | isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip); | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | if (!isValidTurnstile) { | |
| 116 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 117 | - | return new Response( | |
| 118 | - | JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), | |
| 119 | - | { status: 400 } | |
| 120 | - | ); | |
| 121 | - | } | |
| 122 | 85 | ||
| 123 | 86 | // Find user by username | |
| 124 | 87 | const user = await db.query.users.findFirst({ | |
M src/routes/api/sse/+server.ts +122 -48
| @@ -8,9 +8,11 @@ import type { RequestHandler } from './$types'; | |||
| 8 | 8 | import { sseEmitter } from '$lib/sseEmitter'; | |
| 9 | 9 | import db from '$lib/db/db.server'; | |
| 10 | 10 | import { users } from '$lib/db/schema'; | |
| 11 | - | import { eq, and, lt } from 'drizzle-orm'; | |
| 11 | + | import { eq, and, lt, ne } from 'drizzle-orm'; | |
| 12 | 12 | import { createSafeUser } from '$lib/types/chat'; | |
| 13 | 13 | import { createLogger } from '$lib/utils/logger.server'; | |
| 14 | + | import { sseConnectionTracker } from '$lib/sseConnectionTracker'; | |
| 15 | + | import { buddyListCache } from '$lib/buddyListCache'; | |
| 14 | 16 | ||
| 15 | 17 | const log = createLogger('sse-server'); | |
| 16 | 18 | ||
| @@ -52,7 +54,7 @@ function startTimeoutCheck() { | |||
| 52 | 54 | }) | |
| 53 | 55 | .where( | |
| 54 | 56 | and( | |
| 55 | - | eq(users.status, 'online'), // Only update online users | |
| 57 | + | ne(users.status, 'offline'), // Update any non-offline users | |
| 56 | 58 | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | |
| 57 | 59 | ) | |
| 58 | 60 | ) | |
| @@ -67,6 +69,9 @@ function startTimeoutCheck() { | |||
| 67 | 69 | usersUpdated: result.length, | |
| 68 | 70 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 69 | 71 | }); | |
| 72 | + | ||
| 73 | + | // Invalidate buddy list cache when users timeout | |
| 74 | + | buddyListCache.invalidate(); | |
| 70 | 75 | } | |
| 71 | 76 | } catch (error) { | |
| 72 | 77 | log.error('Error during timeout check', { error }); | |
| @@ -84,19 +89,35 @@ function startBuddyListUpdateInterval() { | |||
| 84 | 89 | const now = Date.now(); | |
| 85 | 90 | // Only fetch and broadcast if 10 seconds have passed since last update | |
| 86 | 91 | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 87 | - | log.debug('Broadcasting buddy list update...'); | |
| 88 | - | // Query the complete buddy list from the database. | |
| 89 | - | const buddyList = await db.select().from(users); | |
| 90 | - | // Use createSafeUser to properly sanitize user data | |
| 91 | - | const safeBuddyList = buddyList.map(user => createSafeUser(user)); | |
| 92 | - | ||
| 93 | - | // Only broadcast if data has changed | |
| 94 | - | if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) { | |
| 95 | - | lastBuddyListData = safeBuddyList; | |
| 96 | - | sseEmitter.emit('sse', { | |
| 97 | - | type: 'buddyListUpdate', | |
| 98 | - | data: safeBuddyList | |
| 99 | - | }); | |
| 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 | + | } | |
| 100 | 121 | } | |
| 101 | 122 | lastBuddyListUpdate = now; | |
| 102 | 123 | } | |
| @@ -106,10 +127,38 @@ function startBuddyListUpdateInterval() { | |||
| 106 | 127 | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 107 | 128 | } | |
| 108 | 129 | ||
| 130 | + | // Add periodic cleanup for stale connections | |
| 131 | + | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 132 | + | ||
| 133 | + | 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 | |
| 155 | + | } | |
| 156 | + | ||
| 109 | 157 | // <<< NEW: Ensure intervals are started only once >>> | |
| 110 | 158 | if (!globalThis.__sseIntervalsStarted) { | |
| 111 | 159 | startTimeoutCheck(); | |
| 112 | 160 | startBuddyListUpdateInterval(); | |
| 161 | + | startConnectionCleanup(); | |
| 113 | 162 | globalThis.__sseIntervalsStarted = true; | |
| 114 | 163 | log.info('Started global intervals for timeout and buddy list updates'); | |
| 115 | 164 | } else { | |
| @@ -121,18 +170,21 @@ if (typeof process !== 'undefined') { | |||
| 121 | 170 | process.on('beforeExit', () => { | |
| 122 | 171 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 123 | 172 | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 173 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 124 | 174 | }); | |
| 125 | 175 | ||
| 126 | 176 | // Also handle SIGTERM and SIGINT | |
| 127 | 177 | process.on('SIGTERM', () => { | |
| 128 | 178 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 129 | 179 | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 180 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 130 | 181 | process.exit(0); | |
| 131 | 182 | }); | |
| 132 | 183 | ||
| 133 | 184 | process.on('SIGINT', () => { | |
| 134 | 185 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 135 | 186 | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 187 | + | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 136 | 188 | process.exit(0); | |
| 137 | 189 | }); | |
| 138 | 190 | } | |
| @@ -160,29 +212,42 @@ export const GET: RequestHandler = async ({ request, locals, }) => { | |||
| 160 | 212 | ||
| 161 | 213 | const now = Date.now(); | |
| 162 | 214 | ||
| 163 | - | try { | |
| 164 | - | await db.update(users) | |
| 165 | - | .set({ | |
| 166 | - | status: 'online', | |
| 167 | - | lastSeen: now | |
| 168 | - | }) | |
| 169 | - | .where(eq(users.id, userId)); | |
| 170 | - | ||
| 171 | - | log.info('User status updated to online', { | |
| 172 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | |
| 173 | - | timestamp: new Date(now).toISOString() | |
| 174 | - | }); | |
| 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)); | |
| 175 | 226 | ||
| 176 | - | sseEmitter.emit('sse', { | |
| 177 | - | type: 'userStatusUpdate', | |
| 178 | - | data: { | |
| 179 | - | userId, | |
| 180 | - | status: 'online', | |
| 181 | - | lastSeen: now | |
| 182 | - | } | |
| 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)}` | |
| 183 | 250 | }); | |
| 184 | - | } catch (error) { | |
| 185 | - | log.error('Error updating user status', { error }); | |
| 186 | 251 | } | |
| 187 | 252 | ||
| 188 | 253 | const stream = new ReadableStream({ | |
| @@ -225,19 +290,28 @@ export const GET: RequestHandler = async ({ request, locals, }) => { | |||
| 225 | 290 | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 226 | 291 | log.info('Connection closed', { userId: maskedUserId, reason }); | |
| 227 | 292 | ||
| 228 | - | const now = Date.now(); | |
| 229 | - | await db.update(users) | |
| 230 | - | .set({ status: 'offline', lastSeen: now }) | |
| 231 | - | .where(eq(users.id, userId)); | |
| 293 | + | // Remove from connection tracker | |
| 294 | + | const wasTracked = sseConnectionTracker.removeConnection(userId); | |
| 232 | 295 | ||
| 233 | - | sseEmitter.emit('sse', { | |
| 234 | - | type: 'userStatusUpdate', | |
| 235 | - | data: { | |
| 236 | - | userId, | |
| 237 | - | status: 'offline', | |
| 238 | - | lastSeen: now | |
| 239 | - | } | |
| 240 | - | }); | |
| 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 | + | } | |
| 241 | 315 | } | |
| 242 | 316 | }); | |
| 243 | 317 | ||
M src/routes/api/status/+server.ts +4 -0
| @@ -5,6 +5,7 @@ import { validateSessionToken, generateSessionToken, createSession } from '$lib/ | |||
| 5 | 5 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 6 | 6 | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | 7 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | + | import { buddyListCache } from '$lib/buddyListCache'; | |
| 8 | 9 | import type { RequestHandler } from './$types'; | |
| 9 | 10 | ||
| 10 | 11 | const log = createLogger('status-server'); | |
| @@ -46,6 +47,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => { | |||
| 46 | 47 | .returning() | |
| 47 | 48 | .get(); | |
| 48 | 49 | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 50 | + | ||
| 51 | + | // Invalidate buddy list cache when status changes | |
| 52 | + | buddyListCache.invalidate(); | |
| 49 | 53 | ||
| 50 | 54 | // Renew session if status is online and session expires in less than 1 day | |
| 51 | 55 | if (status === 'online') { | |
A static/_headers +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | # Cache configuration for static assets | |
| 2 | + | ||
| 3 | + | # Font files - cache for 1 year (31536000 seconds) | |
| 4 | + | /fonts/* | |
| 5 | + | Cache-Control: public, max-age=31536000, immutable | |
| 6 | + | Access-Control-Allow-Origin: * | |
| 7 | + | Cross-Origin-Resource-Policy: cross-origin | |
| 8 | + | ||
| 9 | + | # General static assets - cache for 1 month (2592000 seconds) | |
| 10 | + | /favicon.png | |
| 11 | + | Cache-Control: public, max-age=2592000 | |
| 12 | + | ||
| 13 | + | # Icons and images - cache for 1 month | |
| 14 | + | /aim/* | |
| 15 | + | Cache-Control: public, max-age=2592000 | |
| 16 | + | ||
| 17 | + | /desktop/* | |
| 18 | + | Cache-Control: public, max-age=2592000⏎̸ | |
A static/fonts/Arial.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Arialbd.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Arialbi.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Ariali.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Comic.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Comicbd.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/LiberationMono-Bold.ttf +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/LiberationMono-BoldItalic.ttf +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/LiberationMono-Italic.ttf +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/LiberationMono-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Times.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Timesbd.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Timesbi.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Timesi.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Verdana.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Verdanab.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Verdanai.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/Verdanaz.TTF +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/tahoma.ttf +0 -0 binary file: no text diff
binary file: no text diff
A static/fonts/tahomabd.ttf +0 -0 binary file: no text diff
binary file: no text diff
A test-gradient-fixes.md +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | # Gradient and Text Formatting Fixes Summary | |
| 2 | + | ||
| 3 | + | ## Issues Fixed: | |
| 4 | + | ||
| 5 | + | ### 1. Color Picker Button Gradient Preview | |
| 6 | + | **Problem**: The color picker button wasn't showing gradient preview correctly. | |
| 7 | + | **Fix**: Updated `displayStyle` in `color-picker-256.svelte` to use consistent `background` property instead of `background-color`. | |
| 8 | + | ||
| 9 | + | ### 2. Text Input Gradient Display | |
| 10 | + | **Problem**: Text input only showed the first color of a gradient. | |
| 11 | + | **Fix**: This is by design to preserve the input background. Added a visual gradient indicator next to the input field in `chat-room.svelte` when gradient mode is active. | |
| 12 | + | ||
| 13 | + | ### 3. Chat Messages Formatting | |
| 14 | + | **Problem**: Messages weren't displaying with user-specific fonts, styles and colors. | |
| 15 | + | **Fixes**: | |
| 16 | + | - Updated `sendMessage` function to accept and pass `textStyle` parameter | |
| 17 | + | - Added `styleData` field to `SendMessageRequest` payload type | |
| 18 | + | - Updated server-side message handler to save `styleData` and `hasFormatting` fields | |
| 19 | + | - Fixed `formatted-message.svelte` to properly handle gradient styles | |
| 20 | + | - Updated text formatter to apply styles correctly to paragraph elements | |
| 21 | + | ||
| 22 | + | ## Files Modified: | |
| 23 | + | ||
| 24 | + | 1. `/src/lib/components/color-picker-256.svelte` - Fixed gradient preview display | |
| 25 | + | 2. `/src/lib/components/chat-room.svelte` - Added textStyle to sendMessage call and gradient indicator | |
| 26 | + | 3. `/src/lib/components/formatted-message.svelte` - Fixed style application for gradients | |
| 27 | + | 4. `/src/lib/utils/text-formatter.ts` - Fixed style application to all text elements | |
| 28 | + | 5. `/src/lib/states/chat.svelte.ts` - Added textStyle parameter to sendMessage | |
| 29 | + | 6. `/src/lib/types/payloads.ts` - Added styleData to SendMessageRequest | |
| 30 | + | 7. `/src/routes/api/chat/messages/+server.ts` - Added styleData handling in message creation | |
| 31 | + | ||
| 32 | + | ## How It Works Now: | |
| 33 | + | ||
| 34 | + | 1. When a user selects colors/gradients and formatting options in the toolbar, the `currentTextStyle` state is updated | |
| 35 | + | 2. When sending a message, the style data is serialized and sent along with the message content | |
| 36 | + | 3. The server saves the style data with the message in the database | |
| 37 | + | 4. When messages are displayed, the `FormattedMessage` component applies the saved styles | |
| 38 | + | 5. Gradients are rendered character-by-character for the classic AOL effect | |
| 39 | + | 6. The input field shows a gradient indicator bar when gradient mode is active | |
| 40 | + | ||
| 41 | + | ## Testing: | |
| 42 | + | ||
| 43 | + | To test the fixes: | |
| 44 | + | 1. Open the chat window | |
| 45 | + | 2. Select a gradient using the color picker (add 2-3 colors) | |
| 46 | + | 3. Choose a font and adjust size | |
| 47 | + | 4. Type a message - you should see the first gradient color in the input | |
| 48 | + | 5. Send the message - it should display with the full gradient effect | |
| 49 | + | 6. Other users should see your message with your chosen formatting⏎̸ | |