feat(chat-room): open rooms as XP tabs in a single Salon window
- desktop state tracks roomTabs/activeRoomId; openChatRoom adds or selects a tab - new chat-rooms-window hosts the menu bar, XP tablist and one mounted ChatRoom per tab so drafts and scroll survive tab switches - chat-room is now window-less content
- commit
- 6750577fb5f456dbe026c0d316070b11b624d73a
- author
- Kit · · 2026-09-27 13:48 UTC
- parent
- cb6b0af
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
4 files · +205 -36
M src/lib/components/chat-room.svelte +13 -27
| @@ -12,7 +12,6 @@ | |||
| 12 | 12 | import LoadingButton from './ui/button-loading.svelte'; | |
| 13 | 13 | import Tooltip from './ui/tooltip.svelte'; | |
| 14 | 14 | import LoadingDots from './ui/loading-dots.svelte'; | |
| 15 | - | import XpWindow from './desktop/xp-window.svelte'; | |
| 16 | 15 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 17 | 16 | import { useAimClient } from '$lib/aim/client'; | |
| 18 | 17 | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | |
| @@ -37,16 +36,16 @@ | |||
| 37 | 36 | let { | |
| 38 | 37 | roomId: requestedRoomId = null, | |
| 39 | 38 | roomName = 'General', | |
| 40 | - | initialTextStyle = DEFAULT_TEXT_STYLE | |
| 39 | + | initialTextStyle = DEFAULT_TEXT_STYLE, | |
| 40 | + | showUserList = false | |
| 41 | 41 | } = $props<{ | |
| 42 | 42 | roomId?: Id<'chatRooms'> | null; | |
| 43 | 43 | roomName?: string; | |
| 44 | 44 | initialTextStyle?: TextStyle; | |
| 45 | + | /** Mobile only: whether the people list is expanded */ | |
| 46 | + | showUserList?: boolean; | |
| 45 | 47 | }>(); | |
| 46 | 48 | ||
| 47 | - | // svelte-ignore state_referenced_locally | |
| 48 | - | const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default'; | |
| 49 | - | ||
| 50 | 49 | const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL); | |
| 51 | 50 | const aim = useAimClient(); | |
| 52 | 51 | ||
| @@ -176,7 +175,6 @@ | |||
| 176 | 175 | // ============ WINDOW STATE ============ | |
| 177 | 176 | ||
| 178 | 177 | let isMobile = $state(false); | |
| 179 | - | let showUserList = $state(false); | |
| 180 | 178 | ||
| 181 | 179 | // ============ USER LIST INTERACTIONS (AIM-style) ============ | |
| 182 | 180 | ||
| @@ -1052,26 +1050,7 @@ | |||
| 1052 | 1050 | }); | |
| 1053 | 1051 | </script> | |
| 1054 | 1052 | ||
| 1055 | - | <XpWindow | |
| 1056 | - | id={windowId} | |
| 1057 | - | width={800} | |
| 1058 | - | height={600} | |
| 1059 | - | minWidth={420} | |
| 1060 | - | minHeight={420} | |
| 1061 | - | showMaximize | |
| 1062 | - | bodyClass="chat-room-body" | |
| 1063 | - | > | |
| 1064 | - | {#snippet menu()} | |
| 1065 | - | <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button> | |
| 1066 | - | <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button> | |
| 1067 | - | <button type="button" onclick={() => (showUserList = !showUserList)}> | |
| 1068 | - | {showUserList || !isMobile ? 'Participants' : 'Participants'} | |
| 1069 | - | </button> | |
| 1070 | - | {#if !currentUser} | |
| 1071 | - | <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button> | |
| 1072 | - | {/if} | |
| 1073 | - | {/snippet} | |
| 1074 | - | ||
| 1053 | + | <div class="chat-room"> | |
| 1075 | 1054 | <div class="room-header"> | |
| 1076 | 1055 | <img src="/aim/chat-room-16.png" alt="" width="16" height="16" /> | |
| 1077 | 1056 | <span class="room-name">{roomName}</span> | |
| @@ -1448,9 +1427,16 @@ | |||
| 1448 | 1427 | </div> | |
| 1449 | 1428 | {/if} | |
| 1450 | 1429 | </div> | |
| 1451 | - | </XpWindow> | |
| 1430 | + | </div> | |
| 1452 | 1431 | ||
| 1453 | 1432 | <style> | |
| 1433 | + | .chat-room { | |
| 1434 | + | flex: 1 1 auto; | |
| 1435 | + | min-height: 0; | |
| 1436 | + | display: flex; | |
| 1437 | + | flex-direction: column; | |
| 1438 | + | } | |
| 1439 | + | ||
| 1454 | 1440 | .chat-area, | |
| 1455 | 1441 | .users-list { | |
| 1456 | 1442 | font-size: 1rem; | |
| @@ -12,7 +12,6 @@ | |||||
| 12 | import LoadingButton from './ui/button-loading.svelte'; | 12 | import LoadingButton from './ui/button-loading.svelte'; | ||
| 13 | import Tooltip from './ui/tooltip.svelte'; | 13 | import Tooltip from './ui/tooltip.svelte'; | ||
| 14 | import LoadingDots from './ui/loading-dots.svelte'; | 14 | import LoadingDots from './ui/loading-dots.svelte'; | ||
| 15 | - | import XpWindow from './desktop/xp-window.svelte'; | |||
| 16 | import { desktop } from '$lib/states/desktop.svelte'; | 15 | import { desktop } from '$lib/states/desktop.svelte'; | ||
| 17 | import { useAimClient } from '$lib/aim/client'; | 16 | import { useAimClient } from '$lib/aim/client'; | ||
| 18 | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | 17 | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | ||
| @@ -37,16 +36,16 @@ | |||||
| 37 | let { | 36 | let { | ||
| 38 | roomId: requestedRoomId = null, | 37 | roomId: requestedRoomId = null, | ||
| 39 | roomName = 'General', | 38 | roomName = 'General', | ||
| 40 | - | initialTextStyle = DEFAULT_TEXT_STYLE | 39 | + | initialTextStyle = DEFAULT_TEXT_STYLE, |
| 40 | + | showUserList = false | |||
| 41 | } = $props<{ | 41 | } = $props<{ | ||
| 42 | roomId?: Id<'chatRooms'> | null; | 42 | roomId?: Id<'chatRooms'> | null; | ||
| 43 | roomName?: string; | 43 | roomName?: string; | ||
| 44 | initialTextStyle?: TextStyle; | 44 | initialTextStyle?: TextStyle; | ||
| 45 | + | /** Mobile only: whether the people list is expanded */ | |||
| 46 | + | showUserList?: boolean; | |||
| 45 | }>(); | 47 | }>(); | ||
| 46 | 48 | ||||
| 47 | - | // svelte-ignore state_referenced_locally | |||
| 48 | - | const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default'; | |||
| 49 | - | ||||
| 50 | const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL); | 49 | const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL); | ||
| 51 | const aim = useAimClient(); | 50 | const aim = useAimClient(); | ||
| 52 | 51 | ||||
| @@ -176,7 +175,6 @@ | |||||
| 176 | // ============ WINDOW STATE ============ | 175 | // ============ WINDOW STATE ============ | ||
| 177 | 176 | ||||
| 178 | let isMobile = $state(false); | 177 | let isMobile = $state(false); | ||
| 179 | - | let showUserList = $state(false); | |||
| 180 | 178 | ||||
| 181 | // ============ USER LIST INTERACTIONS (AIM-style) ============ | 179 | // ============ USER LIST INTERACTIONS (AIM-style) ============ | ||
| 182 | 180 | ||||
| @@ -1052,26 +1050,7 @@ | |||||
| 1052 | }); | 1050 | }); | ||
| 1053 | </script> | 1051 | </script> | ||
| 1054 | 1052 | ||||
| 1055 | - | <XpWindow | 1053 | + | <div class="chat-room"> |
| 1056 | - | id={windowId} | |||
| 1057 | - | width={800} | |||
| 1058 | - | height={600} | |||
| 1059 | - | minWidth={420} | |||
| 1060 | - | minHeight={420} | |||
| 1061 | - | showMaximize | |||
| 1062 | - | bodyClass="chat-room-body" | |||
| 1063 | - | > | |||
| 1064 | - | {#snippet menu()} | |||
| 1065 | - | <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button> | |||
| 1066 | - | <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button> | |||
| 1067 | - | <button type="button" onclick={() => (showUserList = !showUserList)}> | |||
| 1068 | - | {showUserList || !isMobile ? 'Participants' : 'Participants'} | |||
| 1069 | - | </button> | |||
| 1070 | - | {#if !currentUser} | |||
| 1071 | - | <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button> | |||
| 1072 | - | {/if} | |||
| 1073 | - | {/snippet} | |||
| 1074 | - | ||||
| 1075 | <div class="room-header"> | 1054 | <div class="room-header"> | ||
| 1076 | <img src="/aim/chat-room-16.png" alt="" width="16" height="16" /> | 1055 | <img src="/aim/chat-room-16.png" alt="" width="16" height="16" /> | ||
| 1077 | <span class="room-name">{roomName}</span> | 1056 | <span class="room-name">{roomName}</span> | ||
| @@ -1448,9 +1427,16 @@ | |||||
| 1448 | </div> | 1427 | </div> | ||
| 1449 | {/if} | 1428 | {/if} | ||
| 1450 | </div> | 1429 | </div> | ||
| 1451 | - | </XpWindow> | 1430 | + | </div> |
| 1452 | 1431 | ||||
| 1453 | <style> | 1432 | <style> | ||
| 1433 | + | .chat-room { | |||
| 1434 | + | flex: 1 1 auto; | |||
| 1435 | + | min-height: 0; | |||
| 1436 | + | display: flex; | |||
| 1437 | + | flex-direction: column; | |||
| 1438 | + | } | |||
| 1439 | + | ||||
| 1454 | .chat-area, | 1440 | .chat-area, | ||
| 1455 | .users-list { | 1441 | .users-list { | ||
| 1456 | font-size: 1rem; | 1442 | font-size: 1rem; | ||
A src/lib/components/desktop/chat-rooms-window.svelte +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import ChatRoom from '../chat-room.svelte'; | |
| 3 | + | import XpWindow from './xp-window.svelte'; | |
| 4 | + | import { desktop, CHAT_WINDOW_ID } from '$lib/states/desktop.svelte'; | |
| 5 | + | import { chatState } from '$lib/states/chat.svelte'; | |
| 6 | + | import type { TextStyle } from '$lib/types/text-formatting'; | |
| 7 | + | ||
| 8 | + | let { initialTextStyle }: { initialTextStyle?: TextStyle } = $props(); | |
| 9 | + | ||
| 10 | + | const currentUser = $derived(chatState.getCurrentUser()); | |
| 11 | + | let showUserList = $state(false); | |
| 12 | + | ||
| 13 | + | const tabKey = (roomId: string | null) => roomId ?? 'default'; | |
| 14 | + | </script> | |
| 15 | + | ||
| 16 | + | <XpWindow | |
| 17 | + | id={CHAT_WINDOW_ID} | |
| 18 | + | width={800} | |
| 19 | + | height={600} | |
| 20 | + | minWidth={420} | |
| 21 | + | minHeight={420} | |
| 22 | + | showMaximize | |
| 23 | + | bodyClass="chat-rooms-body" | |
| 24 | + | > | |
| 25 | + | {#snippet menu()} | |
| 26 | + | <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button> | |
| 27 | + | <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button> | |
| 28 | + | <button type="button" onclick={() => (showUserList = !showUserList)}>Participants</button> | |
| 29 | + | {#if !currentUser} | |
| 30 | + | <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button> | |
| 31 | + | {/if} | |
| 32 | + | {/snippet} | |
| 33 | + | ||
| 34 | + | <menu role="tablist" class="room-tabs"> | |
| 35 | + | {#each desktop.roomTabs as tab (tabKey(tab.roomId))} | |
| 36 | + | {@const active = tab.roomId === desktop.activeRoomId} | |
| 37 | + | <button | |
| 38 | + | type="button" | |
| 39 | + | role="tab" | |
| 40 | + | aria-selected={active} | |
| 41 | + | aria-controls="room-panel-{tabKey(tab.roomId)}" | |
| 42 | + | onclick={() => desktop.selectRoomTab(tab.roomId)} | |
| 43 | + | onauxclick={(e) => { | |
| 44 | + | if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId); | |
| 45 | + | }} | |
| 46 | + | > | |
| 47 | + | <img src="/aim/chat-room-16.png" alt="" width="14" height="14" /> | |
| 48 | + | <span>{tab.roomName}</span> | |
| 49 | + | {#if desktop.roomTabs.length > 1} | |
| 50 | + | <span | |
| 51 | + | class="tab-close" | |
| 52 | + | role="button" | |
| 53 | + | tabindex="-1" | |
| 54 | + | aria-label="Fermer {tab.roomName}" | |
| 55 | + | onclick={(e) => { | |
| 56 | + | e.stopPropagation(); | |
| 57 | + | desktop.closeRoomTab(tab.roomId); | |
| 58 | + | }} | |
| 59 | + | onkeydown={() => {}}>×</span | |
| 60 | + | > | |
| 61 | + | {/if} | |
| 62 | + | </button> | |
| 63 | + | {/each} | |
| 64 | + | </menu> | |
| 65 | + | ||
| 66 | + | <div class="tab-panels"> | |
| 67 | + | {#each desktop.roomTabs as tab (tabKey(tab.roomId))} | |
| 68 | + | <!-- Every open room stays mounted so switching tabs keeps scroll and draft --> | |
| 69 | + | <div | |
| 70 | + | id="room-panel-{tabKey(tab.roomId)}" | |
| 71 | + | role="tabpanel" | |
| 72 | + | class="tab-panel" | |
| 73 | + | hidden={tab.roomId !== desktop.activeRoomId} | |
| 74 | + | > | |
| 75 | + | <ChatRoom roomId={tab.roomId} roomName={tab.roomName} {initialTextStyle} {showUserList} /> | |
| 76 | + | </div> | |
| 77 | + | {/each} | |
| 78 | + | </div> | |
| 79 | + | </XpWindow> | |
| 80 | + | ||
| 81 | + | <style> | |
| 82 | + | .room-tabs { | |
| 83 | + | flex: 0 0 auto; | |
| 84 | + | margin-top: 2px; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | .room-tabs button { | |
| 88 | + | display: flex; | |
| 89 | + | align-items: center; | |
| 90 | + | gap: 4px; | |
| 91 | + | padding: 2px 8px 3px; | |
| 92 | + | font: | |
| 93 | + | 11px Tahoma, | |
| 94 | + | 'Pixelated MS Sans Serif', | |
| 95 | + | sans-serif; | |
| 96 | + | max-width: 180px; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | .room-tabs button span:first-of-type { | |
| 100 | + | overflow: hidden; | |
| 101 | + | text-overflow: ellipsis; | |
| 102 | + | white-space: nowrap; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | .tab-close { | |
| 106 | + | margin-left: 2px; | |
| 107 | + | padding: 0 3px; | |
| 108 | + | line-height: 12px; | |
| 109 | + | color: #555; | |
| 110 | + | cursor: default; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | .tab-close:hover { | |
| 114 | + | background: #c75050; | |
| 115 | + | color: #fff; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | .tab-panels { | |
| 119 | + | flex: 1 1 auto; | |
| 120 | + | min-height: 0; | |
| 121 | + | display: flex; | |
| 122 | + | flex-direction: column; | |
| 123 | + | background: #fcfcfe; | |
| 124 | + | border: 1px solid #919b9c; | |
| 125 | + | padding: 4px; | |
| 126 | + | position: relative; | |
| 127 | + | z-index: 2; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /* xp.css frames every [role=tabpanel]; the outer .tab-panels is the frame here */ | |
| 131 | + | .tab-panel { | |
| 132 | + | padding: 0; | |
| 133 | + | margin: 0; | |
| 134 | + | border: 0; | |
| 135 | + | background: transparent; | |
| 136 | + | flex: 1 1 auto; | |
| 137 | + | min-height: 0; | |
| 138 | + | display: flex; | |
| 139 | + | flex-direction: column; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | .tab-panel[hidden] { | |
| 143 | + | display: none; | |
| 144 | + | } | |
| 145 | + | </style> | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import ChatRoom from '../chat-room.svelte'; | |||
| 3 | + | import XpWindow from './xp-window.svelte'; | |||
| 4 | + | import { desktop, CHAT_WINDOW_ID } from '$lib/states/desktop.svelte'; | |||
| 5 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 6 | + | import type { TextStyle } from '$lib/types/text-formatting'; | |||
| 7 | + | ||||
| 8 | + | let { initialTextStyle }: { initialTextStyle?: TextStyle } = $props(); | |||
| 9 | + | ||||
| 10 | + | const currentUser = $derived(chatState.getCurrentUser()); | |||
| 11 | + | let showUserList = $state(false); | |||
| 12 | + | ||||
| 13 | + | const tabKey = (roomId: string | null) => roomId ?? 'default'; | |||
| 14 | + | </script> | |||
| 15 | + | ||||
| 16 | + | <XpWindow | |||
| 17 | + | id={CHAT_WINDOW_ID} | |||
| 18 | + | width={800} | |||
| 19 | + | height={600} | |||
| 20 | + | minWidth={420} | |||
| 21 | + | minHeight={420} | |||
| 22 | + | showMaximize | |||
| 23 | + | bodyClass="chat-rooms-body" | |||
| 24 | + | > | |||
| 25 | + | {#snippet menu()} | |||
| 26 | + | <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button> | |||
| 27 | + | <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button> | |||
| 28 | + | <button type="button" onclick={() => (showUserList = !showUserList)}>Participants</button> | |||
| 29 | + | {#if !currentUser} | |||
| 30 | + | <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button> | |||
| 31 | + | {/if} | |||
| 32 | + | {/snippet} | |||
| 33 | + | ||||
| 34 | + | <menu role="tablist" class="room-tabs"> | |||
| 35 | + | {#each desktop.roomTabs as tab (tabKey(tab.roomId))} | |||
| 36 | + | {@const active = tab.roomId === desktop.activeRoomId} | |||
| 37 | + | <button | |||
| 38 | + | type="button" | |||
| 39 | + | role="tab" | |||
| 40 | + | aria-selected={active} | |||
| 41 | + | aria-controls="room-panel-{tabKey(tab.roomId)}" | |||
| 42 | + | onclick={() => desktop.selectRoomTab(tab.roomId)} | |||
| 43 | + | onauxclick={(e) => { | |||
| 44 | + | if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId); | |||
| 45 | + | }} | |||
| 46 | + | > | |||
| 47 | + | <img src="/aim/chat-room-16.png" alt="" width="14" height="14" /> | |||
| 48 | + | <span>{tab.roomName}</span> | |||
| 49 | + | {#if desktop.roomTabs.length > 1} | |||
| 50 | + | <span | |||
| 51 | + | class="tab-close" | |||
| 52 | + | role="button" | |||
| 53 | + | tabindex="-1" | |||
| 54 | + | aria-label="Fermer {tab.roomName}" | |||
| 55 | + | onclick={(e) => { | |||
| 56 | + | e.stopPropagation(); | |||
| 57 | + | desktop.closeRoomTab(tab.roomId); | |||
| 58 | + | }} | |||
| 59 | + | onkeydown={() => {}}>×</span | |||
| 60 | + | > | |||
| 61 | + | {/if} | |||
| 62 | + | </button> | |||
| 63 | + | {/each} | |||
| 64 | + | </menu> | |||
| 65 | + | ||||
| 66 | + | <div class="tab-panels"> | |||
| 67 | + | {#each desktop.roomTabs as tab (tabKey(tab.roomId))} | |||
| 68 | + | <!-- Every open room stays mounted so switching tabs keeps scroll and draft --> | |||
| 69 | + | <div | |||
| 70 | + | id="room-panel-{tabKey(tab.roomId)}" | |||
| 71 | + | role="tabpanel" | |||
| 72 | + | class="tab-panel" | |||
| 73 | + | hidden={tab.roomId !== desktop.activeRoomId} | |||
| 74 | + | > | |||
| 75 | + | <ChatRoom roomId={tab.roomId} roomName={tab.roomName} {initialTextStyle} {showUserList} /> | |||
| 76 | + | </div> | |||
| 77 | + | {/each} | |||
| 78 | + | </div> | |||
| 79 | + | </XpWindow> | |||
| 80 | + | ||||
| 81 | + | <style> | |||
| 82 | + | .room-tabs { | |||
| 83 | + | flex: 0 0 auto; | |||
| 84 | + | margin-top: 2px; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | .room-tabs button { | |||
| 88 | + | display: flex; | |||
| 89 | + | align-items: center; | |||
| 90 | + | gap: 4px; | |||
| 91 | + | padding: 2px 8px 3px; | |||
| 92 | + | font: | |||
| 93 | + | 11px Tahoma, | |||
| 94 | + | 'Pixelated MS Sans Serif', | |||
| 95 | + | sans-serif; | |||
| 96 | + | max-width: 180px; | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | .room-tabs button span:first-of-type { | |||
| 100 | + | overflow: hidden; | |||
| 101 | + | text-overflow: ellipsis; | |||
| 102 | + | white-space: nowrap; | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | .tab-close { | |||
| 106 | + | margin-left: 2px; | |||
| 107 | + | padding: 0 3px; | |||
| 108 | + | line-height: 12px; | |||
| 109 | + | color: #555; | |||
| 110 | + | cursor: default; | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | .tab-close:hover { | |||
| 114 | + | background: #c75050; | |||
| 115 | + | color: #fff; | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | .tab-panels { | |||
| 119 | + | flex: 1 1 auto; | |||
| 120 | + | min-height: 0; | |||
| 121 | + | display: flex; | |||
| 122 | + | flex-direction: column; | |||
| 123 | + | background: #fcfcfe; | |||
| 124 | + | border: 1px solid #919b9c; | |||
| 125 | + | padding: 4px; | |||
| 126 | + | position: relative; | |||
| 127 | + | z-index: 2; | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | /* xp.css frames every [role=tabpanel]; the outer .tab-panels is the frame here */ | |||
| 131 | + | .tab-panel { | |||
| 132 | + | padding: 0; | |||
| 133 | + | margin: 0; | |||
| 134 | + | border: 0; | |||
| 135 | + | background: transparent; | |||
| 136 | + | flex: 1 1 auto; | |||
| 137 | + | min-height: 0; | |||
| 138 | + | display: flex; | |||
| 139 | + | flex-direction: column; | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | .tab-panel[hidden] { | |||
| 143 | + | display: none; | |||
| 144 | + | } | |||
| 145 | + | </style> | |||
M src/lib/states/desktop.svelte.ts +43 -4
| @@ -36,11 +36,13 @@ export interface ImWindowProps { | |||
| 36 | 36 | otherNickname: string; | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | export interface ChatRoomWindowProps { | |
| 39 | + | export interface ChatRoomTab { | |
| 40 | 40 | roomId: Id<'chatRooms'> | null; // null = default room | |
| 41 | 41 | roomName: string; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | export const CHAT_WINDOW_ID = 'chat-rooms'; | |
| 45 | + | ||
| 44 | 46 | export interface ProfileWindowProps { | |
| 45 | 47 | userId: Id<'users'>; | |
| 46 | 48 | nickname: string; | |
| @@ -51,6 +53,10 @@ class DesktopState { | |||
| 51 | 53 | focusedId = $state<string | null>(null); | |
| 52 | 54 | private nextZ = 10; | |
| 53 | 55 | ||
| 56 | + | /** Open chat room tabs (all shown in the single chat-room window). */ | |
| 57 | + | roomTabs = $state<ChatRoomTab[]>([]); | |
| 58 | + | activeRoomId = $state<Id<'chatRooms'> | null>(null); | |
| 59 | + | ||
| 54 | 60 | /** Whether the login "sign on" sequence is running (modem animation). */ | |
| 55 | 61 | signingOn = $state(false); | |
| 56 | 62 | ||
| @@ -90,6 +96,10 @@ class DesktopState { | |||
| 90 | 96 | } | |
| 91 | 97 | ||
| 92 | 98 | close(id: string) { | |
| 99 | + | if (id === CHAT_WINDOW_ID) { | |
| 100 | + | this.roomTabs = []; | |
| 101 | + | this.activeRoomId = null; | |
| 102 | + | } | |
| 93 | 103 | this.windows = this.windows.filter((w) => w.id !== id); | |
| 94 | 104 | if (this.focusedId === id) { | |
| 95 | 105 | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | |
| @@ -147,17 +157,46 @@ class DesktopState { | |||
| 147 | 157 | }); | |
| 148 | 158 | } | |
| 149 | 159 | ||
| 160 | + | /** | |
| 161 | + | * Chat rooms live as tabs inside one "Salon" window. Opening a room adds | |
| 162 | + | * (or selects) its tab and brings the window forward. | |
| 163 | + | */ | |
| 150 | 164 | openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) { | |
| 151 | - | const props: ChatRoomWindowProps = { roomId, roomName }; | |
| 165 | + | if (!this.roomTabs.some((t) => t.roomId === roomId)) { | |
| 166 | + | this.roomTabs = [...this.roomTabs, { roomId, roomName }]; | |
| 167 | + | } | |
| 168 | + | this.activeRoomId = roomId; | |
| 152 | 169 | return this.open({ | |
| 153 | - | id: roomId ? `room-${roomId}` : 'room-default', | |
| 170 | + | id: CHAT_WINDOW_ID, | |
| 154 | 171 | kind: 'chat-room', | |
| 155 | 172 | title: `Salon : ${roomName}`, | |
| 156 | 173 | icon: '/aim/chat-room-16.png', | |
| 157 | - | props: props as unknown as Record<string, unknown> | |
| 174 | + | props: {} | |
| 158 | 175 | }); | |
| 159 | 176 | } | |
| 160 | 177 | ||
| 178 | + | selectRoomTab(roomId: Id<'chatRooms'> | null) { | |
| 179 | + | const tab = this.roomTabs.find((t) => t.roomId === roomId); | |
| 180 | + | if (!tab) return; | |
| 181 | + | this.activeRoomId = roomId; | |
| 182 | + | const win = this.get(CHAT_WINDOW_ID); | |
| 183 | + | if (win) win.title = `Salon : ${tab.roomName}`; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | closeRoomTab(roomId: Id<'chatRooms'> | null) { | |
| 187 | + | const index = this.roomTabs.findIndex((t) => t.roomId === roomId); | |
| 188 | + | if (index === -1) return; | |
| 189 | + | this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId); | |
| 190 | + | if (this.roomTabs.length === 0) { | |
| 191 | + | this.close(CHAT_WINDOW_ID); | |
| 192 | + | return; | |
| 193 | + | } | |
| 194 | + | if (this.activeRoomId === roomId) { | |
| 195 | + | const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)]; | |
| 196 | + | this.selectRoomTab(next.roomId); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 161 | 200 | openIm(props: ImWindowProps) { | |
| 162 | 201 | return this.open({ | |
| 163 | 202 | id: `im-${props.roomId}`, | |
| @@ -36,11 +36,13 @@ export interface ImWindowProps { | |||||
| 36 | otherNickname: string; | 36 | otherNickname: string; | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | - | export interface ChatRoomWindowProps { | 39 | + | export interface ChatRoomTab { |
| 40 | roomId: Id<'chatRooms'> | null; // null = default room | 40 | roomId: Id<'chatRooms'> | null; // null = default room | ||
| 41 | roomName: string; | 41 | roomName: string; | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | + | export const CHAT_WINDOW_ID = 'chat-rooms'; | |||
| 45 | + | ||||
| 44 | export interface ProfileWindowProps { | 46 | export interface ProfileWindowProps { | ||
| 45 | userId: Id<'users'>; | 47 | userId: Id<'users'>; | ||
| 46 | nickname: string; | 48 | nickname: string; | ||
| @@ -51,6 +53,10 @@ class DesktopState { | |||||
| 51 | focusedId = $state<string | null>(null); | 53 | focusedId = $state<string | null>(null); | ||
| 52 | private nextZ = 10; | 54 | private nextZ = 10; | ||
| 53 | 55 | ||||
| 56 | + | /** Open chat room tabs (all shown in the single chat-room window). */ | |||
| 57 | + | roomTabs = $state<ChatRoomTab[]>([]); | |||
| 58 | + | activeRoomId = $state<Id<'chatRooms'> | null>(null); | |||
| 59 | + | ||||
| 54 | /** Whether the login "sign on" sequence is running (modem animation). */ | 60 | /** Whether the login "sign on" sequence is running (modem animation). */ | ||
| 55 | signingOn = $state(false); | 61 | signingOn = $state(false); | ||
| 56 | 62 | ||||
| @@ -90,6 +96,10 @@ class DesktopState { | |||||
| 90 | } | 96 | } | ||
| 91 | 97 | ||||
| 92 | close(id: string) { | 98 | close(id: string) { | ||
| 99 | + | if (id === CHAT_WINDOW_ID) { | |||
| 100 | + | this.roomTabs = []; | |||
| 101 | + | this.activeRoomId = null; | |||
| 102 | + | } | |||
| 93 | this.windows = this.windows.filter((w) => w.id !== id); | 103 | this.windows = this.windows.filter((w) => w.id !== id); | ||
| 94 | if (this.focusedId === id) { | 104 | if (this.focusedId === id) { | ||
| 95 | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | 105 | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | ||
| @@ -147,17 +157,46 @@ class DesktopState { | |||||
| 147 | }); | 157 | }); | ||
| 148 | } | 158 | } | ||
| 149 | 159 | ||||
| 160 | + | /** | |||
| 161 | + | * Chat rooms live as tabs inside one "Salon" window. Opening a room adds | |||
| 162 | + | * (or selects) its tab and brings the window forward. | |||
| 163 | + | */ | |||
| 150 | openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) { | 164 | openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) { | ||
| 151 | - | const props: ChatRoomWindowProps = { roomId, roomName }; | 165 | + | if (!this.roomTabs.some((t) => t.roomId === roomId)) { |
| 166 | + | this.roomTabs = [...this.roomTabs, { roomId, roomName }]; | |||
| 167 | + | } | |||
| 168 | + | this.activeRoomId = roomId; | |||
| 152 | return this.open({ | 169 | return this.open({ | ||
| 153 | - | id: roomId ? `room-${roomId}` : 'room-default', | 170 | + | id: CHAT_WINDOW_ID, |
| 154 | kind: 'chat-room', | 171 | kind: 'chat-room', | ||
| 155 | title: `Salon : ${roomName}`, | 172 | title: `Salon : ${roomName}`, | ||
| 156 | icon: '/aim/chat-room-16.png', | 173 | icon: '/aim/chat-room-16.png', | ||
| 157 | - | props: props as unknown as Record<string, unknown> | 174 | + | props: {} |
| 158 | }); | 175 | }); | ||
| 159 | } | 176 | } | ||
| 160 | 177 | ||||
| 178 | + | selectRoomTab(roomId: Id<'chatRooms'> | null) { | |||
| 179 | + | const tab = this.roomTabs.find((t) => t.roomId === roomId); | |||
| 180 | + | if (!tab) return; | |||
| 181 | + | this.activeRoomId = roomId; | |||
| 182 | + | const win = this.get(CHAT_WINDOW_ID); | |||
| 183 | + | if (win) win.title = `Salon : ${tab.roomName}`; | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | closeRoomTab(roomId: Id<'chatRooms'> | null) { | |||
| 187 | + | const index = this.roomTabs.findIndex((t) => t.roomId === roomId); | |||
| 188 | + | if (index === -1) return; | |||
| 189 | + | this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId); | |||
| 190 | + | if (this.roomTabs.length === 0) { | |||
| 191 | + | this.close(CHAT_WINDOW_ID); | |||
| 192 | + | return; | |||
| 193 | + | } | |||
| 194 | + | if (this.activeRoomId === roomId) { | |||
| 195 | + | const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)]; | |||
| 196 | + | this.selectRoomTab(next.roomId); | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 161 | openIm(props: ImWindowProps) { | 200 | openIm(props: ImWindowProps) { | ||
| 162 | return this.open({ | 201 | return this.open({ | ||
| 163 | id: `im-${props.roomId}`, | 202 | id: `im-${props.roomId}`, | ||
M src/routes/+page.svelte +4 -5
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { onMount } from 'svelte'; | |
| 3 | 3 | import { browser } from '$app/environment'; | |
| 4 | - | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 4 | + | import ChatRoomsWindow from '$lib/components/desktop/chat-rooms-window.svelte'; | |
| 5 | 5 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 6 | 6 | import Taskbar from '$lib/components/desktop/taskbar.svelte'; | |
| 7 | 7 | import BuddyList from '$lib/components/desktop/buddy-list.svelte'; | |
| @@ -14,7 +14,7 @@ | |||
| 14 | 14 | import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte'; | |
| 15 | 15 | import { | |
| 16 | 16 | desktop, | |
| 17 | - | type ChatRoomWindowProps, | |
| 17 | + | CHAT_WINDOW_ID, | |
| 18 | 18 | type ImWindowProps, | |
| 19 | 19 | type ProfileWindowProps | |
| 20 | 20 | } from '$lib/states/desktop.svelte'; | |
| @@ -34,7 +34,7 @@ | |||
| 34 | 34 | } else { | |
| 35 | 35 | desktop.openLogin('signin'); | |
| 36 | 36 | } | |
| 37 | - | desktop.focus('room-default'); | |
| 37 | + | desktop.focus(CHAT_WINDOW_ID); | |
| 38 | 38 | }); | |
| 39 | 39 | ||
| 40 | 40 | // When the session appears (login) make sure the buddy list is around; | |
| @@ -68,8 +68,7 @@ | |||
| 68 | 68 | ||
| 69 | 69 | {#each desktop.windows as win (win.id)} | |
| 70 | 70 | {#if win.kind === 'chat-room'} | |
| 71 | - | {@const p = win.props as unknown as ChatRoomWindowProps} | |
| 72 | - | <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} /> | |
| 71 | + | <ChatRoomsWindow initialTextStyle={pageData.textStyle} /> | |
| 73 | 72 | {:else if win.kind === 'buddy-list'} | |
| 74 | 73 | <BuddyList /> | |
| 75 | 74 | {:else if win.kind === 'im'} | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { onMount } from 'svelte'; | 2 | import { onMount } from 'svelte'; | ||
| 3 | import { browser } from '$app/environment'; | 3 | import { browser } from '$app/environment'; | ||
| 4 | - | import ChatRoom from '$lib/components/chat-room.svelte'; | 4 | + | import ChatRoomsWindow from '$lib/components/desktop/chat-rooms-window.svelte'; |
| 5 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | 5 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | ||
| 6 | import Taskbar from '$lib/components/desktop/taskbar.svelte'; | 6 | import Taskbar from '$lib/components/desktop/taskbar.svelte'; | ||
| 7 | import BuddyList from '$lib/components/desktop/buddy-list.svelte'; | 7 | import BuddyList from '$lib/components/desktop/buddy-list.svelte'; | ||
| @@ -14,7 +14,7 @@ | |||||
| 14 | import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte'; | 14 | import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte'; | ||
| 15 | import { | 15 | import { | ||
| 16 | desktop, | 16 | desktop, | ||
| 17 | - | type ChatRoomWindowProps, | 17 | + | CHAT_WINDOW_ID, |
| 18 | type ImWindowProps, | 18 | type ImWindowProps, | ||
| 19 | type ProfileWindowProps | 19 | type ProfileWindowProps | ||
| 20 | } from '$lib/states/desktop.svelte'; | 20 | } from '$lib/states/desktop.svelte'; | ||
| @@ -34,7 +34,7 @@ | |||||
| 34 | } else { | 34 | } else { | ||
| 35 | desktop.openLogin('signin'); | 35 | desktop.openLogin('signin'); | ||
| 36 | } | 36 | } | ||
| 37 | - | desktop.focus('room-default'); | 37 | + | desktop.focus(CHAT_WINDOW_ID); |
| 38 | }); | 38 | }); | ||
| 39 | 39 | ||||
| 40 | // When the session appears (login) make sure the buddy list is around; | 40 | // When the session appears (login) make sure the buddy list is around; | ||
| @@ -68,8 +68,7 @@ | |||||
| 68 | 68 | ||||
| 69 | {#each desktop.windows as win (win.id)} | 69 | {#each desktop.windows as win (win.id)} | ||
| 70 | {#if win.kind === 'chat-room'} | 70 | {#if win.kind === 'chat-room'} | ||
| 71 | - | {@const p = win.props as unknown as ChatRoomWindowProps} | 71 | + | <ChatRoomsWindow initialTextStyle={pageData.textStyle} /> |
| 72 | - | <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} /> | |||
| 73 | {:else if win.kind === 'buddy-list'} | 72 | {:else if win.kind === 'buddy-list'} | ||
| 74 | <BuddyList /> | 73 | <BuddyList /> | ||
| 75 | {:else if win.kind === 'im'} | 74 | {:else if win.kind === 'im'} | ||