Files
thumbnail-tinder/index.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

675 lines
30 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
body { height: 100%; display: flex; flex-direction: column; overflow: hidden; -webkit-tap-highlight-color: transparent; }
/* ---------- Top bar ---------- */
header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 14px 20px; }
.top-right { display: flex; justify-content: flex-end; gap: 8px; }
#skipBtn { padding: 10px 30px; }
.progress { padding: 0 20px; }
.progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; font: 400 12px/1.4 var(--f-mono); color: var(--on-muted); }
.progress-meta b { color: var(--on); font-weight: 500; }
.bar { height: 3px; background: #ffffff2e; }
.bar > div { height: 100%; width: 0; background: var(--on); transition: width .25s; }
/* ---------- Stage ---------- */
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; }
/* all cards share one grid cell: the stack is as tall as the tallest card */
.stack {
position: relative; display: grid; min-height: 360px;
/* keep card + controls inside the viewport */
width: min(100%, 600px, max(360px, calc((100dvh - 470px) * 16 / 9)));
}
.stack > * { grid-area: 1 / 1; }
/* the card is a slide in a mount */
.card {
position: relative; display: flex; flex-direction: column; min-height: 0; padding: 10px;
background: var(--mount); border: 1px solid var(--line); border-radius: 6px;
box-shadow: 0 1px 0 #fff inset, 0 26px 40px -26px #000a, 0 2px 5px #0002;
user-select: none; -webkit-user-select: none; touch-action: none;
will-change: transform; cursor: grab; container-type: inline-size;
transition: transform .3s cubic-bezier(.2,.8,.2,1), opacity .3s;
}
.card.dragging { transition: none; cursor: grabbing; }
.card.behind { transform: scale(.96) translateY(12px); opacity: .7; pointer-events: none; }
.card.behind-2 { transform: scale(.92) translateY(24px); opacity: .4; pointer-events: none; }
.thumb { position: relative; aspect-ratio: 16 / 9; background: #000; flex-shrink: 1; min-height: 0; border-radius: 2px; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; border-radius: 2px; }
.tags { position: absolute; left: 8px; top: 8px; display: flex; gap: 4px; }
.tag { padding: 4px 6px; border-radius: 2px; background: var(--mount); font: 500 11px/1 var(--f-mono); letter-spacing: .06em; text-transform: uppercase; }
.tag.short { background: var(--red); color: #fff; }
/* grease-pencil marks, drawn over the thumbnail while dragging */
.stamp { position: absolute; inset: -6px; opacity: 0; pointer-events: none; z-index: 2; }
.stamp svg { width: 100%; height: 100%; overflow: visible; }
.stamp path { fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.stamp path.u { stroke: #fff; stroke-width: 9; opacity: .75; }
.stamp path.m { stroke-width: 4.5; }
.stamp.like path.m { stroke: var(--red); }
.stamp.nope path.m { stroke: var(--ink); }
.stamp.skip { inset: auto; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(-6deg); }
.stamp.skip span {
display: block; padding: 8px 16px; background: #fffd; border: 3px solid var(--blue); border-radius: 3px;
color: var(--blue); font: 500 22px/1 var(--f-mono); letter-spacing: .1em; text-transform: uppercase;
}
.info { padding: 14px 6px 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.title {
margin: 0; font: 700 20px/1.25 var(--f-body); letter-spacing: -.01em;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.title:hover { text-decoration: underline; text-underline-offset: 3px; }
.channel { color: var(--muted); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
a.channel:hover { color: var(--ink); }
.facts { display: grid; grid-template-columns: 1fr 1fr 1.3fr; margin-top: 6px; border-top: 1.5px solid var(--ink); }
.fact { padding: 10px 12px 0 0; min-width: 0; }
.fact + .fact { padding-left: 14px; border-left: 1px solid var(--ink); }
.fact .label { font: 500 11.5px/1.2 var(--f-mono); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.fact .value { margin-top: 3px; font: 700 28px/1.15 var(--f-body); letter-spacing: -.02em; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.fact.score .value { font: 600 28px/1.15 var(--f-display); letter-spacing: -.05em; }
/* log scale 1x … 1000x: shows where this video sits */
.ruler { position: relative; height: 26px; margin-top: 4px; font: 400 10px/1 var(--f-mono); color: var(--muted); }
.ruler::before { content: ''; position: absolute; left: 0; right: 0; top: 4px; height: 1px; background: var(--ink); opacity: .55; }
.ruler .tick { position: absolute; top: 1px; width: 1px; height: 7px; background: var(--ink); opacity: .55; }
.ruler span { position: absolute; top: 13px; }
.ruler .t0 { left: 0; }
.ruler .t1 { left: 33.33%; transform: translateX(-33%); }
.ruler .t2 { left: 66.67%; transform: translateX(-66%); }
.ruler .t3 { right: 0; }
.ruler .mark { position: absolute; top: 0; width: 9px; height: 9px; margin-left: -4.5px; border-radius: 50%; background: var(--ink); box-shadow: 0 0 0 2px var(--mount); text-decoration: none; }
@container (max-width: 440px) {
.title { font-size: 17px; }
.fact .value { font-size: 23px; }
.fact.score .value { font-size: 23px; }
.fact .label { font-size: 10.5px; letter-spacing: .02em; }
}
@container (max-width: 380px) {
.fact { padding-right: 8px; }
.fact + .fact { padding-left: 10px; }
.fact .value { font-size: 19px; }
.fact.score .value { font-size: 19px; }
.fact .label { font-size: 9.5px; }
}
/* ---------- Bottom controls ---------- */
footer { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 4px 20px 18px; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: min(440px, 100%); }
.act {
display: flex; align-items: center; justify-content: center; gap: 10px; height: 54px;
border-radius: 6px; border: 1.5px solid var(--ink); background: var(--mount);
font-weight: 700; font-size: 16px; transition: transform .1s, background .15s, color .15s;
}
.act:not(:disabled):hover { background: var(--ink); color: var(--mount); }
.act:not(:disabled):active { transform: scale(.97); }
.act.yes { background: var(--red); border-color: var(--red); color: #fff; }
.act.yes:not(:disabled):hover { background: #b9211a; border-color: #b9211a; }
.act:disabled { opacity: .3; }
.minor { display: flex; gap: 20px; }
.link { display: inline-flex; align-items: center; gap: 8px; padding: 4px 0; background: none; border: 0; color: var(--on-muted); font-size: 13px; }
.link:not(:disabled):hover { color: var(--on); }
.link:disabled { opacity: .35; }
.screen .hint { font-size: 13px; }
.screen .hint a { text-decoration: underline; text-underline-offset: 3px; }
/* ---------- Done screen ---------- */
.summary { display: flex; gap: 28px; margin: 4px 0; font: 400 12px/1.3 var(--f-mono); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.summary b { display: block; font: 600 30px/1.1 var(--f-display); letter-spacing: -.04em; color: var(--ink); }
.summary .like b { color: var(--red); }
.summary .skip b { color: var(--blue); }
@media (max-width: 560px) {
header { grid-template-columns: auto 1fr; padding: 12px 14px; gap: 8px; }
.brand { display: none; }
header .btn { padding: 9px 12px; }
#skipBtn { padding: 9px 16px; }
.top-right { gap: 6px; }
.progress { padding: 0 14px; }
.info { padding: 12px 4px 2px; }
#newFileBtn { display: none; }
}
@media (max-height: 700px) {
.title { -webkit-line-clamp: 2; }
}
</style>
</head>
<body>
<header>
<a class="brand" href="index.html">Thumbnail Tinder</a>
<button id="skipBtn" class="btn" disabled title="Отложить видео (↑ или S)">Пропустить</button>
<div class="top-right">
<button id="exportBtn" class="btn primary" disabled>Скачать CSV · <span id="exportCount">0</span></button>
<button id="newFileBtn" class="btn hidden" title="Загрузить другой файл">Другой файл</button>
<a class="btn" href="liked.html" title="Открыть галерею понравившихся">Галерея</a>
</div>
</header>
<div class="progress hidden" id="progress">
<div class="progress-meta">
<span id="fileName"></span>
<span><b id="doneCount">0</b> из <span id="totalCount">0</span> · нравится <b id="likeCount">0</b> · нет <b id="nopeCount">0</b> · отложено <b id="skipCount">0</b></span>
</div>
<div class="bar"><div id="barFill"></div></div>
</div>
<main>
<div class="stack" id="stack">
<div class="screen" id="dropScreen">
<h2>Загрузите CSV из Outlier Saver</h2>
<p>Перетащите файл на страницу или выберите его. Понравившиеся видео сохранятся в новый 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>
</main>
<footer>
<div class="actions">
<button class="act no" id="nopeBtn" disabled title="Не нравится (←)"><kbd>←</kbd>Не нравится</button>
<button class="act yes" id="likeBtn" disabled title="Нравится (→)">Нравится<kbd>→</kbd></button>
</div>
<div class="minor">
<button class="link" id="undoBtn" disabled title="Отменить последнее решение">Отменить <kbd>Z</kbd></button>
<button class="link" id="openBtn" disabled title="Открыть видео на YouTube">Открыть на YouTube <kbd>Enter</kbd></button>
</div>
</footer>
<input type="file" id="fileInput" accept=".csv,text/csv" hidden>
<div class="toast" id="toast"></div>
<script>
(() => {
'use strict';
// ================= CSV =================
// Parses CSV and keeps each record's exact raw text, so the export
// reproduces liked rows byte-for-byte (quoting, empty fields, etc.).
function parseCSV(text) {
const bom = text.charCodeAt(0) === 0xFEFF;
if (bom) text = text.slice(1);
const eol = text.includes('\r\n') ? '\r\n' : '\n';
const endsWithEol = /\r?\n$/.test(text);
const records = [];
let fields = [], field = '', inQuotes = false, start = 0, i = 0;
const n = text.length;
const endRecord = (end) => {
fields.push(field);
const raw = text.slice(start, end);
if (raw.length) records.push({ raw, fields });
fields = []; field = '';
};
while (i < n) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
}
field += c; i++; continue;
}
if (c === '"') { inQuotes = true; i++; continue; }
if (c === ',') { fields.push(field); field = ''; i++; continue; }
if (c === '\r' && text[i + 1] === '\n') { endRecord(i); i += 2; start = i; continue; }
if (c === '\n' || c === '\r') { endRecord(i); i++; start = i; continue; }
field += c; i++;
}
if (start < n) endRecord(n);
if (!records.length) throw new Error('Файл пустой');
const header = records.shift();
const cols = header.fields.map(h => h.trim());
const rows = records.map((r, idx) => {
const obj = { _i: idx, _raw: r.raw };
cols.forEach((c, k) => { obj[c] = r.fields[k] ?? ''; });
return obj;
});
return { bom, eol, endsWithEol, headerRaw: header.raw, cols, rows };
}
function buildExport(csv, likedRows) {
const lines = [csv.headerRaw, ...likedRows.map(r => r._raw)];
return (csv.bom ? '' : '') + lines.join(csv.eol) + (csv.endsWithEol ? csv.eol : '');
}
// ================= State =================
let csv = null; // parsed file
let fileName = '';
let storeKey = '';
let decisions = {}; // rowIndex -> 'like' | 'nope' | 'skip'
let history = []; // rowIndex[] in decision order (for undo)
let queue = []; // undecided row indexes, file order
let busy = false;
const $ = (id) => document.getElementById(id);
const stack = $('stack');
// ================= Persistence =================
function hash(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
}
function save() {
try { localStorage.setItem(storeKey, JSON.stringify({ decisions, history })); } catch {}
}
function load() {
try {
const s = JSON.parse(localStorage.getItem(storeKey) || 'null');
if (s && s.decisions && Array.isArray(s.history)) return s;
} catch {}
return null;
}
// ================= Formatting =================
const compact = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 });
const full = new Intl.NumberFormat('ru-RU');
const fmtNum = (v) => {
if (v === '' || v == null || isNaN(+v)) return '—';
return +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 foundOnLabel = { home: 'главная', search: 'поиск', watch: 'рекомендации', channel: 'канал' };
function thumbSources(r) {
const id = r.videoId;
const orig = r.thumbnailUrl;
const list = [];
// mqdefault/hqdefault are low-res; try maxres first. hq720 links are already 1280×720.
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;
// YouTube sometimes returns a 120×90 grey placeholder instead of a 404
img.onload = () => { if (img.naturalWidth <= 120 && k < sources.length) next(); };
next();
}
const preloaded = new Set();
function preload(idx) {
if (idx == null || preloaded.has(idx)) return;
preloaded.add(idx);
setImg(new Image(), thumbSources(csv.rows[idx]));
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ================= Rendering =================
function makeCard(r) {
const isShort = String(r.isShort).toLowerCase() === 'true';
const score = +r.outlierScore;
const el = document.createElement('div');
el.className = 'card';
el.dataset.idx = r._i;
const logPos = (isFinite(score) && score > 1) ? Math.min(1, Math.log10(score) / 3) * 100 : 0;
el.innerHTML = `
<div class="thumb">
<img alt="" draggable="false">
<div class="tags">
${isShort ? '<span class="tag short">Shorts</span>' : ''}
${r.foundOn ? `<span class="tag">${esc(foundOnLabel[r.foundOn] || r.foundOn)}</span>` : ''}
</div>
<div class="stamp like"><svg viewBox="0 0 100 60" preserveAspectRatio="none" aria-hidden="true"><path class="u" d="M52 3 C82 1 98 14 97 31 C96 49 72 58 46 57 C19 56 3 44 4 28 C5 12 28 4 56 4 C68 4 78 7 85 12"/><path class="m" d="M52 3 C82 1 98 14 97 31 C96 49 72 58 46 57 C19 56 3 44 4 28 C5 12 28 4 56 4 C68 4 78 7 85 12"/></svg></div>
<div class="stamp nope"><svg viewBox="0 0 100 60" preserveAspectRatio="none" aria-hidden="true"><path class="u" d="M6 5 C30 20 70 42 95 56 M95 4 C70 20 30 42 5 55"/><path class="m" d="M6 5 C30 20 70 42 95 56 M95 4 C70 20 30 42 5 55"/></svg></div>
<div class="stamp skip"><span>Потом</span></div>
</div>
<div class="info">
<a class="title" href="${esc(r.url)}" target="_blank" rel="noopener" draggable="false">${esc(r.title)}</a>
${r.channelUrl
? `<a class="channel" href="${esc(r.channelUrl)}" target="_blank" rel="noopener" draggable="false">${esc(r.channelTitle)}</a>`
: `<span class="channel">${esc(r.channelTitle)}</span>`}
<div class="facts">
<div class="fact"><div class="label">Просмотры</div><div class="value" title="${esc(r.views)}">${fmtNum(r.views)}</div></div>
<div class="fact"><div class="label">Подписчики</div><div class="value" title="${esc(r.subscribers)}">${fmtNum(r.subscribers)}</div></div>
<div class="fact score"><div class="label">Outlier score</div><div class="value" title="${esc(r.outlierScore)}">${fmtScore(r.outlierScore)}</div></div>
</div>
<div class="ruler" aria-hidden="true">
<i class="tick" style="left:0"></i><i class="tick" style="left:33.33%"></i><i class="tick" style="left:66.67%"></i><i class="tick" style="left:100%"></i>
<span class="t0">1×</span><span class="t1">10×</span><span class="t2">100×</span><span class="t3">1000×</span>
<u class="mark" style="left:${logPos}%"></u>
</div>
</div>`;
setImg(el.querySelector('img'), thumbSources(r));
return el;
}
function computeQueue() {
queue = csv.rows.map(r => r._i).filter(i => !decisions[i]);
}
function counts() {
let like = 0, nope = 0, skip = 0;
for (const k in decisions) {
if (decisions[k] === 'like') like++;
else if (decisions[k] === 'nope') nope++;
else if (decisions[k] === 'skip') skip++;
}
return { like, nope, skip };
}
function updateChrome() {
const c = counts();
const total = csv.rows.length;
const done = c.like + c.nope + c.skip;
$('doneCount').textContent = done;
$('totalCount').textContent = total;
$('likeCount').textContent = c.like;
$('nopeCount').textContent = c.nope;
$('skipCount').textContent = c.skip;
$('exportCount').textContent = c.like;
$('barFill').style.width = (total ? (done / total) * 100 : 0) + '%';
const has = queue.length > 0;
$('likeBtn').disabled = $('nopeBtn').disabled = $('skipBtn').disabled = $('openBtn').disabled = !has;
$('undoBtn').disabled = history.length === 0;
$('exportBtn').disabled = c.like === 0;
return c;
}
function render() {
stack.innerHTML = '';
const c = updateChrome();
if (!queue.length) { renderDone(c); return; }
// back to front: up to 3 cards visible
const visible = queue.slice(0, 3);
for (let k = visible.length - 1; k >= 0; k--) {
const card = makeCard(csv.rows[visible[k]]);
if (k === 1) card.classList.add('behind');
if (k === 2) card.classList.add('behind-2');
stack.appendChild(card);
if (k === 0) attachDrag(card);
}
queue.slice(3, 6).forEach(preload);
}
function renderDone(c) {
const el = document.createElement('div');
el.className = 'screen solid';
el.innerHTML = `
<h2>${c.skip ? 'Основной проход закончен' : (c.like ? 'Все видео оценены' : 'Понравившихся нет')}</h2>
<div class="summary">
<div class="like"><b>${c.like}</b>нравится</div>
<div><b>${c.nope}</b>не нравится</div>
<div class="skip"><b>${c.skip}</b>отложено</div>
</div>
<p>${c.skip ? 'Отложенные видео не попадут в файл, пока вы их не оцените.' : 'В файл попадут только понравившиеся видео, в том же формате, что и в исходном.'}</p>
<div class="row">
${c.like ? '<button class="btn primary" data-act="export">Скачать CSV · ' + c.like + '</button>' : ''}
${c.skip ? '<button class="btn" data-act="review">Пересмотреть отложенные · ' + c.skip + '</button>' : ''}
<button class="btn" data-act="restart">Начать заново</button>
</div>`;
el.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'export') exportCSV();
if (act === 'review') reviewSkipped();
if (act === 'restart') restart();
});
stack.appendChild(el);
}
// ================= Actions =================
function decide(kind) {
if (busy || !queue.length) return;
const card = stack.querySelector('.card:not(.behind):not(.behind-2)');
if (!card) return;
busy = true;
const idx = +card.dataset.idx;
flyOut(card, kind, () => {
decisions[idx] = kind;
history.push(idx);
save();
computeQueue();
busy = false;
render();
});
}
function flyOut(card, kind, done) {
const w = window.innerWidth;
const cur = card._drag || { x: 0, y: 0 };
let x = 0, y = 0, rot = 0;
if (kind === 'like') { x = w; y = cur.y; rot = 30; }
if (kind === 'nope') { x = -w; y = cur.y; rot = -30; }
if (kind === 'skip') { x = cur.x; y = -window.innerHeight; rot = 0; }
card.classList.remove('dragging');
card.querySelector('.stamp.' + kind).style.opacity = 1;
card.style.transition = 'transform .35s ease-in, opacity .35s';
card.style.transform = `translate(${x}px, ${y}px) rotate(${rot}deg)`;
card.style.opacity = '0';
// bring next card forward at the same time
const next = stack.querySelector('.card.behind');
if (next) { next.classList.remove('behind'); next.style.opacity = '1'; }
setTimeout(done, 300);
}
function undo() {
if (busy || !history.length) return;
const idx = history.pop();
delete decisions[idx];
save();
computeQueue();
// put the undone card on top even if it was not first in file order
queue = [idx, ...queue.filter(i => i !== idx)];
render();
toast('Отменено');
}
function reviewSkipped() {
for (const k in decisions) if (decisions[k] === 'skip') delete decisions[k];
history = history.filter(i => decisions[i]);
save();
computeQueue();
render();
}
function restart() {
if (!confirm('Сбросить все оценки для этого файла?')) return;
decisions = {}; history = [];
save();
computeQueue();
render();
}
function openCurrent() {
if (!queue.length) return;
const r = csv.rows[queue[0]];
if (r.url) window.open(r.url, '_blank', 'noopener');
}
function exportCSV() {
const liked = csv.rows.filter(r => decisions[r._i] === 'like');
if (!liked.length) return;
const blob = new Blob([buildExport(csv, liked)], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.csv$/i, '') + '-liked.csv';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast(`Сохранено ${liked.length} видео → ${a.download}`);
}
// ================= Drag / swipe =================
function attachDrag(card) {
let sx = 0, sy = 0, moved = false, active = false, lastX = 0, lastT = 0, vx = 0;
const stamps = {
like: card.querySelector('.stamp.like'),
nope: card.querySelector('.stamp.nope'),
skip: card.querySelector('.stamp.skip'),
};
const next = () => stack.querySelector('.card.behind');
card.addEventListener('pointerdown', (e) => {
if (busy || e.button !== 0) return;
active = true; moved = false;
sx = e.clientX; sy = e.clientY; lastX = sx; lastT = performance.now(); vx = 0;
card._drag = { x: 0, y: 0 };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
});
function onMove(e) {
if (!active) return;
const dx = e.clientX - sx, dy = e.clientY - sy;
if (!moved && Math.hypot(dx, dy) < 6) return;
if (!moved) { moved = true; card.classList.add('dragging'); }
const now = performance.now();
vx = (e.clientX - lastX) / Math.max(1, now - lastT);
lastX = e.clientX; lastT = now;
card._drag = { x: dx, y: dy };
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${dx / 18}deg)`;
const t = 110;
stamps.like.style.opacity = Math.max(0, Math.min(1, dx / t));
stamps.nope.style.opacity = Math.max(0, Math.min(1, -dx / t));
stamps.skip.style.opacity = Math.abs(dx) < 60 ? Math.max(0, Math.min(1, -dy / t)) : 0;
const n = next();
if (n) {
const p = Math.min(1, Math.max(Math.abs(dx), -dy) / 200);
n.style.transition = 'none';
n.style.transform = `scale(${0.95 + 0.05 * p}) translateY(${14 - 14 * p}px)`;
n.style.opacity = 0.6 + 0.4 * p;
}
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if (!active) return;
active = false;
if (!moved) return;
const { x, y } = card._drag;
const w = card.offsetWidth;
const n = next();
if (n) { n.style.transition = ''; }
if (x > w * 0.3 || vx > 0.6) return decide('like');
if (x < -w * 0.3 || vx < -0.6) return decide('nope');
if (y < -140 && Math.abs(x) < w * 0.3) return decide('skip');
// snap back
card.classList.remove('dragging');
card.style.transform = '';
card._drag = { x: 0, y: 0 };
Object.values(stamps).forEach(s => s.style.opacity = 0);
if (n) { n.style.transform = ''; n.style.opacity = ''; }
}
// suppress link clicks that were actually drags
card.addEventListener('click', (e) => {
if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
}, true);
}
// ================= File loading =================
async function loadFile(file) {
if (!file) return;
let text;
try { text = await file.text(); } catch { return toast('Не удалось прочитать файл'); }
let parsed;
try { parsed = parseCSV(text); } catch (err) { return toast('Ошибка CSV: ' + err.message); }
const need = ['videoId', 'title', 'thumbnailUrl'];
const missing = need.filter(c => !parsed.cols.includes(c));
if (missing.length) return toast('В файле нет колонок: ' + missing.join(', '));
if (!parsed.rows.length) return toast('В файле нет строк с видео');
csv = parsed;
fileName = file.name || 'outliers.csv';
storeKey = 'thumbnail-tinder:' + hash(text);
preloaded.clear();
const saved = load();
decisions = saved ? saved.decisions : {};
history = saved ? saved.history.filter(i => decisions[i]) : [];
computeQueue();
$('fileName').textContent = fileName;
$('progress').classList.remove('hidden');
$('newFileBtn').classList.remove('hidden');
render();
if (saved && history.length) toast(`Прогресс восстановлен: оценено ${history.length} из ${csv.rows.length}`);
}
const fileInput = $('fileInput');
fileInput.addEventListener('change', () => { loadFile(fileInput.files[0]); fileInput.value = ''; });
$('pickBtn').addEventListener('click', () => fileInput.click());
$('newFileBtn').addEventListener('click', () => fileInput.click());
// drag & drop anywhere on the page
let dragDepth = 0;
const dropScreen = () => document.getElementById('dropScreen');
window.addEventListener('dragenter', (e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); dragDepth++; dropScreen()?.classList.add('dragover'); } });
window.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; dropScreen()?.classList.remove('dragover'); } });
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => {
e.preventDefault(); dragDepth = 0; dropScreen()?.classList.remove('dragover');
const f = e.dataTransfer?.files?.[0];
if (f) loadFile(f);
});
// ================= Buttons & keys =================
$('likeBtn').addEventListener('click', () => decide('like'));
$('nopeBtn').addEventListener('click', () => decide('nope'));
$('skipBtn').addEventListener('click', () => decide('skip'));
$('undoBtn').addEventListener('click', undo);
$('openBtn').addEventListener('click', openCurrent);
$('exportBtn').addEventListener('click', exportCSV);
window.addEventListener('keydown', (e) => {
if (!csv || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k === 'arrowright' || k === 'd' || k === 'в') { e.preventDefault(); decide('like'); }
else if (k === 'arrowleft' || k === 'a' || k === 'ф') { e.preventDefault(); decide('nope'); }
else if (k === 'arrowup' || k === 's' || k === 'ы') { e.preventDefault(); decide('skip'); }
else if (k === 'z' || k === 'я' || k === 'backspace') { e.preventDefault(); undo(); }
else if (k === 'enter') { e.preventDefault(); openCurrent(); }
});
window.addEventListener('beforeunload', save);
// ================= Toast =================
let toastTimer;
function toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}
// expose for testing
window.__tt = { parseCSV, buildExport };
})();
</script>
</body>
</html>