Files
proxy_tmdb/public/app.js

308 lines
10 KiB
JavaScript
Raw Normal View History

// 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(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300"><rect width="200" height="300" fill="#0f172a"/><text x="100" y="150" text-anchor="middle" fill="#475569" font-family="sans-serif" font-size="14">no poster</text></svg>',
);
const escapeHtml = (s) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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 = `
<img class="card-poster" src="${escapeHtml(poster)}" alt="${escapeHtml(title)}" loading="lazy">
<div class="card-body">
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-meta">
${year ? `<span>${escapeHtml(year)}</span>` : ''}
${item.runtime ? `<span>${escapeHtml(item.runtime)}</span>` : ''}
${item.season ? `<span>${escapeHtml(item.season)}</span>` : ''}
</div>
<div class="card-ratings">
${note_tmdb ? `<span class="rating tmdb">TMDB ${note_tmdb}</span>` : ''}
${note_imdb ? `<span class="rating imdb">IMDb ${note_imdb}</span>` : ''}
</div>
</div>
`;
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(`<div class="status loading">Chargement…</div>`);
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(`
<div class="detail-poster">
<img src="${escapeHtml(poster)}" alt="${escapeHtml(title)}">
</div>
<div class="detail-info">
<h2>${escapeHtml(title)} ${summary.years ? `<span style="color:var(--text-muted);font-weight:400">(${escapeHtml(summary.years)})</span>` : ''}</h2>
${showAlt ? `<div class="alt-title">${escapeHtml(altTitle)}</div>` : ''}
<div class="badges">
${genres ? `<span class="badge">${escapeHtml(genres)}</span>` : ''}
${countries ? `<span class="badge">${escapeHtml(countries)}</span>` : ''}
${runtime ? `<span class="badge">${escapeHtml(runtime)}</span>` : ''}
${summary.season ? `<span class="badge">${escapeHtml(summary.season)}</span>` : ''}
</div>
<div class="links">
<a class="link-pill tmdb" href="${escapeHtml(tmdbUrl)}" target="_blank" rel="noopener">
TMDB ${noteTmdb ? `· ${noteTmdb}` : ''} ${voteTmdb ? `<span style="opacity:.6">(${voteTmdb})</span>` : ''}
</a>
${
imdbUrl
? `<a class="link-pill imdb" href="${escapeHtml(imdbUrl)}" target="_blank" rel="noopener">
IMDb ${noteImdb ? `· ${noteImdb}` : ''} ${voteImdb ? `<span style="opacity:.6">(${voteImdb})</span>` : ''}
</a>`
: ''
}
</div>
${full.tagline ? `<div class="tagline">« ${escapeHtml(full.tagline)} »</div>` : ''}
${full.overview ? `<div class="overview">${escapeHtml(full.overview)}</div>` : ''}
${
full.budget || full.revenue
? `<div class="money">
${full.budget ? `<span>Budget : <b>${fmtMoney(full.budget)}</b></span>` : ''}
${full.revenue ? `<span>Revenue : <b>${fmtMoney(full.revenue)}</b></span>` : ''}
</div>`
: ''
}
<div id="providers-section" class="hidden">
<h3>Disponible sur (FR)</h3>
<div id="providers-list" class="providers-list"></div>
</div>
</div>
`);
}
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) => `
<span class="provider" title="${escapeHtml(p.provider_name)}">
${p.logo_path ? `<img src="https://image.tmdb.org/t/p/w45${p.logo_path}" alt="">` : ''}
${escapeHtml(p.provider_name)}
</span>
`,
)
.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);
}