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