Files
thumbnail-tinder/liked.html
T
IgorVolochayandClaude Sonnet 5.5 1263aade8b Add links to extension and live site, shrink sample CSV
Replace the 822-row sample with a 6-row example.csv. Link the Outlier Saver
extension from the README and both empty screens. Set nginx server_name to
outliers.viaproger.ru.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 22:51:15 +03:00

262 lines
12 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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap">
<link rel="stylesheet" href="style.css">
<style>
header {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 12px 16px; flex-wrap: wrap;
padding: 12px 24px; background: #1B1B1Fee; backdrop-filter: blur(8px);
border-bottom: 1px solid #ffffff2e;
}
.heading { font: 600 14px/1 var(--f-display); letter-spacing: -.02em; color: var(--on); }
.meta { font: 400 12px/1.4 var(--f-mono); color: var(--on-muted); }
.meta b { color: var(--on); font-weight: 500; }
.spacer { flex: 1; }
@media (max-width: 520px) {
header { padding: 10px 12px; gap: 8px; }
header .btn { padding: 8px 12px; font-size: 13px; }
.heading { flex-basis: 100%; }
.spacer { display: none; }
}
/* ---------- Contact sheet: 2 → 3 → 4 columns ---------- */
.grid {
display: grid; gap: 30px 18px; 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: 22px 10px; } }
/* each video is one paper mount: print on top, caption below */
.item {
display: flex; flex-direction: column; min-width: 0; padding: 6px 6px 14px; background: var(--mount); border-radius: 4px;
box-shadow: 0 1px 0 #fff inset, 0 18px 26px -18px #000c, 0 1px 3px #0003;
transition: transform .15s, box-shadow .15s;
}
.item:hover { transform: translateY(-3px); box-shadow: 0 1px 0 #fff inset, 0 24px 30px -18px #000d, 0 1px 3px #0003; }
.print { position: relative; display: block; }
.frame { aspect-ratio: 16 / 9; background: #000; border-radius: 2px; overflow: hidden; }
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tag {
position: absolute; left: 12px; top: 12px; padding: 4px 6px; border-radius: 2px;
background: var(--red); color: #fff; font: 500 11px/1 var(--f-mono); letter-spacing: .06em; text-transform: uppercase;
}
.cap { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: 1fr auto; gap: 8px 12px; align-items: start; padding: 12px 6px 0; }
.title {
margin: 0 0 3px; font: 600 15px/1.3 var(--f-body);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
a:hover > .title { text-decoration: underline; text-underline-offset: 3px; }
.channel { display: block; color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.channel:hover { color: var(--ink); }
.stats { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; margin-top: 4px; border-top: 1.5px solid var(--ink); }
.stats div { padding: 8px 10px 0 0; min-width: 0; }
.stats div + div { padding-left: 12px; border-left: 1px solid var(--ink); }
.stats span { display: block; font: 500 11px/1.2 var(--f-mono); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.stats b { display: block; margin-top: 2px; font: 700 22px/1.2 var(--f-body); letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 520px) { .stats b { font-size: 16px; } .stats span { font-size: 9.5px; letter-spacing: .02em; } .stats div + div { padding-left: 8px; } }
/* every video on this sheet was chosen, so every score gets the red pencil circle */
.score {
display: inline-block; margin-top: 2px; padding: 7px 11px; white-space: nowrap;
border: 2.5px solid var(--red); border-radius: 52% 48% 55% 45% / 50% 56% 44% 50%;
transform: rotate(-5deg);
font: 600 14px/1 var(--f-display); letter-spacing: -.04em; font-variant-numeric: tabular-nums;
}
@media (max-width: 520px) { .cap { grid-template-columns: 1fr; } .score { justify-self: start; } }
/* ---------- Empty ---------- */
.empty { margin: 12vh auto 0; width: min(560px, calc(100% - 32px)); }
.screen .hint { font-size: 13px; }
.screen .hint a { text-decoration: underline; text-underline-offset: 3px; }
</style>
</head>
<body>
<header>
<span class="heading">Понравившиеся</span>
<div class="meta hidden" id="meta"><b id="count">0</b> видео · <span id="fileName"></span></div>
<div class="spacer"></div>
<select class="btn hidden" id="sort" aria-label="Сортировка">
<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="btn hidden" id="newFileBtn">Другой файл</button>
<a class="btn" href="index.html">К отбору</a>
</header>
<div class="screen empty" id="empty">
<h2>Загрузите CSV с понравившимися</h2>
<p>Выберите файл …-liked.csv, который вы скачали в приложении для отбора. Подойдёт и любой CSV из расширения.</p>
<div class="row"><button class="btn primary" id="pickBtn">Выбрать файл</button></div>
<p class="hint">Файл создаёт <a href="https://gitea.viaproger.ru/volochay/outlier-saver-extension" target="_blank" rel="noopener">расширение Outlier Saver</a>.</p>
</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) => {
if (v === '' || isNaN(+v)) return '—';
v = +v;
return '×' + (v >= 1000 ? compact.format(v) : v >= 100 ? Math.round(v) : +v.toFixed(1));
};
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
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 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="print" aria-label="${esc(r.title)}">
<div class="frame"><img alt="" loading="lazy" draggable="false"></div>
${short ? '<span class="tag">Shorts</span>' : ''}
</a>
<div class="cap">
<div>
<a href="${esc(r.url)}" target="_blank" rel="noopener"><h3 class="title" title="${esc(r.title)}">${esc(r.title)}</h3></a>
${ch}
</div>
<div class="score" title="Outlier score: ${esc(r.outlierScore)}">${fmtScore(r.outlierScore)}</div>
<div class="stats">
<div><span>Просмотры</span><b>${fmtNum(r.views)}</b></div>
<div><span>Подписчики</span><b>${fmtNum(r.subscribers)}</b></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>