// proxytmdb — UI vanilla JS // Single-page app: search bar -> grid of results -> click for detail dialog. const $ = (sel) => document.querySelector(sel); const form = $('#search-form'); const input = $('#q'); const status = $('#status'); const results = $('#results'); const dialog = $('#detail'); const dialogBody = $('#detail-body'); const NO_POSTER = 'data:image/svg+xml;utf8,' + encodeURIComponent( 'no poster', ); const escapeHtml = (s) => String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); function showStatus(text, kind = '') { status.className = `status ${kind}`; status.textContent = text; status.classList.remove('hidden'); } function hideStatus() { status.classList.add('hidden'); } function isImdbId(s) { return /^tt\d{6,}$/.test(s.trim()); } function _isTmdbUrl(s) { return /themoviedb\.org\/(movie|tv)\/(\d+)/.test(s); } async function searchAndRender(query) { query = query.trim(); if (!query) return; results.innerHTML = ''; showStatus('Recherche en cours', 'loading'); let endpoint; if (isImdbId(query)) { endpoint = `/api?t=imdb&q=${encodeURIComponent(query)}`; } else { const m = query.match(/themoviedb\.org\/(movie|tv)\/(\d+)/); if (m) endpoint = `/api?t=${m[1]}&q=${m[2]}`; else endpoint = `/api?t=search&q=${encodeURIComponent(query)}`; } let data; try { const res = await fetch(endpoint); data = await res.json(); } catch (err) { showStatus(`Erreur réseau : ${err.message}`, 'error'); return; } if (data.error) { showStatus(data.error, 'error'); return; } // Single-entry response (movie/tv/imdb endpoint) -> open detail directly. if (!data.results) { hideStatus(); openDetailFromEntry(data); return; } if (!data.results.length) { showStatus('Aucun résultat', ''); return; } hideStatus(); renderResults(data.results); } function renderResults(items) { const frag = document.createDocumentFragment(); for (const item of items) { const card = document.createElement('article'); card.className = 'card'; card.tabIndex = 0; card.dataset.tmdbId = item.tmdb_id; card.dataset.type = item.api_url?.includes('t=tv') ? 'tv' : 'movie'; const poster = item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : NO_POSTER; const title = item.title || item.english_title || item.original_title || ''; const year = item.years || ''; const note_imdb = item.note_imdb; const note_tmdb = item.note_tmdb; card.innerHTML = ` ${escapeHtml(title)}
${escapeHtml(title)}
${year ? `${escapeHtml(year)}` : ''} ${item.runtime ? `${escapeHtml(item.runtime)}` : ''} ${item.season ? `${escapeHtml(item.season)}` : ''}
${note_tmdb ? `TMDB ${note_tmdb}` : ''} ${note_imdb ? `IMDb ${note_imdb}` : ''}
`; card.addEventListener('click', () => openDetailFromCard(item)); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetailFromCard(item); } }); frag.appendChild(card); } results.appendChild(frag); } // Open detail from a card item (already has summary; we still fetch full detail // for tagline/overview which may be missing depending on result shape). async function openDetailFromCard(item) { const type = item.api_url?.includes('t=tv') ? 'tv' : 'movie'; showDialog(`
Chargement…
`); let full; try { const res = await fetch(`/api?t=${type}&q=${item.tmdb_id}`); full = await res.json(); } catch { full = {}; } renderDetail({ summary: item, full, type }); loadProviders(type, item.tmdb_id); } async function openDetailFromEntry(detail) { // Detail came directly from /api?t=movie|tv|imdb : we don't have a "summary" // shape, so synthesize minimal fields. const type = detail?.first_air_date ? 'tv' : 'movie'; const summary = { tmdb_id: detail.id, title: detail.title || detail.name, original_title: detail.original_title || detail.original_name, years: (detail.release_date || detail.first_air_date || '').slice(0, 4), poster_path: detail.poster_path, note_tmdb: detail.vote_average ? Math.round(detail.vote_average * 10) / 10 : null, note_imdb: detail.note_imdb, runtime: detail.runtime ? `${Math.floor(detail.runtime / 60)} h ${String(detail.runtime % 60).padStart(2, '0')} min` : '', }; renderDetail({ summary, full: detail, type }); loadProviders(type, detail.id); showDialog(dialogBody.innerHTML); } function showDialog(html) { dialogBody.innerHTML = html; if (!dialog.open) dialog.showModal(); } function renderDetail({ summary, full, type }) { const poster = summary.poster_path ? `https://image.tmdb.org/t/p/w400${summary.poster_path}` : NO_POSTER; const title = summary.title || full.title || full.name || ''; const altTitle = summary.original_title || full.original_title || full.original_name || ''; const showAlt = altTitle && altTitle !== title; const genres = (full.genres || []).map((g) => g.name).join(' · '); const countries = (full.production_countries || []).map((c) => c.iso_3166_1).join(' '); const runtime = full.runtime ? `${Math.floor(full.runtime / 60)} h ${String(full.runtime % 60).padStart(2, '0')} min` : ''; const tmdbId = summary.tmdb_id || full.id; const imdbId = full.imdb_id || full?.external_ids?.imdb_id; const tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`; const imdbUrl = imdbId ? `https://www.imdb.com/title/${imdbId}` : null; const noteTmdb = full.vote_average ? Math.round(full.vote_average * 10) / 10 : summary.note_tmdb; const voteTmdb = full.vote_count; const noteImdb = full.note_imdb || summary.note_imdb; const voteImdb = full.vote_imdb || summary.vote_imdb; const fmtMoney = (n) => n ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(n) : ''; showDialog(`
${escapeHtml(title)}

