From 0f56395de9bc1456f9543ac9f2c0ddcbcdccf5ab Mon Sep 17 00:00:00 2001 From: RDarius Date: Sun, 7 Jun 2026 21:45:42 +0300 Subject: [PATCH] init --- icons/icon128.png | Bin 0 -> 552 bytes icons/icon16.png | Bin 0 -> 108 bytes icons/icon48.png | Bin 0 -> 218 bytes manifest.json | 17 +++++ popup.css | 47 +++++++++++++ popup.html | 42 ++++++++++++ popup.js | 165 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+) create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..b2009ce0ef74b954a8d1be207d3c169048f5a91b GIT binary patch literal 552 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV0`cC;uumf=k2A9fh-O@ECVqQ$GI6uIaKXo<49&)!k+DDAmmWBLXG7y!xHu^X9F&Wsu^x93=9+MU2gxFEYQO+ zL2i+xql`mU11DoZoNmLHhw7{jm8A@q*yl?yJiW@`AzxL*Fr{41;nx9arUf<(r>waZ zYH#>52FNiy1ru|bC)m9>#-PF9@Ch5?$Jmf??*{`9A5_|psUhJSkk8xj$&a}q;T({; zhKYg2_!xUaL@0y8iGKbA3$z#=CdkS+D26g;xKG&kmxEy~$0c)l)&;zdzpgPb81OrA zH+|`KXWr4LPrFYV>ll$wS0N&$8e)dp>}gH!!|{a`>#|Qcm*zP zU&&w~==4i$DubGX$A8%dftD}vj2sCb|2Z4P7kTU#Wnd&6C!pB+d&B(m)!h93lb5G4 O0D-5gpUXO@geCwyVYcl6 literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..876d65889eb2698cd0fcc754416ef2c1580da4e5 GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`#-1*YAr*6yKfM3>f4I|-g3p>SbB_#hBk4nD%e9fLlJ_QC@2L`JKh7tzG7Y(Y6$182~A`e8Ds2pQm z(D~hI2E()h{SK)IlKGA+7z95?x!60T>|s-6N;%>!$TEd7<(Rhsi+}?IqbtMBj`hAQ zQ<$tv5m1BG%*Z?G^vft?zf4%F!>5P{^ P$1-@j`njxgN@xNAS#V1? literal 0 HcmV?d00001 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();