/**
* js/display.js
* Handles the display and rendering of media items, hero section, details overlay, collections, and notifications.
* @module display
*/
// Import utility functions for video playback and watch data
import { playVideo, getFilmWatchData, getEpisodeWatchData, isSeriesFullyWatched } from './utils.js';
// Global state variables for managing active media details and video context
let activeDetailItem = null;
let activeVideoSrc = "";
let activeVideoContext = null;
let activeSelectedSeason = null; // Track the currently selected season
/**
* Resolves a relative asset path against the app base path.
* @param {string} path - Asset path.
* @returns {string} Absolute asset URL.
*/
function resolveAssetUrl(path) {
if (!path) return '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:')) return path;
const base = (typeof window !== 'undefined' && window.STREAMIT_BASE) ? window.STREAMIT_BASE : '/';
const safeBase = base.endsWith('/') ? base : `${base}/`;
const cleaned = String(path).replace(/^\/+/, '');
return new URL(cleaned, window.location.origin + safeBase).toString();
}
/**
* Opens media details and syncs route if available.
* @param {Object} item - Media item.
*/
function openDetailsWithRoute(item) {
if (typeof window !== 'undefined' && typeof window.openMediaDetails === 'function') {
window.openMediaDetails(item);
} else {
openDetails(item);
}
}
/**
* Opens actor details and syncs route if available.
* @param {Object} actor - Actor item.
* @param {Object} filmsData - Films data.
* @param {Object} seriesData - Series data.
*/
function openActorDetailsWithRoute(actor, filmsData, seriesData) {
if (typeof window !== 'undefined' && typeof window.openActorDetailsRoute === 'function') {
window.openActorDetailsRoute(actor, filmsData, seriesData);
} else {
openActorDetails(actor, filmsData, seriesData);
}
}
/**
* Sets up the hero section with the given media item.
* @param {Object} item - The media item to display in the hero section.
*/
export function setupHero(item) {
if (!item) return;
// Determine if the item is a series or film
const isSerie = item.type === 'serie' || item.seasons !== undefined;
// Populate hero section elements with media data
document.getElementById('heroImage').src = resolveAssetUrl(item.banner || item.poster);
document.getElementById('heroTitle').innerText = item.title;
document.getElementById('heroDesc').innerText = item.description;
document.getElementById('heroRating').innerText = item.IMDb;
document.getElementById('heroYear').innerText = item.year;
document.getElementById('heroType').innerText = isSerie ? 'Série' : 'Film';
// Set duration text (seasons count for series, duration for films)
let durationText = item.duration || "2h 15min";
if (isSerie && item.seasons) {
const nbSeasons = Object.keys(item.seasons).length;
durationText = nbSeasons + (nbSeasons > 1 ? " Saisons" : " Saison");
}
document.getElementById('heroDuration').innerText = durationText;
document.getElementById('heroGenre').innerText = item.genres?.[0] || "";
// Replace play button to clear previous event listeners
const heroBtn = document.getElementById('heroPlayBtn');
const newBtn = heroBtn.cloneNode(true);
heroBtn.parentNode.replaceChild(newBtn, heroBtn);
// Set up play button click handler based on media type
newBtn.onclick = () => {
openDetailsWithRoute(item);
if (isSerie && item.seasons?.["1"]?.[0]) {
const ctx = {
type: 'series',
title: item.title,
season: '1',
episodeIndex: 0,
episodeTitle: item.seasons["1"][0].title || 'Épisode 1'
};
activeVideoContext = ctx;
activeVideoSrc = resolveAssetUrl(item.seasons["1"][0].video);
playVideo(activeVideoSrc, ctx);
} else if (item.video) {
const ctx = { type: 'film', title: item.title };
activeVideoContext = ctx;
activeVideoSrc = resolveAssetUrl(item.video);
playVideo(activeVideoSrc, ctx);
}
};
// Set up info button to open details overlay
const infoBtn = document.getElementById('heroInfoBtn');
if (infoBtn) {
const newInfoBtn = infoBtn.cloneNode(true);
infoBtn.parentNode.replaceChild(newInfoBtn, infoBtn);
newInfoBtn.onclick = () => openDetailsWithRoute(item);
}
}
/**
* Opens the details overlay for a given media item.
* @param {Object} item - The media item to display details for.
* @param {string|null} [preferredSeason=null] - Optional season to pre-select.
*/
export function openDetails(item, preferredSeason = null) {
activeDetailItem = item;
const overlay = document.getElementById('detailsOverlay');
const isSerie = item.type === 'serie' || item.seasons !== undefined;
const playBtn = document.getElementById('detailPlayBtn');
// Populate detail overlay with media information
document.getElementById('detailHeroImg').src = resolveAssetUrl(item.banner || item.poster);
document.getElementById('detailTitle').innerText = item.title;
document.getElementById('detailDesc').innerText = item.description;
document.getElementById('detailYear').innerText = item.year;
// Calculate and display match score based on IMDb rating
const matchScore = item.IMDb ? Math.round(item.IMDb * 10) : 90;
document.getElementById('detailMatch').innerText = `Recommandé à ${matchScore}%`;
let durationText = item.duration || "2h 15min";
if (isSerie && item.seasons) {
const nbSeasons = Object.keys(item.seasons).length;
durationText = nbSeasons + (nbSeasons > 1 ? " Saisons" : " Saison");
}
document.getElementById('detailDuration').innerText = durationText;
// Generate genre pills and cast badges
document.getElementById('detailGenrePills').innerHTML = item.genres?.map(g => `<span class="text-gray-300 text-sm font-medium px-3 py-1 rounded-full bg-white/5 border border-white/10">${g}</span>`).join('') || "";
document.getElementById('detailCast').innerHTML = item.stars?.map(s => `<span class="bg-red-600/10 text-red-300 px-4 py-2 rounded-full text-sm font-bold">${s}</span>`).join('') || "Non renseigné";
document.getElementById('detailCreators').innerText = (item.directors || item.creators || []).join(", ") || "Non renseigné";
// Handle series-specific elements (seasons and episodes)
const seriesSec = document.getElementById('seriesSection');
const seasonSelect = document.getElementById('seasonSelect');
if (isSerie) {
seriesSec.classList.remove('hidden');
activeVideoSrc = "";
activeVideoContext = null;
// Populate season selector dropdown
seasonSelect.innerHTML = '';
const seasons = item.seasons || {};
const seasonKeys = Object.keys(seasons).sort((a, b) => parseInt(a) - parseInt(b));
if (seasonKeys.length > 0) {
seasonKeys.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.innerText = `Saison ${key}`;
seasonSelect.appendChild(opt);
});
// Determine which season to display
let initialSeason = seasonKeys[0];
if (preferredSeason && seasonKeys.includes(String(preferredSeason))) {
initialSeason = String(preferredSeason);
} else if (activeSelectedSeason && seasonKeys.includes(String(activeSelectedSeason))) {
initialSeason = String(activeSelectedSeason);
}
// Set the select value and render episodes
seasonSelect.value = initialSeason;
activeSelectedSeason = initialSeason;
renderEpisodes(seasons[initialSeason], initialSeason);
// Handle season selection changes
seasonSelect.onchange = (e) => {
const rawValue = e.target && typeof e.target.value === 'string' ? e.target.value : null;
const selectedKey = rawValue && Object.prototype.hasOwnProperty.call(seasons, rawValue)
? rawValue
: null;
const selectedSeason = selectedKey ? seasons[selectedKey] : null;
if (selectedSeason) {
const safeSeasonId = String(selectedKey);
activeSelectedSeason = safeSeasonId; // Save the selected season
renderEpisodes(selectedSeason, safeSeasonId);
}
};
} else {
const opt = document.createElement('option');
opt.innerText = "Saison 1";
seasonSelect.appendChild(opt);
renderEpisodes([], 1);
}
} else {
seriesSec.classList.add('hidden');
activeVideoSrc = resolveAssetUrl(item.video);
activeVideoContext = item.video ? { type: 'film', title: item.title } : null;
}
// Update play button state based on video availability
if (playBtn) {
const hasVideo = activeVideoSrc && activeVideoSrc.trim() !== '';
playBtn.disabled = !hasVideo;
if (hasVideo) {
playBtn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
playBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
// Show overlay and prevent background scrolling
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
/**
* Closes the details overlay.
*/
export function closeDetails() {
document.getElementById('detailsOverlay').classList.add('hidden');
document.body.style.overflow = '';
}
/**
* Refreshes the details overlay for the active series.
* This is useful after watching an episode to update the watched status.
*/
export function refreshActiveSeriesDetails() {
if (activeDetailItem && (activeDetailItem.type === 'serie' || activeDetailItem.seasons !== undefined)) {
openDetails(activeDetailItem, activeSelectedSeason);
}
}
/**
* Plays the currently selected media in the details overlay.
*/
export function playCurrentMedia() {
if (!activeVideoSrc && activeDetailItem && activeDetailItem.seasons) {
// Fallback: select first episode if none chosen yet
const firstSeasonKey = Object.keys(activeDetailItem.seasons || {})[0];
const firstEpisode = firstSeasonKey ? activeDetailItem.seasons[firstSeasonKey]?.[0] : null;
if (firstEpisode) {
activeVideoSrc = resolveAssetUrl(firstEpisode.video);
activeVideoContext = {
type: 'series',
title: activeDetailItem.title,
season: firstSeasonKey,
episodeIndex: 0,
episodeTitle: firstEpisode.title || 'Épisode 1'
};
}
}
// Play video if available, otherwise show alert
if (activeVideoSrc) {
playVideo(activeVideoSrc, activeVideoContext);
} else {
alert("Vidéo non disponible");
}
}
/**
* Renders the list of episodes for a given season.
* @param {Array} episodes - The list of episodes to render.
* @param {number|string} seasonNum - The season number.
*/
function renderEpisodes(episodes, seasonNum) {
const list = document.getElementById('episodesList');
list.innerHTML = '';
// Use default placeholder episodes if none provided
if (!episodes || episodes.length === 0) {
episodes = [
{ title: "Épisode 1", desc: "Description indisponible.", duration: "45m", video: "" },
{ title: "Épisode 2", desc: "Description indisponible.", duration: "42m", video: "" }
];
}
// Sanitize season number to ensure it's a valid string
const safeSeasonNum = typeof seasonNum === 'string'
? seasonNum.replace(/[^0-9]/g, '') || '1'
: String(Number.isFinite(seasonNum) ? seasonNum : 1);
// Update the active selected season
activeSelectedSeason = safeSeasonNum;
// Set first episode as default video source
if (episodes.length > 0) {
activeVideoSrc = episodes[0].video;
activeVideoContext = {
type: 'series',
title: activeDetailItem?.title || '',
season: safeSeasonNum,
episodeIndex: 0,
episodeTitle: episodes[0].title || 'Épisode 1',
};
}
const playBtn = document.getElementById('detailPlayBtn');
if (playBtn) {
const hasVideo = activeVideoSrc && activeVideoSrc.trim() !== '';
playBtn.disabled = !hasVideo;
if (hasVideo) {
playBtn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
playBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
// Render each episode as a card with watch status
episodes.forEach((ep, idx) => {
const watchData = getEpisodeWatchData(activeDetailItem?.title, safeSeasonNum, idx);
const isWatched = watchData.watched;
const hasProgress = !isWatched && watchData.time > 0;
// Create episode card container
const row = document.createElement('div');
row.className = "episode-item flex flex-col md:flex-row items-center gap-6 p-4 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 hover:bg-white/[0.02] group bg-[#0a0a0a]";
if (isWatched) row.classList.add('episode-watched');
const fallbackThumb = `https://placehold.co/300x200/333/666?text=S${safeSeasonNum}-EP${idx + 1}`;
const thumbUrl = activeDetailItem ? resolveAssetUrl(activeDetailItem.poster) : fallbackThumb;
// Create left container with episode number and thumbnail
const leftContainer = document.createElement('div');
leftContainer.className = "flex items-center gap-6 w-full md:w-auto";
const indexSpan = document.createElement('span');
indexSpan.className = "text-2xl font-black text-gray-600 group-hover:text-red-500 transition-colors w-8 text-center";
indexSpan.textContent = String(idx + 1);
leftContainer.appendChild(indexSpan);
const thumbContainer = document.createElement('div');
thumbContainer.className = "relative w-40 h-24 flex-shrink-0 bg-gray-900 rounded-lg overflow-hidden shadow-lg";
const img = document.createElement('img');
img.src = thumbUrl;
img.className = "w-full h-full object-cover opacity-70 group-hover:opacity-100 transition-all duration-500 scale-100 group-hover:scale-110";
img.onerror = function () {
this.src = fallbackThumb;
};
img.alt = `Thumbnail for ${ep.title}`;
thumbContainer.appendChild(img);
// Add play icon overlay on hover
const overlay = document.createElement('div');
overlay.className = "absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors";
const playIcon = document.createElement('i');
playIcon.className = "fas fa-play text-white text-xl opacity-0 group-hover:opacity-100 transition-all";
overlay.appendChild(playIcon);
thumbContainer.appendChild(overlay);
leftContainer.appendChild(thumbContainer);
// Create right container with episode title, duration, and description
const rightContainer = document.createElement('div');
rightContainer.className = "flex-1 w-full text-center md:text-left overflow-hidden";
const titleRow = document.createElement('div');
titleRow.className = "flex flex-col md:flex-row md:items-center justify-between gap-2 mb-2";
const titleEl = document.createElement('h4');
titleEl.className = "font-bold text-white text-xl truncate group-hover:text-red-400 transition-colors";
titleEl.textContent = ep.title;
const durationEl = document.createElement('span');
durationEl.className = "text-sm font-bold text-gray-400 bg-black/30 px-2 py-1 rounded-md";
durationEl.textContent = ep.duration || '45m';
const metaRow = document.createElement('div');
metaRow.className = "flex items-center gap-2 flex-wrap justify-center md:justify-end";
metaRow.appendChild(durationEl);
// Add watched or resume badges based on watch status
if (isWatched) {
const watchedBadge = document.createElement('span');
watchedBadge.className = "watched-pill";
watchedBadge.innerHTML = '<i class="fas fa-eye text-xs"></i> Vu';
metaRow.appendChild(watchedBadge);
} else if (hasProgress) {
const resumeBadge = document.createElement('span');
resumeBadge.className = "resume-pill";
resumeBadge.textContent = "Reprendre";
metaRow.appendChild(resumeBadge);
}
titleRow.appendChild(titleEl);
titleRow.appendChild(metaRow);
const descEl = document.createElement('p');
descEl.className = "text-gray-400 text-sm leading-relaxed line-clamp-2";
descEl.textContent = ep.desc || "...";
rightContainer.appendChild(titleRow);
rightContainer.appendChild(descEl);
row.appendChild(leftContainer);
row.appendChild(rightContainer);
// Set up click handler to play the episode
row.onclick = (e) => {
e.stopPropagation();
const ctx = {
type: 'series',
title: activeDetailItem?.title || '',
season: safeSeasonNum,
episodeIndex: idx,
episodeTitle: ep.title || `Épisode ${idx + 1}`,
};
activeVideoSrc = resolveAssetUrl(ep.video);
activeVideoContext = ctx;
playVideo(activeVideoSrc, ctx);
};
list.appendChild(row);
});
}
/**
* Creates a media card element for a given media item.
* @param {Object} item - The media item to create a card for.
* @param {string} [extraClasses=""] - Additional CSS classes to apply to the card.
* @param {boolean} [showStatusBadges=true] - Whether to display watch/resume badges.
* @returns {HTMLElement} The created media card element.
*/
export function createMediaCard(item, extraClasses = "", showStatusBadges = true) {
const card = document.createElement('div');
card.className = `media-card group relative rounded-xl overflow-hidden cursor-pointer bg-[#1a1a1a] shadow-lg transition-all duration-300 ${extraClasses}`;
const fallback = `https://placehold.co/400x600/1a1a1a/e50914?text=${encodeURIComponent(item.title)}`;
const isSerie = item.type === 'serie' || item.seasons !== undefined;
const filmWatch = !isSerie ? getFilmWatchData(item.title) : null;
// Determine watch status (watched/resume) for badge display
let watched = false;
let hasResume = false;
// Check watch status for series (fully watched or has progress)
if (isSerie) {
watched = isSeriesFullyWatched(item);
if (!watched && item.seasons) {
const seasonKeys = Object.keys(item.seasons).sort((a, b) => parseInt(a) - parseInt(b));
for (const sKey of seasonKeys) {
const episodes = item.seasons[sKey] || [];
for (let i = 0; i < episodes.length; i++) {
const w = getEpisodeWatchData(item.title, sKey, i);
if (w.time > 0 && !w.watched) {
hasResume = true;
break;
}
}
if (hasResume) break;
}
}
} else {
watched = filmWatch?.watched || false;
hasResume = !watched && filmWatch && (filmWatch.time || 0) > 0;
}
// Generate status badges HTML (watched/resume)
const watchedBadge = watched ? '<span class="watched-pill"><i class="fas fa-eye text-xs"></i> Vu</span>' : '';
const resumeBadge = !watched && hasResume ? '<span class="resume-pill">Reprendre</span>' : '';
const badgeStack = showStatusBadges && (watchedBadge || resumeBadge)
? `<div class="status-badges">${watchedBadge}${resumeBadge}</div>`
: '';
// Build media card HTML structure
card.innerHTML = `
${badgeStack}
<img src="${resolveAssetUrl(item.poster)}" alt="Poster for ${item.title}" onerror="this.src='${fallback}'" class="w-full h-full object-cover object-center transition-transform duration-700 loading='lazy'">
<div class="absolute inset-x-0 bottom-0 p-4 z-20 bg-gradient-to-t from-black/90 via-black/50 to-transparent translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
<h3 class="font-bold text-white text-base md:text-lg leading-tight mb-1 drop-shadow-md line-clamp-1">${item.title}</h3>
<div class="flex items-center gap-3 text-xs font-bold text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity delay-100">
<span class="text-green-400"><i class="fas fa-star text-[10px]"></i> ${item.IMDb}</span>
<span>${item.year}</span>
</div>
</div>
`;
card.onclick = () => openDetailsWithRoute(item);
return card;
}
/**
* Renders a grid of media items.
* @param {Array} items - The list of media items to render.
* @param {Object} [options={}] - Optional rendering options.
* @param {boolean} [options.showStatusBadges=true] - Whether to display watch/resume badges.
*/
export function renderGrid(items, options = {}) {
const grid = document.getElementById('contentGrid');
grid.innerHTML = '';
const { showStatusBadges = true } = options;
items.forEach(item => grid.appendChild(createMediaCard(item, "aspect-[2/3]", showStatusBadges)));
}
/**
* Renders a horizontal row of media items.
* @param {string} containerId - The ID of the container element.
* @param {Array} items - The list of media items to render.
*/
export function renderHorizontalRow(containerId, items) {
const container = document.getElementById(containerId);
container.innerHTML = '';
container.classList.add("scroll-row");
if (items.length === 0) return;
items.forEach(item => {
const card = createMediaCard(item, "min-w-[200px] md:min-w-[280px] aspect-[2/3] snap-start");
container.appendChild(card);
});
}
/**
* Renders collections of media items.
* @param {Object} collectionsData - The collections data.
* @param {Object} appData - The application data containing films and series.
*/
export function renderCollections(collectionsData, appData) {
const container = document.getElementById('collectionsContent');
container.innerHTML = '';
if (Object.keys(collectionsData).length === 0) {
container.innerHTML = '<div class="text-center text-gray-500 py-10">Aucune collection disponible.</div>';
return;
}
// Sort collections alphabetically by name
const sortedCollections = Object.entries(collectionsData).sort(([, a], [, b]) => {
return a.name.localeCompare(b.name);
});
const slugifyCollection = (text) => String(text || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const grid = document.createElement('div');
grid.className = "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 gap-y-10";
let hasRenderableCollection = false;
for (const [key, collection] of sortedCollections) {
const items = [];
// Aggregate films from collection
if (collection.films && Array.isArray(collection.films)) {
collection.films.forEach(title => {
const foundFilm = Object.values(appData.films).find(f => f.title === title);
if (foundFilm) items.push(foundFilm);
});
}
// Aggregate series from collection
if (collection.series && Array.isArray(collection.series)) {
collection.series.forEach(title => {
const foundSerie = Object.values(appData.series).find(s => s.title === title);
if (foundSerie) items.push(foundSerie);
});
}
// Render collection section if it has items
if (items.length > 0) {
items.sort((a, b) => a.year - b.year);
const firstFilmTitle = Array.isArray(collection.films) && collection.films.length > 0
? collection.films[0]
: null;
const firstFilmItem = firstFilmTitle
? Object.values(appData.films).find(f => f.title === firstFilmTitle)
: null;
const previewItem = firstFilmItem || items[0];
const collectionSlug = slugifyCollection(collection.name || key);
hasRenderableCollection = true;
const previewWrapper = document.createElement('div');
previewWrapper.className = "space-y-3 animate-fade-in-up";
const previewCard = createMediaCard(previewItem, "w-[200px] md:w-[280px] aspect-[2/3]", false);
previewCard.onclick = () => {
if (typeof window !== 'undefined' && typeof window.router === 'function') {
window.router('collections', { detail: { type: 'collections', slug: collectionSlug }, pushState: true });
}
};
const collectionTitle = document.createElement('h3');
collectionTitle.className = "font-bold text-white text-base md:text-lg tracking-tight line-clamp-1";
collectionTitle.textContent = collection.name;
previewWrapper.appendChild(collectionTitle);
previewWrapper.appendChild(previewCard);
grid.appendChild(previewWrapper);
}
}
if (hasRenderableCollection) {
container.appendChild(grid);
} else {
container.innerHTML = '<div class="text-center text-gray-500 py-10">Aucune collection disponible.</div>';
}
}
/**
* Renders the list of notifications.
* @param {Array} list - The list of notifications to render.
*/
export function renderNotifs(list) {
const container = document.getElementById('notifList');
const badge = document.getElementById('notifBadge');
// Show notification badge if there are notifications
if (list.length > 0) badge.classList.remove('hidden');
container.innerHTML = list.map(n => `
<div class="p-4 border-b border-white/5 hover:bg-white/5 transition-colors cursor-pointer group">
<div class="flex justify-between items-start mb-1">
<span class="font-bold text-red-500 text-xs uppercase tracking-wide">${n.title}</span>
<span class="text-[10px] text-gray-500">${n.time}</span>
</div>
<p class="text-sm text-gray-300 group-hover:text-white transition-colors">${n.message}</p>
</div>
`).join('');
if (list.length === 0) container.innerHTML = '<div class="p-4 text-center text-gray-500 text-xs">Aucune notification</div>';
}
/**
* Computes age from a birth date string.
* @param {string} dateStr - The birth date string.
* @returns {number|null} The calculated age or null if invalid.
*/
function computeAge(dateStr) {
if (!dateStr) return null;
const dob = new Date(dateStr);
if (Number.isNaN(dob.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - dob.getFullYear();
const monthDiff = now.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < dob.getDate())) age -= 1;
return age;
}
/**
* Formats a birth date string into a localized French date format.
* @param {string} dateStr - The birth date string.
* @returns {string} Formatted date or "Date inconnue" if invalid.
*/
function formatBirthDate(dateStr) {
if (!dateStr) return "Date inconnue";
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return "Date inconnue";
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
}
/**
* Generates an availability badge HTML.
* @param {boolean} isAvailable - Whether the item is available in the catalog.
* @returns {string} HTML string for the availability badge.
*/
function availabilityBadge(isAvailable) {
return isAvailable
? '<span class="badge badge-available">Disponible</span>'
: '<span class="badge badge-missing">Hors catalogue</span>';
}
/**
* Generates a media type badge (Film/Série).
* @param {string} type - The media type ('serie' or 'film').
* @returns {string} HTML string for the type badge.
*/
function typeBadge(type) {
const label = type === 'serie' ? 'Série' : 'Film';
return `<span class="badge badge-type">${label}</span>`;
}
/**
* Renders the filmography timeline for an actor.
* @param {HTMLElement} timelineEl - The timeline container element.
* @param {Array} filmography - Array of filmography items.
* @param {Object} filmsData - Films data for availability check.
* @param {Object} seriesData - Series data for availability check.
*/
function renderTimeline(timelineEl, filmography, filmsData, seriesData) {
timelineEl.innerHTML = '';
if (!filmography || filmography.length === 0) {
timelineEl.innerHTML = '<div class="text-gray-500 text-sm">Aucun projet enregistré.</div>';
return;
}
// Group films by year for timeline display
const grouped = {};
filmography.forEach(item => {
const year = item.year || 0;
if (!grouped[year]) grouped[year] = [];
grouped[year].push(item);
});
// Process grouped years in descending order (newest first)
Object.keys(grouped)
.sort((a, b) => b - a)
.forEach(year => {
const items = grouped[year];
const row = document.createElement('div');
row.className = "timeline-item";
// Check if all items in this year are available
const allAvailable = items.every(item =>
item.type === 'serie' ? Boolean(seriesData[item.title]) : Boolean(filmsData[item.title])
);
let projectsHTML = '';
items.forEach(item => {
const isAvailable = item.type === 'serie'
? Boolean(seriesData[item.title])
: Boolean(filmsData[item.title]);
const episodesLabel = item.type === 'serie' && item.episodes
? `${item.episodes} ${item.episodes > 1 ? 'épisodes' : 'épisode'}`
: '';
const roleText = ['Executive Producer', 'Director', 'Screenplay', 'Co-Executive Producer', 'Producer', 'Songs', 'Associate Producer', 'Thanks', 'Writer', 'Musician', 'Vocals'].includes(item.role) ? 'En tant que' : 'Incarnant';
projectsHTML += `
<div class="timeline-project mb-2 pb-2 last:mb-0 last:pb-0 border-b border-white/5 last:border-b-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="font-bold text-white">${item.title || 'Titre inconnu'}</span>
${availabilityBadge(isAvailable)}
${typeBadge(item.type)}
</div>
<div class="text-sm text-gray-400 leading-relaxed">
${item.role ? `<strong><u>${episodesLabel}</u></strong> ${roleText} <a style="color: #f87171;">${item.role}</a>` : `<strong><u>${episodesLabel}</u></strong> Rôle non renseigné.`}
</div>
</div>
`;
});
row.innerHTML = `
<div class="timeline-year">${year || '—'}</div>
<div class="timeline-body">
${projectsHTML}
</div>
`;
timelineEl.appendChild(row);
});
}
/**
* Opens the actor details overlay.
* @param {Object} actor - Actor object.
* @param {Object} filmsData - Films data keyed by title.
* @param {Object} seriesData - Series data keyed by title.
*/
export function openActorDetails(actor, filmsData = {}, seriesData = {}) {
const overlay = document.getElementById('actorOverlay');
if (!overlay || !actor) return;
const photo = actor.photo ? resolveAssetUrl(actor.photo) : `https://placehold.co/500x700/111/fff?text=${encodeURIComponent(actor.name || 'Acteur')}`;
const filmography = Array.isArray(actor.filmography) ? [...actor.filmography] : [];
filmography.sort((a, b) => (b.year || 0) - (a.year || 0));
// Calculate age and format birth information
const age = computeAge(actor.birthDate);
const ageLabel = age !== null ? `${age} ans` : 'Âge inconnu';
const birthLabel = formatBirthDate(actor.birthDate);
const photoEl = document.getElementById('actorDetailPhoto');
if (photoEl) {
photoEl.src = photo;
photoEl.onerror = function () {
this.src = 'https://placehold.co/500x700/111/fff?text=Acteur';
};
}
// Get references to all detail elements
const nameEl = document.getElementById('actorDetailName');
const bioEl = document.getElementById('actorDetailBio');
const genderEl = document.getElementById('actorDetailGender');
const ageEl = document.getElementById('actorDetailAge');
const birthEl = document.getElementById('actorDetailBirth');
const nationalityEl = document.getElementById('actorDetailNationality');
const projectsEl = document.getElementById('actorDetailProjects');
const timelineEl = document.getElementById('actorTimeline');
// Populate actor detail elements
if (nameEl) nameEl.textContent = actor.name || 'Nom inconnu';
if (bioEl) bioEl.textContent = actor.bio || 'Biographie non renseignée.';
if (genderEl) genderEl.textContent = actor.gender || 'Non renseigné';
if (ageEl) ageEl.textContent = ageLabel;
if (birthEl) birthEl.textContent = actor.birthPlace ? `${birthLabel} • ${actor.birthPlace}` : birthLabel;
if (nationalityEl) nationalityEl.textContent = actor.nationality || '—';
if (projectsEl) projectsEl.textContent = `${filmography.length} projets`;
// Render filmography timeline
if (timelineEl) renderTimeline(timelineEl, filmography, filmsData, seriesData);
// Show overlay and prevent background scrolling
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
/**
* Closes the actor details overlay.
*/
export function closeActorDetails() {
const overlay = document.getElementById('actorOverlay');
if (!overlay) return;
overlay.classList.add('hidden');
document.body.style.overflow = '';
}
/**
* Renders the grid of actors (photo + nom).
* @param {Object} actorsData - Actors keyed by slug/id.
* @param {Object} filmsData - Films data keyed by title.
* @param {Object} seriesData - Series data keyed by title.
*/
export function renderActorsList(actorsData, filmsData = {}, seriesData = {}) {
const container = document.getElementById('actorsContent');
if (!container) return;
// Convert actors object to array and sort alphabetically
const actors = Object.values(actorsData || {});
if (actors.length === 0) {
container.innerHTML = '<div class="text-center text-gray-500 py-12">Aucun acteur enregistré pour le moment.</div>';
return;
}
actors.sort((a, b) => a.name.localeCompare(b.name));
container.innerHTML = '';
// Create responsive grid for actor cards
const grid = document.createElement('div');
grid.className = "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 gap-y-8";
// Create and append actor cards
actors.forEach(actor => {
const photo = actor.photo ? resolveAssetUrl(actor.photo) : `https://placehold.co/500x700/111/fff?text=${encodeURIComponent(actor.name || 'Acteur')}`;
const card = document.createElement('div');
card.className = "relative overflow-hidden rounded-2xl group shadow-lg bg-[#121212] border border-white/5 cursor-pointer actor-thumb";
card.innerHTML = `
<img src="${photo}" alt="${actor.name || 'Acteur'}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" onerror="this.src='https://placehold.co/500x700/111/fff?text=Acteur'" />
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-4 flex items-center justify-between">
<h4 class="font-black text-white text-lg drop-shadow-md line-clamp-2">${actor.name || 'Nom inconnu'}</h4>
<span class="text-xs font-bold text-gray-300 bg-white/10 rounded-full px-3 py-1 border border-white/10">Voir</span>
</div>
`;
card.onclick = () => openActorDetailsWithRoute(actor, filmsData, seriesData);
grid.appendChild(card);
});
container.appendChild(grid);
}
/**
* Renders a list of actors in a search results section.
* @param {Array} actorsData - Array of actor objects to display.
* @param {Object} filmsData - Films data for opening actor details.
* @param {Object} seriesData - Series data for opening actor details.
*/
export function renderActorsListSearch(actorsData, filmsData = {}, seriesData = {}) {
const container = document.getElementById('contentGrid');
if (!container) return;
if (!actorsData || actorsData.length === 0) {
return;
}
// Create a section title for actors in search results
const section = document.createElement('div');
section.className = "col-span-full mt-12 mb-6";
const titleDiv = document.createElement('div');
titleDiv.className = "flex items-center gap-4 mb-6";
const accentSpan = document.createElement('span');
accentSpan.className = 'w-1 h-8 bg-red-600 rounded-full shadow-[0_0_15px_#dc2626]';
titleDiv.appendChild(accentSpan);
const titleText = document.createElement('span');
titleText.className = 'tracking-tight text-xl font-bold';
titleText.textContent = `Acteurs (${actorsData.length})`;
titleDiv.appendChild(titleText);
section.appendChild(titleDiv);
// Create grid for actor cards in search results
const grid = document.createElement('div');
grid.className = "col-span-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 gap-y-8";
actorsData.forEach(actor => {
const photo = actor.photo ? resolveAssetUrl(actor.photo) : `https://placehold.co/500x700/111/fff?text=${encodeURIComponent(actor.name || 'Acteur')}`;
const card = document.createElement('div');
card.className = "relative overflow-hidden rounded-2xl group shadow-lg bg-[#121212] border border-white/5 cursor-pointer actor-thumb";
card.innerHTML = `
<img src="${photo}" alt="${actor.name || 'Acteur'}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" onerror="this.src='https://placehold.co/500x700/111/fff?text=Acteur'" />
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-4 flex items-center justify-between">
<h4 class="font-black text-white text-lg drop-shadow-md line-clamp-2">${actor.name || 'Nom inconnu'}</h4>
<span class="text-xs font-bold text-gray-300 bg-white/10 rounded-full px-3 py-1 border border-white/10">Voir</span>
</div>
`;
card.onclick = () => openActorDetailsWithRoute(actor, filmsData, seriesData);
grid.appendChild(card);
});
section.appendChild(grid);
container.appendChild(section);
}