feat(desktop): AIM desktop experience — buddy list, IM windows, taskbar, sign-on, profiles, away messages
- commit
- b04c35896a583c23cbbf09d90dd6e4403e7e7673
- author
- Kit · · 2026-09-27 12:47 UTC
- parent
- 2a6dd11
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
20 files · +3388 -722
M .gitignore +4 -1
| @@ -30,4 +30,7 @@ docs/ | |||||
| 30 | migration/ | 30 | migration/ | ||
| 31 | 31 | ||||
| 32 | # Plans | 32 | # Plans | ||
| 33 | - | docs/plans/⏎̸ | 33 | + | docs/plans/ |
| 34 | + | # Local audio samples (copyrighted, never committed) | |||
| 35 | + | static/sounds/* | |||
| 36 | + | !static/sounds/README.md | |||
M src/convex/aim.ts +4 -9
| @@ -48,11 +48,7 @@ export function directKeyFor(a: Id<'users'>, b: Id<'users'>) { | |||||
| 48 | return [a, b].sort().join(':'); | 48 | return [a, b].sort().join(':'); | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | - | export async function assertRoomAccess( | 51 | + | export async function assertRoomAccess(ctx: QueryCtx, room: Doc<'chatRooms'>, userId: Id<'users'>) { |
| 52 | - | ctx: QueryCtx, | |||
| 53 | - | room: Doc<'chatRooms'>, | |||
| 54 | - | userId: Id<'users'> | |||
| 55 | - | ) { | |||
| 56 | if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) { | 52 | if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) { | ||
| 57 | throw new Error('Not a member of this conversation'); | 53 | throw new Error('Not a member of this conversation'); | ||
| 58 | } | 54 | } | ||
| @@ -147,9 +143,7 @@ export const getMyDirectRooms = authQuery({ | |||||
| 147 | }; | 143 | }; | ||
| 148 | }) | 144 | }) | ||
| 149 | ); | 145 | ); | ||
| 150 | - | return result.sort( | 146 | + | return result.sort((a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0)); |
| 151 | - | (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0) | |||
| 152 | - | ); | |||
| 153 | } | 147 | } | ||
| 154 | }); | 148 | }); | ||
| 155 | 149 | ||||
| @@ -246,7 +240,8 @@ export const updateProfile = authMutation({ | |||||
| 246 | args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) }, | 240 | args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) }, | ||
| 247 | handler: async (ctx, args) => { | 241 | handler: async (ctx, args) => { | ||
| 248 | const patch: Partial<Doc<'users'>> = {}; | 242 | const patch: Partial<Doc<'users'>> = {}; | ||
| 249 | - | if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH); | 243 | + | if (args.profile !== undefined) |
| 244 | + | patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH); | |||
| 250 | if (args.buddyIcon !== undefined) { | 245 | if (args.buddyIcon !== undefined) { | ||
| 251 | if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon'); | 246 | if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon'); | ||
| 252 | patch.buddyIcon = args.buddyIcon; | 247 | patch.buddyIcon = args.buddyIcon; | ||
M src/lib/aim/client.ts +2 -4
| @@ -29,10 +29,8 @@ export function tokenHash() { | |||||
| 29 | */ | 29 | */ | ||
| 30 | export function authQuery<Q extends FunctionReference<'query'>>( | 30 | export function authQuery<Q extends FunctionReference<'query'>>( | ||
| 31 | fn: Q, | 31 | fn: Q, | ||
| 32 | - | args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit< | 32 | + | args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => |
| 33 | - | FunctionArgs<Q>, | 33 | + | ({}) as Omit<FunctionArgs<Q>, 'tokenHash'> |
| 34 | - | 'tokenHash' | |||
| 35 | - | > | |||
| 36 | ) { | 34 | ) { | ||
| 37 | if (!convexAvailable) return null; | 35 | if (!convexAvailable) return null; | ||
| 38 | return useQuery(fn, () => { | 36 | return useQuery(fn, () => { | ||
M src/lib/aim/sounds.ts +66 -7
| @@ -1,9 +1,10 @@ | |||||
| 1 | /** | 1 | /** | ||
| 2 | - | * AIM sound effects synthesized with the Web Audio API. | 2 | + | * AIM sound effects. |
| 3 | * | 3 | * | ||
| 4 | - | * No audio files are shipped: everything is generated procedurally so the | 4 | + | * If a sample exists in /static/sounds/<name>.(mp3|wav|ogg) it is played; |
| 5 | - | * project stays free of copyrighted samples. Each sound is a small sketch | 5 | + | * otherwise a Web Audio sketch is synthesized. No audio files are shipped |
| 6 | - | * evoking the original: | 6 | + | * in the repository so the project stays free of copyrighted samples. |
| 7 | + | * Each synthesized sound evokes the original: | |||
| 7 | * - doorOpen : "buddy in" — creak + soft thud | 8 | * - doorOpen : "buddy in" — creak + soft thud | ||
| 8 | * - doorSlam : "buddy out" — sharp slam | 9 | * - doorSlam : "buddy out" — sharp slam | ||
| 9 | * - imReceive : incoming IM "ding" | 10 | * - imReceive : incoming IM "ding" | ||
| @@ -147,7 +148,14 @@ const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) = | |||||
| 147 | doorSlam(ac, dest, t) { | 148 | doorSlam(ac, dest, t) { | ||
| 148 | thud(ac, dest, t, 1.0, 0.22); | 149 | thud(ac, dest, t, 1.0, 0.22); | ||
| 149 | // door frame rattle | 150 | // door frame rattle | ||
| 150 | - | tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 }); | 151 | + | tone(ac, dest, { |
| 152 | + | type: 'square', | |||
| 153 | + | freq: 90, | |||
| 154 | + | freqEnd: 60, | |||
| 155 | + | start: t + 0.02, | |||
| 156 | + | duration: 0.12, | |||
| 157 | + | gain: 0.12 | |||
| 158 | + | }); | |||
| 151 | }, | 159 | }, | ||
| 152 | imReceive(ac, dest, t) { | 160 | imReceive(ac, dest, t) { | ||
| 153 | // Two-note ding, bright | 161 | // Two-note ding, bright | ||
| @@ -185,8 +193,22 @@ const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) = | |||||
| 185 | // Handshake: answer tone, then chirps | 193 | // Handshake: answer tone, then chirps | ||
| 186 | const h = t + 1.7; | 194 | const h = t + 1.7; | ||
| 187 | tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 }); | 195 | tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 }); | ||
| 188 | - | tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 }); | 196 | + | tone(ac, dest, { |
| 189 | - | tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 }); | 197 | + | type: 'square', |
| 198 | + | freq: 1200, | |||
| 199 | + | freqEnd: 2400, | |||
| 200 | + | start: h + 0.7, | |||
| 201 | + | duration: 0.35, | |||
| 202 | + | gain: 0.05 | |||
| 203 | + | }); | |||
| 204 | + | tone(ac, dest, { | |||
| 205 | + | type: 'square', | |||
| 206 | + | freq: 2400, | |||
| 207 | + | freqEnd: 1200, | |||
| 208 | + | start: h + 1.1, | |||
| 209 | + | duration: 0.35, | |||
| 210 | + | gain: 0.05 | |||
| 211 | + | }); | |||
| 190 | // Noise burst (training) | 212 | // Noise burst (training) | ||
| 191 | const src = ac.createBufferSource(); | 213 | const src = ac.createBufferSource(); | ||
| 192 | src.buffer = noiseBuffer(ac, 1.2); | 214 | src.buffer = noiseBuffer(ac, 1.2); | ||
| @@ -212,10 +234,47 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = { | |||||
| 212 | modem: 4500 | 234 | modem: 4500 | ||
| 213 | }; | 235 | }; | ||
| 214 | 236 | ||||
| 237 | + | // Sample cache: undefined = not probed yet, null = no file available | |||
| 238 | + | const samples = new Map<AimSound, AudioBuffer | null>(); | |||
| 239 | + | const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg']; | |||
| 240 | + | ||||
| 241 | + | async function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> { | |||
| 242 | + | if (samples.has(name)) return samples.get(name) ?? null; | |||
| 243 | + | samples.set(name, null); | |||
| 244 | + | for (const ext of SAMPLE_EXTENSIONS) { | |||
| 245 | + | try { | |||
| 246 | + | const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' }); | |||
| 247 | + | if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue; | |||
| 248 | + | const buffer = await ac.decodeAudioData(await res.arrayBuffer()); | |||
| 249 | + | samples.set(name, buffer); | |||
| 250 | + | return buffer; | |||
| 251 | + | } catch { | |||
| 252 | + | // try next extension | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | return null; | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | /** Warm the sample cache so the first playback is not delayed by a fetch. */ | |||
| 259 | + | export function preloadSounds() { | |||
| 260 | + | const ac = getContext(); | |||
| 261 | + | if (!ac) return; | |||
| 262 | + | for (const name of Object.keys(players) as AimSound[]) void loadSample(ac, name); | |||
| 263 | + | } | |||
| 264 | + | ||||
| 215 | export function playSound(name: AimSound) { | 265 | export function playSound(name: AimSound) { | ||
| 216 | if (!enabled) return; | 266 | if (!enabled) return; | ||
| 217 | const ac = getContext(); | 267 | const ac = getContext(); | ||
| 218 | if (!ac) return; | 268 | if (!ac) return; | ||
| 269 | + | const cached = samples.get(name); | |||
| 270 | + | if (cached) { | |||
| 271 | + | const src = ac.createBufferSource(); | |||
| 272 | + | src.buffer = cached; | |||
| 273 | + | src.connect(ac.destination); | |||
| 274 | + | src.start(); | |||
| 275 | + | return; | |||
| 276 | + | } | |||
| 277 | + | if (cached === undefined) void loadSample(ac, name); | |||
| 219 | const master = ac.createGain(); | 278 | const master = ac.createGain(); | ||
| 220 | master.gain.value = 0.9; | 279 | master.gain.value = 0.9; | ||
| 221 | master.connect(ac.destination); | 280 | master.connect(ac.destination); | ||
M src/lib/components/chat-room.svelte +599 -627
| @@ -9,14 +9,14 @@ | |||||
| 9 | import { browser } from '$app/environment'; | 9 | import { browser } from '$app/environment'; | ||
| 10 | import { env } from '$env/dynamic/public'; | 10 | import { env } from '$env/dynamic/public'; | ||
| 11 | import { api } from '$lib/api/client'; | 11 | import { api } from '$lib/api/client'; | ||
| 12 | - | import { draggable } from '$lib/actions/draggable'; | |||
| 13 | - | import { resizable } from '$lib/actions/resizable'; | |||
| 14 | - | import { maximizable } from '$lib/actions/maximizable'; | |||
| 15 | - | import { minimizable, type MinimizableNode } from '$lib/actions/minimizable'; | |||
| 16 | import LoadingButton from './ui/button-loading.svelte'; | 12 | import LoadingButton from './ui/button-loading.svelte'; | ||
| 17 | import Tooltip from './ui/tooltip.svelte'; | 13 | import Tooltip from './ui/tooltip.svelte'; | ||
| 18 | import LoadingDots from './ui/loading-dots.svelte'; | 14 | import LoadingDots from './ui/loading-dots.svelte'; | ||
| 19 | - | import AimLogin from './aim-login.svelte'; | 15 | + | import XpWindow from './desktop/xp-window.svelte'; |
| 16 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 17 | + | import { useAimClient } from '$lib/aim/client'; | |||
| 18 | + | import { getBuddyIcon } from '$lib/aim/buddy-icons'; | |||
| 19 | + | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | |||
| 20 | import FormattedMessage from './formatted-message.svelte'; | 20 | import FormattedMessage from './formatted-message.svelte'; | ||
| 21 | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | 21 | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | ||
| 22 | import MentionPicker from './mention-picker.svelte'; | 22 | import MentionPicker from './mention-picker.svelte'; | ||
| @@ -31,27 +31,44 @@ | |||||
| 31 | } from '../utils/mention'; | 31 | } from '../utils/mention'; | ||
| 32 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting'; | 32 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting'; | ||
| 33 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | 33 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | ||
| 34 | - | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | 34 | + | import { debounce } from '$lib/utils/chat-window-state'; |
| 35 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | 35 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | ||
| 36 | 36 | ||||
| 37 | // Props | 37 | // Props | ||
| 38 | - | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{ | 38 | + | let { |
| 39 | - | showChatRoom?: boolean; | 39 | + | roomId: requestedRoomId = null, |
| 40 | + | roomName = 'General', | |||
| 41 | + | initialTextStyle = DEFAULT_TEXT_STYLE | |||
| 42 | + | } = $props<{ | |||
| 43 | + | roomId?: Id<'chatRooms'> | null; | |||
| 44 | + | roomName?: string; | |||
| 40 | initialTextStyle?: TextStyle; | 45 | initialTextStyle?: TextStyle; | ||
| 41 | }>(); | 46 | }>(); | ||
| 42 | 47 | ||||
| 48 | + | // svelte-ignore state_referenced_locally | |||
| 49 | + | const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default'; | |||
| 50 | + | ||||
| 43 | const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL); | 51 | const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL); | ||
| 52 | + | const aim = useAimClient(); | |||
| 44 | 53 | ||||
| 45 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | 54 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | ||
| 46 | 55 | ||||
| 47 | - | // Subscribe to default room (to get room ID) | 56 | + | // Subscribe to default room (to get room ID) when no explicit room was requested |
| 48 | - | const defaultRoomQuery = isClient ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null; | 57 | + | // svelte-ignore state_referenced_locally |
| 58 | + | const defaultRoomQuery = | |||
| 59 | + | isClient && !requestedRoomId ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null; | |||
| 49 | 60 | ||||
| 50 | // Subscribe to users (buddy list) - real-time updates | 61 | // Subscribe to users (buddy list) - real-time updates | ||
| 51 | const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null; | 62 | const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null; | ||
| 52 | 63 | ||||
| 53 | - | // Room ID from Convex query | 64 | + | // Room ID: explicit prop or the default room |
| 54 | - | const roomId = $derived(defaultRoomQuery?.data?.id as Id<'chatRooms'> | undefined); | 65 | + | const roomId = $derived( |
| 66 | + | (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined | |||
| 67 | + | ); | |||
| 68 | + | ||||
| 69 | + | // Room topic (from public group rooms list) | |||
| 70 | + | const groupRoomsQuery = isClient ? useQuery(convexApi.aim.getGroupRooms, {}) : null; | |||
| 71 | + | const roomTopic = $derived(groupRoomsQuery?.data?.find((r) => r.id === roomId)?.topic ?? null); | |||
| 55 | 72 | ||||
| 56 | // Subscribe to messages - only when room ID is available | 73 | // Subscribe to messages - only when room ID is available | ||
| 57 | const messagesQuery = $derived( | 74 | const messagesQuery = $derived( | ||
| @@ -71,11 +88,20 @@ | |||||
| 71 | nickname: u.nickname, | 88 | nickname: u.nickname, | ||
| 72 | status: u.status as SafeUser['status'], | 89 | status: u.status as SafeUser['status'], | ||
| 73 | avatarUrl: u.avatarUrl ?? null, | 90 | avatarUrl: u.avatarUrl ?? null, | ||
| 74 | - | lastSeen: u.lastSeen ?? null | 91 | + | lastSeen: u.lastSeen ?? null, |
| 92 | + | awayMessage: u.awayMessage, | |||
| 93 | + | buddyIcon: u.buddyIcon | |||
| 75 | })) | 94 | })) | ||
| 76 | .sort((a, b) => { | 95 | .sort((a, b) => { | ||
| 77 | - | const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 }; | 96 | + | const statusOrder: Record<string, number> = { |
| 78 | - | return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3); | 97 | + | online: 0, |
| 98 | + | idle: 1, | |||
| 99 | + | away: 2, | |||
| 100 | + | busy: 3, | |||
| 101 | + | offline: 4 | |||
| 102 | + | }; | |||
| 103 | + | const diff = (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4); | |||
| 104 | + | return diff !== 0 ? diff : a.nickname.localeCompare(b.nickname); | |||
| 79 | }); | 105 | }); | ||
| 80 | }); | 106 | }); | ||
| 81 | 107 | ||||
| @@ -152,15 +178,57 @@ | |||||
| 152 | 178 | ||||
| 153 | // ============ WINDOW STATE ============ | 179 | // ============ WINDOW STATE ============ | ||
| 154 | 180 | ||||
| 155 | - | let windowWidth = $state(800); | |||
| 156 | - | let windowHeight = $state(600); | |||
| 157 | - | let windowX = $state(0); | |||
| 158 | - | let windowY = $state(0); | |||
| 159 | let isMobile = $state(false); | 181 | let isMobile = $state(false); | ||
| 160 | let showUserList = $state(false); | 182 | let showUserList = $state(false); | ||
| 161 | - | let isMaximized = $state(false); | 183 | + | |
| 162 | - | let isMinimized = $state(false); | 184 | + | // ============ USER LIST INTERACTIONS (AIM-style) ============ |
| 163 | - | let isCentered = $state(true); | 185 | + | |
| 186 | + | let selectedUserId = $state<string | null>(null); | |||
| 187 | + | let userMenu = $state<{ x: number; y: number; user: SafeUser } | null>(null); | |||
| 188 | + | let roomPanel = $state<HTMLDivElement | null>(null); | |||
| 189 | + | ||||
| 190 | + | async function openImWith(user: SafeUser) { | |||
| 191 | + | if (!currentUser || user.id === currentUser.id) return; | |||
| 192 | + | try { | |||
| 193 | + | const { id } = await aim.getOrCreateDirectRoom(user.id as Id<'users'>); | |||
| 194 | + | desktop.openIm({ | |||
| 195 | + | roomId: id, | |||
| 196 | + | otherUserId: user.id as Id<'users'>, | |||
| 197 | + | otherNickname: user.nickname | |||
| 198 | + | }); | |||
| 199 | + | } catch (err) { | |||
| 200 | + | console.error('Failed to open IM', err); | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | function openUserMenu(event: MouseEvent, user: SafeUser) { | |||
| 205 | + | event.preventDefault(); | |||
| 206 | + | selectedUserId = user.id; | |||
| 207 | + | const rect = roomPanel?.getBoundingClientRect(); | |||
| 208 | + | userMenu = { | |||
| 209 | + | x: event.clientX - (rect?.left ?? 0), | |||
| 210 | + | y: event.clientY - (rect?.top ?? 0), | |||
| 211 | + | user | |||
| 212 | + | }; | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | function closeUserMenu() { | |||
| 216 | + | userMenu = null; | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | function statusIcon(status: string) { | |||
| 220 | + | const known: AimStatus[] = ['online', 'away', 'busy', 'idle', 'offline']; | |||
| 221 | + | return `/aim/status-${known.includes(status as AimStatus) ? status : 'offline'}.svg`; | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | function statusLabel(user: SafeUser) { | |||
| 225 | + | const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status; | |||
| 226 | + | if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`; | |||
| 227 | + | if (user.status === 'offline') { | |||
| 228 | + | return `${label} — dernière connexion : ${formatFrenchRelativeTimeSafe(user.lastSeen)}`; | |||
| 229 | + | } | |||
| 230 | + | return label; | |||
| 231 | + | } | |||
| 164 | 232 | ||||
| 165 | // ============ INPUT STATE ============ | 233 | // ============ INPUT STATE ============ | ||
| 166 | 234 | ||||
| @@ -206,10 +274,6 @@ | |||||
| 206 | let rateLimitWarning = $state<string | null>(null); | 274 | let rateLimitWarning = $state<string | null>(null); | ||
| 207 | let isSendingMessage = $state(false); | 275 | let isSendingMessage = $state(false); | ||
| 208 | 276 | ||||
| 209 | - | // ============ AUTH STATE ============ | |||
| 210 | - | ||||
| 211 | - | let showAuth = $state(false); | |||
| 212 | - | ||||
| 213 | // ============ SCROLL STATE ============ | 277 | // ============ SCROLL STATE ============ | ||
| 214 | 278 | ||||
| 215 | let isLoadingMore = $state(false); | 279 | let isLoadingMore = $state(false); | ||
| @@ -241,7 +305,6 @@ | |||||
| 241 | ); | 305 | ); | ||
| 242 | 306 | ||||
| 243 | // User counts | 307 | // User counts | ||
| 244 | - | const totalUsers = $derived(onlineUsers.length); | |||
| 245 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | 308 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | ||
| 246 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | 309 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | ||
| 247 | 310 | ||||
| @@ -635,8 +698,6 @@ | |||||
| 635 | pagedMessages = [...pagedMessages, ...newMessages]; | 698 | pagedMessages = [...pagedMessages, ...newMessages]; | ||
| 636 | } | 699 | } | ||
| 637 | 700 | ||||
| 638 | - | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |||
| 639 | - | ||||
| 640 | // Debounced function to update the server with last read mention timestamp | 701 | // Debounced function to update the server with last read mention timestamp | ||
| 641 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | 702 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | ||
| 642 | if (!currentUser || !browser) return; | 703 | if (!currentUser || !browser) return; | ||
| @@ -815,10 +876,6 @@ | |||||
| 815 | 876 | ||||
| 816 | // ============ HANDLERS ============ | 877 | // ============ HANDLERS ============ | ||
| 817 | 878 | ||||
| 818 | - | function handleClose() { | |||
| 819 | - | showChatRoom = false; | |||
| 820 | - | } | |||
| 821 | - | ||||
| 822 | function shouldHighlightMention(message: EnrichedMessage) { | 879 | function shouldHighlightMention(message: EnrichedMessage) { | ||
| 823 | if (!currentUser) return false; | 880 | if (!currentUser) return false; | ||
| 824 | if (message.senderId === currentUser.id) return false; | 881 | if (message.senderId === currentUser.id) return false; | ||
| @@ -909,51 +966,6 @@ | |||||
| 909 | } | 966 | } | ||
| 910 | } | 967 | } | ||
| 911 | 968 | ||||
| 912 | - | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |||
| 913 | - | windowX = event.detail.x; | |||
| 914 | - | windowY = event.detail.y; | |||
| 915 | - | isCentered = false; | |||
| 916 | - | } | |||
| 917 | - | ||||
| 918 | - | interface MaximizableNode extends HTMLElement { | |||
| 919 | - | toggleMaximize: () => void; | |||
| 920 | - | } | |||
| 921 | - | ||||
| 922 | - | function handleMaximize(event: MouseEvent) { | |||
| 923 | - | const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode; | |||
| 924 | - | if (node) { | |||
| 925 | - | if (isMinimized) { | |||
| 926 | - | const minimizableNode = node as unknown as MinimizableNode; | |||
| 927 | - | if (minimizableNode?.toggleMinimize) { | |||
| 928 | - | minimizableNode.toggleMinimize(); | |||
| 929 | - | } | |||
| 930 | - | setTimeout(() => node.toggleMaximize(), 300); | |||
| 931 | - | } else { | |||
| 932 | - | node.toggleMaximize(); | |||
| 933 | - | } | |||
| 934 | - | } | |||
| 935 | - | } | |||
| 936 | - | ||||
| 937 | - | function handleMaximizeEvent( | |||
| 938 | - | event: CustomEvent<{ | |||
| 939 | - | isMaximized: boolean; | |||
| 940 | - | width: number; | |||
| 941 | - | height: number; | |||
| 942 | - | x: number; | |||
| 943 | - | y: number; | |||
| 944 | - | }> | |||
| 945 | - | ) { | |||
| 946 | - | isMaximized = event.detail.isMaximized; | |||
| 947 | - | windowWidth = event.detail.width; | |||
| 948 | - | windowHeight = event.detail.height; | |||
| 949 | - | windowX = event.detail.x; | |||
| 950 | - | windowY = event.detail.y; | |||
| 951 | - | } | |||
| 952 | - | ||||
| 953 | - | function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) { | |||
| 954 | - | isMinimized = event.detail.isMinimized; | |||
| 955 | - | } | |||
| 956 | - | ||||
| 957 | async function handleScroll(event: Event) { | 969 | async function handleScroll(event: Event) { | ||
| 958 | const target = event.target as HTMLElement; | 970 | const target = event.target as HTMLElement; | ||
| 959 | const { scrollTop } = target; | 971 | const { scrollTop } = target; | ||
| @@ -1008,11 +1020,7 @@ | |||||
| 1008 | } | 1020 | } | ||
| 1009 | 1021 | ||||
| 1010 | function openSignup() { | 1022 | function openSignup() { | ||
| 1011 | - | showAuth = true; | 1023 | + | desktop.openLogin('signup'); |
| 1012 | - | } | |||
| 1013 | - | ||||
| 1014 | - | function handleLoginSuccess() { | |||
| 1015 | - | showAuth = false; | |||
| 1016 | } | 1024 | } | ||
| 1017 | 1025 | ||||
| 1018 | // ============ LIFECYCLE ============ | 1026 | // ============ LIFECYCLE ============ | ||
| @@ -1021,101 +1029,28 @@ | |||||
| 1021 | if (!browser) return; | 1029 | if (!browser) return; | ||
| 1022 | 1030 | ||||
| 1023 | isMobile = window.innerWidth <= 768; | 1031 | isMobile = window.innerWidth <= 768; | ||
| 1024 | - | ||||
| 1025 | - | if (!isMobile) { | |||
| 1026 | - | const savedState = loadWindowState(); | |||
| 1027 | - | const maxWidth = window.innerWidth - 40; | |||
| 1028 | - | const maxHeight = window.innerHeight - 40; | |||
| 1029 | - | ||||
| 1030 | - | windowWidth = Math.min(savedState.width, maxWidth); | |||
| 1031 | - | windowHeight = Math.min(savedState.height, maxHeight); | |||
| 1032 | - | ||||
| 1033 | - | if (savedState.x === -1) { | |||
| 1034 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 1035 | - | } else { | |||
| 1036 | - | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |||
| 1037 | - | } | |||
| 1038 | - | if (savedState.y === -1) { | |||
| 1039 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 1040 | - | } else { | |||
| 1041 | - | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |||
| 1042 | - | } | |||
| 1043 | - | ||||
| 1044 | - | isMaximized = savedState.isMaximized; | |||
| 1045 | - | isMinimized = savedState.isMinimized; | |||
| 1046 | - | showUserList = savedState.showUserList; | |||
| 1047 | - | isCentered = savedState.isCentered; | |||
| 1048 | - | } | |||
| 1049 | - | ||||
| 1050 | const handleResize = () => { | 1032 | const handleResize = () => { | ||
| 1051 | - | const wasMobile = isMobile; | |||
| 1052 | isMobile = window.innerWidth <= 768; | 1033 | isMobile = window.innerWidth <= 768; | ||
| 1053 | - | ||||
| 1054 | - | if (isMobile) { | |||
| 1055 | - | windowWidth = window.innerWidth; | |||
| 1056 | - | windowHeight = window.innerHeight; | |||
| 1057 | - | windowX = 0; | |||
| 1058 | - | windowY = 0; | |||
| 1059 | - | } else if (wasMobile && !isMobile) { | |||
| 1060 | - | const savedState = loadWindowState(); | |||
| 1061 | - | const maxWidth = window.innerWidth - 40; | |||
| 1062 | - | const maxHeight = window.innerHeight - 40; | |||
| 1063 | - | windowWidth = Math.min(savedState.width, maxWidth); | |||
| 1064 | - | windowHeight = Math.min(savedState.height, maxHeight); | |||
| 1065 | - | ||||
| 1066 | - | if (savedState.x === -1) { | |||
| 1067 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 1068 | - | } else { | |||
| 1069 | - | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |||
| 1070 | - | } | |||
| 1071 | - | if (savedState.y === -1) { | |||
| 1072 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 1073 | - | } else { | |||
| 1074 | - | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |||
| 1075 | - | } | |||
| 1076 | - | } else if (!isMobile) { | |||
| 1077 | - | const maxWidth = window.innerWidth - 40; | |||
| 1078 | - | const maxHeight = window.innerHeight - 40; | |||
| 1079 | - | windowWidth = Math.min(windowWidth, maxWidth); | |||
| 1080 | - | windowHeight = Math.min(windowHeight, maxHeight); | |||
| 1081 | - | ||||
| 1082 | - | if (isCentered) { | |||
| 1083 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |||
| 1084 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |||
| 1085 | - | } else { | |||
| 1086 | - | windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth)); | |||
| 1087 | - | windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight)); | |||
| 1088 | - | } | |||
| 1089 | - | } | |||
| 1090 | }; | 1034 | }; | ||
| 1091 | - | ||||
| 1092 | - | if (isMobile) handleResize(); | |||
| 1093 | - | ||||
| 1094 | window.addEventListener('resize', handleResize); | 1035 | window.addEventListener('resize', handleResize); | ||
| 1095 | 1036 | ||||
| 1037 | + | const onGlobalClick = () => closeUserMenu(); | |||
| 1038 | + | const onGlobalKey = (e: KeyboardEvent) => { | |||
| 1039 | + | if (e.key === 'Escape') closeUserMenu(); | |||
| 1040 | + | }; | |||
| 1041 | + | window.addEventListener('click', onGlobalClick); | |||
| 1042 | + | window.addEventListener('keydown', onGlobalKey); | |||
| 1043 | + | ||||
| 1096 | return () => { | 1044 | return () => { | ||
| 1097 | window.removeEventListener('resize', handleResize); | 1045 | window.removeEventListener('resize', handleResize); | ||
| 1046 | + | window.removeEventListener('click', onGlobalClick); | |||
| 1047 | + | window.removeEventListener('keydown', onGlobalKey); | |||
| 1098 | if (cooldownInterval) clearInterval(cooldownInterval); | 1048 | if (cooldownInterval) clearInterval(cooldownInterval); | ||
| 1099 | // Clean up canvas context used for text measurement | 1049 | // Clean up canvas context used for text measurement | ||
| 1100 | mentionMeasureContext = null; | 1050 | mentionMeasureContext = null; | ||
| 1101 | }; | 1051 | }; | ||
| 1102 | }); | 1052 | }); | ||
| 1103 | 1053 | ||||
| 1104 | - | // Save window state when it changes | |||
| 1105 | - | $effect(() => { | |||
| 1106 | - | if (!browser || isMobile) return; | |||
| 1107 | - | debouncedSaveWindowState({ | |||
| 1108 | - | width: windowWidth, | |||
| 1109 | - | height: windowHeight, | |||
| 1110 | - | x: windowX, | |||
| 1111 | - | y: windowY, | |||
| 1112 | - | isMaximized, | |||
| 1113 | - | isMinimized, | |||
| 1114 | - | showUserList, | |||
| 1115 | - | isCentered | |||
| 1116 | - | }); | |||
| 1117 | - | }); | |||
| 1118 | - | ||||
| 1119 | // Scroll selected mention option into view when selection changes | 1054 | // Scroll selected mention option into view when selection changes | ||
| 1120 | $effect(() => { | 1055 | $effect(() => { | ||
| 1121 | if (!mentionOpen || mentionSuggestions.length === 0) return; | 1056 | if (!mentionOpen || mentionSuggestions.length === 0) return; | ||
| @@ -1125,196 +1060,146 @@ | |||||
| 1125 | }); | 1060 | }); | ||
| 1126 | </script> | 1061 | </script> | ||
| 1127 | 1062 | ||||
| 1128 | - | {#if showChatRoom} | 1063 | + | <XpWindow |
| 1129 | - | <div | 1064 | + | id={windowId} |
| 1130 | - | class="chat-window window" | 1065 | + | width={800} |
| 1131 | - | class:minimized={isMinimized} | 1066 | + | height={600} |
| 1132 | - | style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;" | 1067 | + | minWidth={420} |
| 1133 | - | use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }} | 1068 | + | minHeight={420} |
| 1134 | - | use:resizable={{ | 1069 | + | showMaximize |
| 1135 | - | enabled: !isMobile && !isMaximized && !isMinimized, | 1070 | + | bodyClass="chat-room-body" |
| 1136 | - | minWidth: 400, | 1071 | + | > |
| 1137 | - | minHeight: 500, | 1072 | + | {#snippet menu()} |
| 1138 | - | maxWidth: window.innerWidth - 40, | 1073 | + | <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button> |
| 1139 | - | maxHeight: window.innerHeight - 40 | 1074 | + | <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button> |
| 1140 | - | }} | 1075 | + | <button type="button" onclick={() => (showUserList = !showUserList)}> |
| 1141 | - | use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }} | 1076 | + | {showUserList || !isMobile ? 'Participants' : 'Participants'} |
| 1142 | - | use:minimizable={{ enabled: !isMobile }} | 1077 | + | </button> |
| 1143 | - | onmaximize={handleMaximizeEvent} | 1078 | + | {#if !currentUser} |
| 1144 | - | onminimize={handleMinimize} | 1079 | + | <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button> |
| 1145 | - | onresizemove={(e) => { | 1080 | + | {/if} |
| 1146 | - | windowWidth = Math.max(400, e.detail.width); | 1081 | + | {/snippet} |
| 1147 | - | windowHeight = Math.max(500, e.detail.height); | 1082 | + | |
| 1148 | - | }} | 1083 | + | <div class="room-header"> |
| 1149 | - | ondragmove={handleDragMove} | 1084 | + | <img src="/aim/chat-room.svg" alt="" width="16" height="16" /> |
| 1150 | - | > | 1085 | + | <span class="room-name">{roomName}</span> |
| 1151 | - | <div class="title-bar"> | 1086 | + | {#if roomTopic} |
| 1152 | - | <div | 1087 | + | <span class="room-topic">— {roomTopic}</span> |
| 1153 | - | class="title-bar-text" | 1088 | + | {/if} |
| 1154 | - | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | 1089 | + | <span class="room-count">{usersOnline.length} en ligne</span> |
| 1090 | + | {#if mentionUnreadCount > 0} | |||
| 1091 | + | <span class="room-mentions" | |||
| 1092 | + | >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span | |||
| 1155 | > | 1093 | > | ||
| 1156 | - | Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount}) | 1094 | + | {/if} |
| 1157 | - | {/if}{#if currentUser} | 1095 | + | {#if connectionError} |
| 1158 | - | - {currentUser.nickname}{:else} | 1096 | + | <span class="connection-error">⚠️ {connectionError}</span> |
| 1159 | - | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | 1097 | + | {/if} |
| 1160 | - | {#if connectionError && !isMinimized} | 1098 | + | </div> |
| 1161 | - | <span class="connection-error">⚠️ {connectionError}</span> | 1099 | + | |
| 1162 | - | {/if} | 1100 | + | <div class="room-panel" bind:this={roomPanel}> |
| 1163 | - | </div> | 1101 | + | <div class="chat-container"> |
| 1164 | - | <div class="title-bar-controls"> | 1102 | + | {#if rateLimitWarning} |
| 1165 | - | {#if !isMobile} | 1103 | + | <div class="rate-limit-warning" class:with-progress={cooldownEndTime}> |
| 1166 | - | <button | 1104 | + | <span>{rateLimitWarning}</span> |
| 1167 | - | aria-label="Minimize" | 1105 | + | {#if cooldownEndTime} |
| 1168 | - | onclick={(e) => { | 1106 | + | <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small> |
| 1169 | - | e.stopPropagation(); | 1107 | + | {/if} |
| 1170 | - | const node = (e.currentTarget as HTMLElement).closest( | 1108 | + | </div> |
| 1171 | - | '.window' | 1109 | + | {/if} |
| 1172 | - | ) as MinimizableNode | null; | |||
| 1173 | - | if (node?.toggleMinimize) node.toggleMinimize(); | |||
| 1174 | - | }} | |||
| 1175 | - | ></button> | |||
| 1176 | - | <button aria-label="Maximize" class:maximized={isMaximized} onclick={handleMaximize} | |||
| 1177 | - | ></button> | |||
| 1178 | - | <button onclick={handleClose} aria-label="Close"></button> | |||
| 1179 | - | {:else} | |||
| 1180 | - | <button | |||
| 1181 | - | aria-label="Afficher/Masquer la liste des contacts" | |||
| 1182 | - | onclick={() => (showUserList = !showUserList)} | |||
| 1183 | - | class="contacts-btn">👥</button | |||
| 1184 | - | > | |||
| 1185 | - | <button | |||
| 1186 | - | aria-label="Minimize" | |||
| 1187 | - | onclick={(e) => { | |||
| 1188 | - | e.stopPropagation(); | |||
| 1189 | - | const node = (e.currentTarget as HTMLElement).closest( | |||
| 1190 | - | '.window' | |||
| 1191 | - | ) as MinimizableNode | null; | |||
| 1192 | - | if (node?.toggleMinimize) node.toggleMinimize(); | |||
| 1193 | - | }} | |||
| 1194 | - | ></button> | |||
| 1195 | - | <button onclick={handleClose} aria-label="Close"></button> | |||
| 1196 | - | {/if} | |||
| 1197 | - | </div> | |||
| 1198 | - | </div> | |||
| 1199 | 1110 | ||||
| 1200 | - | {#if !isMinimized} | |||
| 1201 | <div | 1111 | <div | ||
| 1202 | - | class="window-body" | 1112 | + | class="sunken-panel chat-area" |
| 1203 | - | style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;" | 1113 | + | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" |
| 1114 | + | onscroll={(e) => handleScroll(e)} | |||
| 1115 | + | bind:this={chatArea} | |||
| 1204 | > | 1116 | > | ||
| 1205 | - | {#if connectionError} | 1117 | + | {#if isInitialLoading} |
| 1206 | - | <div class="error-banner"> | 1118 | + | <div class="initial-loading"> |
| 1207 | - | {connectionError} | 1119 | + | <LoadingDots text="Chargement des messages" /> |
| 1120 | + | </div> | |||
| 1121 | + | {:else if isLoadingMore} | |||
| 1122 | + | <div class="loading-messages"> | |||
| 1123 | + | <LoadingDots text="Chargement" /> | |||
| 1208 | </div> | 1124 | </div> | ||
| 1209 | {/if} | 1125 | {/if} | ||
| 1210 | - | 1126 | + | {#if showRegistrationPrompt} | |
| 1211 | - | <div | 1127 | + | <div class="registration-prompt"> |
| 1212 | - | class="chat-container" | 1128 | + | <p> |
| 1213 | - | style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;" | 1129 | + | 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le |
| 1214 | - | > | 1130 | + | reste du chat ! |
| 1215 | - | {#if rateLimitWarning} | 1131 | + | </p> |
| 1216 | - | <div class="rate-limit-warning" class:with-progress={cooldownEndTime}> | 1132 | + | </div> |
| 1217 | - | <span>{rateLimitWarning}</span> | 1133 | + | {/if} |
| 1218 | - | {#if cooldownEndTime} | 1134 | + | {#each visibleMessages as message (message.id)} |
| 1219 | - | <small | |||
| 1220 | - | >Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small | |||
| 1221 | - | > | |||
| 1222 | - | {/if} | |||
| 1223 | - | </div> | |||
| 1224 | - | {/if} | |||
| 1225 | - | ||||
| 1226 | <div | 1135 | <div | ||
| 1227 | - | class="sunken-panel chat-area" | 1136 | + | class="message {message.type} text" |
| 1228 | - | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 1137 | + | class:mention-highlight={hasMentionHighlight[message.id]} |
| 1229 | - | onscroll={(e) => handleScroll(e)} | 1138 | + | data-message-id={message.id} |
| 1230 | - | bind:this={chatArea} | 1139 | + | data-mention={hasMentionHighlight[message.id] |
| 1140 | + | ? `Mention de @${currentUser?.nickname ?? ''}` | |||
| 1141 | + | : undefined} | |||
| 1142 | + | aria-label={hasMentionHighlight[message.id] | |||
| 1143 | + | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |||
| 1144 | + | : undefined} | |||
| 1145 | + | use:registerMentionNode | |||
| 1231 | > | 1146 | > | ||
| 1232 | - | {#if isInitialLoading} | 1147 | + | {#if message.type === 'emote'} |
| 1233 | - | <div class="initial-loading"> | 1148 | + | <span class="emote-text"> |
| 1234 | - | <LoadingDots text="Chargement des messages" /> | 1149 | + | <Tooltip |
| 1235 | - | </div> | 1150 | + | data={{ |
| 1236 | - | {:else if isLoadingMore} | 1151 | + | text: formatFrenchDateTime(new Date(message.timestamp)), |
| 1237 | - | <div class="loading-messages"> | 1152 | + | direction: 'left', |
| 1238 | - | <LoadingDots text="Chargement" /> | 1153 | + | closeDelay: 1000, |
| 1239 | - | </div> | 1154 | + | touchBehavior: 'remove', |
| 1240 | - | {/if} | 1155 | + | interactive: false |
| 1241 | - | {#if showRegistrationPrompt} | 1156 | + | }} |
| 1242 | - | <div class="registration-prompt"> | 1157 | + | > |
| 1243 | - | <p> | 1158 | + | <span class="nickname pointer-events-none">{message.user.nickname}</span> |
| 1244 | - | 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire | 1159 | + | </Tooltip> |
| 1245 | - | le reste du chat ! | 1160 | + | {message.content} |
| 1246 | - | </p> | 1161 | + | </span> |
| 1247 | - | </div> | 1162 | + | {:else} |
| 1248 | - | {/if} | 1163 | + | <Tooltip |
| 1249 | - | {#each visibleMessages as message (message.id)} | 1164 | + | data={{ |
| 1250 | - | <div | 1165 | + | text: formatFrenchDateTime(new Date(message.timestamp)), |
| 1251 | - | class="message {message.type} text" | 1166 | + | direction: 'left', |
| 1252 | - | class:mention-highlight={hasMentionHighlight[message.id]} | 1167 | + | closeDelay: 1000, |
| 1253 | - | data-message-id={message.id} | 1168 | + | touchBehavior: 'remove', |
| 1254 | - | data-mention={hasMentionHighlight[message.id] | 1169 | + | interactive: false |
| 1255 | - | ? `Mention de @${currentUser?.nickname ?? ''}` | 1170 | + | }} |
| 1256 | - | : undefined} | |||
| 1257 | - | aria-label={hasMentionHighlight[message.id] | |||
| 1258 | - | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |||
| 1259 | - | : undefined} | |||
| 1260 | - | use:registerMentionNode | |||
| 1261 | > | 1171 | > | ||
| 1262 | - | {#if message.type === 'emote'} | 1172 | + | <span class="nickname pointer-events-none">{message.user.nickname}:</span> |
| 1263 | - | <span class="emote-text"> | 1173 | + | </Tooltip> |
| 1264 | - | <Tooltip | 1174 | + | <span class="message-content"> |
| 1265 | - | data={{ | 1175 | + | <FormattedMessage {message} allowFormatting={true} /> |
| 1266 | - | text: formatFrenchDateTime(new Date(message.timestamp)), | 1176 | + | </span> |
| 1267 | - | direction: 'left', | 1177 | + | {/if} |
| 1268 | - | closeDelay: 1000, | |||
| 1269 | - | touchBehavior: 'remove', | |||
| 1270 | - | interactive: false | |||
| 1271 | - | }} | |||
| 1272 | - | > | |||
| 1273 | - | <span class="nickname pointer-events-none">{message.user.nickname}</span> | |||
| 1274 | - | </Tooltip> | |||
| 1275 | - | {message.content} | |||
| 1276 | - | </span> | |||
| 1277 | - | {:else} | |||
| 1278 | - | <Tooltip | |||
| 1279 | - | data={{ | |||
| 1280 | - | text: formatFrenchDateTime(new Date(message.timestamp)), | |||
| 1281 | - | direction: 'left', | |||
| 1282 | - | closeDelay: 1000, | |||
| 1283 | - | touchBehavior: 'remove', | |||
| 1284 | - | interactive: false | |||
| 1285 | - | }} | |||
| 1286 | - | > | |||
| 1287 | - | <span class="nickname pointer-events-none">{message.user.nickname}:</span> | |||
| 1288 | - | </Tooltip> | |||
| 1289 | - | <span class="message-content"> | |||
| 1290 | - | <FormattedMessage {message} allowFormatting={true} /> | |||
| 1291 | - | </span> | |||
| 1292 | - | {/if} | |||
| 1293 | - | </div> | |||
| 1294 | - | {/each} | |||
| 1295 | </div> | 1178 | </div> | ||
| 1179 | + | {/each} | |||
| 1180 | + | </div> | |||
| 1296 | 1181 | ||||
| 1297 | - | <!-- Text Formatting Toolbar --> | 1182 | + | <!-- Text Formatting Toolbar --> |
| 1298 | - | {#if currentUser} | 1183 | + | {#if currentUser} |
| 1299 | - | <div style="margin-bottom: 0.25rem;"> | 1184 | + | <div style="margin-bottom: 0.25rem;"> |
| 1300 | - | <TextFormattingToolbar | 1185 | + | <TextFormattingToolbar |
| 1301 | - | bind:style={currentTextStyle} | 1186 | + | bind:style={currentTextStyle} |
| 1302 | - | compact={true} | 1187 | + | compact={true} |
| 1303 | - | showFontSelector={true} | 1188 | + | showFontSelector={true} |
| 1304 | - | /> | 1189 | + | /> |
| 1305 | - | </div> | 1190 | + | </div> |
| 1306 | - | {/if} | 1191 | + | {/if} |
| 1307 | 1192 | ||||
| 1308 | - | <div class="field-row input-container" style="margin: 0; position: relative;"> | 1193 | + | <div class="field-row input-container" style="margin: 0; position: relative;"> |
| 1309 | - | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | 1194 | + | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} |
| 1310 | - | <div | 1195 | + | <div |
| 1311 | - | class="gradient-input-wrapper" | 1196 | + | class="gradient-input-wrapper" |
| 1312 | - | style="flex: 1; position: relative; background: white; overflow: hidden;" | 1197 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" |
| 1313 | - | > | 1198 | + | > |
| 1314 | - | <!-- Gradient text overlay that syncs with input scroll --> | 1199 | + | <!-- Gradient text overlay that syncs with input scroll --> |
| 1315 | - | <span | 1200 | + | <span |
| 1316 | - | class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}" | 1201 | + | class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}" |
| 1317 | - | style=" | 1202 | + | style=" |
| 1318 | position: absolute; | 1203 | position: absolute; | ||
| 1319 | left: 3px; | 1204 | left: 3px; | ||
| 1320 | top: 50%; | 1205 | top: 50%; | ||
| @@ -1323,66 +1208,66 @@ | |||||
| 1323 | white-space: nowrap; | 1208 | white-space: nowrap; | ||
| 1324 | font-size: {currentTextStyle.fontSize}px; | 1209 | font-size: {currentTextStyle.fontSize}px; | ||
| 1325 | {currentTextStyle.bold | 1210 | {currentTextStyle.bold | ||
| 1326 | - | ? currentTextStyle.fontFamily === 'tahoma' | 1211 | + | ? currentTextStyle.fontFamily === 'tahoma' |
| 1327 | - | ? 'font-weight: 200;' | 1212 | + | ? 'font-weight: 200;' |
| 1328 | - | : 'font-weight: 700;' | 1213 | + | : 'font-weight: 700;' |
| 1329 | - | : ''} | 1214 | + | : ''} |
| 1330 | {currentTextStyle.italic ? 'font-style: italic;' : ''} | 1215 | {currentTextStyle.italic ? 'font-style: italic;' : ''} | ||
| 1331 | {currentTextStyle.underline || currentTextStyle.strikethrough | 1216 | {currentTextStyle.underline || currentTextStyle.strikethrough | ||
| 1332 | - | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1217 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` |
| 1333 | - | : ''} | 1218 | + | : ''} |
| 1334 | background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')}); | 1219 | background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')}); | ||
| 1335 | -webkit-background-clip: text; | 1220 | -webkit-background-clip: text; | ||
| 1336 | -webkit-text-fill-color: transparent; | 1221 | -webkit-text-fill-color: transparent; | ||
| 1337 | background-clip: text; | 1222 | background-clip: text; | ||
| 1338 | " | 1223 | " | ||
| 1339 | - | aria-hidden="true" | 1224 | + | aria-hidden="true" |
| 1340 | - | > | 1225 | + | > |
| 1341 | - | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | 1226 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> |
| 1342 | - | {@html formattedInputMessage} | 1227 | + | {@html formattedInputMessage} |
| 1343 | - | </span> | 1228 | + | </span> |
| 1344 | - | <input | 1229 | + | <input |
| 1345 | - | type="text" | 1230 | + | type="text" |
| 1346 | - | bind:value={currentMessage} | 1231 | + | bind:value={currentMessage} |
| 1347 | - | maxlength={MAX_MESSAGE_LENGTH} | 1232 | + | maxlength={MAX_MESSAGE_LENGTH} |
| 1348 | - | class="styled-input retro-font-{currentTextStyle.fontFamily}" | 1233 | + | class="styled-input retro-font-{currentTextStyle.fontFamily}" |
| 1349 | - | style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold | 1234 | + | style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold |
| 1350 | - | ? currentTextStyle.fontFamily === 'tahoma' | 1235 | + | ? currentTextStyle.fontFamily === 'tahoma' |
| 1351 | - | ? 'font-weight: 200;' | 1236 | + | ? 'font-weight: 200;' |
| 1352 | - | : 'font-weight: 700;' | 1237 | + | : 'font-weight: 700;' |
| 1353 | - | : ''} {currentTextStyle.italic | 1238 | + | : ''} {currentTextStyle.italic |
| 1354 | - | ? 'font-style: italic;' | 1239 | + | ? 'font-style: italic;' |
| 1355 | - | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | 1240 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough |
| 1356 | - | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1241 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` |
| 1357 | - | : ''}" | 1242 | + | : ''}" |
| 1358 | - | onkeydown={handleInputKeydown} | 1243 | + | onkeydown={handleInputKeydown} |
| 1359 | - | onkeyup={handleInputKeyup} | 1244 | + | onkeyup={handleInputKeyup} |
| 1360 | - | oninput={handleInputChange} | 1245 | + | oninput={handleInputChange} |
| 1361 | - | onscroll={handleInputScroll} | 1246 | + | onscroll={handleInputScroll} |
| 1362 | - | onfocus={handleInputFocus} | 1247 | + | onfocus={handleInputFocus} |
| 1363 | - | onclick={handleInputClick} | 1248 | + | onclick={handleInputClick} |
| 1364 | - | bind:this={messageInput} | 1249 | + | bind:this={messageInput} |
| 1365 | - | placeholder={!currentUser | 1250 | + | placeholder={!currentUser |
| 1366 | - | ? 'Inscris-toi pour participer.' | 1251 | + | ? 'Inscris-toi pour participer.' |
| 1367 | - | : cooldownEndTime | 1252 | + | : cooldownEndTime |
| 1368 | - | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1253 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` |
| 1369 | - | : 'Écrivez un message...'} | 1254 | + | : 'Écrivez un message...'} |
| 1370 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | 1255 | + | disabled={!currentUser || Boolean(cooldownEndTime)} |
| 1371 | - | role="combobox" | 1256 | + | role="combobox" |
| 1372 | - | aria-autocomplete="list" | 1257 | + | aria-autocomplete="list" |
| 1373 | - | aria-controls={mentionOpen ? mentionListBoxId : undefined} | 1258 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} |
| 1374 | - | aria-expanded={mentionOpen} | 1259 | + | aria-expanded={mentionOpen} |
| 1375 | - | /> | 1260 | + | /> |
| 1376 | - | </div> | 1261 | + | </div> |
| 1377 | - | {:else} | 1262 | + | {:else} |
| 1378 | - | <div | 1263 | + | <div |
| 1379 | - | class="mention-input-wrapper" | 1264 | + | class="mention-input-wrapper" |
| 1380 | - | style="flex: 1; position: relative; background: white; overflow: hidden;" | 1265 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" |
| 1381 | - | > | 1266 | + | > |
| 1382 | - | <!-- Text overlay with mention highlighting --> | 1267 | + | <!-- Text overlay with mention highlighting --> |
| 1383 | - | <span | 1268 | + | <span |
| 1384 | - | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" | 1269 | + | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" |
| 1385 | - | style=" | 1270 | + | style=" |
| 1386 | position: absolute; | 1271 | position: absolute; | ||
| 1387 | left: 3px; | 1272 | left: 3px; | ||
| 1388 | top: 50%; | 1273 | top: 50%; | ||
| @@ -1392,132 +1277,195 @@ | |||||
| 1392 | font-size: {currentTextStyle.fontSize}px; | 1277 | font-size: {currentTextStyle.fontSize}px; | ||
| 1393 | color: {currentTextStyle.color || '#000000'}; | 1278 | color: {currentTextStyle.color || '#000000'}; | ||
| 1394 | {currentTextStyle.bold | 1279 | {currentTextStyle.bold | ||
| 1395 | - | ? currentTextStyle.fontFamily === 'tahoma' | 1280 | + | ? currentTextStyle.fontFamily === 'tahoma' |
| 1396 | - | ? 'font-weight: 200;' | 1281 | + | ? 'font-weight: 200;' |
| 1397 | - | : 'font-weight: 700;' | 1282 | + | : 'font-weight: 700;' |
| 1398 | - | : ''} | 1283 | + | : ''} |
| 1399 | {currentTextStyle.italic ? 'font-style: italic;' : ''} | 1284 | {currentTextStyle.italic ? 'font-style: italic;' : ''} | ||
| 1400 | {currentTextStyle.underline || currentTextStyle.strikethrough | 1285 | {currentTextStyle.underline || currentTextStyle.strikethrough | ||
| 1401 | - | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1286 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` |
| 1402 | - | : ''} | 1287 | + | : ''} |
| 1403 | " | 1288 | " | ||
| 1404 | - | aria-hidden="true" | 1289 | + | aria-hidden="true" |
| 1405 | - | > | 1290 | + | > |
| 1406 | - | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | 1291 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> |
| 1407 | - | {@html formattedInputMessage} | 1292 | + | {@html formattedInputMessage} |
| 1408 | - | </span> | 1293 | + | </span> |
| 1409 | - | <input | 1294 | + | <input |
| 1410 | - | type="text" | 1295 | + | type="text" |
| 1411 | - | bind:value={currentMessage} | 1296 | + | bind:value={currentMessage} |
| 1412 | - | maxlength={MAX_MESSAGE_LENGTH} | 1297 | + | maxlength={MAX_MESSAGE_LENGTH} |
| 1413 | - | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" | 1298 | + | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" |
| 1414 | - | style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold | 1299 | + | style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold |
| 1415 | - | ? currentTextStyle.fontFamily === 'tahoma' | 1300 | + | ? currentTextStyle.fontFamily === 'tahoma' |
| 1416 | - | ? 'font-weight: 200;' | 1301 | + | ? 'font-weight: 200;' |
| 1417 | - | : 'font-weight: 700;' | 1302 | + | : 'font-weight: 700;' |
| 1418 | - | : ''} {currentTextStyle.italic | 1303 | + | : ''} {currentTextStyle.italic |
| 1419 | - | ? 'font-style: italic;' | 1304 | + | ? 'font-style: italic;' |
| 1420 | - | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | 1305 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough |
| 1421 | - | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1306 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` |
| 1422 | - | : ''} caret-color: {currentTextStyle.color || 'black'};" | 1307 | + | : ''} caret-color: {currentTextStyle.color || 'black'};" |
| 1423 | - | onkeydown={handleInputKeydown} | 1308 | + | onkeydown={handleInputKeydown} |
| 1424 | - | onkeyup={handleInputKeyup} | 1309 | + | onkeyup={handleInputKeyup} |
| 1425 | - | oninput={handleInputChange} | 1310 | + | oninput={handleInputChange} |
| 1426 | - | onscroll={handleInputScroll} | 1311 | + | onscroll={handleInputScroll} |
| 1427 | - | onfocus={handleInputFocus} | 1312 | + | onfocus={handleInputFocus} |
| 1428 | - | onclick={handleInputClick} | 1313 | + | onclick={handleInputClick} |
| 1429 | - | bind:this={messageInput} | 1314 | + | bind:this={messageInput} |
| 1430 | - | placeholder={!currentUser | 1315 | + | placeholder={!currentUser |
| 1431 | - | ? 'Inscris-toi pour participer.' | 1316 | + | ? 'Inscris-toi pour participer.' |
| 1432 | - | : cooldownEndTime | 1317 | + | : cooldownEndTime |
| 1433 | - | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1318 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` |
| 1434 | - | : 'Écrivez un message...'} | 1319 | + | : 'Écrivez un message...'} |
| 1435 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | |||
| 1436 | - | role="combobox" | |||
| 1437 | - | aria-autocomplete="list" | |||
| 1438 | - | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |||
| 1439 | - | aria-expanded={mentionOpen} | |||
| 1440 | - | /> | |||
| 1441 | - | </div> | |||
| 1442 | - | {/if} | |||
| 1443 | - | <LoadingButton | |||
| 1444 | - | onclick={handleSubmit} | |||
| 1445 | disabled={!currentUser || Boolean(cooldownEndTime)} | 1320 | disabled={!currentUser || Boolean(cooldownEndTime)} | ||
| 1446 | - | loading={isSendingMessage} | 1321 | + | role="combobox" |
| 1447 | - | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'} | 1322 | + | aria-autocomplete="list" |
| 1323 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |||
| 1324 | + | aria-expanded={mentionOpen} | |||
| 1448 | /> | 1325 | /> | ||
| 1449 | - | {#if showCharCounter} | |||
| 1450 | - | <span class="char-counter {charCounterClass}"> | |||
| 1451 | - | {currentMessage.length}/{MAX_MESSAGE_LENGTH} | |||
| 1452 | - | </span> | |||
| 1453 | - | {/if} | |||
| 1454 | </div> | 1326 | </div> | ||
| 1455 | - | {#if mentionOpen} | 1327 | + | {/if} |
| 1456 | - | <MentionPicker | 1328 | + | <LoadingButton |
| 1457 | - | suggestions={mentionSuggestions} | 1329 | + | onclick={handleSubmit} |
| 1458 | - | selectedIndex={mentionSelectedIndex} | 1330 | + | disabled={!currentUser || Boolean(cooldownEndTime)} |
| 1459 | - | activeId={mentionActiveId} | 1331 | + | loading={isSendingMessage} |
| 1460 | - | listBoxId={mentionListBoxId} | 1332 | + | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'} |
| 1461 | - | listStyle={mentionListStyle} | 1333 | + | /> |
| 1462 | - | isPortal={!mentionListInside} | 1334 | + | {#if showCharCounter} |
| 1463 | - | onSelect={applyMentionSelection} | 1335 | + | <span class="char-counter {charCounterClass}"> |
| 1464 | - | /> | 1336 | + | {currentMessage.length}/{MAX_MESSAGE_LENGTH} |
| 1465 | - | {/if} | 1337 | + | </span> |
| 1466 | - | 1338 | + | {/if} | |
| 1467 | - | {#if cooldownEndTime} | 1339 | + | </div> |
| 1468 | - | <div | 1340 | + | {#if mentionOpen} |
| 1469 | - | class="cooldown-progress" | 1341 | + | <MentionPicker |
| 1470 | - | style="width: {100 - | 1342 | + | suggestions={mentionSuggestions} |
| 1471 | - | ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%" | 1343 | + | selectedIndex={mentionSelectedIndex} |
| 1472 | - | ></div> | 1344 | + | activeId={mentionActiveId} |
| 1473 | - | {/if} | 1345 | + | listBoxId={mentionListBoxId} |
| 1474 | - | </div> | 1346 | + | listStyle={mentionListStyle} |
| 1347 | + | isPortal={!mentionListInside} | |||
| 1348 | + | onSelect={applyMentionSelection} | |||
| 1349 | + | /> | |||
| 1350 | + | {/if} | |||
| 1351 | + | ||||
| 1352 | + | {#if cooldownEndTime} | |||
| 1353 | + | <div | |||
| 1354 | + | class="cooldown-progress" | |||
| 1355 | + | style="width: {100 - ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%" | |||
| 1356 | + | ></div> | |||
| 1357 | + | {/if} | |||
| 1358 | + | </div> | |||
| 1475 | 1359 | ||||
| 1476 | - | <!-- Online users list --> | 1360 | + | <!-- Participants (AIM chat room people list) --> |
| 1361 | + | <div | |||
| 1362 | + | class="sunken-panel users-list" | |||
| 1363 | + | class:mobile={isMobile} | |||
| 1364 | + | class:hidden={isMobile && !showUserList} | |||
| 1365 | + | > | |||
| 1366 | + | <p class="section-header">Participants ({usersOnline.length})</p> | |||
| 1367 | + | {#each usersOnline as user (user.id)} | |||
| 1477 | <div | 1368 | <div | ||
| 1478 | - | class="sunken-panel users-list" | 1369 | + | class="user {user.status}" |
| 1479 | - | class:mobile={isMobile} | 1370 | + | class:selected={selectedUserId === user.id} |
| 1480 | - | class:hidden={isMobile && !showUserList} | 1371 | + | class:me={currentUser?.id === user.id} |
| 1481 | - | style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;" | 1372 | + | role="button" |
| 1373 | + | tabindex="0" | |||
| 1374 | + | title={statusLabel(user)} | |||
| 1375 | + | onclick={(e) => { | |||
| 1376 | + | e.stopPropagation(); | |||
| 1377 | + | selectedUserId = user.id; | |||
| 1378 | + | closeUserMenu(); | |||
| 1379 | + | }} | |||
| 1380 | + | ondblclick={() => openImWith(user)} | |||
| 1381 | + | oncontextmenu={(e) => openUserMenu(e, user)} | |||
| 1382 | + | onkeydown={(e) => { | |||
| 1383 | + | if (e.key === 'Enter') openImWith(user); | |||
| 1384 | + | }} | |||
| 1482 | > | 1385 | > | ||
| 1483 | - | <!-- Online users section --> | 1386 | + | <span |
| 1484 | - | {#if usersOnline.length > 0} | 1387 | + | class="buddy-tile" |
| 1485 | - | <p class="section-header">En ligne ({usersOnline.length})</p> | 1388 | + | style="background: {getBuddyIcon(user.buddyIcon).bg}" |
| 1486 | - | {#each usersOnline as user (user.id)} | 1389 | + | aria-hidden="true">{getBuddyIcon(user.buddyIcon).emoji}</span |
| 1487 | - | <div class="user">{user.nickname}</div> | 1390 | + | > |
| 1488 | - | {/each} | 1391 | + | <img class="status-icon" src={statusIcon(user.status)} alt="" width="12" height="12" /> |
| 1489 | - | {/if} | 1392 | + | <span class="nickname">{user.nickname}</span> |
| 1490 | - | ||||
| 1491 | - | <!-- Offline users section --> | |||
| 1492 | - | {#if usersOffline.length > 0} | |||
| 1493 | - | {#if usersOnline.length > 0} | |||
| 1494 | - | <div class="section-separator"></div> | |||
| 1495 | - | {/if} | |||
| 1496 | - | <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p> | |||
| 1497 | - | {#each usersOffline as user (user.id)} | |||
| 1498 | - | <div class="user offline"> | |||
| 1499 | - | <Tooltip | |||
| 1500 | - | data={{ | |||
| 1501 | - | text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen), | |||
| 1502 | - | direction: 'bottom', | |||
| 1503 | - | closeDelay: 1000, | |||
| 1504 | - | touchBehavior: 'remove' | |||
| 1505 | - | }} | |||
| 1506 | - | > | |||
| 1507 | - | <span class="nickname">{user.nickname}</span> | |||
| 1508 | - | </Tooltip> | |||
| 1509 | - | </div> | |||
| 1510 | - | {/each} | |||
| 1511 | - | {/if} | |||
| 1512 | </div> | 1393 | </div> | ||
| 1394 | + | {/each} | |||
| 1395 | + | ||||
| 1396 | + | {#if usersOffline.length > 0} | |||
| 1397 | + | <div class="section-separator"></div> | |||
| 1398 | + | <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p> | |||
| 1399 | + | {#each usersOffline as user (user.id)} | |||
| 1400 | + | <div | |||
| 1401 | + | class="user offline" | |||
| 1402 | + | class:selected={selectedUserId === user.id} | |||
| 1403 | + | role="button" | |||
| 1404 | + | tabindex="0" | |||
| 1405 | + | title={statusLabel(user)} | |||
| 1406 | + | onclick={(e) => { | |||
| 1407 | + | e.stopPropagation(); | |||
| 1408 | + | selectedUserId = user.id; | |||
| 1409 | + | closeUserMenu(); | |||
| 1410 | + | }} | |||
| 1411 | + | ondblclick={() => openImWith(user)} | |||
| 1412 | + | oncontextmenu={(e) => openUserMenu(e, user)} | |||
| 1413 | + | onkeydown={(e) => { | |||
| 1414 | + | if (e.key === 'Enter') openImWith(user); | |||
| 1415 | + | }} | |||
| 1416 | + | > | |||
| 1417 | + | <img class="status-icon" src={statusIcon('offline')} alt="" width="12" height="12" /> | |||
| 1418 | + | <span class="nickname">{user.nickname}</span> | |||
| 1419 | + | </div> | |||
| 1420 | + | {/each} | |||
| 1421 | + | {/if} | |||
| 1422 | + | </div> | |||
| 1423 | + | ||||
| 1424 | + | {#if userMenu} | |||
| 1425 | + | <div | |||
| 1426 | + | class="user-menu" | |||
| 1427 | + | style="left: {userMenu.x}px; top: {userMenu.y}px;" | |||
| 1428 | + | role="menu" | |||
| 1429 | + | tabindex="-1" | |||
| 1430 | + | onclick={(e) => e.stopPropagation()} | |||
| 1431 | + | onkeydown={(e) => { | |||
| 1432 | + | if (e.key === 'Escape') closeUserMenu(); | |||
| 1433 | + | }} | |||
| 1434 | + | > | |||
| 1435 | + | <div class="user-menu-title">{userMenu.user.nickname}</div> | |||
| 1436 | + | {#if currentUser && userMenu.user.id !== currentUser.id} | |||
| 1437 | + | <button | |||
| 1438 | + | type="button" | |||
| 1439 | + | role="menuitem" | |||
| 1440 | + | onclick={() => { | |||
| 1441 | + | const u = userMenu?.user; | |||
| 1442 | + | closeUserMenu(); | |||
| 1443 | + | if (u) void openImWith(u); | |||
| 1444 | + | }}>Envoyer un message instantané</button | |||
| 1445 | + | > | |||
| 1446 | + | <button | |||
| 1447 | + | type="button" | |||
| 1448 | + | role="menuitem" | |||
| 1449 | + | onclick={() => { | |||
| 1450 | + | const u = userMenu?.user; | |||
| 1451 | + | closeUserMenu(); | |||
| 1452 | + | if (u) void aim.addBuddy(u.id as Id<'users'>).catch(console.error); | |||
| 1453 | + | }}>Ajouter à ma liste de contacts</button | |||
| 1454 | + | > | |||
| 1455 | + | {/if} | |||
| 1456 | + | <button | |||
| 1457 | + | type="button" | |||
| 1458 | + | role="menuitem" | |||
| 1459 | + | onclick={() => { | |||
| 1460 | + | const u = userMenu?.user; | |||
| 1461 | + | closeUserMenu(); | |||
| 1462 | + | if (u) desktop.openProfile({ userId: u.id as Id<'users'>, nickname: u.nickname }); | |||
| 1463 | + | }}>Infos sur {userMenu.user.nickname}</button | |||
| 1464 | + | > | |||
| 1513 | </div> | 1465 | </div> | ||
| 1514 | {/if} | 1466 | {/if} | ||
| 1515 | </div> | 1467 | </div> | ||
| 1516 | - | {/if} | 1468 | + | </XpWindow> |
| 1517 | - | ||||
| 1518 | - | {#if showAuth} | |||
| 1519 | - | <AimLogin bind:showAuth activeTab={'signup' as const} onLoginSuccess={handleLoginSuccess} /> | |||
| 1520 | - | {/if} | |||
| 1521 | 1469 | ||||
| 1522 | <style> | 1470 | <style> | ||
| 1523 | .chat-area, | 1471 | .chat-area, | ||
| @@ -1531,36 +1479,157 @@ | |||||
| 1531 | font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif; | 1479 | font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif; | ||
| 1532 | } | 1480 | } | ||
| 1533 | 1481 | ||||
| 1534 | - | .title-bar { | 1482 | + | /* ===== Layout inside the XP window ===== */ |
| 1535 | - | height: 2rem; | 1483 | + | .room-header { |
| 1536 | - | position: relative; | |||
| 1537 | - | cursor: default; | |||
| 1538 | - | user-select: none; | |||
| 1539 | display: flex; | 1484 | display: flex; | ||
| 1540 | align-items: center; | 1485 | align-items: center; | ||
| 1541 | - | padding: 0 0.5rem; | 1486 | + | gap: 6px; |
| 1487 | + | padding: 3px 6px; | |||
| 1488 | + | margin-bottom: 4px; | |||
| 1489 | + | background: linear-gradient(180deg, #fff3a6 0%, #f6c700 100%); | |||
| 1490 | + | border: 1px solid #c99a00; | |||
| 1491 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif; | |||
| 1492 | + | font-size: 11px; | |||
| 1493 | + | flex: 0 0 auto; | |||
| 1494 | + | min-width: 0; | |||
| 1495 | + | } | |||
| 1496 | + | ||||
| 1497 | + | .room-name { | |||
| 1498 | + | font-weight: bold; | |||
| 1499 | + | } | |||
| 1500 | + | ||||
| 1501 | + | .room-topic { | |||
| 1502 | + | color: #5a4a00; | |||
| 1503 | + | overflow: hidden; | |||
| 1504 | + | text-overflow: ellipsis; | |||
| 1505 | + | white-space: nowrap; | |||
| 1506 | + | min-width: 0; | |||
| 1507 | + | flex: 1; | |||
| 1542 | } | 1508 | } | ||
| 1543 | 1509 | ||||
| 1544 | - | .title-bar:not(:has(button:hover)) { | 1510 | + | .room-count { |
| 1545 | - | cursor: move; | 1511 | + | margin-left: auto; |
| 1512 | + | color: #5a4a00; | |||
| 1513 | + | white-space: nowrap; | |||
| 1546 | } | 1514 | } | ||
| 1547 | 1515 | ||||
| 1548 | - | .chat-window { | 1516 | + | .room-mentions { |
| 1549 | - | position: fixed; | 1517 | + | font-weight: bold; |
| 1550 | - | box-sizing: border-box; | 1518 | + | color: #c00000; |
| 1551 | - | transition: | 1519 | + | white-space: nowrap; |
| 1552 | - | width 0.3s ease, | |||
| 1553 | - | height 0.3s ease, | |||
| 1554 | - | left 0.3s ease, | |||
| 1555 | - | top 0.3s ease; | |||
| 1556 | - | will-change: transform; | |||
| 1557 | } | 1520 | } | ||
| 1558 | 1521 | ||||
| 1559 | - | .chat-window.dragging { | 1522 | + | .room-panel { |
| 1560 | - | transition: none !important; | 1523 | + | position: relative; |
| 1524 | + | flex: 1 1 auto; | |||
| 1525 | + | min-height: 0; | |||
| 1526 | + | display: flex; | |||
| 1527 | + | gap: 6px; | |||
| 1528 | + | } | |||
| 1529 | + | ||||
| 1530 | + | .chat-container { | |||
| 1531 | + | flex: 1 1 auto; | |||
| 1532 | + | min-width: 0; | |||
| 1533 | + | display: flex; | |||
| 1534 | + | flex-direction: column; | |||
| 1535 | + | } | |||
| 1536 | + | ||||
| 1537 | + | .users-list { | |||
| 1538 | + | width: 10.5rem; | |||
| 1539 | + | flex: 0 0 auto; | |||
| 1540 | + | padding: 0.4rem; | |||
| 1541 | + | overflow-y: auto; | |||
| 1542 | + | font-size: 0.85rem; | |||
| 1543 | + | } | |||
| 1544 | + | ||||
| 1545 | + | .user { | |||
| 1546 | + | display: flex; | |||
| 1547 | + | align-items: center; | |||
| 1548 | + | gap: 4px; | |||
| 1549 | + | padding: 1px 3px; | |||
| 1550 | + | cursor: pointer; | |||
| 1551 | + | border: 1px dotted transparent; | |||
| 1561 | user-select: none; | 1552 | user-select: none; | ||
| 1562 | } | 1553 | } | ||
| 1563 | 1554 | ||||
| 1555 | + | .user:hover { | |||
| 1556 | + | background: #e8eefb; | |||
| 1557 | + | } | |||
| 1558 | + | ||||
| 1559 | + | .user.selected { | |||
| 1560 | + | background: #316ac5; | |||
| 1561 | + | color: #fff; | |||
| 1562 | + | border-color: #fff; | |||
| 1563 | + | } | |||
| 1564 | + | ||||
| 1565 | + | .user.me .nickname { | |||
| 1566 | + | font-weight: bold; | |||
| 1567 | + | } | |||
| 1568 | + | ||||
| 1569 | + | .user.away .nickname, | |||
| 1570 | + | .user.idle .nickname { | |||
| 1571 | + | color: #777; | |||
| 1572 | + | font-style: italic; | |||
| 1573 | + | } | |||
| 1574 | + | ||||
| 1575 | + | .user.selected .nickname { | |||
| 1576 | + | color: #fff; | |||
| 1577 | + | } | |||
| 1578 | + | ||||
| 1579 | + | .buddy-tile { | |||
| 1580 | + | display: inline-flex; | |||
| 1581 | + | align-items: center; | |||
| 1582 | + | justify-content: center; | |||
| 1583 | + | width: 16px; | |||
| 1584 | + | height: 16px; | |||
| 1585 | + | font-size: 11px; | |||
| 1586 | + | border: 1px solid rgba(0, 0, 0, 0.25); | |||
| 1587 | + | border-radius: 2px; | |||
| 1588 | + | flex: 0 0 16px; | |||
| 1589 | + | } | |||
| 1590 | + | ||||
| 1591 | + | .status-icon { | |||
| 1592 | + | flex: 0 0 12px; | |||
| 1593 | + | } | |||
| 1594 | + | ||||
| 1595 | + | .user-menu { | |||
| 1596 | + | position: absolute; | |||
| 1597 | + | z-index: 50; | |||
| 1598 | + | min-width: 200px; | |||
| 1599 | + | background: #fff; | |||
| 1600 | + | border: 1px solid #aca899; | |||
| 1601 | + | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); | |||
| 1602 | + | padding: 2px; | |||
| 1603 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif; | |||
| 1604 | + | font-size: 11px; | |||
| 1605 | + | display: flex; | |||
| 1606 | + | flex-direction: column; | |||
| 1607 | + | } | |||
| 1608 | + | ||||
| 1609 | + | .user-menu-title { | |||
| 1610 | + | font-weight: bold; | |||
| 1611 | + | padding: 3px 20px; | |||
| 1612 | + | border-bottom: 1px solid #aca899; | |||
| 1613 | + | margin-bottom: 2px; | |||
| 1614 | + | color: #444; | |||
| 1615 | + | } | |||
| 1616 | + | ||||
| 1617 | + | .user-menu button { | |||
| 1618 | + | text-align: left; | |||
| 1619 | + | background: transparent; | |||
| 1620 | + | border: none; | |||
| 1621 | + | box-shadow: none; | |||
| 1622 | + | border-radius: 0; | |||
| 1623 | + | padding: 3px 20px; | |||
| 1624 | + | min-width: 0; | |||
| 1625 | + | font-size: 11px; | |||
| 1626 | + | } | |||
| 1627 | + | ||||
| 1628 | + | .user-menu button:hover { | |||
| 1629 | + | background: #316ac5; | |||
| 1630 | + | color: #fff; | |||
| 1631 | + | } | |||
| 1632 | + | ||||
| 1564 | .chat-area { | 1633 | .chat-area { | ||
| 1565 | line-height: 1.4; | 1634 | line-height: 1.4; | ||
| 1566 | } | 1635 | } | ||
| @@ -1665,24 +1734,11 @@ | |||||
| 1665 | margin: 0.5rem 0; | 1734 | margin: 0.5rem 0; | ||
| 1666 | } | 1735 | } | ||
| 1667 | 1736 | ||||
| 1668 | - | .user { | |||
| 1669 | - | margin-bottom: 0.125rem; | |||
| 1670 | - | padding-left: 0.25rem; | |||
| 1671 | - | line-height: 1.4; | |||
| 1672 | - | font-size: 0.9rem; | |||
| 1673 | - | font-weight: normal; | |||
| 1674 | - | color: var(--color-foreground); | |||
| 1675 | - | } | |||
| 1676 | - | ||||
| 1677 | .user.offline { | 1737 | .user.offline { | ||
| 1678 | color: var(--color-muted-foreground); | 1738 | color: var(--color-muted-foreground); | ||
| 1679 | font-style: italic; | 1739 | font-style: italic; | ||
| 1680 | } | 1740 | } | ||
| 1681 | 1741 | ||||
| 1682 | - | .user.offline .nickname { | |||
| 1683 | - | cursor: help; | |||
| 1684 | - | } | |||
| 1685 | - | ||||
| 1686 | .sunken-panel { | 1742 | .sunken-panel { | ||
| 1687 | background: white; | 1743 | background: white; | ||
| 1688 | border: 0.125rem inset #dfdfdf; | 1744 | border: 0.125rem inset #dfdfdf; | ||
| @@ -1728,90 +1784,14 @@ | |||||
| 1728 | display: none !important; | 1784 | display: none !important; | ||
| 1729 | } | 1785 | } | ||
| 1730 | 1786 | ||||
| 1731 | - | :global(.resize-handle) { | |||
| 1732 | - | position: absolute; | |||
| 1733 | - | bottom: 0; | |||
| 1734 | - | right: 0; | |||
| 1735 | - | width: 15px !important; | |||
| 1736 | - | height: 15px !important; | |||
| 1737 | - | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E"); | |||
| 1738 | - | background-position: bottom right; | |||
| 1739 | - | background-repeat: no-repeat; | |||
| 1740 | - | cursor: se-resize !important; | |||
| 1741 | - | } | |||
| 1742 | - | ||||
| 1743 | @media (max-width: 768px) { | 1787 | @media (max-width: 768px) { | ||
| 1744 | - | .window { | 1788 | + | .room-panel { |
| 1745 | - | position: fixed !important; | |||
| 1746 | - | top: 0 !important; | |||
| 1747 | - | left: 0 !important; | |||
| 1748 | - | right: 0 !important; | |||
| 1749 | - | bottom: 0 !important; | |||
| 1750 | - | width: 100% !important; | |||
| 1751 | - | height: 100% !important; | |||
| 1752 | - | transition: transform 0.3s ease !important; | |||
| 1753 | - | margin: 0 !important; | |||
| 1754 | - | border-radius: 0 !important; | |||
| 1755 | - | } | |||
| 1756 | - | ||||
| 1757 | - | .window.minimized { | |||
| 1758 | - | transform: translateY(calc(100% - 32px)) !important; | |||
| 1759 | - | height: 100% !important; | |||
| 1760 | - | } | |||
| 1761 | - | ||||
| 1762 | - | .window.minimized .title-bar { | |||
| 1763 | - | height: 32px; | |||
| 1764 | - | } | |||
| 1765 | - | ||||
| 1766 | - | .title-bar-controls button { | |||
| 1767 | - | width: 24px; | |||
| 1768 | - | height: 24px; | |||
| 1769 | - | padding: 0; | |||
| 1770 | - | margin: 0 2px; | |||
| 1771 | - | background-color: transparent; | |||
| 1772 | - | border: 1px solid transparent; | |||
| 1773 | - | position: relative; | |||
| 1774 | - | } | |||
| 1775 | - | ||||
| 1776 | - | .title-bar-controls button.contacts-btn { | |||
| 1777 | - | width: auto; | |||
| 1778 | - | padding: 0 0.5rem; | |||
| 1779 | - | font-size: 1.25rem; | |||
| 1780 | - | } | |||
| 1781 | - | ||||
| 1782 | - | .window.minimized .title-bar-controls button { | |||
| 1783 | - | opacity: 0.8; | |||
| 1784 | - | } | |||
| 1785 | - | ||||
| 1786 | - | .window.minimized .title-bar-controls button.contacts-btn { | |||
| 1787 | - | opacity: 0.8; | |||
| 1788 | - | } | |||
| 1789 | - | ||||
| 1790 | - | .window-body { | |||
| 1791 | flex-direction: column; | 1789 | flex-direction: column; | ||
| 1792 | } | 1790 | } | ||
| 1793 | 1791 | ||||
| 1794 | - | .chat-container { | |||
| 1795 | - | margin-right: 0 !important; | |||
| 1796 | - | margin-bottom: 0 !important; | |||
| 1797 | - | height: 100%; | |||
| 1798 | - | } | |||
| 1799 | - | ||||
| 1800 | .users-list.mobile { | 1792 | .users-list.mobile { | ||
| 1801 | - | position: fixed; | 1793 | + | width: 100%; |
| 1802 | - | bottom: 0; | |||
| 1803 | - | left: 0; | |||
| 1804 | - | right: 0; | |||
| 1805 | - | height: auto; | |||
| 1806 | max-height: 30vh; | 1794 | max-height: 30vh; | ||
| 1807 | - | z-index: 1000; | |||
| 1808 | - | border-top: 0.125rem solid #dfdfdf; | |||
| 1809 | - | background: white; | |||
| 1810 | - | } | |||
| 1811 | - | ||||
| 1812 | - | .input-container { | |||
| 1813 | - | padding: 0.5rem; | |||
| 1814 | - | border-top: 0.125rem solid #dfdfdf; | |||
| 1815 | } | 1795 | } | ||
| 1816 | } | 1796 | } | ||
| 1817 | 1797 | ||||
| @@ -1992,14 +1972,6 @@ | |||||
| 1992 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | 1972 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | ||
| 1993 | } | 1973 | } | ||
| 1994 | 1974 | ||||
| 1995 | - | .minimized .title-bar { | |||
| 1996 | - | cursor: pointer; | |||
| 1997 | - | } | |||
| 1998 | - | ||||
| 1999 | - | .minimized .window-body { | |||
| 2000 | - | display: none; | |||
| 2001 | - | } | |||
| 2002 | - | ||||
| 2003 | .minimized :global(.resize-handle) { | 1975 | .minimized :global(.resize-handle) { | ||
| 2004 | display: none; | 1976 | display: none; | ||
| 2005 | } | 1977 | } | ||
M src/lib/components/desktop-icons.svelte +106 -52
| @@ -1,5 +1,59 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | - | let { showChatRoom = $bindable(), showAuth = $bindable() } = $props(); | 2 | + | import { desktop } from '$lib/states/desktop.svelte'; |
| 3 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 4 | + | ||||
| 5 | + | const currentUser = $derived(chatState.getCurrentUser()); | |||
| 6 | + | ||||
| 7 | + | interface DesktopIcon { | |||
| 8 | + | id: string; | |||
| 9 | + | label: string; | |||
| 10 | + | icon: string; | |||
| 11 | + | action: () => void; | |||
| 12 | + | hidden?: boolean; | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | const icons = $derived<DesktopIcon[]>([ | |||
| 16 | + | { | |||
| 17 | + | id: 'aim', | |||
| 18 | + | label: 'PDR AIM', | |||
| 19 | + | icon: '/desktop/aim-desktop-icon.png', | |||
| 20 | + | action: () => (currentUser ? desktop.openBuddyList() : desktop.openLogin('signin')) | |||
| 21 | + | }, | |||
| 22 | + | { | |||
| 23 | + | id: 'general', | |||
| 24 | + | label: 'Salon General', | |||
| 25 | + | icon: '/aim/chat-room.svg', | |||
| 26 | + | action: () => desktop.openChatRoom(null, 'General') | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | id: 'login', | |||
| 30 | + | label: 'Connexion', | |||
| 31 | + | icon: '/desktop/application-x-shellscript.png', | |||
| 32 | + | action: () => desktop.openLogin('signin'), | |||
| 33 | + | hidden: Boolean(currentUser) | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | id: 'away', | |||
| 37 | + | label: 'Message d’absence', | |||
| 38 | + | icon: '/aim/away.svg', | |||
| 39 | + | action: () => desktop.openAway(), | |||
| 40 | + | hidden: !currentUser | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | id: 'prefs', | |||
| 44 | + | label: 'Préférences', | |||
| 45 | + | icon: '/aim/setup-icon.png', | |||
| 46 | + | action: () => desktop.openPreferences() | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | id: 'about', | |||
| 50 | + | label: 'Aide', | |||
| 51 | + | icon: '/aim/help-icon.png', | |||
| 52 | + | action: () => desktop.openAbout() | |||
| 53 | + | } | |||
| 54 | + | ]); | |||
| 55 | + | ||||
| 56 | + | let selected = $state<string | null>(null); | |||
| 3 | 57 | ||||
| 4 | function handleKeydown(event: KeyboardEvent, action: () => void) { | 58 | function handleKeydown(event: KeyboardEvent, action: () => void) { | ||
| 5 | if (event.key === 'Enter' || event.key === ' ') { | 59 | if (event.key === 'Enter' || event.key === ' ') { | ||
| @@ -7,89 +61,89 @@ | |||||
| 7 | action(); | 61 | action(); | ||
| 8 | } | 62 | } | ||
| 9 | } | 63 | } | ||
| 10 | - | ||||
| 11 | - | function handleChatRoomClick() { | |||
| 12 | - | showChatRoom = true; | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | function handleAuthClick() { | |||
| 16 | - | showAuth = true; | |||
| 17 | - | } | |||
| 18 | </script> | 64 | </script> | ||
| 19 | 65 | ||||
| 20 | <div class="desktop-icons"> | 66 | <div class="desktop-icons"> | ||
| 21 | - | <div | 67 | + | {#each icons.filter((i) => !i.hidden) as icon (icon.id)} |
| 22 | - | class="desktop-icon" | 68 | + | <div |
| 23 | - | ondblclick={handleChatRoomClick} | 69 | + | class="desktop-icon" |
| 24 | - | onkeydown={(e) => handleKeydown(e, handleChatRoomClick)} | 70 | + | class:selected={selected === icon.id} |
| 25 | - | role="button" | 71 | + | onclick={(e) => { |
| 26 | - | tabindex="0" | 72 | + | e.stopPropagation(); |
| 27 | - | > | 73 | + | selected = icon.id; |
| 28 | - | <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" /> | 74 | + | }} |
| 29 | - | <span class="icon-label">Pdr Aim</span> | 75 | + | ondblclick={icon.action} |
| 30 | - | </div> | 76 | + | onkeydown={(e) => handleKeydown(e, icon.action)} |
| 31 | - | 77 | + | onfocus={() => (selected = icon.id)} | |
| 32 | - | <div | 78 | + | role="button" |
| 33 | - | class="desktop-icon" | 79 | + | tabindex="0" |
| 34 | - | ondblclick={handleAuthClick} | 80 | + | > |
| 35 | - | onkeydown={(e) => handleKeydown(e, handleAuthClick)} | 81 | + | <img src={icon.icon} alt="" /> |
| 36 | - | role="button" | 82 | + | <span class="icon-label">{icon.label}</span> |
| 37 | - | tabindex="0" | 83 | + | </div> |
| 38 | - | > | 84 | + | {/each} |
| 39 | - | <img src="/desktop/application-x-shellscript.png" alt="Connexion" /> | |||
| 40 | - | <span class="icon-label">Connexion</span> | |||
| 41 | - | </div> | |||
| 42 | </div> | 85 | </div> | ||
| 43 | 86 | ||||
| 44 | <style> | 87 | <style> | ||
| 45 | .desktop-icons { | 88 | .desktop-icons { | ||
| 46 | position: fixed; | 89 | position: fixed; | ||
| 47 | - | top: 20px; | 90 | + | top: 16px; |
| 48 | - | left: 20px; | 91 | + | left: 12px; |
| 49 | display: flex; | 92 | display: flex; | ||
| 50 | flex-direction: column; | 93 | flex-direction: column; | ||
| 51 | - | gap: 20px; | 94 | + | gap: 10px; |
| 95 | + | z-index: 1; | |||
| 52 | } | 96 | } | ||
| 53 | 97 | ||||
| 54 | .desktop-icon { | 98 | .desktop-icon { | ||
| 55 | display: flex; | 99 | display: flex; | ||
| 56 | flex-direction: column; | 100 | flex-direction: column; | ||
| 57 | align-items: center; | 101 | align-items: center; | ||
| 58 | - | cursor: pointer; | 102 | + | cursor: default; |
| 59 | - | width: 75px; | 103 | + | width: 80px; |
| 60 | text-align: center; | 104 | text-align: center; | ||
| 61 | - | padding: 8px; | 105 | + | padding: 6px 4px; |
| 62 | border: 1px dotted transparent; | 106 | border: 1px dotted transparent; | ||
| 63 | border-radius: 2px; | 107 | border-radius: 2px; | ||
| 64 | - | margin: 2px; | 108 | + | outline: none; |
| 65 | } | 109 | } | ||
| 66 | 110 | ||||
| 67 | - | .desktop-icon:hover, | 111 | + | .desktop-icon:hover { |
| 68 | - | .desktop-icon:focus { | 112 | + | background: rgba(49, 106, 197, 0.15); |
| 69 | - | background: rgba(49, 106, 197, 0.2); | |||
| 70 | - | border: 1px dotted rgba(255, 255, 255, 0.8); | |||
| 71 | - | outline: 1px solid rgba(49, 106, 197, 0.6); | |||
| 72 | - | outline-offset: -1px; | |||
| 73 | } | 113 | } | ||
| 74 | 114 | ||||
| 75 | - | .desktop-icon:active { | 115 | + | .desktop-icon.selected, |
| 76 | - | background: rgba(49, 106, 197, 0.4); | 116 | + | .desktop-icon:focus-visible { |
| 117 | + | background: rgba(49, 106, 197, 0.45); | |||
| 118 | + | border-color: rgba(255, 255, 255, 0.8); | |||
| 77 | } | 119 | } | ||
| 78 | 120 | ||||
| 79 | .desktop-icon img { | 121 | .desktop-icon img { | ||
| 80 | - | width: 48px; | 122 | + | width: 40px; |
| 81 | - | height: 48px; | 123 | + | height: 40px; |
| 82 | - | margin-bottom: 5px; | 124 | + | margin-bottom: 4px; |
| 83 | - | pointer-events: none; /* Prevents image from interfering with hover */ | 125 | + | pointer-events: none; |
| 126 | + | image-rendering: pixelated; | |||
| 127 | + | filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.6)); | |||
| 84 | } | 128 | } | ||
| 85 | 129 | ||||
| 86 | .icon-label { | 130 | .icon-label { | ||
| 87 | color: white; | 131 | color: white; | ||
| 88 | - | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); | 132 | + | text-shadow: |
| 89 | - | font-size: 12px; | 133 | + | 1px 1px 1px rgba(0, 0, 0, 0.9), |
| 134 | + | 0 0 4px rgba(0, 0, 0, 0.6); | |||
| 135 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif; | |||
| 136 | + | font-size: 11px; | |||
| 90 | user-select: none; | 137 | user-select: none; | ||
| 91 | - | pointer-events: none; /* Prevents text from interfering with hover */ | 138 | + | pointer-events: none; |
| 92 | max-width: 100%; | 139 | max-width: 100%; | ||
| 93 | overflow-wrap: break-word; | 140 | overflow-wrap: break-word; | ||
| 141 | + | line-height: 1.2; | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | @media (max-width: 768px) { | |||
| 145 | + | .desktop-icons { | |||
| 146 | + | display: none; | |||
| 147 | + | } | |||
| 94 | } | 148 | } | ||
| 95 | </style> | 149 | </style> | ||
A src/lib/components/desktop/about-window.svelte +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import XpWindow from './xp-window.svelte'; | |||
| 3 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 4 | + | </script> | |||
| 5 | + | ||||
| 6 | + | <XpWindow id="about" width={360} height={260} resizableWindow={false}> | |||
| 7 | + | <div class="content"> | |||
| 8 | + | <img src="/aim/running-man.svg" alt="" width="48" height="48" /> | |||
| 9 | + | <h2>PDR AIM</h2> | |||
| 10 | + | <p>v0.3.0</p> | |||
| 11 | + | <p> | |||
| 12 | + | Un bac à sable associatif et sans but lucratif, pour retrouver l’époque des salons de | |||
| 13 | + | discussion AOL/AIM. | |||
| 14 | + | </p> | |||
| 15 | + | <p>Un petit coin de nostalgie pour les anciens habitués.</p> | |||
| 16 | + | <a href="https://pdraim.org" target="_blank" rel="noopener noreferrer">pdraim.org</a> | |||
| 17 | + | <div class="actions"><button onclick={() => desktop.close('about')}>OK</button></div> | |||
| 18 | + | </div> | |||
| 19 | + | </XpWindow> | |||
| 20 | + | ||||
| 21 | + | <style> | |||
| 22 | + | .content { | |||
| 23 | + | display: flex; | |||
| 24 | + | flex-direction: column; | |||
| 25 | + | align-items: center; | |||
| 26 | + | text-align: center; | |||
| 27 | + | gap: 4px; | |||
| 28 | + | height: 100%; | |||
| 29 | + | overflow: auto; | |||
| 30 | + | font: | |||
| 31 | + | 11px Tahoma, | |||
| 32 | + | 'Pixelated MS Sans Serif', | |||
| 33 | + | sans-serif; | |||
| 34 | + | } | |||
| 35 | + | h2, | |||
| 36 | + | p { | |||
| 37 | + | margin: 0; | |||
| 38 | + | } | |||
| 39 | + | h2 { | |||
| 40 | + | font-size: 18px; | |||
| 41 | + | } | |||
| 42 | + | .actions { | |||
| 43 | + | margin-top: auto; | |||
| 44 | + | align-self: flex-end; | |||
| 45 | + | } | |||
| 46 | + | </style> | |||
A src/lib/components/desktop/away-window.svelte +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { api } from '../../../convex/_generated/api'; | |||
| 4 | + | import XpWindow from './xp-window.svelte'; | |||
| 5 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 6 | + | import { authQuery, useAimClient } from '$lib/aim/client'; | |||
| 7 | + | ||||
| 8 | + | const presets = [ | |||
| 9 | + | 'Je suis parti(e) manger', | |||
| 10 | + | 'Au téléphone…', | |||
| 11 | + | 'Parti(e) faire un tour, je reviens', | |||
| 12 | + | "En cours, j'ai pas le droit d'être là 😅", | |||
| 13 | + | 'Je dors, laisse un message' | |||
| 14 | + | ]; | |||
| 15 | + | const me = authQuery(api.aim.getMe, () => ({})); | |||
| 16 | + | const client = useAimClient(); | |||
| 17 | + | let text = $state(''); | |||
| 18 | + | let custom = $state(''); | |||
| 19 | + | let selected = $state('custom'); | |||
| 20 | + | let pending = $state(false); | |||
| 21 | + | let error = $state(''); | |||
| 22 | + | let initialized = $state(false); | |||
| 23 | + | ||||
| 24 | + | onMount(() => { | |||
| 25 | + | custom = localStorage.getItem('pdraim-away-last') ?? ''; | |||
| 26 | + | }); | |||
| 27 | + | $effect(() => { | |||
| 28 | + | const data = me?.data; | |||
| 29 | + | if (!initialized && data) { | |||
| 30 | + | const current = data.awayMessage ?? ''; | |||
| 31 | + | text = current || custom; | |||
| 32 | + | selected = presets.includes(current) ? current : 'custom'; | |||
| 33 | + | initialized = true; | |||
| 34 | + | } | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | function selectPreset(event: Event) { | |||
| 38 | + | selected = (event.currentTarget as HTMLSelectElement).value; | |||
| 39 | + | text = selected === 'custom' ? custom : selected; | |||
| 40 | + | } | |||
| 41 | + | function editText() { | |||
| 42 | + | selected = 'custom'; | |||
| 43 | + | custom = text; | |||
| 44 | + | localStorage.setItem('pdraim-away-last', custom); | |||
| 45 | + | } | |||
| 46 | + | async function apply(value?: string) { | |||
| 47 | + | pending = true; | |||
| 48 | + | error = ''; | |||
| 49 | + | try { | |||
| 50 | + | await client.setAwayMessage(value); | |||
| 51 | + | if (value && selected === 'custom') localStorage.setItem('pdraim-away-last', value); | |||
| 52 | + | desktop.close('away'); | |||
| 53 | + | } catch (err) { | |||
| 54 | + | error = err instanceof Error ? err.message : 'Impossible de modifier le message.'; | |||
| 55 | + | } finally { | |||
| 56 | + | pending = false; | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | </script> | |||
| 60 | + | ||||
| 61 | + | <XpWindow id="away" width={360} height={300} resizableWindow={false}> | |||
| 62 | + | <div class="content"> | |||
| 63 | + | <label for="away-preset">Message d’absence</label> | |||
| 64 | + | <select id="away-preset" value={selected} onchange={selectPreset}> | |||
| 65 | + | {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each} | |||
| 66 | + | <option value="custom">Personnalisé…</option> | |||
| 67 | + | </select> | |||
| 68 | + | <label for="away-text">Votre message</label> | |||
| 69 | + | <textarea id="away-text" maxlength="300" bind:value={text} oninput={editText}></textarea> | |||
| 70 | + | <small>{text.length}/300</small> | |||
| 71 | + | {#if error}<span role="alert">{error}</span>{/if} | |||
| 72 | + | <div class="actions"> | |||
| 73 | + | <button disabled={pending || !text.trim()} onclick={() => apply(text)} | |||
| 74 | + | >Je suis absent(e)</button | |||
| 75 | + | > | |||
| 76 | + | <button disabled={pending} onclick={() => apply(undefined)}>Je suis de retour</button> | |||
| 77 | + | <button onclick={() => desktop.close('away')}>Annuler</button> | |||
| 78 | + | </div> | |||
| 79 | + | </div> | |||
| 80 | + | </XpWindow> | |||
| 81 | + | ||||
| 82 | + | <style> | |||
| 83 | + | .content { | |||
| 84 | + | display: flex; | |||
| 85 | + | flex-direction: column; | |||
| 86 | + | gap: 7px; | |||
| 87 | + | height: 100%; | |||
| 88 | + | font: | |||
| 89 | + | 11px Tahoma, | |||
| 90 | + | 'Pixelated MS Sans Serif', | |||
| 91 | + | sans-serif; | |||
| 92 | + | } | |||
| 93 | + | select, | |||
| 94 | + | textarea { | |||
| 95 | + | width: 100%; | |||
| 96 | + | box-sizing: border-box; | |||
| 97 | + | font: inherit; | |||
| 98 | + | } | |||
| 99 | + | textarea { | |||
| 100 | + | flex: 1; | |||
| 101 | + | min-height: 65px; | |||
| 102 | + | resize: none; | |||
| 103 | + | } | |||
| 104 | + | small { | |||
| 105 | + | align-self: flex-end; | |||
| 106 | + | } | |||
| 107 | + | .actions { | |||
| 108 | + | display: flex; | |||
| 109 | + | gap: 4px; | |||
| 110 | + | flex-wrap: wrap; | |||
| 111 | + | justify-content: flex-end; | |||
| 112 | + | margin-top: auto; | |||
| 113 | + | } | |||
| 114 | + | [role='alert'] { | |||
| 115 | + | color: #a00; | |||
| 116 | + | } | |||
| 117 | + | </style> | |||
A src/lib/components/desktop/buddy-list.svelte +706 -0
| @@ -0,0 +1,706 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { SvelteSet, SvelteMap } from 'svelte/reactivity'; | |||
| 4 | + | import { useQuery } from 'convex-svelte'; | |||
| 5 | + | import { api } from '../../../convex/_generated/api'; | |||
| 6 | + | import type { Id } from '../../../convex/_generated/dataModel'; | |||
| 7 | + | import type { Buddy, AimStatus } from '$lib/types/aim'; | |||
| 8 | + | import { DEFAULT_BUDDY_GROUPS, STATUS_LABELS_FR } from '$lib/types/aim'; | |||
| 9 | + | import { getBuddyIcon } from '$lib/aim/buddy-icons'; | |||
| 10 | + | import { playSound } from '$lib/aim/sounds'; | |||
| 11 | + | import { authQuery, useAimClient, convexAvailable } from '$lib/aim/client'; | |||
| 12 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 13 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 14 | + | import XpWindow from './xp-window.svelte'; | |||
| 15 | + | ||||
| 16 | + | const aim = useAimClient(); | |||
| 17 | + | const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({})); | |||
| 18 | + | const directQuery = authQuery(api.aim.getMyDirectRooms, () => ({})); | |||
| 19 | + | const meQuery = authQuery(api.aim.getMe, () => ({})); | |||
| 20 | + | const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null; | |||
| 21 | + | const usersQuery = convexAvailable ? useQuery(api.queries.getUsersPublic, {}) : null; | |||
| 22 | + | const me = $derived(chatState.getCurrentUser()); | |||
| 23 | + | const buddies = $derived(buddyQuery?.data ?? []); | |||
| 24 | + | const collapsed = new SvelteSet<string>(); | |||
| 25 | + | const flashing = new SvelteSet<string>(); | |||
| 26 | + | let selectedId = $state<Id<'users'> | null>(null); | |||
| 27 | + | let editMode = $state(false); | |||
| 28 | + | let panel = $state<'im' | 'add' | null>(null); | |||
| 29 | + | let nickname = $state(''); | |||
| 30 | + | let addId = $state(''); | |||
| 31 | + | let addGroup = $state<string>('Buddies'); | |||
| 32 | + | let customGroup = $state(''); | |||
| 33 | + | let error = $state(''); | |||
| 34 | + | let dropdownMenu = $state<'aim' | 'contacts' | 'help' | null>(null); | |||
| 35 | + | let context = $state<{ buddy: Buddy; x: number; y: number } | null>(null); | |||
| 36 | + | let menuBar: HTMLDivElement; | |||
| 37 | + | let contextElement = $state<HTMLDivElement>(); | |||
| 38 | + | let previous = new SvelteMap<string, AimStatus>(); | |||
| 39 | + | let initialized = false; | |||
| 40 | + | const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>(); | |||
| 41 | + | ||||
| 42 | + | const sortedGroups = $derived.by(() => { | |||
| 43 | + | const groups = new SvelteMap<string, Buddy[]>(); | |||
| 44 | + | for (const name of DEFAULT_BUDDY_GROUPS) groups.set(name, []); | |||
| 45 | + | for (const buddy of buddies) { | |||
| 46 | + | const name = buddy.group || 'Buddies'; | |||
| 47 | + | if (!groups.has(name)) groups.set(name, []); | |||
| 48 | + | if (buddy.status !== 'offline') groups.get(name)!.push(buddy); | |||
| 49 | + | } | |||
| 50 | + | const rank: Record<AimStatus, number> = { online: 0, away: 1, idle: 2, busy: 3, offline: 4 }; | |||
| 51 | + | return [...groups.entries()] | |||
| 52 | + | .sort(([a], [b]) => { | |||
| 53 | + | const ai = DEFAULT_BUDDY_GROUPS.indexOf(a as (typeof DEFAULT_BUDDY_GROUPS)[number]); | |||
| 54 | + | const bi = DEFAULT_BUDDY_GROUPS.indexOf(b as (typeof DEFAULT_BUDDY_GROUPS)[number]); | |||
| 55 | + | return ai !== -1 && bi !== -1 | |||
| 56 | + | ? ai - bi | |||
| 57 | + | : ai !== -1 | |||
| 58 | + | ? -1 | |||
| 59 | + | : bi !== -1 | |||
| 60 | + | ? 1 | |||
| 61 | + | : a.localeCompare(b); | |||
| 62 | + | }) | |||
| 63 | + | .map(([name, members]) => ({ | |||
| 64 | + | name, | |||
| 65 | + | members: members.sort( | |||
| 66 | + | (a, b) => rank[a.status] - rank[b.status] || a.nickname.localeCompare(b.nickname) | |||
| 67 | + | ), | |||
| 68 | + | total: buddies.filter((b) => (b.group || 'Buddies') === name).length | |||
| 69 | + | })); | |||
| 70 | + | }); | |||
| 71 | + | const offline = $derived( | |||
| 72 | + | buddies | |||
| 73 | + | .filter((b) => b.status === 'offline') | |||
| 74 | + | .sort((a, b) => a.nickname.localeCompare(b.nickname)) | |||
| 75 | + | ); | |||
| 76 | + | const availableUsers = $derived( | |||
| 77 | + | (usersQuery?.data ?? []) | |||
| 78 | + | .filter((u) => u.id !== me?.id && !buddies.some((b) => b.id === u.id)) | |||
| 79 | + | .sort((a, b) => a.nickname.localeCompare(b.nickname)) | |||
| 80 | + | ); | |||
| 81 | + | const selectedBuddy = $derived(buddies.find((b) => b.id === selectedId)); | |||
| 82 | + | ||||
| 83 | + | onMount(() => { | |||
| 84 | + | try { | |||
| 85 | + | const stored = JSON.parse(localStorage.getItem('pdraim-buddylist-collapsed') ?? '[]'); | |||
| 86 | + | if (Array.isArray(stored)) | |||
| 87 | + | for (const name of stored) if (typeof name === 'string') collapsed.add(name); | |||
| 88 | + | } catch { | |||
| 89 | + | /* Ignore malformed saved preferences. */ | |||
| 90 | + | } | |||
| 91 | + | return () => { | |||
| 92 | + | for (const timer of timers.values()) clearTimeout(timer); | |||
| 93 | + | }; | |||
| 94 | + | }); | |||
| 95 | + | ||||
| 96 | + | $effect(() => { | |||
| 97 | + | const data = buddyQuery?.data; | |||
| 98 | + | if (!me || !data) { | |||
| 99 | + | previous = new SvelteMap<string, AimStatus>(); | |||
| 100 | + | initialized = false; | |||
| 101 | + | return; | |||
| 102 | + | } | |||
| 103 | + | const next = new SvelteMap<string, AimStatus>(); | |||
| 104 | + | for (const buddy of data) { | |||
| 105 | + | next.set(buddy.id, buddy.status); | |||
| 106 | + | const old = previous.get(buddy.id); | |||
| 107 | + | if (!initialized || old === undefined || old === buddy.status) continue; | |||
| 108 | + | if (old === 'offline' && buddy.status !== 'offline') { | |||
| 109 | + | playSound('doorOpen'); | |||
| 110 | + | flashing.add(buddy.id); | |||
| 111 | + | const existing = timers.get(buddy.id); | |||
| 112 | + | if (existing) clearTimeout(existing); | |||
| 113 | + | timers.set( | |||
| 114 | + | buddy.id, | |||
| 115 | + | setTimeout(() => { | |||
| 116 | + | flashing.delete(buddy.id); | |||
| 117 | + | timers.delete(buddy.id); | |||
| 118 | + | }, 1500) | |||
| 119 | + | ); | |||
| 120 | + | } else if (old !== 'offline' && buddy.status === 'offline') playSound('doorSlam'); | |||
| 121 | + | } | |||
| 122 | + | previous = next; | |||
| 123 | + | initialized = true; | |||
| 124 | + | }); | |||
| 125 | + | ||||
| 126 | + | function toggleGroup(name: string) { | |||
| 127 | + | if (collapsed.has(name)) collapsed.delete(name); | |||
| 128 | + | else collapsed.add(name); | |||
| 129 | + | localStorage.setItem('pdraim-buddylist-collapsed', JSON.stringify([...collapsed])); | |||
| 130 | + | } | |||
| 131 | + | function openPanel(which: 'im' | 'add') { | |||
| 132 | + | panel = panel === which ? null : which; | |||
| 133 | + | error = ''; | |||
| 134 | + | dropdownMenu = null; | |||
| 135 | + | } | |||
| 136 | + | async function openIm(id: Id<'users'>, name: string) { | |||
| 137 | + | try { | |||
| 138 | + | const room = await aim.getOrCreateDirectRoom(id); | |||
| 139 | + | desktop.openIm({ roomId: room.id, otherUserId: id, otherNickname: name }); | |||
| 140 | + | panel = null; | |||
| 141 | + | } catch { | |||
| 142 | + | error = 'Impossible d’ouvrir la conversation.'; | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | async function startIm() { | |||
| 146 | + | const user = usersQuery?.data?.find( | |||
| 147 | + | (u) => u.nickname.toLocaleLowerCase() === nickname.trim().toLocaleLowerCase() | |||
| 148 | + | ); | |||
| 149 | + | if (!user) { | |||
| 150 | + | error = 'Pseudo introuvable.'; | |||
| 151 | + | return; | |||
| 152 | + | } | |||
| 153 | + | await openIm(user.id, user.nickname); | |||
| 154 | + | } | |||
| 155 | + | async function addBuddy() { | |||
| 156 | + | const user = availableUsers.find((u) => u.id === addId); | |||
| 157 | + | const group = addGroup === 'Autre…' ? customGroup.trim() : addGroup; | |||
| 158 | + | if (!user || !group) { | |||
| 159 | + | error = 'Choisissez un contact et un groupe.'; | |||
| 160 | + | return; | |||
| 161 | + | } | |||
| 162 | + | try { | |||
| 163 | + | await aim.addBuddy(user.id, group); | |||
| 164 | + | panel = null; | |||
| 165 | + | addId = ''; | |||
| 166 | + | error = ''; | |||
| 167 | + | } catch { | |||
| 168 | + | error = 'Impossible d’ajouter ce contact.'; | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | async function removeBuddy(id: Id<'users'>) { | |||
| 172 | + | context = null; | |||
| 173 | + | try { | |||
| 174 | + | await aim.removeBuddy(id); | |||
| 175 | + | if (selectedId === id) selectedId = null; | |||
| 176 | + | } catch { | |||
| 177 | + | error = 'Impossible de retirer ce contact.'; | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | function showContext(event: MouseEvent, buddy: Buddy) { | |||
| 181 | + | event.preventDefault(); | |||
| 182 | + | selectedId = buddy.id; | |||
| 183 | + | dropdownMenu = null; | |||
| 184 | + | context = { | |||
| 185 | + | buddy, | |||
| 186 | + | x: Math.min(event.clientX, window.innerWidth - 190), | |||
| 187 | + | y: Math.min(event.clientY, window.innerHeight - 95) | |||
| 188 | + | }; | |||
| 189 | + | } | |||
| 190 | + | function outside(event: MouseEvent) { | |||
| 191 | + | const target = event.target as Node; | |||
| 192 | + | if (dropdownMenu && !menuBar?.contains(target)) dropdownMenu = null; | |||
| 193 | + | if (context && !contextElement?.contains(target)) context = null; | |||
| 194 | + | } | |||
| 195 | + | function escape(event: KeyboardEvent) { | |||
| 196 | + | if (event.key === 'Escape') { | |||
| 197 | + | dropdownMenu = null; | |||
| 198 | + | context = null; | |||
| 199 | + | panel = null; | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | async function logout() { | |||
| 203 | + | dropdownMenu = null; | |||
| 204 | + | try { | |||
| 205 | + | await fetch('/api/session/logout', { method: 'POST' }); | |||
| 206 | + | window.location.reload(); | |||
| 207 | + | } catch { | |||
| 208 | + | error = 'Déconnexion impossible.'; | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | function tooltip(buddy: Buddy) { | |||
| 212 | + | return `${STATUS_LABELS_FR[buddy.status]}${buddy.status === 'offline' && buddy.lastSeen ? ` — Dernière visite : ${new Date(buddy.lastSeen).toLocaleString('fr-FR')}` : ''}`; | |||
| 213 | + | } | |||
| 214 | + | </script> | |||
| 215 | + | ||||
| 216 | + | <svelte:window onclick={outside} onkeydown={escape} /> | |||
| 217 | + | ||||
| 218 | + | {#snippet buddyRow(buddy: Buddy)} | |||
| 219 | + | <div class="row" class:selected={selectedId === buddy.id} class:flash={flashing.has(buddy.id)}> | |||
| 220 | + | <button | |||
| 221 | + | class="row-action" | |||
| 222 | + | class:offline={buddy.status === 'offline'} | |||
| 223 | + | class:away={buddy.status === 'away'} | |||
| 224 | + | title={tooltip(buddy)} | |||
| 225 | + | onclick={() => (selectedId = buddy.id)} | |||
| 226 | + | ondblclick={() => openIm(buddy.id, buddy.nickname)} | |||
| 227 | + | oncontextmenu={(e) => showContext(e, buddy)} | |||
| 228 | + | > | |||
| 229 | + | <img src={`/aim/status-${buddy.status}.svg`} alt="" width="12" height="12" /> | |||
| 230 | + | <span class="truncate">{buddy.nickname}{buddy.status === 'idle' ? ' (Inactif)' : ''}</span> | |||
| 231 | + | {#if buddy.status === 'away'}<img src="/aim/away.svg" alt="" width="12" height="12" />{/if} | |||
| 232 | + | </button> | |||
| 233 | + | {#if editMode}<button | |||
| 234 | + | class="remove" | |||
| 235 | + | aria-label={`Retirer ${buddy.nickname}`} | |||
| 236 | + | title={`Retirer ${buddy.nickname}`} | |||
| 237 | + | onclick={() => removeBuddy(buddy.id)}>✕</button | |||
| 238 | + | >{/if} | |||
| 239 | + | </div> | |||
| 240 | + | {/snippet} | |||
| 241 | + | ||||
| 242 | + | <XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}> | |||
| 243 | + | {#snippet menu()} | |||
| 244 | + | <div class="menus" bind:this={menuBar}> | |||
| 245 | + | <div class="menu-wrap"> | |||
| 246 | + | <button | |||
| 247 | + | aria-expanded={dropdownMenu === 'aim'} | |||
| 248 | + | onclick={() => (dropdownMenu = dropdownMenu === 'aim' ? null : 'aim')}>Mon AIM</button | |||
| 249 | + | > | |||
| 250 | + | {#if dropdownMenu === 'aim'}<div class="dropdown"> | |||
| 251 | + | <button | |||
| 252 | + | onclick={() => { | |||
| 253 | + | dropdownMenu = null; | |||
| 254 | + | desktop.openAway(); | |||
| 255 | + | }}>Message d’absence…</button | |||
| 256 | + | > | |||
| 257 | + | <button | |||
| 258 | + | disabled={!me} | |||
| 259 | + | onclick={() => { | |||
| 260 | + | dropdownMenu = null; | |||
| 261 | + | if (me) | |||
| 262 | + | desktop.openProfile({ userId: me.id as Id<'users'>, nickname: me.nickname }); | |||
| 263 | + | }}>Modifier mon profil…</button | |||
| 264 | + | > | |||
| 265 | + | <button | |||
| 266 | + | onclick={() => { | |||
| 267 | + | dropdownMenu = null; | |||
| 268 | + | desktop.openPreferences(); | |||
| 269 | + | }}>Préférences…</button | |||
| 270 | + | > | |||
| 271 | + | <hr /> | |||
| 272 | + | <button onclick={logout}>Se déconnecter</button> | |||
| 273 | + | </div>{/if} | |||
| 274 | + | </div> | |||
| 275 | + | <div class="menu-wrap"> | |||
| 276 | + | <button | |||
| 277 | + | aria-expanded={dropdownMenu === 'contacts'} | |||
| 278 | + | onclick={() => (dropdownMenu = dropdownMenu === 'contacts' ? null : 'contacts')} | |||
| 279 | + | >Contacts</button | |||
| 280 | + | > | |||
| 281 | + | {#if dropdownMenu === 'contacts'}<div class="dropdown"> | |||
| 282 | + | <button onclick={() => openPanel('add')}>Ajouter un contact…</button><button | |||
| 283 | + | onclick={() => { | |||
| 284 | + | dropdownMenu = null; | |||
| 285 | + | desktop.openNewRoom(); | |||
| 286 | + | }}>Nouveau salon…</button | |||
| 287 | + | > | |||
| 288 | + | </div>{/if} | |||
| 289 | + | </div> | |||
| 290 | + | <div class="menu-wrap"> | |||
| 291 | + | <button | |||
| 292 | + | aria-expanded={dropdownMenu === 'help'} | |||
| 293 | + | onclick={() => (dropdownMenu = dropdownMenu === 'help' ? null : 'help')}>Aide</button | |||
| 294 | + | > | |||
| 295 | + | {#if dropdownMenu === 'help'}<div class="dropdown"> | |||
| 296 | + | <button | |||
| 297 | + | onclick={() => { | |||
| 298 | + | dropdownMenu = null; | |||
| 299 | + | desktop.openAbout(); | |||
| 300 | + | }}>À propos de PDR AIM</button | |||
| 301 | + | > | |||
| 302 | + | </div>{/if} | |||
| 303 | + | </div> | |||
| 304 | + | </div> | |||
| 305 | + | {/snippet} | |||
| 306 | + | <div class="buddy-content"> | |||
| 307 | + | <header class="identity"> | |||
| 308 | + | {#if me} | |||
| 309 | + | <div class="icon" style:background={getBuddyIcon(meQuery?.data?.buddyIcon).bg}> | |||
| 310 | + | {getBuddyIcon(meQuery?.data?.buddyIcon).emoji} | |||
| 311 | + | </div> | |||
| 312 | + | <div class="identity-text"> | |||
| 313 | + | <strong | |||
| 314 | + | ><img src="/aim/running-man.svg" alt="" width="16" height="16" /> {me.nickname}</strong | |||
| 315 | + | ><span title={meQuery?.data?.awayMessage ?? ''} | |||
| 316 | + | >{meQuery?.data?.status === 'away' | |||
| 317 | + | ? `Absent${meQuery.data.awayMessage ? ` : ${meQuery.data.awayMessage}` : ''}` | |||
| 318 | + | : (STATUS_LABELS_FR[(meQuery?.data?.status ?? me.status) as AimStatus] ?? | |||
| 319 | + | 'En ligne')}</span | |||
| 320 | + | > | |||
| 321 | + | </div> | |||
| 322 | + | {:else} | |||
| 323 | + | <div> | |||
| 324 | + | <button onclick={() => desktop.openLogin('signin')}>Se connecter</button> | |||
| 325 | + | <p>Connectez-vous pour voir vos contacts.</p> | |||
| 326 | + | </div> | |||
| 327 | + | {/if} | |||
| 328 | + | </header> | |||
| 329 | + | <div class="tabs"> | |||
| 330 | + | <button class:active={!editMode} aria-pressed={!editMode} onclick={() => (editMode = false)} | |||
| 331 | + | >Online</button | |||
| 332 | + | ><button class:active={editMode} aria-pressed={editMode} onclick={() => (editMode = true)} | |||
| 333 | + | >List Setup</button | |||
| 334 | + | > | |||
| 335 | + | </div> | |||
| 336 | + | <div class="sunken-panel tree"> | |||
| 337 | + | {#if me} | |||
| 338 | + | {#each sortedGroups as group (group.name)} | |||
| 339 | + | <button | |||
| 340 | + | class="group" | |||
| 341 | + | aria-expanded={!collapsed.has(group.name)} | |||
| 342 | + | onclick={() => toggleGroup(group.name)} | |||
| 343 | + | >{collapsed.has(group.name) ? '▶' : '▼'} | |||
| 344 | + | {group.name} ({group.members.filter((buddy) => buddy.status === 'online') | |||
| 345 | + | .length}/{group.total})</button | |||
| 346 | + | > | |||
| 347 | + | {#if !collapsed.has(group.name)}{#each group.members as buddy (buddy.id)}{@render buddyRow( | |||
| 348 | + | buddy | |||
| 349 | + | )}{/each}{/if} | |||
| 350 | + | {/each} | |||
| 351 | + | <button | |||
| 352 | + | class="group" | |||
| 353 | + | aria-expanded={!collapsed.has('Offline')} | |||
| 354 | + | onclick={() => toggleGroup('Offline')} | |||
| 355 | + | >{collapsed.has('Offline') ? '▶' : '▼'} Offline ({offline.length})</button | |||
| 356 | + | > | |||
| 357 | + | {#if !collapsed.has('Offline')}{#each offline as buddy (buddy.id)}{@render buddyRow( | |||
| 358 | + | buddy | |||
| 359 | + | )}{/each}{/if} | |||
| 360 | + | <button | |||
| 361 | + | class="group" | |||
| 362 | + | aria-expanded={!collapsed.has('Salons')} | |||
| 363 | + | onclick={() => toggleGroup('Salons')} | |||
| 364 | + | >{collapsed.has('Salons') ? '▶' : '▼'} Salons ({roomsQuery?.data?.length ?? 0})</button | |||
| 365 | + | > | |||
| 366 | + | {#if !collapsed.has('Salons')}{#each [...(roomsQuery?.data ?? [])].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) as room (room.id)}<button | |||
| 367 | + | class="list-entry" | |||
| 368 | + | onclick={() => desktop.openChatRoom(room.id, room.name)} | |||
| 369 | + | ><img src="/aim/chat-room.svg" alt="" width="12" height="12" /> | |||
| 370 | + | <span class="truncate">{room.name}</span></button | |||
| 371 | + | >{/each}{/if} | |||
| 372 | + | <button | |||
| 373 | + | class="group" | |||
| 374 | + | aria-expanded={!collapsed.has('Conversations')} | |||
| 375 | + | onclick={() => toggleGroup('Conversations')} | |||
| 376 | + | >{collapsed.has('Conversations') ? '▶' : '▼'} Conversations ({directQuery?.data?.length ?? | |||
| 377 | + | 0})</button | |||
| 378 | + | > | |||
| 379 | + | {#if !collapsed.has('Conversations')}{#each directQuery?.data ?? [] as room (room.id)}{#if room.other}<button | |||
| 380 | + | class="list-entry" | |||
| 381 | + | class:unread={room.unreadCount > 0} | |||
| 382 | + | onclick={() => | |||
| 383 | + | desktop.openIm({ | |||
| 384 | + | roomId: room.id, | |||
| 385 | + | otherUserId: room.other!.id, | |||
| 386 | + | otherNickname: room.other!.nickname | |||
| 387 | + | })} | |||
| 388 | + | ><img src="/aim/im.svg" alt="" width="12" height="12" /> | |||
| 389 | + | <span class="truncate" | |||
| 390 | + | >{room.other.nickname}{room.unreadCount ? ` (${room.unreadCount})` : ''}</span | |||
| 391 | + | ></button | |||
| 392 | + | >{/if}{/each}{/if} | |||
| 393 | + | {/if} | |||
| 394 | + | </div> | |||
| 395 | + | {#if me && panel === 'im'}<form | |||
| 396 | + | class="inline-panel" | |||
| 397 | + | onsubmit={(e) => { | |||
| 398 | + | e.preventDefault(); | |||
| 399 | + | void startIm(); | |||
| 400 | + | }} | |||
| 401 | + | > | |||
| 402 | + | <label for="im-nick">Pseudo du contact</label><input | |||
| 403 | + | id="im-nick" | |||
| 404 | + | bind:value={nickname} | |||
| 405 | + | autocomplete="off" | |||
| 406 | + | /><button type="submit">Ouvrir IM</button> | |||
| 407 | + | </form>{/if} | |||
| 408 | + | {#if me && panel === 'add'}<form | |||
| 409 | + | class="inline-panel" | |||
| 410 | + | onsubmit={(e) => { | |||
| 411 | + | e.preventDefault(); | |||
| 412 | + | void addBuddy(); | |||
| 413 | + | }} | |||
| 414 | + | > | |||
| 415 | + | <label for="add-user">Contact</label><select id="add-user" bind:value={addId} | |||
| 416 | + | ><option value="">Choisir…</option>{#each availableUsers as user (user.id)}<option | |||
| 417 | + | value={user.id}>{user.nickname}</option | |||
| 418 | + | >{/each}</select | |||
| 419 | + | ><label for="add-group">Groupe</label><select id="add-group" bind:value={addGroup} | |||
| 420 | + | >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option | |||
| 421 | + | >{/each}<option value="Autre…">Autre…</option></select | |||
| 422 | + | >{#if addGroup === 'Autre…'}<input | |||
| 423 | + | aria-label="Nom du groupe" | |||
| 424 | + | bind:value={customGroup} | |||
| 425 | + | />{/if}<button type="submit">Ajouter</button> | |||
| 426 | + | </form>{/if} | |||
| 427 | + | {#if error}<p class="error" role="alert">{error}</p>{/if} | |||
| 428 | + | <div class="toolbar"> | |||
| 429 | + | <button disabled={!me} onclick={() => openPanel('im')} | |||
| 430 | + | ><img src="/aim/im.svg" alt="" />IM</button | |||
| 431 | + | > | |||
| 432 | + | <button disabled={!me} onclick={() => desktop.openNewRoom()} | |||
| 433 | + | ><img src="/aim/chat-room.svg" alt="" />Chat</button | |||
| 434 | + | > | |||
| 435 | + | <button | |||
| 436 | + | disabled={!selectedBuddy} | |||
| 437 | + | onclick={() => { | |||
| 438 | + | if (selectedBuddy) | |||
| 439 | + | desktop.openProfile({ userId: selectedBuddy.id, nickname: selectedBuddy.nickname }); | |||
| 440 | + | }}><img src="/aim/info.svg" alt="" />Infos</button | |||
| 441 | + | > | |||
| 442 | + | <button disabled={!me} onclick={() => desktop.openAway()} | |||
| 443 | + | ><img src="/aim/away.svg" alt="" />Absent</button | |||
| 444 | + | > | |||
| 445 | + | <button disabled={!me} onclick={() => openPanel('add')} | |||
| 446 | + | ><img src="/aim/door-open.svg" alt="" />Ajouter</button | |||
| 447 | + | > | |||
| 448 | + | </div> | |||
| 449 | + | </div> | |||
| 450 | + | </XpWindow> | |||
| 451 | + | ||||
| 452 | + | {#if context}<div | |||
| 453 | + | class="context dropdown" | |||
| 454 | + | bind:this={contextElement} | |||
| 455 | + | style:left={`${Math.max(0, context.x)}px`} | |||
| 456 | + | style:top={`${Math.max(0, context.y)}px`} | |||
| 457 | + | > | |||
| 458 | + | <button | |||
| 459 | + | onclick={() => { | |||
| 460 | + | if (context) void openIm(context.buddy.id, context.buddy.nickname); | |||
| 461 | + | context = null; | |||
| 462 | + | }}>Envoyer un message instantané</button | |||
| 463 | + | > | |||
| 464 | + | <button | |||
| 465 | + | onclick={() => { | |||
| 466 | + | if (context) | |||
| 467 | + | desktop.openProfile({ userId: context.buddy.id, nickname: context.buddy.nickname }); | |||
| 468 | + | context = null; | |||
| 469 | + | }}>Infos sur {context.buddy.nickname}</button | |||
| 470 | + | > | |||
| 471 | + | <button | |||
| 472 | + | onclick={() => { | |||
| 473 | + | if (context) void removeBuddy(context.buddy.id); | |||
| 474 | + | }}>Retirer de la liste</button | |||
| 475 | + | > | |||
| 476 | + | </div>{/if} | |||
| 477 | + | ||||
| 478 | + | <style> | |||
| 479 | + | .buddy-content { | |||
| 480 | + | display: flex; | |||
| 481 | + | flex-direction: column; | |||
| 482 | + | flex: 1; | |||
| 483 | + | min-height: 0; | |||
| 484 | + | font: | |||
| 485 | + | 11px Tahoma, | |||
| 486 | + | 'Pixelated MS Sans Serif', | |||
| 487 | + | sans-serif; | |||
| 488 | + | } | |||
| 489 | + | .identity { | |||
| 490 | + | display: flex; | |||
| 491 | + | align-items: center; | |||
| 492 | + | gap: 6px; | |||
| 493 | + | min-height: 49px; | |||
| 494 | + | padding: 4px; | |||
| 495 | + | background: linear-gradient(#ffe880, #f6c700); | |||
| 496 | + | border-bottom: 1px solid #c59d00; | |||
| 497 | + | } | |||
| 498 | + | .identity p { | |||
| 499 | + | margin: 3px 0 0; | |||
| 500 | + | } | |||
| 501 | + | .icon { | |||
| 502 | + | display: grid; | |||
| 503 | + | place-items: center; | |||
| 504 | + | width: 32px; | |||
| 505 | + | height: 32px; | |||
| 506 | + | flex: none; | |||
| 507 | + | border: 1px solid #9c8700; | |||
| 508 | + | font-size: 22px; | |||
| 509 | + | } | |||
| 510 | + | .identity-text { | |||
| 511 | + | min-width: 0; | |||
| 512 | + | display: flex; | |||
| 513 | + | flex-direction: column; | |||
| 514 | + | gap: 3px; | |||
| 515 | + | } | |||
| 516 | + | .identity-text strong { | |||
| 517 | + | display: flex; | |||
| 518 | + | align-items: center; | |||
| 519 | + | white-space: nowrap; | |||
| 520 | + | overflow: hidden; | |||
| 521 | + | text-overflow: ellipsis; | |||
| 522 | + | } | |||
| 523 | + | .identity-text span, | |||
| 524 | + | .truncate { | |||
| 525 | + | overflow: hidden; | |||
| 526 | + | text-overflow: ellipsis; | |||
| 527 | + | white-space: nowrap; | |||
| 528 | + | } | |||
| 529 | + | .tabs { | |||
| 530 | + | display: flex; | |||
| 531 | + | padding-top: 4px; | |||
| 532 | + | } | |||
| 533 | + | .tabs button { | |||
| 534 | + | border: 1px solid #aaa; | |||
| 535 | + | border-bottom: 0; | |||
| 536 | + | border-radius: 4px 4px 0 0; | |||
| 537 | + | padding: 3px 9px; | |||
| 538 | + | min-width: 0; | |||
| 539 | + | font-size: 11px; | |||
| 540 | + | background: #dedbd0; | |||
| 541 | + | } | |||
| 542 | + | .tabs button.active { | |||
| 543 | + | background: #fff; | |||
| 544 | + | font-weight: bold; | |||
| 545 | + | } | |||
| 546 | + | .tree { | |||
| 547 | + | flex: 1; | |||
| 548 | + | min-height: 0; | |||
| 549 | + | overflow: auto; | |||
| 550 | + | background: #fff; | |||
| 551 | + | padding: 2px; | |||
| 552 | + | } | |||
| 553 | + | .group, | |||
| 554 | + | .list-entry, | |||
| 555 | + | .row-action { | |||
| 556 | + | display: flex; | |||
| 557 | + | align-items: center; | |||
| 558 | + | width: 100%; | |||
| 559 | + | min-width: 0; | |||
| 560 | + | text-align: left; | |||
| 561 | + | border: 0; | |||
| 562 | + | border-radius: 0; | |||
| 563 | + | box-shadow: none; | |||
| 564 | + | font: inherit; | |||
| 565 | + | cursor: default; | |||
| 566 | + | } | |||
| 567 | + | .group { | |||
| 568 | + | background: #ece9d8; | |||
| 569 | + | font-weight: bold; | |||
| 570 | + | padding: 3px 2px; | |||
| 571 | + | margin-top: 2px; | |||
| 572 | + | } | |||
| 573 | + | .list-entry, | |||
| 574 | + | .row-action { | |||
| 575 | + | background: transparent; | |||
| 576 | + | padding: 2px 2px 2px 12px; | |||
| 577 | + | gap: 4px; | |||
| 578 | + | } | |||
| 579 | + | .list-entry:hover, | |||
| 580 | + | .row-action:hover, | |||
| 581 | + | .row.selected .row-action { | |||
| 582 | + | background: #316ac5; | |||
| 583 | + | color: #fff; | |||
| 584 | + | } | |||
| 585 | + | .row { | |||
| 586 | + | display: flex; | |||
| 587 | + | align-items: center; | |||
| 588 | + | } | |||
| 589 | + | .row-action { | |||
| 590 | + | flex: 1; | |||
| 591 | + | } | |||
| 592 | + | .row-action.offline, | |||
| 593 | + | .row-action.away { | |||
| 594 | + | color: #777; | |||
| 595 | + | font-style: italic; | |||
| 596 | + | } | |||
| 597 | + | .row-action:hover, | |||
| 598 | + | .row.selected .row-action { | |||
| 599 | + | color: #fff; | |||
| 600 | + | } | |||
| 601 | + | .row-action img, | |||
| 602 | + | .list-entry img { | |||
| 603 | + | flex: none; | |||
| 604 | + | } | |||
| 605 | + | .remove { | |||
| 606 | + | min-width: 17px; | |||
| 607 | + | padding: 0; | |||
| 608 | + | border: 0; | |||
| 609 | + | box-shadow: none; | |||
| 610 | + | color: #900; | |||
| 611 | + | } | |||
| 612 | + | .unread { | |||
| 613 | + | font-weight: bold; | |||
| 614 | + | } | |||
| 615 | + | .inline-panel { | |||
| 616 | + | display: flex; | |||
| 617 | + | flex-direction: column; | |||
| 618 | + | gap: 3px; | |||
| 619 | + | padding: 4px; | |||
| 620 | + | background: #ece9d8; | |||
| 621 | + | } | |||
| 622 | + | .inline-panel input, | |||
| 623 | + | .inline-panel select { | |||
| 624 | + | width: 100%; | |||
| 625 | + | box-sizing: border-box; | |||
| 626 | + | } | |||
| 627 | + | .error { | |||
| 628 | + | color: #a00; | |||
| 629 | + | margin: 2px; | |||
| 630 | + | } | |||
| 631 | + | .toolbar { | |||
| 632 | + | display: flex; | |||
| 633 | + | justify-content: space-between; | |||
| 634 | + | border-top: 1px solid #aca899; | |||
| 635 | + | padding-top: 3px; | |||
| 636 | + | } | |||
| 637 | + | .toolbar button { | |||
| 638 | + | display: flex; | |||
| 639 | + | align-items: center; | |||
| 640 | + | flex-direction: column; | |||
| 641 | + | gap: 2px; | |||
| 642 | + | min-width: 0; | |||
| 643 | + | padding: 2px; | |||
| 644 | + | font: inherit; | |||
| 645 | + | } | |||
| 646 | + | .toolbar img { | |||
| 647 | + | width: 20px; | |||
| 648 | + | height: 20px; | |||
| 649 | + | } | |||
| 650 | + | .menus { | |||
| 651 | + | display: flex; | |||
| 652 | + | } | |||
| 653 | + | .menu-wrap { | |||
| 654 | + | position: relative; | |||
| 655 | + | } | |||
| 656 | + | .dropdown { | |||
| 657 | + | position: absolute; | |||
| 658 | + | z-index: 10000; | |||
| 659 | + | top: 100%; | |||
| 660 | + | left: 0; | |||
| 661 | + | min-width: 170px; | |||
| 662 | + | padding: 2px; | |||
| 663 | + | background: #fff; | |||
| 664 | + | border: 1px solid #aca899; | |||
| 665 | + | box-shadow: 2px 2px 4px #888; | |||
| 666 | + | } | |||
| 667 | + | .dropdown button { | |||
| 668 | + | display: block; | |||
| 669 | + | width: 100%; | |||
| 670 | + | min-width: 0; | |||
| 671 | + | padding: 4px 8px; | |||
| 672 | + | text-align: left; | |||
| 673 | + | border: 0; | |||
| 674 | + | box-shadow: none; | |||
| 675 | + | background: #fff; | |||
| 676 | + | color: #000; | |||
| 677 | + | white-space: nowrap; | |||
| 678 | + | font: | |||
| 679 | + | 11px Tahoma, | |||
| 680 | + | sans-serif; | |||
| 681 | + | } | |||
| 682 | + | .dropdown button:hover:not(:disabled) { | |||
| 683 | + | background: #316ac5; | |||
| 684 | + | color: #fff; | |||
| 685 | + | } | |||
| 686 | + | .dropdown hr { | |||
| 687 | + | margin: 2px 4px; | |||
| 688 | + | } | |||
| 689 | + | .context { | |||
| 690 | + | position: fixed; | |||
| 691 | + | top: auto; | |||
| 692 | + | min-width: 185px; | |||
| 693 | + | } | |||
| 694 | + | @keyframes sign-on { | |||
| 695 | + | 0%, | |||
| 696 | + | 75% { | |||
| 697 | + | background: #fff199; | |||
| 698 | + | } | |||
| 699 | + | 100% { | |||
| 700 | + | background: transparent; | |||
| 701 | + | } | |||
| 702 | + | } | |||
| 703 | + | .row.flash { | |||
| 704 | + | animation: sign-on 1.5s ease-out; | |||
| 705 | + | } | |||
| 706 | + | </style> | |||
A src/lib/components/desktop/im-window.svelte +484 -0
| @@ -0,0 +1,484 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount, tick } from 'svelte'; | |||
| 3 | + | import { useQuery } from 'convex-svelte'; | |||
| 4 | + | import { api as convexApi } from '../../../convex/_generated/api'; | |||
| 5 | + | import type { Id } from '../../../convex/_generated/dataModel'; | |||
| 6 | + | import type { EnrichedMessage, SafeUser } from '$lib/types/chat'; | |||
| 7 | + | import { authQuery, useAimClient } from '$lib/aim/client'; | |||
| 8 | + | import { playSound } from '$lib/aim/sounds'; | |||
| 9 | + | import { getBuddyIcon } from '$lib/aim/buddy-icons'; | |||
| 10 | + | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | |||
| 11 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 12 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 13 | + | import { api } from '$lib/api/client'; | |||
| 14 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |||
| 15 | + | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |||
| 16 | + | import { debounce } from '$lib/utils/chat-window-state'; | |||
| 17 | + | import { formatFrenchTime } from '$lib/utils/date-format'; | |||
| 18 | + | import XpWindow from './xp-window.svelte'; | |||
| 19 | + | import FormattedMessage from '../formatted-message.svelte'; | |||
| 20 | + | import TextFormattingToolbar from '../text-formatting-toolbar.svelte'; | |||
| 21 | + | ||||
| 22 | + | let { roomId, otherUserId, otherNickname } = $props<{ | |||
| 23 | + | roomId: Id<'chatRooms'>; | |||
| 24 | + | otherUserId: Id<'users'>; | |||
| 25 | + | otherNickname: string; | |||
| 26 | + | }>(); | |||
| 27 | + | const id = $derived(`im-${roomId}`); | |||
| 28 | + | const aim = useAimClient(); | |||
| 29 | + | const messagesQuery = authQuery(convexApi.aim.getRoomMessages, () => ({ roomId })); | |||
| 30 | + | const typingQuery = authQuery(convexApi.aim.getRoomTyping, () => ({ roomId })); | |||
| 31 | + | const profileQuery = useQuery(convexApi.aim.getUserProfile, () => ({ userId: otherUserId })); | |||
| 32 | + | const currentUser = $derived(chatState.getCurrentUser()); | |||
| 33 | + | const profile = $derived(profileQuery.data); | |||
| 34 | + | const buddyIcon = $derived(getBuddyIcon(profile?.buddyIcon)); | |||
| 35 | + | const status = $derived((profile?.status ?? 'offline') as AimStatus); | |||
| 36 | + | const messages = $derived.by<EnrichedMessage[]>(() => | |||
| 37 | + | (messagesQuery?.data ?? []).map((msg) => ({ | |||
| 38 | + | id: msg.id, | |||
| 39 | + | chatRoomId: msg.chatRoomId, | |||
| 40 | + | senderId: msg.senderId, | |||
| 41 | + | content: msg.content, | |||
| 42 | + | type: msg.type, | |||
| 43 | + | timestamp: msg.timestamp, | |||
| 44 | + | styleData: msg.styleData, | |||
| 45 | + | hasFormatting: msg.hasFormatting ?? false, | |||
| 46 | + | user: { | |||
| 47 | + | id: msg.sender?.id ?? msg.senderId, | |||
| 48 | + | nickname: msg.sender?.nickname ?? otherNickname, | |||
| 49 | + | status: (msg.sender?.status ?? 'offline') as SafeUser['status'], | |||
| 50 | + | avatarUrl: msg.sender?.avatarUrl ?? null, | |||
| 51 | + | lastSeen: null | |||
| 52 | + | } | |||
| 53 | + | })) | |||
| 54 | + | ); | |||
| 55 | + | let menuOpen = $state<'file' | 'contact' | null>(null); | |||
| 56 | + | let draft = $state(''); | |||
| 57 | + | let textStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |||
| 58 | + | let styleReady = $state(false); | |||
| 59 | + | let loadedStyle = $state(''); | |||
| 60 | + | let sending = $state(false); | |||
| 61 | + | let error = $state(''); | |||
| 62 | + | let cooldownSeconds = $state(0); | |||
| 63 | + | let cooldownEnd = 0; | |||
| 64 | + | let cooldownTimer: ReturnType<typeof setInterval> | undefined; | |||
| 65 | + | let typingTimer: ReturnType<typeof setTimeout> | undefined; | |||
| 66 | + | let readTimer: ReturnType<typeof setTimeout> | undefined; | |||
| 67 | + | let lastTypingAt = 0; | |||
| 68 | + | let conversation: HTMLDivElement; | |||
| 69 | + | let firstScroll = true; | |||
| 70 | + | let previousLast: string | null = null; | |||
| 71 | + | let previousRoom: string | null = null; | |||
| 72 | + | const disabled = $derived(!currentUser || sending || cooldownSeconds > 0); | |||
| 73 | + | ||||
| 74 | + | const saveStyle = debounce((style: TextStyle) => { | |||
| 75 | + | void api.textPreferences.save({ | |||
| 76 | + | defaultStyle: style, | |||
| 77 | + | allowFormatting: true, | |||
| 78 | + | maxMessageLength: MAX_MESSAGE_LENGTH | |||
| 79 | + | }); | |||
| 80 | + | }, 1500); | |||
| 81 | + | ||||
| 82 | + | // Load once per signed-in identity; do not persist the fetched default as an edit. | |||
| 83 | + | $effect(() => { | |||
| 84 | + | const userId = currentUser?.id; | |||
| 85 | + | styleReady = false; | |||
| 86 | + | if (!userId) return; | |||
| 87 | + | let active = true; | |||
| 88 | + | void api.textPreferences | |||
| 89 | + | .get() | |||
| 90 | + | .then((prefs) => { | |||
| 91 | + | if (!active) return; | |||
| 92 | + | textStyle = { ...DEFAULT_TEXT_STYLE, ...prefs?.defaultStyle }; | |||
| 93 | + | loadedStyle = JSON.stringify(textStyle); | |||
| 94 | + | styleReady = true; | |||
| 95 | + | }) | |||
| 96 | + | .catch(() => { | |||
| 97 | + | if (!active) return; | |||
| 98 | + | loadedStyle = JSON.stringify(textStyle); | |||
| 99 | + | styleReady = true; | |||
| 100 | + | }); | |||
| 101 | + | return () => { | |||
| 102 | + | active = false; | |||
| 103 | + | }; | |||
| 104 | + | }); | |||
| 105 | + | $effect(() => { | |||
| 106 | + | const style = JSON.stringify(textStyle); | |||
| 107 | + | if (styleReady && currentUser && style !== loadedStyle) { | |||
| 108 | + | loadedStyle = style; | |||
| 109 | + | saveStyle(JSON.parse(style) as TextStyle); | |||
| 110 | + | } | |||
| 111 | + | }); | |||
| 112 | + | ||||
| 113 | + | function stopTyping() { | |||
| 114 | + | if (typingTimer) clearTimeout(typingTimer); | |||
| 115 | + | typingTimer = undefined; | |||
| 116 | + | if (lastTypingAt) { | |||
| 117 | + | lastTypingAt = 0; | |||
| 118 | + | void aim.setTyping(roomId, false).catch(() => {}); | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | function onInput() { | |||
| 122 | + | if (!currentUser) return; | |||
| 123 | + | if (!draft.trim()) { | |||
| 124 | + | stopTyping(); | |||
| 125 | + | return; | |||
| 126 | + | } | |||
| 127 | + | const now = Date.now(); | |||
| 128 | + | if (draft.trim() && now - lastTypingAt >= 3000) { | |||
| 129 | + | lastTypingAt = now; | |||
| 130 | + | void aim.setTyping(roomId, true).catch(() => {}); | |||
| 131 | + | } | |||
| 132 | + | if (typingTimer) clearTimeout(typingTimer); | |||
| 133 | + | typingTimer = setTimeout(stopTyping, 4000); | |||
| 134 | + | } | |||
| 135 | + | function startCooldown(retryAfter: number) { | |||
| 136 | + | cooldownEnd = Date.now() + retryAfter; | |||
| 137 | + | if (cooldownTimer) clearInterval(cooldownTimer); | |||
| 138 | + | const update = () => { | |||
| 139 | + | cooldownSeconds = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / 1000)); | |||
| 140 | + | if (!cooldownSeconds && cooldownTimer) { | |||
| 141 | + | clearInterval(cooldownTimer); | |||
| 142 | + | cooldownTimer = undefined; | |||
| 143 | + | } | |||
| 144 | + | }; | |||
| 145 | + | update(); | |||
| 146 | + | if (cooldownSeconds) cooldownTimer = setInterval(update, 200); | |||
| 147 | + | } | |||
| 148 | + | async function send() { | |||
| 149 | + | if (disabled || !draft.trim() || draft.length > MAX_MESSAGE_LENGTH || !currentUser) return; | |||
| 150 | + | sending = true; | |||
| 151 | + | error = ''; | |||
| 152 | + | const content = draft; | |||
| 153 | + | try { | |||
| 154 | + | const result = await api.chat.sendMessage({ | |||
| 155 | + | content, | |||
| 156 | + | type: 'chat', | |||
| 157 | + | userId: currentUser.id, | |||
| 158 | + | chatRoomId: roomId, | |||
| 159 | + | styleData: JSON.stringify(textStyle) | |||
| 160 | + | }); | |||
| 161 | + | if (result.success) { | |||
| 162 | + | if (draft === content) draft = ''; | |||
| 163 | + | stopTyping(); | |||
| 164 | + | playSound('imSend'); | |||
| 165 | + | } else if (result.isRateLimited && result.retryAfter) { | |||
| 166 | + | startCooldown(result.retryAfter); | |||
| 167 | + | } else { | |||
| 168 | + | error = result.error ?? 'Impossible d’envoyer le message.'; | |||
| 169 | + | } | |||
| 170 | + | } catch { | |||
| 171 | + | error = 'Impossible d’envoyer le message.'; | |||
| 172 | + | } finally { | |||
| 173 | + | sending = false; | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | function onKeydown(event: KeyboardEvent) { | |||
| 177 | + | if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { | |||
| 178 | + | event.preventDefault(); | |||
| 179 | + | void send(); | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | $effect(() => { | |||
| 184 | + | const room = roomId; | |||
| 185 | + | const data = messagesQuery?.data; | |||
| 186 | + | if (room !== previousRoom) { | |||
| 187 | + | previousRoom = room; | |||
| 188 | + | previousLast = null; | |||
| 189 | + | firstScroll = true; | |||
| 190 | + | } | |||
| 191 | + | if (!data) return; | |||
| 192 | + | const last = data.at(-1); | |||
| 193 | + | const signature = last ? `${last.id}:${last.timestamp}` : ''; | |||
| 194 | + | if ( | |||
| 195 | + | previousLast !== null && | |||
| 196 | + | last && | |||
| 197 | + | signature !== previousLast && | |||
| 198 | + | last.senderId === otherUserId | |||
| 199 | + | ) { | |||
| 200 | + | playSound('imReceive'); | |||
| 201 | + | if (desktop.focusedId !== id) desktop.requestAttention(id); | |||
| 202 | + | } | |||
| 203 | + | const changed = signature !== previousLast; | |||
| 204 | + | previousLast = signature; | |||
| 205 | + | if (!changed || !conversation) return; | |||
| 206 | + | const shouldScroll = | |||
| 207 | + | firstScroll || | |||
| 208 | + | conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 40; | |||
| 209 | + | firstScroll = false; | |||
| 210 | + | if (shouldScroll) void tick().then(() => conversation?.scrollTo(0, conversation.scrollHeight)); | |||
| 211 | + | }); | |||
| 212 | + | $effect(() => { | |||
| 213 | + | const last = messagesQuery?.data?.at(-1); | |||
| 214 | + | const focused = desktop.focusedId === id && !desktop.get(id)?.minimized; | |||
| 215 | + | if (!focused || !currentUser || !messagesQuery?.data) return; | |||
| 216 | + | // Depend on the latest message and focus, not the mutation's result. | |||
| 217 | + | void last; | |||
| 218 | + | readTimer = setTimeout(() => { | |||
| 219 | + | void aim.markRoomRead(roomId).catch(() => {}); | |||
| 220 | + | }, 500); | |||
| 221 | + | return () => { | |||
| 222 | + | if (readTimer) clearTimeout(readTimer); | |||
| 223 | + | }; | |||
| 224 | + | }); | |||
| 225 | + | onMount(() => () => { | |||
| 226 | + | stopTyping(); | |||
| 227 | + | if (cooldownTimer) clearInterval(cooldownTimer); | |||
| 228 | + | if (readTimer) clearTimeout(readTimer); | |||
| 229 | + | }); | |||
| 230 | + | </script> | |||
| 231 | + | ||||
| 232 | + | <XpWindow {id} width={460} height={420} minWidth={320} minHeight={300} showMaximize> | |||
| 233 | + | {#snippet menu()} | |||
| 234 | + | <div class="menu-item"> | |||
| 235 | + | <button | |||
| 236 | + | aria-expanded={menuOpen === 'file'} | |||
| 237 | + | onclick={() => (menuOpen = menuOpen === 'file' ? null : 'file')}>Fichier</button | |||
| 238 | + | > | |||
| 239 | + | {#if menuOpen === 'file'}<div class="dropdown"> | |||
| 240 | + | <button | |||
| 241 | + | onclick={() => { | |||
| 242 | + | menuOpen = null; | |||
| 243 | + | desktop.close(id); | |||
| 244 | + | }}>Fermer</button | |||
| 245 | + | > | |||
| 246 | + | </div>{/if} | |||
| 247 | + | </div> | |||
| 248 | + | <div class="menu-item"> | |||
| 249 | + | <button | |||
| 250 | + | aria-expanded={menuOpen === 'contact'} | |||
| 251 | + | onclick={() => (menuOpen = menuOpen === 'contact' ? null : 'contact')}>Contact</button | |||
| 252 | + | > | |||
| 253 | + | {#if menuOpen === 'contact'}<div class="dropdown"> | |||
| 254 | + | <button | |||
| 255 | + | onclick={() => { | |||
| 256 | + | menuOpen = null; | |||
| 257 | + | desktop.openProfile({ userId: otherUserId, nickname: otherNickname }); | |||
| 258 | + | }}>Infos sur {otherNickname}</button | |||
| 259 | + | > | |||
| 260 | + | </div>{/if} | |||
| 261 | + | </div> | |||
| 262 | + | {/snippet} | |||
| 263 | + | <div class="im-body"> | |||
| 264 | + | <div class="header"> | |||
| 265 | + | <div | |||
| 266 | + | class="buddy-icon" | |||
| 267 | + | style:background={buddyIcon.bg} | |||
| 268 | + | role="img" | |||
| 269 | + | aria-label={buddyIcon.label} | |||
| 270 | + | > | |||
| 271 | + | {buddyIcon.emoji} | |||
| 272 | + | </div> | |||
| 273 | + | <div class="identity"> | |||
| 274 | + | <strong>{profile?.nickname ?? otherNickname}</strong><span | |||
| 275 | + | ><img src={`/aim/status-${status}.svg`} alt="" width="14" height="14" /> | |||
| 276 | + | {STATUS_LABELS_FR[status] ?? 'Hors ligne'}</span | |||
| 277 | + | > | |||
| 278 | + | </div> | |||
| 279 | + | </div> | |||
| 280 | + | {#if status === 'away' && profile?.awayMessage}<div class="away"> | |||
| 281 | + | Message d'absence : {profile.awayMessage} | |||
| 282 | + | </div>{/if} | |||
| 283 | + | <div | |||
| 284 | + | class="sunken-panel conversation" | |||
| 285 | + | bind:this={conversation} | |||
| 286 | + | role="log" | |||
| 287 | + | aria-label={`Conversation avec ${otherNickname}`} | |||
| 288 | + | > | |||
| 289 | + | {#if !messagesQuery?.data}<div class="loading">Chargement…</div>{/if} | |||
| 290 | + | {#each messages as message (message.id)} | |||
| 291 | + | {#if message.type === 'system'}<div class="system">{message.content}</div> | |||
| 292 | + | {:else}<div class="message"> | |||
| 293 | + | <span | |||
| 294 | + | class:me={message.senderId === currentUser?.id} | |||
| 295 | + | class="nick" | |||
| 296 | + | title={new Date(message.timestamp).toLocaleString('fr-FR')} | |||
| 297 | + | >{message.user.nickname} ({formatFrenchTime(new Date(message.timestamp)).replace( | |||
| 298 | + | 'h', | |||
| 299 | + | ':' | |||
| 300 | + | )}):</span | |||
| 301 | + | > | |||
| 302 | + | <FormattedMessage {message} allowFormatting={true} /> | |||
| 303 | + | </div>{/if} | |||
| 304 | + | {/each} | |||
| 305 | + | {#if typingQuery?.data?.length}<div class="typing"> | |||
| 306 | + | {typingQuery.data.map((user) => user.nickname).join(', ')} est en train d'écrire<span | |||
| 307 | + | class="dots">…</span | |||
| 308 | + | > | |||
| 309 | + | </div>{/if} | |||
| 310 | + | </div> | |||
| 311 | + | <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} /> | |||
| 312 | + | <div class="compose field-row"> | |||
| 313 | + | <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label> | |||
| 314 | + | <textarea | |||
| 315 | + | id={`im-input-${roomId}`} | |||
| 316 | + | bind:value={draft} | |||
| 317 | + | oninput={onInput} | |||
| 318 | + | onkeydown={onKeydown} | |||
| 319 | + | disabled={!currentUser || cooldownSeconds > 0} | |||
| 320 | + | placeholder={currentUser ? 'Écris ton message…' : 'Connecte-toi pour discuter.'} | |||
| 321 | + | maxlength={MAX_MESSAGE_LENGTH} | |||
| 322 | + | rows="3" | |||
| 323 | + | ></textarea> | |||
| 324 | + | <button class="send" onclick={send} disabled={disabled || !draft.trim()}>Envoyer</button> | |||
| 325 | + | </div> | |||
| 326 | + | {#if cooldownSeconds > 0}<span class="notice">Patiente {cooldownSeconds}s…</span | |||
| 327 | + | >{:else if error}<span class="notice" role="alert">{error}</span>{/if} | |||
| 328 | + | {#if draft.length > MAX_MESSAGE_LENGTH * 0.8}<span class="counter" | |||
| 329 | + | >{draft.length}/{MAX_MESSAGE_LENGTH}</span | |||
| 330 | + | >{/if} | |||
| 331 | + | </div> | |||
| 332 | + | </XpWindow> | |||
| 333 | + | ||||
| 334 | + | <style> | |||
| 335 | + | .im-body { | |||
| 336 | + | display: flex; | |||
| 337 | + | flex-direction: column; | |||
| 338 | + | min-height: 0; | |||
| 339 | + | height: 100%; | |||
| 340 | + | gap: 5px; | |||
| 341 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif; | |||
| 342 | + | } | |||
| 343 | + | .menu-item { | |||
| 344 | + | position: relative; | |||
| 345 | + | } | |||
| 346 | + | .dropdown { | |||
| 347 | + | position: absolute; | |||
| 348 | + | top: 100%; | |||
| 349 | + | left: 0; | |||
| 350 | + | z-index: 100; | |||
| 351 | + | min-width: 160px; | |||
| 352 | + | padding: 2px; | |||
| 353 | + | background: #fff; | |||
| 354 | + | border: 1px solid #aca899; | |||
| 355 | + | box-shadow: 2px 2px 4px #777; | |||
| 356 | + | } | |||
| 357 | + | .dropdown button { | |||
| 358 | + | width: 100%; | |||
| 359 | + | text-align: left; | |||
| 360 | + | white-space: nowrap; | |||
| 361 | + | } | |||
| 362 | + | .dropdown button:hover { | |||
| 363 | + | background: #316ac5; | |||
| 364 | + | color: white; | |||
| 365 | + | } | |||
| 366 | + | .header { | |||
| 367 | + | display: flex; | |||
| 368 | + | align-items: center; | |||
| 369 | + | gap: 9px; | |||
| 370 | + | flex: none; | |||
| 371 | + | padding: 2px 4px; | |||
| 372 | + | } | |||
| 373 | + | .buddy-icon { | |||
| 374 | + | width: 48px; | |||
| 375 | + | height: 48px; | |||
| 376 | + | display: grid; | |||
| 377 | + | place-items: center; | |||
| 378 | + | font-size: 29px; | |||
| 379 | + | border: 1px solid #aca899; | |||
| 380 | + | flex: none; | |||
| 381 | + | } | |||
| 382 | + | .identity { | |||
| 383 | + | display: flex; | |||
| 384 | + | flex-direction: column; | |||
| 385 | + | gap: 5px; | |||
| 386 | + | min-width: 0; | |||
| 387 | + | } | |||
| 388 | + | .identity strong { | |||
| 389 | + | overflow: hidden; | |||
| 390 | + | text-overflow: ellipsis; | |||
| 391 | + | white-space: nowrap; | |||
| 392 | + | } | |||
| 393 | + | .identity span { | |||
| 394 | + | display: flex; | |||
| 395 | + | align-items: center; | |||
| 396 | + | gap: 3px; | |||
| 397 | + | } | |||
| 398 | + | .away { | |||
| 399 | + | flex: none; | |||
| 400 | + | background: #fff8cc; | |||
| 401 | + | border: 1px solid #e1c466; | |||
| 402 | + | padding: 5px; | |||
| 403 | + | overflow-wrap: anywhere; | |||
| 404 | + | } | |||
| 405 | + | .conversation { | |||
| 406 | + | flex: 1 1 auto; | |||
| 407 | + | min-height: 0; | |||
| 408 | + | background: white; | |||
| 409 | + | overflow: auto; | |||
| 410 | + | padding: 6px; | |||
| 411 | + | font: | |||
| 412 | + | 12px/1.5 Tahoma, | |||
| 413 | + | 'Pixelated MS Sans Serif', | |||
| 414 | + | sans-serif; | |||
| 415 | + | } | |||
| 416 | + | .message { | |||
| 417 | + | margin-bottom: 5px; | |||
| 418 | + | overflow-wrap: anywhere; | |||
| 419 | + | white-space: pre-wrap; | |||
| 420 | + | } | |||
| 421 | + | .nick { | |||
| 422 | + | font-weight: bold; | |||
| 423 | + | color: #0000c0; | |||
| 424 | + | } | |||
| 425 | + | .nick.me { | |||
| 426 | + | color: #c00000; | |||
| 427 | + | } | |||
| 428 | + | .system { | |||
| 429 | + | text-align: center; | |||
| 430 | + | color: #777; | |||
| 431 | + | font-style: italic; | |||
| 432 | + | margin: 6px 0; | |||
| 433 | + | } | |||
| 434 | + | .typing, | |||
| 435 | + | .loading { | |||
| 436 | + | color: #777; | |||
| 437 | + | font-style: italic; | |||
| 438 | + | } | |||
| 439 | + | .dots { | |||
| 440 | + | display: inline-block; | |||
| 441 | + | animation: blink 1s infinite alternate; | |||
| 442 | + | } | |||
| 443 | + | @keyframes blink { | |||
| 444 | + | from { | |||
| 445 | + | opacity: 0.25; | |||
| 446 | + | } | |||
| 447 | + | to { | |||
| 448 | + | opacity: 1; | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | .compose { | |||
| 452 | + | display: flex; | |||
| 453 | + | align-items: stretch; | |||
| 454 | + | gap: 6px; | |||
| 455 | + | flex: none; | |||
| 456 | + | } | |||
| 457 | + | textarea { | |||
| 458 | + | flex: 1; | |||
| 459 | + | min-width: 0; | |||
| 460 | + | resize: none; | |||
| 461 | + | font: | |||
| 462 | + | 12px Tahoma, | |||
| 463 | + | sans-serif; | |||
| 464 | + | } | |||
| 465 | + | .send { | |||
| 466 | + | align-self: flex-end; | |||
| 467 | + | } | |||
| 468 | + | .notice { | |||
| 469 | + | color: #9b2600; | |||
| 470 | + | } | |||
| 471 | + | .counter { | |||
| 472 | + | align-self: flex-end; | |||
| 473 | + | font-size: 10px; | |||
| 474 | + | color: #777; | |||
| 475 | + | } | |||
| 476 | + | .sr-only { | |||
| 477 | + | position: absolute; | |||
| 478 | + | width: 1px; | |||
| 479 | + | height: 1px; | |||
| 480 | + | overflow: hidden; | |||
| 481 | + | clip: rect(0, 0, 0, 0); | |||
| 482 | + | white-space: nowrap; | |||
| 483 | + | } | |||
| 484 | + | </style> | |||
A src/lib/components/desktop/new-room-window.svelte +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import XpWindow from './xp-window.svelte'; | |||
| 3 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 4 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 5 | + | import { useAimClient } from '$lib/aim/client'; | |||
| 6 | + | ||||
| 7 | + | const client = useAimClient(); | |||
| 8 | + | const signedIn = $derived(Boolean(chatState.getCurrentUser())); | |||
| 9 | + | let name = $state(''); | |||
| 10 | + | let topic = $state(''); | |||
| 11 | + | let pending = $state(false); | |||
| 12 | + | let error = $state(''); | |||
| 13 | + | ||||
| 14 | + | async function create() { | |||
| 15 | + | const trimmedName = name.trim(); | |||
| 16 | + | const trimmedTopic = topic.trim(); | |||
| 17 | + | if (trimmedName.length < 2 || trimmedName.length > 40) { | |||
| 18 | + | error = 'Le nom du salon doit contenir entre 2 et 40 caractères.'; | |||
| 19 | + | return; | |||
| 20 | + | } | |||
| 21 | + | if (trimmedTopic.length > 120) { | |||
| 22 | + | error = 'Le sujet ne peut pas dépasser 120 caractères.'; | |||
| 23 | + | return; | |||
| 24 | + | } | |||
| 25 | + | pending = true; | |||
| 26 | + | error = ''; | |||
| 27 | + | try { | |||
| 28 | + | const room = await client.createGroupRoom(trimmedName, trimmedTopic || undefined); | |||
| 29 | + | desktop.openChatRoom(room.id, trimmedName); | |||
| 30 | + | desktop.close('new-room'); | |||
| 31 | + | } catch (err) { | |||
| 32 | + | error = err instanceof Error ? err.message : 'Impossible de créer le salon.'; | |||
| 33 | + | } finally { | |||
| 34 | + | pending = false; | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | </script> | |||
| 38 | + | ||||
| 39 | + | <XpWindow id="new-room" width={340} height={220} resizableWindow={false}> | |||
| 40 | + | <div class="content"> | |||
| 41 | + | {#if signedIn} | |||
| 42 | + | <label for="room-name">Nom du salon</label> | |||
| 43 | + | <input | |||
| 44 | + | id="room-name" | |||
| 45 | + | maxlength="40" | |||
| 46 | + | minlength="2" | |||
| 47 | + | bind:value={name} | |||
| 48 | + | oninput={() => (error = '')} | |||
| 49 | + | /> | |||
| 50 | + | <label for="room-topic">Sujet (facultatif)</label> | |||
| 51 | + | <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} /> | |||
| 52 | + | {#if error}<span role="alert">{error}</span>{/if} | |||
| 53 | + | {:else} | |||
| 54 | + | <p>Connectez-vous pour créer un salon.</p> | |||
| 55 | + | {/if} | |||
| 56 | + | <div class="actions"> | |||
| 57 | + | {#if signedIn}<button disabled={pending} onclick={create}>Créer</button>{:else}<button | |||
| 58 | + | onclick={() => desktop.openLogin('signin')}>Se connecter</button | |||
| 59 | + | >{/if} | |||
| 60 | + | <button onclick={() => desktop.close('new-room')}>Annuler</button> | |||
| 61 | + | </div> | |||
| 62 | + | </div> | |||
| 63 | + | </XpWindow> | |||
| 64 | + | ||||
| 65 | + | <style> | |||
| 66 | + | .content { | |||
| 67 | + | display: flex; | |||
| 68 | + | flex-direction: column; | |||
| 69 | + | gap: 7px; | |||
| 70 | + | height: 100%; | |||
| 71 | + | font: | |||
| 72 | + | 11px Tahoma, | |||
| 73 | + | 'Pixelated MS Sans Serif', | |||
| 74 | + | sans-serif; | |||
| 75 | + | } | |||
| 76 | + | input { | |||
| 77 | + | width: 100%; | |||
| 78 | + | box-sizing: border-box; | |||
| 79 | + | font: inherit; | |||
| 80 | + | } | |||
| 81 | + | .actions { | |||
| 82 | + | display: flex; | |||
| 83 | + | justify-content: flex-end; | |||
| 84 | + | gap: 6px; | |||
| 85 | + | margin-top: auto; | |||
| 86 | + | } | |||
| 87 | + | [role='alert'] { | |||
| 88 | + | color: #a00; | |||
| 89 | + | } | |||
| 90 | + | </style> | |||
A src/lib/components/desktop/preferences-window.svelte +86 -0
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import XpWindow from './xp-window.svelte'; | |||
| 4 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 5 | + | import { | |||
| 6 | + | soundsEnabled, | |||
| 7 | + | setSoundsEnabled, | |||
| 8 | + | playSound, | |||
| 9 | + | unlockAudio, | |||
| 10 | + | type AimSound | |||
| 11 | + | } from '$lib/aim/sounds'; | |||
| 12 | + | ||||
| 13 | + | const sounds: { label: string; name: AimSound }[] = [ | |||
| 14 | + | { label: "Porte qui s'ouvre", name: 'doorOpen' }, | |||
| 15 | + | { label: 'Porte qui claque', name: 'doorSlam' }, | |||
| 16 | + | { label: 'Message reçu', name: 'imReceive' }, | |||
| 17 | + | { label: 'Message envoyé', name: 'imSend' }, | |||
| 18 | + | { label: 'Bienvenue', name: 'welcome' }, | |||
| 19 | + | { label: 'Modem 56k', name: 'modem' } | |||
| 20 | + | ]; | |||
| 21 | + | let soundOn = $state(true); | |||
| 22 | + | let timestamps = $state(true); | |||
| 23 | + | onMount(() => { | |||
| 24 | + | soundOn = soundsEnabled(); | |||
| 25 | + | timestamps = localStorage.getItem('pdraim-show-timestamps') !== 'false'; | |||
| 26 | + | }); | |||
| 27 | + | function changeSound() { | |||
| 28 | + | setSoundsEnabled(soundOn); | |||
| 29 | + | if (soundOn) unlockAudio(); | |||
| 30 | + | } | |||
| 31 | + | function changeTimestamps() { | |||
| 32 | + | localStorage.setItem('pdraim-show-timestamps', String(timestamps)); | |||
| 33 | + | } | |||
| 34 | + | </script> | |||
| 35 | + | ||||
| 36 | + | <XpWindow id="preferences" width={380} height={320} resizableWindow={false}> | |||
| 37 | + | <div class="content"> | |||
| 38 | + | <label | |||
| 39 | + | ><input type="checkbox" bind:checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label | |||
| 40 | + | > | |||
| 41 | + | <div class="sound-list"> | |||
| 42 | + | {#each sounds as sound (sound.name)} | |||
| 43 | + | <div class="sound-row"> | |||
| 44 | + | <span>{sound.label}</span><button | |||
| 45 | + | disabled={!soundOn} | |||
| 46 | + | onclick={() => { | |||
| 47 | + | unlockAudio(); | |||
| 48 | + | playSound(sound.name); | |||
| 49 | + | }}>Tester</button | |||
| 50 | + | > | |||
| 51 | + | </div> | |||
| 52 | + | {/each} | |||
| 53 | + | </div> | |||
| 54 | + | <label | |||
| 55 | + | ><input type="checkbox" bind:checked={timestamps} onchange={changeTimestamps} /> Afficher les horodatages</label | |||
| 56 | + | > | |||
| 57 | + | <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div> | |||
| 58 | + | </div> | |||
| 59 | + | </XpWindow> | |||
| 60 | + | ||||
| 61 | + | <style> | |||
| 62 | + | .content { | |||
| 63 | + | display: flex; | |||
| 64 | + | flex-direction: column; | |||
| 65 | + | gap: 8px; | |||
| 66 | + | height: 100%; | |||
| 67 | + | overflow: auto; | |||
| 68 | + | font: | |||
| 69 | + | 11px Tahoma, | |||
| 70 | + | 'Pixelated MS Sans Serif', | |||
| 71 | + | sans-serif; | |||
| 72 | + | } | |||
| 73 | + | .sound-list { | |||
| 74 | + | display: grid; | |||
| 75 | + | gap: 3px; | |||
| 76 | + | } | |||
| 77 | + | .sound-row { | |||
| 78 | + | display: flex; | |||
| 79 | + | align-items: center; | |||
| 80 | + | justify-content: space-between; | |||
| 81 | + | } | |||
| 82 | + | .actions { | |||
| 83 | + | margin-top: auto; | |||
| 84 | + | align-self: flex-end; | |||
| 85 | + | } | |||
| 86 | + | </style> | |||
A src/lib/components/desktop/profile-window.svelte +201 -0
| @@ -0,0 +1,201 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { useQuery } from 'convex-svelte'; | |||
| 3 | + | import { api } from '../../../convex/_generated/api'; | |||
| 4 | + | import type { Id } from '../../../convex/_generated/dataModel'; | |||
| 5 | + | import XpWindow from './xp-window.svelte'; | |||
| 6 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 7 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 8 | + | import { useAimClient } from '$lib/aim/client'; | |||
| 9 | + | import { BUDDY_ICONS, getBuddyIcon } from '$lib/aim/buddy-icons'; | |||
| 10 | + | import { STATUS_LABELS_FR } from '$lib/types/aim'; | |||
| 11 | + | import { formatFrenchDateTime } from '$lib/utils/date-format'; | |||
| 12 | + | ||||
| 13 | + | let { userId, nickname } = $props<{ userId: Id<'users'>; nickname: string }>(); | |||
| 14 | + | const id = $derived(`profile-${userId}`); | |||
| 15 | + | const profileQuery = useQuery(api.aim.getUserProfile, () => ({ userId })); | |||
| 16 | + | const client = useAimClient(); | |||
| 17 | + | const profile = $derived(profileQuery.data); | |||
| 18 | + | const own = $derived(userId === chatState.getCurrentUser()?.id); | |||
| 19 | + | let text = $state(''); | |||
| 20 | + | let icon = $state('runner'); | |||
| 21 | + | let saved = $state(false); | |||
| 22 | + | let saving = $state(false); | |||
| 23 | + | let error = $state(''); | |||
| 24 | + | let initializedFor = $state<string | null>(null); | |||
| 25 | + | $effect(() => { | |||
| 26 | + | if (profile && initializedFor !== userId) { | |||
| 27 | + | text = profile.profile ?? ''; | |||
| 28 | + | icon = profile.buddyIcon ?? 'runner'; | |||
| 29 | + | initializedFor = userId; | |||
| 30 | + | } | |||
| 31 | + | }); | |||
| 32 | + | ||||
| 33 | + | async function save() { | |||
| 34 | + | saving = true; | |||
| 35 | + | saved = false; | |||
| 36 | + | error = ''; | |||
| 37 | + | try { | |||
| 38 | + | await client.updateProfile({ profile: text, buddyIcon: icon }); | |||
| 39 | + | saved = true; | |||
| 40 | + | } catch (err) { | |||
| 41 | + | error = err instanceof Error ? err.message : 'Impossible d’enregistrer.'; | |||
| 42 | + | } finally { | |||
| 43 | + | saving = false; | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | </script> | |||
| 47 | + | ||||
| 48 | + | <XpWindow {id} width={380} height={400} minWidth={300} minHeight={260}> | |||
| 49 | + | <div class="profile-content"> | |||
| 50 | + | {#if profile} | |||
| 51 | + | <div class="identity"> | |||
| 52 | + | <div | |||
| 53 | + | class="buddy-icon" | |||
| 54 | + | style:background={getBuddyIcon(profile.buddyIcon).bg} | |||
| 55 | + | aria-label={getBuddyIcon(profile.buddyIcon).label} | |||
| 56 | + | > | |||
| 57 | + | {getBuddyIcon(profile.buddyIcon).emoji} | |||
| 58 | + | </div> | |||
| 59 | + | <div> | |||
| 60 | + | <strong>{profile.nickname}</strong> | |||
| 61 | + | <div class="status"> | |||
| 62 | + | <img | |||
| 63 | + | src={`/aim/status-${profile.status}.svg`} | |||
| 64 | + | alt="" | |||
| 65 | + | width="16" | |||
| 66 | + | height="16" | |||
| 67 | + | />{STATUS_LABELS_FR[profile.status]} | |||
| 68 | + | </div> | |||
| 69 | + | {#if profile.createdAt}<small | |||
| 70 | + | >Membre depuis {formatFrenchDateTime(new Date(profile.createdAt))}</small | |||
| 71 | + | >{/if} | |||
| 72 | + | </div> | |||
| 73 | + | </div> | |||
| 74 | + | {#if profile.awayMessage}<div class="away-message">{profile.awayMessage}</div>{/if} | |||
| 75 | + | <label for="profile-text">Profil</label> | |||
| 76 | + | {#if own} | |||
| 77 | + | <textarea | |||
| 78 | + | id="profile-text" | |||
| 79 | + | class="sunken-panel" | |||
| 80 | + | maxlength="1000" | |||
| 81 | + | bind:value={text} | |||
| 82 | + | oninput={() => (saved = false)} | |||
| 83 | + | ></textarea> | |||
| 84 | + | <small>{text.length}/1000</small> | |||
| 85 | + | <div class="icon-grid" aria-label="Choisir une icône"> | |||
| 86 | + | {#each BUDDY_ICONS as preset (preset.id)} | |||
| 87 | + | <button | |||
| 88 | + | class:selected={icon === preset.id} | |||
| 89 | + | aria-label={preset.label} | |||
| 90 | + | aria-pressed={icon === preset.id} | |||
| 91 | + | title={preset.label} | |||
| 92 | + | style:background={preset.bg} | |||
| 93 | + | onclick={() => { | |||
| 94 | + | icon = preset.id; | |||
| 95 | + | saved = false; | |||
| 96 | + | }}>{preset.emoji}</button | |||
| 97 | + | > | |||
| 98 | + | {/each} | |||
| 99 | + | </div> | |||
| 100 | + | {:else} | |||
| 101 | + | <div id="profile-text" class="sunken-panel profile-text"> | |||
| 102 | + | {profile.profile ?? 'Aucun profil pour le moment.'} | |||
| 103 | + | </div> | |||
| 104 | + | {/if} | |||
| 105 | + | {:else if profile === null} | |||
| 106 | + | <p>Profil introuvable : {nickname}</p> | |||
| 107 | + | {:else} | |||
| 108 | + | <p>Chargement du profil…</p> | |||
| 109 | + | {/if} | |||
| 110 | + | <div class="actions"> | |||
| 111 | + | {#if error}<span role="alert">{error}</span>{/if} | |||
| 112 | + | {#if saved}<span role="status">Enregistré ✓</span>{/if} | |||
| 113 | + | {#if own && profile}<button disabled={saving || text.length > 1000} onclick={save} | |||
| 114 | + | >Enregistrer</button | |||
| 115 | + | >{/if} | |||
| 116 | + | <button onclick={() => desktop.close(id)}>Fermer</button> | |||
| 117 | + | </div> | |||
| 118 | + | </div> | |||
| 119 | + | </XpWindow> | |||
| 120 | + | ||||
| 121 | + | <style> | |||
| 122 | + | .profile-content { | |||
| 123 | + | display: flex; | |||
| 124 | + | flex-direction: column; | |||
| 125 | + | gap: 6px; | |||
| 126 | + | height: 100%; | |||
| 127 | + | overflow: auto; | |||
| 128 | + | font: | |||
| 129 | + | 11px Tahoma, | |||
| 130 | + | 'Pixelated MS Sans Serif', | |||
| 131 | + | sans-serif; | |||
| 132 | + | } | |||
| 133 | + | .identity { | |||
| 134 | + | display: flex; | |||
| 135 | + | align-items: center; | |||
| 136 | + | gap: 10px; | |||
| 137 | + | } | |||
| 138 | + | .buddy-icon { | |||
| 139 | + | width: 48px; | |||
| 140 | + | height: 48px; | |||
| 141 | + | flex: 0 0 48px; | |||
| 142 | + | display: grid; | |||
| 143 | + | place-items: center; | |||
| 144 | + | font-size: 28px; | |||
| 145 | + | border: 1px solid #777; | |||
| 146 | + | } | |||
| 147 | + | .status { | |||
| 148 | + | display: flex; | |||
| 149 | + | align-items: center; | |||
| 150 | + | gap: 4px; | |||
| 151 | + | } | |||
| 152 | + | .away-message { | |||
| 153 | + | border: 1px solid #d0c7a0; | |||
| 154 | + | background: #fff9dc; | |||
| 155 | + | padding: 6px; | |||
| 156 | + | white-space: pre-wrap; | |||
| 157 | + | } | |||
| 158 | + | .sunken-panel { | |||
| 159 | + | background: white; | |||
| 160 | + | padding: 5px; | |||
| 161 | + | box-sizing: border-box; | |||
| 162 | + | width: 100%; | |||
| 163 | + | min-height: 65px; | |||
| 164 | + | font: inherit; | |||
| 165 | + | } | |||
| 166 | + | .profile-text { | |||
| 167 | + | flex: 1; | |||
| 168 | + | overflow: auto; | |||
| 169 | + | white-space: pre-wrap; | |||
| 170 | + | overflow-wrap: anywhere; | |||
| 171 | + | } | |||
| 172 | + | textarea { | |||
| 173 | + | resize: vertical; | |||
| 174 | + | } | |||
| 175 | + | .icon-grid { | |||
| 176 | + | display: grid; | |||
| 177 | + | grid-template-columns: repeat(8, 1fr); | |||
| 178 | + | gap: 3px; | |||
| 179 | + | } | |||
| 180 | + | .icon-grid button { | |||
| 181 | + | padding: 0; | |||
| 182 | + | min-width: 0; | |||
| 183 | + | height: 30px; | |||
| 184 | + | font-size: 18px; | |||
| 185 | + | cursor: pointer; | |||
| 186 | + | border: 2px solid transparent; | |||
| 187 | + | } | |||
| 188 | + | .icon-grid button.selected { | |||
| 189 | + | border-color: #245edb; | |||
| 190 | + | } | |||
| 191 | + | .actions { | |||
| 192 | + | margin-top: auto; | |||
| 193 | + | display: flex; | |||
| 194 | + | justify-content: flex-end; | |||
| 195 | + | align-items: center; | |||
| 196 | + | gap: 6px; | |||
| 197 | + | } | |||
| 198 | + | .actions span[role='alert'] { | |||
| 199 | + | color: #a00; | |||
| 200 | + | } | |||
| 201 | + | </style> | |||
A src/lib/components/desktop/sign-on-window.svelte +481 -0
| @@ -0,0 +1,481 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | /** | |||
| 3 | + | * AIM "Sign On" window: screen name + password, "Save password" checkbox, | |||
| 4 | + | * and the classic three-step connection sequence (modem, chime). | |||
| 5 | + | * Also hosts the sign-up tab. | |||
| 6 | + | */ | |||
| 7 | + | import { invalidate } from '$app/navigation'; | |||
| 8 | + | import { z } from 'zod'; | |||
| 9 | + | import XpWindow from './xp-window.svelte'; | |||
| 10 | + | import PasswordStrengthIndicator from '../password-strength-indicator.svelte'; | |||
| 11 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 12 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 13 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 14 | + | import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds'; | |||
| 15 | + | import { | |||
| 16 | + | loginSchema, | |||
| 17 | + | createRegistrationSchema, | |||
| 18 | + | DEFAULT_PASSWORD_CONSTRAINTS | |||
| 19 | + | } from '$lib/validation/password'; | |||
| 20 | + | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |||
| 21 | + | ||||
| 22 | + | let { tab = 'signin' } = $props<{ tab?: 'signin' | 'signup' }>(); | |||
| 23 | + | ||||
| 24 | + | const WINDOW_ID = 'login'; | |||
| 25 | + | const REMEMBER_KEY = 'pdraim-screen-name'; | |||
| 26 | + | ||||
| 27 | + | // svelte-ignore state_referenced_locally | |||
| 28 | + | let activeTab = $state<'signin' | 'signup'>(tab); | |||
| 29 | + | ||||
| 30 | + | // Sign in | |||
| 31 | + | let username = $state(''); | |||
| 32 | + | let password = $state(''); | |||
| 33 | + | let rememberName = $state(true); | |||
| 34 | + | let error = $state(''); | |||
| 35 | + | ||||
| 36 | + | // Sign-on sequence: 0 idle, 1 connecting, 2 verifying, 3 starting | |||
| 37 | + | let step = $state(0); | |||
| 38 | + | let signedOnAs = $state<string | null>(null); | |||
| 39 | + | ||||
| 40 | + | // Sign up | |||
| 41 | + | let suUsername = $state(''); | |||
| 42 | + | let suPassword = $state(''); | |||
| 43 | + | let suConfirmPassword = $state(''); | |||
| 44 | + | let captchaAnswer = $state(''); | |||
| 45 | + | let signupError = $state(''); | |||
| 46 | + | let signupBusy = $state(false); | |||
| 47 | + | let showStrength = $state(false); | |||
| 48 | + | ||||
| 49 | + | $effect(() => { | |||
| 50 | + | const saved = localStorage.getItem(REMEMBER_KEY); | |||
| 51 | + | if (saved && !username) username = saved; | |||
| 52 | + | }); | |||
| 53 | + | ||||
| 54 | + | const STEP_LABELS = [ | |||
| 55 | + | '', | |||
| 56 | + | 'Connexion en cours…', | |||
| 57 | + | 'Vérification du nom et du mot de passe…', | |||
| 58 | + | 'Démarrage des services…' | |||
| 59 | + | ]; | |||
| 60 | + | ||||
| 61 | + | function wait(ms: number) { | |||
| 62 | + | return new Promise((r) => setTimeout(r, ms)); | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | async function login(name: string, pass: string): Promise<{ ok: boolean; error?: string }> { | |||
| 66 | + | const res = await fetch('/api/session/login', { | |||
| 67 | + | method: 'POST', | |||
| 68 | + | headers: { 'Content-Type': 'application/json' }, | |||
| 69 | + | body: JSON.stringify({ username: name, password: pass }) | |||
| 70 | + | }); | |||
| 71 | + | const data = await res.json(); | |||
| 72 | + | if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' }; | |||
| 73 | + | chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |||
| 74 | + | await invalidate('app:session'); | |||
| 75 | + | await invalidate('app:chat'); | |||
| 76 | + | return { ok: true }; | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | async function runSignOn(name: string, pass: string) { | |||
| 80 | + | unlockAudio(); | |||
| 81 | + | error = ''; | |||
| 82 | + | step = 1; | |||
| 83 | + | desktop.signingOn = true; | |||
| 84 | + | playSound('modem'); | |||
| 85 | + | const modemStart = Date.now(); | |||
| 86 | + | try { | |||
| 87 | + | await wait(900); | |||
| 88 | + | step = 2; | |||
| 89 | + | const result = await login(name, pass); | |||
| 90 | + | if (!result.ok) { | |||
| 91 | + | error = result.error ?? 'Connexion impossible.'; | |||
| 92 | + | step = 0; | |||
| 93 | + | return; | |||
| 94 | + | } | |||
| 95 | + | if (rememberName) localStorage.setItem(REMEMBER_KEY, name); | |||
| 96 | + | else localStorage.removeItem(REMEMBER_KEY); | |||
| 97 | + | step = 3; | |||
| 98 | + | // Let the modem "handshake" finish before the welcome chime | |||
| 99 | + | const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart); | |||
| 100 | + | await wait(Math.max(400, Math.min(remaining, 2500))); | |||
| 101 | + | playSound('welcome'); | |||
| 102 | + | signedOnAs = name; | |||
| 103 | + | desktop.openBuddyList(); | |||
| 104 | + | await wait(1200); | |||
| 105 | + | desktop.close(WINDOW_ID); | |||
| 106 | + | } finally { | |||
| 107 | + | desktop.signingOn = false; | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | async function handleSignin(e: Event) { | |||
| 112 | + | e.preventDefault(); | |||
| 113 | + | const parsed = loginSchema.safeParse({ username: username.trim(), password: password.trim() }); | |||
| 114 | + | if (!parsed.success) { | |||
| 115 | + | error = parsed.error.issues[0]?.message ?? 'Saisie invalide'; | |||
| 116 | + | return; | |||
| 117 | + | } | |||
| 118 | + | try { | |||
| 119 | + | await runSignOn(username.trim(), password.trim()); | |||
| 120 | + | } catch (err) { | |||
| 121 | + | console.debug('Sign-on error', err); | |||
| 122 | + | error = 'Erreur de connexion. Réessaie.'; | |||
| 123 | + | step = 0; | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | async function handleSignup(e: Event) { | |||
| 128 | + | e.preventDefault(); | |||
| 129 | + | signupError = ''; | |||
| 130 | + | const schema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS); | |||
| 131 | + | try { | |||
| 132 | + | schema.parse({ | |||
| 133 | + | suUsername: suUsername.trim(), | |||
| 134 | + | suPassword: suPassword.trim(), | |||
| 135 | + | suConfirmPassword: suConfirmPassword.trim(), | |||
| 136 | + | captchaAnswer: captchaAnswer.trim(), | |||
| 137 | + | turnstileToken: '' | |||
| 138 | + | }); | |||
| 139 | + | } catch (err) { | |||
| 140 | + | signupError = | |||
| 141 | + | err instanceof z.ZodError | |||
| 142 | + | ? (err.issues[0]?.message ?? 'Saisie invalide') | |||
| 143 | + | : 'Saisie invalide'; | |||
| 144 | + | return; | |||
| 145 | + | } | |||
| 146 | + | signupBusy = true; | |||
| 147 | + | try { | |||
| 148 | + | const res = await fetch('/api/register', { | |||
| 149 | + | method: 'POST', | |||
| 150 | + | headers: { 'Content-Type': 'application/json' }, | |||
| 151 | + | body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer }) | |||
| 152 | + | }); | |||
| 153 | + | const data = (await res.json()) as RegisterResponse; | |||
| 154 | + | if (!res.ok) { | |||
| 155 | + | signupError = (data as RegisterResponseError).error || 'Inscription impossible.'; | |||
| 156 | + | return; | |||
| 157 | + | } | |||
| 158 | + | username = suUsername.trim(); | |||
| 159 | + | password = suPassword.trim(); | |||
| 160 | + | activeTab = 'signin'; | |||
| 161 | + | await runSignOn(username, password); | |||
| 162 | + | } catch (err) { | |||
| 163 | + | console.debug('Signup error', err); | |||
| 164 | + | signupError = "Erreur d'inscription. Réessaie."; | |||
| 165 | + | } finally { | |||
| 166 | + | signupBusy = false; | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | function cancelSignOn() { | |||
| 171 | + | step = 0; | |||
| 172 | + | desktop.signingOn = false; | |||
| 173 | + | } | |||
| 174 | + | </script> | |||
| 175 | + | ||||
| 176 | + | <XpWindow | |||
| 177 | + | id={WINDOW_ID} | |||
| 178 | + | width={300} | |||
| 179 | + | height={activeTab === 'signin' ? 400 : 520} | |||
| 180 | + | resizableWindow={false} | |||
| 181 | + | > | |||
| 182 | + | <div class="signon"> | |||
| 183 | + | <div class="banner"> | |||
| 184 | + | <img src="/aim/running-man.svg" alt="" width="40" height="40" /> | |||
| 185 | + | <div class="brand"> | |||
| 186 | + | <div class="brand-title">PDR AIM</div> | |||
| 187 | + | <div class="brand-sub">Instant Messenger</div> | |||
| 188 | + | </div> | |||
| 189 | + | </div> | |||
| 190 | + | ||||
| 191 | + | {#if signedOnAs} | |||
| 192 | + | <div class="progress"> | |||
| 193 | + | <p class="welcome">Bienvenue, <b>{signedOnAs}</b> !</p> | |||
| 194 | + | <p class="hint">Ouverture de la liste de contacts…</p> | |||
| 195 | + | </div> | |||
| 196 | + | {:else if step > 0} | |||
| 197 | + | <div class="progress"> | |||
| 198 | + | <p class="step-title">Connexion à PDR AIM</p> | |||
| 199 | + | <ol class="steps"> | |||
| 200 | + | {#each [1, 2, 3] as n (n)} | |||
| 201 | + | <li class:done={step > n} class:active={step === n}>{STEP_LABELS[n]}</li> | |||
| 202 | + | {/each} | |||
| 203 | + | </ol> | |||
| 204 | + | <div class="bar"><div class="bar-fill" style="width: {step * 33}%"></div></div> | |||
| 205 | + | <button type="button" onclick={cancelSignOn}>Annuler</button> | |||
| 206 | + | </div> | |||
| 207 | + | {:else} | |||
| 208 | + | <div class="tabs"> | |||
| 209 | + | <button | |||
| 210 | + | type="button" | |||
| 211 | + | class:active={activeTab === 'signin'} | |||
| 212 | + | onclick={() => (activeTab = 'signin')}>Connexion</button | |||
| 213 | + | > | |||
| 214 | + | <button | |||
| 215 | + | type="button" | |||
| 216 | + | class:active={activeTab === 'signup'} | |||
| 217 | + | onclick={() => (activeTab = 'signup')}>Inscription</button | |||
| 218 | + | > | |||
| 219 | + | </div> | |||
| 220 | + | ||||
| 221 | + | {#if activeTab === 'signin'} | |||
| 222 | + | <form onsubmit={handleSignin}> | |||
| 223 | + | <div class="field-row-stacked"> | |||
| 224 | + | <label for="so-name">Pseudo</label> | |||
| 225 | + | <input id="so-name" type="text" bind:value={username} autocomplete="username" /> | |||
| 226 | + | </div> | |||
| 227 | + | <div class="field-row-stacked"> | |||
| 228 | + | <label for="so-pass">Mot de passe</label> | |||
| 229 | + | <input | |||
| 230 | + | id="so-pass" | |||
| 231 | + | type="password" | |||
| 232 | + | bind:value={password} | |||
| 233 | + | autocomplete="current-password" | |||
| 234 | + | /> | |||
| 235 | + | </div> | |||
| 236 | + | <div class="field-row"> | |||
| 237 | + | <input id="so-remember" type="checkbox" bind:checked={rememberName} /> | |||
| 238 | + | <label for="so-remember">Se souvenir du pseudo</label> | |||
| 239 | + | </div> | |||
| 240 | + | {#if error} | |||
| 241 | + | <div class="error">{error}</div> | |||
| 242 | + | {/if} | |||
| 243 | + | <div class="actions"> | |||
| 244 | + | <button type="submit" class="primary">Se connecter</button> | |||
| 245 | + | </div> | |||
| 246 | + | <p class="hint"> | |||
| 247 | + | Pas encore de compte ? | |||
| 248 | + | <button type="button" class="link" onclick={() => (activeTab = 'signup')} | |||
| 249 | + | >Inscris-toi</button | |||
| 250 | + | > | |||
| 251 | + | </p> | |||
| 252 | + | </form> | |||
| 253 | + | {:else} | |||
| 254 | + | <form onsubmit={handleSignup}> | |||
| 255 | + | <div class="field-row-stacked"> | |||
| 256 | + | <label for="su-name">Pseudo</label> | |||
| 257 | + | <input id="su-name" type="text" bind:value={suUsername} autocomplete="username" /> | |||
| 258 | + | </div> | |||
| 259 | + | <div class="field-row-stacked"> | |||
| 260 | + | <label for="su-pass">Mot de passe</label> | |||
| 261 | + | <input | |||
| 262 | + | id="su-pass" | |||
| 263 | + | type="password" | |||
| 264 | + | bind:value={suPassword} | |||
| 265 | + | autocomplete="new-password" | |||
| 266 | + | onfocus={() => (showStrength = true)} | |||
| 267 | + | /> | |||
| 268 | + | {#if showStrength} | |||
| 269 | + | <PasswordStrengthIndicator password={suPassword} showDetails={true} /> | |||
| 270 | + | {/if} | |||
| 271 | + | </div> | |||
| 272 | + | <div class="field-row-stacked"> | |||
| 273 | + | <label for="su-confirm">Confirmer le mot de passe</label> | |||
| 274 | + | <input | |||
| 275 | + | id="su-confirm" | |||
| 276 | + | type="password" | |||
| 277 | + | bind:value={suConfirmPassword} | |||
| 278 | + | autocomplete="new-password" | |||
| 279 | + | /> | |||
| 280 | + | </div> | |||
| 281 | + | <div class="field-row-stacked"> | |||
| 282 | + | <label for="su-captcha">Que signifie PDR ?</label> | |||
| 283 | + | <input id="su-captcha" type="text" bind:value={captchaAnswer} /> | |||
| 284 | + | </div> | |||
| 285 | + | {#if signupError} | |||
| 286 | + | <div class="error">{signupError}</div> | |||
| 287 | + | {/if} | |||
| 288 | + | <div class="actions"> | |||
| 289 | + | <button type="submit" class="primary" disabled={signupBusy}> | |||
| 290 | + | {signupBusy ? 'Inscription…' : "S'inscrire"} | |||
| 291 | + | </button> | |||
| 292 | + | </div> | |||
| 293 | + | </form> | |||
| 294 | + | {/if} | |||
| 295 | + | {/if} | |||
| 296 | + | </div> | |||
| 297 | + | </XpWindow> | |||
| 298 | + | ||||
| 299 | + | <style> | |||
| 300 | + | .signon { | |||
| 301 | + | display: flex; | |||
| 302 | + | flex-direction: column; | |||
| 303 | + | gap: 8px; | |||
| 304 | + | font-size: 11px; | |||
| 305 | + | height: 100%; | |||
| 306 | + | overflow-y: auto; | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | .banner { | |||
| 310 | + | display: flex; | |||
| 311 | + | align-items: center; | |||
| 312 | + | gap: 10px; | |||
| 313 | + | padding: 8px 10px; | |||
| 314 | + | background: linear-gradient(180deg, #fff3a6 0%, #f6c700 100%); | |||
| 315 | + | border: 1px solid #c99a00; | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | .brand-title { | |||
| 319 | + | font-weight: bold; | |||
| 320 | + | font-size: 18px; | |||
| 321 | + | color: #1b1b1b; | |||
| 322 | + | letter-spacing: 1px; | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | .brand-sub { | |||
| 326 | + | font-size: 10px; | |||
| 327 | + | color: #5a4a00; | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | .tabs { | |||
| 331 | + | display: flex; | |||
| 332 | + | border-bottom: 1px solid #919b9c; | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | .tabs button { | |||
| 336 | + | flex: 1; | |||
| 337 | + | border: 1px solid #919b9c; | |||
| 338 | + | border-bottom: none; | |||
| 339 | + | background: #ece9d8; | |||
| 340 | + | box-shadow: none; | |||
| 341 | + | border-radius: 3px 3px 0 0; | |||
| 342 | + | min-width: 0; | |||
| 343 | + | padding: 4px; | |||
| 344 | + | font-size: 11px; | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | .tabs button.active { | |||
| 348 | + | background: #fff; | |||
| 349 | + | font-weight: bold; | |||
| 350 | + | position: relative; | |||
| 351 | + | top: 1px; | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | form { | |||
| 355 | + | display: flex; | |||
| 356 | + | flex-direction: column; | |||
| 357 | + | gap: 8px; | |||
| 358 | + | padding: 4px 2px; | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | .field-row-stacked input[type='text'], | |||
| 362 | + | .field-row-stacked input[type='password'] { | |||
| 363 | + | width: 100%; | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | .actions { | |||
| 367 | + | display: flex; | |||
| 368 | + | justify-content: center; | |||
| 369 | + | margin-top: 4px; | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | .primary { | |||
| 373 | + | font-weight: bold; | |||
| 374 | + | min-width: 120px; | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | .error { | |||
| 378 | + | color: #c00000; | |||
| 379 | + | background: #fff0f0; | |||
| 380 | + | border: 1px solid #e0a0a0; | |||
| 381 | + | padding: 4px 6px; | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | .hint { | |||
| 385 | + | color: #555; | |||
| 386 | + | text-align: center; | |||
| 387 | + | margin: 4px 0 0; | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | .link { | |||
| 391 | + | background: none; | |||
| 392 | + | border: none; | |||
| 393 | + | box-shadow: none; | |||
| 394 | + | color: #0000c0; | |||
| 395 | + | text-decoration: underline; | |||
| 396 | + | padding: 0; | |||
| 397 | + | min-width: 0; | |||
| 398 | + | font-size: 11px; | |||
| 399 | + | cursor: pointer; | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | .progress { | |||
| 403 | + | display: flex; | |||
| 404 | + | flex-direction: column; | |||
| 405 | + | gap: 10px; | |||
| 406 | + | padding: 10px 6px; | |||
| 407 | + | align-items: center; | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | .step-title { | |||
| 411 | + | font-weight: bold; | |||
| 412 | + | margin: 0; | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | .steps { | |||
| 416 | + | list-style: none; | |||
| 417 | + | padding: 0; | |||
| 418 | + | margin: 0; | |||
| 419 | + | width: 100%; | |||
| 420 | + | display: flex; | |||
| 421 | + | flex-direction: column; | |||
| 422 | + | gap: 4px; | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | .steps li { | |||
| 426 | + | color: #888; | |||
| 427 | + | padding-left: 18px; | |||
| 428 | + | position: relative; | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | .steps li::before { | |||
| 432 | + | content: '○'; | |||
| 433 | + | position: absolute; | |||
| 434 | + | left: 2px; | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | .steps li.active { | |||
| 438 | + | color: #000; | |||
| 439 | + | font-weight: bold; | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | .steps li.active::before { | |||
| 443 | + | content: '●'; | |||
| 444 | + | color: #f6c700; | |||
| 445 | + | animation: blink 0.8s steps(2, start) infinite; | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | .steps li.done { | |||
| 449 | + | color: #2a7a2a; | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | .steps li.done::before { | |||
| 453 | + | content: '✓'; | |||
| 454 | + | color: #2a7a2a; | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | .bar { | |||
| 458 | + | width: 100%; | |||
| 459 | + | height: 14px; | |||
| 460 | + | border: 1px solid #919b9c; | |||
| 461 | + | background: #fff; | |||
| 462 | + | padding: 1px; | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | .bar-fill { | |||
| 466 | + | height: 100%; | |||
| 467 | + | background: repeating-linear-gradient(90deg, #2c6ed5 0 8px, transparent 8px 10px); | |||
| 468 | + | transition: width 0.4s ease; | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | .welcome { | |||
| 472 | + | font-size: 14px; | |||
| 473 | + | margin: 8px 0 0; | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | @keyframes blink { | |||
| 477 | + | to { | |||
| 478 | + | visibility: hidden; | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | </style> | |||
A src/lib/components/desktop/taskbar.svelte +273 -0
| @@ -0,0 +1,273 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 4 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 5 | + | import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds'; | |||
| 6 | + | ||||
| 7 | + | let menuOpen = $state(false); | |||
| 8 | + | let soundOn = $state(true); | |||
| 9 | + | let clock = $state(''); | |||
| 10 | + | let menuElement = $state<HTMLElement>(); | |||
| 11 | + | let startElement = $state<HTMLButtonElement>(); | |||
| 12 | + | const user = $derived(chatState.getCurrentUser()); | |||
| 13 | + | ||||
| 14 | + | function updateClock() { | |||
| 15 | + | clock = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | onMount(() => { | |||
| 19 | + | soundOn = soundsEnabled(); | |||
| 20 | + | updateClock(); | |||
| 21 | + | const timer = setInterval(updateClock, 30_000); | |||
| 22 | + | function outside(event: PointerEvent) { | |||
| 23 | + | if ( | |||
| 24 | + | menuOpen && | |||
| 25 | + | !menuElement?.contains(event.target as Node) && | |||
| 26 | + | !startElement?.contains(event.target as Node) | |||
| 27 | + | ) | |||
| 28 | + | menuOpen = false; | |||
| 29 | + | } | |||
| 30 | + | function escape(event: KeyboardEvent) { | |||
| 31 | + | if (event.key === 'Escape') menuOpen = false; | |||
| 32 | + | } | |||
| 33 | + | document.addEventListener('pointerdown', outside); | |||
| 34 | + | document.addEventListener('keydown', escape); | |||
| 35 | + | return () => { | |||
| 36 | + | clearInterval(timer); | |||
| 37 | + | document.removeEventListener('pointerdown', outside); | |||
| 38 | + | document.removeEventListener('keydown', escape); | |||
| 39 | + | }; | |||
| 40 | + | }); | |||
| 41 | + | ||||
| 42 | + | function choose(action: () => void) { | |||
| 43 | + | menuOpen = false; | |||
| 44 | + | action(); | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | async function logout() { | |||
| 48 | + | const response = await fetch('/api/session/logout', { method: 'POST' }); | |||
| 49 | + | if (response.ok) window.location.reload(); | |||
| 50 | + | } | |||
| 51 | + | </script> | |||
| 52 | + | ||||
| 53 | + | <div class="taskbar"> | |||
| 54 | + | <button | |||
| 55 | + | class="start" | |||
| 56 | + | bind:this={startElement} | |||
| 57 | + | aria-expanded={menuOpen} | |||
| 58 | + | aria-controls="start-menu" | |||
| 59 | + | onclick={() => (menuOpen = !menuOpen)} | |||
| 60 | + | ><img src="/aim/start.svg" alt="" width="20" height="20" />démarrer</button | |||
| 61 | + | > | |||
| 62 | + | {#if menuOpen} | |||
| 63 | + | <div class="start-menu" id="start-menu" bind:this={menuElement}> | |||
| 64 | + | <div class="menu-header">{user?.nickname ?? 'Invité'}</div> | |||
| 65 | + | <button onclick={() => choose(() => desktop.openBuddyList())} | |||
| 66 | + | >PDR AIM — Liste de contacts</button | |||
| 67 | + | > | |||
| 68 | + | <button onclick={() => choose(() => desktop.openChatRoom(null, 'General'))} | |||
| 69 | + | >Salon General</button | |||
| 70 | + | > | |||
| 71 | + | <button onclick={() => choose(() => desktop.openNewRoom())}>Nouveau salon…</button> | |||
| 72 | + | <hr /> | |||
| 73 | + | <button onclick={() => choose(() => desktop.openPreferences())}>Préférences</button> | |||
| 74 | + | <button onclick={() => choose(() => desktop.openAbout())}>À propos</button> | |||
| 75 | + | <hr /> | |||
| 76 | + | {#if user} | |||
| 77 | + | <button | |||
| 78 | + | onclick={() => { | |||
| 79 | + | menuOpen = false; | |||
| 80 | + | void logout(); | |||
| 81 | + | }}>Se déconnecter</button | |||
| 82 | + | > | |||
| 83 | + | {:else} | |||
| 84 | + | <button onclick={() => choose(() => desktop.openLogin('signin'))}>Se connecter</button> | |||
| 85 | + | <button onclick={() => choose(() => desktop.openLogin('signup'))}>S'inscrire</button> | |||
| 86 | + | {/if} | |||
| 87 | + | </div> | |||
| 88 | + | {/if} | |||
| 89 | + | <div class="tasks"> | |||
| 90 | + | {#each desktop.taskbarWindows as w (w.id)} | |||
| 91 | + | <button | |||
| 92 | + | class="task" | |||
| 93 | + | class:pressed={desktop.focusedId === w.id} | |||
| 94 | + | class:minimized={w.minimized} | |||
| 95 | + | class:attention={w.attention} | |||
| 96 | + | title={w.title} | |||
| 97 | + | onclick={() => desktop.toggleFromTaskbar(w.id)} | |||
| 98 | + | ><img src={w.icon} alt="" width="16" height="16" /><span>{w.title}</span></button | |||
| 99 | + | > | |||
| 100 | + | {/each} | |||
| 101 | + | </div> | |||
| 102 | + | <div class="tray"> | |||
| 103 | + | <button | |||
| 104 | + | class="tray-button" | |||
| 105 | + | title={soundOn ? 'Désactiver les sons' : 'Activer les sons'} | |||
| 106 | + | aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'} | |||
| 107 | + | onclick={() => { | |||
| 108 | + | unlockAudio(); | |||
| 109 | + | soundOn = !soundsEnabled(); | |||
| 110 | + | setSoundsEnabled(soundOn); | |||
| 111 | + | }}><span aria-hidden="true">{soundOn ? '🔊' : '🔇'}</span></button | |||
| 112 | + | > | |||
| 113 | + | <img | |||
| 114 | + | src="/aim/running-man.svg" | |||
| 115 | + | alt="" | |||
| 116 | + | title={user ? `Statut : ${user.status}` : 'Hors ligne'} | |||
| 117 | + | width="16" | |||
| 118 | + | height="16" | |||
| 119 | + | /> | |||
| 120 | + | <time>{clock}</time> | |||
| 121 | + | </div> | |||
| 122 | + | </div> | |||
| 123 | + | ||||
| 124 | + | <style> | |||
| 125 | + | .taskbar { | |||
| 126 | + | position: fixed; | |||
| 127 | + | bottom: 0; | |||
| 128 | + | left: 0; | |||
| 129 | + | right: 0; | |||
| 130 | + | height: 30px; | |||
| 131 | + | z-index: 5000; | |||
| 132 | + | display: flex; | |||
| 133 | + | align-items: stretch; | |||
| 134 | + | gap: 4px; | |||
| 135 | + | background: linear-gradient(#245edb, #3168d5); | |||
| 136 | + | border-top: 1px solid #0f3d9a; | |||
| 137 | + | box-sizing: border-box; | |||
| 138 | + | color: white; | |||
| 139 | + | font: | |||
| 140 | + | 11px Tahoma, | |||
| 141 | + | 'Pixelated MS Sans Serif', | |||
| 142 | + | sans-serif; | |||
| 143 | + | } | |||
| 144 | + | .start { | |||
| 145 | + | display: flex; | |||
| 146 | + | align-items: center; | |||
| 147 | + | gap: 4px; | |||
| 148 | + | border: 0; | |||
| 149 | + | border-radius: 0 14px 14px 0; | |||
| 150 | + | background: linear-gradient(#3c8f3c, #2c7a2c); | |||
| 151 | + | color: white; | |||
| 152 | + | font: | |||
| 153 | + | bold italic 14px Tahoma, | |||
| 154 | + | sans-serif; | |||
| 155 | + | padding: 0 14px 0 6px; | |||
| 156 | + | cursor: pointer; | |||
| 157 | + | box-shadow: inset 0 1px #8acb70; | |||
| 158 | + | } | |||
| 159 | + | .start-menu { | |||
| 160 | + | position: absolute; | |||
| 161 | + | bottom: 30px; | |||
| 162 | + | left: 0; | |||
| 163 | + | width: 250px; | |||
| 164 | + | background: white; | |||
| 165 | + | border: 2px solid #245edb; | |||
| 166 | + | box-shadow: 2px 2px 8px #3339; | |||
| 167 | + | color: #222; | |||
| 168 | + | padding-bottom: 4px; | |||
| 169 | + | } | |||
| 170 | + | .menu-header { | |||
| 171 | + | background: linear-gradient(#245edb, #4f91ef); | |||
| 172 | + | color: white; | |||
| 173 | + | font-weight: bold; | |||
| 174 | + | padding: 10px; | |||
| 175 | + | } | |||
| 176 | + | .start-menu button { | |||
| 177 | + | display: block; | |||
| 178 | + | width: 100%; | |||
| 179 | + | text-align: left; | |||
| 180 | + | background: transparent; | |||
| 181 | + | border: 0; | |||
| 182 | + | box-shadow: none; | |||
| 183 | + | border-radius: 0; | |||
| 184 | + | padding: 6px 12px; | |||
| 185 | + | font: inherit; | |||
| 186 | + | color: #222; | |||
| 187 | + | cursor: pointer; | |||
| 188 | + | } | |||
| 189 | + | .start-menu button:hover, | |||
| 190 | + | .start-menu button:focus-visible { | |||
| 191 | + | background: #316ac5; | |||
| 192 | + | color: white; | |||
| 193 | + | } | |||
| 194 | + | .start-menu hr { | |||
| 195 | + | border: 0; | |||
| 196 | + | border-top: 1px solid #ccc; | |||
| 197 | + | margin: 4px 8px; | |||
| 198 | + | } | |||
| 199 | + | .tasks { | |||
| 200 | + | display: flex; | |||
| 201 | + | gap: 3px; | |||
| 202 | + | align-items: center; | |||
| 203 | + | flex: 1; | |||
| 204 | + | overflow: hidden; | |||
| 205 | + | min-width: 0; | |||
| 206 | + | } | |||
| 207 | + | .task { | |||
| 208 | + | display: flex; | |||
| 209 | + | align-items: center; | |||
| 210 | + | gap: 4px; | |||
| 211 | + | width: 160px; | |||
| 212 | + | max-width: 160px; | |||
| 213 | + | min-width: 36px; | |||
| 214 | + | height: 24px; | |||
| 215 | + | flex-shrink: 1; | |||
| 216 | + | color: white; | |||
| 217 | + | background: #ffffff30; | |||
| 218 | + | border: 1px solid #ffffff44; | |||
| 219 | + | border-radius: 3px; | |||
| 220 | + | padding: 2px 6px; | |||
| 221 | + | font: inherit; | |||
| 222 | + | text-align: left; | |||
| 223 | + | cursor: pointer; | |||
| 224 | + | } | |||
| 225 | + | .task span { | |||
| 226 | + | overflow: hidden; | |||
| 227 | + | text-overflow: ellipsis; | |||
| 228 | + | white-space: nowrap; | |||
| 229 | + | } | |||
| 230 | + | .task img { | |||
| 231 | + | flex: 0 0 16px; | |||
| 232 | + | } | |||
| 233 | + | .task.pressed { | |||
| 234 | + | background: #103b96; | |||
| 235 | + | box-shadow: inset 1px 1px 3px #082a76; | |||
| 236 | + | } | |||
| 237 | + | .task.minimized { | |||
| 238 | + | background: #ffffff18; | |||
| 239 | + | } | |||
| 240 | + | .task.attention { | |||
| 241 | + | animation: flash 1s infinite alternate; | |||
| 242 | + | } | |||
| 243 | + | @keyframes flash { | |||
| 244 | + | from { | |||
| 245 | + | background: #e69a12; | |||
| 246 | + | } | |||
| 247 | + | to { | |||
| 248 | + | background: #ffdc55; | |||
| 249 | + | color: #222; | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | .tray { | |||
| 253 | + | display: flex; | |||
| 254 | + | align-items: center; | |||
| 255 | + | gap: 8px; | |||
| 256 | + | padding: 0 8px; | |||
| 257 | + | background: linear-gradient(#0c91e8, #1684d1); | |||
| 258 | + | border-left: 1px solid #78baff; | |||
| 259 | + | white-space: nowrap; | |||
| 260 | + | } | |||
| 261 | + | .tray-button { | |||
| 262 | + | min-width: 0; | |||
| 263 | + | padding: 0; | |||
| 264 | + | border: 0; | |||
| 265 | + | box-shadow: none; | |||
| 266 | + | background: none; | |||
| 267 | + | cursor: pointer; | |||
| 268 | + | font-size: 14px; | |||
| 269 | + | } | |||
| 270 | + | .tray time { | |||
| 271 | + | font: inherit; | |||
| 272 | + | } | |||
| 273 | + | </style> | |||
M src/lib/components/desktop/xp-window.svelte +10 -5
| @@ -43,7 +43,7 @@ | |||||
| 43 | const win = $derived(desktop.get(id)); | 43 | const win = $derived(desktop.get(id)); | ||
| 44 | const focused = $derived(desktop.focusedId === id); | 44 | const focused = $derived(desktop.focusedId === id); | ||
| 45 | 45 | ||||
| 46 | - | // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards | 46 | + | // svelte-ignore state_referenced_locally |
| 47 | let w = $state(width); | 47 | let w = $state(width); | ||
| 48 | // svelte-ignore state_referenced_locally | 48 | // svelte-ignore state_referenced_locally | ||
| 49 | let h = $state(height); | 49 | let h = $state(height); | ||
| @@ -136,9 +136,7 @@ | |||||
| 136 | <div class="title-bar-controls"> | 136 | <div class="title-bar-controls"> | ||
| 137 | <button aria-label="Minimize" onclick={minimize}></button> | 137 | <button aria-label="Minimize" onclick={minimize}></button> | ||
| 138 | {#if showMaximize && !isMobile} | 138 | {#if showMaximize && !isMobile} | ||
| 139 | - | <button | 139 | + | <button aria-label={isMaximized ? 'Restore' : 'Maximize'} onclick={toggleMaximize} |
| 140 | - | aria-label={isMaximized ? 'Restore' : 'Maximize'} | |||
| 141 | - | onclick={toggleMaximize} | |||
| 142 | ></button> | 140 | ></button> | ||
| 143 | {/if} | 141 | {/if} | ||
| 144 | <button aria-label="Close" onclick={close}></button> | 142 | <button aria-label="Close" onclick={close}></button> | ||
| @@ -174,7 +172,14 @@ | |||||
| 174 | /* xp.css already styles active/inactive gradients through :not(:focus-within); | 172 | /* xp.css already styles active/inactive gradients through :not(:focus-within); | ||
| 175 | force the inactive look based on our own focus tracking instead. */ | 173 | force the inactive look based on our own focus tracking instead. */ | ||
| 176 | .xp-window.inactive .title-bar { | 174 | .xp-window.inactive .title-bar { | ||
| 177 | - | background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%); | 175 | + | background: linear-gradient( |
| 176 | + | 180deg, | |||
| 177 | + | #7697e7 0%, | |||
| 178 | + | #7e9ee3 8%, | |||
| 179 | + | #94afec 40%, | |||
| 180 | + | #97b4e9 88%, | |||
| 181 | + | #82a5e4 100% | |||
| 182 | + | ); | |||
| 178 | } | 183 | } | ||
| 179 | 184 | ||||
| 180 | .title-bar { | 185 | .title-bar { | ||
M src/lib/types/chat.ts +2 -0
| @@ -8,6 +8,8 @@ export interface User { | |||||
| 8 | createdAt: number; // Timestamp (e.g., Unix timestamp) | 8 | createdAt: number; // Timestamp (e.g., Unix timestamp) | ||
| 9 | lastSeen?: number | null; // Optional last active timestamp | 9 | lastSeen?: number | null; // Optional last active timestamp | ||
| 10 | lastReadMentionTimestamp?: number | null; // Timestamp of last read mention | 10 | lastReadMentionTimestamp?: number | null; // Timestamp of last read mention | ||
| 11 | + | awayMessage?: string; // AIM away message (only exposed while away) | |||
| 12 | + | buddyIcon?: string; // Preset buddy icon id | |||
| 11 | } | 13 | } | ||
| 12 | 14 | ||||
| 13 | export type UserStatus = 'offline' | 'online' | 'away' | string; | 15 | export type UserStatus = 'offline' | 'online' | 'away' | string; | ||
M src/routes/+page.svelte +84 -17
| @@ -1,37 +1,104 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { browser } from '$app/environment'; | |||
| 2 | import ChatRoom from '$lib/components/chat-room.svelte'; | 4 | import ChatRoom from '$lib/components/chat-room.svelte'; | ||
| 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | 5 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | ||
| 4 | - | import AimLogin from '$lib/components/aim-login.svelte'; | 6 | + | import Taskbar from '$lib/components/desktop/taskbar.svelte'; |
| 7 | + | import BuddyList from '$lib/components/desktop/buddy-list.svelte'; | |||
| 8 | + | import ImWindow from '$lib/components/desktop/im-window.svelte'; | |||
| 9 | + | import SignOnWindow from '$lib/components/desktop/sign-on-window.svelte'; | |||
| 10 | + | import ProfileWindow from '$lib/components/desktop/profile-window.svelte'; | |||
| 11 | + | import AwayWindow from '$lib/components/desktop/away-window.svelte'; | |||
| 12 | + | import NewRoomWindow from '$lib/components/desktop/new-room-window.svelte'; | |||
| 13 | + | import AboutWindow from '$lib/components/desktop/about-window.svelte'; | |||
| 14 | + | import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte'; | |||
| 15 | + | import { | |||
| 16 | + | desktop, | |||
| 17 | + | type ChatRoomWindowProps, | |||
| 18 | + | type ImWindowProps, | |||
| 19 | + | type ProfileWindowProps | |||
| 20 | + | } from '$lib/states/desktop.svelte'; | |||
| 21 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 5 | 22 | ||||
| 6 | const props = $props(); | 23 | const props = $props(); | ||
| 7 | const pageData = $derived(props.data); | 24 | const pageData = $derived(props.data); | ||
| 8 | - | let showChatRoom = $state(true); | |||
| 9 | - | let showAuth = $state(false); | |||
| 10 | 25 | ||||
| 11 | - | function handleLoginSuccess() { | 26 | + | const currentUser = $derived(chatState.getCurrentUser()); |
| 12 | - | showAuth = false; | 27 | + | |
| 28 | + | // Initial desktop layout: the General room, plus the buddy list when signed in. | |||
| 29 | + | onMount(() => { | |||
| 30 | + | if (!browser) return; | |||
| 31 | + | desktop.openChatRoom(null, 'General'); | |||
| 32 | + | if (pageData.user) { | |||
| 33 | + | desktop.openBuddyList(); | |||
| 34 | + | } else { | |||
| 35 | + | desktop.openLogin('signin'); | |||
| 36 | + | } | |||
| 37 | + | desktop.focus('room-default'); | |||
| 38 | + | }); | |||
| 39 | + | ||||
| 40 | + | // When the session appears (login) make sure the buddy list is around; | |||
| 41 | + | // when it disappears (logout) close private windows. | |||
| 42 | + | let lastUserId: string | null = null; | |||
| 43 | + | $effect(() => { | |||
| 44 | + | const id = currentUser?.id ?? null; | |||
| 45 | + | if (id === lastUserId) return; | |||
| 46 | + | lastUserId = id; | |||
| 47 | + | if (id) { | |||
| 48 | + | if (!desktop.has('buddy-list')) desktop.openBuddyList(); | |||
| 49 | + | } else { | |||
| 50 | + | for (const w of desktop.windows) { | |||
| 51 | + | if (w.kind === 'im' || w.kind === 'buddy-list' || w.kind === 'away') desktop.close(w.id); | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | }); | |||
| 55 | + | ||||
| 56 | + | function onDesktopClick(e: MouseEvent) { | |||
| 57 | + | if (e.target === e.currentTarget) desktop.blurAll(); | |||
| 13 | } | 58 | } | ||
| 14 | </script> | 59 | </script> | ||
| 15 | 60 | ||||
| 16 | - | <div class="desktop content-wrapper"> | 61 | + | <div |
| 17 | - | <DesktopIcons bind:showChatRoom bind:showAuth /> | 62 | + | class="desktop" |
| 63 | + | class:signing-on={desktop.signingOn} | |||
| 64 | + | onmousedown={onDesktopClick} | |||
| 65 | + | role="presentation" | |||
| 66 | + | > | |||
| 67 | + | <DesktopIcons /> | |||
| 18 | 68 | ||||
| 19 | - | {#if showChatRoom} | 69 | + | {#each desktop.windows as win (win.id)} |
| 20 | - | <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} /> | 70 | + | {#if win.kind === 'chat-room'} |
| 21 | - | {/if} | 71 | + | {@const p = win.props as unknown as ChatRoomWindowProps} |
| 72 | + | <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} /> | |||
| 73 | + | {:else if win.kind === 'buddy-list'} | |||
| 74 | + | <BuddyList /> | |||
| 75 | + | {:else if win.kind === 'im'} | |||
| 76 | + | {@const p = win.props as unknown as ImWindowProps} | |||
| 77 | + | <ImWindow roomId={p.roomId} otherUserId={p.otherUserId} otherNickname={p.otherNickname} /> | |||
| 78 | + | {:else if win.kind === 'login'} | |||
| 79 | + | <SignOnWindow tab={(win.props.tab as 'signin' | 'signup') ?? 'signin'} /> | |||
| 80 | + | {:else if win.kind === 'profile'} | |||
| 81 | + | {@const p = win.props as unknown as ProfileWindowProps} | |||
| 82 | + | <ProfileWindow userId={p.userId} nickname={p.nickname} /> | |||
| 83 | + | {:else if win.kind === 'away'} | |||
| 84 | + | <AwayWindow /> | |||
| 85 | + | {:else if win.kind === 'new-room'} | |||
| 86 | + | <NewRoomWindow /> | |||
| 87 | + | {:else if win.kind === 'about'} | |||
| 88 | + | <AboutWindow /> | |||
| 89 | + | {:else if win.kind === 'preferences'} | |||
| 90 | + | <PreferencesWindow /> | |||
| 91 | + | {/if} | |||
| 92 | + | {/each} | |||
| 22 | 93 | ||||
| 23 | - | {#if showAuth} | 94 | + | <Taskbar /> |
| 24 | - | <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} /> | |||
| 25 | - | {/if} | |||
| 26 | </div> | 95 | </div> | ||
| 27 | 96 | ||||
| 28 | <style> | 97 | <style> | ||
| 29 | - | .content-wrapper { | 98 | + | .desktop { |
| 30 | position: absolute; | 99 | position: absolute; | ||
| 31 | inset: 0; | 100 | inset: 0; | ||
| 32 | - | display: flex; | |||
| 33 | - | align-items: center; | |||
| 34 | - | justify-content: center; | |||
| 35 | box-sizing: border-box; | 101 | box-sizing: border-box; | ||
| 102 | + | overflow: hidden; | |||
| 36 | } | 103 | } | ||
| 37 | </style> | 104 | </style> | ||
A src/routes/api/session/logout/+server.ts +24 -0
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | import type { RequestHandler } from '@sveltejs/kit'; | |||
| 2 | + | import { invalidateSession } from '$lib/api/session.server'; | |||
| 3 | + | import { deleteSessionTokenCookie } from '$lib/api/session.cookie'; | |||
| 4 | + | import { users } from '$lib/db/convex.server'; | |||
| 5 | + | import { createLogger } from '$lib/utils/logger.server'; | |||
| 6 | + | ||||
| 7 | + | const log = createLogger('logout-server'); | |||
| 8 | + | ||||
| 9 | + | export const POST: RequestHandler = async ({ cookies, locals }) => { | |||
| 10 | + | if (locals.session) { | |||
| 11 | + | try { | |||
| 12 | + | await users.updateStatus(locals.session.userId, 'offline'); | |||
| 13 | + | await invalidateSession(locals.session.id); | |||
| 14 | + | } catch (err) { | |||
| 15 | + | log.error('Logout cleanup failed', { | |||
| 16 | + | error: err instanceof Error ? err.message : 'Unknown error' | |||
| 17 | + | }); | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | deleteSessionTokenCookie({ cookies }); | |||
| 21 | + | return new Response(JSON.stringify({ success: true }), { | |||
| 22 | + | headers: { 'Content-Type': 'application/json' } | |||
| 23 | + | }); | |||
| 24 | + | }; | |||
A static/sounds/README.md +3 -0
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | # Drop your own AIM samples here (doorOpen, doorSlam, imReceive, imSend, welcome, modem) as .wav/.mp3/.ogg. | |||
| 2 | + | ||||
| 3 | + | # They are ignored by git; without them the app synthesizes the sounds. | |||