detached 88fcae1

feat: Improve authentication security and UX with rate limiting and login attempts management

- Add IP-based rate limiting for login attempts with exponential backoff
- Implement login attempt tracking and cleanup mechanism
- Enhance login error messages with remaining attempt count
- Refactor AIM login component to use bindable props
- Add session renewal logic for active users
- Improve chat room and login UI with tooltips and date formatting
commit
88fcae1d915cd77eaec409a6d49b828ad834f5d6
author
Kit · · 2025-02-14 07:48 UTC
parent
6b9673c
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
refs
tag: v0.1.1
11 files · +532 -79
M package.json +1 -1
@@ -1,7 +1,7 @@
11 {
22 "name": "pdraim",
33 "private": true,
4- "version": "0.1.0",
4+ "version": "0.1.1",
55 "type": "module",
66 "engines": {
77 "node": ">=22.13.0"
@@ -1,7 +1,7 @@
1 {1 {
2 "name": "pdraim",2 "name": "pdraim",
3 "private": true,3 "private": true,
4- "version": "0.1.0",4+ "version": "0.1.1",
5 "type": "module",5 "type": "module",
6 "engines": {6 "engines": {
7 "node": ">=22.13.0"7 "node": ">=22.13.0"
M src/hooks.server.ts +7 -0
@@ -2,6 +2,7 @@ import type { Handle } from "@sveltejs/kit";
22 import { validateSessionToken } from "$lib/api/session.server";
33 import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";
44 import { initializeDatabase } from "$lib/db/db.server";
5+import { handleRateLimit } from "$lib/api/rate-limiter";
56
67 // Initialize database when the server starts
78 console.debug("Initializing database on server start...");
@@ -12,6 +13,12 @@ initializeDatabase().catch(error => {
1213 export const handle: Handle = async ({ event, resolve }) => {
1314 console.debug("Handling request:", event.url.pathname);
1415
16+ // Apply rate limiting for API routes
17+ const rateLimitResponse = await handleRateLimit(event);
18+ if (rateLimitResponse) {
19+ return rateLimitResponse;
20+ }
21+
1522 // Check if this is a public chat messages request
1623 const isPublicChatRequest =
1724 event.url.pathname === '/api/chat/messages' &&
@@ -2,6 +2,7 @@ import type { Handle } from "@sveltejs/kit";
2 import { validateSessionToken } from "$lib/api/session.server";2 import { validateSessionToken } from "$lib/api/session.server";
3 import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";3 import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";
4 import { initializeDatabase } from "$lib/db/db.server";4 import { initializeDatabase } from "$lib/db/db.server";
5+import { handleRateLimit } from "$lib/api/rate-limiter";
5 6
6 // Initialize database when the server starts7 // Initialize database when the server starts
7 console.debug("Initializing database on server start...");8 console.debug("Initializing database on server start...");
@@ -12,6 +13,12 @@ initializeDatabase().catch(error => {
12 export const handle: Handle = async ({ event, resolve }) => {13 export const handle: Handle = async ({ event, resolve }) => {
13 console.debug("Handling request:", event.url.pathname);14 console.debug("Handling request:", event.url.pathname);
14 15
16+ // Apply rate limiting for API routes
17+ const rateLimitResponse = await handleRateLimit(event);
18+ if (rateLimitResponse) {
19+ return rateLimitResponse;
20+ }
21+
15 // Check if this is a public chat messages request22 // Check if this is a public chat messages request
16 const isPublicChatRequest = 23 const isPublicChatRequest =
17 event.url.pathname === '/api/chat/messages' && 24 event.url.pathname === '/api/chat/messages' &&
A src/lib/api/rate-limiter.ts +98 -0
@@ -0,0 +1,98 @@
1+import type { RequestEvent } from '@sveltejs/kit';
2+
3+// Rate limit configuration by endpoint type
4+const RATE_LIMITS = {
5+ // Authentication endpoints (login, register)
6+ auth: { points: 5, durationMs: 5 * 60 * 1000 }, // 5 requests per 5 minutes
7+
8+ // Public endpoints (public chat, rooms)
9+ public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute
10+
11+ // Protected endpoints (authenticated API calls)
12+ protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute
13+
14+ // SSE endpoints
15+ sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute
16+} as const;
17+
18+// In-memory store for rate limiting
19+const ipRequests = new Map<string, Array<{ timestamp: number }>>();
20+
21+// Cleanup old entries every 5 minutes
22+setInterval(() => {
23+ const now = Date.now();
24+ for (const [ip, timestamps] of ipRequests.entries()) {
25+ // Remove entries older than the maximum duration we care about (5 minutes)
26+ const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000);
27+ if (filtered.length === 0) {
28+ ipRequests.delete(ip);
29+ } else {
30+ ipRequests.set(ip, filtered);
31+ }
32+ }
33+}, 5 * 60 * 1000);
34+
35+function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS {
36+ if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
37+ return 'auth';
38+ }
39+ if (pathname.startsWith('/api/sse')) {
40+ return 'sse';
41+ }
42+ if (isPublic) {
43+ return 'public';
44+ }
45+ return 'protected';
46+}
47+
48+function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean {
49+ const now = Date.now();
50+ const { points, durationMs } = RATE_LIMITS[endpointType];
51+
52+ // Get or initialize request history for this IP
53+ const requests = ipRequests.get(ip) || [];
54+
55+ // Clean old requests outside the window
56+ const validRequests = requests.filter(req => now - req.timestamp < durationMs);
57+
58+ // Check if we're over the limit
59+ if (validRequests.length >= points) {
60+ console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`);
61+ return true;
62+ }
63+
64+ // Add this request
65+ validRequests.push({ timestamp: now });
66+ ipRequests.set(ip, validRequests);
67+
68+ return false;
69+}
70+
71+export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
72+ const ip = event.getClientAddress();
73+ const url = new URL(event.request.url);
74+ const isPublic = url.searchParams.get('public') === 'true';
75+
76+ // Skip rate limiting for non-API routes
77+ if (!url.pathname.startsWith('/api')) {
78+ return null;
79+ }
80+
81+ const endpointType = getEndpointType(url.pathname, isPublic);
82+
83+ if (isRateLimited(ip, endpointType)) {
84+ console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`);
85+ return new Response(JSON.stringify({
86+ error: 'Too many requests',
87+ retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000)
88+ }), {
89+ status: 429,
90+ headers: {
91+ 'Content-Type': 'application/json',
92+ 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString()
93+ }
94+ });
95+ }
96+
97+ return null;
98+} ⏎̸
@@ -0,0 +1,98 @@
1+import type { RequestEvent } from '@sveltejs/kit';
2+
3+// Rate limit configuration by endpoint type
4+const RATE_LIMITS = {
5+ // Authentication endpoints (login, register)
6+ auth: { points: 5, durationMs: 5 * 60 * 1000 }, // 5 requests per 5 minutes
7+
8+ // Public endpoints (public chat, rooms)
9+ public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute
10+
11+ // Protected endpoints (authenticated API calls)
12+ protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute
13+
14+ // SSE endpoints
15+ sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute
16+} as const;
17+
18+// In-memory store for rate limiting
19+const ipRequests = new Map<string, Array<{ timestamp: number }>>();
20+
21+// Cleanup old entries every 5 minutes
22+setInterval(() => {
23+ const now = Date.now();
24+ for (const [ip, timestamps] of ipRequests.entries()) {
25+ // Remove entries older than the maximum duration we care about (5 minutes)
26+ const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000);
27+ if (filtered.length === 0) {
28+ ipRequests.delete(ip);
29+ } else {
30+ ipRequests.set(ip, filtered);
31+ }
32+ }
33+}, 5 * 60 * 1000);
34+
35+function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS {
36+ if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
37+ return 'auth';
38+ }
39+ if (pathname.startsWith('/api/sse')) {
40+ return 'sse';
41+ }
42+ if (isPublic) {
43+ return 'public';
44+ }
45+ return 'protected';
46+}
47+
48+function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean {
49+ const now = Date.now();
50+ const { points, durationMs } = RATE_LIMITS[endpointType];
51+
52+ // Get or initialize request history for this IP
53+ const requests = ipRequests.get(ip) || [];
54+
55+ // Clean old requests outside the window
56+ const validRequests = requests.filter(req => now - req.timestamp < durationMs);
57+
58+ // Check if we're over the limit
59+ if (validRequests.length >= points) {
60+ console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`);
61+ return true;
62+ }
63+
64+ // Add this request
65+ validRequests.push({ timestamp: now });
66+ ipRequests.set(ip, validRequests);
67+
68+ return false;
69+}
70+
71+export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
72+ const ip = event.getClientAddress();
73+ const url = new URL(event.request.url);
74+ const isPublic = url.searchParams.get('public') === 'true';
75+
76+ // Skip rate limiting for non-API routes
77+ if (!url.pathname.startsWith('/api')) {
78+ return null;
79+ }
80+
81+ const endpointType = getEndpointType(url.pathname, isPublic);
82+
83+ if (isRateLimited(ip, endpointType)) {
84+ console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`);
85+ return new Response(JSON.stringify({
86+ error: 'Too many requests',
87+ retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000)
88+ }), {
89+ status: 429,
90+ headers: {
91+ 'Content-Type': 'application/json',
92+ 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString()
93+ }
94+ });
95+ }
96+
97+ return null;
98+} ⏎̸
M src/lib/components/aim-login.svelte +11 -38
@@ -3,19 +3,15 @@
33 import { draggable } from '$lib/actions/draggable';
44 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
55 import type { User } from '$lib/types/chat';
6- import { invalidate } from '$app/navigation';
7- import { createEventDispatcher } from 'svelte';
6+ import { invalidate, invalidateAll } from '$app/navigation';
87
9- let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props();
10- const dispatch = createEventDispatcher();
8+ let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
119
1210 let activeTab = $state('signin');
1311
1412 // Sign In state
1513 let siUsername = $state('');
1614 let siPassword = $state('');
17- let savePassword = $state(true);
18- let autoLogin = $state(false);
1915 let error = $state('');
2016 let loginStatus = $state('idle');
2117
@@ -27,6 +23,11 @@
2723 let signupError = $state('');
2824 let signupStatus = $state('idle');
2925
26+ function handleClose() {
27+ console.debug("Closing AIM Login component.");
28+ showAuth = false;
29+ }
30+
3031 async function handleSigninSubmit() {
3132 error = '';
3233 loginStatus = 'loading';
@@ -138,17 +139,14 @@
138139 return;
139140 }
140141 console.debug("Auto login after registration succeeded:", data);
141- await Promise.all([
142- invalidate('app:session'),
143- invalidate('app:chat'),
144- invalidate('custom:chat:session'),
145- invalidate('chat:session')
146- ]);
142+
147143 onLoginSuccess?.(data.user);
148144 setTimeout(() => {
149145 console.debug("Auto closing AIM Login component after auto login on registration");
146+ invalidateAll();
150147 handleClose();
151148 }, 3000);
149+
152150 } catch (err) {
153151 console.debug("Auto login error:", err);
154152 signupError = "Auto login error. Please try to login manually.";
@@ -161,11 +159,6 @@
161159 error = '';
162160 signupError = '';
163161 }
164-
165- function handleClose() {
166- console.debug("Closing AIM Login component.");
167- dispatch('close');
168- }
169162
170163 function handleKeydown(event: KeyboardEvent) {
171164 if (event.key === 'Escape') {
@@ -214,16 +207,6 @@
214207 <label for="si-password">Password</label>
215208 <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" />
216209 </div>
217- <div class="checkboxes">
218- <label>
219- <input type="checkbox" bind:checked={savePassword} />
220- Save password
221- </label>
222- <label>
223- <input type="checkbox" bind:checked={autoLogin} />
224- Auto-login
225- </label>
226- </div>
227210 {#if error}
228211 <div class="error">{error}</div>
229212 {/if}
@@ -366,17 +349,7 @@
366349 width: 100%;
367350 padding: 4px;
368351 font-size: 1rem;
369- }
370- .checkboxes {
371- display: flex;
372- gap: 16px;
373- margin-bottom: 16px;
374- font-size: 1rem;
375- }
376- .checkboxes label {
377- display: flex;
378- align-items: center;
379- gap: 4px;
352+ font-family: Arial, Verdana, Tahoma, sans-serif;
380353 }
381354 .button-bar {
382355 display: flex;
@@ -3,19 +3,15 @@
3 import { draggable } from '$lib/actions/draggable';3 import { draggable } from '$lib/actions/draggable';
4 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';4 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5 import type { User } from '$lib/types/chat';5 import type { User } from '$lib/types/chat';
6- import { invalidate } from '$app/navigation';6+ import { invalidate, invalidateAll } from '$app/navigation';
7- import { createEventDispatcher } from 'svelte';
8 7
9- let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props();8+ let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
10- const dispatch = createEventDispatcher();
11 9
12 let activeTab = $state('signin');10 let activeTab = $state('signin');
13 11
14 // Sign In state12 // Sign In state
15 let siUsername = $state('');13 let siUsername = $state('');
16 let siPassword = $state('');14 let siPassword = $state('');
17- let savePassword = $state(true);
18- let autoLogin = $state(false);
19 let error = $state('');15 let error = $state('');
20 let loginStatus = $state('idle');16 let loginStatus = $state('idle');
21 17
@@ -27,6 +23,11 @@
27 let signupError = $state('');23 let signupError = $state('');
28 let signupStatus = $state('idle');24 let signupStatus = $state('idle');
29 25
26+ function handleClose() {
27+ console.debug("Closing AIM Login component.");
28+ showAuth = false;
29+ }
30+
30 async function handleSigninSubmit() {31 async function handleSigninSubmit() {
31 error = '';32 error = '';
32 loginStatus = 'loading';33 loginStatus = 'loading';
@@ -138,17 +139,14 @@
138 return;139 return;
139 }140 }
140 console.debug("Auto login after registration succeeded:", data);141 console.debug("Auto login after registration succeeded:", data);
141- await Promise.all([142+
142- invalidate('app:session'),
143- invalidate('app:chat'),
144- invalidate('custom:chat:session'),
145- invalidate('chat:session')
146- ]);
147 onLoginSuccess?.(data.user);143 onLoginSuccess?.(data.user);
148 setTimeout(() => {144 setTimeout(() => {
149 console.debug("Auto closing AIM Login component after auto login on registration");145 console.debug("Auto closing AIM Login component after auto login on registration");
146+ invalidateAll();
150 handleClose();147 handleClose();
151 }, 3000);148 }, 3000);
149+
152 } catch (err) {150 } catch (err) {
153 console.debug("Auto login error:", err);151 console.debug("Auto login error:", err);
154 signupError = "Auto login error. Please try to login manually.";152 signupError = "Auto login error. Please try to login manually.";
@@ -161,11 +159,6 @@
161 error = '';159 error = '';
162 signupError = '';160 signupError = '';
163 }161 }
164-
165- function handleClose() {
166- console.debug("Closing AIM Login component.");
167- dispatch('close');
168- }
169 162
170 function handleKeydown(event: KeyboardEvent) {163 function handleKeydown(event: KeyboardEvent) {
171 if (event.key === 'Escape') {164 if (event.key === 'Escape') {
@@ -214,16 +207,6 @@
214 <label for="si-password">Password</label>207 <label for="si-password">Password</label>
215 <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" />208 <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" />
216 </div>209 </div>
217- <div class="checkboxes">
218- <label>
219- <input type="checkbox" bind:checked={savePassword} />
220- Save password
221- </label>
222- <label>
223- <input type="checkbox" bind:checked={autoLogin} />
224- Auto-login
225- </label>
226- </div>
227 {#if error}210 {#if error}
228 <div class="error">{error}</div>211 <div class="error">{error}</div>
229 {/if}212 {/if}
@@ -366,17 +349,7 @@
366 width: 100%;349 width: 100%;
367 padding: 4px;350 padding: 4px;
368 font-size: 1rem;351 font-size: 1rem;
369- }352+ font-family: Arial, Verdana, Tahoma, sans-serif;
370- .checkboxes {
371- display: flex;
372- gap: 16px;
373- margin-bottom: 16px;
374- font-size: 1rem;
375- }
376- .checkboxes label {
377- display: flex;
378- align-items: center;
379- gap: 4px;
380 }353 }
381 .button-bar {354 .button-bar {
382 display: flex;355 display: flex;
M src/lib/components/chat-room.svelte +41 -26
@@ -7,7 +7,9 @@ import { draggable } from '$lib/actions/draggable';
77 import { resizable } from '$lib/actions/resizable';
88 import { maximizable } from '$lib/actions/maximizable';
99 import LoadingButton from './ui/button-loading.svelte';
10+import Tooltip from './ui/tooltip.svelte';
1011 import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
12+import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
1113
1214 // Initialize with default values for SSR
1315 let windowWidth = $state(400);
@@ -150,14 +152,6 @@ async function handleSubmit() {
150152 }
151153 }
152154
153-function formatTimestamp(date: Date) {
154- return date.toLocaleTimeString('en-US', {
155- hour: 'numeric',
156- minute: '2-digit',
157- hour12: true
158- });
159-}
160-
161155 function getStatusIcon(status: User['status']) {
162156 switch (status) {
163157 case 'online': return '🟢';
@@ -281,11 +275,16 @@ function handleMaximizeEvent(event: CustomEvent<{
281275 {#each visibleMessages as message (message.id)}
282276 <div class="message {message.type} text">
283277 {#if message.type === 'emote'}
284- <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
285- <span class="emote-text">{message.user.nickname} {message.content}</span>
278+ <span class="emote-text">
279+ <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
280+ <span class="nickname">{message.user.nickname}</span>
281+ </Tooltip>
282+ {message.content}
283+ </span>
286284 {:else}
287- <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
288- <span class="nickname">{message.user.nickname}:</span>
285+ <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
286+ <span class="nickname">{message.user.nickname}:</span>
287+ </Tooltip>
289288 <span class="content">{message.content}</span>
290289 {/if}
291290 </div>
@@ -317,18 +316,22 @@ function handleMaximizeEvent(event: CustomEvent<{
317316 class:hidden={isMobile && !showUserList}
318317 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
319318 >
320- <p style="margin: 0 0 0.5rem 0;"><strong>Buddy List</strong></p>
319+ <p style="margin: 0 0 0.3rem 0;"><strong>Buddy List</strong></p>
321320 {#each onlineUsers as user}
322321 <div class="user" class:offline={user.status === 'offline'}>
323322 <span class="status-icon">{getStatusIcon(user.status)}</span>
324323 <div class="user-info">
325- <div class="nickname">{user.nickname}</div>
324+ {#if user.status === 'offline'}
325+ <Tooltip data={{ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen), direction: "bottom" }}>
326+ <div class="nickname select-none">{user.nickname}</div>
327+ </Tooltip>
328+ {:else}
329+ <div class="nickname select-none">{user.nickname}</div>
330+ {/if}
326331 {#if user.status === 'away'}
327332 <div class="status-message">Away</div>
328333 {:else if user.status === 'busy'}
329334 <div class="status-message">Busy</div>
330- {:else if user.status === 'offline'}
331- <div class="status-message">Last seen: {formatLastSeen(user.lastSeen)}</div>
332335 {/if}
333336 </div>
334337 </div>
@@ -342,7 +345,7 @@ function handleMaximizeEvent(event: CustomEvent<{
342345
343346 .chat-area, .users-list, input {
344347 font-size: 1rem;
345- font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;
348+ font-family: Arial, Verdana, Tahoma, sans-serif;
346349 }
347350
348351 .title-bar {
@@ -362,20 +365,21 @@ function handleMaximizeEvent(event: CustomEvent<{
362365 }
363366
364367 .message {
365- margin-bottom: 0.5rem;
368+ margin-bottom: 0.25rem;
366369 word-break: break-word;
367- }
368-
369- .message .timestamp {
370- color: #666;
371- font-size: 0.875rem;
372- margin-right: 0.5rem;
370+ display: flex;
371+ gap: 0.5rem;
372+ align-items: flex-start;
373373 }
374374
375375 .message .nickname {
376376 font-weight: bold;
377377 color: #2d31a6;
378- margin-right: 0.5rem;
378+ cursor: help;
379+ }
380+
381+ .message .content {
382+ flex: 1;
379383 }
380384
381385 .message.emote {
@@ -383,10 +387,14 @@ function handleMaximizeEvent(event: CustomEvent<{
383387 font-style: italic;
384388 }
385389
390+ .message.emote .nickname {
391+ color: #666;
392+ }
393+
386394 .user {
387395 display: flex;
388396 align-items: flex-start;
389- margin-bottom: 0.75rem;
397+ margin-bottom: 0.25rem;
390398 gap: 0.375rem;
391399 transition: opacity 0.3s ease;
392400 }
@@ -513,4 +521,11 @@ function handleMaximizeEvent(event: CustomEvent<{
513521 display: none;
514522 }
515523 }
524+
525+ .relative-time {
526+ color: #666;
527+ font-size: 0.75rem;
528+ margin-right: 0.5rem;
529+ font-style: italic;
530+ }
516531 </style>⏎̸
@@ -7,7 +7,9 @@ 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 LoadingButton from './ui/button-loading.svelte';9 import LoadingButton from './ui/button-loading.svelte';
10+import Tooltip from './ui/tooltip.svelte';
10 import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';11 import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
12+import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
11 13
12 // Initialize with default values for SSR14 // Initialize with default values for SSR
13 let windowWidth = $state(400);15 let windowWidth = $state(400);
@@ -150,14 +152,6 @@ async function handleSubmit() {
150 }152 }
151 }153 }
152 154
153-function formatTimestamp(date: Date) {
154- return date.toLocaleTimeString('en-US', {
155- hour: 'numeric',
156- minute: '2-digit',
157- hour12: true
158- });
159-}
160-
161 function getStatusIcon(status: User['status']) {155 function getStatusIcon(status: User['status']) {
162 switch (status) {156 switch (status) {
163 case 'online': return '🟢';157 case 'online': return '🟢';
@@ -281,11 +275,16 @@ function handleMaximizeEvent(event: CustomEvent<{
281 {#each visibleMessages as message (message.id)}275 {#each visibleMessages as message (message.id)}
282 <div class="message {message.type} text">276 <div class="message {message.type} text">
283 {#if message.type === 'emote'}277 {#if message.type === 'emote'}
284- <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>278+ <span class="emote-text">
285- <span class="emote-text">{message.user.nickname} {message.content}</span>279+ <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
280+ <span class="nickname">{message.user.nickname}</span>
281+ </Tooltip>
282+ {message.content}
283+ </span>
286 {:else}284 {:else}
287- <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>285+ <Tooltip data={{ text: formatFrenchDateTime(new Date(message.timestamp)), direction: "right" }}>
288- <span class="nickname">{message.user.nickname}:</span>286+ <span class="nickname">{message.user.nickname}:</span>
287+ </Tooltip>
289 <span class="content">{message.content}</span>288 <span class="content">{message.content}</span>
290 {/if}289 {/if}
291 </div>290 </div>
@@ -317,18 +316,22 @@ function handleMaximizeEvent(event: CustomEvent<{
317 class:hidden={isMobile && !showUserList}316 class:hidden={isMobile && !showUserList}
318 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"317 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
319 >318 >
320- <p style="margin: 0 0 0.5rem 0;"><strong>Buddy List</strong></p>319+ <p style="margin: 0 0 0.3rem 0;"><strong>Buddy List</strong></p>
321 {#each onlineUsers as user}320 {#each onlineUsers as user}
322 <div class="user" class:offline={user.status === 'offline'}>321 <div class="user" class:offline={user.status === 'offline'}>
323 <span class="status-icon">{getStatusIcon(user.status)}</span>322 <span class="status-icon">{getStatusIcon(user.status)}</span>
324 <div class="user-info">323 <div class="user-info">
325- <div class="nickname">{user.nickname}</div>324+ {#if user.status === 'offline'}
325+ <Tooltip data={{ text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen), direction: "bottom" }}>
326+ <div class="nickname select-none">{user.nickname}</div>
327+ </Tooltip>
328+ {:else}
329+ <div class="nickname select-none">{user.nickname}</div>
330+ {/if}
326 {#if user.status === 'away'}331 {#if user.status === 'away'}
327 <div class="status-message">Away</div>332 <div class="status-message">Away</div>
328 {:else if user.status === 'busy'}333 {:else if user.status === 'busy'}
329 <div class="status-message">Busy</div>334 <div class="status-message">Busy</div>
330- {:else if user.status === 'offline'}
331- <div class="status-message">Last seen: {formatLastSeen(user.lastSeen)}</div>
332 {/if}335 {/if}
333 </div>336 </div>
334 </div>337 </div>
@@ -342,7 +345,7 @@ function handleMaximizeEvent(event: CustomEvent<{
342 345
343 .chat-area, .users-list, input {346 .chat-area, .users-list, input {
344 font-size: 1rem;347 font-size: 1rem;
345- font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;348+ font-family: Arial, Verdana, Tahoma, sans-serif;
346 }349 }
347 350
348 .title-bar {351 .title-bar {
@@ -362,20 +365,21 @@ function handleMaximizeEvent(event: CustomEvent<{
362 }365 }
363 366
364 .message {367 .message {
365- margin-bottom: 0.5rem;368+ margin-bottom: 0.25rem;
366 word-break: break-word;369 word-break: break-word;
367- }370+ display: flex;
368-371+ gap: 0.5rem;
369- .message .timestamp {372+ align-items: flex-start;
370- color: #666;
371- font-size: 0.875rem;
372- margin-right: 0.5rem;
373 }373 }
374 374
375 .message .nickname {375 .message .nickname {
376 font-weight: bold;376 font-weight: bold;
377 color: #2d31a6;377 color: #2d31a6;
378- margin-right: 0.5rem;378+ cursor: help;
379+ }
380+
381+ .message .content {
382+ flex: 1;
379 }383 }
380 384
381 .message.emote {385 .message.emote {
@@ -383,10 +387,14 @@ function handleMaximizeEvent(event: CustomEvent<{
383 font-style: italic;387 font-style: italic;
384 }388 }
385 389
390+ .message.emote .nickname {
391+ color: #666;
392+ }
393+
386 .user {394 .user {
387 display: flex;395 display: flex;
388 align-items: flex-start;396 align-items: flex-start;
389- margin-bottom: 0.75rem;397+ margin-bottom: 0.25rem;
390 gap: 0.375rem;398 gap: 0.375rem;
391 transition: opacity 0.3s ease;399 transition: opacity 0.3s ease;
392 }400 }
@@ -513,4 +521,11 @@ function handleMaximizeEvent(event: CustomEvent<{
513 display: none;521 display: none;
514 }522 }
515 }523 }
524+
525+ .relative-time {
526+ color: #666;
527+ font-size: 0.75rem;
528+ margin-right: 0.5rem;
529+ font-style: italic;
530+ }
516 </style>⏎̸531 </style>⏎̸
A src/lib/components/ui/tooltip.svelte +157 -0
@@ -0,0 +1,157 @@
1+<script lang="ts">
2+ type $$Props = {
3+ data: {
4+ text: string,
5+ direction?: 'top' | 'bottom' | 'left' | 'right'
6+ }
7+ };
8+
9+ let { children, data } = $props();
10+
11+ // Derive values from data with defaults
12+ let text = $derived(data.text);
13+ let direction = $derived(data.direction ?? 'top');
14+
15+ let wrapper: HTMLDivElement;
16+ let tooltipDiv: HTMLDivElement | null = null;
17+ let coords = { x: 0, y: 0 };
18+
19+ function updateCoords() {
20+ if (wrapper) {
21+ const rect = wrapper.getBoundingClientRect();
22+ if (direction === 'top') {
23+ coords = { x: rect.left + rect.width / 2, y: rect.top - 8 };
24+ } else if (direction === 'bottom') {
25+ coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 };
26+ } else if (direction === 'left') {
27+ coords = { x: rect.left - 8, y: rect.top + rect.height / 2 };
28+ } else if (direction === 'right') {
29+ coords = { x: rect.right + 8, y: rect.top + rect.height / 2 };
30+ }
31+ }
32+ }
33+
34+ function showTooltip() {
35+ updateCoords();
36+ tooltipDiv = document.createElement('div');
37+ tooltipDiv.className = `tooltip ${direction}`;
38+ tooltipDiv.style.position = 'fixed';
39+ tooltipDiv.style.zIndex = '1000000';
40+ tooltipDiv.textContent = text;
41+ document.body.appendChild(tooltipDiv);
42+
43+ const tooltipRect = tooltipDiv.getBoundingClientRect();
44+ let finalLeft = coords.x;
45+ let finalTop = coords.y;
46+ if (direction === 'top') {
47+ finalLeft = coords.x - tooltipRect.width / 2;
48+ finalTop = coords.y - tooltipRect.height;
49+ } else if (direction === 'bottom') {
50+ finalLeft = coords.x - tooltipRect.width / 2;
51+ finalTop = coords.y;
52+ } else if (direction === 'left') {
53+ finalLeft = coords.x - tooltipRect.width;
54+ finalTop = coords.y - tooltipRect.height / 2;
55+ } else if (direction === 'right') {
56+ finalLeft = coords.x;
57+ finalTop = coords.y - tooltipRect.height / 2;
58+ }
59+
60+ if (finalLeft < 0) {
61+ finalLeft = 0;
62+ } else if (finalLeft + tooltipRect.width > window.innerWidth) {
63+ finalLeft = window.innerWidth - tooltipRect.width;
64+ }
65+ if (finalTop < 0) {
66+ finalTop = 0;
67+ } else if (finalTop + tooltipRect.height > window.innerHeight) {
68+ finalTop = window.innerHeight - tooltipRect.height;
69+ }
70+
71+ tooltipDiv.style.left = `${finalLeft}px`;
72+ tooltipDiv.style.top = `${finalTop}px`;
73+ }
74+
75+ function hideTooltip() {
76+ if (tooltipDiv && tooltipDiv.parentNode) {
77+ tooltipDiv.parentNode.removeChild(tooltipDiv);
78+ tooltipDiv = null;
79+ }
80+ }
81+</script>
82+
83+<div
84+ class="tooltip-wrapper"
85+ bind:this={wrapper}
86+ onmouseover={showTooltip}
87+ onmouseout={hideTooltip}
88+ onfocus={showTooltip}
89+ onblur={hideTooltip}
90+ role="tooltip"
91+ tabindex="-1"
92+>
93+ {@render children()}
94+</div>
95+
96+<style>
97+ .tooltip-wrapper {
98+ display: inline-block;
99+ }
100+ /* Use :global for styles that need to apply to elements outside the component */
101+ :global(.tooltip) {
102+ white-space: nowrap;
103+ padding: 0.5rem;
104+ background: #ECE9D8;
105+ color: #000;
106+ border: 1px solid #003C74;
107+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
108+ border-radius: 2px;
109+ font-size: 0.875rem;
110+ font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;
111+ }
112+
113+ /* Direction-specific styles for the tooltip arrow */
114+ :global(.tooltip.top)::before {
115+ content: '';
116+ position: absolute;
117+ bottom: -6px;
118+ left: 50%;
119+ transform: translateX(-50%);
120+ border-left: 6px solid transparent;
121+ border-right: 6px solid transparent;
122+ border-top: 6px solid #003C74;
123+ }
124+
125+ :global(.tooltip.bottom)::before {
126+ content: '';
127+ position: absolute;
128+ top: -6px;
129+ left: 50%;
130+ transform: translateX(-50%);
131+ border-left: 6px solid transparent;
132+ border-right: 6px solid transparent;
133+ border-bottom: 6px solid #003C74;
134+ }
135+
136+ :global(.tooltip.left)::before {
137+ content: '';
138+ position: absolute;
139+ right: -6px;
140+ top: 50%;
141+ transform: translateY(-50%);
142+ border-top: 6px solid transparent;
143+ border-bottom: 6px solid transparent;
144+ border-left: 6px solid #003C74;
145+ }
146+
147+ :global(.tooltip.right)::before {
148+ content: '';
149+ position: absolute;
150+ left: -6px;
151+ top: 50%;
152+ transform: translateY(-50%);
153+ border-top: 6px solid transparent;
154+ border-bottom: 6px solid transparent;
155+ border-right: 6px solid #003C74;
156+ }
157+</style>⏎̸
@@ -0,0 +1,157 @@
1+<script lang="ts">
2+ type $$Props = {
3+ data: {
4+ text: string,
5+ direction?: 'top' | 'bottom' | 'left' | 'right'
6+ }
7+ };
8+
9+ let { children, data } = $props();
10+
11+ // Derive values from data with defaults
12+ let text = $derived(data.text);
13+ let direction = $derived(data.direction ?? 'top');
14+
15+ let wrapper: HTMLDivElement;
16+ let tooltipDiv: HTMLDivElement | null = null;
17+ let coords = { x: 0, y: 0 };
18+
19+ function updateCoords() {
20+ if (wrapper) {
21+ const rect = wrapper.getBoundingClientRect();
22+ if (direction === 'top') {
23+ coords = { x: rect.left + rect.width / 2, y: rect.top - 8 };
24+ } else if (direction === 'bottom') {
25+ coords = { x: rect.left + rect.width / 2, y: rect.bottom + 8 };
26+ } else if (direction === 'left') {
27+ coords = { x: rect.left - 8, y: rect.top + rect.height / 2 };
28+ } else if (direction === 'right') {
29+ coords = { x: rect.right + 8, y: rect.top + rect.height / 2 };
30+ }
31+ }
32+ }
33+
34+ function showTooltip() {
35+ updateCoords();
36+ tooltipDiv = document.createElement('div');
37+ tooltipDiv.className = `tooltip ${direction}`;
38+ tooltipDiv.style.position = 'fixed';
39+ tooltipDiv.style.zIndex = '1000000';
40+ tooltipDiv.textContent = text;
41+ document.body.appendChild(tooltipDiv);
42+
43+ const tooltipRect = tooltipDiv.getBoundingClientRect();
44+ let finalLeft = coords.x;
45+ let finalTop = coords.y;
46+ if (direction === 'top') {
47+ finalLeft = coords.x - tooltipRect.width / 2;
48+ finalTop = coords.y - tooltipRect.height;
49+ } else if (direction === 'bottom') {
50+ finalLeft = coords.x - tooltipRect.width / 2;
51+ finalTop = coords.y;
52+ } else if (direction === 'left') {
53+ finalLeft = coords.x - tooltipRect.width;
54+ finalTop = coords.y - tooltipRect.height / 2;
55+ } else if (direction === 'right') {
56+ finalLeft = coords.x;
57+ finalTop = coords.y - tooltipRect.height / 2;
58+ }
59+
60+ if (finalLeft < 0) {
61+ finalLeft = 0;
62+ } else if (finalLeft + tooltipRect.width > window.innerWidth) {
63+ finalLeft = window.innerWidth - tooltipRect.width;
64+ }
65+ if (finalTop < 0) {
66+ finalTop = 0;
67+ } else if (finalTop + tooltipRect.height > window.innerHeight) {
68+ finalTop = window.innerHeight - tooltipRect.height;
69+ }
70+
71+ tooltipDiv.style.left = `${finalLeft}px`;
72+ tooltipDiv.style.top = `${finalTop}px`;
73+ }
74+
75+ function hideTooltip() {
76+ if (tooltipDiv && tooltipDiv.parentNode) {
77+ tooltipDiv.parentNode.removeChild(tooltipDiv);
78+ tooltipDiv = null;
79+ }
80+ }
81+</script>
82+
83+<div
84+ class="tooltip-wrapper"
85+ bind:this={wrapper}
86+ onmouseover={showTooltip}
87+ onmouseout={hideTooltip}
88+ onfocus={showTooltip}
89+ onblur={hideTooltip}
90+ role="tooltip"
91+ tabindex="-1"
92+>
93+ {@render children()}
94+</div>
95+
96+<style>
97+ .tooltip-wrapper {
98+ display: inline-block;
99+ }
100+ /* Use :global for styles that need to apply to elements outside the component */
101+ :global(.tooltip) {
102+ white-space: nowrap;
103+ padding: 0.5rem;
104+ background: #ECE9D8;
105+ color: #000;
106+ border: 1px solid #003C74;
107+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
108+ border-radius: 2px;
109+ font-size: 0.875rem;
110+ font-family: 'Microsoft Sans Serif', 'Segoe UI', Arial, Tahoma, sans-serif;
111+ }
112+
113+ /* Direction-specific styles for the tooltip arrow */
114+ :global(.tooltip.top)::before {
115+ content: '';
116+ position: absolute;
117+ bottom: -6px;
118+ left: 50%;
119+ transform: translateX(-50%);
120+ border-left: 6px solid transparent;
121+ border-right: 6px solid transparent;
122+ border-top: 6px solid #003C74;
123+ }
124+
125+ :global(.tooltip.bottom)::before {
126+ content: '';
127+ position: absolute;
128+ top: -6px;
129+ left: 50%;
130+ transform: translateX(-50%);
131+ border-left: 6px solid transparent;
132+ border-right: 6px solid transparent;
133+ border-bottom: 6px solid #003C74;
134+ }
135+
136+ :global(.tooltip.left)::before {
137+ content: '';
138+ position: absolute;
139+ right: -6px;
140+ top: 50%;
141+ transform: translateY(-50%);
142+ border-top: 6px solid transparent;
143+ border-bottom: 6px solid transparent;
144+ border-left: 6px solid #003C74;
145+ }
146+
147+ :global(.tooltip.right)::before {
148+ content: '';
149+ position: absolute;
150+ left: -6px;
151+ top: 50%;
152+ transform: translateY(-50%);
153+ border-top: 6px solid transparent;
154+ border-bottom: 6px solid transparent;
155+ border-right: 6px solid #003C74;
156+ }
157+</style>⏎̸
A src/lib/utils/date-format.ts +142 -0
@@ -0,0 +1,142 @@
1+/**
2+ * Format a date to French style (e.g., "22/02/2023 à 23h17")
3+ * @param date - The date to format
4+ * @returns The formatted date string
5+ */
6+export function formatFrenchDateTime(date: Date): string {
7+ console.debug('Formatting date to French style:', date);
8+ return date.toLocaleDateString('fr-FR', {
9+ day: '2-digit',
10+ month: '2-digit',
11+ year: 'numeric',
12+ hour: '2-digit',
13+ minute: '2-digit',
14+ hour12: false
15+ }).replace(' à ', ' à '); // Ensure proper spacing around 'à'
16+}
17+
18+/**
19+ * Format a date to French style with only time (e.g., "23h17")
20+ * @param date - The date to format
21+ * @returns The formatted time string
22+ */
23+export function formatFrenchTime(date: Date): string {
24+ console.debug('Formatting time to French style:', date);
25+ return date.toLocaleTimeString('fr-FR', {
26+ hour: '2-digit',
27+ minute: '2-digit',
28+ hour12: false
29+ }).replace(':', 'h');
30+}
31+
32+/**
33+ * Format a date to French style with only date (e.g., "22/02/2023")
34+ * @param date - The date to format
35+ * @returns The formatted date string
36+ */
37+export function formatFrenchDate(date: Date): string {
38+ console.debug('Formatting date to French style:', date);
39+ return date.toLocaleDateString('fr-FR', {
40+ day: '2-digit',
41+ month: '2-digit',
42+ year: 'numeric'
43+ });
44+}
45+
46+/**
47+ * Format a relative time in French (e.g., "il y a 5 minutes", "il y a 2 heures")
48+ * @param date - The date to format relative to now
49+ * @returns The formatted relative time string
50+ */
51+export function formatFrenchRelativeTime(date: Date): string {
52+ console.debug('Formatting relative time to French style:', date);
53+ const now = new Date();
54+ const diff = now.getTime() - date.getTime();
55+ const seconds = Math.floor(diff / 1000);
56+ const minutes = Math.floor(seconds / 60);
57+ const hours = Math.floor(minutes / 60);
58+ const days = Math.floor(hours / 24);
59+ const months = Math.floor(days / 30);
60+ const years = Math.floor(days / 365);
61+
62+ if (seconds < 60) {
63+ return "à l'instant";
64+ }
65+
66+ if (minutes < 60) {
67+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
68+ }
69+
70+ if (hours < 24) {
71+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
72+ }
73+
74+ if (days < 30) {
75+ return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`;
76+ }
77+
78+ if (months < 12) {
79+ return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`;
80+ }
81+
82+ return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`;
83+}
84+
85+/**
86+ * Format a relative time in French with more precision (e.g., "il y a 5 minutes et 30 secondes")
87+ * @param date - The date to format relative to now
88+ * @param withSeconds - Whether to include seconds in the output
89+ * @returns The formatted relative time string with more detail
90+ */
91+export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string {
92+ console.debug('Formatting detailed relative time to French style:', date);
93+ const now = new Date();
94+ const diff = now.getTime() - date.getTime();
95+ const seconds = Math.floor(diff / 1000);
96+ const minutes = Math.floor(seconds / 60);
97+ const hours = Math.floor(minutes / 60);
98+
99+ if (seconds < 60) {
100+ return withSeconds ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` : "à l'instant";
101+ }
102+
103+ if (minutes < 60) {
104+ const remainingSeconds = seconds % 60;
105+ if (withSeconds && remainingSeconds > 0) {
106+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`;
107+ }
108+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
109+ }
110+
111+ if (hours < 24) {
112+ const remainingMinutes = minutes % 60;
113+ if (remainingMinutes > 0) {
114+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`;
115+ }
116+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
117+ }
118+
119+ // For anything longer than 24 hours, use the simple format
120+ return formatFrenchRelativeTime(date);
121+}
122+
123+/**
124+ * Safely format a timestamp (number), Date, or null/undefined value to a relative time string in French
125+ * @param timestamp - The timestamp (in ms), Date object, or null/undefined
126+ * @returns The formatted relative time string or a fallback message
127+ */
128+export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string {
129+ console.debug('Formatting safe relative time to French style:', timestamp);
130+ if (!timestamp) {
131+ return "jamais";
132+ }
133+
134+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
135+
136+ // Check if the date is valid
137+ if (isNaN(date.getTime())) {
138+ return "date invalide";
139+ }
140+
141+ return formatFrenchRelativeTime(date);
142+} ⏎̸
@@ -0,0 +1,142 @@
1+/**
2+ * Format a date to French style (e.g., "22/02/2023 à 23h17")
3+ * @param date - The date to format
4+ * @returns The formatted date string
5+ */
6+export function formatFrenchDateTime(date: Date): string {
7+ console.debug('Formatting date to French style:', date);
8+ return date.toLocaleDateString('fr-FR', {
9+ day: '2-digit',
10+ month: '2-digit',
11+ year: 'numeric',
12+ hour: '2-digit',
13+ minute: '2-digit',
14+ hour12: false
15+ }).replace(' à ', ' à '); // Ensure proper spacing around 'à'
16+}
17+
18+/**
19+ * Format a date to French style with only time (e.g., "23h17")
20+ * @param date - The date to format
21+ * @returns The formatted time string
22+ */
23+export function formatFrenchTime(date: Date): string {
24+ console.debug('Formatting time to French style:', date);
25+ return date.toLocaleTimeString('fr-FR', {
26+ hour: '2-digit',
27+ minute: '2-digit',
28+ hour12: false
29+ }).replace(':', 'h');
30+}
31+
32+/**
33+ * Format a date to French style with only date (e.g., "22/02/2023")
34+ * @param date - The date to format
35+ * @returns The formatted date string
36+ */
37+export function formatFrenchDate(date: Date): string {
38+ console.debug('Formatting date to French style:', date);
39+ return date.toLocaleDateString('fr-FR', {
40+ day: '2-digit',
41+ month: '2-digit',
42+ year: 'numeric'
43+ });
44+}
45+
46+/**
47+ * Format a relative time in French (e.g., "il y a 5 minutes", "il y a 2 heures")
48+ * @param date - The date to format relative to now
49+ * @returns The formatted relative time string
50+ */
51+export function formatFrenchRelativeTime(date: Date): string {
52+ console.debug('Formatting relative time to French style:', date);
53+ const now = new Date();
54+ const diff = now.getTime() - date.getTime();
55+ const seconds = Math.floor(diff / 1000);
56+ const minutes = Math.floor(seconds / 60);
57+ const hours = Math.floor(minutes / 60);
58+ const days = Math.floor(hours / 24);
59+ const months = Math.floor(days / 30);
60+ const years = Math.floor(days / 365);
61+
62+ if (seconds < 60) {
63+ return "à l'instant";
64+ }
65+
66+ if (minutes < 60) {
67+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
68+ }
69+
70+ if (hours < 24) {
71+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
72+ }
73+
74+ if (days < 30) {
75+ return `il y a ${days} ${days === 1 ? 'jour' : 'jours'}`;
76+ }
77+
78+ if (months < 12) {
79+ return `il y a ${months} ${months === 1 ? 'mois' : 'mois'}`;
80+ }
81+
82+ return `il y a ${years} ${years === 1 ? 'an' : 'ans'}`;
83+}
84+
85+/**
86+ * Format a relative time in French with more precision (e.g., "il y a 5 minutes et 30 secondes")
87+ * @param date - The date to format relative to now
88+ * @param withSeconds - Whether to include seconds in the output
89+ * @returns The formatted relative time string with more detail
90+ */
91+export function formatFrenchRelativeTimeDetailed(date: Date, withSeconds = false): string {
92+ console.debug('Formatting detailed relative time to French style:', date);
93+ const now = new Date();
94+ const diff = now.getTime() - date.getTime();
95+ const seconds = Math.floor(diff / 1000);
96+ const minutes = Math.floor(seconds / 60);
97+ const hours = Math.floor(minutes / 60);
98+
99+ if (seconds < 60) {
100+ return withSeconds ? `il y a ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}` : "à l'instant";
101+ }
102+
103+ if (minutes < 60) {
104+ const remainingSeconds = seconds % 60;
105+ if (withSeconds && remainingSeconds > 0) {
106+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} et ${remainingSeconds} ${remainingSeconds === 1 ? 'seconde' : 'secondes'}`;
107+ }
108+ return `il y a ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
109+ }
110+
111+ if (hours < 24) {
112+ const remainingMinutes = minutes % 60;
113+ if (remainingMinutes > 0) {
114+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'} et ${remainingMinutes} ${remainingMinutes === 1 ? 'minute' : 'minutes'}`;
115+ }
116+ return `il y a ${hours} ${hours === 1 ? 'heure' : 'heures'}`;
117+ }
118+
119+ // For anything longer than 24 hours, use the simple format
120+ return formatFrenchRelativeTime(date);
121+}
122+
123+/**
124+ * Safely format a timestamp (number), Date, or null/undefined value to a relative time string in French
125+ * @param timestamp - The timestamp (in ms), Date object, or null/undefined
126+ * @returns The formatted relative time string or a fallback message
127+ */
128+export function formatFrenchRelativeTimeSafe(timestamp: number | Date | null | undefined): string {
129+ console.debug('Formatting safe relative time to French style:', timestamp);
130+ if (!timestamp) {
131+ return "jamais";
132+ }
133+
134+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
135+
136+ // Check if the date is valid
137+ if (isNaN(date.getTime())) {
138+ return "date invalide";
139+ }
140+
141+ return formatFrenchRelativeTime(date);
142+} ⏎̸
M src/routes/+layout.svelte +1 -3
@@ -1,7 +1,5 @@
11 <script lang="ts">
2- import '../app.css';
3- import DesktopIcons from '$lib/components/desktop-icons.svelte';
4- import ChatRoom from '$lib/components/chat-room.svelte';
2+ import '../app.css';;
53 import { chatState } from '$lib/states/chat.svelte';
64 import { onMount, onDestroy } from 'svelte';
75 import { browser } from '$app/environment';
@@ -1,7 +1,5 @@
1 <script lang="ts">1 <script lang="ts">
2- import '../app.css';2+ import '../app.css';;
3- import DesktopIcons from '$lib/components/desktop-icons.svelte';
4- import ChatRoom from '$lib/components/chat-room.svelte';
5 import { chatState } from '$lib/states/chat.svelte';3 import { chatState } from '$lib/states/chat.svelte';
6 import { onMount, onDestroy } from 'svelte';4 import { onMount, onDestroy } from 'svelte';
7 import { browser } from '$app/environment';5 import { browser } from '$app/environment';
M src/routes/+page.svelte +1 -4
@@ -6,9 +6,6 @@
66 let showChatRoom = $state(true);
77 let showAuth = $state(false);
88
9- function handleAuthClose() {
10- showAuth = false;
11- }
129 </script>
1310
1411 <div class="desktop content-wrapper">
@@ -19,7 +16,7 @@
1916 {/if}
2017
2118 {#if showAuth}
22- <AimLogin on:close={handleAuthClose} />
19+ <AimLogin bind:showAuth />
2320 {/if}
2421 </div>
2522
@@ -6,9 +6,6 @@
6 let showChatRoom = $state(true);6 let showChatRoom = $state(true);
7 let showAuth = $state(false);7 let showAuth = $state(false);
8 8
9- function handleAuthClose() {
10- showAuth = false;
11- }
12 </script>9 </script>
13 10
14 <div class="desktop content-wrapper">11 <div class="desktop content-wrapper">
@@ -19,7 +16,7 @@
19 {/if}16 {/if}
20 17
21 {#if showAuth}18 {#if showAuth}
22- <AimLogin on:close={handleAuthClose} />19+ <AimLogin bind:showAuth />
23 {/if}20 {/if}
24 </div>21 </div>
25 22
M src/routes/api/session/login/+server.ts +47 -4
@@ -7,6 +7,22 @@ import { verifyPassword } from '$lib/utils/password';
77 import { generateSessionToken, createSession } from '$lib/api/session.server';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
99
10+// In-memory map to track failed login attempts per IP
11+const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
12+const MAX_ATTEMPTS = 3;
13+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
14+
15+// Cleanup old entries every hour
16+setInterval(() => {
17+ const now = Date.now();
18+ const ONE_HOUR = 60 * 60 * 1000;
19+ for (const [ip, data] of loginAttempts.entries()) {
20+ if (now - data.lastAttempt > ONE_HOUR) {
21+ loginAttempts.delete(ip);
22+ }
23+ }
24+}, 60 * 60 * 1000);
25+
1026 export const POST: RequestHandler = async ({ request, cookies }) => {
1127 console.debug("POST /api/session/login called");
1228
@@ -14,6 +30,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
1430 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
1531 }
1632
33+ // Get the IP address for rate limiting
34+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
35+ const now = Date.now();
36+ const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
37+
38+ // If the user has failed three or more times, apply exponential backoff
39+ if (attemptData.count >= MAX_ATTEMPTS) {
40+ const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; // delay in milliseconds
41+ console.debug(`Applying exponential backoff delay of ${delay}ms for IP ${ip}`);
42+ await sleep(delay);
43+ }
44+
1745 let body: unknown;
1846 try {
1947 body = await request.json();
@@ -35,22 +63,37 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
3563 });
3664
3765 if (!user) {
38- console.debug("User not found:", username);
39- return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
66+ attemptData.count++;
67+ attemptData.lastAttempt = now;
68+ loginAttempts.set(ip, attemptData);
69+ console.debug(`Login failed for IP ${ip}. Attempt count: ${attemptData.count}`);
70+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
71+ return new Response(JSON.stringify({
72+ error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
73+ } as LoginResponseError), { status: 401 });
4074 }
4175
4276 // Verify password
4377 try {
4478 const isValid = await verifyPassword(password.trim(), user.password);
4579 if (!isValid) {
46- console.debug("Invalid password for user:", username);
47- return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
80+ attemptData.count++;
81+ attemptData.lastAttempt = now;
82+ loginAttempts.set(ip, attemptData);
83+ console.debug(`Login failed for IP ${ip}. Attempt count: ${attemptData.count}`);
84+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
85+ return new Response(JSON.stringify({
86+ error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
87+ } as LoginResponseError), { status: 401 });
4888 }
4989 } catch (err) {
5090 console.debug("Error verifying password", err);
5191 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
5292 }
5393
94+ // Reset login attempt data on success
95+ loginAttempts.delete(ip);
96+
5497 // Generate session token and create session
5598 const token = generateSessionToken();
5699 const session = await createSession(token, user.id);
@@ -7,6 +7,22 @@ import { verifyPassword } from '$lib/utils/password';
7 import { generateSessionToken, createSession } from '$lib/api/session.server';7 import { generateSessionToken, createSession } from '$lib/api/session.server';
8 import { setSessionTokenCookie } from '$lib/api/session.cookie';8 import { setSessionTokenCookie } from '$lib/api/session.cookie';
9 9
10+// In-memory map to track failed login attempts per IP
11+const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
12+const MAX_ATTEMPTS = 3;
13+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
14+
15+// Cleanup old entries every hour
16+setInterval(() => {
17+ const now = Date.now();
18+ const ONE_HOUR = 60 * 60 * 1000;
19+ for (const [ip, data] of loginAttempts.entries()) {
20+ if (now - data.lastAttempt > ONE_HOUR) {
21+ loginAttempts.delete(ip);
22+ }
23+ }
24+}, 60 * 60 * 1000);
25+
10 export const POST: RequestHandler = async ({ request, cookies }) => {26 export const POST: RequestHandler = async ({ request, cookies }) => {
11 console.debug("POST /api/session/login called");27 console.debug("POST /api/session/login called");
12 28
@@ -14,6 +30,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
14 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });30 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
15 }31 }
16 32
33+ // Get the IP address for rate limiting
34+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
35+ const now = Date.now();
36+ const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
37+
38+ // If the user has failed three or more times, apply exponential backoff
39+ if (attemptData.count >= MAX_ATTEMPTS) {
40+ const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; // delay in milliseconds
41+ console.debug(`Applying exponential backoff delay of ${delay}ms for IP ${ip}`);
42+ await sleep(delay);
43+ }
44+
17 let body: unknown;45 let body: unknown;
18 try {46 try {
19 body = await request.json();47 body = await request.json();
@@ -35,22 +63,37 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
35 });63 });
36 64
37 if (!user) {65 if (!user) {
38- console.debug("User not found:", username);66+ attemptData.count++;
39- return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });67+ attemptData.lastAttempt = now;
68+ loginAttempts.set(ip, attemptData);
69+ console.debug(`Login failed for IP ${ip}. Attempt count: ${attemptData.count}`);
70+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
71+ return new Response(JSON.stringify({
72+ error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
73+ } as LoginResponseError), { status: 401 });
40 }74 }
41 75
42 // Verify password76 // Verify password
43 try {77 try {
44 const isValid = await verifyPassword(password.trim(), user.password);78 const isValid = await verifyPassword(password.trim(), user.password);
45 if (!isValid) {79 if (!isValid) {
46- console.debug("Invalid password for user:", username);80+ attemptData.count++;
47- return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });81+ attemptData.lastAttempt = now;
82+ loginAttempts.set(ip, attemptData);
83+ console.debug(`Login failed for IP ${ip}. Attempt count: ${attemptData.count}`);
84+ const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
85+ return new Response(JSON.stringify({
86+ error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
87+ } as LoginResponseError), { status: 401 });
48 }88 }
49 } catch (err) {89 } catch (err) {
50 console.debug("Error verifying password", err);90 console.debug("Error verifying password", err);
51 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });91 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
52 }92 }
53 93
94+ // Reset login attempt data on success
95+ loginAttempts.delete(ip);
96+
54 // Generate session token and create session97 // Generate session token and create session
55 const token = generateSessionToken();98 const token = generateSessionToken();
56 const session = await createSession(token, user.id);99 const session = await createSession(token, user.id);
M src/routes/api/status/+server.ts +26 -3
@@ -3,8 +3,10 @@ import { sseEmitter } from '$lib/sseEmitter';
33 import db from '$lib/db/db.server';
44 import { users } from '$lib/db/schema';
55 import { eq } from 'drizzle-orm';
6+import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
7+import { setSessionTokenCookie } from '$lib/api/session.cookie';
68
7-export async function POST({ request, locals }) {
9+export async function POST({ request, cookies, locals }) {
810 if (!locals.user) {
911 throw error(401, 'Authentication required');
1012 }
@@ -19,11 +21,32 @@ export async function POST({ request, locals }) {
1921 console.debug('Updating status for user', userId, 'to', status);
2022
2123 // Update the user status in the database
22- await db.update(users).set({ status, lastSeen: status === 'offline' ? Date.now() : null }).where(eq(users.id, userId));
24+ await db.update(users)
25+ .set({ status, lastSeen: status === 'offline' ? Date.now() : null })
26+ .where(eq(users.id, userId));
2327 console.debug('Status updated in database');
2428
29+ // Renew session if status is online and session is near expiry
30+ if (status === 'online') {
31+ const token = cookies.get('session');
32+ if (token) {
33+ const result = await validateSessionToken(token);
34+ if (result.session) {
35+ const now = Date.now();
36+ const remaining = result.session.expiresAt - now;
37+ const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds
38+ if (remaining < threshold) {
39+ const newToken = generateSessionToken();
40+ const newSession = await createSession(newToken, userId);
41+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
42+ console.debug('Session renewed for user', userId);
43+ }
44+ }
45+ }
46+ }
47+
2548 // Broadcast the status update via SSE
2649 sseEmitter.emit('sse', { type: 'userStatusUpdate', data: { userId, status, lastSeen: status === 'offline' ? Date.now() : null } });
2750
2851 return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
29-} ⏎̸
52+}⏎̸
@@ -3,8 +3,10 @@ import { sseEmitter } from '$lib/sseEmitter';
3 import db from '$lib/db/db.server';3 import db from '$lib/db/db.server';
4 import { users } from '$lib/db/schema';4 import { users } from '$lib/db/schema';
5 import { eq } from 'drizzle-orm';5 import { eq } from 'drizzle-orm';
6+import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
7+import { setSessionTokenCookie } from '$lib/api/session.cookie';
6 8
7-export async function POST({ request, locals }) {9+export async function POST({ request, cookies, locals }) {
8 if (!locals.user) {10 if (!locals.user) {
9 throw error(401, 'Authentication required');11 throw error(401, 'Authentication required');
10 }12 }
@@ -19,11 +21,32 @@ export async function POST({ request, locals }) {
19 console.debug('Updating status for user', userId, 'to', status);21 console.debug('Updating status for user', userId, 'to', status);
20 22
21 // Update the user status in the database23 // Update the user status in the database
22- await db.update(users).set({ status, lastSeen: status === 'offline' ? Date.now() : null }).where(eq(users.id, userId));24+ await db.update(users)
25+ .set({ status, lastSeen: status === 'offline' ? Date.now() : null })
26+ .where(eq(users.id, userId));
23 console.debug('Status updated in database');27 console.debug('Status updated in database');
24 28
29+ // Renew session if status is online and session is near expiry
30+ if (status === 'online') {
31+ const token = cookies.get('session');
32+ if (token) {
33+ const result = await validateSessionToken(token);
34+ if (result.session) {
35+ const now = Date.now();
36+ const remaining = result.session.expiresAt - now;
37+ const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds
38+ if (remaining < threshold) {
39+ const newToken = generateSessionToken();
40+ const newSession = await createSession(newToken, userId);
41+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
42+ console.debug('Session renewed for user', userId);
43+ }
44+ }
45+ }
46+ }
47+
25 // Broadcast the status update via SSE48 // Broadcast the status update via SSE
26 sseEmitter.emit('sse', { type: 'userStatusUpdate', data: { userId, status, lastSeen: status === 'offline' ? Date.now() : null } });49 sseEmitter.emit('sse', { type: 'userStatusUpdate', data: { userId, status, lastSeen: status === 'offline' ? Date.now() : null } });
27 50
28 return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });51 return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
29-} ⏎̸52+}⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close