Files
thumbnail-tinder/index.html
T
IgorVolochayandClaude Sonnet 5.5 ea655d47b9 Add Tinder-style swipe app for filtering outlier thumbnails
Load an Outlier Saver CSV, swipe right to like, left to dislike, or skip.
Export contains only liked rows, byte-identical to the source format.

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

701 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>
<style>
:root {
--bg: #0e0e12;
--surface: #1a1a21;
--surface-2: #24242d;
--border: #2e2e39;
--text: #f3f3f6;
--muted: #9a9aa8;
--like: #22c55e;
--nope: #ef4444;
--skip: #3b82f6;
--accent-1: #fd267a;
--accent-2: #ff6036;
--radius: 18px;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: radial-gradient(1200px 600px at 50% -10%, #2a1420 0%, var(--bg) 60%);
background-color: var(--bg);
color: var(--text);
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; }
button:disabled { opacity: .35; cursor: default; }
a { color: inherit; }
/* ---------- Top bar ---------- */
header {
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
gap: 12px; padding: 12px 16px;
}
.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;
}
.top-right { display: flex; justify-content: flex-end; gap: 8px; }
.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:not(:disabled):hover { background: #2d2d38; }
.pill.primary {
background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); border-color: transparent;
}
#skipBtn { padding: 10px 22px; font-size: 15px; border-color: #3b82f655; }
#skipBtn:not(:disabled):hover { background: #1e3a6e; }
.progress { padding: 0 16px; }
.progress-meta { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; margin-bottom: 6px; }
.progress-meta b { color: var(--text); font-weight: 600; }
.bar { height: 4px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); 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: ~440px is header, info block and buttons */
width: min(100%, 600px, max(320px, calc((100dvh - 440px) * 16 / 9))); }
.stack > * { grid-area: 1 / 1; }
.card {
position: relative; display: flex; flex-direction: column; min-height: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; box-shadow: 0 20px 50px #0008;
user-select: none; -webkit-user-select: none; touch-action: none;
will-change: transform; cursor: grab;
transition: transform .3s cubic-bezier(.2,.8,.2,1), opacity .3s;
}
.card.dragging { transition: none; cursor: grabbing; }
.card.behind { transform: scale(.95) translateY(14px); opacity: .6; pointer-events: none; }
.card.behind-2 { transform: scale(.9) translateY(28px); opacity: .3; pointer-events: none; }
.thumb { position: relative; aspect-ratio: 16 / 9; background: #000; flex-shrink: 1; min-height: 0; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.thumb.short img { object-fit: contain; }
.chip {
position: absolute; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700;
background: #000b; backdrop-filter: blur(6px);
}
.chip.found { top: 10px; right: 10px; text-transform: uppercase; letter-spacing: .04em; color: #ddd; }
.chip.short-badge { top: 10px; left: 10px; background: #ff0033cc; }
.stamp {
position: absolute; top: 28px; padding: 6px 14px; border: 4px solid; border-radius: 10px;
font-size: 34px; font-weight: 900; letter-spacing: .05em; opacity: 0; pointer-events: none;
text-shadow: 0 2px 8px #0008; z-index: 2;
}
.stamp.like { left: 22px; color: var(--like); border-color: var(--like); transform: rotate(-14deg); }
.stamp.nope { right: 22px; color: var(--nope); border-color: var(--nope); transform: rotate(14deg); }
.stamp.skip { left: 50%; top: 40%; color: var(--skip); border-color: var(--skip); transform: translateX(-50%); }
.info { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow: hidden; }
.title {
font-size: 19px; font-weight: 700; line-height: 1.3; margin: 0;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
text-decoration: none;
}
.title:hover { text-decoration: underline; }
.channel { color: var(--muted); font-size: 14px; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
a.channel:hover { color: var(--text); }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: auto; }
.stat { background: var(--surface-2); border-radius: 12px; padding: 10px 12px; min-width: 0; }
.stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.stat .value { font-size: 20px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat.score .value { color: #ffb547; }
.stat.score.hot .value { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
/* ---------- Bottom controls ---------- */
footer { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 4px 16px 18px; }
.controls { display: flex; align-items: center; justify-content: center; gap: 22px; }
.round {
display: grid; place-items: center; border-radius: 50%;
background: var(--surface); border: 1px solid var(--border); box-shadow: 0 6px 20px #0006;
transition: transform .12s, background .15s;
}
.round:not(:disabled):hover { transform: scale(1.08); }
.round:not(:disabled):active { transform: scale(.94); }
.round.big { width: 68px; height: 68px; }
.round.small { width: 48px; height: 48px; }
.round svg { width: 50%; height: 50%; }
#nopeBtn { color: var(--nope); }
#likeBtn { color: var(--like); }
#undoBtn { color: #facc15; }
.hint { color: var(--muted); font-size: 12px; }
.hint kbd { background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; font: inherit; }
/* ---------- Screens ---------- */
.screen {
position: relative; min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; text-align: center; padding: 24px;
background: var(--surface); border: 2px dashed var(--border); border-radius: var(--radius);
}
.screen.solid { border-style: solid; }
.screen h2 { margin: 0; font-size: 24px; }
.screen p { margin: 0; color: var(--muted); max-width: 380px; }
.screen.dragover { border-color: var(--accent-1); background: #2a1420; }
.screen .big-num { font-size: 56px; font-weight: 900; line-height: 1; }
.screen .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.summary { display: flex; gap: 20px; color: var(--muted); }
.summary b { display: block; font-size: 22px; color: var(--text); }
.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; }
.card { container-type: inline-size; }
@container (max-width: 440px) {
.stat { padding: 8px 10px; }
.stat .value { font-size: 16px; }
.stat .label { font-size: 10px; letter-spacing: .03em; }
}
@media (max-width: 560px) {
header { grid-template-columns: 1fr auto; }
.brand { display: none; }
.info { padding: 12px 14px 14px; gap: 8px; }
.title { font-size: 16px; }
.stat .value { font-size: 16px; }
.hint { display: none; }
}
@media (max-height: 700px) {
.title { -webkit-line-clamp: 2; }
}
</style>
</head>
<body>
<header>
<div class="brand">🔥 Thumbnail <span>Tinder</span></div>
<button id="skipBtn" class="pill" disabled title="Пропустить (↑ или S)">Пропустить ⤴</button>
<div class="top-right">
<button id="exportBtn" class="pill primary" disabled>Скачать ♥ <span id="exportCount">0</span></button>
<button id="newFileBtn" class="pill hidden" title="Загрузить другой файл">Файл…</button>
</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">
<div style="font-size:52px">🗂️</div>
<h2>Загрузите CSV с outliers</h2>
<p>Перетащите файл сюда или выберите его. Свайп вправо — нравится, влево — нет. В итоговый файл попадут только понравившиеся видео.</p>
<div class="row">
<button class="pill primary" id="pickBtn">Выбрать файл</button>
</div>
</div>
</div>
</main>
<footer>
<div class="controls">
<button class="round small" id="undoBtn" disabled title="Отменить (Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>
</button>
<button class="round big" id="nopeBtn" disabled title="Не нравится (←)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
<button class="round big" id="likeBtn" disabled title="Нравится (→)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-7.5-4.6-10-9.3C.3 8.4 2.2 4 6.3 4c2.3 0 3.9 1.3 5.7 3.4C13.8 5.3 15.4 4 17.7 4 21.8 4 23.7 8.4 22 11.7 19.5 16.4 12 21 12 21z"/></svg>
</button>
<button class="round small" id="openBtn" disabled title="Открыть видео (Enter)" style="color:#cbd5e1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg>
</button>
</div>
<div class="hint"><kbd>←</kbd> нет · <kbd>→</kbd> нравится · <kbd>↑</kbd>/<kbd>S</kbd> пропустить · <kbd>Z</kbd> отменить · <kbd>Enter</kbd> открыть видео</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) => (v === '' || isNaN(+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;
el.innerHTML = `
<div class="thumb ${isShort ? 'short' : ''}">
<img alt="" draggable="false">
${isShort ? '<span class="chip short-badge">SHORTS</span>' : ''}
${r.foundOn ? `<span class="chip found">${esc(foundOnLabel[r.foundOn] || r.foundOn)}</span>` : ''}
<div class="stamp like">НРАВИТСЯ</div>
<div class="stamp nope">НЕТ</div>
<div class="stamp skip">ПОТОМ</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="stats">
<div class="stat"><div class="label">Просмотры</div><div class="value" title="${esc(r.views)}">${fmtNum(r.views)}</div></div>
<div class="stat"><div class="label">Подписчики</div><div class="value" title="${esc(r.subscribers)}">${fmtNum(r.subscribers)}</div></div>
<div class="stat score ${score >= 10 ? 'hot' : ''}"><div class="label">Outlier</div><div class="value" title="${esc(r.outlierScore)}">${fmtScore(r.outlierScore)}</div></div>
</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 = `
<div style="font-size:48px">${c.like ? '🎉' : '🤷'}</div>
<h2>${c.skip ? 'Основной проход закончен' : 'Готово!'}</h2>
<div class="summary">
<div><b style="color:var(--like)">${c.like}</b>нравится</div>
<div><b style="color:var(--nope)">${c.nope}</b>нет</div>
<div><b style="color:var(--skip)">${c.skip}</b>пропущено</div>
</div>
<p>${c.skip ? 'Пропущенные видео не попадут в файл, пока вы их не оцените.' : 'В файл попадут только понравившиеся видео — в том же формате, что и исходный.'}</p>
<div class="row">
${c.like ? '<button class="pill primary" data-act="export">Скачать CSV (' + c.like + ')</button>' : ''}
${c.skip ? '<button class="pill" data-act="review">Пересмотреть пропущенные (' + c.skip + ')</button>' : ''}
<button class="pill" 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>