fix(desktop): XP inputs, inline away presets, docked buddy list, compact send button
- new-room and buddy-list inputs use type=text so xp.css renders them white - away presets become an inline list box that fits the window - buddy list opens top-right with margins and stays under the sign-on window - chat room drops the redundant online count and the Envoyer button matches the 21px input height - sounds: playback always waits for the sample probe so the first play no longer falls back to the synthesized sketch
- commit
- cb6b0afdd5621fb038c54d83e36c5aed972d2cf1
- author
- Kit · · 2026-09-27 13:41 UTC
- parent
- 824e236
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
6 files · +109 -49
M src/lib/aim/sounds.ts +39 -34
| @@ -50,7 +50,7 @@ function getContext(): AudioContext | null { | |||
| 50 | 50 | ||
| 51 | 51 | /** Call once from a user gesture so the browser allows playback. */ | |
| 52 | 52 | export function unlockAudio() { | |
| 53 | - | getContext(); | |
| 53 | + | if (getContext()) preloadSounds(); | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | function noiseBuffer(ac: AudioContext, seconds: number) { | |
| @@ -234,25 +234,28 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = { | |||
| 234 | 234 | modem: 4500 | |
| 235 | 235 | }; | |
| 236 | 236 | ||
| 237 | - | // Sample cache: undefined = not probed yet, null = no file available | |
| 238 | - | const samples = new Map<AimSound, AudioBuffer | null>(); | |
| 237 | + | // Sample cache: one in-flight/resolved promise per sound (null = no file available) | |
| 238 | + | const samples = new Map<AimSound, Promise<AudioBuffer | null>>(); | |
| 239 | 239 | const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg']; | |
| 240 | 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 | - | } | |
| 241 | + | function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> { | |
| 242 | + | let pending = samples.get(name); | |
| 243 | + | if (!pending) { | |
| 244 | + | pending = (async () => { | |
| 245 | + | for (const ext of SAMPLE_EXTENSIONS) { | |
| 246 | + | try { | |
| 247 | + | const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' }); | |
| 248 | + | if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue; | |
| 249 | + | return await ac.decodeAudioData(await res.arrayBuffer()); | |
| 250 | + | } catch { | |
| 251 | + | // try next extension | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return null; | |
| 255 | + | })(); | |
| 256 | + | samples.set(name, pending); | |
| 254 | 257 | } | |
| 255 | - | return null; | |
| 258 | + | return pending; | |
| 256 | 259 | } | |
| 257 | 260 | ||
| 258 | 261 | /** Warm the sample cache so the first playback is not delayed by a fetch. */ | |
| @@ -266,21 +269,23 @@ export function playSound(name: AimSound) { | |||
| 266 | 269 | if (!enabled) return; | |
| 267 | 270 | const ac = getContext(); | |
| 268 | 271 | 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); | |
| 278 | - | const master = ac.createGain(); | |
| 279 | - | master.gain.value = 0.9; | |
| 280 | - | master.connect(ac.destination); | |
| 281 | - | try { | |
| 282 | - | players[name](ac, master, ac.currentTime); | |
| 283 | - | } catch (err) { | |
| 284 | - | console.warn('Sound playback failed', err); | |
| 285 | - | } | |
| 272 | + | // Always wait for the sample probe so the first play never falls back to the | |
| 273 | + | // synthesized sketch while the real file is still downloading. | |
| 274 | + | void loadSample(ac, name).then((buffer) => { | |
| 275 | + | if (buffer) { | |
| 276 | + | const src = ac.createBufferSource(); | |
| 277 | + | src.buffer = buffer; | |
| 278 | + | src.connect(ac.destination); | |
| 279 | + | src.start(); | |
| 280 | + | return; | |
| 281 | + | } | |
| 282 | + | const master = ac.createGain(); | |
| 283 | + | master.gain.value = 0.9; | |
| 284 | + | master.connect(ac.destination); | |
| 285 | + | try { | |
| 286 | + | players[name](ac, master, ac.currentTime); | |
| 287 | + | } catch (err) { | |
| 288 | + | console.warn('Sound playback failed', err); | |
| 289 | + | } | |
| 290 | + | }); | |
| 286 | 291 | } | |
| @@ -50,7 +50,7 @@ function getContext(): AudioContext | null { | |||||
| 50 | 50 | ||||
| 51 | /** Call once from a user gesture so the browser allows playback. */ | 51 | /** Call once from a user gesture so the browser allows playback. */ | ||
| 52 | export function unlockAudio() { | 52 | export function unlockAudio() { | ||
| 53 | - | getContext(); | 53 | + | if (getContext()) preloadSounds(); |
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | function noiseBuffer(ac: AudioContext, seconds: number) { | 56 | function noiseBuffer(ac: AudioContext, seconds: number) { | ||
| @@ -234,25 +234,28 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = { | |||||
| 234 | modem: 4500 | 234 | modem: 4500 | ||
| 235 | }; | 235 | }; | ||
| 236 | 236 | ||||
| 237 | - | // Sample cache: undefined = not probed yet, null = no file available | 237 | + | // Sample cache: one in-flight/resolved promise per sound (null = no file available) |
| 238 | - | const samples = new Map<AimSound, AudioBuffer | null>(); | 238 | + | const samples = new Map<AimSound, Promise<AudioBuffer | null>>(); |
| 239 | const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg']; | 239 | const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg']; | ||
| 240 | 240 | ||||
| 241 | - | async function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> { | 241 | + | function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> { |
| 242 | - | if (samples.has(name)) return samples.get(name) ?? null; | 242 | + | let pending = samples.get(name); |
| 243 | - | samples.set(name, null); | 243 | + | if (!pending) { |
| 244 | - | for (const ext of SAMPLE_EXTENSIONS) { | 244 | + | pending = (async () => { |
| 245 | - | try { | 245 | + | for (const ext of SAMPLE_EXTENSIONS) { |
| 246 | - | const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' }); | 246 | + | try { |
| 247 | - | if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue; | 247 | + | const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' }); |
| 248 | - | const buffer = await ac.decodeAudioData(await res.arrayBuffer()); | 248 | + | if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue; |
| 249 | - | samples.set(name, buffer); | 249 | + | return await ac.decodeAudioData(await res.arrayBuffer()); |
| 250 | - | return buffer; | 250 | + | } catch { |
| 251 | - | } catch { | 251 | + | // try next extension |
| 252 | - | // try next extension | 252 | + | } |
| 253 | - | } | 253 | + | } |
| 254 | + | return null; | |||
| 255 | + | })(); | |||
| 256 | + | samples.set(name, pending); | |||
| 254 | } | 257 | } | ||
| 255 | - | return null; | 258 | + | return pending; |
| 256 | } | 259 | } | ||
| 257 | 260 | ||||
| 258 | /** Warm the sample cache so the first playback is not delayed by a fetch. */ | 261 | /** Warm the sample cache so the first playback is not delayed by a fetch. */ | ||
| @@ -266,21 +269,23 @@ export function playSound(name: AimSound) { | |||||
| 266 | if (!enabled) return; | 269 | if (!enabled) return; | ||
| 267 | const ac = getContext(); | 270 | const ac = getContext(); | ||
| 268 | if (!ac) return; | 271 | if (!ac) return; | ||
| 269 | - | const cached = samples.get(name); | 272 | + | // Always wait for the sample probe so the first play never falls back to the |
| 270 | - | if (cached) { | 273 | + | // synthesized sketch while the real file is still downloading. |
| 271 | - | const src = ac.createBufferSource(); | 274 | + | void loadSample(ac, name).then((buffer) => { |
| 272 | - | src.buffer = cached; | 275 | + | if (buffer) { |
| 273 | - | src.connect(ac.destination); | 276 | + | const src = ac.createBufferSource(); |
| 274 | - | src.start(); | 277 | + | src.buffer = buffer; |
| 275 | - | return; | 278 | + | src.connect(ac.destination); |
| 276 | - | } | 279 | + | src.start(); |
| 277 | - | if (cached === undefined) void loadSample(ac, name); | 280 | + | return; |
| 278 | - | const master = ac.createGain(); | 281 | + | } |
| 279 | - | master.gain.value = 0.9; | 282 | + | const master = ac.createGain(); |
| 280 | - | master.connect(ac.destination); | 283 | + | master.gain.value = 0.9; |
| 281 | - | try { | 284 | + | master.connect(ac.destination); |
| 282 | - | players[name](ac, master, ac.currentTime); | 285 | + | try { |
| 283 | - | } catch (err) { | 286 | + | players[name](ac, master, ac.currentTime); |
| 284 | - | console.warn('Sound playback failed', err); | 287 | + | } catch (err) { |
| 285 | - | } | 288 | + | console.warn('Sound playback failed', err); |
| 289 | + | } | |||
| 290 | + | }); | |||
| 286 | } | 291 | } | ||
M src/lib/components/chat-room.svelte +9 -9
| @@ -1078,7 +1078,6 @@ | |||
| 1078 | 1078 | {#if roomTopic} | |
| 1079 | 1079 | <span class="room-topic">— {roomTopic}</span> | |
| 1080 | 1080 | {/if} | |
| 1081 | - | <span class="room-count">{usersOnline.length} en ligne</span> | |
| 1082 | 1081 | {#if mentionUnreadCount > 0} | |
| 1083 | 1082 | <span class="room-mentions" | |
| 1084 | 1083 | >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span | |
| @@ -1492,13 +1491,8 @@ | |||
| 1492 | 1491 | flex: 1; | |
| 1493 | 1492 | } | |
| 1494 | 1493 | ||
| 1495 | - | .room-count { | |
| 1496 | - | margin-left: auto; | |
| 1497 | - | color: #666; | |
| 1498 | - | white-space: nowrap; | |
| 1499 | - | } | |
| 1500 | - | ||
| 1501 | 1494 | .room-mentions { | |
| 1495 | + | margin-left: auto; | |
| 1502 | 1496 | font-weight: bold; | |
| 1503 | 1497 | color: #c00000; | |
| 1504 | 1498 | white-space: nowrap; | |
| @@ -1729,8 +1723,14 @@ | |||
| 1729 | 1723 | font-style: inherit; | |
| 1730 | 1724 | } | |
| 1731 | 1725 | ||
| 1732 | - | button { | |
| 1733 | - | font-size: 1rem; | |
| 1726 | + | .input-container :global(> button) { | |
| 1727 | + | height: 21px; | |
| 1728 | + | min-height: 21px; | |
| 1729 | + | min-width: 0; | |
| 1730 | + | padding: 0 10px; | |
| 1731 | + | margin-left: 4px; | |
| 1732 | + | font-size: 11px; | |
| 1733 | + | white-space: nowrap; | |
| 1734 | 1734 | } | |
| 1735 | 1735 | ||
| 1736 | 1736 | .chat-container { | |
| @@ -1078,7 +1078,6 @@ | |||||
| 1078 | {#if roomTopic} | 1078 | {#if roomTopic} | ||
| 1079 | <span class="room-topic">— {roomTopic}</span> | 1079 | <span class="room-topic">— {roomTopic}</span> | ||
| 1080 | {/if} | 1080 | {/if} | ||
| 1081 | - | <span class="room-count">{usersOnline.length} en ligne</span> | |||
| 1082 | {#if mentionUnreadCount > 0} | 1081 | {#if mentionUnreadCount > 0} | ||
| 1083 | <span class="room-mentions" | 1082 | <span class="room-mentions" | ||
| 1084 | >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span | 1083 | >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span | ||
| @@ -1492,13 +1491,8 @@ | |||||
| 1492 | flex: 1; | 1491 | flex: 1; | ||
| 1493 | } | 1492 | } | ||
| 1494 | 1493 | ||||
| 1495 | - | .room-count { | |||
| 1496 | - | margin-left: auto; | |||
| 1497 | - | color: #666; | |||
| 1498 | - | white-space: nowrap; | |||
| 1499 | - | } | |||
| 1500 | - | ||||
| 1501 | .room-mentions { | 1494 | .room-mentions { | ||
| 1495 | + | margin-left: auto; | |||
| 1502 | font-weight: bold; | 1496 | font-weight: bold; | ||
| 1503 | color: #c00000; | 1497 | color: #c00000; | ||
| 1504 | white-space: nowrap; | 1498 | white-space: nowrap; | ||
| @@ -1729,8 +1723,14 @@ | |||||
| 1729 | font-style: inherit; | 1723 | font-style: inherit; | ||
| 1730 | } | 1724 | } | ||
| 1731 | 1725 | ||||
| 1732 | - | button { | 1726 | + | .input-container :global(> button) { |
| 1733 | - | font-size: 1rem; | 1727 | + | height: 21px; |
| 1728 | + | min-height: 21px; | |||
| 1729 | + | min-width: 0; | |||
| 1730 | + | padding: 0 10px; | |||
| 1731 | + | margin-left: 4px; | |||
| 1732 | + | font-size: 11px; | |||
| 1733 | + | white-space: nowrap; | |||
| 1734 | } | 1734 | } | ||
| 1735 | 1735 | ||||
| 1736 | .chat-container { | 1736 | .chat-container { | ||
M src/lib/components/desktop/away-window.svelte +33 -4
| @@ -58,10 +58,17 @@ | |||
| 58 | 58 | } | |
| 59 | 59 | </script> | |
| 60 | 60 | ||
| 61 | - | <XpWindow id="away" width={360} height={300} resizableWindow={false}> | |
| 61 | + | <XpWindow id="away" width={360} height={340} resizableWindow={false}> | |
| 62 | 62 | <div class="content"> | |
| 63 | - | <label for="away-preset">Message d’absence</label> | |
| 64 | - | <select id="away-preset" value={selected} onchange={selectPreset}> | |
| 63 | + | <label for="away-preset">Messages d’absence enregistrés</label> | |
| 64 | + | <!-- Inline list box (like AIM) so the choices always stay inside the window --> | |
| 65 | + | <select | |
| 66 | + | id="away-preset" | |
| 67 | + | class="preset-list" | |
| 68 | + | size={presets.length + 1} | |
| 69 | + | value={selected} | |
| 70 | + | onchange={selectPreset} | |
| 71 | + | > | |
| 65 | 72 | {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each} | |
| 66 | 73 | <option value="custom">Personnalisé…</option> | |
| 67 | 74 | </select> | |
| @@ -96,10 +103,32 @@ | |||
| 96 | 103 | box-sizing: border-box; | |
| 97 | 104 | font: inherit; | |
| 98 | 105 | } | |
| 106 | + | .preset-list { | |
| 107 | + | height: auto; | |
| 108 | + | padding: 1px; | |
| 109 | + | background-image: none; | |
| 110 | + | border: 1px solid #7f9db9; | |
| 111 | + | overflow: hidden; | |
| 112 | + | } | |
| 113 | + | .preset-list:focus { | |
| 114 | + | background-color: #fff; | |
| 115 | + | color: inherit; | |
| 116 | + | } | |
| 117 | + | .preset-list option { | |
| 118 | + | padding: 1px 3px; | |
| 119 | + | overflow: hidden; | |
| 120 | + | text-overflow: ellipsis; | |
| 121 | + | white-space: nowrap; | |
| 122 | + | } | |
| 123 | + | .preset-list option:checked { | |
| 124 | + | background: #316ac5; | |
| 125 | + | color: #fff; | |
| 126 | + | } | |
| 99 | 127 | textarea { | |
| 100 | 128 | flex: 1; | |
| 101 | - | min-height: 65px; | |
| 129 | + | min-height: 60px; | |
| 102 | 130 | resize: none; | |
| 131 | + | border: 1px solid #7f9db9; | |
| 103 | 132 | } | |
| 104 | 133 | small { | |
| 105 | 134 | align-self: flex-end; | |
| @@ -58,10 +58,17 @@ | |||||
| 58 | } | 58 | } | ||
| 59 | </script> | 59 | </script> | ||
| 60 | 60 | ||||
| 61 | - | <XpWindow id="away" width={360} height={300} resizableWindow={false}> | 61 | + | <XpWindow id="away" width={360} height={340} resizableWindow={false}> |
| 62 | <div class="content"> | 62 | <div class="content"> | ||
| 63 | - | <label for="away-preset">Message d’absence</label> | 63 | + | <label for="away-preset">Messages d’absence enregistrés</label> |
| 64 | - | <select id="away-preset" value={selected} onchange={selectPreset}> | 64 | + | <!-- Inline list box (like AIM) so the choices always stay inside the window --> |
| 65 | + | <select | |||
| 66 | + | id="away-preset" | |||
| 67 | + | class="preset-list" | |||
| 68 | + | size={presets.length + 1} | |||
| 69 | + | value={selected} | |||
| 70 | + | onchange={selectPreset} | |||
| 71 | + | > | |||
| 65 | {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each} | 72 | {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each} | ||
| 66 | <option value="custom">Personnalisé…</option> | 73 | <option value="custom">Personnalisé…</option> | ||
| 67 | </select> | 74 | </select> | ||
| @@ -96,10 +103,32 @@ | |||||
| 96 | box-sizing: border-box; | 103 | box-sizing: border-box; | ||
| 97 | font: inherit; | 104 | font: inherit; | ||
| 98 | } | 105 | } | ||
| 106 | + | .preset-list { | |||
| 107 | + | height: auto; | |||
| 108 | + | padding: 1px; | |||
| 109 | + | background-image: none; | |||
| 110 | + | border: 1px solid #7f9db9; | |||
| 111 | + | overflow: hidden; | |||
| 112 | + | } | |||
| 113 | + | .preset-list:focus { | |||
| 114 | + | background-color: #fff; | |||
| 115 | + | color: inherit; | |||
| 116 | + | } | |||
| 117 | + | .preset-list option { | |||
| 118 | + | padding: 1px 3px; | |||
| 119 | + | overflow: hidden; | |||
| 120 | + | text-overflow: ellipsis; | |||
| 121 | + | white-space: nowrap; | |||
| 122 | + | } | |||
| 123 | + | .preset-list option:checked { | |||
| 124 | + | background: #316ac5; | |||
| 125 | + | color: #fff; | |||
| 126 | + | } | |||
| 99 | textarea { | 127 | textarea { | ||
| 100 | flex: 1; | 128 | flex: 1; | ||
| 101 | - | min-height: 65px; | 129 | + | min-height: 60px; |
| 102 | resize: none; | 130 | resize: none; | ||
| 131 | + | border: 1px solid #7f9db9; | |||
| 103 | } | 132 | } | ||
| 104 | small { | 133 | small { | ||
| 105 | align-self: flex-end; | 134 | align-self: flex-end; | ||
M src/lib/components/desktop/buddy-list.svelte +19 -1
| @@ -12,6 +12,14 @@ | |||
| 12 | 12 | import { chatState } from '$lib/states/chat.svelte'; | |
| 13 | 13 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 14 | 14 | import XpWindow from './xp-window.svelte'; | |
| 15 | + | import { browser } from '$app/environment'; | |
| 16 | + | ||
| 17 | + | // Dock top-right like the real AIM buddy list, with breathing room above and | |
| 18 | + | // above the taskbar (30px) below. | |
| 19 | + | const BUDDY_LIST_WIDTH = 230; | |
| 20 | + | const buddyListHeight = browser | |
| 21 | + | ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40)) | |
| 22 | + | : 520; | |
| 15 | 23 | ||
| 16 | 24 | const aim = useAimClient(); | |
| 17 | 25 | const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({})); | |
| @@ -244,7 +252,15 @@ | |||
| 244 | 252 | </div> | |
| 245 | 253 | {/snippet} | |
| 246 | 254 | ||
| 247 | - | <XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}> | |
| 255 | + | <XpWindow | |
| 256 | + | id="buddy-list" | |
| 257 | + | width={BUDDY_LIST_WIDTH} | |
| 258 | + | height={buddyListHeight} | |
| 259 | + | minWidth={BUDDY_LIST_WIDTH} | |
| 260 | + | showMaximize={false} | |
| 261 | + | x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined} | |
| 262 | + | y={browser ? 40 : undefined} | |
| 263 | + | > | |
| 248 | 264 | {#snippet menu()} | |
| 249 | 265 | <div class="menus" bind:this={menuBar}> | |
| 250 | 266 | <div class="menu-wrap"> | |
| @@ -407,6 +423,7 @@ | |||
| 407 | 423 | > | |
| 408 | 424 | <label for="im-nick">Pseudo du contact</label><input | |
| 409 | 425 | id="im-nick" | |
| 426 | + | type="text" | |
| 410 | 427 | bind:value={nickname} | |
| 411 | 428 | autocomplete="off" | |
| 412 | 429 | /><button type="submit">Ouvrir IM</button> | |
| @@ -426,6 +443,7 @@ | |||
| 426 | 443 | >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option | |
| 427 | 444 | >{/each}<option value="Autre…">Autre…</option></select | |
| 428 | 445 | >{#if addGroup === 'Autre…'}<input | |
| 446 | + | type="text" | |
| 429 | 447 | aria-label="Nom du groupe" | |
| 430 | 448 | bind:value={customGroup} | |
| 431 | 449 | />{/if}<button type="submit">Ajouter</button> | |
| @@ -12,6 +12,14 @@ | |||||
| 12 | import { chatState } from '$lib/states/chat.svelte'; | 12 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 13 | import { desktop } from '$lib/states/desktop.svelte'; | 13 | import { desktop } from '$lib/states/desktop.svelte'; | ||
| 14 | import XpWindow from './xp-window.svelte'; | 14 | import XpWindow from './xp-window.svelte'; | ||
| 15 | + | import { browser } from '$app/environment'; | |||
| 16 | + | ||||
| 17 | + | // Dock top-right like the real AIM buddy list, with breathing room above and | |||
| 18 | + | // above the taskbar (30px) below. | |||
| 19 | + | const BUDDY_LIST_WIDTH = 230; | |||
| 20 | + | const buddyListHeight = browser | |||
| 21 | + | ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40)) | |||
| 22 | + | : 520; | |||
| 15 | 23 | ||||
| 16 | const aim = useAimClient(); | 24 | const aim = useAimClient(); | ||
| 17 | const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({})); | 25 | const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({})); | ||
| @@ -244,7 +252,15 @@ | |||||
| 244 | </div> | 252 | </div> | ||
| 245 | {/snippet} | 253 | {/snippet} | ||
| 246 | 254 | ||||
| 247 | - | <XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}> | 255 | + | <XpWindow |
| 256 | + | id="buddy-list" | |||
| 257 | + | width={BUDDY_LIST_WIDTH} | |||
| 258 | + | height={buddyListHeight} | |||
| 259 | + | minWidth={BUDDY_LIST_WIDTH} | |||
| 260 | + | showMaximize={false} | |||
| 261 | + | x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined} | |||
| 262 | + | y={browser ? 40 : undefined} | |||
| 263 | + | > | |||
| 248 | {#snippet menu()} | 264 | {#snippet menu()} | ||
| 249 | <div class="menus" bind:this={menuBar}> | 265 | <div class="menus" bind:this={menuBar}> | ||
| 250 | <div class="menu-wrap"> | 266 | <div class="menu-wrap"> | ||
| @@ -407,6 +423,7 @@ | |||||
| 407 | > | 423 | > | ||
| 408 | <label for="im-nick">Pseudo du contact</label><input | 424 | <label for="im-nick">Pseudo du contact</label><input | ||
| 409 | id="im-nick" | 425 | id="im-nick" | ||
| 426 | + | type="text" | |||
| 410 | bind:value={nickname} | 427 | bind:value={nickname} | ||
| 411 | autocomplete="off" | 428 | autocomplete="off" | ||
| 412 | /><button type="submit">Ouvrir IM</button> | 429 | /><button type="submit">Ouvrir IM</button> | ||
| @@ -426,6 +443,7 @@ | |||||
| 426 | >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option | 443 | >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option | ||
| 427 | >{/each}<option value="Autre…">Autre…</option></select | 444 | >{/each}<option value="Autre…">Autre…</option></select | ||
| 428 | >{#if addGroup === 'Autre…'}<input | 445 | >{#if addGroup === 'Autre…'}<input | ||
| 446 | + | type="text" | |||
| 429 | aria-label="Nom du groupe" | 447 | aria-label="Nom du groupe" | ||
| 430 | bind:value={customGroup} | 448 | bind:value={customGroup} | ||
| 431 | />{/if}<button type="submit">Ajouter</button> | 449 | />{/if}<button type="submit">Ajouter</button> | ||
M src/lib/components/desktop/new-room-window.svelte +8 -1
| @@ -42,13 +42,20 @@ | |||
| 42 | 42 | <label for="room-name">Nom du salon</label> | |
| 43 | 43 | <input | |
| 44 | 44 | id="room-name" | |
| 45 | + | type="text" | |
| 45 | 46 | maxlength="40" | |
| 46 | 47 | minlength="2" | |
| 47 | 48 | bind:value={name} | |
| 48 | 49 | oninput={() => (error = '')} | |
| 49 | 50 | /> | |
| 50 | 51 | <label for="room-topic">Sujet (facultatif)</label> | |
| 51 | - | <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} /> | |
| 52 | + | <input | |
| 53 | + | id="room-topic" | |
| 54 | + | type="text" | |
| 55 | + | maxlength="120" | |
| 56 | + | bind:value={topic} | |
| 57 | + | oninput={() => (error = '')} | |
| 58 | + | /> | |
| 52 | 59 | {#if error}<span role="alert">{error}</span>{/if} | |
| 53 | 60 | {:else} | |
| 54 | 61 | <p>Connectez-vous pour créer un salon.</p> | |
| @@ -42,13 +42,20 @@ | |||||
| 42 | <label for="room-name">Nom du salon</label> | 42 | <label for="room-name">Nom du salon</label> | ||
| 43 | <input | 43 | <input | ||
| 44 | id="room-name" | 44 | id="room-name" | ||
| 45 | + | type="text" | |||
| 45 | maxlength="40" | 46 | maxlength="40" | ||
| 46 | minlength="2" | 47 | minlength="2" | ||
| 47 | bind:value={name} | 48 | bind:value={name} | ||
| 48 | oninput={() => (error = '')} | 49 | oninput={() => (error = '')} | ||
| 49 | /> | 50 | /> | ||
| 50 | <label for="room-topic">Sujet (facultatif)</label> | 51 | <label for="room-topic">Sujet (facultatif)</label> | ||
| 51 | - | <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} /> | 52 | + | <input |
| 53 | + | id="room-topic" | |||
| 54 | + | type="text" | |||
| 55 | + | maxlength="120" | |||
| 56 | + | bind:value={topic} | |||
| 57 | + | oninput={() => (error = '')} | |||
| 58 | + | /> | |||
| 52 | {#if error}<span role="alert">{error}</span>{/if} | 59 | {#if error}<span role="alert">{error}</span>{/if} | ||
| 53 | {:else} | 60 | {:else} | ||
| 54 | <p>Connectez-vous pour créer un salon.</p> | 61 | <p>Connectez-vous pour créer un salon.</p> | ||
M src/lib/components/desktop/sign-on-window.svelte +1 -0
| @@ -101,6 +101,7 @@ | |||
| 101 | 101 | playSound('welcome'); | |
| 102 | 102 | signedOnAs = name; | |
| 103 | 103 | desktop.openBuddyList(); | |
| 104 | + | desktop.focus(WINDOW_ID); | |
| 104 | 105 | await wait(1200); | |
| 105 | 106 | desktop.close(WINDOW_ID); | |
| 106 | 107 | } finally { | |
| @@ -101,6 +101,7 @@ | |||||
| 101 | playSound('welcome'); | 101 | playSound('welcome'); | ||
| 102 | signedOnAs = name; | 102 | signedOnAs = name; | ||
| 103 | desktop.openBuddyList(); | 103 | desktop.openBuddyList(); | ||
| 104 | + | desktop.focus(WINDOW_ID); | |||
| 104 | await wait(1200); | 105 | await wait(1200); | ||
| 105 | desktop.close(WINDOW_ID); | 106 | desktop.close(WINDOW_ID); | ||
| 106 | } finally { | 107 | } finally { | ||