// 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;
}