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
+
+
+
+
+
+
+
+
+
+
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());