1<script lang="ts">
2import { chatState } from '../states/chat.svelte';
3import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat';
4import { onMount } from 'svelte';
5import { browser } from '$app/environment';
6import { draggable } from '$lib/actions/draggable';
7import { resizable } from '$lib/actions/resizable';
8import { maximizable } from '$lib/actions/maximizable';
9import LoadingButton from './ui/button-loading.svelte';
10import Tooltip from './ui/tooltip.svelte';
11import LoadingDots from './ui/loading-dots.svelte';
12import AimLogin from './aim-login.svelte';
13import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
14
15// Initialize with default values for SSR
16let windowWidth = $state(400);
17let windowHeight = $state(500);
18let windowX = $state(0);
19let windowY = $state(0);
20let isMobile = $state(false);
21let currentMessage = $state('');
22let showUserList = $state(false);
23let isMaximized = $state(false);
24let isInitialLoading = $state(true);
25let showAuth = $state(false);
26
27// Rate limiting state
28let cooldownEndTime = $state<number | null>(null);
29let cooldownProgress = $state(0);
30let cooldownInterval: ReturnType<typeof setInterval> | null = null;
31let rateLimitWarning = $state<string | null>(null);
32
33function updateCooldownProgress() {
34 if (!cooldownEndTime) return;
35
36 const now = Date.now();
37 if (now >= cooldownEndTime) {
38 cooldownEndTime = null;
39 cooldownProgress = 0;
40 rateLimitWarning = null;
41 if (cooldownInterval) {
42 clearInterval(cooldownInterval);
43 cooldownInterval = null;
44 }
45 return;
46 }
47
48 cooldownProgress = ((cooldownEndTime - now) / 1000);
49}
50
51function startCooldownTimer(retryAfter: number) {
52 cooldownEndTime = Date.now() + retryAfter;
53 if (cooldownInterval) clearInterval(cooldownInterval);
54 cooldownInterval = setInterval(updateCooldownProgress, 100);
55 updateCooldownProgress();
56}
57
58let { showChatRoom = $bindable() } = $props();
59
60function handleClose() {
61 showChatRoom = false;
62}
63
64// Reactive state using derived values
65let messages = $state<EnrichedMessage[]>([]);
66let onlineUsers = $state<User[]>([]);
67let currentUser = $state<User | null>(null);
68let isLoadingMore = $state(false);
69let hasMoreMessages = $state(true);
70let oldestMessageTimestamp = $state<number | null>(null);
71let currentRoomId = $state('');
72
73// Compute visible messages based on login status
74let visibleMessages = $derived((() => {
75 const isLoggedIn = Boolean(currentUser);
76 const messageCount = messages.length;
77 const result: EnrichedMessage[] = isLoggedIn
78 ? messages
79 : messages.slice(Math.max(0, messageCount - 50));
80 return result;
81})());
82
83// Add a derived state to check if we're showing the registration prompt
84let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
85
86// Add SSE error state
87let sseError = $state<string | null>(null);
88let sseRetryAfter = $state<number | null>(null);
89
90// Update the effect to check for SSE errors
91$effect(() => {
92 const user = chatState.getCurrentUser();
93 if (user?.id !== currentUser?.id) {
94 currentUser = user;
95 hasMoreMessages = true; // Reset when user changes
96 }
97
98 // Check for SSE errors
99 const { error, retryAfter } = chatState.getSSEError();
100 sseError = error;
101 sseRetryAfter = retryAfter;
102
103 // Update messages if there's any difference
104 const stateMessages = chatState.getMessages();
105 if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
106 messages = stateMessages;
107
108 // Update oldest message timestamp
109 if (messages.length > 0) {
110 oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp));
111 }
112
113 // Scroll to bottom when new messages arrive
114 const chatArea = document.querySelector('.chat-area');
115 if (chatArea) {
116 setTimeout(() => {
117 chatArea.scrollTop = chatArea.scrollHeight;
118 }, 0);
119 }
120 }
121
122 // Update online users
123 const stateOnlineUsers = chatState.getOnlineUsers();
124 if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
125 onlineUsers = stateOnlineUsers;
126 }
127});
128
129// Fetch public messages only once when needed
130let fetchedPublicMessages = false;
131let fetchedPublicRoom = false;
132
133$effect(() => {
134 if (!currentUser) {
135 // Fetch both public room data and messages in parallel but handle them in sequence
136 if (!fetchedPublicRoom && !fetchedPublicMessages) {
137 fetchedPublicRoom = true;
138 fetchedPublicMessages = true;
139
140 // Get the default chat room id
141 const defaultRoomId = chatState.getDefaultChatRoomId();
142
143 // Fetch both in parallel
144 Promise.all([
145 fetch(`/api/rooms/${defaultRoomId}?public=true`).then(r => r.json()),
146 fetch('/api/chat/messages?public=true').then(r => r.json())
147 ])
148 .then(([roomData, messagesData]) => {
149 if (roomData.success) {
150 // Update both the chat state and local state with the buddy list
151 chatState.updateOnlineUsers(roomData.buddyList);
152 onlineUsers = roomData.buddyList;
153 console.debug('Updated public buddy list:', roomData.buddyList);
154 }
155
156 // Then update messages after buddy list is cached
157 if (messagesData.success && messagesData.messages) {
158 chatState.updateMessages(messagesData.messages);
159 messages = chatState.enrichMessages(messagesData.messages);
160 console.debug('Updated public messages:', messages);
161 }
162 })
163 .catch(error => {
164 console.error("Error fetching public data:", error);
165 onlineUsers = [];
166 messages = [];
167 });
168 }
169 }
170});
171
172onMount(() => {
173 if (!browser) return;
174
175 const handleResize = () => {
176 isMobile = window.innerWidth <= 768;
177 if (isMobile) {
178 windowWidth = window.innerWidth;
179 windowHeight = window.innerHeight;
180 windowX = 0;
181 windowY = 0;
182 } else {
183 windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8));
184 windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8));
185 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
186 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
187 }
188 };
189
190 // Initial resize
191 handleResize();
192
193 // Add resize listener
194 window.addEventListener('resize', handleResize);
195
196 return () => {
197 window.removeEventListener('resize', handleResize);
198 if (cooldownInterval) clearInterval(cooldownInterval);
199 };
200});
201
202// New reactive state to track when a message is being sent
203let isSendingMessage = $state(false);
204
205async function handleSubmit() {
206 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
207
208 isSendingMessage = true;
209 try {
210 const response = await chatState.sendMessage(currentMessage);
211 if (!response.success && response.isRateLimited && response.retryAfter) {
212 startCooldownTimer(response.retryAfter);
213 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
214 } else if (!response.success) {
215 rateLimitWarning = response.error || 'Failed to send message. Please try again.';
216 setTimeout(() => rateLimitWarning = null, 3000);
217 } else {
218 currentMessage = '';
219 rateLimitWarning = null;
220 }
221 } catch (error) {
222 console.error('Failed to send message:', error);
223 rateLimitWarning = 'Failed to send message. Please try again.';
224 setTimeout(() => rateLimitWarning = null, 3000);
225 } finally {
226 isSendingMessage = false;
227 }
228}
229
230function getStatusIcon(status: User['status']) {
231 switch (status) {
232 case 'online': return '🟢';
233 case 'away': return '🌙';
234 case 'busy': return '🔴';
235 default: return '⚫';
236 }
237}
238
239function formatLastSeen(lastSeen: number | null | undefined) {
240 if (!lastSeen) return 'Never';
241
242 const now = Date.now();
243 const diff = now - lastSeen;
244
245 // Less than a minute
246 if (diff < 60000) {
247 return 'Just now';
248 }
249
250 // Less than an hour
251 if (diff < 3600000) {
252 const minutes = Math.floor(diff / 60000);
253 return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
254 }
255
256 // Less than a day
257 if (diff < 86400000) {
258 const hours = Math.floor(diff / 3600000);
259 return `${hours} hour${hours > 1 ? 's' : ''} ago`;
260 }
261
262 // Less than a week
263 if (diff < 604800000) {
264 const days = Math.floor(diff / 86400000);
265 return `${days} day${days > 1 ? 's' : ''} ago`;
266 }
267
268 // More than a week
269 return new Date(lastSeen).toLocaleDateString();
270}
271
272function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
273 windowX = event.detail.x;
274 windowY = event.detail.y;
275}
276
277interface MaximizableNode extends HTMLElement {
278 toggleMaximize: () => void;
279}
280
281function handleMaximize(event: MouseEvent) {
282 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
283 if (node) {
284 node.toggleMaximize();
285 }
286}
287
288function handleMaximizeEvent(event: CustomEvent<{
289 isMaximized: boolean;
290 width: number;
291 height: number;
292 x: number;
293 y: number;
294}>) {
295 isMaximized = event.detail.isMaximized;
296 windowWidth = event.detail.width;
297 windowHeight = event.detail.height;
298 windowX = event.detail.x;
299 windowY = event.detail.y;
300}
301
302async function handleScroll(event: Event) {
303 const chatArea = event.target as HTMLElement;
304 const { scrollTop } = chatArea;
305
306 // Check if we've scrolled near the top (within 100px) and not already loading
307 if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
308 isLoadingMore = true;
309
310 // Store the scroll height and position before loading
311 const scrollHeight = chatArea.scrollHeight;
312 const currentScrollTop = chatArea.scrollTop;
313
314 console.debug('Loading more messages...', {
315 oldestMessageTimestamp,
316 scrollHeight,
317 currentScrollTop
318 });
319
320 try {
321 const response = await fetch(`/api/chat/messages?${new URLSearchParams({
322 before: oldestMessageTimestamp?.toString() || '',
323 roomId: currentRoomId,
324 ...(currentUser ? {} : { public: 'true' })
325 })}`);
326
327 if (response.ok) {
328 const data = await response.json();
329 if (data.success) {
330 if (data.messages.length === 0) {
331 hasMoreMessages = false;
332 } else {
333 // Update messages through chat state
334 chatState.prependMessages(data.messages, data.hasMore);
335
336 // Use requestAnimationFrame to ensure DOM has updated
337 requestAnimationFrame(() => {
338 // Calculate how much the content height has increased
339 const newScrollHeight = chatArea.scrollHeight;
340 const heightDifference = newScrollHeight - scrollHeight;
341
342 // Adjust scroll position to maintain relative position
343 chatArea.scrollTop = currentScrollTop + heightDifference;
344
345 console.debug('Scroll position adjusted:', {
346 heightDifference,
347 newScrollTop: chatArea.scrollTop,
348 newScrollHeight
349 });
350 });
351
352 // Update oldest timestamp
353 const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
354 oldestMessageTimestamp = newOldest;
355
356 // Update hasMoreMessages from response
357 hasMoreMessages = data.hasMore;
358 }
359 }
360 }
361 } catch (error) {
362 console.error('Error loading more messages:', error);
363 hasMoreMessages = false;
364 } finally {
365 isLoadingMore = false;
366 }
367 }
368}
369
370// Add effect to track initial loading state
371$effect(() => {
372 const stateMessages = chatState.getMessages();
373 if (stateMessages.length > 0 && isInitialLoading) {
374 isInitialLoading = false;
375 }
376});
377
378// Add derived state for total users count
379let totalUsers = $derived(onlineUsers.length);
380
381function openSignup() {
382 showAuth = true;
383}
384</script>
385
386{#if showChatRoom}
387<div
388 class="chat-window window"
389 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
390 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
391 use:resizable={{
392 enabled: !isMobile && !isMaximized,
393 minWidth: 400,
394 minHeight: 500,
395 maxWidth: window.innerWidth - 40,
396 maxHeight: window.innerHeight - 40
397 }}
398 use:maximizable={{ enabled: !isMobile, padding: 4 }}
399 onmaximize={handleMaximizeEvent}
400 onresizemove={(e) => {
401 windowWidth = Math.max(400, e.detail.width);
402 windowHeight = Math.max(500, e.detail.height);
403 }}
404 ondragmove={handleDragMove}
405>
406 <div class="title-bar">
407 <div class="title-bar-text">
408 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
409 {#if sseError}
410 <span class="connection-error">⚠️ Erreur de connexion</span>
411 {/if}
412 </div>
413 <div class="title-bar-controls">
414 {#if !isMobile}
415 <button aria-label="Minimize"></button>
416 <button
417 aria-label="Maximize"
418 class:maximized={isMaximized}
419 onclick={handleMaximize}
420 ></button>
421 <button onclick={handleClose} aria-label="Close"></button>
422 {:else}
423 <button
424 aria-label="Afficher/Masquer la liste des contacts"
425 onclick={() => showUserList = !showUserList}
426 >👥</button>
427 {/if}
428 </div>
429 </div>
430
431 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
432 {#if sseError}
433 <div class="error-banner">
434 {sseError}
435 {#if sseRetryAfter}
436 <br>
437 <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
438 {/if}
439 </div>
440 {/if}
441
442 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
443 {#if rateLimitWarning}
444 <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
445 <span>{rateLimitWarning}</span>
446 {#if cooldownEndTime}
447 <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
448 {/if}
449 </div>
450 {/if}
451
452 <div
453 class="sunken-panel chat-area"
454 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
455 onscroll={(e) => handleScroll(e)}
456 >
457 {#if isInitialLoading}
458 <div class="initial-loading">
459 <LoadingDots text="Chargement des messages" />
460 </div>
461 {:else if isLoadingMore}
462 <div class="loading-messages">
463 <LoadingDots text="Chargement" />
464 </div>
465 {/if}
466 {#if showRegistrationPrompt}
467 <div class="registration-prompt">
468 <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button> pour lire le reste du chat !</p>
469 </div>
470 {/if}
471 {#each visibleMessages as message (message.id)}
472 <div class="message {message.type} text">
473 {#if message.type === 'emote'}
474 <span class="emote-text">
475 <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
476 <span class="nickname">{message.user.nickname}</span>
477 </Tooltip>
478 {message.content}
479 </span>
480 {:else}
481 <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
482 <span class="nickname">{message.user.nickname}:</span>
483 </Tooltip>
484 <span class="content">{message.content}</span>
485 {/if}
486 </div>
487 {/each}
488 </div>
489
490 <div class="field-row input-container" style="margin: 0;">
491 <input
492 type="text"
493 bind:value={currentMessage}
494 style="flex: 1;"
495 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
496 placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
497 disabled={!currentUser || Boolean(cooldownEndTime)}
498 />
499 <LoadingButton
500 onclick={handleSubmit}
501 disabled={!currentUser || Boolean(cooldownEndTime)}
502 loading={isSendingMessage}
503 text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Envoyer"}
504 />
505 </div>
506 {#if cooldownEndTime}
507 <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
508 {/if}
509 </div>
510
511 <!-- Online users list -->
512 <div
513 class="sunken-panel users-list"
514 class:mobile={isMobile}
515 class:hidden={isMobile && !showUserList}
516 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
517 >
518 <p style="margin: 0 0 0.3rem 0;"><strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong></p>
519 {#each onlineUsers as user}
520 <div class="user" class:offline={user.status === 'offline'}>
521 <span class="status-icon">{getStatusIcon(user.status)}</span>
522 <div class="user-info">
523 {#if user.status === 'offline'}
524 <Tooltip data={{ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen), direction: "bottom" }}>
525 <div class="nickname select-none">{user.nickname}</div>
526 </Tooltip>
527 {:else}
528 <div class="nickname select-none">{user.nickname}</div>
529 {/if}
530 </div>
531 </div>
532 {/each}
533 </div>
534 </div>
535</div>
536{/if}
537
538{#if showAuth}
539 <AimLogin
540 bind:showAuth
541 activeTab={'signup' as const}
542 />
543{/if}
544
545<style>
546 .chat-area, .users-list, input {
547 font-size: 1rem;
548 font-family: Arial, Verdana, Tahoma, sans-serif;
549 }
550
551 .title-bar {
552 height: 2rem;
553 position: relative;
554 cursor: move;
555 user-select: none;
556 }
557
558 .chat-window {
559 position: fixed;
560 box-sizing: border-box;
561 }
562
563 .chat-area {
564 line-height: 1.4;
565 }
566
567 .message {
568 margin-bottom: 0.25rem;
569 word-break: break-word;
570 display: flex;
571 gap: 0.5rem;
572 align-items: flex-start;
573 }
574
575 .message .nickname {
576 font-weight: bold;
577 color: #2d31a6;
578 cursor: help;
579 }
580
581 .message .content {
582 flex: 1;
583 }
584
585 .message.emote {
586 color: #666;
587 font-style: italic;
588 }
589
590 .message.emote .nickname {
591 color: #666;
592 }
593
594 .user {
595 display: flex;
596 align-items: flex-start;
597 margin-bottom: 0.25rem;
598 gap: 0.375rem;
599 transition: opacity 0.3s ease;
600 }
601
602 .user.offline {
603 opacity: 0.6;
604 }
605
606 .status-icon {
607 font-size: 0.875rem;
608 transition: color 0.3s ease;
609 }
610
611 .user-info {
612 flex: 1;
613 min-width: 0;
614 }
615
616 .user .nickname {
617 font-weight: bold;
618 margin-bottom: 0.125rem;
619 transition: color 0.3s ease;
620 }
621
622 .user.offline .nickname {
623 color: #666;
624 }
625
626 /* .user .status-message {
627 font-size: 0.875rem;
628 color: #666;
629 font-style: italic;
630 overflow: hidden;
631 text-overflow: ellipsis;
632 white-space: nowrap;
633 } */
634
635 .sunken-panel {
636 background: white;
637 border: 0.125rem inset #dfdfdf;
638 }
639
640 input[type="text"] {
641 font-size: 1rem;
642 margin-right: 0.25rem;
643 }
644
645 button {
646 font-size: 1rem;
647 }
648
649 .chat-container {
650 position: relative;
651 z-index: 1;
652 }
653
654 .input-container {
655 position: relative;
656 z-index: 2;
657 }
658
659 .input-container input:disabled,
660 .input-container {
661 opacity: 0.7;
662 background-color: rgba(128, 128, 128, 0.1);
663 cursor: not-allowed;
664 }
665
666 .hidden {
667 display: none !important;
668 }
669
670 :global(.resize-handle) {
671 position: absolute;
672 bottom: 0;
673 right: 0;
674 width: 15px !important;
675 height: 15px !important;
676 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E");
677 background-position: bottom right;
678 background-repeat: no-repeat;
679 cursor: se-resize !important;
680 }
681
682 @media (max-width: 768px) {
683 .window {
684 position: fixed !important;
685 top: 0 !important;
686 left: 0 !important;
687 right: 0 !important;
688 bottom: 0 !important;
689 width: 100% !important;
690 height: 100% !important;
691 }
692
693 .window-body {
694 flex-direction: column;
695 }
696
697 .chat-container {
698 margin-right: 0 !important;
699 margin-bottom: 0 !important;
700 height: 100%;
701 }
702
703 .users-list.mobile {
704 position: fixed;
705 bottom: 0;
706 left: 0;
707 right: 0;
708 height: auto;
709 max-height: 30vh;
710 z-index: 1000;
711 border-top: 0.125rem solid #dfdfdf;
712 background: white;
713 }
714
715 .input-container {
716 padding: 0.5rem;
717 border-top: 0.125rem solid #dfdfdf;
718 }
719
720 :global(.resize-handle) {
721 display: none;
722 }
723 }
724
725 .relative-time {
726 color: #666;
727 font-size: 0.75rem;
728 margin-right: 0.5rem;
729 font-style: italic;
730 }
731
732 .cooldown-progress {
733 position: absolute;
734 bottom: 0;
735 left: 0;
736 height: 2px;
737 background: #2d31a6;
738 transition: width 0.1s linear;
739 }
740
741 .input-container {
742 position: relative;
743 }
744
745 .connection-error {
746 font-size: 0.75rem;
747 color: #ff4444;
748 margin-left: 0.5rem;
749 }
750
751 .error-banner {
752 position: absolute;
753 top: 2rem;
754 left: 0;
755 right: 0;
756 background: #ffebee;
757 color: #c62828;
758 padding: 0.5rem;
759 text-align: center;
760 z-index: 100;
761 border-bottom: 1px solid #ffcdd2;
762 font-size: 0.875rem;
763 }
764
765 .error-banner small {
766 color: #666;
767 }
768
769 .rate-limit-warning {
770 background: #fff3e0;
771 color: #e65100;
772 padding: 0.5rem;
773 margin-bottom: 0.5rem;
774 border-radius: 4px;
775 font-size: 0.875rem;
776 display: flex;
777 flex-direction: column;
778 gap: 0.25rem;
779 animation: slideIn 0.3s ease-out;
780 border: 1px solid #ffe0b2;
781 }
782
783 .rate-limit-warning.with-progress {
784 border-left: 4px solid #e65100;
785 }
786
787 .rate-limit-warning small {
788 color: #666;
789 font-size: 0.75rem;
790 }
791
792 @keyframes slideIn {
793 from {
794 transform: translateY(-100%);
795 opacity: 0;
796 }
797 to {
798 transform: translateY(0);
799 opacity: 1;
800 }
801 }
802
803 .cooldown-progress {
804 position: absolute;
805 bottom: 0;
806 left: 0;
807 height: 2px;
808 background: #e65100;
809 transition: width 0.1s linear;
810 }
811
812 .initial-loading {
813 display: flex;
814 justify-content: center;
815 align-items: center;
816 height: 100%;
817 font-size: 1.1rem;
818 color: #666;
819 }
820
821 .loading-messages {
822 text-align: center;
823 padding: 1rem;
824 color: #666;
825 font-style: italic;
826 background: rgba(0, 0, 0, 0.05);
827 margin-bottom: 1rem;
828 border-radius: 4px;
829 }
830
831 .registration-prompt {
832 background: #fff3e0;
833 color: #e65100;
834 padding: 1rem;
835 margin-bottom: 1rem;
836 border-radius: 4px;
837 text-align: center;
838 font-size: 0.95rem;
839 border: 1px solid #ffe0b2;
840 animation: fadeIn 0.3s ease-out;
841 }
842
843 .registration-prompt p {
844 margin: 0;
845 }
846
847 @keyframes fadeIn {
848 from {
849 opacity: 0;
850 transform: translateY(-10px);
851 }
852 to {
853 opacity: 1;
854 transform: translateY(0);
855 }
856 }
857
858 .registration-prompt .link-button {
859 color: #e65100;
860 text-decoration: underline;
861 cursor: pointer;
862 background: none;
863 border: none;
864 padding: 0;
865 font: inherit;
866 }
867
868 .registration-prompt .link-button:hover {
869 color: #ef6c00;
870 text-decoration: none;
871 }
872</style>