commit 0f56395de9bc1456f9543ac9f2c0ddcbcdccf5ab Author: RDarius Date: Sun Jun 7 21:45:42 2026 +0300 init diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..b2009ce Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..876d658 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..0e7dc9a Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cfb6a97 --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Tab Archive", + "version": "1.0.0", + "description": "Save all your open tabs to your self-hosted Tab Archive backend.", + "permissions": ["tabs", "storage"], + "host_permissions": ["http://localhost/*", "https://*/*", "http://*/*"], + "action": { + "default_popup": "popup.html", + "default_title": "Tab Archive" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..3d3ca3e --- /dev/null +++ b/popup.css @@ -0,0 +1,47 @@ +* { box-sizing: border-box; } +body { + width: 320px; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #0f1115; + color: #e6e9ef; + font-size: 13px; + padding: 14px; +} +.header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } +.logo { font-size: 18px; } +.title { font-weight: 600; font-size: 15px; flex: 1; } +.link { background: none; border: none; color: #8b93a5; cursor: pointer; font-size: 15px; } +.link:hover { color: #e6e9ef; } + +.hidden { display: none !important; } + +label { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; color: #8b93a5; font-size: 12px; } +input { + background: #22262f; border: 1px solid #2c313c; border-radius: 7px; + color: #e6e9ef; padding: 8px 10px; font-size: 13px; outline: none; +} +input:focus { border-color: #4f8cff; } + +.btn { + background: #22262f; border: 1px solid #2c313c; color: #e6e9ef; + padding: 9px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; +} +.btn:hover { background: #2b303b; } +.btn.primary { background: #4f8cff; border-color: #4f8cff; color: #fff; } +.btn.primary:hover { background: #3b73e0; } +.btn.block { display: block; width: 100%; margin-top: 8px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +.row { display: flex; gap: 8px; } +.row .btn { flex: 1; } + +.settings { border-bottom: 1px solid #2c313c; padding-bottom: 12px; margin-bottom: 12px; } +.hint { color: #8b93a5; font-size: 11px; margin: 6px 0 0; } + +.status-line { color: #8b93a5; margin: 0 0 4px; } +#tabCount { color: #e6e9ef; font-weight: 600; } + +.message { margin-top: 12px; padding: 9px 11px; border-radius: 7px; font-size: 12px; } +.message.success { background: rgba(47,174,102,.15); border: 1px solid #2fae66; } +.message.error { background: rgba(229,72,77,.15); border: 1px solid #e5484d; } diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..bdc28c9 --- /dev/null +++ b/popup.html @@ -0,0 +1,42 @@ + + + + + + + +
+ + Tab Archive + +
+ + + +
+

open tabs in this window

