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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe 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 @@ | |||
| 355 | 355 | /* Changed to fixed positioning for correct dragging */ | |
| 356 | 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 | 384 | .title-bar { | |
| 359 | 385 | background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%); | |
| 360 | 386 | 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'; | |||
| 6 | 6 | import { draggable } from '$lib/actions/draggable'; | |
| 7 | 7 | import { resizable } from '$lib/actions/resizable'; | |
| 8 | 8 | import { maximizable } from '$lib/actions/maximizable'; | |
| 9 | + | import { minimizable } from '$lib/actions/minimizable'; | |
| 9 | 10 | import LoadingButton from './ui/button-loading.svelte'; | |
| 10 | 11 | import Tooltip from './ui/tooltip.svelte'; | |
| 11 | 12 | import LoadingDots from './ui/loading-dots.svelte'; | |
| @@ -23,6 +24,7 @@ let showUserList = $state(false); | |||
| 23 | 24 | let isMaximized = $state(false); | |
| 24 | 25 | let isInitialLoading = $state(true); | |
| 25 | 26 | let showAuth = $state(false); | |
| 27 | + | let isMinimized = $state(false); | |
| 26 | 28 | ||
| 27 | 29 | // Rate limiting state | |
| 28 | 30 | let cooldownEndTime = $state<number | null>(null); | |
| @@ -311,7 +313,19 @@ interface MaximizableNode extends HTMLElement { | |||
| 311 | 313 | function handleMaximize(event: MouseEvent) { | |
| 312 | 314 | const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode; | |
| 313 | 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 | 343 | windowY = event.detail.y; | |
| 330 | 344 | } | |
| 331 | 345 | ||
| 346 | + | function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) { | |
| 347 | + | isMinimized = event.detail.isMinimized; | |
| 348 | + | } | |
| 349 | + | ||
| 332 | 350 | async function handleScroll(event: Event) { | |
| 333 | 351 | const chatArea = event.target as HTMLElement; | |
| 334 | 352 | const { scrollTop } = chatArea; | |
| @@ -419,18 +437,21 @@ function handleLoginSuccess() { | |||
| 419 | 437 | ||
| 420 | 438 | {#if showChatRoom} | |
| 421 | 439 | <div | |
| 422 | - | class="chat-window window" | |
| 440 | + | class="chat-window window" | |
| 441 | + | class:minimized={isMinimized} | |
| 423 | 442 | style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;" | |
| 424 | 443 | use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }} | |
| 425 | 444 | use:resizable={{ | |
| 426 | - | enabled: !isMobile && !isMaximized, | |
| 445 | + | enabled: !isMobile && !isMaximized && !isMinimized, | |
| 427 | 446 | minWidth: 400, | |
| 428 | 447 | minHeight: 500, | |
| 429 | 448 | maxWidth: window.innerWidth - 40, | |
| 430 | 449 | 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 | 453 | onmaximize={handleMaximizeEvent} | |
| 454 | + | onminimize={handleMinimize} | |
| 434 | 455 | onresizemove={(e) => { | |
| 435 | 456 | windowWidth = Math.max(400, e.detail.width); | |
| 436 | 457 | windowHeight = Math.max(500, e.detail.height); | |
| @@ -438,15 +459,19 @@ function handleLoginSuccess() { | |||
| 438 | 459 | ondragmove={handleDragMove} | |
| 439 | 460 | > | |
| 440 | 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 | 463 | Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | |
| 443 | - | {#if sseError} | |
| 464 | + | {#if sseError && !isMinimized} | |
| 444 | 465 | <span class="connection-error">⚠️ Erreur de connexion</span> | |
| 445 | 466 | {/if} | |
| 446 | 467 | </div> | |
| 447 | 468 | <div class="title-bar-controls"> | |
| 448 | 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 | 475 | <button | |
| 451 | 476 | aria-label="Maximize" | |
| 452 | 477 | class:maximized={isMaximized} | |
| @@ -457,115 +482,142 @@ function handleLoginSuccess() { | |||
| 457 | 482 | <button | |
| 458 | 483 | aria-label="Afficher/Masquer la liste des contacts" | |
| 459 | 484 | onclick={() => showUserList = !showUserList} | |
| 485 | + | class="contacts-btn" | |
| 460 | 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 | 496 | {/if} | |
| 462 | 497 | </div> | |
| 463 | 498 | </div> | |
| 464 | 499 | ||
| 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> | |
| 482 | 508 | {/if} | |
| 483 | 509 | </div> | |
| 484 | 510 | {/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} | |
| 503 | 519 | </div> | |
| 504 | 520 | {/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> | |
| 511 | 563 | </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} | |
| 539 | 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 --> | |
| 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} | |
| 559 | 613 | <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> | |
| 564 | 616 | </div> | |
| 565 | - | </div> | |
| 566 | - | {/each} | |
| 617 | + | {/each} | |
| 618 | + | </div> | |
| 567 | 619 | </div> | |
| 568 | - | </div> | |
| 620 | + | {/if} | |
| 569 | 621 | </div> | |
| 570 | 622 | {/if} | |
| 571 | 623 | ||
| @@ -588,11 +640,15 @@ function handleLoginSuccess() { | |||
| 588 | 640 | position: relative; | |
| 589 | 641 | cursor: move; | |
| 590 | 642 | user-select: none; | |
| 643 | + | display: flex; | |
| 644 | + | align-items: center; | |
| 645 | + | padding: 0 0.5rem; | |
| 591 | 646 | } | |
| 592 | 647 | ||
| 593 | 648 | .chat-window { | |
| 594 | 649 | position: fixed; | |
| 595 | 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 | 654 | .chat-area { | |
| @@ -723,6 +779,42 @@ function handleLoginSuccess() { | |||
| 723 | 779 | bottom: 0 !important; | |
| 724 | 780 | width: 100% !important; | |
| 725 | 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 | 820 | .window-body { | |
| @@ -751,10 +843,6 @@ function handleLoginSuccess() { | |||
| 751 | 843 | padding: 0.5rem; | |
| 752 | 844 | border-top: 0.125rem solid #dfdfdf; | |
| 753 | 845 | } | |
| 754 | - | ||
| 755 | - | :global(.resize-handle) { | |
| 756 | - | display: none; | |
| 757 | - | } | |
| 758 | 846 | } | |
| 759 | 847 | ||
| 760 | 848 | .relative-time { | |
| @@ -904,4 +992,32 @@ function handleLoginSuccess() { | |||
| 904 | 992 | color: #ef6c00; | |
| 905 | 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 | 1023 | </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 state | 29 | // 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 - 40 | 449 | 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>⏎̸ | ||