Redesign: light-table concept, dark background, readable stats

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>
This commit is contained in:
IgorVolochay
2026-11-14 22:30:43 +03:00
co-authored by Claude Sonnet 5.5
parent 874a53e492
commit 401833104b
5 changed files with 1141 additions and 287 deletions
+75 -102
View File
@@ -4,130 +4,102 @@
<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>
:root {
--bg: #0e0e12;
--surface: #1a1a21;
--surface-2: #24242d;
--border: #2e2e39;
--text: #f3f3f6;
--muted: #9a9aa8;
--accent-1: #fd267a;
--accent-2: #ff6036;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: radial-gradient(1200px 600px at 50% -10%, #2a1420 0%, var(--bg) 60%) no-repeat;
background-color: var(--bg);
color: var(--text);
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, select { font: inherit; color: inherit; }
button { cursor: pointer; border: 0; background: none; }
a { color: inherit; text-decoration: none; }
header {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 24px; background: #0e0e12e6; backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
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;
}
.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; }
.meta { color: var(--muted); font-size: 14px; }
.meta b { color: var(--text); }
.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; }
.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;
@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; }
}
.pill:hover { background: #2d2d38; }
select.pill { appearance: none; padding-right: 14px; cursor: pointer; }
select.pill option { background: var(--surface); }
/* ---------- Grid: 2 → 3 → 4 columns, like the YouTube home page ---------- */
/* ---------- Contact sheet: 2 → 3 → 4 columns ---------- */
.grid {
display: grid; gap: 28px 16px; padding: 24px;
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: 20px 10px; } header { padding: 10px 12px; } }
@media (max-width: 520px) { .grid { padding: 12px; gap: 22px 10px; } }
.item { display: block; min-width: 0; }
.thumb {
display: block; position: relative; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden;
background: var(--surface); transition: border-radius .15s;
/* 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 .thumb { border-radius: 0; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.badge {
position: absolute; right: 6px; bottom: 6px; padding: 2px 7px; border-radius: 6px;
font-size: 12px; font-weight: 800; background: #000c; color: #ffb547;
.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;
}
.badge.hot { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); color: #fff; }
.shorts { position: absolute; left: 6px; top: 6px; padding: 2px 7px; border-radius: 6px; font-size: 11px; font-weight: 800; background: #ff0033cc; }
.details { display: flex; gap: 10px; padding-top: 10px; }
.avatar {
flex: none; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-weight: 700; font-size: 15px; color: #fff; background: var(--hue, #555);
}
.text { min-width: 0; }
.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 4px; font-size: 15px; font-weight: 600; line-height: 1.3;
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;
}
.sub { color: var(--muted); font-size: 13px; line-height: 1.45; }
.channel { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.channel:hover { color: var(--text); }
.nums b { color: var(--text); font-weight: 600; }
.dot { margin: 0 4px; }
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)); padding: 40px 24px;
display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
background: var(--surface); border: 2px dashed var(--border); border-radius: 18px;
}
.empty.dragover { border-color: var(--accent-1); background: #2a1420; }
.empty h2 { margin: 0; font-size: 24px; }
.empty p { margin: 0; color: var(--muted); max-width: 400px; }
.pill.primary { background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); border-color: transparent; }
.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; }
.empty { margin: 12vh auto 0; width: min(560px, calc(100% - 32px)); }
</style>
</head>
<body>
<header>
<div class="brand">🔥 Thumbnail <span>Tinder</span></div>
<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="pill hidden" id="sort" title="Сортировка">
<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="pill hidden" id="newFileBtn">Файл…</button>
<a class="pill" href="index.html">← Отбор</a>
<button class="btn hidden" id="newFileBtn">Другой файл</button>
<a class="btn" href="index.html">К отбору</a>
</header>
<div class="empty" id="empty">
<div style="font-size:52px">❤️</div>
<h2>Загрузите отобранный CSV</h2>
<p>Файл <b>…-liked.csv</b>, который вы скачали из Thumbnail Tinder (подойдёт и любой CSV из расширения). Видео покажутся сеткой, как на главной YouTube.</p>
<button class="pill primary" id="pickBtn">Выбрать файл</button>
<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>
</div>
<div class="grid hidden" id="grid"></div>
@@ -176,10 +148,12 @@
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) => (v === '' || isNaN(+v)) ? '—' : '×' + (+v >= 100 ? Math.round(+v) : +(+v).toFixed(1));
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const hue = (s) => { let h = 0; for (const ch of s) h = (h * 31 + ch.codePointAt(0)) % 360; return `hsl(${h} 45% 40%)`; };
const initial = (s) => (Array.from((s || '?').trim())[0] || '?').toUpperCase();
function thumbSources(r) {
const id = r.videoId, orig = r.thumbnailUrl, list = [];
@@ -212,7 +186,6 @@
grid.innerHTML = '';
const frag = document.createDocumentFragment();
for (const r of list) {
const score = +r.outlierScore;
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>`
@@ -220,19 +193,19 @@
const el = document.createElement('div');
el.className = 'item';
el.innerHTML = `
<a href="${esc(r.url)}" target="_blank" rel="noopener" class="thumb">
<img alt="" loading="lazy" draggable="false">
${short ? '<span class="shorts">SHORTS</span>' : ''}
<span class="badge ${score >= 10 ? 'hot' : ''}" title="Outlier score">${fmtScore(r.outlierScore)}</span>
<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="details">
<div class="avatar" style="--hue:${hue(r.channelTitle || '')}">${esc(initial(r.channelTitle))}</div>
<div class="text">
<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>
<div class="sub">
${ch}
<div class="nums"><b>${fmtNum(r.views)}</b> просмотров<span class="dot">•</span><b>${fmtNum(r.subscribers)}</b> подписчиков</div>
</div>
${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));