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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown - refs
- tag: v0.1.1
11 files · +532 -79
M package.json +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "pdraim", | |
| 3 | 3 | "private": true, | |
| 4 | - | "version": "0.1.0", | |
| 4 | + | "version": "0.1.1", | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "engines": { | |
| 7 | 7 | "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"; | |||
| 2 | 2 | import { validateSessionToken } from "$lib/api/session.server"; | |
| 3 | 3 | import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie"; | |
| 4 | 4 | import { initializeDatabase } from "$lib/db/db.server"; | |
| 5 | + | import { handleRateLimit } from "$lib/api/rate-limiter"; | |
| 5 | 6 | ||
| 6 | 7 | // Initialize database when the server starts | |
| 7 | 8 | console.debug("Initializing database on server start..."); | |
| @@ -12,6 +13,12 @@ initializeDatabase().catch(error => { | |||
| 12 | 13 | export const handle: Handle = async ({ event, resolve }) => { | |
| 13 | 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 | 22 | // Check if this is a public chat messages request | |
| 16 | 23 | const isPublicChatRequest = | |
| 17 | 24 | 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 starts | 7 | // 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 request | 22 | // 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 @@ | |||
| 3 | 3 | import { draggable } from '$lib/actions/draggable'; | |
| 4 | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | 5 | 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'; | |
| 8 | 7 | ||
| 9 | - | let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props(); | |
| 10 | - | const dispatch = createEventDispatcher(); | |
| 8 | + | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | |
| 11 | 9 | ||
| 12 | 10 | let activeTab = $state('signin'); | |
| 13 | 11 | ||
| 14 | 12 | // Sign In state | |
| 15 | 13 | let siUsername = $state(''); | |
| 16 | 14 | let siPassword = $state(''); | |
| 17 | - | let savePassword = $state(true); | |
| 18 | - | let autoLogin = $state(false); | |
| 19 | 15 | let error = $state(''); | |
| 20 | 16 | let loginStatus = $state('idle'); | |
| 21 | 17 | ||
| @@ -27,6 +23,11 @@ | |||
| 27 | 23 | let signupError = $state(''); | |
| 28 | 24 | let signupStatus = $state('idle'); | |
| 29 | 25 | ||
| 26 | + | function handleClose() { | |
| 27 | + | console.debug("Closing AIM Login component."); | |
| 28 | + | showAuth = false; | |
| 29 | + | } | |
| 30 | + | ||
| 30 | 31 | async function handleSigninSubmit() { | |
| 31 | 32 | error = ''; | |
| 32 | 33 | loginStatus = 'loading'; | |
| @@ -138,17 +139,14 @@ | |||
| 138 | 139 | return; | |
| 139 | 140 | } | |
| 140 | 141 | 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 | + | ||
| 147 | 143 | onLoginSuccess?.(data.user); | |
| 148 | 144 | setTimeout(() => { | |
| 149 | 145 | console.debug("Auto closing AIM Login component after auto login on registration"); | |
| 146 | + | invalidateAll(); | |
| 150 | 147 | handleClose(); | |
| 151 | 148 | }, 3000); | |
| 149 | + | ||
| 152 | 150 | } catch (err) { | |
| 153 | 151 | console.debug("Auto login error:", err); | |
| 154 | 152 | signupError = "Auto login error. Please try to login manually."; | |
| @@ -161,11 +159,6 @@ | |||
| 161 | 159 | error = ''; | |
| 162 | 160 | signupError = ''; | |
| 163 | 161 | } | |
| 164 | - | ||
| 165 | - | function handleClose() { | |
| 166 | - | console.debug("Closing AIM Login component."); | |
| 167 | - | dispatch('close'); | |
| 168 | - | } | |
| 169 | 162 | ||
| 170 | 163 | function handleKeydown(event: KeyboardEvent) { | |
| 171 | 164 | if (event.key === 'Escape') { | |
| @@ -214,16 +207,6 @@ | |||
| 214 | 207 | <label for="si-password">Password</label> | |
| 215 | 208 | <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" /> | |
| 216 | 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 | 210 | {#if error} | |
| 228 | 211 | <div class="error">{error}</div> | |
| 229 | 212 | {/if} | |
| @@ -366,17 +349,7 @@ | |||
| 366 | 349 | width: 100%; | |
| 367 | 350 | padding: 4px; | |
| 368 | 351 | 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; | |
| 380 | 353 | } | |
| 381 | 354 | .button-bar { | |
| 382 | 355 | 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 state | 12 | // 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'; | |||
| 7 | 7 | import { resizable } from '$lib/actions/resizable'; | |
| 8 | 8 | import { maximizable } from '$lib/actions/maximizable'; | |
| 9 | 9 | import LoadingButton from './ui/button-loading.svelte'; | |
| 10 | + | import Tooltip from './ui/tooltip.svelte'; | |
| 10 | 11 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema'; | |
| 12 | + | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | |
| 11 | 13 | ||
| 12 | 14 | // Initialize with default values for SSR | |
| 13 | 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 | 155 | function getStatusIcon(status: User['status']) { | |
| 162 | 156 | switch (status) { | |
| 163 | 157 | case 'online': return '🟢'; | |
| @@ -281,11 +275,16 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 281 | 275 | {#each visibleMessages as message (message.id)} | |
| 282 | 276 | <div class="message {message.type} text"> | |
| 283 | 277 | {#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> | |
| 286 | 284 | {: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> | |
| 289 | 288 | <span class="content">{message.content}</span> | |
| 290 | 289 | {/if} | |
| 291 | 290 | </div> | |
| @@ -317,18 +316,22 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 317 | 316 | class:hidden={isMobile && !showUserList} | |
| 318 | 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 | 320 | {#each onlineUsers as user} | |
| 322 | 321 | <div class="user" class:offline={user.status === 'offline'}> | |
| 323 | 322 | <span class="status-icon">{getStatusIcon(user.status)}</span> | |
| 324 | 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 | 331 | {#if user.status === 'away'} | |
| 327 | 332 | <div class="status-message">Away</div> | |
| 328 | 333 | {:else if user.status === 'busy'} | |
| 329 | 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 | 335 | {/if} | |
| 333 | 336 | </div> | |
| 334 | 337 | </div> | |
| @@ -342,7 +345,7 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 342 | 345 | ||
| 343 | 346 | .chat-area, .users-list, input { | |
| 344 | 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 | 351 | .title-bar { | |
| @@ -362,20 +365,21 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 362 | 365 | } | |
| 363 | 366 | ||
| 364 | 367 | .message { | |
| 365 | - | margin-bottom: 0.5rem; | |
| 368 | + | margin-bottom: 0.25rem; | |
| 366 | 369 | 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; | |
| 373 | 373 | } | |
| 374 | 374 | ||
| 375 | 375 | .message .nickname { | |
| 376 | 376 | font-weight: bold; | |
| 377 | 377 | color: #2d31a6; | |
| 378 | - | margin-right: 0.5rem; | |
| 378 | + | cursor: help; | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | .message .content { | |
| 382 | + | flex: 1; | |
| 379 | 383 | } | |
| 380 | 384 | ||
| 381 | 385 | .message.emote { | |
| @@ -383,10 +387,14 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 383 | 387 | font-style: italic; | |
| 384 | 388 | } | |
| 385 | 389 | ||
| 390 | + | .message.emote .nickname { | |
| 391 | + | color: #666; | |
| 392 | + | } | |
| 393 | + | ||
| 386 | 394 | .user { | |
| 387 | 395 | display: flex; | |
| 388 | 396 | align-items: flex-start; | |
| 389 | - | margin-bottom: 0.75rem; | |
| 397 | + | margin-bottom: 0.25rem; | |
| 390 | 398 | gap: 0.375rem; | |
| 391 | 399 | transition: opacity 0.3s ease; | |
| 392 | 400 | } | |
| @@ -513,4 +521,11 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 513 | 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 | 531 | </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 SSR | 14 | // 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 @@ | |||
| 1 | 1 | <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';; | |
| 5 | 3 | import { chatState } from '$lib/states/chat.svelte'; | |
| 6 | 4 | import { onMount, onDestroy } from 'svelte'; | |
| 7 | 5 | 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 @@ | |||
| 6 | 6 | let showChatRoom = $state(true); | |
| 7 | 7 | let showAuth = $state(false); | |
| 8 | 8 | ||
| 9 | - | function handleAuthClose() { | |
| 10 | - | showAuth = false; | |
| 11 | - | } | |
| 12 | 9 | </script> | |
| 13 | 10 | ||
| 14 | 11 | <div class="desktop content-wrapper"> | |
| @@ -19,7 +16,7 @@ | |||
| 19 | 16 | {/if} | |
| 20 | 17 | ||
| 21 | 18 | {#if showAuth} | |
| 22 | - | <AimLogin on:close={handleAuthClose} /> | |
| 19 | + | <AimLogin bind:showAuth /> | |
| 23 | 20 | {/if} | |
| 24 | 21 | </div> | |
| 25 | 22 | ||
| @@ -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'; | |||
| 7 | 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 8 | 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 | 26 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 11 | 27 | console.debug("POST /api/session/login called"); | |
| 12 | 28 | ||
| @@ -14,6 +30,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 14 | 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 | 45 | let body: unknown; | |
| 18 | 46 | try { | |
| 19 | 47 | body = await request.json(); | |
| @@ -35,22 +63,37 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 35 | 63 | }); | |
| 36 | 64 | ||
| 37 | 65 | 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 }); | |
| 40 | 74 | } | |
| 41 | 75 | ||
| 42 | 76 | // Verify password | |
| 43 | 77 | try { | |
| 44 | 78 | const isValid = await verifyPassword(password.trim(), user.password); | |
| 45 | 79 | 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 }); | |
| 48 | 88 | } | |
| 49 | 89 | } catch (err) { | |
| 50 | 90 | console.debug("Error verifying password", err); | |
| 51 | 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 | 97 | // Generate session token and create session | |
| 55 | 98 | const token = generateSessionToken(); | |
| 56 | 99 | 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 password | 76 | // 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 session | 97 | // 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'; | |||
| 3 | 3 | import db from '$lib/db/db.server'; | |
| 4 | 4 | import { users } from '$lib/db/schema'; | |
| 5 | 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 | 10 | if (!locals.user) { | |
| 9 | 11 | throw error(401, 'Authentication required'); | |
| 10 | 12 | } | |
| @@ -19,11 +21,32 @@ export async function POST({ request, locals }) { | |||
| 19 | 21 | console.debug('Updating status for user', userId, 'to', status); | |
| 20 | 22 | ||
| 21 | 23 | // 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 | 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 | 48 | // Broadcast the status update via SSE | |
| 26 | 49 | sseEmitter.emit('sse', { type: 'userStatusUpdate', data: { userId, status, lastSeen: status === 'offline' ? Date.now() : null } }); | |
| 27 | 50 | ||
| 28 | 51 | 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 database | 23 | // 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 SSE | 48 | // 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 | + | }⏎̸ |