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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
dc5fa92d94
commit
ea655d47b9
@@ -1,3 +1,19 @@
|
|||||||
# thumbnail-tinder
|
# thumbnail-tinder
|
||||||
|
|
||||||
Приложение для итогового отбора превью из расширения Outlier Saver
|
Приложение для итогового отбора превью из расширения Outlier Saver.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Откройте `index.html` в браузере (двойной клик). Сервер и установка не нужны.
|
||||||
|
|
||||||
|
1. Перетащите CSV из расширения на страницу или нажмите «Выбрать файл».
|
||||||
|
2. Оцените видео:
|
||||||
|
- свайп вправо / `→` / ♥ — нравится;
|
||||||
|
- свайп влево / `←` / ✕ — не нравится;
|
||||||
|
- кнопка «Пропустить» вверху / свайп вверх / `↑` / `S` — отложить;
|
||||||
|
- `Z` — отменить последнее решение, `Enter` — открыть видео на YouTube.
|
||||||
|
3. Нажмите «Скачать ♥». Получится файл `<имя>-liked.csv` в том же формате, что и исходный
|
||||||
|
(BOM, CRLF, те же колонки и кавычки), но только с понравившимися строками в исходном порядке.
|
||||||
|
|
||||||
|
Пропущенные видео в файл не попадают. Когда основной проход закончится, их можно пересмотреть.
|
||||||
|
Прогресс сохраняется в браузере: если снова открыть тот же файл, работа продолжится с того места, где вы остановились.
|
||||||
|
|||||||
+700
@@ -0,0 +1,700 @@
|
|||||||
|
<!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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||||
Reference in New Issue
Block a user