308 lines
10 KiB
JavaScript
308 lines
10 KiB
JavaScript
// 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, '&')
|
|
.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 = `
|
|
<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);
|
|
}
|