Files
thumbnail-tinder/liked.html
T
IgorVolochayandClaude Sonnet 5.5 874a53e492 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>
2026-11-14 22:08:53 +03:00

286 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>