Add liked videos gallery page

YouTube-style grid (2-4 columns) with thumbnail, views, subscribers and
outlier score, sortable. Link to it from the swipe app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
IgorVolochay
2026-11-14 22:08:53 +03:00
co-authored by Claude Sonnet 5.5
parent ea655d47b9
commit 874a53e492
3 changed files with 293 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Понравившиеся — Thumbnail Tinder</title>
<style>
:root {
--bg: #0e0e12;
--surface: #1a1a21;
--surface-2: #24242d;
--border: #2e2e39;
--text: #f3f3f6;
--muted: #9a9aa8;
--accent-1: #fd267a;
--accent-2: #ff6036;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: radial-gradient(1200px 600px at 50% -10%, #2a1420 0%, var(--bg) 60%) no-repeat;
background-color: var(--bg);
color: var(--text);
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, select { font: inherit; color: inherit; }
button { cursor: pointer; border: 0; background: none; }
a { color: inherit; text-decoration: none; }
header {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 24px; background: #0e0e12e6; backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
.brand { font-weight: 800; font-size: 18px; letter-spacing: -.02em; white-space: nowrap; }
.brand span { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.meta { color: var(--muted); font-size: 14px; }
.meta b { color: var(--text); }
.spacer { flex: 1; }
.pill {
padding: 8px 14px; border-radius: 999px; background: var(--surface-2);
border: 1px solid var(--border); font-weight: 600; font-size: 14px; white-space: nowrap;
}
.pill:hover { background: #2d2d38; }
select.pill { appearance: none; padding-right: 14px; cursor: pointer; }
select.pill option { background: var(--surface); }
/* ---------- Grid: 2 → 3 → 4 columns, like the YouTube home page ---------- */
.grid {
display: grid; gap: 28px 16px; padding: 24px;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 1800px; margin: 0 auto;
}
@media (min-width: 900px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 1300px) { .grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 520px) { .grid { padding: 12px; gap: 20px 10px; } header { padding: 10px 12px; } }
.item { display: block; min-width: 0; }
.thumb {
display: block; position: relative; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden;
background: var(--surface); transition: border-radius .15s;
}
.item:hover .thumb { border-radius: 0; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.badge {
position: absolute; right: 6px; bottom: 6px; padding: 2px 7px; border-radius: 6px;
font-size: 12px; font-weight: 800; background: #000c; color: #ffb547;
}
.badge.hot { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); color: #fff; }
.shorts { position: absolute; left: 6px; top: 6px; padding: 2px 7px; border-radius: 6px; font-size: 11px; font-weight: 800; background: #ff0033cc; }
.details { display: flex; gap: 10px; padding-top: 10px; }
.avatar {
flex: none; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-weight: 700; font-size: 15px; color: #fff; background: var(--hue, #555);
}
.text { min-width: 0; }
.title {
margin: 0 0 4px; font-size: 15px; font-weight: 600; line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.sub { color: var(--muted); font-size: 13px; line-height: 1.45; }
.channel { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.channel:hover { color: var(--text); }
.nums b { color: var(--text); font-weight: 600; }
.dot { margin: 0 4px; }
/* ---------- Empty ---------- */
.empty {
margin: 12vh auto 0; width: min(560px, calc(100% - 32px)); padding: 40px 24px;
display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
background: var(--surface); border: 2px dashed var(--border); border-radius: 18px;
}
.empty.dragover { border-color: var(--accent-1); background: #2a1420; }
.empty h2 { margin: 0; font-size: 24px; }
.empty p { margin: 0; color: var(--muted); max-width: 400px; }
.pill.primary { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); border-color: transparent; }
.toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(120%);
background: var(--surface-2); border: 1px solid var(--border); padding: 10px 16px; border-radius: 12px;
opacity: 0; transition: .25s; z-index: 10; pointer-events: none; max-width: calc(100% - 32px);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.hidden { display: none !important; }
</style>
</head>
<body>
<header>
<div class="brand">🔥 Thumbnail <span>Tinder</span></div>
<div class="meta hidden" id="meta"><b id="count">0</b> видео · <span id="fileName"></span></div>
<div class="spacer"></div>
<select class="pill hidden" id="sort" title="Сортировка">
<option value="file">Как в файле</option>
<option value="score">По outlier score</option>
<option value="views">По просмотрам</option>
<option value="subs">По подписчикам</option>
<option value="date">Сначала новые</option>
</select>
<button class="pill hidden" id="newFileBtn">Файл…</button>
<a class="pill" href="index.html">← Отбор</a>
</header>
<div class="empty" id="empty">
<div style="font-size:52px">❤️</div>
<h2>Загрузите отобранный CSV</h2>
<p>Файл <b>…-liked.csv</b>, который вы скачали из Thumbnail Tinder (подойдёт и любой CSV из расширения). Видео покажутся сеткой, как на главной YouTube.</p>
<button class="pill primary" id="pickBtn">Выбрать файл</button>
</div>
<div class="grid hidden" id="grid"></div>
<input type="file" id="fileInput" accept=".csv,text/csv" hidden>
<div class="toast" id="toast"></div>
<script>
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
// ---------- CSV ----------
function parseCSV(text) {
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
const records = [];
let fields = [], field = '', q = false, i = 0, has = false;
const n = text.length;
const end = () => { fields.push(field); if (has || fields.length > 1) records.push(fields); fields = []; field = ''; has = false; };
while (i < n) {
const c = text[i];
if (q) {
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } q = false; i++; continue; }
field += c; i++; continue;
}
if (c === '"') { q = true; has = true; i++; continue; }
if (c === ',') { fields.push(field); field = ''; has = true; i++; continue; }
if (c === '\r' && text[i + 1] === '\n') { end(); i += 2; continue; }
if (c === '\n' || c === '\r') { end(); i++; continue; }
field += c; has = true; i++;
}
if (has || field) end();
if (!records.length) throw new Error('Файл пустой');
const cols = records.shift().map(h => h.trim());
return {
cols,
rows: records.map((r, idx) => {
const o = { _i: idx };
cols.forEach((c, k) => { o[c] = r[k] ?? ''; });
return o;
}),
};
}
// ---------- Formatting ----------
const compact = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 });
const full = new Intl.NumberFormat('ru-RU');
const fmtNum = (v) => (v === '' || isNaN(+v)) ? '—' : (+v >= 10000 ? compact.format(+v) : full.format(+v));
const fmtScore = (v) => (v === '' || isNaN(+v)) ? '—' : '×' + (+v >= 100 ? Math.round(+v) : +(+v).toFixed(1));
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const hue = (s) => { let h = 0; for (const ch of s) h = (h * 31 + ch.codePointAt(0)) % 360; return `hsl(${h} 45% 40%)`; };
const initial = (s) => (Array.from((s || '?').trim())[0] || '?').toUpperCase();
function thumbSources(r) {
const id = r.videoId, orig = r.thumbnailUrl, list = [];
if (id && !/hq720/.test(orig || '')) list.push(`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`);
if (orig) list.push(orig);
if (id) list.push(`https://i.ytimg.com/vi/${id}/hqdefault.jpg`);
return list;
}
function setImg(img, sources) {
let k = 0;
const next = () => { if (k >= sources.length) { img.onerror = null; return; } img.src = sources[k++]; };
img.onerror = next;
img.onload = () => { if (img.naturalWidth <= 120 && k < sources.length) next(); }; // grey placeholder
next();
}
// ---------- Render ----------
let rows = [], fileName = '';
const sorters = {
file: (a, b) => a._i - b._i,
score: (a, b) => (+b.outlierScore || 0) - (+a.outlierScore || 0),
views: (a, b) => (+b.views || 0) - (+a.views || 0),
subs: (a, b) => (+b.subscribers || 0) - (+a.subscribers || 0),
date: (a, b) => String(b.firstSeenAt).localeCompare(String(a.firstSeenAt)),
};
function render() {
const list = [...rows].sort(sorters[$('sort').value]);
const grid = $('grid');
grid.innerHTML = '';
const frag = document.createDocumentFragment();
for (const r of list) {
const score = +r.outlierScore;
const short = String(r.isShort).toLowerCase() === 'true';
const ch = r.channelUrl
? `<a class="channel" href="${esc(r.channelUrl)}" target="_blank" rel="noopener">${esc(r.channelTitle)}</a>`
: `<span class="channel">${esc(r.channelTitle)}</span>`;
const el = document.createElement('div');
el.className = 'item';
el.innerHTML = `
<a href="${esc(r.url)}" target="_blank" rel="noopener" class="thumb">
<img alt="" loading="lazy" draggable="false">
${short ? '<span class="shorts">SHORTS</span>' : ''}
<span class="badge ${score >= 10 ? 'hot' : ''}" title="Outlier score">${fmtScore(r.outlierScore)}</span>
</a>
<div class="details">
<div class="avatar" style="--hue:${hue(r.channelTitle || '')}">${esc(initial(r.channelTitle))}</div>
<div class="text">
<a href="${esc(r.url)}" target="_blank" rel="noopener"><h3 class="title" title="${esc(r.title)}">${esc(r.title)}</h3></a>
<div class="sub">
${ch}
<div class="nums"><b>${fmtNum(r.views)}</b> просмотров<span class="dot">•</span><b>${fmtNum(r.subscribers)}</b> подписчиков</div>
</div>
</div>
</div>`;
setImg(el.querySelector('img'), thumbSources(r));
frag.appendChild(el);
}
grid.appendChild(frag);
}
// ---------- Loading ----------
async function loadFile(file) {
if (!file) return;
let parsed;
try { parsed = parseCSV(await file.text()); } catch (e) { return toast('Ошибка CSV: ' + e.message); }
const missing = ['videoId', 'title'].filter(c => !parsed.cols.includes(c));
if (missing.length) return toast('В файле нет колонок: ' + missing.join(', '));
if (!parsed.rows.length) return toast('В файле нет строк с видео');
rows = parsed.rows;
fileName = file.name || 'liked.csv';
$('count').textContent = rows.length;
$('fileName').textContent = fileName;
['meta', 'sort', 'newFileBtn', 'grid'].forEach(id => $(id).classList.remove('hidden'));
$('empty').classList.add('hidden');
window.scrollTo(0, 0);
render();
}
const input = $('fileInput');
input.addEventListener('change', () => { loadFile(input.files[0]); input.value = ''; });
$('pickBtn').addEventListener('click', () => input.click());
$('newFileBtn').addEventListener('click', () => input.click());
$('sort').addEventListener('change', render);
let depth = 0;
window.addEventListener('dragenter', (e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); depth++; $('empty').classList.add('dragover'); } });
window.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; $('empty').classList.remove('dragover'); } });
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => {
e.preventDefault(); depth = 0; $('empty').classList.remove('dragover');
loadFile(e.dataTransfer?.files?.[0]);
});
let timer;
function toast(msg) {
const t = $('toast'); t.textContent = msg; t.classList.add('show');
clearTimeout(timer); timer = setTimeout(() => t.classList.remove('show'), 2600);
}
})();
</script>
</body>
</html>