Initial commit
Some checks failed
Docker / docker (push) Failing after 26s

Interface web pour noter rapidement les films non notés sur Trakt.
Enrichissement TMDB (titres/résumés FR), notation 1-10 en un clic,
bouton passer, filtres, tri, pagination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev
2026-03-04 13:33:58 +00:00
commit 26808fc2b0
13 changed files with 1327 additions and 0 deletions

315
static/app.js Normal file
View File

@@ -0,0 +1,315 @@
/* ── State ──────────────────────────────────────────────── */
let currentPage = 1;
let currentFilter = 'all';
let currentSort = 'watched_at';
let totalPages = 1;
let totalCount = 0;
let showSkipped = false;
/* Rating value → color (index 0 unused) */
const COLORS = [
null,
'#f43f5e', '#f43f5e', // 1-2
'#fb7185', // 3
'#fb923c', // 4
'#f59e0b', // 5
'#eab308', // 6
'#84cc16', // 7
'#22c55e', // 8
'#10b981', // 9
'#06b6d4', // 10
];
/* ── Skipped (localStorage) ─────────────────────────────── */
function getSkipped() {
try { return new Set(JSON.parse(localStorage.getItem('skipped') || '[]')); }
catch { return new Set(); }
}
function saveSkipped(set) {
localStorage.setItem('skipped', JSON.stringify([...set]));
document.getElementById('skipped-count').textContent = set.size;
}
function skipMovie(traktId) {
const s = getSkipped();
s.add(traktId);
saveSkipped(s);
}
function unskipMovie(traktId) {
const s = getSkipped();
s.delete(traktId);
saveSkipped(s);
}
function toggleSkipped() {
showSkipped = !showSkipped;
const btn = document.getElementById('skipped-toggle');
btn.style.color = showSkipped ? 'var(--accent)' : '';
btn.style.borderColor = showSkipped ? 'var(--accent)' : '';
currentPage = 1;
loadMovies();
}
/* ── Boot ───────────────────────────────────────────────── */
async function init() {
const { authenticated } = await fetch('/api/auth/status').then(r => r.json());
if (!authenticated) {
show('login-screen');
} else {
show('app');
document.getElementById('skipped-count').textContent = getSkipped().size;
document.getElementById('filter-select').addEventListener('change', e => {
currentFilter = e.target.value;
currentPage = 1;
loadMovies();
});
document.getElementById('sort-select').addEventListener('change', e => {
currentSort = e.target.value;
currentPage = 1;
loadMovies();
});
loadMovies();
}
}
function show(id) { document.getElementById(id).classList.remove('hidden'); }
function hide(id) { document.getElementById(id).classList.add('hidden'); }
/* ── Load page ──────────────────────────────────────────── */
async function loadMovies() {
const list = document.getElementById('movie-list');
list.innerHTML = '';
hide('empty');
document.getElementById('pagination').innerHTML = '';
show('loading');
try {
const skipped = getSkipped();
const params = new URLSearchParams({
page: currentPage,
per_page: 20,
sort: currentSort,
filter_type: currentFilter,
exclude: showSkipped ? '' : [...skipped].join(','),
});
const data = await fetch(`/api/movies?${params}`).then(r => r.json());
hide('loading');
// In "skipped" view, filter client-side to only show skipped ones
const movies = showSkipped
? data.movies.filter(m => skipped.has(m.trakt_id))
: data.movies;
totalPages = data.total_pages;
document.getElementById('count-badge').textContent =
`${data.total} film${data.total !== 1 ? 's' : ''}`;
if (!movies.length) {
show('empty');
document.getElementById('empty').querySelector('p').textContent =
showSkipped ? 'Aucun film passé.' : 'Tous les films sont notés !';
return;
}
movies.forEach(m => list.appendChild(buildRow(m)));
renderPagination();
} catch (err) {
hide('loading');
console.error(err);
showToast('Erreur de chargement', true);
}
}
/* ── Build movie row ────────────────────────────────────── */
function buildRow(movie) {
const skipped = getSkipped();
const isSkipped = skipped.has(movie.trakt_id);
const row = document.createElement('div');
row.className = 'movie-row' + (isSkipped ? ' is-skipped' : '');
row.dataset.id = movie.trakt_id;
// Poster
const poster = movie.poster
? `<img class="movie-poster" src="${movie.poster}" alt="" loading="lazy">`
: `<div class="poster-ph">🎬</div>`;
// Info
const meta = [movie.year, fmtDate(movie.watched_at)].filter(Boolean).join(' · ');
const badge = movie.current_rating === 10
? `<span class="badge-10">★ noté 10/10</span>` : '';
const info = `
<div class="movie-info">
<div class="movie-title">${esc(movie.title_fr)}</div>
<div class="movie-meta">${esc(meta)}</div>
${badge}
</div>`;
// Synopsis
const synopsis = movie.overview
? `<div class="movie-synopsis"><div class="synopsis-text">${esc(movie.overview)}</div></div>`
: `<div class="movie-synopsis"><span class="synopsis-empty">Aucun résumé disponible</span></div>`;
// Rating buttons + skip button
const btns = Array.from({ length: 10 }, (_, i) =>
`<button class="r-btn" data-n="${i + 1}">${i + 1}</button>`
).join('');
const skipLabel = isSkipped ? 'Remettre' : 'Passer';
const skipBtn = `<button class="skip-btn" title="${skipLabel}">${skipLabel}</button>`;
const ratingEl = `<div class="movie-rating">${btns}${skipBtn}</div>`;
row.innerHTML = poster + info + synopsis + ratingEl;
// Rating hover & click
const container = row.querySelector('.movie-rating');
const buttons = [...container.querySelectorAll('.r-btn')];
buttons.forEach((btn, idx) => {
btn.addEventListener('mouseenter', () => {
buttons.forEach((b, i) => {
if (i <= idx) {
const c = COLORS[i + 1];
b.style.background = c + '28';
b.style.borderColor = c;
b.style.color = c;
} else {
b.style.background = '';
b.style.borderColor = '';
b.style.color = '';
}
});
});
btn.addEventListener('click', () => rateMovie(movie, parseInt(btn.dataset.n), row));
});
container.addEventListener('mouseleave', () => {
buttons.forEach(b => {
b.style.background = '';
b.style.borderColor = '';
b.style.color = '';
});
});
// Skip button
container.querySelector('.skip-btn').addEventListener('click', () => {
if (isSkipped) {
unskipMovie(movie.trakt_id);
showToast(`${movie.title_fr} — remis dans la liste`);
} else {
skipMovie(movie.trakt_id);
showToast(`${movie.title_fr} — passé`);
}
animateOut(row);
});
return row;
}
/* ── Rate ───────────────────────────────────────────────── */
async function rateMovie(movie, rating, row) {
row.style.pointerEvents = 'none';
try {
const r = await fetch(`/api/rate/${movie.trakt_id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }),
});
if (!r.ok) throw new Error('Failed');
showToast(`${movie.title_fr} — noté ${rating}/10`);
animateOut(row);
} catch {
row.style.pointerEvents = '';
showToast('Erreur lors de la notation', true);
}
}
/* ── Animate row out ────────────────────────────────────── */
function animateOut(row) {
row.style.transition = 'opacity .28s ease, transform .28s ease';
row.style.opacity = '0';
row.style.transform = 'translateX(16px)';
setTimeout(() => {
const h = row.offsetHeight;
row.style.height = h + 'px';
row.style.overflow = 'hidden';
row.style.transition = 'height .22s ease, padding .22s ease, margin .22s ease, border-width .22s ease';
requestAnimationFrame(() => {
row.style.height = '0';
row.style.paddingTop = '0';
row.style.paddingBottom = '0';
row.style.marginBottom = '0';
row.style.borderWidth = '0';
});
setTimeout(() => {
row.remove();
if (!document.querySelectorAll('.movie-row').length) {
if (currentPage > 1) currentPage--;
loadMovies();
}
}, 240);
}, 280);
}
/* ── Pagination ─────────────────────────────────────────── */
function renderPagination() {
if (totalPages <= 1) return;
const el = document.getElementById('pagination');
const prev = mkBtn('← Précédent', currentPage === 1, () => { currentPage--; reload(); });
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = `Page ${currentPage} / ${totalPages}`;
const next = mkBtn('Suivant →', currentPage === totalPages, () => { currentPage++; reload(); });
el.append(prev, info, next);
}
function mkBtn(label, disabled, onClick) {
const b = document.createElement('button');
b.className = 'page-btn';
b.textContent = label;
b.disabled = disabled;
b.addEventListener('click', onClick);
return b;
}
function reload() {
window.scrollTo({ top: 0, behavior: 'smooth' });
loadMovies();
}
/* ── Toast ──────────────────────────────────────────────── */
let toastTimer;
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.borderColor = isError ? 'rgba(244,63,94,.35)' : '';
t.style.color = isError ? '#f87191' : '';
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2800);
}
/* ── Utils ──────────────────────────────────────────────── */
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
function esc(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str ?? ''));
return d.innerHTML;
}
init();

76
static/index.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trakt Rater</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Login -->
<div id="login-screen" class="hidden">
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M7 4v16l13-8L7 4z"/></svg>
<h1>Trakt Rater</h1>
</div>
<p>Notez rapidement vos films non notés</p>
<a href="/auth/login" class="btn-primary">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>
Se connecter avec Trakt
</a>
</div>
</div>
<!-- App -->
<div id="app" class="hidden">
<header>
<div class="header-left">
<svg class="logo-mark" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4v16l13-8L7 4z"/></svg>
<span class="app-name">Trakt Rater</span>
<span id="count-badge" class="badge"></span>
</div>
<div class="header-right">
<div class="ctrl">
<label>Afficher</label>
<select id="filter-select">
<option value="all">Tous</option>
<option value="unrated">Non notés</option>
<option value="10">Notés 10/10</option>
</select>
</div>
<div class="ctrl">
<label>Trier par</label>
<select id="sort-select">
<option value="watched_at">Date de visionnage</option>
<option value="title">Titre</option>
<option value="year">Année</option>
</select>
</div>
<button id="skipped-toggle" class="btn-ghost" onclick="toggleSkipped()">Voir les passés (<span id="skipped-count">0</span>)</button>
<a href="/auth/logout" class="btn-ghost">Déconnexion</a>
</div>
</header>
<main>
<div id="loading" class="state hidden">
<div class="spinner"></div>
<span>Chargement…</span>
</div>
<div id="empty" class="state hidden">
<span style="font-size:2rem">🎉</span>
<p>Tous les films sont notés !</p>
</div>
<div id="movie-list"></div>
<div id="pagination"></div>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/static/app.js"></script>
</body>
</html>

395
static/style.css Normal file
View File

@@ -0,0 +1,395 @@
:root {
--bg: #09090e;
--surface: #101018;
--surface-2: #15151f;
--border: #1d1d2e;
--border-2: #26263a;
--accent: #7c6af7;
--accent-bg: rgba(124,106,247,.12);
--text: #e2e2f0;
--muted: #6a6a8e;
--faint: #32324e;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ── Login ─────────────────────────────────────────────── */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background:
radial-gradient(ellipse 70% 50% at 50% -10%, rgba(124,106,247,.18) 0%, transparent 60%),
var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 18px;
padding: 2.8rem 2.4rem;
width: 100%;
max-width: 360px;
text-align: center;
box-shadow: 0 32px 64px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.03) inset;
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
margin-bottom: .8rem;
color: var(--accent);
}
.login-logo h1 {
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -.03em;
background: linear-gradient(135deg, #b39dff 0%, #7c6af7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-card p {
color: var(--muted);
font-size: .88rem;
margin-bottom: 1.8rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: .45rem;
background: var(--accent);
color: #fff;
text-decoration: none;
border-radius: 9px;
padding: .65rem 1.4rem;
font-weight: 600;
font-size: .88rem;
transition: background .2s, transform .15s, box-shadow .2s;
}
.btn-primary:hover {
background: #8e7df8;
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(124,106,247,.38);
}
/* ── Header ─────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .75rem 1.5rem;
background: rgba(16,16,24,.88);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.header-left { display: flex; align-items: center; gap: .55rem; }
.logo-mark { width: 18px; height: 18px; color: var(--accent); flex-shrink: 0; }
.app-name {
font-weight: 700;
font-size: .9rem;
letter-spacing: -.02em;
background: linear-gradient(135deg, #b39dff, #7c6af7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.badge {
background: var(--accent-bg);
color: var(--accent);
border: 1px solid rgba(124,106,247,.22);
border-radius: 20px;
padding: 1px 9px;
font-size: .72rem;
font-weight: 600;
}
.header-right { display: flex; align-items: center; gap: .8rem; }
.ctrl { display: flex; align-items: center; gap: .35rem; }
.ctrl label { color: var(--muted); font-size: .78rem; white-space: nowrap; }
select {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border-2);
border-radius: 7px;
padding: .3rem .5rem .3rem .6rem;
font-size: .78rem;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color .15s;
appearance: none;
-webkit-appearance: none;
padding-right: 1.4rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%236a6a8e'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .5rem center;
}
select:hover, select:focus { border-color: var(--accent); }
.btn-ghost {
color: var(--muted);
text-decoration: none;
font-size: .78rem;
padding: .3rem .65rem;
border: 1px solid var(--border-2);
border-radius: 7px;
transition: color .15s, border-color .15s;
}
.btn-ghost:hover { color: #f43f5e; border-color: rgba(244,63,94,.4); }
/* ── Main ─────────────────────────────────────────────── */
main {
max-width: 1420px;
margin: 0 auto;
padding: 1.2rem 1.5rem 5rem;
}
/* ── States ─────────────────────────────────────────────── */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .9rem;
padding: 5rem 2rem;
color: var(--muted);
font-size: .88rem;
}
.spinner {
width: 32px;
height: 32px;
border: 2.5px solid var(--border-2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Movie list ─────────────────────────────────────────── */
#movie-list { display: flex; flex-direction: column; gap: .45rem; }
/* ── Movie row ─────────────────────────────────────────── */
.movie-row {
display: grid;
grid-template-columns: 68px 195px 1fr 340px;
gap: .9rem;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
padding: .55rem .7rem;
transition: background .2s, border-color .2s;
}
.movie-row:hover {
background: var(--surface-2);
border-color: var(--border-2);
}
/* ── Poster ─────────────────────────────────────────────── */
.movie-poster {
width: 68px;
height: 102px;
border-radius: 5px;
object-fit: cover;
display: block;
flex-shrink: 0;
background: var(--border);
}
.poster-ph {
width: 68px;
height: 102px;
border-radius: 5px;
background: var(--surface-2);
border: 1px solid var(--border-2);
display: flex;
align-items: center;
justify-content: center;
color: var(--faint);
font-size: 1.4rem;
flex-shrink: 0;
}
/* ── Info ─────────────────────────────────────────────── */
.movie-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.movie-title {
font-weight: 600;
font-size: .88rem;
line-height: 1.35;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.movie-meta { color: var(--muted); font-size: .75rem; margin-top: 1px; }
.badge-10 {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(244,63,94,.1);
color: #f87191;
border: 1px solid rgba(244,63,94,.22);
border-radius: 5px;
padding: 1px 7px;
font-size: .7rem;
font-weight: 600;
width: fit-content;
margin-top: 3px;
}
/* ── Synopsis ─────────────────────────────────────────── */
.movie-synopsis { position: relative; min-width: 0; }
.synopsis-text {
color: var(--muted);
font-size: .78rem;
line-height: 1.6;
max-height: 3.2em;
overflow: hidden;
transition: max-height .32s ease;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
}
.movie-synopsis:hover .synopsis-text {
max-height: 250px;
mask-image: none;
-webkit-mask-image: none;
}
.synopsis-empty {
color: var(--faint);
font-size: .75rem;
font-style: italic;
}
/* ── Rating ─────────────────────────────────────────────── */
.movie-rating {
display: flex;
gap: 3px;
justify-content: flex-end;
align-items: center;
}
.r-btn {
width: 24px;
height: 24px;
border-radius: 5px;
border: 1px solid var(--border-2);
background: transparent;
color: var(--muted);
font-size: .7rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color .08s, background .08s, color .08s, transform .1s;
font-family: inherit;
}
.r-btn:active { transform: scale(.92); }
.skip-btn {
margin-left: 6px;
padding: 0 9px;
height: 24px;
border-radius: 5px;
border: 1px solid var(--border-2);
background: transparent;
color: var(--muted);
font-size: .7rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: border-color .15s, color .15s, background .15s;
white-space: nowrap;
}
.skip-btn:hover {
border-color: rgba(251,146,60,.4);
color: #fb923c;
background: rgba(251,146,60,.07);
}
.is-skipped .skip-btn {
border-color: rgba(124,106,247,.3);
color: var(--accent);
}
.is-skipped .skip-btn:hover {
background: var(--accent-bg);
}
/* ── Pagination ─────────────────────────────────────────── */
#pagination {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: 1.8rem 0;
}
.page-btn {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-2);
border-radius: 7px;
padding: .4rem .85rem;
font-size: .78rem;
font-family: inherit;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.page-btn:disabled { opacity: .35; cursor: not-allowed; }
.page-info { color: var(--muted); font-size: .78rem; padding: 0 .4rem; }
/* ── Toast ─────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1.8rem;
left: 50%;
transform: translateX(-50%) translateY(6px);
background: var(--surface-2);
border: 1px solid var(--border-2);
color: var(--text);
padding: .55rem 1.1rem;
border-radius: 9px;
font-size: .82rem;
font-weight: 500;
box-shadow: 0 12px 40px rgba(0,0,0,.55);
z-index: 1000;
opacity: 0;
transition: opacity .22s, transform .22s;
pointer-events: none;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}