Merge pull request #59 from kitpdr/fix/chat-ui-improvements
fix(chat): improve UI/UX with validation, persistence and user list redesign
10 files · +478 -105
M package.json +1 -0
| @@ -56,6 +56,7 @@ | |||
| 56 | 56 | }, | |
| 57 | 57 | "dependencies": { | |
| 58 | 58 | "@libsql/client": "^0.15.15", | |
| 59 | + | "@lucide/svelte": "^0.561.0", | |
| 59 | 60 | "dotenv": "^16.6.1", | |
| 60 | 61 | "drizzle-orm": "^0.44.7", | |
| 61 | 62 | "pino": "^9.14.0", | |
| @@ -56,6 +56,7 @@ | |||||
| 56 | }, | 56 | }, | ||
| 57 | "dependencies": { | 57 | "dependencies": { | ||
| 58 | "@libsql/client": "^0.15.15", | 58 | "@libsql/client": "^0.15.15", | ||
| 59 | + | "@lucide/svelte": "^0.561.0", | |||
| 59 | "dotenv": "^16.6.1", | 60 | "dotenv": "^16.6.1", | ||
| 60 | "drizzle-orm": "^0.44.7", | 61 | "drizzle-orm": "^0.44.7", | ||
| 61 | "pino": "^9.14.0", | 62 | "pino": "^9.14.0", | ||
M pnpm-lock.yaml +12 -0
| @@ -11,6 +11,9 @@ importers: | |||
| 11 | 11 | '@libsql/client': | |
| 12 | 12 | specifier: ^0.15.15 | |
| 13 | 13 | version: 0.15.15 | |
| 14 | + | '@lucide/svelte': | |
| 15 | + | specifier: ^0.561.0 | |
| 16 | + | version: 0.561.0(svelte@5.43.14) | |
| 14 | 17 | dotenv: | |
| 15 | 18 | specifier: ^16.6.1 | |
| 16 | 19 | version: 16.6.1 | |
| @@ -957,6 +960,11 @@ packages: | |||
| 957 | 960 | cpu: [x64] | |
| 958 | 961 | os: [win32] | |
| 959 | 962 | ||
| 963 | + | '@lucide/svelte@0.561.0': | |
| 964 | + | resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} | |
| 965 | + | peerDependencies: | |
| 966 | + | svelte: ^5 | |
| 967 | + | ||
| 960 | 968 | '@neon-rs/load@0.0.4': | |
| 961 | 969 | resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} | |
| 962 | 970 | ||
| @@ -3693,6 +3701,10 @@ snapshots: | |||
| 3693 | 3701 | '@libsql/win32-x64-msvc@0.5.22': | |
| 3694 | 3702 | optional: true | |
| 3695 | 3703 | ||
| 3704 | + | '@lucide/svelte@0.561.0(svelte@5.43.14)': | |
| 3705 | + | dependencies: | |
| 3706 | + | svelte: 5.43.14 | |
| 3707 | + | ||
| 3696 | 3708 | '@neon-rs/load@0.0.4': {} | |
| 3697 | 3709 | ||
| 3698 | 3710 | '@nodelib/fs.scandir@2.1.5': | |
| @@ -11,6 +11,9 @@ importers: | |||||
| 11 | '@libsql/client': | 11 | '@libsql/client': | ||
| 12 | specifier: ^0.15.15 | 12 | specifier: ^0.15.15 | ||
| 13 | version: 0.15.15 | 13 | version: 0.15.15 | ||
| 14 | + | '@lucide/svelte': | |||
| 15 | + | specifier: ^0.561.0 | |||
| 16 | + | version: 0.561.0(svelte@5.43.14) | |||
| 14 | dotenv: | 17 | dotenv: | ||
| 15 | specifier: ^16.6.1 | 18 | specifier: ^16.6.1 | ||
| 16 | version: 16.6.1 | 19 | version: 16.6.1 | ||
| @@ -957,6 +960,11 @@ packages: | |||||
| 957 | cpu: [x64] | 960 | cpu: [x64] | ||
| 958 | os: [win32] | 961 | os: [win32] | ||
| 959 | 962 | ||||
| 963 | + | '@lucide/svelte@0.561.0': | |||
| 964 | + | resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} | |||
| 965 | + | peerDependencies: | |||
| 966 | + | svelte: ^5 | |||
| 967 | + | ||||
| 960 | '@neon-rs/load@0.0.4': | 968 | '@neon-rs/load@0.0.4': | ||
| 961 | resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} | 969 | resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} | ||
| 962 | 970 | ||||
| @@ -3693,6 +3701,10 @@ snapshots: | |||||
| 3693 | '@libsql/win32-x64-msvc@0.5.22': | 3701 | '@libsql/win32-x64-msvc@0.5.22': | ||
| 3694 | optional: true | 3702 | optional: true | ||
| 3695 | 3703 | ||||
| 3704 | + | '@lucide/svelte@0.561.0(svelte@5.43.14)': | |||
| 3705 | + | dependencies: | |||
| 3706 | + | svelte: 5.43.14 | |||
| 3707 | + | ||||
| 3696 | '@neon-rs/load@0.0.4': {} | 3708 | '@neon-rs/load@0.0.4': {} | ||
| 3697 | 3709 | ||||
| 3698 | '@nodelib/fs.scandir@2.1.5': | 3710 | '@nodelib/fs.scandir@2.1.5': | ||
M src/app.css +13 -5
| @@ -22,6 +22,8 @@ | |||
| 22 | 22 | --accent-foreground: hsl(240 5.9% 10%); | |
| 23 | 23 | --destructive: hsl(0 72.2% 50.6%); | |
| 24 | 24 | --destructive-foreground: hsl(0 0% 98%); | |
| 25 | + | --warning: hsl(25 95% 53%); | |
| 26 | + | --warning-foreground: hsl(0 0% 98%); | |
| 25 | 27 | --ring: hsl(240 10% 3.9%); | |
| 26 | 28 | --sidebar: hsl(0 0% 98%); | |
| 27 | 29 | --sidebar-foreground: hsl(240 5.3% 26.1%); | |
| @@ -54,6 +56,8 @@ | |||
| 54 | 56 | --accent-foreground: hsl(0 0% 98%); | |
| 55 | 57 | --destructive: hsl(0 62.8% 30.6%); | |
| 56 | 58 | --destructive-foreground: hsl(0 0% 98%); | |
| 59 | + | --warning: hsl(25 95% 63%); | |
| 60 | + | --warning-foreground: hsl(0 0% 98%); | |
| 57 | 61 | --ring: hsl(240 4.9% 83.9%); | |
| 58 | 62 | --sidebar: hsl(240 5.9% 10%); | |
| 59 | 63 | --sidebar-foreground: hsl(240 4.8% 95.9%); | |
| @@ -91,6 +95,8 @@ | |||
| 91 | 95 | --color-accent-foreground: var(--accent-foreground); | |
| 92 | 96 | --color-destructive: var(--destructive); | |
| 93 | 97 | --color-destructive-foreground: var(--destructive-foreground); | |
| 98 | + | --color-warning: var(--warning); | |
| 99 | + | --color-warning-foreground: var(--warning-foreground); | |
| 94 | 100 | --color-ring: var(--ring); | |
| 95 | 101 | --color-radius: var(--radius); | |
| 96 | 102 | --color-sidebar: var(--sidebar); | |
| @@ -327,21 +333,23 @@ | |||
| 327 | 333 | word-spacing: normal; | |
| 328 | 334 | } | |
| 329 | 335 | ||
| 330 | - | /* Gradient text container uses flexbox */ | |
| 336 | + | /* Gradient text container uses inline display for proper text alignment */ | |
| 331 | 337 | .gradient-text-static { | |
| 332 | - | display: inline-flex !important; | |
| 333 | - | flex-wrap: wrap !important; | |
| 334 | - | align-items: baseline !important; | |
| 338 | + | display: inline !important; | |
| 339 | + | vertical-align: baseline !important; | |
| 340 | + | line-height: inherit !important; | |
| 335 | 341 | } | |
| 336 | 342 | ||
| 337 | 343 | /* Ensure gradient character spans don't add spacing */ | |
| 338 | - | .gradient-text-static > span { | |
| 344 | + | .gradient-text-static > span, | |
| 345 | + | .gradient-text-static .gradient-char { | |
| 339 | 346 | display: inline !important; | |
| 340 | 347 | margin: 0 !important; | |
| 341 | 348 | padding: 0 !important; | |
| 342 | 349 | letter-spacing: 0 !important; | |
| 343 | 350 | line-height: inherit !important; | |
| 344 | 351 | font-size: inherit !important; | |
| 352 | + | vertical-align: baseline !important; | |
| 345 | 353 | } | |
| 346 | 354 | ||
| 347 | 355 | /* Ensure gradient character colors from inline styles are respected */ | |
| @@ -22,6 +22,8 @@ | |||||
| 22 | --accent-foreground: hsl(240 5.9% 10%); | 22 | --accent-foreground: hsl(240 5.9% 10%); | ||
| 23 | --destructive: hsl(0 72.2% 50.6%); | 23 | --destructive: hsl(0 72.2% 50.6%); | ||
| 24 | --destructive-foreground: hsl(0 0% 98%); | 24 | --destructive-foreground: hsl(0 0% 98%); | ||
| 25 | + | --warning: hsl(25 95% 53%); | |||
| 26 | + | --warning-foreground: hsl(0 0% 98%); | |||
| 25 | --ring: hsl(240 10% 3.9%); | 27 | --ring: hsl(240 10% 3.9%); | ||
| 26 | --sidebar: hsl(0 0% 98%); | 28 | --sidebar: hsl(0 0% 98%); | ||
| 27 | --sidebar-foreground: hsl(240 5.3% 26.1%); | 29 | --sidebar-foreground: hsl(240 5.3% 26.1%); | ||
| @@ -54,6 +56,8 @@ | |||||
| 54 | --accent-foreground: hsl(0 0% 98%); | 56 | --accent-foreground: hsl(0 0% 98%); | ||
| 55 | --destructive: hsl(0 62.8% 30.6%); | 57 | --destructive: hsl(0 62.8% 30.6%); | ||
| 56 | --destructive-foreground: hsl(0 0% 98%); | 58 | --destructive-foreground: hsl(0 0% 98%); | ||
| 59 | + | --warning: hsl(25 95% 63%); | |||
| 60 | + | --warning-foreground: hsl(0 0% 98%); | |||
| 57 | --ring: hsl(240 4.9% 83.9%); | 61 | --ring: hsl(240 4.9% 83.9%); | ||
| 58 | --sidebar: hsl(240 5.9% 10%); | 62 | --sidebar: hsl(240 5.9% 10%); | ||
| 59 | --sidebar-foreground: hsl(240 4.8% 95.9%); | 63 | --sidebar-foreground: hsl(240 4.8% 95.9%); | ||
| @@ -91,6 +95,8 @@ | |||||
| 91 | --color-accent-foreground: var(--accent-foreground); | 95 | --color-accent-foreground: var(--accent-foreground); | ||
| 92 | --color-destructive: var(--destructive); | 96 | --color-destructive: var(--destructive); | ||
| 93 | --color-destructive-foreground: var(--destructive-foreground); | 97 | --color-destructive-foreground: var(--destructive-foreground); | ||
| 98 | + | --color-warning: var(--warning); | |||
| 99 | + | --color-warning-foreground: var(--warning-foreground); | |||
| 94 | --color-ring: var(--ring); | 100 | --color-ring: var(--ring); | ||
| 95 | --color-radius: var(--radius); | 101 | --color-radius: var(--radius); | ||
| 96 | --color-sidebar: var(--sidebar); | 102 | --color-sidebar: var(--sidebar); | ||
| @@ -327,21 +333,23 @@ | |||||
| 327 | word-spacing: normal; | 333 | word-spacing: normal; | ||
| 328 | } | 334 | } | ||
| 329 | 335 | ||||
| 330 | - | /* Gradient text container uses flexbox */ | 336 | + | /* Gradient text container uses inline display for proper text alignment */ |
| 331 | .gradient-text-static { | 337 | .gradient-text-static { | ||
| 332 | - | display: inline-flex !important; | 338 | + | display: inline !important; |
| 333 | - | flex-wrap: wrap !important; | 339 | + | vertical-align: baseline !important; |
| 334 | - | align-items: baseline !important; | 340 | + | line-height: inherit !important; |
| 335 | } | 341 | } | ||
| 336 | 342 | ||||
| 337 | /* Ensure gradient character spans don't add spacing */ | 343 | /* Ensure gradient character spans don't add spacing */ | ||
| 338 | - | .gradient-text-static > span { | 344 | + | .gradient-text-static > span, |
| 345 | + | .gradient-text-static .gradient-char { | |||
| 339 | display: inline !important; | 346 | display: inline !important; | ||
| 340 | margin: 0 !important; | 347 | margin: 0 !important; | ||
| 341 | padding: 0 !important; | 348 | padding: 0 !important; | ||
| 342 | letter-spacing: 0 !important; | 349 | letter-spacing: 0 !important; | ||
| 343 | line-height: inherit !important; | 350 | line-height: inherit !important; | ||
| 344 | font-size: inherit !important; | 351 | font-size: inherit !important; | ||
| 352 | + | vertical-align: baseline !important; | |||
| 345 | } | 353 | } | ||
| 346 | 354 | ||||
| 347 | /* Ensure gradient character colors from inline styles are respected */ | 355 | /* Ensure gradient character colors from inline styles are respected */ | ||
M src/lib/components/chat-room.svelte +275 -83
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | - | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 3 | + | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 4 | 4 | import { onMount } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| 6 | 6 | import { draggable } from '$lib/actions/draggable'; | |
| @@ -19,13 +19,15 @@ | |||
| 19 | 19 | generateInputCSSStyle | |
| 20 | 20 | } from '../types/text-formatting'; | |
| 21 | 21 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | |
| 22 | + | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | |
| 23 | + | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 22 | 24 | ||
| 23 | 25 | // Props destructuring must come first | |
| 24 | 26 | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | |
| 25 | 27 | ||
| 26 | - | // Initialize with default values for SSR | |
| 27 | - | let windowWidth = $state(400); | |
| 28 | - | let windowHeight = $state(500); | |
| 28 | + | // Initialize with default values for SSR (800x600 - 16:10 aspect ratio) | |
| 29 | + | let windowWidth = $state(800); | |
| 30 | + | let windowHeight = $state(600); | |
| 29 | 31 | let windowX = $state(0); | |
| 30 | 32 | let windowY = $state(0); | |
| 31 | 33 | let isMobile = $state(false); | |
| @@ -35,6 +37,25 @@ | |||
| 35 | 37 | let isInitialLoading = $state(true); | |
| 36 | 38 | let showAuth = $state(false); | |
| 37 | 39 | let isMinimized = $state(false); | |
| 40 | + | let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned | |
| 41 | + | let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync | |
| 42 | + | ||
| 43 | + | // Handle input changes - truncate and sync scroll | |
| 44 | + | function handleInputChange(e: Event) { | |
| 45 | + | const input = e.target as HTMLInputElement; | |
| 46 | + | // Truncate if over limit (handles paste) | |
| 47 | + | if (input.value.length > MAX_MESSAGE_LENGTH) { | |
| 48 | + | input.value = input.value.slice(0, MAX_MESSAGE_LENGTH); | |
| 49 | + | currentMessage = input.value; | |
| 50 | + | } | |
| 51 | + | // Sync scroll position for gradient overlay | |
| 52 | + | inputScrollLeft = input.scrollLeft; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function handleInputScroll(e: Event) { | |
| 56 | + | const input = e.target as HTMLInputElement; | |
| 57 | + | inputScrollLeft = input.scrollLeft; | |
| 58 | + | } | |
| 38 | 59 | ||
| 39 | 60 | // Text formatting state - initialize directly with merged initial style | |
| 40 | 61 | let currentTextStyle = $state<TextStyle>({ | |
| @@ -52,6 +73,9 @@ | |||
| 52 | 73 | // At the top along with other variable declarations, add: | |
| 53 | 74 | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | |
| 54 | 75 | ||
| 76 | + | // Debounced save function for window state persistence | |
| 77 | + | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 78 | + | ||
| 55 | 79 | function updateCooldownProgress() { | |
| 56 | 80 | if (!cooldownEndTime) return; | |
| 57 | 81 | ||
| @@ -105,6 +129,17 @@ | |||
| 105 | 129 | // Add a derived state to check if we're showing the registration prompt | |
| 106 | 130 | let showRegistrationPrompt = $derived(!currentUser && messages.length > 50); | |
| 107 | 131 | ||
| 132 | + | // Character counter - show when approaching limit (80%+) | |
| 133 | + | const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars | |
| 134 | + | let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD); | |
| 135 | + | let charCounterClass = $derived( | |
| 136 | + | currentMessage.length >= MAX_MESSAGE_LENGTH | |
| 137 | + | ? 'at-limit' | |
| 138 | + | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | |
| 139 | + | ? 'near-limit' | |
| 140 | + | : 'warning' | |
| 141 | + | ); | |
| 142 | + | ||
| 108 | 143 | // Add SSE error state | |
| 109 | 144 | let sseError = $state<string | null>(null); | |
| 110 | 145 | let sseRetryAfter = $state<number | null>(null); | |
| @@ -194,23 +229,87 @@ | |||
| 194 | 229 | onMount(() => { | |
| 195 | 230 | if (!browser) return; | |
| 196 | 231 | ||
| 232 | + | // Detect mobile first | |
| 233 | + | isMobile = window.innerWidth <= 768; | |
| 234 | + | ||
| 235 | + | // Load saved state from localStorage (only for desktop) | |
| 236 | + | if (!isMobile) { | |
| 237 | + | const savedState = loadWindowState(); | |
| 238 | + | // Only apply saved position/size if they're within current viewport bounds | |
| 239 | + | const maxWidth = window.innerWidth - 40; | |
| 240 | + | const maxHeight = window.innerHeight - 40; | |
| 241 | + | ||
| 242 | + | windowWidth = Math.min(savedState.width, maxWidth); | |
| 243 | + | windowHeight = Math.min(savedState.height, maxHeight); | |
| 244 | + | ||
| 245 | + | // Handle centering: if x or y is -1, center the window | |
| 246 | + | if (savedState.x === -1) { | |
| 247 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 248 | + | } else { | |
| 249 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 250 | + | } | |
| 251 | + | if (savedState.y === -1) { | |
| 252 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 253 | + | } else { | |
| 254 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | isMaximized = savedState.isMaximized; | |
| 258 | + | isMinimized = savedState.isMinimized; | |
| 259 | + | showUserList = savedState.showUserList; | |
| 260 | + | isCentered = savedState.isCentered; | |
| 261 | + | } | |
| 262 | + | ||
| 197 | 263 | const handleResize = () => { | |
| 264 | + | const wasMobile = isMobile; | |
| 198 | 265 | isMobile = window.innerWidth <= 768; | |
| 199 | 266 | if (isMobile) { | |
| 200 | 267 | windowWidth = window.innerWidth; | |
| 201 | 268 | windowHeight = window.innerHeight; | |
| 202 | 269 | windowX = 0; | |
| 203 | 270 | windowY = 0; | |
| 204 | - | } else { | |
| 205 | - | windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8)); | |
| 206 | - | windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8)); | |
| 207 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 208 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 271 | + | } else if (wasMobile && !isMobile) { | |
| 272 | + | // Switching from mobile to desktop - reload saved state | |
| 273 | + | const savedState = loadWindowState(); | |
| 274 | + | const maxWidth = window.innerWidth - 40; | |
| 275 | + | const maxHeight = window.innerHeight - 40; | |
| 276 | + | windowWidth = Math.min(savedState.width, maxWidth); | |
| 277 | + | windowHeight = Math.min(savedState.height, maxHeight); | |
| 278 | + | ||
| 279 | + | // Handle centering | |
| 280 | + | if (savedState.x === -1) { | |
| 281 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 282 | + | } else { | |
| 283 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 284 | + | } | |
| 285 | + | if (savedState.y === -1) { | |
| 286 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 287 | + | } else { | |
| 288 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 289 | + | } | |
| 290 | + | } else if (!isMobile) { | |
| 291 | + | // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds | |
| 292 | + | const maxWidth = window.innerWidth - 40; | |
| 293 | + | const maxHeight = window.innerHeight - 40; | |
| 294 | + | windowWidth = Math.min(windowWidth, maxWidth); | |
| 295 | + | windowHeight = Math.min(windowHeight, maxHeight); | |
| 296 | + | ||
| 297 | + | if (isCentered) { | |
| 298 | + | // Keep window centered | |
| 299 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 300 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 301 | + | } else { | |
| 302 | + | // Just keep window within bounds | |
| 303 | + | windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth)); | |
| 304 | + | windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight)); | |
| 305 | + | } | |
| 209 | 306 | } | |
| 210 | 307 | }; | |
| 211 | 308 | ||
| 212 | - | // Initial resize | |
| 213 | - | handleResize(); | |
| 309 | + | // Only run handleResize if on mobile (desktop already loaded from localStorage) | |
| 310 | + | if (isMobile) { | |
| 311 | + | handleResize(); | |
| 312 | + | } | |
| 214 | 313 | ||
| 215 | 314 | // Add resize listener | |
| 216 | 315 | window.addEventListener('resize', handleResize); | |
| @@ -248,6 +347,23 @@ | |||
| 248 | 347 | }; | |
| 249 | 348 | }); | |
| 250 | 349 | ||
| 350 | + | // Save window state to localStorage when it changes (desktop only) | |
| 351 | + | $effect(() => { | |
| 352 | + | if (!browser || isMobile) return; | |
| 353 | + | ||
| 354 | + | // Save current state (debounced) | |
| 355 | + | debouncedSaveWindowState({ | |
| 356 | + | width: windowWidth, | |
| 357 | + | height: windowHeight, | |
| 358 | + | x: windowX, | |
| 359 | + | y: windowY, | |
| 360 | + | isMaximized, | |
| 361 | + | isMinimized, | |
| 362 | + | showUserList, | |
| 363 | + | isCentered | |
| 364 | + | }); | |
| 365 | + | }); | |
| 366 | + | ||
| 251 | 367 | // New reactive state to track when a message is being sent | |
| 252 | 368 | let isSendingMessage = $state(false); | |
| 253 | 369 | ||
| @@ -276,22 +392,11 @@ | |||
| 276 | 392 | } | |
| 277 | 393 | } | |
| 278 | 394 | ||
| 279 | - | function getStatusIcon(status: User['status']) { | |
| 280 | - | switch (status) { | |
| 281 | - | case 'online': | |
| 282 | - | return '🟢'; | |
| 283 | - | case 'away': | |
| 284 | - | return '🌙'; | |
| 285 | - | case 'busy': | |
| 286 | - | return '🔴'; | |
| 287 | - | default: | |
| 288 | - | return '⚫'; | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | ||
| 292 | 395 | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |
| 293 | 396 | windowX = event.detail.x; | |
| 294 | 397 | windowY = event.detail.y; | |
| 398 | + | // User manually moved the window, no longer keep it centered on resize | |
| 399 | + | isCentered = false; | |
| 295 | 400 | } | |
| 296 | 401 | ||
| 297 | 402 | interface MaximizableNode extends HTMLElement { | |
| @@ -418,6 +523,10 @@ | |||
| 418 | 523 | // Add derived state for total users count | |
| 419 | 524 | let totalUsers = $derived(onlineUsers.length); | |
| 420 | 525 | ||
| 526 | + | // Split users into online and offline groups | |
| 527 | + | let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |
| 528 | + | let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |
| 529 | + | ||
| 421 | 530 | function openSignup() { | |
| 422 | 531 | showAuth = true; | |
| 423 | 532 | } | |
| @@ -603,16 +712,52 @@ | |||
| 603 | 712 | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | |
| 604 | 713 | <div | |
| 605 | 714 | class="gradient-input-wrapper" | |
| 606 | - | style="flex: 1; position: relative; background: white;" | |
| 715 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" | |
| 607 | 716 | > | |
| 717 | + | <!-- Gradient text overlay that syncs with input scroll --> | |
| 718 | + | <span | |
| 719 | + | class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}" | |
| 720 | + | style=" | |
| 721 | + | position: absolute; | |
| 722 | + | left: 3px; | |
| 723 | + | top: 50%; | |
| 724 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); | |
| 725 | + | pointer-events: none; | |
| 726 | + | white-space: nowrap; | |
| 727 | + | font-size: {currentTextStyle.fontSize}px; | |
| 728 | + | {currentTextStyle.bold | |
| 729 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 730 | + | ? 'font-weight: 200;' | |
| 731 | + | : 'font-weight: 700;' | |
| 732 | + | : ''} | |
| 733 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |
| 734 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 735 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 736 | + | : ''} | |
| 737 | + | background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')}); | |
| 738 | + | -webkit-background-clip: text; | |
| 739 | + | -webkit-text-fill-color: transparent; | |
| 740 | + | background-clip: text; | |
| 741 | + | " | |
| 742 | + | aria-hidden="true">{currentMessage}</span | |
| 743 | + | > | |
| 608 | 744 | <input | |
| 609 | 745 | type="text" | |
| 610 | 746 | bind:value={currentMessage} | |
| 747 | + | maxlength={MAX_MESSAGE_LENGTH} | |
| 611 | 748 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | |
| 612 | - | style="width: 100%; background: transparent; {generateInputCSSStyle( | |
| 613 | - | currentTextStyle | |
| 614 | - | )}" | |
| 749 | + | style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold | |
| 750 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 751 | + | ? 'font-weight: 200;' | |
| 752 | + | : 'font-weight: 700;' | |
| 753 | + | : ''} {currentTextStyle.italic | |
| 754 | + | ? 'font-style: italic;' | |
| 755 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 756 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 757 | + | : ''}" | |
| 615 | 758 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 759 | + | oninput={handleInputChange} | |
| 760 | + | onscroll={handleInputScroll} | |
| 616 | 761 | placeholder={cooldownEndTime | |
| 617 | 762 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 618 | 763 | : 'Écrivez un message...'} | |
| @@ -623,6 +768,7 @@ | |||
| 623 | 768 | <input | |
| 624 | 769 | type="text" | |
| 625 | 770 | bind:value={currentMessage} | |
| 771 | + | maxlength={MAX_MESSAGE_LENGTH} | |
| 626 | 772 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | |
| 627 | 773 | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | |
| 628 | 774 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| @@ -638,6 +784,11 @@ | |||
| 638 | 784 | loading={isSendingMessage} | |
| 639 | 785 | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'} | |
| 640 | 786 | /> | |
| 787 | + | {#if showCharCounter} | |
| 788 | + | <span class="char-counter {charCounterClass}"> | |
| 789 | + | {currentMessage.length}/{MAX_MESSAGE_LENGTH} | |
| 790 | + | </span> | |
| 791 | + | {/if} | |
| 641 | 792 | </div> | |
| 642 | 793 | {#if cooldownEndTime} | |
| 643 | 794 | <div | |
| @@ -655,30 +806,33 @@ | |||
| 655 | 806 | class:hidden={isMobile && !showUserList} | |
| 656 | 807 | style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;" | |
| 657 | 808 | > | |
| 658 | - | <p style="margin: 0 0 0.3rem 0;"> | |
| 659 | - | <strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong> | |
| 660 | - | </p> | |
| 661 | - | {#each onlineUsers as user (user.id)} | |
| 662 | - | <div class="user" class:offline={user.status === 'offline'}> | |
| 663 | - | <span class="status-icon">{getStatusIcon(user.status)}</span> | |
| 664 | - | <div class="user-info"> | |
| 665 | - | {#if user.status === 'offline'} | |
| 666 | - | <Tooltip | |
| 667 | - | data={{ | |
| 668 | - | text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen), | |
| 669 | - | direction: 'bottom', | |
| 670 | - | closeDelay: 1000, | |
| 671 | - | touchBehavior: 'remove' | |
| 672 | - | }} | |
| 673 | - | > | |
| 674 | - | <div class="nickname select-none">{user.nickname}</div> | |
| 675 | - | </Tooltip> | |
| 676 | - | {:else} | |
| 677 | - | <div class="nickname select-none">{user.nickname}</div> | |
| 678 | - | {/if} | |
| 809 | + | <!-- Online users section --> | |
| 810 | + | {#if usersOnline.length > 0} | |
| 811 | + | <p class="section-header">En ligne ({usersOnline.length})</p> | |
| 812 | + | {#each usersOnline as user (user.id)} | |
| 813 | + | <div class="user">{user.nickname}</div> | |
| 814 | + | {/each} | |
| 815 | + | {/if} | |
| 816 | + | ||
| 817 | + | <!-- Offline users section --> | |
| 818 | + | {#if usersOffline.length > 0} | |
| 819 | + | <div class="section-separator"></div> | |
| 820 | + | <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p> | |
| 821 | + | {#each usersOffline as user (user.id)} | |
| 822 | + | <div class="user offline"> | |
| 823 | + | <Tooltip | |
| 824 | + | data={{ | |
| 825 | + | text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen), | |
| 826 | + | direction: 'bottom', | |
| 827 | + | closeDelay: 1000, | |
| 828 | + | touchBehavior: 'remove' | |
| 829 | + | }} | |
| 830 | + | > | |
| 831 | + | <span class="nickname">{user.nickname}</span> | |
| 832 | + | </Tooltip> | |
| 679 | 833 | </div> | |
| 680 | - | </div> | |
| 681 | - | {/each} | |
| 834 | + | {/each} | |
| 835 | + | {/if} | |
| 682 | 836 | </div> | |
| 683 | 837 | </div> | |
| 684 | 838 | {/if} | |
| @@ -738,35 +892,41 @@ | |||
| 738 | 892 | .message { | |
| 739 | 893 | margin-bottom: 0.25rem; | |
| 740 | 894 | word-break: break-word; | |
| 741 | - | display: flex; | |
| 742 | - | gap: 0.5rem; | |
| 743 | - | align-items: flex-start; | |
| 895 | + | line-height: 1.4; | |
| 744 | 896 | } | |
| 745 | 897 | ||
| 746 | - | /* Fix gradient text spacing in messages */ | |
| 898 | + | /* Fix gradient text alignment in messages */ | |
| 747 | 899 | .message :global(.gradient-text-static) { | |
| 748 | - | letter-spacing: normal !important; | |
| 749 | - | word-spacing: normal !important; | |
| 900 | + | display: inline; | |
| 901 | + | vertical-align: baseline; | |
| 902 | + | line-height: inherit; | |
| 750 | 903 | } | |
| 751 | 904 | ||
| 752 | - | .message :global(.gradient-text-static span) { | |
| 753 | - | letter-spacing: 0 !important; | |
| 905 | + | .message :global(.gradient-char) { | |
| 906 | + | display: inline; | |
| 907 | + | vertical-align: baseline; | |
| 908 | + | line-height: inherit; | |
| 754 | 909 | } | |
| 755 | 910 | ||
| 756 | 911 | .message .nickname { | |
| 912 | + | display: inline; | |
| 757 | 913 | font-weight: bold; | |
| 758 | 914 | color: #2d31a6; | |
| 759 | 915 | cursor: help; | |
| 760 | - | flex-shrink: 0; | |
| 916 | + | margin-right: 0.25rem; | |
| 917 | + | vertical-align: baseline; | |
| 761 | 918 | } | |
| 762 | 919 | ||
| 763 | 920 | .message-content { | |
| 764 | - | flex: 1; | |
| 765 | - | min-width: 0; | |
| 921 | + | display: inline; | |
| 922 | + | vertical-align: baseline; | |
| 766 | 923 | } | |
| 767 | 924 | ||
| 768 | - | .message .content { | |
| 769 | - | flex: 1; | |
| 925 | + | /* Ensure formatted message content aligns properly with nickname */ | |
| 926 | + | .message-content :global(.formatted-message), | |
| 927 | + | .message-content :global(.gradient-text-static) { | |
| 928 | + | display: inline; | |
| 929 | + | vertical-align: baseline; | |
| 770 | 930 | } | |
| 771 | 931 | ||
| 772 | 932 | .message.emote { | |
| @@ -778,36 +938,40 @@ | |||
| 778 | 938 | color: #666; | |
| 779 | 939 | } | |
| 780 | 940 | ||
| 781 | - | .user { | |
| 782 | - | display: flex; | |
| 783 | - | align-items: flex-start; | |
| 784 | - | margin-bottom: 0.25rem; | |
| 785 | - | gap: 0.375rem; | |
| 786 | - | transition: opacity 0.3s ease; | |
| 941 | + | .section-header { | |
| 942 | + | margin: 0 0 0.25rem 0; | |
| 943 | + | font-weight: bold; | |
| 944 | + | font-size: 0.75rem; | |
| 945 | + | text-transform: uppercase; | |
| 946 | + | letter-spacing: 0.05em; | |
| 947 | + | color: var(--color-muted-foreground); | |
| 787 | 948 | } | |
| 788 | 949 | ||
| 789 | - | .user.offline { | |
| 790 | - | opacity: 0.6; | |
| 950 | + | .offline-header { | |
| 951 | + | opacity: 0.7; | |
| 791 | 952 | } | |
| 792 | 953 | ||
| 793 | - | .status-icon { | |
| 794 | - | font-size: 0.875rem; | |
| 795 | - | transition: color 0.3s ease; | |
| 954 | + | .section-separator { | |
| 955 | + | border-top: 1px solid var(--color-border); | |
| 956 | + | margin: 0.5rem 0; | |
| 796 | 957 | } | |
| 797 | 958 | ||
| 798 | - | .user-info { | |
| 799 | - | flex: 1; | |
| 800 | - | min-width: 0; | |
| 959 | + | .user { | |
| 960 | + | margin-bottom: 0.125rem; | |
| 961 | + | padding-left: 0.25rem; | |
| 962 | + | line-height: 1.4; | |
| 963 | + | font-size: 0.9rem; | |
| 964 | + | font-weight: normal; | |
| 965 | + | color: var(--color-foreground); | |
| 801 | 966 | } | |
| 802 | 967 | ||
| 803 | - | .user .nickname { | |
| 804 | - | font-weight: bold; | |
| 805 | - | margin-bottom: 0.125rem; | |
| 806 | - | transition: color 0.3s ease; | |
| 968 | + | .user.offline { | |
| 969 | + | color: var(--color-muted-foreground); | |
| 970 | + | font-style: italic; | |
| 807 | 971 | } | |
| 808 | 972 | ||
| 809 | 973 | .user.offline .nickname { | |
| 810 | - | color: #666; | |
| 974 | + | cursor: help; | |
| 811 | 975 | } | |
| 812 | 976 | ||
| 813 | 977 | /* .user .status-message { | |
| @@ -829,6 +993,11 @@ | |||
| 829 | 993 | margin-right: 0.25rem; | |
| 830 | 994 | } | |
| 831 | 995 | ||
| 996 | + | /* Styled input - font is controlled by retro-font-* classes */ | |
| 997 | + | .styled-input { | |
| 998 | + | /* Font family is set by retro-font-* classes, don't override here */ | |
| 999 | + | } | |
| 1000 | + | ||
| 832 | 1001 | button { | |
| 833 | 1002 | font-size: 1rem; | |
| 834 | 1003 | } | |
| @@ -961,6 +1130,29 @@ | |||
| 961 | 1130 | position: relative; | |
| 962 | 1131 | } | |
| 963 | 1132 | ||
| 1133 | + | /* Character counter styles */ | |
| 1134 | + | .char-counter { | |
| 1135 | + | font-size: 0.75rem; | |
| 1136 | + | padding: 0 0.25rem; | |
| 1137 | + | margin-left: 0.25rem; | |
| 1138 | + | white-space: nowrap; | |
| 1139 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | .char-counter.warning { | |
| 1143 | + | color: var(--color-muted-foreground); | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | .char-counter.near-limit { | |
| 1147 | + | color: var(--color-warning); | |
| 1148 | + | font-weight: bold; | |
| 1149 | + | } | |
| 1150 | + | ||
| 1151 | + | .char-counter.at-limit { | |
| 1152 | + | color: var(--color-destructive); | |
| 1153 | + | font-weight: bold; | |
| 1154 | + | } | |
| 1155 | + | ||
| 964 | 1156 | .connection-error { | |
| 965 | 1157 | font-size: 0.75rem; | |
| 966 | 1158 | color: #ff4444; | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { chatState } from '../states/chat.svelte'; | 2 | import { chatState } from '../states/chat.svelte'; | ||
| 3 | - | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | 3 | + | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; |
| 4 | import { onMount } from 'svelte'; | 4 | import { onMount } from 'svelte'; | ||
| 5 | import { browser } from '$app/environment'; | 5 | import { browser } from '$app/environment'; | ||
| 6 | import { draggable } from '$lib/actions/draggable'; | 6 | import { draggable } from '$lib/actions/draggable'; | ||
| @@ -19,13 +19,15 @@ | |||||
| 19 | generateInputCSSStyle | 19 | generateInputCSSStyle | ||
| 20 | } from '../types/text-formatting'; | 20 | } from '../types/text-formatting'; | ||
| 21 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | 21 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | ||
| 22 | + | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | |||
| 23 | + | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |||
| 22 | 24 | ||||
| 23 | // Props destructuring must come first | 25 | // Props destructuring must come first | ||
| 24 | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | 26 | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | ||
| 25 | 27 | ||||
| 26 | - | // Initialize with default values for SSR | 28 | + | // Initialize with default values for SSR (800x600 - 16:10 aspect ratio) |
| 27 | - | let windowWidth = $state(400); | 29 | + | let windowWidth = $state(800); |
| 28 | - | let windowHeight = $state(500); | 30 | + | let windowHeight = $state(600); |
| 29 | let windowX = $state(0); | 31 | let windowX = $state(0); | ||
| 30 | let windowY = $state(0); | 32 | let windowY = $state(0); | ||
| 31 | let isMobile = $state(false); | 33 | let isMobile = $state(false); | ||
| @@ -35,6 +37,25 @@ | |||||
| 35 | let isInitialLoading = $state(true); | 37 | let isInitialLoading = $state(true); | ||
| 36 | let showAuth = $state(false); | 38 | let showAuth = $state(false); | ||
| 37 | let isMinimized = $state(false); | 39 | let isMinimized = $state(false); | ||
| 40 | + | let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned | |||
| 41 | + | let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync | |||
| 42 | + | ||||
| 43 | + | // Handle input changes - truncate and sync scroll | |||
| 44 | + | function handleInputChange(e: Event) { | |||
| 45 | + | const input = e.target as HTMLInputElement; | |||
| 46 | + | // Truncate if over limit (handles paste) | |||
| 47 | + | if (input.value.length > MAX_MESSAGE_LENGTH) { | |||
| 48 | + | input.value = input.value.slice(0, MAX_MESSAGE_LENGTH); | |||
| 49 | + | currentMessage = input.value; | |||
| 50 | + | } | |||
| 51 | + | // Sync scroll position for gradient overlay | |||
| 52 | + | inputScrollLeft = input.scrollLeft; | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | function handleInputScroll(e: Event) { | |||
| 56 | + | const input = e.target as HTMLInputElement; | |||
| 57 | + | inputScrollLeft = input.scrollLeft; | |||
| 58 | + | } | |||
| 38 | 59 | ||||
| 39 | // Text formatting state - initialize directly with merged initial style | 60 | // Text formatting state - initialize directly with merged initial style | ||
| 40 | let currentTextStyle = $state<TextStyle>({ | 61 | let currentTextStyle = $state<TextStyle>({ | ||
| @@ -52,6 +73,9 @@ | |||||
| 52 | // At the top along with other variable declarations, add: | 73 | // At the top along with other variable declarations, add: | ||
| 53 | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | 74 | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | ||
| 54 | 75 | ||||
| 76 | + | // Debounced save function for window state persistence | |||
| 77 | + | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |||
| 78 | + | ||||
| 55 | function updateCooldownProgress() { | 79 | function updateCooldownProgress() { | ||
| 56 | if (!cooldownEndTime) return; | 80 | if (!cooldownEndTime) return; | ||
| 57 | 81 | ||||
| @@ -105,6 +129,17 @@ | |||||
| 105 | // Add a derived state to check if we're showing the registration prompt | 129 | // Add a derived state to check if we're showing the registration prompt | ||
| 106 | let showRegistrationPrompt = $derived(!currentUser && messages.length > 50); | 130 | let showRegistrationPrompt = $derived(!currentUser && messages.length > 50); | ||
| 107 | 131 | ||||
| 132 | + | // Character counter - show when approaching limit (80%+) | |||
| 133 | + | const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars | |||
| 134 | + | let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD); | |||
| 135 | + | let charCounterClass = $derived( | |||
| 136 | + | currentMessage.length >= MAX_MESSAGE_LENGTH | |||
| 137 | + | ? 'at-limit' | |||
| 138 | + | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | |||
| 139 | + | ? 'near-limit' | |||
| 140 | + | : 'warning' | |||
| 141 | + | ); | |||
| 142 | + | ||||
| 108 | // Add SSE error state | 143 | // Add SSE error state | ||
| 109 | let sseError = $state<string | null>(null); | 144 | let sseError = $state<string | null>(null); | ||
| 110 | let sseRetryAfter = $state<number | null>(null); | 145 | let sseRetryAfter = $state<number | null>(null); | ||
| @@ -194,23 +229,87 @@ | |||||
| 194 | onMount(() => { | 229 | onMount(() => { | ||
| 195 | if (!browser) return; | 230 | if (!browser) return; | ||
| 196 | 231 | ||||
| 232 | + | // Detect mobile first | |||
| 233 | + | isMobile = window.innerWidth <= 768; | |||
| 234 | + | ||||
| 235 | + | // Load saved state from localStorage (only for desktop) | |||
| 236 | + | if (!isMobile) { | |||
| 237 | + | const savedState = loadWindowState(); | |||
| 238 | + | // Only apply saved position/size if they're within current viewport bounds | |||
| 239 | + | const maxWidth = window.innerWidth - 40; | |||
| 240 | + | const maxHeight = window.innerHeight - 40; | |||
| 241 | + | ||||
| 242 | + | windowWidth = Math.min(savedState.width, maxWidth); | |||
| 243 | + | windowHeight = Math.min(savedState.height, maxHeight); | |||
| 244 | + | ||||
| 245 | + | // Handle centering: if x or y is -1, center the window | |||
| 246 | + | if (savedState.x === -1) { | |||
| 247 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 248 | + | } else { | |||
| 249 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |||
| 250 | + | } | |||
| 251 | + | if (savedState.y === -1) { | |||
| 252 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 253 | + | } else { | |||
| 254 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | isMaximized = savedState.isMaximized; | |||
| 258 | + | isMinimized = savedState.isMinimized; | |||
| 259 | + | showUserList = savedState.showUserList; | |||
| 260 | + | isCentered = savedState.isCentered; | |||
| 261 | + | } | |||
| 262 | + | ||||
| 197 | const handleResize = () => { | 263 | const handleResize = () => { | ||
| 264 | + | const wasMobile = isMobile; | |||
| 198 | isMobile = window.innerWidth <= 768; | 265 | isMobile = window.innerWidth <= 768; | ||
| 199 | if (isMobile) { | 266 | if (isMobile) { | ||
| 200 | windowWidth = window.innerWidth; | 267 | windowWidth = window.innerWidth; | ||
| 201 | windowHeight = window.innerHeight; | 268 | windowHeight = window.innerHeight; | ||
| 202 | windowX = 0; | 269 | windowX = 0; | ||
| 203 | windowY = 0; | 270 | windowY = 0; | ||
| 204 | - | } else { | 271 | + | } else if (wasMobile && !isMobile) { |
| 205 | - | windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8)); | 272 | + | // Switching from mobile to desktop - reload saved state |
| 206 | - | windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8)); | 273 | + | const savedState = loadWindowState(); |
| 207 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | 274 | + | const maxWidth = window.innerWidth - 40; |
| 208 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | 275 | + | const maxHeight = window.innerHeight - 40; |
| 276 | + | windowWidth = Math.min(savedState.width, maxWidth); | |||
| 277 | + | windowHeight = Math.min(savedState.height, maxHeight); | |||
| 278 | + | ||||
| 279 | + | // Handle centering | |||
| 280 | + | if (savedState.x === -1) { | |||
| 281 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 282 | + | } else { | |||
| 283 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |||
| 284 | + | } | |||
| 285 | + | if (savedState.y === -1) { | |||
| 286 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 287 | + | } else { | |||
| 288 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |||
| 289 | + | } | |||
| 290 | + | } else if (!isMobile) { | |||
| 291 | + | // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds | |||
| 292 | + | const maxWidth = window.innerWidth - 40; | |||
| 293 | + | const maxHeight = window.innerHeight - 40; | |||
| 294 | + | windowWidth = Math.min(windowWidth, maxWidth); | |||
| 295 | + | windowHeight = Math.min(windowHeight, maxHeight); | |||
| 296 | + | ||||
| 297 | + | if (isCentered) { | |||
| 298 | + | // Keep window centered | |||
| 299 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 300 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 301 | + | } else { | |||
| 302 | + | // Just keep window within bounds | |||
| 303 | + | windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth)); | |||
| 304 | + | windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight)); | |||
| 305 | + | } | |||
| 209 | } | 306 | } | ||
| 210 | }; | 307 | }; | ||
| 211 | 308 | ||||
| 212 | - | // Initial resize | 309 | + | // Only run handleResize if on mobile (desktop already loaded from localStorage) |
| 213 | - | handleResize(); | 310 | + | if (isMobile) { |
| 311 | + | handleResize(); | |||
| 312 | + | } | |||
| 214 | 313 | ||||
| 215 | // Add resize listener | 314 | // Add resize listener | ||
| 216 | window.addEventListener('resize', handleResize); | 315 | window.addEventListener('resize', handleResize); | ||
| @@ -248,6 +347,23 @@ | |||||
| 248 | }; | 347 | }; | ||
| 249 | }); | 348 | }); | ||
| 250 | 349 | ||||
| 350 | + | // Save window state to localStorage when it changes (desktop only) | |||
| 351 | + | $effect(() => { | |||
| 352 | + | if (!browser || isMobile) return; | |||
| 353 | + | ||||
| 354 | + | // Save current state (debounced) | |||
| 355 | + | debouncedSaveWindowState({ | |||
| 356 | + | width: windowWidth, | |||
| 357 | + | height: windowHeight, | |||
| 358 | + | x: windowX, | |||
| 359 | + | y: windowY, | |||
| 360 | + | isMaximized, | |||
| 361 | + | isMinimized, | |||
| 362 | + | showUserList, | |||
| 363 | + | isCentered | |||
| 364 | + | }); | |||
| 365 | + | }); | |||
| 366 | + | ||||
| 251 | // New reactive state to track when a message is being sent | 367 | // New reactive state to track when a message is being sent | ||
| 252 | let isSendingMessage = $state(false); | 368 | let isSendingMessage = $state(false); | ||
| 253 | 369 | ||||
| @@ -276,22 +392,11 @@ | |||||
| 276 | } | 392 | } | ||
| 277 | } | 393 | } | ||
| 278 | 394 | ||||
| 279 | - | function getStatusIcon(status: User['status']) { | |||
| 280 | - | switch (status) { | |||
| 281 | - | case 'online': | |||
| 282 | - | return '🟢'; | |||
| 283 | - | case 'away': | |||
| 284 | - | return '🌙'; | |||
| 285 | - | case 'busy': | |||
| 286 | - | return '🔴'; | |||
| 287 | - | default: | |||
| 288 | - | return '⚫'; | |||
| 289 | - | } | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | 395 | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | ||
| 293 | windowX = event.detail.x; | 396 | windowX = event.detail.x; | ||
| 294 | windowY = event.detail.y; | 397 | windowY = event.detail.y; | ||
| 398 | + | // User manually moved the window, no longer keep it centered on resize | |||
| 399 | + | isCentered = false; | |||
| 295 | } | 400 | } | ||
| 296 | 401 | ||||
| 297 | interface MaximizableNode extends HTMLElement { | 402 | interface MaximizableNode extends HTMLElement { | ||
| @@ -418,6 +523,10 @@ | |||||
| 418 | // Add derived state for total users count | 523 | // Add derived state for total users count | ||
| 419 | let totalUsers = $derived(onlineUsers.length); | 524 | let totalUsers = $derived(onlineUsers.length); | ||
| 420 | 525 | ||||
| 526 | + | // Split users into online and offline groups | |||
| 527 | + | let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |||
| 528 | + | let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |||
| 529 | + | ||||
| 421 | function openSignup() { | 530 | function openSignup() { | ||
| 422 | showAuth = true; | 531 | showAuth = true; | ||
| 423 | } | 532 | } | ||
| @@ -603,16 +712,52 @@ | |||||
| 603 | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | 712 | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | ||
| 604 | <div | 713 | <div | ||
| 605 | class="gradient-input-wrapper" | 714 | class="gradient-input-wrapper" | ||
| 606 | - | style="flex: 1; position: relative; background: white;" | 715 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" |
| 607 | > | 716 | > | ||
| 717 | + | <!-- Gradient text overlay that syncs with input scroll --> | |||
| 718 | + | <span | |||
| 719 | + | class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}" | |||
| 720 | + | style=" | |||
| 721 | + | position: absolute; | |||
| 722 | + | left: 3px; | |||
| 723 | + | top: 50%; | |||
| 724 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); | |||
| 725 | + | pointer-events: none; | |||
| 726 | + | white-space: nowrap; | |||
| 727 | + | font-size: {currentTextStyle.fontSize}px; | |||
| 728 | + | {currentTextStyle.bold | |||
| 729 | + | ? currentTextStyle.fontFamily === 'tahoma' | |||
| 730 | + | ? 'font-weight: 200;' | |||
| 731 | + | : 'font-weight: 700;' | |||
| 732 | + | : ''} | |||
| 733 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |||
| 734 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 735 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 736 | + | : ''} | |||
| 737 | + | background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')}); | |||
| 738 | + | -webkit-background-clip: text; | |||
| 739 | + | -webkit-text-fill-color: transparent; | |||
| 740 | + | background-clip: text; | |||
| 741 | + | " | |||
| 742 | + | aria-hidden="true">{currentMessage}</span | |||
| 743 | + | > | |||
| 608 | <input | 744 | <input | ||
| 609 | type="text" | 745 | type="text" | ||
| 610 | bind:value={currentMessage} | 746 | bind:value={currentMessage} | ||
| 747 | + | maxlength={MAX_MESSAGE_LENGTH} | |||
| 611 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | 748 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | ||
| 612 | - | style="width: 100%; background: transparent; {generateInputCSSStyle( | 749 | + | style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold |
| 613 | - | currentTextStyle | 750 | + | ? currentTextStyle.fontFamily === 'tahoma' |
| 614 | - | )}" | 751 | + | ? 'font-weight: 200;' |
| 752 | + | : 'font-weight: 700;' | |||
| 753 | + | : ''} {currentTextStyle.italic | |||
| 754 | + | ? 'font-style: italic;' | |||
| 755 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 756 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 757 | + | : ''}" | |||
| 615 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 758 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | ||
| 759 | + | oninput={handleInputChange} | |||
| 760 | + | onscroll={handleInputScroll} | |||
| 616 | placeholder={cooldownEndTime | 761 | placeholder={cooldownEndTime | ||
| 617 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 762 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | ||
| 618 | : 'Écrivez un message...'} | 763 | : 'Écrivez un message...'} | ||
| @@ -623,6 +768,7 @@ | |||||
| 623 | <input | 768 | <input | ||
| 624 | type="text" | 769 | type="text" | ||
| 625 | bind:value={currentMessage} | 770 | bind:value={currentMessage} | ||
| 771 | + | maxlength={MAX_MESSAGE_LENGTH} | |||
| 626 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | 772 | class="styled-input retro-font-{currentTextStyle.fontFamily}" | ||
| 627 | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | 773 | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | ||
| 628 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 774 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | ||
| @@ -638,6 +784,11 @@ | |||||
| 638 | loading={isSendingMessage} | 784 | loading={isSendingMessage} | ||
| 639 | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'} | 785 | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'} | ||
| 640 | /> | 786 | /> | ||
| 787 | + | {#if showCharCounter} | |||
| 788 | + | <span class="char-counter {charCounterClass}"> | |||
| 789 | + | {currentMessage.length}/{MAX_MESSAGE_LENGTH} | |||
| 790 | + | </span> | |||
| 791 | + | {/if} | |||
| 641 | </div> | 792 | </div> | ||
| 642 | {#if cooldownEndTime} | 793 | {#if cooldownEndTime} | ||
| 643 | <div | 794 | <div | ||
| @@ -655,30 +806,33 @@ | |||||
| 655 | class:hidden={isMobile && !showUserList} | 806 | class:hidden={isMobile && !showUserList} | ||
| 656 | style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;" | 807 | style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;" | ||
| 657 | > | 808 | > | ||
| 658 | - | <p style="margin: 0 0 0.3rem 0;"> | 809 | + | <!-- Online users section --> |
| 659 | - | <strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong> | 810 | + | {#if usersOnline.length > 0} |
| 660 | - | </p> | 811 | + | <p class="section-header">En ligne ({usersOnline.length})</p> |
| 661 | - | {#each onlineUsers as user (user.id)} | 812 | + | {#each usersOnline as user (user.id)} |
| 662 | - | <div class="user" class:offline={user.status === 'offline'}> | 813 | + | <div class="user">{user.nickname}</div> |
| 663 | - | <span class="status-icon">{getStatusIcon(user.status)}</span> | 814 | + | {/each} |
| 664 | - | <div class="user-info"> | 815 | + | {/if} |
| 665 | - | {#if user.status === 'offline'} | 816 | + | |
| 666 | - | <Tooltip | 817 | + | <!-- Offline users section --> |
| 667 | - | data={{ | 818 | + | {#if usersOffline.length > 0} |
| 668 | - | text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen), | 819 | + | <div class="section-separator"></div> |
| 669 | - | direction: 'bottom', | 820 | + | <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p> |
| 670 | - | closeDelay: 1000, | 821 | + | {#each usersOffline as user (user.id)} |
| 671 | - | touchBehavior: 'remove' | 822 | + | <div class="user offline"> |
| 672 | - | }} | 823 | + | <Tooltip |
| 673 | - | > | 824 | + | data={{ |
| 674 | - | <div class="nickname select-none">{user.nickname}</div> | 825 | + | text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen), |
| 675 | - | </Tooltip> | 826 | + | direction: 'bottom', |
| 676 | - | {:else} | 827 | + | closeDelay: 1000, |
| 677 | - | <div class="nickname select-none">{user.nickname}</div> | 828 | + | touchBehavior: 'remove' |
| 678 | - | {/if} | 829 | + | }} |
| 830 | + | > | |||
| 831 | + | <span class="nickname">{user.nickname}</span> | |||
| 832 | + | </Tooltip> | |||
| 679 | </div> | 833 | </div> | ||
| 680 | - | </div> | 834 | + | {/each} |
| 681 | - | {/each} | 835 | + | {/if} |
| 682 | </div> | 836 | </div> | ||
| 683 | </div> | 837 | </div> | ||
| 684 | {/if} | 838 | {/if} | ||
| @@ -738,35 +892,41 @@ | |||||
| 738 | .message { | 892 | .message { | ||
| 739 | margin-bottom: 0.25rem; | 893 | margin-bottom: 0.25rem; | ||
| 740 | word-break: break-word; | 894 | word-break: break-word; | ||
| 741 | - | display: flex; | 895 | + | line-height: 1.4; |
| 742 | - | gap: 0.5rem; | |||
| 743 | - | align-items: flex-start; | |||
| 744 | } | 896 | } | ||
| 745 | 897 | ||||
| 746 | - | /* Fix gradient text spacing in messages */ | 898 | + | /* Fix gradient text alignment in messages */ |
| 747 | .message :global(.gradient-text-static) { | 899 | .message :global(.gradient-text-static) { | ||
| 748 | - | letter-spacing: normal !important; | 900 | + | display: inline; |
| 749 | - | word-spacing: normal !important; | 901 | + | vertical-align: baseline; |
| 902 | + | line-height: inherit; | |||
| 750 | } | 903 | } | ||
| 751 | 904 | ||||
| 752 | - | .message :global(.gradient-text-static span) { | 905 | + | .message :global(.gradient-char) { |
| 753 | - | letter-spacing: 0 !important; | 906 | + | display: inline; |
| 907 | + | vertical-align: baseline; | |||
| 908 | + | line-height: inherit; | |||
| 754 | } | 909 | } | ||
| 755 | 910 | ||||
| 756 | .message .nickname { | 911 | .message .nickname { | ||
| 912 | + | display: inline; | |||
| 757 | font-weight: bold; | 913 | font-weight: bold; | ||
| 758 | color: #2d31a6; | 914 | color: #2d31a6; | ||
| 759 | cursor: help; | 915 | cursor: help; | ||
| 760 | - | flex-shrink: 0; | 916 | + | margin-right: 0.25rem; |
| 917 | + | vertical-align: baseline; | |||
| 761 | } | 918 | } | ||
| 762 | 919 | ||||
| 763 | .message-content { | 920 | .message-content { | ||
| 764 | - | flex: 1; | 921 | + | display: inline; |
| 765 | - | min-width: 0; | 922 | + | vertical-align: baseline; |
| 766 | } | 923 | } | ||
| 767 | 924 | ||||
| 768 | - | .message .content { | 925 | + | /* Ensure formatted message content aligns properly with nickname */ |
| 769 | - | flex: 1; | 926 | + | .message-content :global(.formatted-message), |
| 927 | + | .message-content :global(.gradient-text-static) { | |||
| 928 | + | display: inline; | |||
| 929 | + | vertical-align: baseline; | |||
| 770 | } | 930 | } | ||
| 771 | 931 | ||||
| 772 | .message.emote { | 932 | .message.emote { | ||
| @@ -778,36 +938,40 @@ | |||||
| 778 | color: #666; | 938 | color: #666; | ||
| 779 | } | 939 | } | ||
| 780 | 940 | ||||
| 781 | - | .user { | 941 | + | .section-header { |
| 782 | - | display: flex; | 942 | + | margin: 0 0 0.25rem 0; |
| 783 | - | align-items: flex-start; | 943 | + | font-weight: bold; |
| 784 | - | margin-bottom: 0.25rem; | 944 | + | font-size: 0.75rem; |
| 785 | - | gap: 0.375rem; | 945 | + | text-transform: uppercase; |
| 786 | - | transition: opacity 0.3s ease; | 946 | + | letter-spacing: 0.05em; |
| 947 | + | color: var(--color-muted-foreground); | |||
| 787 | } | 948 | } | ||
| 788 | 949 | ||||
| 789 | - | .user.offline { | 950 | + | .offline-header { |
| 790 | - | opacity: 0.6; | 951 | + | opacity: 0.7; |
| 791 | } | 952 | } | ||
| 792 | 953 | ||||
| 793 | - | .status-icon { | 954 | + | .section-separator { |
| 794 | - | font-size: 0.875rem; | 955 | + | border-top: 1px solid var(--color-border); |
| 795 | - | transition: color 0.3s ease; | 956 | + | margin: 0.5rem 0; |
| 796 | } | 957 | } | ||
| 797 | 958 | ||||
| 798 | - | .user-info { | 959 | + | .user { |
| 799 | - | flex: 1; | 960 | + | margin-bottom: 0.125rem; |
| 800 | - | min-width: 0; | 961 | + | padding-left: 0.25rem; |
| 962 | + | line-height: 1.4; | |||
| 963 | + | font-size: 0.9rem; | |||
| 964 | + | font-weight: normal; | |||
| 965 | + | color: var(--color-foreground); | |||
| 801 | } | 966 | } | ||
| 802 | 967 | ||||
| 803 | - | .user .nickname { | 968 | + | .user.offline { |
| 804 | - | font-weight: bold; | 969 | + | color: var(--color-muted-foreground); |
| 805 | - | margin-bottom: 0.125rem; | 970 | + | font-style: italic; |
| 806 | - | transition: color 0.3s ease; | |||
| 807 | } | 971 | } | ||
| 808 | 972 | ||||
| 809 | .user.offline .nickname { | 973 | .user.offline .nickname { | ||
| 810 | - | color: #666; | 974 | + | cursor: help; |
| 811 | } | 975 | } | ||
| 812 | 976 | ||||
| 813 | /* .user .status-message { | 977 | /* .user .status-message { | ||
| @@ -829,6 +993,11 @@ | |||||
| 829 | margin-right: 0.25rem; | 993 | margin-right: 0.25rem; | ||
| 830 | } | 994 | } | ||
| 831 | 995 | ||||
| 996 | + | /* Styled input - font is controlled by retro-font-* classes */ | |||
| 997 | + | .styled-input { | |||
| 998 | + | /* Font family is set by retro-font-* classes, don't override here */ | |||
| 999 | + | } | |||
| 1000 | + | ||||
| 832 | button { | 1001 | button { | ||
| 833 | font-size: 1rem; | 1002 | font-size: 1rem; | ||
| 834 | } | 1003 | } | ||
| @@ -961,6 +1130,29 @@ | |||||
| 961 | position: relative; | 1130 | position: relative; | ||
| 962 | } | 1131 | } | ||
| 963 | 1132 | ||||
| 1133 | + | /* Character counter styles */ | |||
| 1134 | + | .char-counter { | |||
| 1135 | + | font-size: 0.75rem; | |||
| 1136 | + | padding: 0 0.25rem; | |||
| 1137 | + | margin-left: 0.25rem; | |||
| 1138 | + | white-space: nowrap; | |||
| 1139 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1142 | + | .char-counter.warning { | |||
| 1143 | + | color: var(--color-muted-foreground); | |||
| 1144 | + | } | |||
| 1145 | + | ||||
| 1146 | + | .char-counter.near-limit { | |||
| 1147 | + | color: var(--color-warning); | |||
| 1148 | + | font-weight: bold; | |||
| 1149 | + | } | |||
| 1150 | + | ||||
| 1151 | + | .char-counter.at-limit { | |||
| 1152 | + | color: var(--color-destructive); | |||
| 1153 | + | font-weight: bold; | |||
| 1154 | + | } | |||
| 1155 | + | ||||
| 964 | .connection-error { | 1156 | .connection-error { | ||
| 965 | font-size: 0.75rem; | 1157 | font-size: 0.75rem; | ||
| 966 | color: #ff4444; | 1158 | color: #ff4444; | ||
M src/lib/components/color-picker-256.svelte +1 -1
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | // Get current display color/gradient | |
| 39 | 39 | let displayStyle = $derived(() => { | |
| 40 | 40 | if (gradientColors.length > 1) { | |
| 41 | - | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |
| 41 | + | return `background: linear-gradient(to right, ${gradientColors.join(', ')})`; | |
| 42 | 42 | } | |
| 43 | 43 | return `background: ${gradientColors[0] || '#000000'}`; | |
| 44 | 44 | }); | |
| @@ -38,7 +38,7 @@ | |||||
| 38 | // Get current display color/gradient | 38 | // Get current display color/gradient | ||
| 39 | let displayStyle = $derived(() => { | 39 | let displayStyle = $derived(() => { | ||
| 40 | if (gradientColors.length > 1) { | 40 | if (gradientColors.length > 1) { | ||
| 41 | - | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | 41 | + | return `background: linear-gradient(to right, ${gradientColors.join(', ')})`; |
| 42 | } | 42 | } | ||
| 43 | return `background: ${gradientColors[0] || '#000000'}`; | 43 | return `background: ${gradientColors[0] || '#000000'}`; | ||
| 44 | }); | 44 | }); | ||
M src/lib/types/text-formatting.ts +8 -3
| @@ -51,7 +51,7 @@ export function generate256Colors(): string[] { | |||
| 51 | 51 | // Green hues | |
| 52 | 52 | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | |
| 53 | 53 | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | |
| 54 | - | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | |
| 54 | + | colors.push('#336633', '#669966', '#99CC99'); | |
| 55 | 55 | ||
| 56 | 56 | // Cyan hues | |
| 57 | 57 | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | |
| @@ -219,7 +219,7 @@ export function generateCSSStyle(style: TextStyle): string { | |||
| 219 | 219 | // Color or gradient | |
| 220 | 220 | if (style.gradient && style.gradient.length > 1) { | |
| 221 | 221 | const gradientStops = style.gradient.join(', '); | |
| 222 | - | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | |
| 222 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | |
| 223 | 223 | styles.push(`-webkit-background-clip: text`); | |
| 224 | 224 | styles.push(`-webkit-text-fill-color: transparent`); | |
| 225 | 225 | styles.push(`background-clip: text`); | |
| @@ -261,6 +261,7 @@ export function generateInputCSSStyle(style: TextStyle): string { | |||
| 261 | 261 | styles.push(`font-size: ${style.fontSize}px`); | |
| 262 | 262 | ||
| 263 | 263 | // Color handling for inputs | |
| 264 | + | // IMPORTANT: Always set -webkit-text-fill-color to ensure proper color reset when switching from gradient to solid | |
| 264 | 265 | if (style.gradient && style.gradient.length > 1) { | |
| 265 | 266 | // Apply gradient background with text clipping for gradient effect | |
| 266 | 267 | const gradientStops = style.gradient.join(', '); | |
| @@ -272,12 +273,16 @@ export function generateInputCSSStyle(style: TextStyle): string { | |||
| 272 | 273 | styles.push(`word-spacing: 0`); | |
| 273 | 274 | } else if (style.gradient && style.gradient.length === 1) { | |
| 274 | 275 | // Single color from gradient array | |
| 275 | - | styles.push(`color: ${style.gradient[0]}`); | |
| 276 | + | const color = style.gradient[0]; | |
| 277 | + | styles.push(`color: ${color}`); | |
| 278 | + | styles.push(`-webkit-text-fill-color: ${color}`); | |
| 276 | 279 | } else if (style.color) { | |
| 277 | 280 | styles.push(`color: ${style.color}`); | |
| 281 | + | styles.push(`-webkit-text-fill-color: ${style.color}`); | |
| 278 | 282 | } else { | |
| 279 | 283 | // Fallback to black if no color is set | |
| 280 | 284 | styles.push(`color: #000000`); | |
| 285 | + | styles.push(`-webkit-text-fill-color: #000000`); | |
| 281 | 286 | } | |
| 282 | 287 | ||
| 283 | 288 | // Font weight | |
| @@ -51,7 +51,7 @@ export function generate256Colors(): string[] { | |||||
| 51 | // Green hues | 51 | // Green hues | ||
| 52 | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | 52 | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | ||
| 53 | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | 53 | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | ||
| 54 | - | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | 54 | + | colors.push('#336633', '#669966', '#99CC99'); |
| 55 | 55 | ||||
| 56 | // Cyan hues | 56 | // Cyan hues | ||
| 57 | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | 57 | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | ||
| @@ -219,7 +219,7 @@ export function generateCSSStyle(style: TextStyle): string { | |||||
| 219 | // Color or gradient | 219 | // Color or gradient | ||
| 220 | if (style.gradient && style.gradient.length > 1) { | 220 | if (style.gradient && style.gradient.length > 1) { | ||
| 221 | const gradientStops = style.gradient.join(', '); | 221 | const gradientStops = style.gradient.join(', '); | ||
| 222 | - | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | 222 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); |
| 223 | styles.push(`-webkit-background-clip: text`); | 223 | styles.push(`-webkit-background-clip: text`); | ||
| 224 | styles.push(`-webkit-text-fill-color: transparent`); | 224 | styles.push(`-webkit-text-fill-color: transparent`); | ||
| 225 | styles.push(`background-clip: text`); | 225 | styles.push(`background-clip: text`); | ||
| @@ -261,6 +261,7 @@ export function generateInputCSSStyle(style: TextStyle): string { | |||||
| 261 | styles.push(`font-size: ${style.fontSize}px`); | 261 | styles.push(`font-size: ${style.fontSize}px`); | ||
| 262 | 262 | ||||
| 263 | // Color handling for inputs | 263 | // Color handling for inputs | ||
| 264 | + | // IMPORTANT: Always set -webkit-text-fill-color to ensure proper color reset when switching from gradient to solid | |||
| 264 | if (style.gradient && style.gradient.length > 1) { | 265 | if (style.gradient && style.gradient.length > 1) { | ||
| 265 | // Apply gradient background with text clipping for gradient effect | 266 | // Apply gradient background with text clipping for gradient effect | ||
| 266 | const gradientStops = style.gradient.join(', '); | 267 | const gradientStops = style.gradient.join(', '); | ||
| @@ -272,12 +273,16 @@ export function generateInputCSSStyle(style: TextStyle): string { | |||||
| 272 | styles.push(`word-spacing: 0`); | 273 | styles.push(`word-spacing: 0`); | ||
| 273 | } else if (style.gradient && style.gradient.length === 1) { | 274 | } else if (style.gradient && style.gradient.length === 1) { | ||
| 274 | // Single color from gradient array | 275 | // Single color from gradient array | ||
| 275 | - | styles.push(`color: ${style.gradient[0]}`); | 276 | + | const color = style.gradient[0]; |
| 277 | + | styles.push(`color: ${color}`); | |||
| 278 | + | styles.push(`-webkit-text-fill-color: ${color}`); | |||
| 276 | } else if (style.color) { | 279 | } else if (style.color) { | ||
| 277 | styles.push(`color: ${style.color}`); | 280 | styles.push(`color: ${style.color}`); | ||
| 281 | + | styles.push(`-webkit-text-fill-color: ${style.color}`); | |||
| 278 | } else { | 282 | } else { | ||
| 279 | // Fallback to black if no color is set | 283 | // Fallback to black if no color is set | ||
| 280 | styles.push(`color: #000000`); | 284 | styles.push(`color: #000000`); | ||
| 285 | + | styles.push(`-webkit-text-fill-color: #000000`); | |||
| 281 | } | 286 | } | ||
| 282 | 287 | ||||
| 283 | // Font weight | 288 | // Font weight | ||
A src/lib/utils/chat-window-state.ts +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // Chat window state persistence utility | |
| 2 | + | // Stores and retrieves window position, size, and UI state from localStorage | |
| 3 | + | ||
| 4 | + | export interface ChatWindowState { | |
| 5 | + | width: number; | |
| 6 | + | height: number; | |
| 7 | + | x: number; | |
| 8 | + | y: number; | |
| 9 | + | isMaximized: boolean; | |
| 10 | + | isMinimized: boolean; | |
| 11 | + | showUserList: boolean; | |
| 12 | + | isCentered: boolean; // true = window stays centered on resize, false = user has manually positioned | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | const STORAGE_KEY = 'pdraim-chat-window'; | |
| 16 | + | ||
| 17 | + | // Default values for desktop (800x600 - 16:10 aspect ratio, centered) | |
| 18 | + | export const DEFAULT_WINDOW_STATE: ChatWindowState = { | |
| 19 | + | width: 800, | |
| 20 | + | height: 600, | |
| 21 | + | x: -1, // -1 means "center" - will be calculated on load | |
| 22 | + | y: -1, // -1 means "center" - will be calculated on load | |
| 23 | + | isMaximized: false, | |
| 24 | + | isMinimized: false, | |
| 25 | + | showUserList: false, | |
| 26 | + | isCentered: true // default to centered, set to false when user drags window | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * Load window state from localStorage | |
| 31 | + | * Returns saved state merged with defaults, or just defaults if nothing saved | |
| 32 | + | */ | |
| 33 | + | export function loadWindowState(): ChatWindowState { | |
| 34 | + | if (typeof window === 'undefined') { | |
| 35 | + | return DEFAULT_WINDOW_STATE; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | try { | |
| 39 | + | const stored = localStorage.getItem(STORAGE_KEY); | |
| 40 | + | if (stored) { | |
| 41 | + | const parsed = JSON.parse(stored); | |
| 42 | + | // Merge with defaults to ensure all fields exist | |
| 43 | + | return { | |
| 44 | + | ...DEFAULT_WINDOW_STATE, | |
| 45 | + | ...parsed | |
| 46 | + | }; | |
| 47 | + | } | |
| 48 | + | } catch (error) { | |
| 49 | + | console.warn('Failed to load chat window state from localStorage:', error); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | return DEFAULT_WINDOW_STATE; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * Save window state to localStorage | |
| 57 | + | * Debounced externally - this just does the save | |
| 58 | + | */ | |
| 59 | + | export function saveWindowState(state: Partial<ChatWindowState>): void { | |
| 60 | + | if (typeof window === 'undefined') { | |
| 61 | + | return; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | try { | |
| 65 | + | const currentState = loadWindowState(); | |
| 66 | + | const newState = { ...currentState, ...state }; | |
| 67 | + | localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); | |
| 68 | + | } catch (error) { | |
| 69 | + | console.warn('Failed to save chat window state to localStorage:', error); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * Clear saved window state | |
| 75 | + | */ | |
| 76 | + | export function clearWindowState(): void { | |
| 77 | + | if (typeof window === 'undefined') { | |
| 78 | + | return; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | try { | |
| 82 | + | localStorage.removeItem(STORAGE_KEY); | |
| 83 | + | } catch (error) { | |
| 84 | + | console.warn('Failed to clear chat window state from localStorage:', error); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** | |
| 89 | + | * Simple debounce function for save operations | |
| 90 | + | */ | |
| 91 | + | export function debounce<T extends (...args: Parameters<T>) => void>( | |
| 92 | + | fn: T, | |
| 93 | + | delay: number | |
| 94 | + | ): (...args: Parameters<T>) => void { | |
| 95 | + | let timeoutId: ReturnType<typeof setTimeout> | null = null; | |
| 96 | + | ||
| 97 | + | return (...args: Parameters<T>) => { | |
| 98 | + | if (timeoutId) { | |
| 99 | + | clearTimeout(timeoutId); | |
| 100 | + | } | |
| 101 | + | timeoutId = setTimeout(() => { | |
| 102 | + | fn(...args); | |
| 103 | + | timeoutId = null; | |
| 104 | + | }, delay); | |
| 105 | + | }; | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | // Chat window state persistence utility | |||
| 2 | + | // Stores and retrieves window position, size, and UI state from localStorage | |||
| 3 | + | ||||
| 4 | + | export interface ChatWindowState { | |||
| 5 | + | width: number; | |||
| 6 | + | height: number; | |||
| 7 | + | x: number; | |||
| 8 | + | y: number; | |||
| 9 | + | isMaximized: boolean; | |||
| 10 | + | isMinimized: boolean; | |||
| 11 | + | showUserList: boolean; | |||
| 12 | + | isCentered: boolean; // true = window stays centered on resize, false = user has manually positioned | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | const STORAGE_KEY = 'pdraim-chat-window'; | |||
| 16 | + | ||||
| 17 | + | // Default values for desktop (800x600 - 16:10 aspect ratio, centered) | |||
| 18 | + | export const DEFAULT_WINDOW_STATE: ChatWindowState = { | |||
| 19 | + | width: 800, | |||
| 20 | + | height: 600, | |||
| 21 | + | x: -1, // -1 means "center" - will be calculated on load | |||
| 22 | + | y: -1, // -1 means "center" - will be calculated on load | |||
| 23 | + | isMaximized: false, | |||
| 24 | + | isMinimized: false, | |||
| 25 | + | showUserList: false, | |||
| 26 | + | isCentered: true // default to centered, set to false when user drags window | |||
| 27 | + | }; | |||
| 28 | + | ||||
| 29 | + | /** | |||
| 30 | + | * Load window state from localStorage | |||
| 31 | + | * Returns saved state merged with defaults, or just defaults if nothing saved | |||
| 32 | + | */ | |||
| 33 | + | export function loadWindowState(): ChatWindowState { | |||
| 34 | + | if (typeof window === 'undefined') { | |||
| 35 | + | return DEFAULT_WINDOW_STATE; | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | try { | |||
| 39 | + | const stored = localStorage.getItem(STORAGE_KEY); | |||
| 40 | + | if (stored) { | |||
| 41 | + | const parsed = JSON.parse(stored); | |||
| 42 | + | // Merge with defaults to ensure all fields exist | |||
| 43 | + | return { | |||
| 44 | + | ...DEFAULT_WINDOW_STATE, | |||
| 45 | + | ...parsed | |||
| 46 | + | }; | |||
| 47 | + | } | |||
| 48 | + | } catch (error) { | |||
| 49 | + | console.warn('Failed to load chat window state from localStorage:', error); | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | return DEFAULT_WINDOW_STATE; | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | /** | |||
| 56 | + | * Save window state to localStorage | |||
| 57 | + | * Debounced externally - this just does the save | |||
| 58 | + | */ | |||
| 59 | + | export function saveWindowState(state: Partial<ChatWindowState>): void { | |||
| 60 | + | if (typeof window === 'undefined') { | |||
| 61 | + | return; | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | try { | |||
| 65 | + | const currentState = loadWindowState(); | |||
| 66 | + | const newState = { ...currentState, ...state }; | |||
| 67 | + | localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); | |||
| 68 | + | } catch (error) { | |||
| 69 | + | console.warn('Failed to save chat window state to localStorage:', error); | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | /** | |||
| 74 | + | * Clear saved window state | |||
| 75 | + | */ | |||
| 76 | + | export function clearWindowState(): void { | |||
| 77 | + | if (typeof window === 'undefined') { | |||
| 78 | + | return; | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | try { | |||
| 82 | + | localStorage.removeItem(STORAGE_KEY); | |||
| 83 | + | } catch (error) { | |||
| 84 | + | console.warn('Failed to clear chat window state from localStorage:', error); | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | /** | |||
| 89 | + | * Simple debounce function for save operations | |||
| 90 | + | */ | |||
| 91 | + | export function debounce<T extends (...args: Parameters<T>) => void>( | |||
| 92 | + | fn: T, | |||
| 93 | + | delay: number | |||
| 94 | + | ): (...args: Parameters<T>) => void { | |||
| 95 | + | let timeoutId: ReturnType<typeof setTimeout> | null = null; | |||
| 96 | + | ||||
| 97 | + | return (...args: Parameters<T>) => { | |||
| 98 | + | if (timeoutId) { | |||
| 99 | + | clearTimeout(timeoutId); | |||
| 100 | + | } | |||
| 101 | + | timeoutId = setTimeout(() => { | |||
| 102 | + | fn(...args); | |||
| 103 | + | timeoutId = null; | |||
| 104 | + | }, delay); | |||
| 105 | + | }; | |||
| 106 | + | } | |||
M src/lib/utils/text-formatter.ts +2 -2
| @@ -288,12 +288,12 @@ export function createGradientText( | |||
| 288 | 288 | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 289 | 289 | }); | |
| 290 | 290 | ||
| 291 | - | // Wrap all spans in a container that uses flexbox to eliminate spacing | |
| 291 | + | // Wrap all spans in a container - use inline display to match text baseline | |
| 292 | 292 | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 293 | 293 | const containerStyle = style | |
| 294 | 294 | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) | |
| 295 | 295 | : ''; | |
| 296 | - | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 296 | + | const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 297 | 297 | console.debug('Final gradient HTML:', result); | |
| 298 | 298 | return result; | |
| 299 | 299 | } | |
| @@ -288,12 +288,12 @@ export function createGradientText( | |||||
| 288 | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | 288 | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | ||
| 289 | }); | 289 | }); | ||
| 290 | 290 | ||||
| 291 | - | // Wrap all spans in a container that uses flexbox to eliminate spacing | 291 | + | // Wrap all spans in a container - use inline display to match text baseline |
| 292 | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | 292 | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | ||
| 293 | const containerStyle = style | 293 | const containerStyle = style | ||
| 294 | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) | 294 | ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) | ||
| 295 | : ''; | 295 | : ''; | ||
| 296 | - | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | 296 | + | const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`; |
| 297 | console.debug('Final gradient HTML:', result); | 297 | console.debug('Final gradient HTML:', result); | ||
| 298 | return result; | 298 | return result; | ||
| 299 | } | 299 | } | ||
A src/lib/validation/message.ts +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | import { z } from 'zod'; | |
| 2 | + | ||
| 3 | + | // Message constraints | |
| 4 | + | export const MAX_MESSAGE_LENGTH = 500; | |
| 5 | + | export const MIN_MESSAGE_LENGTH = 1; | |
| 6 | + | ||
| 7 | + | // Message content schema | |
| 8 | + | export const messageContentSchema = z | |
| 9 | + | .string() | |
| 10 | + | .min(MIN_MESSAGE_LENGTH, 'Message cannot be empty') | |
| 11 | + | .max(MAX_MESSAGE_LENGTH, `Message cannot exceed ${MAX_MESSAGE_LENGTH} characters`) | |
| 12 | + | .trim(); | |
| 13 | + | ||
| 14 | + | // Full send message request schema | |
| 15 | + | // Note: ID format validation is relaxed - DB queries validate existence | |
| 16 | + | export const sendMessageSchema = z.object({ | |
| 17 | + | content: messageContentSchema, | |
| 18 | + | userId: z.string().min(1, 'User ID is required'), | |
| 19 | + | chatRoomId: z.string().min(1).optional(), | |
| 20 | + | type: z.enum(['chat', 'emote', 'system']).default('chat'), | |
| 21 | + | styleData: z.unknown().optional() | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | export type SendMessageInput = z.infer<typeof sendMessageSchema>; | |
| 25 | + | ||
| 26 | + | // Validation helper function | |
| 27 | + | export function validateMessageContent(content: string): { | |
| 28 | + | isValid: boolean; | |
| 29 | + | error?: string; | |
| 30 | + | sanitized: string; | |
| 31 | + | } { | |
| 32 | + | const result = messageContentSchema.safeParse(content); | |
| 33 | + | ||
| 34 | + | if (result.success) { | |
| 35 | + | return { | |
| 36 | + | isValid: true, | |
| 37 | + | sanitized: result.data | |
| 38 | + | }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | return { | |
| 42 | + | isValid: false, | |
| 43 | + | error: result.error.issues[0]?.message || 'Invalid message', | |
| 44 | + | sanitized: content.slice(0, MAX_MESSAGE_LENGTH).trim() | |
| 45 | + | }; | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | import { z } from 'zod'; | |||
| 2 | + | ||||
| 3 | + | // Message constraints | |||
| 4 | + | export const MAX_MESSAGE_LENGTH = 500; | |||
| 5 | + | export const MIN_MESSAGE_LENGTH = 1; | |||
| 6 | + | ||||
| 7 | + | // Message content schema | |||
| 8 | + | export const messageContentSchema = z | |||
| 9 | + | .string() | |||
| 10 | + | .min(MIN_MESSAGE_LENGTH, 'Message cannot be empty') | |||
| 11 | + | .max(MAX_MESSAGE_LENGTH, `Message cannot exceed ${MAX_MESSAGE_LENGTH} characters`) | |||
| 12 | + | .trim(); | |||
| 13 | + | ||||
| 14 | + | // Full send message request schema | |||
| 15 | + | // Note: ID format validation is relaxed - DB queries validate existence | |||
| 16 | + | export const sendMessageSchema = z.object({ | |||
| 17 | + | content: messageContentSchema, | |||
| 18 | + | userId: z.string().min(1, 'User ID is required'), | |||
| 19 | + | chatRoomId: z.string().min(1).optional(), | |||
| 20 | + | type: z.enum(['chat', 'emote', 'system']).default('chat'), | |||
| 21 | + | styleData: z.unknown().optional() | |||
| 22 | + | }); | |||
| 23 | + | ||||
| 24 | + | export type SendMessageInput = z.infer<typeof sendMessageSchema>; | |||
| 25 | + | ||||
| 26 | + | // Validation helper function | |||
| 27 | + | export function validateMessageContent(content: string): { | |||
| 28 | + | isValid: boolean; | |||
| 29 | + | error?: string; | |||
| 30 | + | sanitized: string; | |||
| 31 | + | } { | |||
| 32 | + | const result = messageContentSchema.safeParse(content); | |||
| 33 | + | ||||
| 34 | + | if (result.success) { | |||
| 35 | + | return { | |||
| 36 | + | isValid: true, | |||
| 37 | + | sanitized: result.data | |||
| 38 | + | }; | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | return { | |||
| 42 | + | isValid: false, | |||
| 43 | + | error: result.error.issues[0]?.message || 'Invalid message', | |||
| 44 | + | sanitized: content.slice(0, MAX_MESSAGE_LENGTH).trim() | |||
| 45 | + | }; | |||
| 46 | + | } | |||
M src/routes/api/chat/messages/+server.ts +14 -11
| @@ -2,11 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; | |||
| 2 | 2 | import db from '$lib/db/db.server'; | |
| 3 | 3 | import { messages } from '$lib/db/schema'; | |
| 4 | 4 | import type { Message } from '$lib/types/chat'; | |
| 5 | - | import type { | |
| 6 | - | SendMessageRequest, | |
| 7 | - | SendMessageResponse, | |
| 8 | - | GetMessagesResponse | |
| 9 | - | } from '$lib/types/payloads'; | |
| 5 | + | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; | |
| 10 | 6 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | |
| 11 | 7 | import type { SQL } from 'drizzle-orm/sql'; | |
| 12 | 8 | import { error } from '@sveltejs/kit'; | |
| @@ -16,6 +12,7 @@ import { users } from '$lib/db/schema'; | |||
| 16 | 12 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | |
| 17 | 13 | import { createLogger } from '$lib/utils/logger.server'; | |
| 18 | 14 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |
| 15 | + | import { sendMessageSchema } from '$lib/validation/message'; | |
| 19 | 16 | ||
| 20 | 17 | const log = createLogger('chat-server'); | |
| 21 | 18 | ||
| @@ -146,19 +143,25 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||
| 146 | 143 | }); | |
| 147 | 144 | } | |
| 148 | 145 | ||
| 149 | - | const data = (await request.json()) as SendMessageRequest; | |
| 150 | - | if (!data.content || !data.userId) { | |
| 151 | - | log.warn('Invalid payload received', { | |
| 152 | - | hasContent: Boolean(data.content), | |
| 153 | - | hasUserId: Boolean(data.userId) | |
| 146 | + | const rawData = await request.json(); | |
| 147 | + | ||
| 148 | + | // Validate with Zod schema | |
| 149 | + | const parseResult = sendMessageSchema.safeParse(rawData); | |
| 150 | + | if (!parseResult.success) { | |
| 151 | + | const errorMessage = parseResult.error.issues[0]?.message || 'Invalid message payload'; | |
| 152 | + | log.warn('Message validation failed', { | |
| 153 | + | error: errorMessage, | |
| 154 | + | issues: parseResult.error.issues | |
| 154 | 155 | }); | |
| 155 | 156 | const errorResponse: SendMessageResponse = { | |
| 156 | 157 | success: false, | |
| 157 | - | error: 'Invalid message payload' | |
| 158 | + | error: errorMessage | |
| 158 | 159 | }; | |
| 159 | 160 | return new Response(JSON.stringify(errorResponse), { status: 400 }); | |
| 160 | 161 | } | |
| 161 | 162 | ||
| 163 | + | const data = parseResult.data; | |
| 164 | + | ||
| 162 | 165 | if (data.userId !== locals.user.id) { | |
| 163 | 166 | log.warn('Unauthorized message attempt', { | |
| 164 | 167 | requestedUserId: data.userId, | |
| @@ -2,11 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; | |||||
| 2 | import db from '$lib/db/db.server'; | 2 | import db from '$lib/db/db.server'; | ||
| 3 | import { messages } from '$lib/db/schema'; | 3 | import { messages } from '$lib/db/schema'; | ||
| 4 | import type { Message } from '$lib/types/chat'; | 4 | import type { Message } from '$lib/types/chat'; | ||
| 5 | - | import type { | 5 | + | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; |
| 6 | - | SendMessageRequest, | |||
| 7 | - | SendMessageResponse, | |||
| 8 | - | GetMessagesResponse | |||
| 9 | - | } from '$lib/types/payloads'; | |||
| 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | 6 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | ||
| 11 | import type { SQL } from 'drizzle-orm/sql'; | 7 | import type { SQL } from 'drizzle-orm/sql'; | ||
| 12 | import { error } from '@sveltejs/kit'; | 8 | import { error } from '@sveltejs/kit'; | ||
| @@ -16,6 +12,7 @@ import { users } from '$lib/db/schema'; | |||||
| 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | 12 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | ||
| 17 | import { createLogger } from '$lib/utils/logger.server'; | 13 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 18 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | 14 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | ||
| 15 | + | import { sendMessageSchema } from '$lib/validation/message'; | |||
| 19 | 16 | ||||
| 20 | const log = createLogger('chat-server'); | 17 | const log = createLogger('chat-server'); | ||
| 21 | 18 | ||||
| @@ -146,19 +143,25 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||||
| 146 | }); | 143 | }); | ||
| 147 | } | 144 | } | ||
| 148 | 145 | ||||
| 149 | - | const data = (await request.json()) as SendMessageRequest; | 146 | + | const rawData = await request.json(); |
| 150 | - | if (!data.content || !data.userId) { | 147 | + | |
| 151 | - | log.warn('Invalid payload received', { | 148 | + | // Validate with Zod schema |
| 152 | - | hasContent: Boolean(data.content), | 149 | + | const parseResult = sendMessageSchema.safeParse(rawData); |
| 153 | - | hasUserId: Boolean(data.userId) | 150 | + | if (!parseResult.success) { |
| 151 | + | const errorMessage = parseResult.error.issues[0]?.message || 'Invalid message payload'; | |||
| 152 | + | log.warn('Message validation failed', { | |||
| 153 | + | error: errorMessage, | |||
| 154 | + | issues: parseResult.error.issues | |||
| 154 | }); | 155 | }); | ||
| 155 | const errorResponse: SendMessageResponse = { | 156 | const errorResponse: SendMessageResponse = { | ||
| 156 | success: false, | 157 | success: false, | ||
| 157 | - | error: 'Invalid message payload' | 158 | + | error: errorMessage |
| 158 | }; | 159 | }; | ||
| 159 | return new Response(JSON.stringify(errorResponse), { status: 400 }); | 160 | return new Response(JSON.stringify(errorResponse), { status: 400 }); | ||
| 160 | } | 161 | } | ||
| 161 | 162 | ||||
| 163 | + | const data = parseResult.data; | |||
| 164 | + | ||||
| 162 | if (data.userId !== locals.user.id) { | 165 | if (data.userId !== locals.user.id) { | ||
| 163 | log.warn('Unauthorized message attempt', { | 166 | log.warn('Unauthorized message attempt', { | ||
| 164 | requestedUserId: data.userId, | 167 | requestedUserId: data.userId, | ||