Shared style.css, grease-pencil swipe marks, log-scale outlier ruler, paper-mount gallery cards with larger views/subscribers figures. Add the sample outliers CSV. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
671 lines
30 KiB
HTML
671 lines
30 KiB
HTML
<!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; }
|
||
|
||
/* ---------- 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>
|
||
</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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|