Replace the 822-row sample with a 6-row example.csv. Link the Outlier Saver extension from the README and both empty screens. Set nginx server_name to outliers.viaproger.ru. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
262 lines
12 KiB
HTML
262 lines
12 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>
|
||
header {
|
||
position: sticky; top: 0; z-index: 5;
|
||
display: flex; align-items: center; gap: 12px 16px; flex-wrap: wrap;
|
||
padding: 12px 24px; background: #1B1B1Fee; backdrop-filter: blur(8px);
|
||
border-bottom: 1px solid #ffffff2e;
|
||
}
|
||
.heading { font: 600 14px/1 var(--f-display); letter-spacing: -.02em; color: var(--on); }
|
||
.meta { font: 400 12px/1.4 var(--f-mono); color: var(--on-muted); }
|
||
.meta b { color: var(--on); font-weight: 500; }
|
||
.spacer { flex: 1; }
|
||
@media (max-width: 520px) {
|
||
header { padding: 10px 12px; gap: 8px; }
|
||
header .btn { padding: 8px 12px; font-size: 13px; }
|
||
.heading { flex-basis: 100%; }
|
||
.spacer { display: none; }
|
||
}
|
||
|
||
/* ---------- Contact sheet: 2 → 3 → 4 columns ---------- */
|
||
.grid {
|
||
display: grid; gap: 30px 18px; padding: 24px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
max-width: 1800px; margin: 0 auto;
|
||
}
|
||
@media (min-width: 900px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||
@media (min-width: 1300px) { .grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||
@media (max-width: 520px) { .grid { padding: 12px; gap: 22px 10px; } }
|
||
|
||
/* each video is one paper mount: print on top, caption below */
|
||
.item {
|
||
display: flex; flex-direction: column; min-width: 0; padding: 6px 6px 14px; background: var(--mount); border-radius: 4px;
|
||
box-shadow: 0 1px 0 #fff inset, 0 18px 26px -18px #000c, 0 1px 3px #0003;
|
||
transition: transform .15s, box-shadow .15s;
|
||
}
|
||
.item:hover { transform: translateY(-3px); box-shadow: 0 1px 0 #fff inset, 0 24px 30px -18px #000d, 0 1px 3px #0003; }
|
||
.print { position: relative; display: block; }
|
||
.frame { aspect-ratio: 16 / 9; background: #000; border-radius: 2px; overflow: hidden; }
|
||
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.tag {
|
||
position: absolute; left: 12px; top: 12px; padding: 4px 6px; border-radius: 2px;
|
||
background: var(--red); color: #fff; font: 500 11px/1 var(--f-mono); letter-spacing: .06em; text-transform: uppercase;
|
||
}
|
||
|
||
.cap { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: 1fr auto; gap: 8px 12px; align-items: start; padding: 12px 6px 0; }
|
||
.title {
|
||
margin: 0 0 3px; font: 600 15px/1.3 var(--f-body);
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||
}
|
||
a:hover > .title { text-decoration: underline; text-underline-offset: 3px; }
|
||
.channel { display: block; color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
a.channel:hover { color: var(--ink); }
|
||
.stats { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; margin-top: 4px; border-top: 1.5px solid var(--ink); }
|
||
.stats div { padding: 8px 10px 0 0; min-width: 0; }
|
||
.stats div + div { padding-left: 12px; border-left: 1px solid var(--ink); }
|
||
.stats span { display: block; font: 500 11px/1.2 var(--f-mono); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
||
.stats b { display: block; margin-top: 2px; font: 700 22px/1.2 var(--f-body); letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
@media (max-width: 520px) { .stats b { font-size: 16px; } .stats span { font-size: 9.5px; letter-spacing: .02em; } .stats div + div { padding-left: 8px; } }
|
||
|
||
/* every video on this sheet was chosen, so every score gets the red pencil circle */
|
||
.score {
|
||
display: inline-block; margin-top: 2px; padding: 7px 11px; white-space: nowrap;
|
||
border: 2.5px solid var(--red); border-radius: 52% 48% 55% 45% / 50% 56% 44% 50%;
|
||
transform: rotate(-5deg);
|
||
font: 600 14px/1 var(--f-display); letter-spacing: -.04em; font-variant-numeric: tabular-nums;
|
||
}
|
||
@media (max-width: 520px) { .cap { grid-template-columns: 1fr; } .score { justify-self: start; } }
|
||
|
||
/* ---------- Empty ---------- */
|
||
.empty { margin: 12vh auto 0; width: min(560px, calc(100% - 32px)); }
|
||
.screen .hint { font-size: 13px; }
|
||
.screen .hint a { text-decoration: underline; text-underline-offset: 3px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<span class="heading">Понравившиеся</span>
|
||
<div class="meta hidden" id="meta"><b id="count">0</b> видео · <span id="fileName"></span></div>
|
||
<div class="spacer"></div>
|
||
<select class="btn hidden" id="sort" aria-label="Сортировка">
|
||
<option value="file">Как в файле</option>
|
||
<option value="score">По outlier score</option>
|
||
<option value="views">По просмотрам</option>
|
||
<option value="subs">По подписчикам</option>
|
||
<option value="date">Сначала новые</option>
|
||
</select>
|
||
<button class="btn hidden" id="newFileBtn">Другой файл</button>
|
||
<a class="btn" href="index.html">К отбору</a>
|
||
</header>
|
||
|
||
<div class="screen empty" id="empty">
|
||
<h2>Загрузите CSV с понравившимися</h2>
|
||
<p>Выберите файл …-liked.csv, который вы скачали в приложении для отбора. Подойдёт и любой CSV из расширения.</p>
|
||
<div class="row"><button class="btn primary" id="pickBtn">Выбрать файл</button></div>
|
||
<p class="hint">Файл создаёт <a href="https://gitea.viaproger.ru/volochay/outlier-saver-extension" target="_blank" rel="noopener">расширение Outlier Saver</a>.</p>
|
||
</div>
|
||
|
||
<div class="grid hidden" id="grid"></div>
|
||
|
||
<input type="file" id="fileInput" accept=".csv,text/csv" hidden>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
// ---------- CSV ----------
|
||
function parseCSV(text) {
|
||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||
const records = [];
|
||
let fields = [], field = '', q = false, i = 0, has = false;
|
||
const n = text.length;
|
||
const end = () => { fields.push(field); if (has || fields.length > 1) records.push(fields); fields = []; field = ''; has = false; };
|
||
while (i < n) {
|
||
const c = text[i];
|
||
if (q) {
|
||
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } q = false; i++; continue; }
|
||
field += c; i++; continue;
|
||
}
|
||
if (c === '"') { q = true; has = true; i++; continue; }
|
||
if (c === ',') { fields.push(field); field = ''; has = true; i++; continue; }
|
||
if (c === '\r' && text[i + 1] === '\n') { end(); i += 2; continue; }
|
||
if (c === '\n' || c === '\r') { end(); i++; continue; }
|
||
field += c; has = true; i++;
|
||
}
|
||
if (has || field) end();
|
||
if (!records.length) throw new Error('Файл пустой');
|
||
const cols = records.shift().map(h => h.trim());
|
||
return {
|
||
cols,
|
||
rows: records.map((r, idx) => {
|
||
const o = { _i: idx };
|
||
cols.forEach((c, k) => { o[c] = r[k] ?? ''; });
|
||
return o;
|
||
}),
|
||
};
|
||
}
|
||
|
||
// ---------- Formatting ----------
|
||
const compact = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 });
|
||
const full = new Intl.NumberFormat('ru-RU');
|
||
const fmtNum = (v) => (v === '' || isNaN(+v)) ? '—' : (+v >= 10000 ? compact.format(+v) : full.format(+v));
|
||
const fmtScore = (v) => {
|
||
if (v === '' || isNaN(+v)) return '—';
|
||
v = +v;
|
||
return '×' + (v >= 1000 ? compact.format(v) : v >= 100 ? Math.round(v) : +v.toFixed(1));
|
||
};
|
||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
|
||
function thumbSources(r) {
|
||
const id = r.videoId, orig = r.thumbnailUrl, list = [];
|
||
if (id && !/hq720/.test(orig || '')) list.push(`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`);
|
||
if (orig) list.push(orig);
|
||
if (id) list.push(`https://i.ytimg.com/vi/${id}/hqdefault.jpg`);
|
||
return list;
|
||
}
|
||
function setImg(img, sources) {
|
||
let k = 0;
|
||
const next = () => { if (k >= sources.length) { img.onerror = null; return; } img.src = sources[k++]; };
|
||
img.onerror = next;
|
||
img.onload = () => { if (img.naturalWidth <= 120 && k < sources.length) next(); }; // grey placeholder
|
||
next();
|
||
}
|
||
|
||
// ---------- Render ----------
|
||
let rows = [], fileName = '';
|
||
const sorters = {
|
||
file: (a, b) => a._i - b._i,
|
||
score: (a, b) => (+b.outlierScore || 0) - (+a.outlierScore || 0),
|
||
views: (a, b) => (+b.views || 0) - (+a.views || 0),
|
||
subs: (a, b) => (+b.subscribers || 0) - (+a.subscribers || 0),
|
||
date: (a, b) => String(b.firstSeenAt).localeCompare(String(a.firstSeenAt)),
|
||
};
|
||
|
||
function render() {
|
||
const list = [...rows].sort(sorters[$('sort').value]);
|
||
const grid = $('grid');
|
||
grid.innerHTML = '';
|
||
const frag = document.createDocumentFragment();
|
||
for (const r of list) {
|
||
const short = String(r.isShort).toLowerCase() === 'true';
|
||
const ch = r.channelUrl
|
||
? `<a class="channel" href="${esc(r.channelUrl)}" target="_blank" rel="noopener">${esc(r.channelTitle)}</a>`
|
||
: `<span class="channel">${esc(r.channelTitle)}</span>`;
|
||
const el = document.createElement('div');
|
||
el.className = 'item';
|
||
el.innerHTML = `
|
||
<a href="${esc(r.url)}" target="_blank" rel="noopener" class="print" aria-label="${esc(r.title)}">
|
||
<div class="frame"><img alt="" loading="lazy" draggable="false"></div>
|
||
${short ? '<span class="tag">Shorts</span>' : ''}
|
||
</a>
|
||
<div class="cap">
|
||
<div>
|
||
<a href="${esc(r.url)}" target="_blank" rel="noopener"><h3 class="title" title="${esc(r.title)}">${esc(r.title)}</h3></a>
|
||
${ch}
|
||
</div>
|
||
<div class="score" title="Outlier score: ${esc(r.outlierScore)}">${fmtScore(r.outlierScore)}</div>
|
||
<div class="stats">
|
||
<div><span>Просмотры</span><b>${fmtNum(r.views)}</b></div>
|
||
<div><span>Подписчики</span><b>${fmtNum(r.subscribers)}</b></div>
|
||
</div>
|
||
</div>`;
|
||
setImg(el.querySelector('img'), thumbSources(r));
|
||
frag.appendChild(el);
|
||
}
|
||
grid.appendChild(frag);
|
||
}
|
||
|
||
// ---------- Loading ----------
|
||
async function loadFile(file) {
|
||
if (!file) return;
|
||
let parsed;
|
||
try { parsed = parseCSV(await file.text()); } catch (e) { return toast('Ошибка CSV: ' + e.message); }
|
||
const missing = ['videoId', 'title'].filter(c => !parsed.cols.includes(c));
|
||
if (missing.length) return toast('В файле нет колонок: ' + missing.join(', '));
|
||
if (!parsed.rows.length) return toast('В файле нет строк с видео');
|
||
rows = parsed.rows;
|
||
fileName = file.name || 'liked.csv';
|
||
$('count').textContent = rows.length;
|
||
$('fileName').textContent = fileName;
|
||
['meta', 'sort', 'newFileBtn', 'grid'].forEach(id => $(id).classList.remove('hidden'));
|
||
$('empty').classList.add('hidden');
|
||
window.scrollTo(0, 0);
|
||
render();
|
||
}
|
||
|
||
const input = $('fileInput');
|
||
input.addEventListener('change', () => { loadFile(input.files[0]); input.value = ''; });
|
||
$('pickBtn').addEventListener('click', () => input.click());
|
||
$('newFileBtn').addEventListener('click', () => input.click());
|
||
$('sort').addEventListener('change', render);
|
||
|
||
let depth = 0;
|
||
window.addEventListener('dragenter', (e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); depth++; $('empty').classList.add('dragover'); } });
|
||
window.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; $('empty').classList.remove('dragover'); } });
|
||
window.addEventListener('dragover', (e) => e.preventDefault());
|
||
window.addEventListener('drop', (e) => {
|
||
e.preventDefault(); depth = 0; $('empty').classList.remove('dragover');
|
||
loadFile(e.dataTransfer?.files?.[0]);
|
||
});
|
||
|
||
let timer;
|
||
function toast(msg) {
|
||
const t = $('toast'); t.textContent = msg; t.classList.add('show');
|
||
clearTimeout(timer); timer = setTimeout(() => t.classList.remove('show'), 2600);
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|