feat: add popup with outlier list and JSON/CSV export

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
IgorVolochay
2026-10-25 10:51:56 +03:00
co-authored by Claude Sonnet 5.5
parent 513a64f84a
commit c95da940eb
4 changed files with 152 additions and 1 deletions
+1 -1
View File
@@ -6,6 +6,6 @@
"permissions": ["storage"],
"host_permissions": ["https://www.googleapis.com/youtube/v3/*"],
"background": { "service_worker": "src/background.js", "type": "module" },
"action": { "default_title": "Outlier Saver" },
"action": { "default_title": "Outlier Saver", "default_popup": "src/popup/popup.html" },
"options_ui": { "page": "src/options/options.html", "open_in_tab": true }
}
+22
View File
@@ -0,0 +1,22 @@
:root {
color-scheme: light dark;
--line: color-mix(in srgb, currentColor 15%, transparent);
--muted: color-mix(in srgb, currentColor 60%, transparent);
--accent: #1a73e8;
}
body { width: 440px; margin: 0; font: 13px/1.4 system-ui, sans-serif; }
header, footer { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
header { justify-content: space-between; border-bottom: 1px solid var(--line); }
footer { border-top: 1px solid var(--line); }
footer button:last-child { margin-left: auto; }
button { padding: 5px 10px; font: inherit; }
#status { margin: 0; padding: 8px 12px; color: var(--muted); }
#status.warn { color: #d93025; }
#list { list-style: none; margin: 0; padding: 0; max-height: 380px; overflow-y: auto; }
#list li { display: grid; grid-template-columns: 96px 1fr; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
#list img { width: 96px; height: 54px; object-fit: cover; border-radius: 4px; background: var(--line); }
#list a { color: inherit; font-weight: 600; text-decoration: none; overflow-wrap: anywhere; }
#list a:hover { text-decoration: underline; }
.meta { margin-top: 2px; color: var(--muted); }
.badge { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 11px; }
.empty { padding: 24px 12px; text-align: center; color: var(--muted); }
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Outlier Saver</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<header>
<strong>Outlier Saver</strong>
<span id="count"></span>
</header>
<p id="status" role="status"></p>
<ul id="list"></ul>
<footer>
<button id="export-json" type="button">Скачать JSON</button>
<button id="export-csv" type="button">Скачать CSV</button>
<button id="open-options" type="button">Настройки</button>
</footer>
<script type="module" src="popup.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
import { countOutliers, listOutliers } from '../lib/db.js';
import { toCsv, toJson } from '../lib/export.js';
import { getStatus } from '../lib/storage.js';
const LIST_LIMIT = 50;
const compact = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 });
const format = (n) => (n === null || n === undefined ? '—' : compact.format(n));
const countEl = document.getElementById('count');
const statusEl = document.getElementById('status');
const listEl = document.getElementById('list');
const jsonButton = document.getElementById('export-json');
const csvButton = document.getElementById('export-csv');
function statusText(status) {
switch (status.state) {
case 'no-key':
return { text: 'Не задан API-ключ — откройте настройки.', warn: true };
case 'invalid-key':
return { text: `API-ключ отклонён: ${status.message ?? 'неизвестная ошибка'}`, warn: true };
case 'quota':
if (Date.now() >= status.until) return { text: 'Сбор работает.', warn: false };
return {
text: `Квота YouTube API исчерпана, сбор возобновится после ${new Date(status.until).toLocaleString('ru-RU')}.`,
warn: true,
};
default:
return { text: 'Сбор работает.', warn: false };
}
}
function badge(text) {
const span = document.createElement('span');
span.className = 'badge';
span.textContent = text;
return span;
}
function renderRow(outlier) {
const li = document.createElement('li');
const thumb = document.createElement('img');
thumb.alt = '';
thumb.loading = 'lazy';
if (outlier.thumbnailUrl) thumb.src = outlier.thumbnailUrl;
const title = document.createElement('a');
title.textContent = outlier.title;
title.href = outlier.url;
title.target = '_blank';
title.rel = 'noreferrer';
const badges = document.createElement('div');
if (outlier.score !== null) badges.append(badge(`×${outlier.score}`));
if (outlier.vidiqScore !== null) badges.append(badge(`VidIQ ${outlier.vidiqScore}`));
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = `${outlier.channelTitle} · ${format(outlier.subscribers)} подп. · ${format(outlier.views)} просм.`;
const body = document.createElement('div');
body.append(title, badges, meta);
li.append(thumb, body);
return li;
}
function download(filename, parts, type) {
const url = URL.createObjectURL(new Blob(parts, { type: `${type};charset=utf-8` }));
const link = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function exportAs(kind) {
const records = await listOutliers();
const day = new Date().toISOString().slice(0, 10);
if (kind === 'json') {
download(`outliers-${day}.json`, [toJson(records)], 'application/json');
} else {
// The BOM makes Excel read the CSV as UTF-8 (Cyrillic titles).
download(`outliers-${day}.csv`, ['', toCsv(records)], 'text/csv');
}
}
const [count, recent, status] = await Promise.all([countOutliers(), listOutliers(LIST_LIMIT), getStatus()]);
countEl.textContent = `Сохранено: ${count}`;
const { text, warn } = statusText(status);
statusEl.textContent = text;
statusEl.classList.toggle('warn', warn);
if (recent.length === 0) {
const empty = document.createElement('li');
empty.className = 'empty';
empty.textContent = 'Пока ничего не найдено. Откройте YouTube и пролистайте ленту.';
listEl.append(empty);
jsonButton.disabled = true;
csvButton.disabled = true;
} else {
listEl.append(...recent.map(renderRow));
}
jsonButton.addEventListener('click', () => exportAs('json'));
csvButton.addEventListener('click', () => exportAs('csv'));
document.getElementById('open-options').addEventListener('click', () => chrome.runtime.openOptionsPage());