detached f6db5c6

fix(chat): improve UI/UX with validation, persistence and user list redesign

- Add Zod message validation (frontend + backend)
- Add localStorage persistence for window state
- Fix gradient text alignment with baseline
- Add character counter with progressive warnings
- Fix input paste handling with scroll sync
- Redesign user list with En ligne/Hors ligne sections
- Add @lucide/svelte package
commit
f6db5c6e9cdcc7a1bb30af3dd81b2045ccd44b0e
author
bnema · · 2025-12-15 21:31 UTC
committer
bnema · · 2025-12-15 21:33 UTC
parent
449502f
signature
GPG Unverified2B82207D370127A0 The key is unknown
10 files · +478 -105
M package.json +1 -0
@@ -56,6 +56,7 @@
5656 },
5757 "dependencies": {
5858 "@libsql/client": "^0.15.15",
59+ "@lucide/svelte": "^0.561.0",
5960 "dotenv": "^16.6.1",
6061 "drizzle-orm": "^0.44.7",
6162 "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 generated file: collapsed
M src/app.css +13 -5
@@ -22,6 +22,8 @@
2222 --accent-foreground: hsl(240 5.9% 10%);
2323 --destructive: hsl(0 72.2% 50.6%);
2424 --destructive-foreground: hsl(0 0% 98%);
25+ --warning: hsl(25 95% 53%);
26+ --warning-foreground: hsl(0 0% 98%);
2527 --ring: hsl(240 10% 3.9%);
2628 --sidebar: hsl(0 0% 98%);
2729 --sidebar-foreground: hsl(240 5.3% 26.1%);
@@ -54,6 +56,8 @@
5456 --accent-foreground: hsl(0 0% 98%);
5557 --destructive: hsl(0 62.8% 30.6%);
5658 --destructive-foreground: hsl(0 0% 98%);
59+ --warning: hsl(25 95% 63%);
60+ --warning-foreground: hsl(0 0% 98%);
5761 --ring: hsl(240 4.9% 83.9%);
5862 --sidebar: hsl(240 5.9% 10%);
5963 --sidebar-foreground: hsl(240 4.8% 95.9%);
@@ -91,6 +95,8 @@
9195 --color-accent-foreground: var(--accent-foreground);
9296 --color-destructive: var(--destructive);
9397 --color-destructive-foreground: var(--destructive-foreground);
98+ --color-warning: var(--warning);
99+ --color-warning-foreground: var(--warning-foreground);
94100 --color-ring: var(--ring);
95101 --color-radius: var(--radius);
96102 --color-sidebar: var(--sidebar);
@@ -327,21 +333,23 @@
327333 word-spacing: normal;
328334 }
329335
330-/* Gradient text container uses flexbox */
336+/* Gradient text container uses inline display for proper text alignment */
331337 .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;
335341 }
336342
337343 /* Ensure gradient character spans don't add spacing */
338-.gradient-text-static > span {
344+.gradient-text-static > span,
345+.gradient-text-static .gradient-char {
339346 display: inline !important;
340347 margin: 0 !important;
341348 padding: 0 !important;
342349 letter-spacing: 0 !important;
343350 line-height: inherit !important;
344351 font-size: inherit !important;
352+ vertical-align: baseline !important;
345353 }
346354
347355 /* 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 @@
11 <script lang="ts">
22 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';
44 import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
66 import { draggable } from '$lib/actions/draggable';
@@ -19,13 +19,15 @@
1919 generateInputCSSStyle
2020 } from '../types/text-formatting';
2121 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';
2224
2325 // Props destructuring must come first
2426 let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
2527
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);
2931 let windowX = $state(0);
3032 let windowY = $state(0);
3133 let isMobile = $state(false);
@@ -35,6 +37,25 @@
3537 let isInitialLoading = $state(true);
3638 let showAuth = $state(false);
3739 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+ }
3859
3960 // Text formatting state - initialize directly with merged initial style
4061 let currentTextStyle = $state<TextStyle>({
@@ -52,6 +73,9 @@
5273 // At the top along with other variable declarations, add:
5374 let roomPollInterval: ReturnType<typeof setInterval> | null = null;
5475
76+ // Debounced save function for window state persistence
77+ const debouncedSaveWindowState = debounce(saveWindowState, 300);
78+
5579 function updateCooldownProgress() {
5680 if (!cooldownEndTime) return;
5781
@@ -105,6 +129,17 @@
105129 // Add a derived state to check if we're showing the registration prompt
106130 let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
107131
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+
108143 // Add SSE error state
109144 let sseError = $state<string | null>(null);
110145 let sseRetryAfter = $state<number | null>(null);
@@ -194,23 +229,87 @@
194229 onMount(() => {
195230 if (!browser) return;
196231
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+
197263 const handleResize = () => {
264+ const wasMobile = isMobile;
198265 isMobile = window.innerWidth <= 768;
199266 if (isMobile) {
200267 windowWidth = window.innerWidth;
201268 windowHeight = window.innerHeight;
202269 windowX = 0;
203270 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+ }
209306 }
210307 };
211308
212- // Initial resize
213- handleResize();
309+ // Only run handleResize if on mobile (desktop already loaded from localStorage)
310+ if (isMobile) {
311+ handleResize();
312+ }
214313
215314 // Add resize listener
216315 window.addEventListener('resize', handleResize);
@@ -248,6 +347,23 @@
248347 };
249348 });
250349
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+
251367 // New reactive state to track when a message is being sent
252368 let isSendingMessage = $state(false);
253369
@@ -276,22 +392,11 @@
276392 }
277393 }
278394
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-
292395 function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
293396 windowX = event.detail.x;
294397 windowY = event.detail.y;
398+ // User manually moved the window, no longer keep it centered on resize
399+ isCentered = false;
295400 }
296401
297402 interface MaximizableNode extends HTMLElement {
@@ -418,6 +523,10 @@
418523 // Add derived state for total users count
419524 let totalUsers = $derived(onlineUsers.length);
420525
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+
421530 function openSignup() {
422531 showAuth = true;
423532 }
@@ -603,16 +712,52 @@
603712 {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
604713 <div
605714 class="gradient-input-wrapper"
606- style="flex: 1; position: relative; background: white;"
715+ style="flex: 1; position: relative; background: white; overflow: hidden;"
607716 >
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+ >
608744 <input
609745 type="text"
610746 bind:value={currentMessage}
747+ maxlength={MAX_MESSAGE_LENGTH}
611748 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+ : ''}"
615758 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
759+ oninput={handleInputChange}
760+ onscroll={handleInputScroll}
616761 placeholder={cooldownEndTime
617762 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
618763 : 'Écrivez un message...'}
@@ -623,6 +768,7 @@
623768 <input
624769 type="text"
625770 bind:value={currentMessage}
771+ maxlength={MAX_MESSAGE_LENGTH}
626772 class="styled-input retro-font-{currentTextStyle.fontFamily}"
627773 style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"
628774 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
@@ -638,6 +784,11 @@
638784 loading={isSendingMessage}
639785 text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
640786 />
787+ {#if showCharCounter}
788+ <span class="char-counter {charCounterClass}">
789+ {currentMessage.length}/{MAX_MESSAGE_LENGTH}
790+ </span>
791+ {/if}
641792 </div>
642793 {#if cooldownEndTime}
643794 <div
@@ -655,30 +806,33 @@
655806 class:hidden={isMobile && !showUserList}
656807 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
657808 >
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>
679833 </div>
680- </div>
681- {/each}
834+ {/each}
835+ {/if}
682836 </div>
683837 </div>
684838 {/if}
@@ -738,35 +892,41 @@
738892 .message {
739893 margin-bottom: 0.25rem;
740894 word-break: break-word;
741- display: flex;
742- gap: 0.5rem;
743- align-items: flex-start;
895+ line-height: 1.4;
744896 }
745897
746- /* Fix gradient text spacing in messages */
898+ /* Fix gradient text alignment in messages */
747899 .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;
750903 }
751904
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;
754909 }
755910
756911 .message .nickname {
912+ display: inline;
757913 font-weight: bold;
758914 color: #2d31a6;
759915 cursor: help;
760- flex-shrink: 0;
916+ margin-right: 0.25rem;
917+ vertical-align: baseline;
761918 }
762919
763920 .message-content {
764- flex: 1;
765- min-width: 0;
921+ display: inline;
922+ vertical-align: baseline;
766923 }
767924
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;
770930 }
771931
772932 .message.emote {
@@ -778,36 +938,40 @@
778938 color: #666;
779939 }
780940
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);
787948 }
788949
789- .user.offline {
790- opacity: 0.6;
950+ .offline-header {
951+ opacity: 0.7;
791952 }
792953
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;
796957 }
797958
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);
801966 }
802967
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;
807971 }
808972
809973 .user.offline .nickname {
810- color: #666;
974+ cursor: help;
811975 }
812976
813977 /* .user .status-message {
@@ -829,6 +993,11 @@
829993 margin-right: 0.25rem;
830994 }
831995
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+
8321001 button {
8331002 font-size: 1rem;
8341003 }
@@ -961,6 +1130,29 @@
9611130 position: relative;
9621131 }
9631132
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+
9641156 .connection-error {
9651157 font-size: 0.75rem;
9661158 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 generateInputCSSStyle19 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 first25 // 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 SSR28+ // 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 style60 // 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 prompt129 // 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 state143 // 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 resize309+ // Only run handleResize if on mobile (desktop already loaded from localStorage)
213- handleResize();310+ if (isMobile) {
311+ handleResize();
312+ }
214 313
215 // Add resize listener314 // 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 sent367 // 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 count523 // 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 <div713 <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 <input744 <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- currentTextStyle750+ ? 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={cooldownEndTime761 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 <input768 <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 <div794 <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- <Tooltip817+ <!-- 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 @@
3838 // Get current display color/gradient
3939 let displayStyle = $derived(() => {
4040 if (gradientColors.length > 1) {
41- return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`;
41+ return `background: linear-gradient(to right, ${gradientColors.join(', ')})`;
4242 }
4343 return `background: ${gradientColors[0] || '#000000'}`;
4444 });
@@ -38,7 +38,7 @@
38 // Get current display color/gradient38 // 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[] {
5151 // Green hues
5252 colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00');
5353 colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC');
54- colors.push('#336633', '#669966', '#99CC99', '#CCFFCC');
54+ colors.push('#336633', '#669966', '#99CC99');
5555
5656 // Cyan hues
5757 colors.push('#006666', '#009999', '#00CCCC', '#00FFFF');
@@ -219,7 +219,7 @@ export function generateCSSStyle(style: TextStyle): string {
219219 // Color or gradient
220220 if (style.gradient && style.gradient.length > 1) {
221221 const gradientStops = style.gradient.join(', ');
222- styles.push(`background: linear-gradient(45deg, ${gradientStops})`);
222+ styles.push(`background: linear-gradient(to right, ${gradientStops})`);
223223 styles.push(`-webkit-background-clip: text`);
224224 styles.push(`-webkit-text-fill-color: transparent`);
225225 styles.push(`background-clip: text`);
@@ -261,6 +261,7 @@ export function generateInputCSSStyle(style: TextStyle): string {
261261 styles.push(`font-size: ${style.fontSize}px`);
262262
263263 // Color handling for inputs
264+ // IMPORTANT: Always set -webkit-text-fill-color to ensure proper color reset when switching from gradient to solid
264265 if (style.gradient && style.gradient.length > 1) {
265266 // Apply gradient background with text clipping for gradient effect
266267 const gradientStops = style.gradient.join(', ');
@@ -272,12 +273,16 @@ export function generateInputCSSStyle(style: TextStyle): string {
272273 styles.push(`word-spacing: 0`);
273274 } else if (style.gradient && style.gradient.length === 1) {
274275 // 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}`);
276279 } else if (style.color) {
277280 styles.push(`color: ${style.color}`);
281+ styles.push(`-webkit-text-fill-color: ${style.color}`);
278282 } else {
279283 // Fallback to black if no color is set
280284 styles.push(`color: #000000`);
285+ styles.push(`-webkit-text-fill-color: #000000`);
281286 }
282287
283288 // Font weight
@@ -51,7 +51,7 @@ export function generate256Colors(): string[] {
51 // Green hues51 // 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 hues56 // 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 gradient219 // 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 inputs263 // 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 effect266 // 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 array275 // 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 set283 // 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 weight288 // 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(
288288 return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`;
289289 });
290290
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
292292 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
293293 const containerStyle = style
294294 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
295295 : '';
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>`;
297297 console.debug('Final gradient HTML:', result);
298298 return result;
299299 }
@@ -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 spacing291+ // 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 = style293 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';
22 import db from '$lib/db/db.server';
33 import { messages } from '$lib/db/schema';
44 import type { Message } from '$lib/types/chat';
5-import type {
6- SendMessageRequest,
7- SendMessageResponse,
8- GetMessagesResponse
9-} from '$lib/types/payloads';
5+import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
106 import { and, desc, eq, lt } from 'drizzle-orm/sql';
117 import type { SQL } from 'drizzle-orm/sql';
128 import { error } from '@sveltejs/kit';
@@ -16,6 +12,7 @@ import { users } from '$lib/db/schema';
1612 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
1713 import { createLogger } from '$lib/utils/logger.server';
1814 import { sanitizeStyleData } from '$lib/validation/text-formatting';
15+import { sendMessageSchema } from '$lib/validation/message';
1916
2017 const log = createLogger('chat-server');
2118
@@ -146,19 +143,25 @@ export async function POST({ request, locals }: { request: Request; locals: App.
146143 });
147144 }
148145
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
154155 });
155156 const errorResponse: SendMessageResponse = {
156157 success: false,
157- error: 'Invalid message payload'
158+ error: errorMessage
158159 };
159160 return new Response(JSON.stringify(errorResponse), { status: 400 });
160161 }
161162
163+ const data = parseResult.data;
164+
162165 if (data.userId !== locals.user.id) {
163166 log.warn('Unauthorized message attempt', {
164167 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,

Keyboard shortcuts

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

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

Branches 3

Tags 8

↑↓ moveenter openesc close