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:
co-authored by
Claude Sonnet 5.5
parent
ea655d47b9
commit
874a53e492
+285
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
Reference in New Issue
Block a user