Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
This commit is contained in:
307
public/app.js
Normal file
307
public/app.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// 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);
|
||||
}
|
||||
40
public/index.html
Normal file
40
public/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#0b1220">
|
||||
<title>proxytmdb · Recherche</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2301b4e4'/%3E%3Ctext x='16' y='22' font-family='sans-serif' font-size='18' font-weight='bold' text-anchor='middle' fill='%23000'%3Et%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">t</span>
|
||||
<span class="brand-text">proxytmdb</span>
|
||||
</a>
|
||||
<form id="search-form" class="search-form" autocomplete="off">
|
||||
<input id="q" type="search" name="q" placeholder="ex: Inception 2010, Mr.Robot S01E02, tt0133093…" >
|
||||
<button type="submit" class="btn">Chercher</button>
|
||||
</form>
|
||||
<a class="nav-link" href="https://www.themoviedb.org/" target="_blank" rel="noopener">TMDB</a>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="status" class="status hidden"></section>
|
||||
<section id="results" class="results"></section>
|
||||
</main>
|
||||
|
||||
<dialog id="detail" class="detail">
|
||||
<button type="button" class="close" aria-label="Fermer" data-action="close">×</button>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
</dialog>
|
||||
|
||||
<footer class="footer">
|
||||
<span id="footer-text">proxytmdb · Cache local TMDB + notes IMDb</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
445
public/style.css
Normal file
445
public/style.css
Normal file
@@ -0,0 +1,445 @@
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--bg-2: #0f172a;
|
||||
--bg-3: #111827;
|
||||
--bg-hover: #1f2937;
|
||||
--border: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
--accent: #01b4e4; /* TMDB blue */
|
||||
--accent-2: #60a5fa;
|
||||
--imdb: #f3ce13;
|
||||
--danger: #fca5a5;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Topbar -------------------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-radius: 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-form input:focus {
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
.search-form input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.btn:active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Main ---------------------------------------------------------------- */
|
||||
|
||||
#main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 80px;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.status.loading::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 10px;
|
||||
border: 2px solid var(--text-muted);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Results grid -------------------------------------------------------- */
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
border-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
background: var(--bg-2);
|
||||
background-image: linear-gradient(135deg, var(--bg-2), var(--bg-3));
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card-poster.no-poster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card-ratings {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rating {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rating.tmdb {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.rating.imdb {
|
||||
background: var(--imdb);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Detail dialog ------------------------------------------------------- */
|
||||
|
||||
.detail {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
max-width: 900px;
|
||||
width: 92%;
|
||||
max-height: 90vh;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.detail::backdrop {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.detail .close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
.detail .close:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
.detail-poster {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-poster img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-info h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
color: #f9fafb;
|
||||
}
|
||||
.detail-info .alt-title {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.detail-info .badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-info .links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.link-pill {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-pill.tmdb {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.link-pill.imdb {
|
||||
background: var(--imdb);
|
||||
color: #000;
|
||||
}
|
||||
.link-pill:hover {
|
||||
text-decoration: none;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.detail-info .tagline {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.detail-info .overview {
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.detail-info .money {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#providers-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
#providers-section h3 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.providers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.provider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.provider img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: none;
|
||||
}
|
||||
.detail-body {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-poster {
|
||||
width: 140px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer -------------------------------------------------------------- */
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
Reference in New Issue
Block a user