From c95da940ebb9c40c04a4eb5b261a7459e7489b58 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 10:51:56 +0300 Subject: [PATCH] feat: add popup with outlier list and JSON/CSV export Co-Authored-By: Claude Sonnet 5.5 --- manifest.json | 2 +- src/popup/popup.css | 22 +++++++++ src/popup/popup.html | 22 +++++++++ src/popup/popup.js | 107 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/popup/popup.css create mode 100644 src/popup/popup.html create mode 100644 src/popup/popup.js diff --git a/manifest.json b/manifest.json index 926c85f..d5e6467 100644 --- a/manifest.json +++ b/manifest.json @@ -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 } } diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..8dd8781 --- /dev/null +++ b/src/popup/popup.css @@ -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); } diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..307a5cf --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,22 @@ + + + + + Outlier Saver + + + +
+ Outlier Saver + +
+

+ +
+ + + +
+ + + diff --git a/src/popup/popup.js b/src/popup/popup.js new file mode 100644 index 0000000..3a326dd --- /dev/null +++ b/src/popup/popup.js @@ -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());