detached bf7e125

feat: enhance authentication UI with improved login component and retro styling

commit
bf7e125c6d512952cf7b2251faa810fd4e54e483
author
Kit · · 2025-07-03 19:55 UTC
parent
7d98a0f
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
5 files · +1073 -61
M src/app.css +445 -0
@@ -2,4 +2,449 @@
22 @import 'tailwindcss/components';
33 @import 'tailwindcss/utilities';
44 @import 'xp.css';
5+@import './lib/styles/fonts.css';
6+
7+/* Local font faces with 30-day cache */
8+@font-face {
9+ font-family: 'Arial';
10+ src: url('/fonts/Arial.TTF') format('truetype');
11+ font-display: swap;
12+ font-weight: normal;
13+ font-style: normal;
14+}
15+
16+@font-face {
17+ font-family: 'Arial';
18+ src: url('/fonts/Arialbd.TTF') format('truetype');
19+ font-display: swap;
20+ font-weight: bold;
21+ font-style: normal;
22+}
23+
24+@font-face {
25+ font-family: 'Arial';
26+ src: url('/fonts/Ariali.TTF') format('truetype');
27+ font-display: swap;
28+ font-weight: normal;
29+ font-style: italic;
30+}
31+
32+@font-face {
33+ font-family: 'Arial';
34+ src: url('/fonts/Arialbi.TTF') format('truetype');
35+ font-display: swap;
36+ font-weight: bold;
37+ font-style: italic;
38+}
39+
40+@font-face {
41+ font-family: 'Tahoma';
42+ src: url('/fonts/tahoma.ttf') format('truetype');
43+ font-display: swap;
44+ font-weight: 80;
45+ font-style: normal;
46+}
47+
48+@font-face {
49+ font-family: 'Tahoma';
50+ src: url('/fonts/tahomabd.ttf') format('truetype');
51+ font-display: swap;
52+ font-weight: 200;
53+ font-style: normal;
54+}
55+
56+@font-face {
57+ font-family: 'Verdana';
58+ src: url('/fonts/Verdana.TTF') format('truetype');
59+ font-display: swap;
60+ font-weight: normal;
61+ font-style: normal;
62+}
63+
64+@font-face {
65+ font-family: 'Verdana';
66+ src: url('/fonts/Verdanab.TTF') format('truetype');
67+ font-display: swap;
68+ font-weight: bold;
69+ font-style: normal;
70+}
71+
72+@font-face {
73+ font-family: 'Verdana';
74+ src: url('/fonts/Verdanai.TTF') format('truetype');
75+ font-display: swap;
76+ font-weight: normal;
77+ font-style: italic;
78+}
79+
80+@font-face {
81+ font-family: 'Verdana';
82+ src: url('/fonts/Verdanaz.TTF') format('truetype');
83+ font-display: swap;
84+ font-weight: bold;
85+ font-style: italic;
86+}
87+
88+@font-face {
89+ font-family: 'Comic Sans MS';
90+ src: url('/fonts/Comic.TTF') format('truetype');
91+ font-display: swap;
92+ font-weight: normal;
93+ font-style: normal;
94+}
95+
96+@font-face {
97+ font-family: 'Comic Sans MS';
98+ src: url('/fonts/Comicbd.TTF') format('truetype');
99+ font-display: swap;
100+ font-weight: bold;
101+ font-style: normal;
102+}
103+
104+@font-face {
105+ font-family: 'Times New Roman';
106+ src: url('/fonts/Times.TTF') format('truetype');
107+ font-display: swap;
108+ font-weight: normal;
109+ font-style: normal;
110+}
111+
112+@font-face {
113+ font-family: 'Times New Roman';
114+ src: url('/fonts/Timesbd.TTF') format('truetype');
115+ font-display: swap;
116+ font-weight: bold;
117+ font-style: normal;
118+}
119+
120+@font-face {
121+ font-family: 'Times New Roman';
122+ src: url('/fonts/Timesi.TTF') format('truetype');
123+ font-display: swap;
124+ font-weight: normal;
125+ font-style: italic;
126+}
127+
128+@font-face {
129+ font-family: 'Times New Roman';
130+ src: url('/fonts/Timesbi.TTF') format('truetype');
131+ font-display: swap;
132+ font-weight: bold;
133+ font-style: italic;
134+}
135+
136+@font-face {
137+ font-family: 'Courier New';
138+ src: url('/fonts/LiberationMono-Regular.ttf') format('truetype');
139+ font-display: swap;
140+ font-weight: normal;
141+ font-style: normal;
142+}
143+
144+@font-face {
145+ font-family: 'Courier New';
146+ src: url('/fonts/LiberationMono-Bold.ttf') format('truetype');
147+ font-display: swap;
148+ font-weight: bold;
149+ font-style: normal;
150+}
151+
152+@font-face {
153+ font-family: 'Courier New';
154+ src: url('/fonts/LiberationMono-Italic.ttf') format('truetype');
155+ font-display: swap;
156+ font-weight: normal;
157+ font-style: italic;
158+}
159+
160+@font-face {
161+ font-family: 'Courier New';
162+ src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype');
163+ font-display: swap;
164+ font-weight: bold;
165+ font-style: italic;
166+}
167+
168+/* ===== RETRO FONT STYLING ===== */
169+/* Retro Windows fonts with proper fallbacks */
170+.retro-font-pixelated {
171+ font-family: "Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif;
172+}
173+
174+.retro-font-tahoma {
175+ font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif;
176+}
177+
178+.retro-font-verdana {
179+ font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif;
180+}
181+
182+.retro-font-comicSans {
183+ font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif;
184+}
185+
186+.retro-font-timesNewRoman {
187+ font-family: "Times New Roman", Times, serif;
188+}
189+
190+.retro-font-arial {
191+ font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif;
192+}
193+
194+.retro-font-courierNew {
195+ font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace;
196+}
197+
198+.retro-font-dos {
199+ font-family: "Perfect DOS VGA 437 Win", "Courier New", Courier, monospace;
200+}
201+
202+/* ===== GRADIENT TEXT EFFECTS ===== */
203+.gradient-text {
204+ background-size: 200% 200%;
205+ -webkit-background-clip: text;
206+ -webkit-text-fill-color: transparent;
207+ background-clip: text;
208+ animation: gradientShift 3s ease infinite;
209+}
210+
211+.gradient-text-static {
212+ /* Remove text-fill-color to allow inline color styles to work */
213+ /* Individual spans will have their own colors set inline */
214+ letter-spacing: normal;
215+ word-spacing: normal;
216+}
217+
218+/* Gradient text container uses flexbox */
219+.gradient-text-static {
220+ display: inline-flex !important;
221+ flex-wrap: wrap !important;
222+ align-items: baseline !important;
223+}
224+
225+/* Ensure gradient character spans don't add spacing */
226+.gradient-text-static > span {
227+ display: inline !important;
228+ margin: 0 !important;
229+ padding: 0 !important;
230+ letter-spacing: 0 !important;
231+ line-height: inherit !important;
232+ font-size: inherit !important;
233+}
234+
235+/* Ensure gradient character colors from inline styles are respected */
236+.gradient-text-static .gradient-char {
237+ -webkit-text-fill-color: initial !important;
238+}
239+
240+/* Gradient text in input fields - preserve normal input background */
241+input[style*="background-clip: text"] {
242+ /* Keep default input background while allowing gradient text */
243+}
244+
245+/* Gradient animation for AOL-style effects */
246+@keyframes gradientShift {
247+ 0% { background-position: 0% 50%; }
248+ 50% { background-position: 100% 50%; }
249+ 100% { background-position: 0% 50%; }
250+}
251+
252+/* Specific gradient presets */
253+.gradient-rainbow {
254+ background: linear-gradient(45deg,
255+ #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80,
256+ #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080);
257+}
258+
259+.gradient-fire {
260+ background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00);
261+}
262+
263+.gradient-ocean {
264+ background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF);
265+}
266+
267+.gradient-sunset {
268+ background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00);
269+}
270+
271+.gradient-neon {
272+ background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF);
273+}
274+
275+.gradient-forest {
276+ background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90);
277+}
278+
279+.gradient-royal {
280+ background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD);
281+}
282+
283+/* ===== CHAT MESSAGE FORMATTING ===== */
284+.formatted-message {
285+ word-break: break-word;
286+ line-height: 1.4;
287+ max-width: 100%;
288+}
289+
290+.formatted-message strong {
291+ font-weight: bold;
292+}
293+
294+.formatted-message em {
295+ font-style: italic;
296+}
297+
298+.formatted-message u {
299+ text-decoration: underline;
300+}
301+
302+.formatted-message s {
303+ text-decoration: line-through;
304+}
305+
306+.formatted-message code {
307+ background: #f0f0f0;
308+ padding: 1px 3px;
309+ border-radius: 2px;
310+ font-family: 'Courier New', Courier, monospace;
311+ font-size: 0.9em;
312+}
313+
314+/* ===== INPUT STYLING FIXES ===== */
315+/* Fix letter spacing issues with gradient text in inputs */
316+input[style*="background-clip: text"] {
317+ letter-spacing: normal !important;
318+ word-spacing: normal !important;
319+}
320+
321+/* Ensure gradient inputs don't have extra padding */
322+input.styled-input {
323+ letter-spacing: 0;
324+ word-spacing: 0;
325+}
326+
327+/* Ensure proper font weights are used */
328+.retro-font-tahoma {
329+ font-weight: 80;
330+}
331+
332+/* ===== TEXT STYLE SELECTOR STYLING ===== */
333+.text-formatting-toolbar {
334+ background: #f0f0f0;
335+ border: 2px inset #dfdfdf;
336+ padding: 4px;
337+ margin-bottom: 8px;
338+ display: flex;
339+ flex-wrap: wrap;
340+ gap: 4px;
341+ align-items: center;
342+}
343+
344+.formatting-button {
345+ width: 24px;
346+ height: 24px;
347+ border: 1px outset #dfdfdf;
348+ background: #f0f0f0;
349+ cursor: pointer;
350+ display: flex;
351+ align-items: center;
352+ justify-content: center;
353+ font-size: 12px;
354+ font-weight: bold;
355+ transition: all 0.1s ease;
356+}
357+
358+.formatting-button:hover {
359+ background: #e0e0e0;
360+}
361+
362+.formatting-button:active,
363+.formatting-button.active {
364+ border: 1px inset #dfdfdf;
365+ background: #d0d0d0;
366+}
367+
368+.font-selector {
369+ padding: 2px 4px;
370+ border: 1px inset #dfdfdf;
371+ background: white;
372+ font-size: 11px;
373+ min-width: 80px;
374+}
375+
376+.size-slider {
377+ width: 60px;
378+}
379+
380+.color-indicator {
381+ width: 16px;
382+ height: 16px;
383+ border: 1px solid #000;
384+ cursor: pointer;
385+ display: inline-block;
386+}
387+
388+/* ===== ACCESSIBILITY IMPROVEMENTS ===== */
389+@media (prefers-reduced-motion: reduce) {
390+ .gradient-text {
391+ animation: none;
392+ }
393+}
394+
395+/* High contrast mode support */
396+@media (prefers-contrast: high) {
397+ .gradient-text {
398+ -webkit-text-fill-color: unset;
399+ background: none;
400+ color: inherit;
401+ }
402+
403+ .gradient-text-static {
404+ -webkit-text-fill-color: unset;
405+ background: none;
406+ color: inherit;
407+ }
408+}
409+
410+/* ===== RETRO STYLING ENHANCEMENTS ===== */
411+.retro-message {
412+ font-family: inherit;
413+ line-height: 1.3;
414+ word-wrap: break-word;
415+}
416+
417+.retro-message.style-bold {
418+ font-weight: bold;
419+}
420+
421+.retro-message.style-italic {
422+ font-style: italic;
423+}
424+
425+.retro-message.style-underline {
426+ text-decoration: underline;
427+}
428+
429+.retro-message.style-strikethrough {
430+ text-decoration: line-through;
431+}
432+
433+.retro-message.style-underline.style-strikethrough {
434+ text-decoration: underline line-through;
435+}
436+
437+/* ===== PERFORMANCE OPTIMIZATIONS ===== */
438+.gradient-text,
439+.gradient-text-static {
440+ will-change: background-position;
441+ contain: layout style paint;
442+}
443+
444+/* Limit gradient text length for performance */
445+.gradient-text-long {
446+ max-width: 500px;
447+ overflow: hidden;
448+ text-overflow: ellipsis;
449+}
5450
@@ -2,4 +2,449 @@
2 @import 'tailwindcss/components';2 @import 'tailwindcss/components';
3 @import 'tailwindcss/utilities';3 @import 'tailwindcss/utilities';
4 @import 'xp.css';4 @import 'xp.css';
5+@import './lib/styles/fonts.css';
6+
7+/* Local font faces with 30-day cache */
8+@font-face {
9+ font-family: 'Arial';
10+ src: url('/fonts/Arial.TTF') format('truetype');
11+ font-display: swap;
12+ font-weight: normal;
13+ font-style: normal;
14+}
15+
16+@font-face {
17+ font-family: 'Arial';
18+ src: url('/fonts/Arialbd.TTF') format('truetype');
19+ font-display: swap;
20+ font-weight: bold;
21+ font-style: normal;
22+}
23+
24+@font-face {
25+ font-family: 'Arial';
26+ src: url('/fonts/Ariali.TTF') format('truetype');
27+ font-display: swap;
28+ font-weight: normal;
29+ font-style: italic;
30+}
31+
32+@font-face {
33+ font-family: 'Arial';
34+ src: url('/fonts/Arialbi.TTF') format('truetype');
35+ font-display: swap;
36+ font-weight: bold;
37+ font-style: italic;
38+}
39+
40+@font-face {
41+ font-family: 'Tahoma';
42+ src: url('/fonts/tahoma.ttf') format('truetype');
43+ font-display: swap;
44+ font-weight: 80;
45+ font-style: normal;
46+}
47+
48+@font-face {
49+ font-family: 'Tahoma';
50+ src: url('/fonts/tahomabd.ttf') format('truetype');
51+ font-display: swap;
52+ font-weight: 200;
53+ font-style: normal;
54+}
55+
56+@font-face {
57+ font-family: 'Verdana';
58+ src: url('/fonts/Verdana.TTF') format('truetype');
59+ font-display: swap;
60+ font-weight: normal;
61+ font-style: normal;
62+}
63+
64+@font-face {
65+ font-family: 'Verdana';
66+ src: url('/fonts/Verdanab.TTF') format('truetype');
67+ font-display: swap;
68+ font-weight: bold;
69+ font-style: normal;
70+}
71+
72+@font-face {
73+ font-family: 'Verdana';
74+ src: url('/fonts/Verdanai.TTF') format('truetype');
75+ font-display: swap;
76+ font-weight: normal;
77+ font-style: italic;
78+}
79+
80+@font-face {
81+ font-family: 'Verdana';
82+ src: url('/fonts/Verdanaz.TTF') format('truetype');
83+ font-display: swap;
84+ font-weight: bold;
85+ font-style: italic;
86+}
87+
88+@font-face {
89+ font-family: 'Comic Sans MS';
90+ src: url('/fonts/Comic.TTF') format('truetype');
91+ font-display: swap;
92+ font-weight: normal;
93+ font-style: normal;
94+}
95+
96+@font-face {
97+ font-family: 'Comic Sans MS';
98+ src: url('/fonts/Comicbd.TTF') format('truetype');
99+ font-display: swap;
100+ font-weight: bold;
101+ font-style: normal;
102+}
103+
104+@font-face {
105+ font-family: 'Times New Roman';
106+ src: url('/fonts/Times.TTF') format('truetype');
107+ font-display: swap;
108+ font-weight: normal;
109+ font-style: normal;
110+}
111+
112+@font-face {
113+ font-family: 'Times New Roman';
114+ src: url('/fonts/Timesbd.TTF') format('truetype');
115+ font-display: swap;
116+ font-weight: bold;
117+ font-style: normal;
118+}
119+
120+@font-face {
121+ font-family: 'Times New Roman';
122+ src: url('/fonts/Timesi.TTF') format('truetype');
123+ font-display: swap;
124+ font-weight: normal;
125+ font-style: italic;
126+}
127+
128+@font-face {
129+ font-family: 'Times New Roman';
130+ src: url('/fonts/Timesbi.TTF') format('truetype');
131+ font-display: swap;
132+ font-weight: bold;
133+ font-style: italic;
134+}
135+
136+@font-face {
137+ font-family: 'Courier New';
138+ src: url('/fonts/LiberationMono-Regular.ttf') format('truetype');
139+ font-display: swap;
140+ font-weight: normal;
141+ font-style: normal;
142+}
143+
144+@font-face {
145+ font-family: 'Courier New';
146+ src: url('/fonts/LiberationMono-Bold.ttf') format('truetype');
147+ font-display: swap;
148+ font-weight: bold;
149+ font-style: normal;
150+}
151+
152+@font-face {
153+ font-family: 'Courier New';
154+ src: url('/fonts/LiberationMono-Italic.ttf') format('truetype');
155+ font-display: swap;
156+ font-weight: normal;
157+ font-style: italic;
158+}
159+
160+@font-face {
161+ font-family: 'Courier New';
162+ src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype');
163+ font-display: swap;
164+ font-weight: bold;
165+ font-style: italic;
166+}
167+
168+/* ===== RETRO FONT STYLING ===== */
169+/* Retro Windows fonts with proper fallbacks */
170+.retro-font-pixelated {
171+ font-family: "Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif;
172+}
173+
174+.retro-font-tahoma {
175+ font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif;
176+}
177+
178+.retro-font-verdana {
179+ font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif;
180+}
181+
182+.retro-font-comicSans {
183+ font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif;
184+}
185+
186+.retro-font-timesNewRoman {
187+ font-family: "Times New Roman", Times, serif;
188+}
189+
190+.retro-font-arial {
191+ font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif;
192+}
193+
194+.retro-font-courierNew {
195+ font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace;
196+}
197+
198+.retro-font-dos {
199+ font-family: "Perfect DOS VGA 437 Win", "Courier New", Courier, monospace;
200+}
201+
202+/* ===== GRADIENT TEXT EFFECTS ===== */
203+.gradient-text {
204+ background-size: 200% 200%;
205+ -webkit-background-clip: text;
206+ -webkit-text-fill-color: transparent;
207+ background-clip: text;
208+ animation: gradientShift 3s ease infinite;
209+}
210+
211+.gradient-text-static {
212+ /* Remove text-fill-color to allow inline color styles to work */
213+ /* Individual spans will have their own colors set inline */
214+ letter-spacing: normal;
215+ word-spacing: normal;
216+}
217+
218+/* Gradient text container uses flexbox */
219+.gradient-text-static {
220+ display: inline-flex !important;
221+ flex-wrap: wrap !important;
222+ align-items: baseline !important;
223+}
224+
225+/* Ensure gradient character spans don't add spacing */
226+.gradient-text-static > span {
227+ display: inline !important;
228+ margin: 0 !important;
229+ padding: 0 !important;
230+ letter-spacing: 0 !important;
231+ line-height: inherit !important;
232+ font-size: inherit !important;
233+}
234+
235+/* Ensure gradient character colors from inline styles are respected */
236+.gradient-text-static .gradient-char {
237+ -webkit-text-fill-color: initial !important;
238+}
239+
240+/* Gradient text in input fields - preserve normal input background */
241+input[style*="background-clip: text"] {
242+ /* Keep default input background while allowing gradient text */
243+}
244+
245+/* Gradient animation for AOL-style effects */
246+@keyframes gradientShift {
247+ 0% { background-position: 0% 50%; }
248+ 50% { background-position: 100% 50%; }
249+ 100% { background-position: 0% 50%; }
250+}
251+
252+/* Specific gradient presets */
253+.gradient-rainbow {
254+ background: linear-gradient(45deg,
255+ #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80,
256+ #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080);
257+}
258+
259+.gradient-fire {
260+ background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00);
261+}
262+
263+.gradient-ocean {
264+ background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF);
265+}
266+
267+.gradient-sunset {
268+ background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00);
269+}
270+
271+.gradient-neon {
272+ background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF);
273+}
274+
275+.gradient-forest {
276+ background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90);
277+}
278+
279+.gradient-royal {
280+ background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD);
281+}
282+
283+/* ===== CHAT MESSAGE FORMATTING ===== */
284+.formatted-message {
285+ word-break: break-word;
286+ line-height: 1.4;
287+ max-width: 100%;
288+}
289+
290+.formatted-message strong {
291+ font-weight: bold;
292+}
293+
294+.formatted-message em {
295+ font-style: italic;
296+}
297+
298+.formatted-message u {
299+ text-decoration: underline;
300+}
301+
302+.formatted-message s {
303+ text-decoration: line-through;
304+}
305+
306+.formatted-message code {
307+ background: #f0f0f0;
308+ padding: 1px 3px;
309+ border-radius: 2px;
310+ font-family: 'Courier New', Courier, monospace;
311+ font-size: 0.9em;
312+}
313+
314+/* ===== INPUT STYLING FIXES ===== */
315+/* Fix letter spacing issues with gradient text in inputs */
316+input[style*="background-clip: text"] {
317+ letter-spacing: normal !important;
318+ word-spacing: normal !important;
319+}
320+
321+/* Ensure gradient inputs don't have extra padding */
322+input.styled-input {
323+ letter-spacing: 0;
324+ word-spacing: 0;
325+}
326+
327+/* Ensure proper font weights are used */
328+.retro-font-tahoma {
329+ font-weight: 80;
330+}
331+
332+/* ===== TEXT STYLE SELECTOR STYLING ===== */
333+.text-formatting-toolbar {
334+ background: #f0f0f0;
335+ border: 2px inset #dfdfdf;
336+ padding: 4px;
337+ margin-bottom: 8px;
338+ display: flex;
339+ flex-wrap: wrap;
340+ gap: 4px;
341+ align-items: center;
342+}
343+
344+.formatting-button {
345+ width: 24px;
346+ height: 24px;
347+ border: 1px outset #dfdfdf;
348+ background: #f0f0f0;
349+ cursor: pointer;
350+ display: flex;
351+ align-items: center;
352+ justify-content: center;
353+ font-size: 12px;
354+ font-weight: bold;
355+ transition: all 0.1s ease;
356+}
357+
358+.formatting-button:hover {
359+ background: #e0e0e0;
360+}
361+
362+.formatting-button:active,
363+.formatting-button.active {
364+ border: 1px inset #dfdfdf;
365+ background: #d0d0d0;
366+}
367+
368+.font-selector {
369+ padding: 2px 4px;
370+ border: 1px inset #dfdfdf;
371+ background: white;
372+ font-size: 11px;
373+ min-width: 80px;
374+}
375+
376+.size-slider {
377+ width: 60px;
378+}
379+
380+.color-indicator {
381+ width: 16px;
382+ height: 16px;
383+ border: 1px solid #000;
384+ cursor: pointer;
385+ display: inline-block;
386+}
387+
388+/* ===== ACCESSIBILITY IMPROVEMENTS ===== */
389+@media (prefers-reduced-motion: reduce) {
390+ .gradient-text {
391+ animation: none;
392+ }
393+}
394+
395+/* High contrast mode support */
396+@media (prefers-contrast: high) {
397+ .gradient-text {
398+ -webkit-text-fill-color: unset;
399+ background: none;
400+ color: inherit;
401+ }
402+
403+ .gradient-text-static {
404+ -webkit-text-fill-color: unset;
405+ background: none;
406+ color: inherit;
407+ }
408+}
409+
410+/* ===== RETRO STYLING ENHANCEMENTS ===== */
411+.retro-message {
412+ font-family: inherit;
413+ line-height: 1.3;
414+ word-wrap: break-word;
415+}
416+
417+.retro-message.style-bold {
418+ font-weight: bold;
419+}
420+
421+.retro-message.style-italic {
422+ font-style: italic;
423+}
424+
425+.retro-message.style-underline {
426+ text-decoration: underline;
427+}
428+
429+.retro-message.style-strikethrough {
430+ text-decoration: line-through;
431+}
432+
433+.retro-message.style-underline.style-strikethrough {
434+ text-decoration: underline line-through;
435+}
436+
437+/* ===== PERFORMANCE OPTIMIZATIONS ===== */
438+.gradient-text,
439+.gradient-text-static {
440+ will-change: background-position;
441+ contain: layout style paint;
442+}
443+
444+/* Limit gradient text length for performance */
445+.gradient-text-long {
446+ max-width: 500px;
447+ overflow: hidden;
448+ text-overflow: ellipsis;
449+}
5 450
M src/lib/components/aim-login.svelte +50 -45
@@ -5,9 +5,10 @@
55 import type { User, SafeUser } from '$lib/types/chat';
66 import { invalidateAll } from '$app/navigation';
77 import { chatState } from '$lib/states/chat.svelte';
8- import Turnstile from './turnstile.svelte';
98 import { createSafeUser } from '$lib/types/chat';
109 import { onMount } from 'svelte';
10+ import PasswordStrengthIndicator from './password-strength-indicator.svelte';
11+ import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password';
1112
1213 interface $$Props {
1314 showAuth: boolean;
@@ -24,7 +25,6 @@
2425 let siPassword = $state('');
2526 let error = $state('');
2627 let loginStatus = $state('idle');
27- let siTurnstileToken = $state('');
2828
2929 // Sign Up state
3030 let suUsername = $state('');
@@ -35,6 +35,10 @@
3535 let signupStatus = $state('idle');
3636 let suTurnstileToken = $state('');
3737
38+ // Password validation state
39+ let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
40+ let showPasswordStrength = $state(false);
41+
3842 // Added dragging state and handler similar to chat-room
3943 let windowX = $state(0);
4044 let windowY = $state(0);
@@ -60,8 +64,11 @@
6064 async function handleSigninSubmit() {
6165 error = '';
6266 loginStatus = 'loading';
63- if (!siUsername || !siPassword || !siTurnstileToken) {
64- error = 'Please complete all fields including the security check';
67+
68+ // Validate login data
69+ const validationError = validateLoginData();
70+ if (validationError) {
71+ error = validationError;
6572 loginStatus = 'error';
6673 return;
6774 }
@@ -73,8 +80,7 @@
7380 headers: { 'Content-Type': 'application/json' },
7481 body: JSON.stringify({
7582 username: siUsername,
76- password: siPassword,
77- turnstileToken: siTurnstileToken
83+ password: siPassword
7884 })
7985 });
8086
@@ -107,41 +113,30 @@
107113 }
108114 }
109115
110- // New: Create a helper function for client-side validations based on server rules.
111- function validateSignupData(): string | null {
112- // Trim values
113- const username = suUsername.trim();
114- const password = suPassword.trim();
115- const confirmPasswordValue = suConfirmPassword.trim();
116-
117- if (!username || !password || !confirmPasswordValue) {
118- return 'All fields must be filled';
119- }
120-
121- if (username.length < 3) {
122- return 'Username must be at least 3 characters';
123- }
124-
125- if (username.length > 32) {
126- return 'Username must be at most 32 characters';
127- }
128-
129- // Only allow letters, numbers, underscores, and dashes in the username.
130- const usernameRegex = /^[a-zA-Z0-9_-]+$/;
131- if (!usernameRegex.test(username)) {
132- return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
133- }
134-
135- if (password !== confirmPasswordValue) {
136- return 'Passwords do not match';
116+ // Validation using Zod schemas
117+ function validateLoginData() {
118+ try {
119+ loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
120+ return null;
121+ } catch (err: any) {
122+ return err.errors?.[0]?.message || 'Invalid input';
137123 }
124+ }
138125
139- if (password.length < 8 || password.length > 64) {
140- return 'Password must be between 8 and 64 characters';
126+ function validateSignupData() {
127+ try {
128+ const registrationSchema = createRegistrationSchema(passwordConstraints);
129+ registrationSchema.parse({
130+ suUsername: suUsername.trim(),
131+ suPassword: suPassword.trim(),
132+ suConfirmPassword: suConfirmPassword.trim(),
133+ captchaAnswer: captchaAnswer.trim(),
134+ turnstileToken: suTurnstileToken
135+ });
136+ return null;
137+ } catch (err: any) {
138+ return err.errors?.[0]?.message || 'Invalid input';
141139 }
142-
143- // All checks passed.
144- return null;
145140 }
146141
147142 async function handleSignupSubmit() {
@@ -156,10 +151,9 @@
156151 return;
157152 }
158153
159- // Additional check for captcha and Turnstile token,
160- // in case those fields are not filled.
161- if (!captchaAnswer || !suTurnstileToken) {
162- signupError = 'Please complete all fields including the security check';
154+ // Additional check for captcha
155+ if (!captchaAnswer) {
156+ signupError = 'Please complete all fields including the PDR question';
163157 signupStatus = 'error';
164158 return;
165159 }
@@ -271,7 +265,6 @@
271265 <label for="si-password">Mot de passe</label>
272266 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
273267 </div>
274- <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
275268 {#if error}
276269 <div class="error">{error}</div>
277270 {/if}
@@ -311,7 +304,20 @@
311304 </div>
312305 <div class="form-group">
313306 <label for="su-password">Mot de passe</label>
314- <input type="password" id="su-password" bind:value={suPassword} placeholder="Mot de passe" />
307+ <input
308+ type="password"
309+ id="su-password"
310+ bind:value={suPassword}
311+ placeholder="Mot de passe"
312+ onfocus={() => showPasswordStrength = true}
313+ />
314+ {#if showPasswordStrength}
315+ <PasswordStrengthIndicator
316+ password={suPassword}
317+ constraints={passwordConstraints}
318+ showDetails={true}
319+ />
320+ {/if}
315321 </div>
316322 <div class="form-group">
317323 <label for="su-confirm-password">Confirmer le mot de passe</label>
@@ -321,7 +327,6 @@
321327 <label for="captcha">Quelle est la signification de PDR ?</label>
322328 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
323329 </div>
324- <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
325330 {#if signupError}
326331 <div class="error">{signupError}</div>
327332 {/if}
@@ -5,9 +5,10 @@
5 import type { User, SafeUser } from '$lib/types/chat';5 import type { User, SafeUser } from '$lib/types/chat';
6 import { invalidateAll } from '$app/navigation';6 import { invalidateAll } from '$app/navigation';
7 import { chatState } from '$lib/states/chat.svelte';7 import { chatState } from '$lib/states/chat.svelte';
8- import Turnstile from './turnstile.svelte';
9 import { createSafeUser } from '$lib/types/chat';8 import { createSafeUser } from '$lib/types/chat';
10 import { onMount } from 'svelte';9 import { onMount } from 'svelte';
10+ import PasswordStrengthIndicator from './password-strength-indicator.svelte';
11+ import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password';
11 12
12 interface $$Props {13 interface $$Props {
13 showAuth: boolean;14 showAuth: boolean;
@@ -24,7 +25,6 @@
24 let siPassword = $state('');25 let siPassword = $state('');
25 let error = $state('');26 let error = $state('');
26 let loginStatus = $state('idle');27 let loginStatus = $state('idle');
27- let siTurnstileToken = $state('');
28 28
29 // Sign Up state29 // Sign Up state
30 let suUsername = $state('');30 let suUsername = $state('');
@@ -35,6 +35,10 @@
35 let signupStatus = $state('idle');35 let signupStatus = $state('idle');
36 let suTurnstileToken = $state('');36 let suTurnstileToken = $state('');
37 37
38+ // Password validation state
39+ let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
40+ let showPasswordStrength = $state(false);
41+
38 // Added dragging state and handler similar to chat-room42 // Added dragging state and handler similar to chat-room
39 let windowX = $state(0);43 let windowX = $state(0);
40 let windowY = $state(0);44 let windowY = $state(0);
@@ -60,8 +64,11 @@
60 async function handleSigninSubmit() {64 async function handleSigninSubmit() {
61 error = '';65 error = '';
62 loginStatus = 'loading';66 loginStatus = 'loading';
63- if (!siUsername || !siPassword || !siTurnstileToken) {67+
64- error = 'Please complete all fields including the security check';68+ // Validate login data
69+ const validationError = validateLoginData();
70+ if (validationError) {
71+ error = validationError;
65 loginStatus = 'error';72 loginStatus = 'error';
66 return;73 return;
67 }74 }
@@ -73,8 +80,7 @@
73 headers: { 'Content-Type': 'application/json' },80 headers: { 'Content-Type': 'application/json' },
74 body: JSON.stringify({ 81 body: JSON.stringify({
75 username: siUsername, 82 username: siUsername,
76- password: siPassword,83+ password: siPassword
77- turnstileToken: siTurnstileToken
78 })84 })
79 });85 });
80 86
@@ -107,41 +113,30 @@
107 }113 }
108 }114 }
109 115
110- // New: Create a helper function for client-side validations based on server rules.116+ // Validation using Zod schemas
111- function validateSignupData(): string | null {117+ function validateLoginData() {
112- // Trim values118+ try {
113- const username = suUsername.trim();119+ loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
114- const password = suPassword.trim();120+ return null;
115- const confirmPasswordValue = suConfirmPassword.trim();121+ } catch (err: any) {
116-122+ return err.errors?.[0]?.message || 'Invalid input';
117- if (!username || !password || !confirmPasswordValue) {
118- return 'All fields must be filled';
119- }
120-
121- if (username.length < 3) {
122- return 'Username must be at least 3 characters';
123- }
124-
125- if (username.length > 32) {
126- return 'Username must be at most 32 characters';
127- }
128-
129- // Only allow letters, numbers, underscores, and dashes in the username.
130- const usernameRegex = /^[a-zA-Z0-9_-]+$/;
131- if (!usernameRegex.test(username)) {
132- return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
133- }
134-
135- if (password !== confirmPasswordValue) {
136- return 'Passwords do not match';
137 }123 }
124+ }
138 125
139- if (password.length < 8 || password.length > 64) {126+ function validateSignupData() {
140- return 'Password must be between 8 and 64 characters';127+ try {
128+ const registrationSchema = createRegistrationSchema(passwordConstraints);
129+ registrationSchema.parse({
130+ suUsername: suUsername.trim(),
131+ suPassword: suPassword.trim(),
132+ suConfirmPassword: suConfirmPassword.trim(),
133+ captchaAnswer: captchaAnswer.trim(),
134+ turnstileToken: suTurnstileToken
135+ });
136+ return null;
137+ } catch (err: any) {
138+ return err.errors?.[0]?.message || 'Invalid input';
141 }139 }
142-
143- // All checks passed.
144- return null;
145 }140 }
146 141
147 async function handleSignupSubmit() {142 async function handleSignupSubmit() {
@@ -156,10 +151,9 @@
156 return;151 return;
157 }152 }
158 153
159- // Additional check for captcha and Turnstile token,154+ // Additional check for captcha
160- // in case those fields are not filled.155+ if (!captchaAnswer) {
161- if (!captchaAnswer || !suTurnstileToken) {156+ signupError = 'Please complete all fields including the PDR question';
162- signupError = 'Please complete all fields including the security check';
163 signupStatus = 'error';157 signupStatus = 'error';
164 return;158 return;
165 }159 }
@@ -271,7 +265,6 @@
271 <label for="si-password">Mot de passe</label>265 <label for="si-password">Mot de passe</label>
272 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />266 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
273 </div>267 </div>
274- <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
275 {#if error}268 {#if error}
276 <div class="error">{error}</div>269 <div class="error">{error}</div>
277 {/if}270 {/if}
@@ -311,7 +304,20 @@
311 </div>304 </div>
312 <div class="form-group">305 <div class="form-group">
313 <label for="su-password">Mot de passe</label>306 <label for="su-password">Mot de passe</label>
314- <input type="password" id="su-password" bind:value={suPassword} placeholder="Mot de passe" />307+ <input
308+ type="password"
309+ id="su-password"
310+ bind:value={suPassword}
311+ placeholder="Mot de passe"
312+ onfocus={() => showPasswordStrength = true}
313+ />
314+ {#if showPasswordStrength}
315+ <PasswordStrengthIndicator
316+ password={suPassword}
317+ constraints={passwordConstraints}
318+ showDetails={true}
319+ />
320+ {/if}
315 </div>321 </div>
316 <div class="form-group">322 <div class="form-group">
317 <label for="su-confirm-password">Confirmer le mot de passe</label>323 <label for="su-confirm-password">Confirmer le mot de passe</label>
@@ -321,7 +327,6 @@
321 <label for="captcha">Quelle est la signification de PDR ?</label>327 <label for="captcha">Quelle est la signification de PDR ?</label>
322 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />328 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
323 </div>329 </div>
324- <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
325 {#if signupError}330 {#if signupError}
326 <div class="error">{signupError}</div>331 <div class="error">{signupError}</div>
327 {/if}332 {/if}
M src/lib/components/chat-room.svelte +82 -15
@@ -11,8 +11,14 @@ import LoadingButton from './ui/button-loading.svelte';
1111 import Tooltip from './ui/tooltip.svelte';
1212 import LoadingDots from './ui/loading-dots.svelte';
1313 import AimLogin from './aim-login.svelte';
14+import FormattedMessage from './formatted-message.svelte';
15+import TextFormattingToolbar from './text-formatting-toolbar.svelte';
16+import { DEFAULT_TEXT_STYLE, type TextStyle, generateInputCSSStyle } from '../types/text-formatting';
1417 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
1518
19+// Props destructuring must come first
20+let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
21+
1622 // Initialize with default values for SSR
1723 let windowWidth = $state(400);
1824 let windowHeight = $state(500);
@@ -26,6 +32,14 @@ let isInitialLoading = $state(true);
2632 let showAuth = $state(false);
2733 let isMinimized = $state(false);
2834
35+// Text formatting state - initialize directly with merged initial style
36+let currentTextStyle = $state<TextStyle>({
37+ ...DEFAULT_TEXT_STYLE,
38+ ...initialTextStyle,
39+ color: initialTextStyle.color || '#000000' // Ensure we always have a valid color
40+});
41+let showFormattingToolbar = $state(true);
42+
2943 // Rate limiting state
3044 let cooldownEndTime = $state<number | null>(null);
3145 let cooldownProgress = $state(0);
@@ -60,8 +74,6 @@ function startCooldownTimer(retryAfter: number) {
6074 updateCooldownProgress();
6175 }
6276
63-let { showChatRoom = $bindable() } = $props();
64-
6577 function handleClose() {
6678 showChatRoom = false;
6779 }
@@ -239,7 +251,7 @@ async function handleSubmit() {
239251
240252 isSendingMessage = true;
241253 try {
242- const response = await chatState.sendMessage(currentMessage);
254+ const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);
243255 if (!response.success && response.isRateLimited && response.retryAfter) {
244256 startCooldownTimer(response.retryAfter);
245257 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
@@ -459,7 +471,7 @@ function handleLoginSuccess() {
459471 ondragmove={handleDragMove}
460472 >
461473 <div class="title-bar">
462- <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
474+ <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;">
463475 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
464476 {#if sseError && !isMinimized}
465477 <span class="connection-error">⚠️ Erreur de connexion</span>
@@ -563,21 +575,49 @@ function handleLoginSuccess() {
563575 }}>
564576 <span class="nickname pointer-events-none">{message.user.nickname}:</span>
565577 </Tooltip>
566- <span class="content">{message.content}</span>
578+ <FormattedMessage {message} allowFormatting={true} />
567579 {/if}
568580 </div>
569581 {/each}
570582 </div>
571583
584+ <!-- Text Formatting Toolbar -->
585+ {#if currentUser}
586+ <div style="margin-bottom: 0.25rem;">
587+ <TextFormattingToolbar
588+ bind:style={currentTextStyle}
589+ compact={true}
590+ showFontSelector={true}
591+ showGradients={true}
592+ />
593+ </div>
594+ {/if}
595+
596+
572597 <div class="field-row input-container" style="margin: 0;">
573- <input
574- type="text"
575- bind:value={currentMessage}
576- style="flex: 1;"
577- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
578- placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
579- disabled={!currentUser || Boolean(cooldownEndTime)}
580- />
598+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
599+ <div class="gradient-input-wrapper" style="flex: 1; position: relative; background: white;">
600+ <input
601+ type="text"
602+ bind:value={currentMessage}
603+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
604+ style="width: 100%; background: transparent; {generateInputCSSStyle(currentTextStyle)}"
605+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
606+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
607+ disabled={!currentUser || Boolean(cooldownEndTime)}
608+ />
609+ </div>
610+ {:else}
611+ <input
612+ type="text"
613+ bind:value={currentMessage}
614+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
615+ style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"
616+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
617+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
618+ disabled={!currentUser || Boolean(cooldownEndTime)}
619+ />
620+ {/if}
581621 <LoadingButton
582622 onclick={handleSubmit}
583623 disabled={!currentUser || Boolean(cooldownEndTime)}
@@ -632,9 +672,14 @@ function handleLoginSuccess() {
632672 {/if}
633673
634674 <style>
635- .chat-area, .users-list, input {
675+ .chat-area, .users-list {
636676 font-size: 1rem;
637- font-family: Arial, Verdana, Tahoma, sans-serif;
677+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
678+ }
679+
680+ input:not(.styled-input) {
681+ font-size: 1rem;
682+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
638683 }
639684
640685 .title-bar {
@@ -674,6 +719,16 @@ function handleLoginSuccess() {
674719 gap: 0.5rem;
675720 align-items: flex-start;
676721 }
722+
723+ /* Fix gradient text spacing in messages */
724+ .message :global(.gradient-text-static) {
725+ letter-spacing: normal !important;
726+ word-spacing: normal !important;
727+ }
728+
729+ .message :global(.gradient-text-static span) {
730+ letter-spacing: 0 !important;
731+ }
677732
678733 .message .nickname {
679734 font-weight: bold;
@@ -1036,4 +1091,16 @@ function handleLoginSuccess() {
10361091 .pointer-events-none {
10371092 pointer-events: none;
10381093 }
1094+
1095+ .gradient-input-wrapper {
1096+ border: 1px inset #dfdfdf;
1097+ padding: 2px;
1098+ }
1099+
1100+ .gradient-input-wrapper input {
1101+ border: none !important;
1102+ outline: none !important;
1103+ padding: 1px 3px;
1104+ }
1105+
10391106 </style>⏎̸
@@ -11,8 +11,14 @@ import LoadingButton from './ui/button-loading.svelte';
11 import Tooltip from './ui/tooltip.svelte';11 import Tooltip from './ui/tooltip.svelte';
12 import LoadingDots from './ui/loading-dots.svelte';12 import LoadingDots from './ui/loading-dots.svelte';
13 import AimLogin from './aim-login.svelte';13 import AimLogin from './aim-login.svelte';
14+import FormattedMessage from './formatted-message.svelte';
15+import TextFormattingToolbar from './text-formatting-toolbar.svelte';
16+import { DEFAULT_TEXT_STYLE, type TextStyle, generateInputCSSStyle } from '../types/text-formatting';
14 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';17 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
15 18
19+// Props destructuring must come first
20+let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
21+
16 // Initialize with default values for SSR22 // Initialize with default values for SSR
17 let windowWidth = $state(400);23 let windowWidth = $state(400);
18 let windowHeight = $state(500);24 let windowHeight = $state(500);
@@ -26,6 +32,14 @@ let isInitialLoading = $state(true);
26 let showAuth = $state(false);32 let showAuth = $state(false);
27 let isMinimized = $state(false);33 let isMinimized = $state(false);
28 34
35+// Text formatting state - initialize directly with merged initial style
36+let currentTextStyle = $state<TextStyle>({
37+ ...DEFAULT_TEXT_STYLE,
38+ ...initialTextStyle,
39+ color: initialTextStyle.color || '#000000' // Ensure we always have a valid color
40+});
41+let showFormattingToolbar = $state(true);
42+
29 // Rate limiting state43 // Rate limiting state
30 let cooldownEndTime = $state<number | null>(null);44 let cooldownEndTime = $state<number | null>(null);
31 let cooldownProgress = $state(0);45 let cooldownProgress = $state(0);
@@ -60,8 +74,6 @@ function startCooldownTimer(retryAfter: number) {
60 updateCooldownProgress();74 updateCooldownProgress();
61 }75 }
62 76
63-let { showChatRoom = $bindable() } = $props();
64-
65 function handleClose() {77 function handleClose() {
66 showChatRoom = false;78 showChatRoom = false;
67 }79 }
@@ -239,7 +251,7 @@ async function handleSubmit() {
239 251
240 isSendingMessage = true;252 isSendingMessage = true;
241 try {253 try {
242- const response = await chatState.sendMessage(currentMessage);254+ const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);
243 if (!response.success && response.isRateLimited && response.retryAfter) {255 if (!response.success && response.isRateLimited && response.retryAfter) {
244 startCooldownTimer(response.retryAfter);256 startCooldownTimer(response.retryAfter);
245 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';257 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
@@ -459,7 +471,7 @@ function handleLoginSuccess() {
459 ondragmove={handleDragMove}471 ondragmove={handleDragMove}
460 >472 >
461 <div class="title-bar">473 <div class="title-bar">
462- <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">474+ <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;">
463 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}475 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
464 {#if sseError && !isMinimized}476 {#if sseError && !isMinimized}
465 <span class="connection-error">⚠️ Erreur de connexion</span>477 <span class="connection-error">⚠️ Erreur de connexion</span>
@@ -563,21 +575,49 @@ function handleLoginSuccess() {
563 }}>575 }}>
564 <span class="nickname pointer-events-none">{message.user.nickname}:</span>576 <span class="nickname pointer-events-none">{message.user.nickname}:</span>
565 </Tooltip>577 </Tooltip>
566- <span class="content">{message.content}</span>578+ <FormattedMessage {message} allowFormatting={true} />
567 {/if}579 {/if}
568 </div>580 </div>
569 {/each}581 {/each}
570 </div>582 </div>
571 583
584+ <!-- Text Formatting Toolbar -->
585+ {#if currentUser}
586+ <div style="margin-bottom: 0.25rem;">
587+ <TextFormattingToolbar
588+ bind:style={currentTextStyle}
589+ compact={true}
590+ showFontSelector={true}
591+ showGradients={true}
592+ />
593+ </div>
594+ {/if}
595+
596+
572 <div class="field-row input-container" style="margin: 0;">597 <div class="field-row input-container" style="margin: 0;">
573- <input 598+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
574- type="text" 599+ <div class="gradient-input-wrapper" style="flex: 1; position: relative; background: white;">
575- bind:value={currentMessage}600+ <input
576- style="flex: 1;"601+ type="text"
577- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}602+ bind:value={currentMessage}
578- placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}603+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
579- disabled={!currentUser || Boolean(cooldownEndTime)}604+ style="width: 100%; background: transparent; {generateInputCSSStyle(currentTextStyle)}"
580- />605+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
606+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
607+ disabled={!currentUser || Boolean(cooldownEndTime)}
608+ />
609+ </div>
610+ {:else}
611+ <input
612+ type="text"
613+ bind:value={currentMessage}
614+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
615+ style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"
616+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
617+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
618+ disabled={!currentUser || Boolean(cooldownEndTime)}
619+ />
620+ {/if}
581 <LoadingButton 621 <LoadingButton
582 onclick={handleSubmit} 622 onclick={handleSubmit}
583 disabled={!currentUser || Boolean(cooldownEndTime)} 623 disabled={!currentUser || Boolean(cooldownEndTime)}
@@ -632,9 +672,14 @@ function handleLoginSuccess() {
632 {/if}672 {/if}
633 673
634 <style>674 <style>
635- .chat-area, .users-list, input {675+ .chat-area, .users-list {
636 font-size: 1rem;676 font-size: 1rem;
637- font-family: Arial, Verdana, Tahoma, sans-serif;677+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
678+ }
679+
680+ input:not(.styled-input) {
681+ font-size: 1rem;
682+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
638 }683 }
639 684
640 .title-bar {685 .title-bar {
@@ -674,6 +719,16 @@ function handleLoginSuccess() {
674 gap: 0.5rem;719 gap: 0.5rem;
675 align-items: flex-start;720 align-items: flex-start;
676 }721 }
722+
723+ /* Fix gradient text spacing in messages */
724+ .message :global(.gradient-text-static) {
725+ letter-spacing: normal !important;
726+ word-spacing: normal !important;
727+ }
728+
729+ .message :global(.gradient-text-static span) {
730+ letter-spacing: 0 !important;
731+ }
677 732
678 .message .nickname {733 .message .nickname {
679 font-weight: bold;734 font-weight: bold;
@@ -1036,4 +1091,16 @@ function handleLoginSuccess() {
1036 .pointer-events-none {1091 .pointer-events-none {
1037 pointer-events: none;1092 pointer-events: none;
1038 }1093 }
1094+
1095+ .gradient-input-wrapper {
1096+ border: 1px inset #dfdfdf;
1097+ padding: 2px;
1098+ }
1099+
1100+ .gradient-input-wrapper input {
1101+ border: none !important;
1102+ outline: none !important;
1103+ padding: 1px 3px;
1104+ }
1105+
1039 </style>⏎̸1106 </style>⏎̸
A src/lib/components/user-preferences-modal.svelte +492 -0
@@ -0,0 +1,492 @@
1+<script lang="ts">
2+import { onMount } from 'svelte';
3+import TextStyleSelector from './text-style-selector.svelte';
4+import {
5+ DEFAULT_USER_PREFERENCES,
6+ DEFAULT_TEXT_STYLE,
7+ type UserTextPreferences,
8+ type TextStyle
9+} from '../types/text-formatting';
10+
11+// Props
12+let {
13+ showModal = $bindable(false),
14+ onSave
15+} = $props<{
16+ showModal: boolean;
17+ onSave?: (preferences: UserTextPreferences) => void;
18+}>();
19+
20+// State
21+let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES });
22+let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
23+let isSaving = $state(false);
24+let hasChanges = $state(false);
25+
26+// Track changes
27+$effect(() => {
28+ hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES);
29+});
30+
31+// Update preferences when style changes
32+$effect(() => {
33+ preferences.defaultStyle = { ...currentStyle };
34+});
35+
36+// Load user preferences on mount
37+onMount(async () => {
38+ try {
39+ const response = await fetch('/api/user/text-preferences');
40+ if (response.ok) {
41+ const data = await response.json();
42+ if (data.success && data.preferences) {
43+ preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences };
44+ currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle };
45+ }
46+ }
47+ } catch (error) {
48+ console.error('Failed to load user preferences:', error);
49+ }
50+});
51+
52+// Save preferences
53+async function savePreferences() {
54+ if (!onSave) return;
55+
56+ isSaving = true;
57+ try {
58+ await onSave(preferences);
59+ showModal = false;
60+ } catch (error) {
61+ console.error('Failed to save preferences:', error);
62+ alert('Failed to save preferences. Please try again.');
63+ } finally {
64+ isSaving = false;
65+ }
66+}
67+
68+// Reset to defaults
69+function resetToDefaults() {
70+ preferences = { ...DEFAULT_USER_PREFERENCES };
71+ currentStyle = { ...DEFAULT_TEXT_STYLE };
72+}
73+
74+// Cancel changes
75+function cancel() {
76+ showModal = false;
77+}
78+
79+// Handle keyboard shortcuts
80+function handleKeydown(event: KeyboardEvent) {
81+ if (event.key === 'Escape') {
82+ cancel();
83+ } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
84+ savePreferences();
85+ }
86+}
87+</script>
88+
89+{#if showModal}
90+ <!-- Modal backdrop -->
91+ <div
92+ class="modal-backdrop"
93+ onclick={cancel}
94+ onkeydown={handleKeydown}
95+ role="dialog"
96+ aria-modal="true"
97+ aria-labelledby="preferences-title"
98+ >
99+ <!-- Modal content -->
100+ <div class="modal-window window" onclick={(e) => e.stopPropagation()}>
101+ <div class="title-bar">
102+ <div class="title-bar-text" id="preferences-title">
103+ Text Formatting Preferences
104+ </div>
105+ <div class="title-bar-controls">
106+ <button onclick={cancel} aria-label="Close"></button>
107+ </div>
108+ </div>
109+
110+ <div class="window-body">
111+ <div class="preferences-content">
112+
113+ <!-- Enable/Disable Formatting -->
114+ <div class="preference-group">
115+ <label class="checkbox-label">
116+ <input
117+ type="checkbox"
118+ bind:checked={preferences.allowFormatting}
119+ />
120+ Enable text formatting in messages
121+ </label>
122+ <small class="help-text">
123+ When enabled, you can use formatting options like bold, italic, colors, and gradients.
124+ </small>
125+ </div>
126+
127+ <!-- Default Text Style -->
128+ <div class="preference-group">
129+ <h3>Default Text Style</h3>
130+ <p class="description">
131+ These settings will be used as defaults when you send new messages.
132+ </p>
133+
134+ <TextStyleSelector
135+ bind:style={currentStyle}
136+ previewText="This is how your messages will look by default"
137+ showPreview={true}
138+ compact={false}
139+ />
140+ </div>
141+
142+ <!-- Message Settings -->
143+ <div class="preference-group">
144+ <h3>Message Settings</h3>
145+
146+ <div class="setting-row">
147+ <label for="max-length">Maximum message length:</label>
148+ <input
149+ id="max-length"
150+ type="number"
151+ min="100"
152+ max="2000"
153+ bind:value={preferences.maxMessageLength}
154+ class="length-input"
155+ />
156+ <span class="unit">characters</span>
157+ </div>
158+
159+ <small class="help-text">
160+ Longer messages may take more time to process and display.
161+ </small>
162+ </div>
163+
164+ <!-- Format Help -->
165+ <div class="preference-group">
166+ <details class="format-help">
167+ <summary>Formatting Guide</summary>
168+ <div class="format-examples">
169+ <h4>BBCode Style:</h4>
170+ <ul>
171+ <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li>
172+ <li><code>[i]italic text[/i]</code> → <em>italic text</em></li>
173+ <li><code>[u]underlined[/u]</code> → <u>underlined</u></li>
174+ <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li>
175+ <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li>
176+ </ul>
177+
178+ <h4>Markdown Style:</h4>
179+ <ul>
180+ <li><code>**bold text**</code> → <strong>bold text</strong></li>
181+ <li><code>*italic text*</code> → <em>italic text</em></li>
182+ <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li>
183+ <li><code>`code text`</code> → <code>code text</code></li>
184+ </ul>
185+ </div>
186+ </details>
187+ </div>
188+
189+ </div>
190+
191+ <!-- Modal buttons -->
192+ <div class="modal-buttons">
193+ <button
194+ class="reset-button"
195+ onclick={resetToDefaults}
196+ disabled={isSaving}
197+ >
198+ Reset to Defaults
199+ </button>
200+
201+ <div class="main-buttons">
202+ <button
203+ onclick={cancel}
204+ disabled={isSaving}
205+ >
206+ Cancel
207+ </button>
208+
209+ <button
210+ class="save-button"
211+ onclick={savePreferences}
212+ disabled={isSaving || !hasChanges}
213+ >
214+ {#if isSaving}
215+ Saving...
216+ {:else}
217+ Save Changes
218+ {/if}
219+ </button>
220+ </div>
221+ </div>
222+ </div>
223+ </div>
224+ </div>
225+{/if}
226+
227+<style>
228+ .modal-backdrop {
229+ position: fixed;
230+ top: 0;
231+ left: 0;
232+ right: 0;
233+ bottom: 0;
234+ background: rgba(0, 0, 0, 0.5);
235+ display: flex;
236+ align-items: center;
237+ justify-content: center;
238+ z-index: 1000;
239+ padding: 20px;
240+ }
241+
242+ .modal-window {
243+ width: 100%;
244+ max-width: 600px;
245+ max-height: 90vh;
246+ overflow: hidden;
247+ box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
248+ }
249+
250+ .window-body {
251+ display: flex;
252+ flex-direction: column;
253+ height: 70vh;
254+ max-height: 600px;
255+ }
256+
257+ .preferences-content {
258+ flex: 1;
259+ overflow-y: auto;
260+ padding: 16px;
261+ }
262+
263+ .preference-group {
264+ margin-bottom: 24px;
265+ padding-bottom: 16px;
266+ border-bottom: 1px solid #ddd;
267+ }
268+
269+ .preference-group:last-child {
270+ border-bottom: none;
271+ margin-bottom: 0;
272+ }
273+
274+ .preference-group h3 {
275+ margin: 0 0 8px 0;
276+ font-size: 14px;
277+ font-weight: bold;
278+ color: #2d31a6;
279+ }
280+
281+ .description {
282+ margin: 0 0 12px 0;
283+ font-size: 12px;
284+ color: #666;
285+ }
286+
287+ .checkbox-label {
288+ display: flex;
289+ align-items: center;
290+ gap: 8px;
291+ font-size: 12px;
292+ cursor: pointer;
293+ }
294+
295+ .help-text {
296+ display: block;
297+ margin-top: 4px;
298+ font-size: 11px;
299+ color: #666;
300+ font-style: italic;
301+ }
302+
303+ .setting-row {
304+ display: flex;
305+ align-items: center;
306+ gap: 8px;
307+ margin-bottom: 8px;
308+ }
309+
310+ .setting-row label {
311+ font-size: 12px;
312+ min-width: 140px;
313+ }
314+
315+ .length-input {
316+ width: 80px;
317+ padding: 2px 4px;
318+ border: 1px inset #dfdfdf;
319+ background: white;
320+ font-size: 11px;
321+ }
322+
323+ .unit {
324+ font-size: 11px;
325+ color: #666;
326+ }
327+
328+ .format-help {
329+ margin-top: 8px;
330+ }
331+
332+ .format-help summary {
333+ font-size: 12px;
334+ font-weight: bold;
335+ cursor: pointer;
336+ padding: 4px 0;
337+ }
338+
339+ .format-examples {
340+ padding: 8px 0;
341+ font-size: 11px;
342+ }
343+
344+ .format-examples h4 {
345+ margin: 12px 0 4px 0;
346+ font-size: 11px;
347+ color: #2d31a6;
348+ }
349+
350+ .format-examples ul {
351+ margin: 0;
352+ padding-left: 16px;
353+ }
354+
355+ .format-examples li {
356+ margin-bottom: 2px;
357+ }
358+
359+ .format-examples code {
360+ background: #f0f0f0;
361+ padding: 1px 3px;
362+ border-radius: 2px;
363+ font-family: 'Courier New', monospace;
364+ font-size: 10px;
365+ }
366+
367+ .modal-buttons {
368+ display: flex;
369+ justify-content: space-between;
370+ align-items: center;
371+ padding: 12px 16px;
372+ border-top: 1px solid #ddd;
373+ background: #f8f8f8;
374+ }
375+
376+ .main-buttons {
377+ display: flex;
378+ gap: 8px;
379+ }
380+
381+ .reset-button {
382+ padding: 4px 12px;
383+ font-size: 11px;
384+ border: 1px outset #dfdfdf;
385+ background: #f0f0f0;
386+ cursor: pointer;
387+ }
388+
389+ .reset-button:hover {
390+ background: #e0e0e0;
391+ }
392+
393+ .reset-button:disabled {
394+ opacity: 0.6;
395+ cursor: not-allowed;
396+ }
397+
398+ .save-button {
399+ padding: 4px 16px;
400+ font-size: 11px;
401+ font-weight: bold;
402+ border: 1px outset #dfdfdf;
403+ background: #0078d4;
404+ color: white;
405+ cursor: pointer;
406+ }
407+
408+ .save-button:hover:not(:disabled) {
409+ background: #106ebe;
410+ }
411+
412+ .save-button:disabled {
413+ opacity: 0.6;
414+ cursor: not-allowed;
415+ background: #999;
416+ }
417+
418+ button {
419+ padding: 4px 12px;
420+ font-size: 11px;
421+ border: 1px outset #dfdfdf;
422+ background: #f0f0f0;
423+ cursor: pointer;
424+ }
425+
426+ button:hover:not(:disabled) {
427+ background: #e0e0e0;
428+ }
429+
430+ button:disabled {
431+ opacity: 0.6;
432+ cursor: not-allowed;
433+ }
434+
435+ /* Scrollbar styling */
436+ .preferences-content::-webkit-scrollbar {
437+ width: 12px;
438+ }
439+
440+ .preferences-content::-webkit-scrollbar-track {
441+ background: #f0f0f0;
442+ border: 1px inset #dfdfdf;
443+ }
444+
445+ .preferences-content::-webkit-scrollbar-thumb {
446+ background: #c0c0c0;
447+ border: 1px outset #dfdfdf;
448+ }
449+
450+ .preferences-content::-webkit-scrollbar-thumb:hover {
451+ background: #a0a0a0;
452+ }
453+
454+ /* Mobile responsive */
455+ @media (max-width: 768px) {
456+ .modal-backdrop {
457+ padding: 10px;
458+ }
459+
460+ .modal-window {
461+ max-width: none;
462+ max-height: 95vh;
463+ }
464+
465+ .window-body {
466+ height: 80vh;
467+ }
468+
469+ .preferences-content {
470+ padding: 12px;
471+ }
472+
473+ .setting-row {
474+ flex-direction: column;
475+ align-items: flex-start;
476+ gap: 4px;
477+ }
478+
479+ .setting-row label {
480+ min-width: auto;
481+ }
482+
483+ .modal-buttons {
484+ flex-direction: column;
485+ gap: 8px;
486+ }
487+
488+ .main-buttons {
489+ order: -1;
490+ }
491+ }
492+</style>⏎̸
@@ -0,0 +1,492 @@
1+<script lang="ts">
2+import { onMount } from 'svelte';
3+import TextStyleSelector from './text-style-selector.svelte';
4+import {
5+ DEFAULT_USER_PREFERENCES,
6+ DEFAULT_TEXT_STYLE,
7+ type UserTextPreferences,
8+ type TextStyle
9+} from '../types/text-formatting';
10+
11+// Props
12+let {
13+ showModal = $bindable(false),
14+ onSave
15+} = $props<{
16+ showModal: boolean;
17+ onSave?: (preferences: UserTextPreferences) => void;
18+}>();
19+
20+// State
21+let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES });
22+let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
23+let isSaving = $state(false);
24+let hasChanges = $state(false);
25+
26+// Track changes
27+$effect(() => {
28+ hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES);
29+});
30+
31+// Update preferences when style changes
32+$effect(() => {
33+ preferences.defaultStyle = { ...currentStyle };
34+});
35+
36+// Load user preferences on mount
37+onMount(async () => {
38+ try {
39+ const response = await fetch('/api/user/text-preferences');
40+ if (response.ok) {
41+ const data = await response.json();
42+ if (data.success && data.preferences) {
43+ preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences };
44+ currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle };
45+ }
46+ }
47+ } catch (error) {
48+ console.error('Failed to load user preferences:', error);
49+ }
50+});
51+
52+// Save preferences
53+async function savePreferences() {
54+ if (!onSave) return;
55+
56+ isSaving = true;
57+ try {
58+ await onSave(preferences);
59+ showModal = false;
60+ } catch (error) {
61+ console.error('Failed to save preferences:', error);
62+ alert('Failed to save preferences. Please try again.');
63+ } finally {
64+ isSaving = false;
65+ }
66+}
67+
68+// Reset to defaults
69+function resetToDefaults() {
70+ preferences = { ...DEFAULT_USER_PREFERENCES };
71+ currentStyle = { ...DEFAULT_TEXT_STYLE };
72+}
73+
74+// Cancel changes
75+function cancel() {
76+ showModal = false;
77+}
78+
79+// Handle keyboard shortcuts
80+function handleKeydown(event: KeyboardEvent) {
81+ if (event.key === 'Escape') {
82+ cancel();
83+ } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
84+ savePreferences();
85+ }
86+}
87+</script>
88+
89+{#if showModal}
90+ <!-- Modal backdrop -->
91+ <div
92+ class="modal-backdrop"
93+ onclick={cancel}
94+ onkeydown={handleKeydown}
95+ role="dialog"
96+ aria-modal="true"
97+ aria-labelledby="preferences-title"
98+ >
99+ <!-- Modal content -->
100+ <div class="modal-window window" onclick={(e) => e.stopPropagation()}>
101+ <div class="title-bar">
102+ <div class="title-bar-text" id="preferences-title">
103+ Text Formatting Preferences
104+ </div>
105+ <div class="title-bar-controls">
106+ <button onclick={cancel} aria-label="Close"></button>
107+ </div>
108+ </div>
109+
110+ <div class="window-body">
111+ <div class="preferences-content">
112+
113+ <!-- Enable/Disable Formatting -->
114+ <div class="preference-group">
115+ <label class="checkbox-label">
116+ <input
117+ type="checkbox"
118+ bind:checked={preferences.allowFormatting}
119+ />
120+ Enable text formatting in messages
121+ </label>
122+ <small class="help-text">
123+ When enabled, you can use formatting options like bold, italic, colors, and gradients.
124+ </small>
125+ </div>
126+
127+ <!-- Default Text Style -->
128+ <div class="preference-group">
129+ <h3>Default Text Style</h3>
130+ <p class="description">
131+ These settings will be used as defaults when you send new messages.
132+ </p>
133+
134+ <TextStyleSelector
135+ bind:style={currentStyle}
136+ previewText="This is how your messages will look by default"
137+ showPreview={true}
138+ compact={false}
139+ />
140+ </div>
141+
142+ <!-- Message Settings -->
143+ <div class="preference-group">
144+ <h3>Message Settings</h3>
145+
146+ <div class="setting-row">
147+ <label for="max-length">Maximum message length:</label>
148+ <input
149+ id="max-length"
150+ type="number"
151+ min="100"
152+ max="2000"
153+ bind:value={preferences.maxMessageLength}
154+ class="length-input"
155+ />
156+ <span class="unit">characters</span>
157+ </div>
158+
159+ <small class="help-text">
160+ Longer messages may take more time to process and display.
161+ </small>
162+ </div>
163+
164+ <!-- Format Help -->
165+ <div class="preference-group">
166+ <details class="format-help">
167+ <summary>Formatting Guide</summary>
168+ <div class="format-examples">
169+ <h4>BBCode Style:</h4>
170+ <ul>
171+ <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li>
172+ <li><code>[i]italic text[/i]</code> → <em>italic text</em></li>
173+ <li><code>[u]underlined[/u]</code> → <u>underlined</u></li>
174+ <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li>
175+ <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li>
176+ </ul>
177+
178+ <h4>Markdown Style:</h4>
179+ <ul>
180+ <li><code>**bold text**</code> → <strong>bold text</strong></li>
181+ <li><code>*italic text*</code> → <em>italic text</em></li>
182+ <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li>
183+ <li><code>`code text`</code> → <code>code text</code></li>
184+ </ul>
185+ </div>
186+ </details>
187+ </div>
188+
189+ </div>
190+
191+ <!-- Modal buttons -->
192+ <div class="modal-buttons">
193+ <button
194+ class="reset-button"
195+ onclick={resetToDefaults}
196+ disabled={isSaving}
197+ >
198+ Reset to Defaults
199+ </button>
200+
201+ <div class="main-buttons">
202+ <button
203+ onclick={cancel}
204+ disabled={isSaving}
205+ >
206+ Cancel
207+ </button>
208+
209+ <button
210+ class="save-button"
211+ onclick={savePreferences}
212+ disabled={isSaving || !hasChanges}
213+ >
214+ {#if isSaving}
215+ Saving...
216+ {:else}
217+ Save Changes
218+ {/if}
219+ </button>
220+ </div>
221+ </div>
222+ </div>
223+ </div>
224+ </div>
225+{/if}
226+
227+<style>
228+ .modal-backdrop {
229+ position: fixed;
230+ top: 0;
231+ left: 0;
232+ right: 0;
233+ bottom: 0;
234+ background: rgba(0, 0, 0, 0.5);
235+ display: flex;
236+ align-items: center;
237+ justify-content: center;
238+ z-index: 1000;
239+ padding: 20px;
240+ }
241+
242+ .modal-window {
243+ width: 100%;
244+ max-width: 600px;
245+ max-height: 90vh;
246+ overflow: hidden;
247+ box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
248+ }
249+
250+ .window-body {
251+ display: flex;
252+ flex-direction: column;
253+ height: 70vh;
254+ max-height: 600px;
255+ }
256+
257+ .preferences-content {
258+ flex: 1;
259+ overflow-y: auto;
260+ padding: 16px;
261+ }
262+
263+ .preference-group {
264+ margin-bottom: 24px;
265+ padding-bottom: 16px;
266+ border-bottom: 1px solid #ddd;
267+ }
268+
269+ .preference-group:last-child {
270+ border-bottom: none;
271+ margin-bottom: 0;
272+ }
273+
274+ .preference-group h3 {
275+ margin: 0 0 8px 0;
276+ font-size: 14px;
277+ font-weight: bold;
278+ color: #2d31a6;
279+ }
280+
281+ .description {
282+ margin: 0 0 12px 0;
283+ font-size: 12px;
284+ color: #666;
285+ }
286+
287+ .checkbox-label {
288+ display: flex;
289+ align-items: center;
290+ gap: 8px;
291+ font-size: 12px;
292+ cursor: pointer;
293+ }
294+
295+ .help-text {
296+ display: block;
297+ margin-top: 4px;
298+ font-size: 11px;
299+ color: #666;
300+ font-style: italic;
301+ }
302+
303+ .setting-row {
304+ display: flex;
305+ align-items: center;
306+ gap: 8px;
307+ margin-bottom: 8px;
308+ }
309+
310+ .setting-row label {
311+ font-size: 12px;
312+ min-width: 140px;
313+ }
314+
315+ .length-input {
316+ width: 80px;
317+ padding: 2px 4px;
318+ border: 1px inset #dfdfdf;
319+ background: white;
320+ font-size: 11px;
321+ }
322+
323+ .unit {
324+ font-size: 11px;
325+ color: #666;
326+ }
327+
328+ .format-help {
329+ margin-top: 8px;
330+ }
331+
332+ .format-help summary {
333+ font-size: 12px;
334+ font-weight: bold;
335+ cursor: pointer;
336+ padding: 4px 0;
337+ }
338+
339+ .format-examples {
340+ padding: 8px 0;
341+ font-size: 11px;
342+ }
343+
344+ .format-examples h4 {
345+ margin: 12px 0 4px 0;
346+ font-size: 11px;
347+ color: #2d31a6;
348+ }
349+
350+ .format-examples ul {
351+ margin: 0;
352+ padding-left: 16px;
353+ }
354+
355+ .format-examples li {
356+ margin-bottom: 2px;
357+ }
358+
359+ .format-examples code {
360+ background: #f0f0f0;
361+ padding: 1px 3px;
362+ border-radius: 2px;
363+ font-family: 'Courier New', monospace;
364+ font-size: 10px;
365+ }
366+
367+ .modal-buttons {
368+ display: flex;
369+ justify-content: space-between;
370+ align-items: center;
371+ padding: 12px 16px;
372+ border-top: 1px solid #ddd;
373+ background: #f8f8f8;
374+ }
375+
376+ .main-buttons {
377+ display: flex;
378+ gap: 8px;
379+ }
380+
381+ .reset-button {
382+ padding: 4px 12px;
383+ font-size: 11px;
384+ border: 1px outset #dfdfdf;
385+ background: #f0f0f0;
386+ cursor: pointer;
387+ }
388+
389+ .reset-button:hover {
390+ background: #e0e0e0;
391+ }
392+
393+ .reset-button:disabled {
394+ opacity: 0.6;
395+ cursor: not-allowed;
396+ }
397+
398+ .save-button {
399+ padding: 4px 16px;
400+ font-size: 11px;
401+ font-weight: bold;
402+ border: 1px outset #dfdfdf;
403+ background: #0078d4;
404+ color: white;
405+ cursor: pointer;
406+ }
407+
408+ .save-button:hover:not(:disabled) {
409+ background: #106ebe;
410+ }
411+
412+ .save-button:disabled {
413+ opacity: 0.6;
414+ cursor: not-allowed;
415+ background: #999;
416+ }
417+
418+ button {
419+ padding: 4px 12px;
420+ font-size: 11px;
421+ border: 1px outset #dfdfdf;
422+ background: #f0f0f0;
423+ cursor: pointer;
424+ }
425+
426+ button:hover:not(:disabled) {
427+ background: #e0e0e0;
428+ }
429+
430+ button:disabled {
431+ opacity: 0.6;
432+ cursor: not-allowed;
433+ }
434+
435+ /* Scrollbar styling */
436+ .preferences-content::-webkit-scrollbar {
437+ width: 12px;
438+ }
439+
440+ .preferences-content::-webkit-scrollbar-track {
441+ background: #f0f0f0;
442+ border: 1px inset #dfdfdf;
443+ }
444+
445+ .preferences-content::-webkit-scrollbar-thumb {
446+ background: #c0c0c0;
447+ border: 1px outset #dfdfdf;
448+ }
449+
450+ .preferences-content::-webkit-scrollbar-thumb:hover {
451+ background: #a0a0a0;
452+ }
453+
454+ /* Mobile responsive */
455+ @media (max-width: 768px) {
456+ .modal-backdrop {
457+ padding: 10px;
458+ }
459+
460+ .modal-window {
461+ max-width: none;
462+ max-height: 95vh;
463+ }
464+
465+ .window-body {
466+ height: 80vh;
467+ }
468+
469+ .preferences-content {
470+ padding: 12px;
471+ }
472+
473+ .setting-row {
474+ flex-direction: column;
475+ align-items: flex-start;
476+ gap: 4px;
477+ }
478+
479+ .setting-row label {
480+ min-width: auto;
481+ }
482+
483+ .modal-buttons {
484+ flex-direction: column;
485+ gap: 8px;
486+ }
487+
488+ .main-buttons {
489+ order: -1;
490+ }
491+ }
492+</style>⏎̸
M src/routes/+page.svelte +4 -1
@@ -2,7 +2,10 @@
22 import ChatRoom from '$lib/components/chat-room.svelte';
33 import DesktopIcons from '$lib/components/desktop-icons.svelte';
44 import AimLogin from '$lib/components/aim-login.svelte';
5+ import type { PageData } from './$types';
56
7+ let { data }: { data: PageData } = $props();
8+
69 let showChatRoom = $state(true);
710 let showAuth = $state(false);
811
@@ -15,7 +18,7 @@
1518 <DesktopIcons bind:showChatRoom bind:showAuth />
1619
1720 {#if showChatRoom}
18- <ChatRoom bind:showChatRoom />
21+ <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
1922 {/if}
2023
2124 {#if showAuth}
@@ -2,7 +2,10 @@
2 import ChatRoom from '$lib/components/chat-room.svelte';2 import ChatRoom from '$lib/components/chat-room.svelte';
3 import DesktopIcons from '$lib/components/desktop-icons.svelte';3 import DesktopIcons from '$lib/components/desktop-icons.svelte';
4 import AimLogin from '$lib/components/aim-login.svelte';4 import AimLogin from '$lib/components/aim-login.svelte';
5+ import type { PageData } from './$types';
5 6
7+ let { data }: { data: PageData } = $props();
8+
6 let showChatRoom = $state(true);9 let showChatRoom = $state(true);
7 let showAuth = $state(false);10 let showAuth = $state(false);
8 11
@@ -15,7 +18,7 @@
15 <DesktopIcons bind:showChatRoom bind:showAuth />18 <DesktopIcons bind:showChatRoom bind:showAuth />
16 19
17 {#if showChatRoom}20 {#if showChatRoom}
18- <ChatRoom bind:showChatRoom />21+ <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
19 {/if}22 {/if}
20 23
21 {#if showAuth}24 {#if showAuth}

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