v0.1.3 ff61cc9

chat-room.svelte RawHistoryFolder

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>

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