v0.1.4 2490b00

chat-room.svelte RawHistoryFolder

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>

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 9

↑↓ moveenter openesc close