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:
co-authored by
Claude Sonnet 5.5
parent
513a64f84a
commit
c95da940eb
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user