${escapeHtml(title)} ${summary.years ? `(${escapeHtml(summary.years)})` : ''}

${showAlt ? `
${escapeHtml(altTitle)}
` : ''}
${genres ? `${escapeHtml(genres)}` : ''} ${countries ? `${escapeHtml(countries)}` : ''} ${runtime ? `${escapeHtml(runtime)}` : ''} ${summary.season ? `${escapeHtml(summary.season)}` : ''}
${full.tagline ? `
« ${escapeHtml(full.tagline)} »
` : ''} ${full.overview ? `
${escapeHtml(full.overview)}
` : ''} ${ full.budget || full.revenue ? `
${full.budget ? `Budget : ${fmtMoney(full.budget)}` : ''} ${full.revenue ? `Revenue : ${fmtMoney(full.revenue)}` : ''}
` : '' }
`); } async function loadProviders(type, tmdbId) { let data; try { const res = await fetch(`/api?t=providers&type=${type}&q=${tmdbId}`); data = await res.json(); } catch { return; } if (!data || data.error) return; const fr = data.results?.FR; if (!fr) return; const set = new Map(); for (const k of ['flatrate', 'rent', 'buy', 'free', 'ads']) { for (const p of fr[k] || []) set.set(p.provider_id, p); } if (!set.size) return; const section = document.getElementById('providers-section'); const list = document.getElementById('providers-list'); list.innerHTML = [...set.values()] .map( (p) => ` ${p.logo_path ? `` : ''} ${escapeHtml(p.provider_name)} `, ) .join(''); section.classList.remove('hidden'); } // Close dialog on backdrop click or close button dialog.addEventListener('click', (e) => { if (e.target === dialog || e.target.dataset.action === 'close') dialog.close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && dialog.open) dialog.close(); }); form.addEventListener('submit', (e) => { e.preventDefault(); const q = input.value; if (!q) return; history.pushState({ q }, '', `?q=${encodeURIComponent(q)}`); searchAndRender(q); }); window.addEventListener('popstate', () => { const q = new URLSearchParams(location.search).get('q') || ''; input.value = q; if (q) searchAndRender(q); }); // Initial load: ?q=... in URL const initial = new URLSearchParams(location.search).get('q'); if (initial) { input.value = initial; searchAndRender(initial); }