// The persistent app chrome (sidebar with collections + main content // area), rendered once per login. The router (router.ts) only ever swaps // what's inside #mainContent - see app.ts's boot(). import './Shell.css'; import html from './Shell.html'; import { auth, getLibraries, logout } from '../api/embyApi'; import { initMusicBar } from '../musicBar/MusicBar'; import type { LibraryDto } from '../types'; // One icon per Emby CollectionType - only used by the mobile bottom // sheet (see docs/architecture.md's "Mobile navigation"); the desktop // sidebar stays plain text, unchanged. Same small inline-SVG-constant // convention as views/player/Player.ts's own icon strings. const HOME_ICON_SVG = ''; const MOVIES_ICON_SVG = ''; const SHOWS_ICON_SVG = ''; const MUSIC_ICON_SVG = ''; const MUSICVIDEOS_ICON_SVG = ''; const PLAYLISTS_ICON_SVG = ''; // Emby could in principle add a CollectionType this app doesn't map yet // (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES // would need updating first either way) - falls back to a plain folder // glyph rather than rendering an empty icon slot. const GENERIC_LIBRARY_ICON_SVG = ''; const COLLECTION_ICONS: Record = { movies: MOVIES_ICON_SVG, tvshows: SHOWS_ICON_SVG, music: MUSIC_ICON_SVG, musicvideos: MUSICVIDEOS_ICON_SVG, playlists: PLAYLISTS_ICON_SVG, }; /** One `` per library, shared by the desktop sidebar (plain text) * and the mobile sheet (icon + text) - `withIcon` is the only * difference between the two call sites below. */ function createLibraryLink(lib: LibraryDto, className: string, withIcon: boolean): HTMLAnchorElement { const a = document.createElement('a'); a.href = `#/library/${lib.id}`; a.className = className; if (withIcon) { const icon = document.createElement('span'); icon.className = 'navIcon'; icon.innerHTML = COLLECTION_ICONS[lib.collectionType] || GENERIC_LIBRARY_ICON_SVG; a.appendChild(icon); } a.appendChild(document.createTextNode(lib.name)); return a; } export async function renderShell(appEl: HTMLElement): Promise { appEl.innerHTML = html; appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG; // Mounted once, outside #mainContent, so a listen party survives // navigating the router - see musicBar/MusicBar.ts's header comment. initMusicBar(appEl.querySelector('#musicBar') as HTMLElement); // Both the sidebar's and the mobile sheet's logout button - same // label, same handler, one line each instead of a shared DOM node // fought over by two very differently-laid-out containers. appEl.querySelectorAll('.logoutBtn').forEach((btn) => { btn.textContent = `Se déconnecter (${auth.userName || ''})`; btn.addEventListener('click', async () => { await logout(); // Simplest reliable way back to a bare login screen: a real reload, // rather than hand-tearing-down every shell/router listener in place. location.hash = ''; location.reload(); }); }); const nav = appEl.querySelector('#sidebarNav')!; const status = appEl.querySelector('#libStatus')!; const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!; const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!; try { const libraries = await getLibraries(); status.remove(); libraries.forEach((lib) => { nav.appendChild(createLibraryLink(lib, 'navItem', false)); // 'playlists' gets pulled out of the "Bibliothèques" group into // its own fixed row - see docs/architecture.md. Everything else // (movies/tvshows/music/musicvideos, whatever mix this server // actually has) lands in the grid, unchanged in count or order. if (lib.collectionType === 'playlists') { mobilePlaylistsSlot.appendChild(createLibraryLink(lib, 'navItem sheetPrimary', true)); } else { mobileLibGrid.appendChild(createLibraryLink(lib, 'navItem sheetItem', true)); } }); } catch { status.textContent = 'Bibliothèques indisponibles.'; } const highlight = () => { appEl.querySelectorAll('.navItem').forEach((a) => { const isHome = a.getAttribute('href') === '#/home'; a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); }); }; highlight(); window.addEventListener('hashchange', highlight); // ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ---- const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement; const navSheet = appEl.querySelector('#mobileNavSheet')!; const navScrim = appEl.querySelector('#mobileNavScrim')!; const closeMobileNav = () => { navSheet.classList.remove('open'); navScrim.classList.remove('open'); navTrigger.setAttribute('aria-expanded', 'false'); }; navTrigger.addEventListener('click', () => { const opening = !navSheet.classList.contains('open'); navSheet.classList.toggle('open', opening); navScrim.classList.toggle('open', opening); navTrigger.setAttribute('aria-expanded', String(opening)); }); navScrim.addEventListener('click', closeMobileNav); // Any of the three zones (Accueil, a library, Listes de lecture) - // the sheet itself has no other clickable children, so this one // delegated listener covers all of them without a per-link handler. navSheet.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('.navItem')) closeMobileNav(); }); window.addEventListener('hashchange', closeMobileNav); return appEl.querySelector('#mainContent') as HTMLElement; }