+ + + +
+ + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..2f430f9 --- /dev/null +++ b/popup.js @@ -0,0 +1,165 @@ +'use strict'; + +const DEFAULTS = { backendUrl: 'http://localhost:3000', apiToken: '', folderName: '' }; + +const els = { + settings: document.getElementById('settings'), + toggleSettings: document.getElementById('toggleSettings'), + backendUrl: document.getElementById('backendUrl'), + apiToken: document.getElementById('apiToken'), + folderName: document.getElementById('folderName'), + testBtn: document.getElementById('testBtn'), + saveSettings: document.getElementById('saveSettings'), + tabCount: document.getElementById('tabCount'), + saveAll: document.getElementById('saveAll'), + saveClose: document.getElementById('saveClose'), + openBackend: document.getElementById('openBackend'), + message: document.getElementById('message'), +}; + +function getSettings() { + return new Promise((resolve) => { + chrome.storage.local.get(DEFAULTS, (s) => resolve(s)); + }); +} + +function setSettings(values) { + return new Promise((resolve) => chrome.storage.local.set(values, resolve)); +} + +function showMessage(text, kind) { + els.message.textContent = text; + els.message.className = 'message ' + (kind || ''); + els.message.classList.remove('hidden'); +} + +function normalizeBase(url) { + return (url || '').trim().replace(/\/+$/, ''); +} + +// Tabs worth archiving: skip internal chrome:// / extension pages. +function archivable(tab) { + return tab.url && /^https?:\/\//i.test(tab.url); +} + +async function getCurrentWindowTabs() { + return new Promise((resolve) => { + chrome.tabs.query({ currentWindow: true }, (tabs) => resolve(tabs)); + }); +} + +async function refreshTabCount() { + const tabs = await getCurrentWindowTabs(); + els.tabCount.textContent = tabs.filter(archivable).length; +} + +async function postBookmarks(settings, bookmarks) { + const base = normalizeBase(settings.backendUrl); + const res = await fetch(base + '/api/bookmarks/bulk', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + settings.apiToken, + }, + body: JSON.stringify({ + bookmarks, + folder_name: settings.folderName ? settings.folderName.trim() : undefined, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || ('HTTP ' + res.status)); + } + return res.json(); +} + +async function handleSave(closeAfter) { + const settings = await getSettings(); + if (!settings.apiToken) { + showMessage('Set your API token in settings (⚙️) first.', 'error'); + els.settings.classList.remove('hidden'); + return; + } + + els.saveAll.disabled = true; + els.saveClose.disabled = true; + showMessage('Saving…', ''); + + try { + const allTabs = await getCurrentWindowTabs(); + const tabs = allTabs.filter(archivable); + if (tabs.length === 0) { + showMessage('No saveable tabs (only http/https tabs are archived).', 'error'); + return; + } + + const bookmarks = tabs.map((t) => ({ title: t.title, url: t.url })); + const result = await postBookmarks(settings, bookmarks); + const notes = []; + if (result.skipped) notes.push(`${result.skipped} duplicate(s) skipped`); + // `limited` is returned by the hosted backend when the free-plan cap is hit. + if (result.limited) notes.push(`${result.limited} not saved — free plan limit reached`); + const noteStr = notes.length ? ` (${notes.join('; ')})` : ''; + showMessage(`Saved ${result.created} tab(s).${noteStr}`, result.limited ? 'error' : 'success'); + + // Only close once the save has been confirmed above. + if (closeAfter) { + await new Promise((resolve) => chrome.tabs.remove(tabs.map((t) => t.id), resolve)); + } + await refreshTabCount(); + } catch (e) { + showMessage('Failed: ' + e.message, 'error'); + } finally { + els.saveAll.disabled = false; + els.saveClose.disabled = false; + } +} + +// --- Wire up UI --- + +async function init() { + const s = await getSettings(); + els.backendUrl.value = s.backendUrl; + els.apiToken.value = s.apiToken; + els.folderName.value = s.folderName; + if (!s.apiToken) els.settings.classList.remove('hidden'); + await refreshTabCount(); +} + +els.toggleSettings.addEventListener('click', () => els.settings.classList.toggle('hidden')); + +els.saveSettings.addEventListener('click', async () => { + await setSettings({ + backendUrl: normalizeBase(els.backendUrl.value) || DEFAULTS.backendUrl, + apiToken: els.apiToken.value.trim(), + folderName: els.folderName.value.trim(), + }); + showMessage('Settings saved.', 'success'); + els.settings.classList.add('hidden'); +}); + +els.testBtn.addEventListener('click', async () => { + const base = normalizeBase(els.backendUrl.value); + const token = els.apiToken.value.trim(); + if (!base || !token) { showMessage('Enter backend URL and token first.', 'error'); return; } + showMessage('Testing…', ''); + try { + const res = await fetch(base + '/api/me', { headers: { 'Authorization': 'Bearer ' + token } }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + showMessage('Connected as ' + data.user.email, 'success'); + } catch (e) { + showMessage('Connection failed: ' + e.message, 'error'); + } +}); + +els.saveAll.addEventListener('click', () => handleSave(false)); +els.saveClose.addEventListener('click', () => handleSave(true)); + +els.openBackend.addEventListener('click', async () => { + const settings = await getSettings(); + const url = normalizeBase(settings.backendUrl) || DEFAULTS.backendUrl; + chrome.tabs.create({ url }); +}); + +init();