detached 69a5042

feat: Add mobile responsiveness and window minimization to chat interface

- Implement mobile-friendly layout for login and chat windows
- Add minimization functionality for chat window
- Improve window controls and responsiveness
- Enhance user interaction with mobile and desktop optimizations
commit
69a5042c70d9a9b93f07cc92d321597b30602712
author
Kit · · 2025-02-18 15:47 UTC
parent
2490b00
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
3 files · +414 -104
A src/lib/actions/minimizable.ts +168 -0
@@ -0,0 +1,168 @@
1+import type { Action } from 'svelte/action';
2+
3+interface MinimizableParameters {
4+ enabled?: boolean;
5+ onMinimize?: (isMinimized: boolean) => void;
6+}
7+
8+interface MinimizableAttributes {
9+ onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void;
10+}
11+
12+interface StoredState {
13+ width: number;
14+ height: number;
15+ x: number;
16+ y: number;
17+}
18+
19+export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (node, params = {}) => {
20+ const {
21+ enabled = true,
22+ onMinimize
23+ } = params;
24+
25+ let isMinimized = false;
26+ let storedState: StoredState | null = null;
27+
28+ function calculateMinimizedWidth() {
29+ const titleBar = node.querySelector('.title-bar-text');
30+ if (!titleBar) return 200;
31+
32+ // Create a temporary element to measure text width
33+ const temp = document.createElement('div');
34+ temp.style.position = 'absolute';
35+ temp.style.visibility = 'hidden';
36+ temp.style.whiteSpace = 'nowrap';
37+ temp.style.font = window.getComputedStyle(titleBar).font;
38+ temp.textContent = titleBar.textContent;
39+ document.body.appendChild(temp);
40+
41+ // Get the width and add some padding
42+ const width = temp.offsetWidth + 100; // Add 100px for buttons and padding
43+ document.body.removeChild(temp);
44+
45+ return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px
46+ }
47+
48+ function minimize() {
49+ if (!enabled || isMinimized) return;
50+
51+ // Store current state
52+ storedState = {
53+ width: node.offsetWidth,
54+ height: node.offsetHeight,
55+ x: parseInt(node.style.left) || 0,
56+ y: parseInt(node.style.top) || 0
57+ };
58+
59+ // Check if we're in mobile mode
60+ const isMobile = window.innerWidth <= 768;
61+
62+ if (!isMobile) {
63+ // Desktop minimization
64+ const minimizedWidth = calculateMinimizedWidth();
65+ const minimizedHeight = 32; // Height of title bar
66+ const minimizedX = 10;
67+ const minimizedY = window.innerHeight - minimizedHeight - 10;
68+
69+ node.style.width = `${minimizedWidth}px`;
70+ node.style.height = `${minimizedHeight}px`;
71+ node.style.left = `${minimizedX}px`;
72+ node.style.top = `${minimizedY}px`;
73+ } else {
74+ // Mobile minimization - position at bottom of screen
75+ node.style.width = '100%';
76+ node.style.height = '100%';
77+ node.style.left = '0';
78+ node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height
79+ }
80+
81+ isMinimized = true;
82+ node.classList.add('minimized');
83+
84+ // Hide resize handle
85+ const resizeHandle = node.querySelector('.resize-handle');
86+ if (resizeHandle) {
87+ (resizeHandle as HTMLElement).style.display = 'none';
88+ }
89+
90+ onMinimize?.(true);
91+ node.dispatchEvent(new CustomEvent('minimize', {
92+ detail: { isMinimized: true }
93+ }));
94+ }
95+
96+ function restore() {
97+ if (!enabled || !isMinimized || !storedState) return;
98+
99+ const isMobile = window.innerWidth <= 768;
100+
101+ if (!isMobile) {
102+ // Desktop restoration
103+ node.style.width = `${storedState.width}px`;
104+ node.style.height = `${storedState.height}px`;
105+ node.style.left = `${storedState.x}px`;
106+ node.style.top = `${storedState.y}px`;
107+ } else {
108+ // Mobile restoration
109+ node.style.width = '100%';
110+ node.style.height = '100%';
111+ node.style.left = '0';
112+ node.style.top = '0';
113+ }
114+
115+ isMinimized = false;
116+ node.classList.remove('minimized');
117+
118+ // Show resize handle if not in mobile mode
119+ const resizeHandle = node.querySelector('.resize-handle');
120+ if (resizeHandle) {
121+ (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none';
122+ }
123+
124+ // Ensure window body is visible
125+ const windowBody = node.querySelector('.window-body');
126+ if (windowBody) {
127+ (windowBody as HTMLElement).style.display = 'flex';
128+ }
129+
130+ onMinimize?.(false);
131+ node.dispatchEvent(new CustomEvent('minimize', {
132+ detail: { isMinimized: false }
133+ }));
134+ }
135+
136+ function toggle() {
137+ if (isMinimized) {
138+ restore();
139+ } else {
140+ minimize();
141+ }
142+ }
143+
144+ // Handle double click on title bar
145+ const titleBar = node.querySelector('.title-bar');
146+ if (titleBar) {
147+ titleBar.addEventListener('dblclick', toggle);
148+ }
149+
150+ // Expose methods to the node
151+ Object.assign(node, {
152+ minimize,
153+ restore,
154+ toggleMinimize: toggle,
155+ isMinimized: () => isMinimized
156+ });
157+
158+ return {
159+ update(newParams: MinimizableParameters) {
160+ Object.assign(params, newParams);
161+ },
162+ destroy() {
163+ if (titleBar) {
164+ titleBar.removeEventListener('dblclick', toggle);
165+ }
166+ }
167+ };
168+}; ⏎̸
@@ -0,0 +1,168 @@
1+import type { Action } from 'svelte/action';
2+
3+interface MinimizableParameters {
4+ enabled?: boolean;
5+ onMinimize?: (isMinimized: boolean) => void;
6+}
7+
8+interface MinimizableAttributes {
9+ onminimize?: (e: CustomEvent<{ isMinimized: boolean }>) => void;
10+}
11+
12+interface StoredState {
13+ width: number;
14+ height: number;
15+ x: number;
16+ y: number;
17+}
18+
19+export const minimizable: Action<HTMLDivElement, MinimizableParameters, MinimizableAttributes> = (node, params = {}) => {
20+ const {
21+ enabled = true,
22+ onMinimize
23+ } = params;
24+
25+ let isMinimized = false;
26+ let storedState: StoredState | null = null;
27+
28+ function calculateMinimizedWidth() {
29+ const titleBar = node.querySelector('.title-bar-text');
30+ if (!titleBar) return 200;
31+
32+ // Create a temporary element to measure text width
33+ const temp = document.createElement('div');
34+ temp.style.position = 'absolute';
35+ temp.style.visibility = 'hidden';
36+ temp.style.whiteSpace = 'nowrap';
37+ temp.style.font = window.getComputedStyle(titleBar).font;
38+ temp.textContent = titleBar.textContent;
39+ document.body.appendChild(temp);
40+
41+ // Get the width and add some padding
42+ const width = temp.offsetWidth + 100; // Add 100px for buttons and padding
43+ document.body.removeChild(temp);
44+
45+ return Math.max(200, Math.min(400, width)); // Keep width between 200 and 400px
46+ }
47+
48+ function minimize() {
49+ if (!enabled || isMinimized) return;
50+
51+ // Store current state
52+ storedState = {
53+ width: node.offsetWidth,
54+ height: node.offsetHeight,
55+ x: parseInt(node.style.left) || 0,
56+ y: parseInt(node.style.top) || 0
57+ };
58+
59+ // Check if we're in mobile mode
60+ const isMobile = window.innerWidth <= 768;
61+
62+ if (!isMobile) {
63+ // Desktop minimization
64+ const minimizedWidth = calculateMinimizedWidth();
65+ const minimizedHeight = 32; // Height of title bar
66+ const minimizedX = 10;
67+ const minimizedY = window.innerHeight - minimizedHeight - 10;
68+
69+ node.style.width = `${minimizedWidth}px`;
70+ node.style.height = `${minimizedHeight}px`;
71+ node.style.left = `${minimizedX}px`;
72+ node.style.top = `${minimizedY}px`;
73+ } else {
74+ // Mobile minimization - position at bottom of screen
75+ node.style.width = '100%';
76+ node.style.height = '100%';
77+ node.style.left = '0';
78+ node.style.top = `${window.innerHeight - 32}px`; // 32px is title bar height
79+ }
80+
81+ isMinimized = true;
82+ node.classList.add('minimized');
83+
84+ // Hide resize handle
85+ const resizeHandle = node.querySelector('.resize-handle');
86+ if (resizeHandle) {
87+ (resizeHandle as HTMLElement).style.display = 'none';
88+ }
89+
90+ onMinimize?.(true);
91+ node.dispatchEvent(new CustomEvent('minimize', {
92+ detail: { isMinimized: true }
93+ }));
94+ }
95+
96+ function restore() {
97+ if (!enabled || !isMinimized || !storedState) return;
98+
99+ const isMobile = window.innerWidth <= 768;
100+
101+ if (!isMobile) {
102+ // Desktop restoration
103+ node.style.width = `${storedState.width}px`;
104+ node.style.height = `${storedState.height}px`;
105+ node.style.left = `${storedState.x}px`;
106+ node.style.top = `${storedState.y}px`;
107+ } else {
108+ // Mobile restoration
109+ node.style.width = '100%';
110+ node.style.height = '100%';
111+ node.style.left = '0';
112+ node.style.top = '0';
113+ }
114+
115+ isMinimized = false;
116+ node.classList.remove('minimized');
117+
118+ // Show resize handle if not in mobile mode
119+ const resizeHandle = node.querySelector('.resize-handle');
120+ if (resizeHandle) {
121+ (resizeHandle as HTMLElement).style.display = !isMobile ? 'block' : 'none';
122+ }
123+
124+ // Ensure window body is visible
125+ const windowBody = node.querySelector('.window-body');
126+ if (windowBody) {
127+ (windowBody as HTMLElement).style.display = 'flex';
128+ }
129+
130+ onMinimize?.(false);
131+ node.dispatchEvent(new CustomEvent('minimize', {
132+ detail: { isMinimized: false }
133+ }));
134+ }
135+
136+ function toggle() {
137+ if (isMinimized) {
138+ restore();
139+ } else {
140+ minimize();
141+ }
142+ }
143+
144+ // Handle double click on title bar
145+ const titleBar = node.querySelector('.title-bar');
146+ if (titleBar) {
147+ titleBar.addEventListener('dblclick', toggle);
148+ }
149+
150+ // Expose methods to the node
151+ Object.assign(node, {
152+ minimize,
153+ restore,
154+ toggleMinimize: toggle,
155+ isMinimized: () => isMinimized
156+ });
157+
158+ return {
159+ update(newParams: MinimizableParameters) {
160+ Object.assign(params, newParams);
161+ },
162+ destroy() {
163+ if (titleBar) {
164+ titleBar.removeEventListener('dblclick', toggle);
165+ }
166+ }
167+ };
168+}; ⏎̸
M src/lib/components/aim-login.svelte +26 -0
@@ -355,6 +355,32 @@
355355 /* Changed to fixed positioning for correct dragging */
356356 position: fixed;
357357 }
358+
359+ @media (max-width: 768px) {
360+ .login-window {
361+ width: 100% !important;
362+ height: 100% !important;
363+ top: 0 !important;
364+ left: 0 !important;
365+ right: 0 !important;
366+ bottom: 0 !important;
367+ margin: 0 !important;
368+ border-radius: 0 !important;
369+ transform: none !important;
370+ }
371+
372+ .window-content {
373+ height: calc(100% - 80px); /* Account for title bar and tabs */
374+ overflow-y: auto;
375+ }
376+
377+ form {
378+ max-width: 400px;
379+ margin: 0 auto;
380+ padding: 1rem;
381+ }
382+ }
383+
358384 .title-bar {
359385 background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
360386 padding: 4px 6px;
@@ -355,6 +355,32 @@
355 /* Changed to fixed positioning for correct dragging */355 /* Changed to fixed positioning for correct dragging */
356 position: fixed;356 position: fixed;
357 }357 }
358+
359+ @media (max-width: 768px) {
360+ .login-window {
361+ width: 100% !important;
362+ height: 100% !important;
363+ top: 0 !important;
364+ left: 0 !important;
365+ right: 0 !important;
366+ bottom: 0 !important;
367+ margin: 0 !important;
368+ border-radius: 0 !important;
369+ transform: none !important;
370+ }
371+
372+ .window-content {
373+ height: calc(100% - 80px); /* Account for title bar and tabs */
374+ overflow-y: auto;
375+ }
376+
377+ form {
378+ max-width: 400px;
379+ margin: 0 auto;
380+ padding: 1rem;
381+ }
382+ }
383+
358 .title-bar {384 .title-bar {
359 background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);385 background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
360 padding: 4px 6px;386 padding: 4px 6px;
M src/lib/components/chat-room.svelte +220 -104
@@ -6,6 +6,7 @@ import { browser } from '$app/environment';
66 import { draggable } from '$lib/actions/draggable';
77 import { resizable } from '$lib/actions/resizable';
88 import { maximizable } from '$lib/actions/maximizable';
9+import { minimizable } from '$lib/actions/minimizable';
910 import LoadingButton from './ui/button-loading.svelte';
1011 import Tooltip from './ui/tooltip.svelte';
1112 import LoadingDots from './ui/loading-dots.svelte';
@@ -23,6 +24,7 @@ let showUserList = $state(false);
2324 let isMaximized = $state(false);
2425 let isInitialLoading = $state(true);
2526 let showAuth = $state(false);
27+let isMinimized = $state(false);
2628
2729 // Rate limiting state
2830 let cooldownEndTime = $state<number | null>(null);
@@ -311,7 +313,19 @@ interface MaximizableNode extends HTMLElement {
311313 function handleMaximize(event: MouseEvent) {
312314 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
313315 if (node) {
314- node.toggleMaximize();
316+ // If minimized, restore first
317+ if (isMinimized) {
318+ const minimizableNode = node as any;
319+ if (minimizableNode?.toggleMinimize) {
320+ minimizableNode.toggleMinimize();
321+ }
322+ // Wait for minimize animation to complete
323+ setTimeout(() => {
324+ node.toggleMaximize();
325+ }, 300);
326+ } else {
327+ node.toggleMaximize();
328+ }
315329 }
316330 }
317331
@@ -329,6 +343,10 @@ function handleMaximizeEvent(event: CustomEvent<{
329343 windowY = event.detail.y;
330344 }
331345
346+function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) {
347+ isMinimized = event.detail.isMinimized;
348+}
349+
332350 async function handleScroll(event: Event) {
333351 const chatArea = event.target as HTMLElement;
334352 const { scrollTop } = chatArea;
@@ -419,18 +437,21 @@ function handleLoginSuccess() {
419437
420438 {#if showChatRoom}
421439 <div
422- class="chat-window window"
440+ class="chat-window window"
441+ class:minimized={isMinimized}
423442 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
424443 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
425444 use:resizable={{
426- enabled: !isMobile && !isMaximized,
445+ enabled: !isMobile && !isMaximized && !isMinimized,
427446 minWidth: 400,
428447 minHeight: 500,
429448 maxWidth: window.innerWidth - 40,
430449 maxHeight: window.innerHeight - 40
431450 }}
432- use:maximizable={{ enabled: !isMobile, padding: 4 }}
451+ use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }}
452+ use:minimizable={{ enabled: !isMobile }}
433453 onmaximize={handleMaximizeEvent}
454+ onminimize={handleMinimize}
434455 onresizemove={(e) => {
435456 windowWidth = Math.max(400, e.detail.width);
436457 windowHeight = Math.max(500, e.detail.height);
@@ -438,15 +459,19 @@ function handleLoginSuccess() {
438459 ondragmove={handleDragMove}
439460 >
440461 <div class="title-bar">
441- <div class="title-bar-text">
462+ <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
442463 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
443- {#if sseError}
464+ {#if sseError && !isMinimized}
444465 <span class="connection-error">⚠️ Erreur de connexion</span>
445466 {/if}
446467 </div>
447468 <div class="title-bar-controls">
448469 {#if !isMobile}
449- <button aria-label="Minimize"></button>
470+ <button aria-label="Minimize" onclick={(e) => {
471+ e.stopPropagation();
472+ const node = (e.currentTarget as HTMLElement).closest('.window') as any;
473+ if (node?.toggleMinimize) node.toggleMinimize();
474+ }}></button>
450475 <button
451476 aria-label="Maximize"
452477 class:maximized={isMaximized}
@@ -457,115 +482,142 @@ function handleLoginSuccess() {
457482 <button
458483 aria-label="Afficher/Masquer la liste des contacts"
459484 onclick={() => showUserList = !showUserList}
485+ class="contacts-btn"
460486 >👥</button>
487+ <button
488+ aria-label="Minimize"
489+ onclick={(e) => {
490+ e.stopPropagation();
491+ const node = (e.currentTarget as HTMLElement).closest('.window') as any;
492+ if (node?.toggleMinimize) node.toggleMinimize();
493+ }}
494+ ></button>
495+ <button onclick={handleClose} aria-label="Close"></button>
461496 {/if}
462497 </div>
463498 </div>
464499
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>
500+ {#if !isMinimized}
501+ <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
502+ {#if sseError}
503+ <div class="error-banner">
504+ {sseError}
505+ {#if sseRetryAfter}
506+ <br>
507+ <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
482508 {/if}
483509 </div>
484510 {/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>
511+
512+ <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
513+ {#if rateLimitWarning}
514+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
515+ <span>{rateLimitWarning}</span>
516+ {#if cooldownEndTime}
517+ <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
518+ {/if}
503519 </div>
504520 {/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>
521+
522+ <div
523+ class="sunken-panel chat-area"
524+ style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
525+ onscroll={(e) => handleScroll(e)}
526+ >
527+ {#if isInitialLoading}
528+ <div class="initial-loading">
529+ <LoadingDots text="Chargement des messages" />
530+ </div>
531+ {:else if isLoadingMore}
532+ <div class="loading-messages">
533+ <LoadingDots text="Chargement" />
534+ </div>
535+ {/if}
536+ {#if showRegistrationPrompt}
537+ <div class="registration-prompt">
538+ <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button> pour lire le reste du chat !</p>
539+ </div>
540+ {/if}
541+ {#each visibleMessages as message (message.id)}
542+ <div class="message {message.type} text">
543+ {#if message.type === 'emote'}
544+ <span class="emote-text">
545+ <Tooltip data={{
546+ text: formatFrenchDateTime(new Date(message.timestamp)),
547+ direction: "left",
548+ closeDelay: 1000,
549+ touchBehavior: "remove"
550+ }}>
551+ <span class="nickname">{message.user.nickname}</span>
552+ </Tooltip>
553+ {message.content}
554+ </span>
555+ {:else}
556+ <Tooltip data={{
557+ text: formatFrenchDateTime(new Date(message.timestamp)),
558+ direction: "left",
559+ closeDelay: 1000,
560+ touchBehavior: "remove"
561+ }}>
562+ <span class="nickname">{message.user.nickname}:</span>
511563 </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- />
564+ <span class="content">{message.content}</span>
565+ {/if}
566+ </div>
567+ {/each}
568+ </div>
569+
570+ <div class="field-row input-container" style="margin: 0;">
571+ <input
572+ type="text"
573+ bind:value={currentMessage}
574+ style="flex: 1;"
575+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
576+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
577+ disabled={!currentUser || Boolean(cooldownEndTime)}
578+ />
579+ <LoadingButton
580+ onclick={handleSubmit}
581+ disabled={!currentUser || Boolean(cooldownEndTime)}
582+ loading={isSendingMessage}
583+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Envoyer"}
584+ />
585+ </div>
586+ {#if cooldownEndTime}
587+ <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
588+ {/if}
539589 </div>
540- {#if cooldownEndTime}
541- <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
542- {/if}
543- </div>
544590
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" }}>
591+ <!-- Online users list -->
592+ <div
593+ class="sunken-panel users-list"
594+ class:mobile={isMobile}
595+ class:hidden={isMobile && !showUserList}
596+ style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
597+ >
598+ <p style="margin: 0 0 0.3rem 0;"><strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong></p>
599+ {#each onlineUsers as user}
600+ <div class="user" class:offline={user.status === 'offline'}>
601+ <span class="status-icon">{getStatusIcon(user.status)}</span>
602+ <div class="user-info">
603+ {#if user.status === 'offline'}
604+ <Tooltip data={{
605+ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen),
606+ direction: "bottom",
607+ closeDelay: 1000,
608+ touchBehavior: "remove"
609+ }}>
610+ <div class="nickname select-none">{user.nickname}</div>
611+ </Tooltip>
612+ {:else}
559613 <div class="nickname select-none">{user.nickname}</div>
560- </Tooltip>
561- {:else}
562- <div class="nickname select-none">{user.nickname}</div>
563- {/if}
614+ {/if}
615+ </div>
564616 </div>
565- </div>
566- {/each}
617+ {/each}
618+ </div>
567619 </div>
568- </div>
620+ {/if}
569621 </div>
570622 {/if}
571623
@@ -588,11 +640,15 @@ function handleLoginSuccess() {
588640 position: relative;
589641 cursor: move;
590642 user-select: none;
643+ display: flex;
644+ align-items: center;
645+ padding: 0 0.5rem;
591646 }
592647
593648 .chat-window {
594649 position: fixed;
595650 box-sizing: border-box;
651+ transition: width 0.3s ease, height 0.3s ease, left 0.3s ease, top 0.3s ease;
596652 }
597653
598654 .chat-area {
@@ -723,6 +779,42 @@ function handleLoginSuccess() {
723779 bottom: 0 !important;
724780 width: 100% !important;
725781 height: 100% !important;
782+ transition: transform 0.3s ease !important;
783+ margin: 0 !important;
784+ border-radius: 0 !important;
785+ }
786+
787+ .window.minimized {
788+ transform: translateY(calc(100% - 32px)) !important;
789+ height: 100% !important;
790+ }
791+
792+ .window.minimized .title-bar {
793+ height: 32px;
794+ }
795+
796+ .title-bar-controls button {
797+ width: 24px;
798+ height: 24px;
799+ padding: 0;
800+ margin: 0 2px;
801+ background-color: transparent;
802+ border: 1px solid transparent;
803+ position: relative;
804+ }
805+
806+ .title-bar-controls button.contacts-btn {
807+ width: auto;
808+ padding: 0 0.5rem;
809+ font-size: 1.25rem;
810+ }
811+
812+ .window.minimized .title-bar-controls button {
813+ opacity: 0.8;
814+ }
815+
816+ .window.minimized .title-bar-controls button.contacts-btn {
817+ opacity: 0.8;
726818 }
727819
728820 .window-body {
@@ -751,10 +843,6 @@ function handleLoginSuccess() {
751843 padding: 0.5rem;
752844 border-top: 0.125rem solid #dfdfdf;
753845 }
754-
755- :global(.resize-handle) {
756- display: none;
757- }
758846 }
759847
760848 .relative-time {
@@ -904,4 +992,32 @@ function handleLoginSuccess() {
904992 color: #ef6c00;
905993 text-decoration: none;
906994 }
995+
996+ .chat-window.minimized {
997+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
998+ }
999+
1000+ .minimized .title-bar {
1001+ cursor: pointer;
1002+ }
1003+
1004+ .minimized .window-body {
1005+ display: none;
1006+ }
1007+
1008+ .minimized :global(.resize-handle) {
1009+ display: none;
1010+ }
1011+
1012+ .title-bar-text {
1013+ flex: 1;
1014+ min-width: 0;
1015+ padding-right: 0.5rem;
1016+ }
1017+
1018+ .title-bar-controls {
1019+ display: flex;
1020+ gap: 2px;
1021+ margin-left: auto;
1022+ }
9071023 </style>⏎̸
@@ -6,6 +6,7 @@ import { browser } from '$app/environment';
6 import { draggable } from '$lib/actions/draggable';6 import { draggable } from '$lib/actions/draggable';
7 import { resizable } from '$lib/actions/resizable';7 import { resizable } from '$lib/actions/resizable';
8 import { maximizable } from '$lib/actions/maximizable';8 import { maximizable } from '$lib/actions/maximizable';
9+import { minimizable } from '$lib/actions/minimizable';
9 import LoadingButton from './ui/button-loading.svelte';10 import LoadingButton from './ui/button-loading.svelte';
10 import Tooltip from './ui/tooltip.svelte';11 import Tooltip from './ui/tooltip.svelte';
11 import LoadingDots from './ui/loading-dots.svelte';12 import LoadingDots from './ui/loading-dots.svelte';
@@ -23,6 +24,7 @@ let showUserList = $state(false);
23 let isMaximized = $state(false);24 let isMaximized = $state(false);
24 let isInitialLoading = $state(true);25 let isInitialLoading = $state(true);
25 let showAuth = $state(false);26 let showAuth = $state(false);
27+let isMinimized = $state(false);
26 28
27 // Rate limiting state29 // Rate limiting state
28 let cooldownEndTime = $state<number | null>(null);30 let cooldownEndTime = $state<number | null>(null);
@@ -311,7 +313,19 @@ interface MaximizableNode extends HTMLElement {
311 function handleMaximize(event: MouseEvent) {313 function handleMaximize(event: MouseEvent) {
312 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;314 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
313 if (node) {315 if (node) {
314- node.toggleMaximize();316+ // If minimized, restore first
317+ if (isMinimized) {
318+ const minimizableNode = node as any;
319+ if (minimizableNode?.toggleMinimize) {
320+ minimizableNode.toggleMinimize();
321+ }
322+ // Wait for minimize animation to complete
323+ setTimeout(() => {
324+ node.toggleMaximize();
325+ }, 300);
326+ } else {
327+ node.toggleMaximize();
328+ }
315 }329 }
316 }330 }
317 331
@@ -329,6 +343,10 @@ function handleMaximizeEvent(event: CustomEvent<{
329 windowY = event.detail.y;343 windowY = event.detail.y;
330 }344 }
331 345
346+function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) {
347+ isMinimized = event.detail.isMinimized;
348+}
349+
332 async function handleScroll(event: Event) {350 async function handleScroll(event: Event) {
333 const chatArea = event.target as HTMLElement;351 const chatArea = event.target as HTMLElement;
334 const { scrollTop } = chatArea;352 const { scrollTop } = chatArea;
@@ -419,18 +437,21 @@ function handleLoginSuccess() {
419 437
420 {#if showChatRoom}438 {#if showChatRoom}
421 <div 439 <div
422- class="chat-window window" 440+ class="chat-window window"
441+ class:minimized={isMinimized}
423 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"442 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
424 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}443 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
425 use:resizable={{444 use:resizable={{
426- enabled: !isMobile && !isMaximized,445+ enabled: !isMobile && !isMaximized && !isMinimized,
427 minWidth: 400,446 minWidth: 400,
428 minHeight: 500,447 minHeight: 500,
429 maxWidth: window.innerWidth - 40,448 maxWidth: window.innerWidth - 40,
430 maxHeight: window.innerHeight - 40449 maxHeight: window.innerHeight - 40
431 }}450 }}
432- use:maximizable={{ enabled: !isMobile, padding: 4 }}451+ use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }}
452+ use:minimizable={{ enabled: !isMobile }}
433 onmaximize={handleMaximizeEvent}453 onmaximize={handleMaximizeEvent}
454+ onminimize={handleMinimize}
434 onresizemove={(e) => {455 onresizemove={(e) => {
435 windowWidth = Math.max(400, e.detail.width);456 windowWidth = Math.max(400, e.detail.width);
436 windowHeight = Math.max(500, e.detail.height);457 windowHeight = Math.max(500, e.detail.height);
@@ -438,15 +459,19 @@ function handleLoginSuccess() {
438 ondragmove={handleDragMove}459 ondragmove={handleDragMove}
439 >460 >
440 <div class="title-bar">461 <div class="title-bar">
441- <div class="title-bar-text">462+ <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
442 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}463 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
443- {#if sseError}464+ {#if sseError && !isMinimized}
444 <span class="connection-error">⚠️ Erreur de connexion</span>465 <span class="connection-error">⚠️ Erreur de connexion</span>
445 {/if}466 {/if}
446 </div>467 </div>
447 <div class="title-bar-controls">468 <div class="title-bar-controls">
448 {#if !isMobile}469 {#if !isMobile}
449- <button aria-label="Minimize"></button>470+ <button aria-label="Minimize" onclick={(e) => {
471+ e.stopPropagation();
472+ const node = (e.currentTarget as HTMLElement).closest('.window') as any;
473+ if (node?.toggleMinimize) node.toggleMinimize();
474+ }}></button>
450 <button 475 <button
451 aria-label="Maximize" 476 aria-label="Maximize"
452 class:maximized={isMaximized}477 class:maximized={isMaximized}
@@ -457,115 +482,142 @@ function handleLoginSuccess() {
457 <button 482 <button
458 aria-label="Afficher/Masquer la liste des contacts"483 aria-label="Afficher/Masquer la liste des contacts"
459 onclick={() => showUserList = !showUserList}484 onclick={() => showUserList = !showUserList}
485+ class="contacts-btn"
460 >👥</button>486 >👥</button>
487+ <button
488+ aria-label="Minimize"
489+ onclick={(e) => {
490+ e.stopPropagation();
491+ const node = (e.currentTarget as HTMLElement).closest('.window') as any;
492+ if (node?.toggleMinimize) node.toggleMinimize();
493+ }}
494+ ></button>
495+ <button onclick={handleClose} aria-label="Close"></button>
461 {/if}496 {/if}
462 </div>497 </div>
463 </div>498 </div>
464 499
465- <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">500+ {#if !isMinimized}
466- {#if sseError}501+ <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
467- <div class="error-banner">502+ {#if sseError}
468- {sseError}503+ <div class="error-banner">
469- {#if sseRetryAfter}504+ {sseError}
470- <br>505+ {#if sseRetryAfter}
471- <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>506+ <br>
472- {/if}507+ <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
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}508 {/if}
483 </div>509 </div>
484 {/if}510 {/if}
485-511+
486- <div 512+ <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
487- class="sunken-panel chat-area"513+ {#if rateLimitWarning}
488- style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"514+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
489- onscroll={(e) => handleScroll(e)}515+ <span>{rateLimitWarning}</span>
490- >516+ {#if cooldownEndTime}
491- {#if isInitialLoading}517+ <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
492- <div class="initial-loading">518+ {/if}
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>519 </div>
504 {/if}520 {/if}
505- {#each visibleMessages as message (message.id)}521+
506- <div class="message {message.type} text">522+ <div
507- {#if message.type === 'emote'}523+ class="sunken-panel chat-area"
508- <span class="emote-text">524+ style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
509- <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>525+ onscroll={(e) => handleScroll(e)}
510- <span class="nickname">{message.user.nickname}</span>526+ >
527+ {#if isInitialLoading}
528+ <div class="initial-loading">
529+ <LoadingDots text="Chargement des messages" />
530+ </div>
531+ {:else if isLoadingMore}
532+ <div class="loading-messages">
533+ <LoadingDots text="Chargement" />
534+ </div>
535+ {/if}
536+ {#if showRegistrationPrompt}
537+ <div class="registration-prompt">
538+ <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button> pour lire le reste du chat !</p>
539+ </div>
540+ {/if}
541+ {#each visibleMessages as message (message.id)}
542+ <div class="message {message.type} text">
543+ {#if message.type === 'emote'}
544+ <span class="emote-text">
545+ <Tooltip data={{
546+ text: formatFrenchDateTime(new Date(message.timestamp)),
547+ direction: "left",
548+ closeDelay: 1000,
549+ touchBehavior: "remove"
550+ }}>
551+ <span class="nickname">{message.user.nickname}</span>
552+ </Tooltip>
553+ {message.content}
554+ </span>
555+ {:else}
556+ <Tooltip data={{
557+ text: formatFrenchDateTime(new Date(message.timestamp)),
558+ direction: "left",
559+ closeDelay: 1000,
560+ touchBehavior: "remove"
561+ }}>
562+ <span class="nickname">{message.user.nickname}:</span>
511 </Tooltip>563 </Tooltip>
512- {message.content}564+ <span class="content">{message.content}</span>
513- </span>565+ {/if}
514- {:else}566+ </div>
515- <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>567+ {/each}
516- <span class="nickname">{message.user.nickname}:</span>568+ </div>
517- </Tooltip>569+
518- <span class="content">{message.content}</span>570+ <div class="field-row input-container" style="margin: 0;">
519- {/if}571+ <input
520- </div>572+ type="text"
521- {/each}573+ bind:value={currentMessage}
522- </div>574+ style="flex: 1;"
523- 575+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
524- <div class="field-row input-container" style="margin: 0;">576+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
525- <input 577+ disabled={!currentUser || Boolean(cooldownEndTime)}
526- type="text" 578+ />
527- bind:value={currentMessage}579+ <LoadingButton
528- style="flex: 1;"580+ onclick={handleSubmit}
529- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}581+ disabled={!currentUser || Boolean(cooldownEndTime)}
530- placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}582+ loading={isSendingMessage}
531- disabled={!currentUser || Boolean(cooldownEndTime)}583+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Envoyer"}
532- />584+ />
533- <LoadingButton 585+ </div>
534- onclick={handleSubmit} 586+ {#if cooldownEndTime}
535- disabled={!currentUser || Boolean(cooldownEndTime)} 587+ <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
536- loading={isSendingMessage}588+ {/if}
537- text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Envoyer"}
538- />
539 </div>589 </div>
540- {#if cooldownEndTime}
541- <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
542- {/if}
543- </div>
544 590
545- <!-- Online users list -->591+ <!-- Online users list -->
546- <div 592+ <div
547- class="sunken-panel users-list"593+ class="sunken-panel users-list"
548- class:mobile={isMobile}594+ class:mobile={isMobile}
549- class:hidden={isMobile && !showUserList}595+ class:hidden={isMobile && !showUserList}
550- style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"596+ style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
551- >597+ >
552- <p style="margin: 0 0 0.3rem 0;"><strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong></p>598+ <p style="margin: 0 0 0.3rem 0;"><strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong></p>
553- {#each onlineUsers as user}599+ {#each onlineUsers as user}
554- <div class="user" class:offline={user.status === 'offline'}>600+ <div class="user" class:offline={user.status === 'offline'}>
555- <span class="status-icon">{getStatusIcon(user.status)}</span>601+ <span class="status-icon">{getStatusIcon(user.status)}</span>
556- <div class="user-info">602+ <div class="user-info">
557- {#if user.status === 'offline'}603+ {#if user.status === 'offline'}
558- <Tooltip data={{ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen), direction: "bottom" }}>604+ <Tooltip data={{
605+ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen),
606+ direction: "bottom",
607+ closeDelay: 1000,
608+ touchBehavior: "remove"
609+ }}>
610+ <div class="nickname select-none">{user.nickname}</div>
611+ </Tooltip>
612+ {:else}
559 <div class="nickname select-none">{user.nickname}</div>613 <div class="nickname select-none">{user.nickname}</div>
560- </Tooltip>614+ {/if}
561- {:else}615+ </div>
562- <div class="nickname select-none">{user.nickname}</div>
563- {/if}
564 </div>616 </div>
565- </div>617+ {/each}
566- {/each}618+ </div>
567 </div>619 </div>
568- </div>620+ {/if}
569 </div>621 </div>
570 {/if}622 {/if}
571 623
@@ -588,11 +640,15 @@ function handleLoginSuccess() {
588 position: relative;640 position: relative;
589 cursor: move;641 cursor: move;
590 user-select: none;642 user-select: none;
643+ display: flex;
644+ align-items: center;
645+ padding: 0 0.5rem;
591 }646 }
592 647
593 .chat-window {648 .chat-window {
594 position: fixed;649 position: fixed;
595 box-sizing: border-box;650 box-sizing: border-box;
651+ transition: width 0.3s ease, height 0.3s ease, left 0.3s ease, top 0.3s ease;
596 }652 }
597 653
598 .chat-area {654 .chat-area {
@@ -723,6 +779,42 @@ function handleLoginSuccess() {
723 bottom: 0 !important;779 bottom: 0 !important;
724 width: 100% !important;780 width: 100% !important;
725 height: 100% !important;781 height: 100% !important;
782+ transition: transform 0.3s ease !important;
783+ margin: 0 !important;
784+ border-radius: 0 !important;
785+ }
786+
787+ .window.minimized {
788+ transform: translateY(calc(100% - 32px)) !important;
789+ height: 100% !important;
790+ }
791+
792+ .window.minimized .title-bar {
793+ height: 32px;
794+ }
795+
796+ .title-bar-controls button {
797+ width: 24px;
798+ height: 24px;
799+ padding: 0;
800+ margin: 0 2px;
801+ background-color: transparent;
802+ border: 1px solid transparent;
803+ position: relative;
804+ }
805+
806+ .title-bar-controls button.contacts-btn {
807+ width: auto;
808+ padding: 0 0.5rem;
809+ font-size: 1.25rem;
810+ }
811+
812+ .window.minimized .title-bar-controls button {
813+ opacity: 0.8;
814+ }
815+
816+ .window.minimized .title-bar-controls button.contacts-btn {
817+ opacity: 0.8;
726 }818 }
727 819
728 .window-body {820 .window-body {
@@ -751,10 +843,6 @@ function handleLoginSuccess() {
751 padding: 0.5rem;843 padding: 0.5rem;
752 border-top: 0.125rem solid #dfdfdf;844 border-top: 0.125rem solid #dfdfdf;
753 }845 }
754-
755- :global(.resize-handle) {
756- display: none;
757- }
758 }846 }
759 847
760 .relative-time {848 .relative-time {
@@ -904,4 +992,32 @@ function handleLoginSuccess() {
904 color: #ef6c00;992 color: #ef6c00;
905 text-decoration: none;993 text-decoration: none;
906 }994 }
995+
996+ .chat-window.minimized {
997+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
998+ }
999+
1000+ .minimized .title-bar {
1001+ cursor: pointer;
1002+ }
1003+
1004+ .minimized .window-body {
1005+ display: none;
1006+ }
1007+
1008+ .minimized :global(.resize-handle) {
1009+ display: none;
1010+ }
1011+
1012+ .title-bar-text {
1013+ flex: 1;
1014+ min-width: 0;
1015+ padding-right: 0.5rem;
1016+ }
1017+
1018+ .title-bar-controls {
1019+ display: flex;
1020+ gap: 2px;
1021+ margin-left: auto;
1022+ }
907 </style>⏎̸1023 </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