From 39209f908311fcd35358709101814948083cecc4 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 8 Jun 2026 20:17:19 -0400 Subject: [PATCH] Add files via upload --- chrome/background.js | 13 + chrome/dashboard.html | 220 ++++++++++++++ chrome/dashboard.js | 637 +++++++++++++++++++++++++++++++++++++++ chrome/icons/icon128.png | Bin 0 -> 4707 bytes chrome/icons/icon16.png | Bin 0 -> 685 bytes chrome/icons/icon32.png | Bin 0 -> 1348 bytes chrome/icons/icon48.png | Bin 0 -> 2075 bytes chrome/manifest.json | 36 +++ chrome/popup.html | 50 +++ chrome/popup.js | 42 +++ chrome/styles.css | 412 +++++++++++++++++++++++++ chrome/theme.js | 140 +++++++++ 12 files changed, 1550 insertions(+) create mode 100644 chrome/background.js create mode 100644 chrome/dashboard.html create mode 100644 chrome/dashboard.js create mode 100644 chrome/icons/icon128.png create mode 100644 chrome/icons/icon16.png create mode 100644 chrome/icons/icon32.png create mode 100644 chrome/icons/icon48.png create mode 100644 chrome/manifest.json create mode 100644 chrome/popup.html create mode 100644 chrome/popup.js create mode 100644 chrome/styles.css create mode 100644 chrome/theme.js diff --git a/chrome/background.js b/chrome/background.js new file mode 100644 index 0000000..4ae17b7 --- /dev/null +++ b/chrome/background.js @@ -0,0 +1,13 @@ +/* +Portage by pvrz +https://github.com/pvrzz/Portage/ +File Name: background.js +*/ + +const api = typeof browser !== "undefined" ? browser : chrome; + +api.runtime.onInstalled.addListener(() => console.log("[Portage] installed.")); + +api.action.onClicked.addListener(() => { + api.tabs.create({ url: api.runtime.getURL("dashboard.html") }); +}); diff --git a/chrome/dashboard.html b/chrome/dashboard.html new file mode 100644 index 0000000..9f1a9fb --- /dev/null +++ b/chrome/dashboard.html @@ -0,0 +1,220 @@ + + + + + + + + + Portage + + +
+ +
+
+
+ + + +
+
+

Portage

+
A universal browser data swap — any browser to any browser
+
+ This browser +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+

Scan this browser, then carry your data across

+

Portage gathers your bookmarks, history, cookies, tabs, sessions and reading list into one portable JSON bundle, and writes your extension list to a separate checklist file. Because it's just JSON, you can import it into any browser running Portage — Chrome ↔ Firefox, or the same browser on a new machine or profile. Nothing leaves your machine, and the data is wiped from memory the moment you export.

+
+ +
+ +
+
+
+ +
+
+

Encrypt this bundle Recommended

+
AES-256-GCM with PBKDF2. The file is unreadable on disk without your passphrase.
+
+ +
+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+ +
+
+
+ + There is no recovery. If you lose this passphrase, nobody — including you — can open the bundle. +
+
+
+ + + + + +
Your data
+
+ +
+ +
+

Your bundle can contain cookies and session tokens

+

Keep encryption on (above) so the .portage.json is unreadable on disk. Portage holds your data only in this page's memory and wipes it after export — it's never written to extension storage. Either way, delete the file once you've imported.

+
+
+
+ + + + +
+ +
+
+
Ready to export 0 items across 0 categories.
+
+ + +
+
+ + + + diff --git a/chrome/dashboard.js b/chrome/dashboard.js new file mode 100644 index 0000000..385bd3d --- /dev/null +++ b/chrome/dashboard.js @@ -0,0 +1,637 @@ +/* +Portage by pvrz +https://github.com/pvrzz/Portage/ +File Name: dashboard.js +*/ + +const api = typeof browser !== "undefined" ? browser : chrome; +const IS_FIREFOX = navigator.userAgent.includes("Firefox"); +const SOURCE = IS_FIREFOX ? "firefox" : "chrome"; +const SOURCE_LABEL = IS_FIREFOX ? "Firefox" : "Chrome"; +const HAS_READINGLIST = typeof api.readingList !== "undefined" && typeof api.readingList?.query === "function"; + +document.getElementById("srcBadge").textContent = SOURCE_LABEL; +document.getElementById("exHeroTitle").textContent = `Scan ${SOURCE_LABEL}, then carry your data across`; +document.getElementById("scanLabel").textContent = `Scan ${SOURCE_LABEL}`; + +const SVG = { + bookmarks: '', + history: '', + cookies: '', + tabs: '', + sessions: '', + reading: '', + extensions: '', + check: '', + arrow: '', + download: '', + lock: '', + eye: '', +}; +const iconSvg = (i) => `${i}`; +const tick = () => new Promise((r) => setTimeout(r, 0)); +const IMPORTABLE = /^(https?|ftp):/i; + +function saveFile(filename, text, mime) { + const url = URL.createObjectURL(new Blob([text], { type: mime })); + const a = document.createElement("a"); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +} +function humanSize(b) { + if (b < 1024) return b + " B"; + if (b < 1048576) return (b / 1024).toFixed(0) + " KB"; + return (b / 1048576).toFixed(1) + " MB"; +} +function stamp() { + const d = new Date(), p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; +} +function countBookmarks(tree) { + let n = 0; (function w(a) { for (const x of a) { if (x.url) n++; if (x.children) w(x.children); } })(tree); return n; +} +function countSessions(list) { + let n = 0; for (const s of list) n += s.type === "window" ? (s.tabs?.length || 0) : 1; return n; +} +function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m])); } +function extensionsTxt(list) { + const L = []; + L.push("Portage — extension reinstall checklist"); + L.push(`Exported from ${SOURCE_LABEL} · ${new Date().toLocaleString()} · ${list.length} extensions`); + L.push(""); + L.push("Tip: paste this whole list into an AI assistant and ask it to find each one as an"); + L.push("add-on for your new browser — or search the names yourself with the links below."); + L.push(""); + list.forEach((e, i) => { + L.push(`${i + 1}. ${e.name} (v${e.version || "?"})`); + if (e.description) L.push(` ${e.description}`); + if (e.homepageUrl) L.push(` Home: ${e.homepageUrl}`); + L.push(` Firefox: https://addons.mozilla.org/firefox/search/?q=${encodeURIComponent(e.name)}`); + L.push(` Chrome: https://chromewebstore.google.com/search/${encodeURIComponent(e.name)}`); + L.push(""); + }); + return L.join("\n"); +} +function toast(msg, icon) { + const wrap = document.getElementById("toasts"); + const el = document.createElement("div"); + el.className = "toast"; + el.innerHTML = iconSvg(SVG[icon] || SVG.check) + `${msg}`; + wrap.appendChild(el); + setTimeout(() => { el.style.transition = "opacity .3s, transform .3s"; el.style.opacity = "0"; el.style.transform = "translateX(20px)"; }, 3400); + setTimeout(() => el.remove(), 3800); +} + +function bufToB64(buf) { + const bytes = new Uint8Array(buf); let bin = ""; const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + return btoa(bin); +} +function b64ToBuf(b64) { + const bin = atob(b64); const len = bin.length; const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +} +async function deriveKey(passphrase, salt, iterations) { + const baseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} +async function encryptBundle(obj, passphrase) { + const iterations = 250000; + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(passphrase, salt, iterations); + const plaintext = new TextEncoder().encode(JSON.stringify({ meta: obj.meta, data: obj.data })); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); + return { + portage: { + format: "portage-bundle-encrypted", version: 1, + source: obj.portage.source, generator: obj.portage.generator, exportedAt: obj.portage.exportedAt, + cipher: "AES-GCM", keyLength: 256, kdf: "PBKDF2", hash: "SHA-256", iterations, + salt: bufToB64(salt), iv: bufToB64(iv), + }, + ciphertext: bufToB64(ct), + }; +} +async function decryptBundle(wrapper, passphrase) { + const p = wrapper.portage; + const salt = new Uint8Array(b64ToBuf(p.salt)); + const iv = new Uint8Array(b64ToBuf(p.iv)); + const key = await deriveKey(passphrase, salt, p.iterations || 250000); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBuf(wrapper.ciphertext)); + const inner = JSON.parse(new TextDecoder().decode(pt)); + return { portage: { format: "portage-bundle", version: 1, source: p.source, generator: p.generator, exportedAt: p.exportedAt }, meta: inner.meta, data: inner.data }; +} +function passStrength(p) { + if (!p) return { pct: 0, label: "—" }; + let s = Math.min(p.length * 4, 40); + if (/[a-z]/.test(p)) s += 10; + if (/[A-Z]/.test(p)) s += 10; + if (/[0-9]/.test(p)) s += 10; + if (/[^A-Za-z0-9]/.test(p)) s += 15; + if (p.length >= 16) s += 15; + s = Math.min(s, 100); + return { pct: s, label: s < 35 ? "Weak" : s < 60 ? "Fair" : s < 80 ? "Good" : "Strong" }; +} + +async function collectBookmarks() { const tree = await api.bookmarks.getTree(); return { data: tree, count: countBookmarks(tree) }; } +async function collectHistory() { + const items = await api.history.search({ text: "", startTime: 0, maxResults: 1000000 }); + const data = items.map((i) => ({ url: i.url, title: i.title || "", lastVisitTime: i.lastVisitTime || 0, visitCount: i.visitCount || 0, typedCount: i.typedCount || 0 })); + return { data, count: data.length }; +} +async function collectCookies() { + const all = await api.cookies.getAll({}); + const data = all.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expirationDate: c.expirationDate, hostOnly: c.hostOnly, session: c.session })); + return { data, count: data.length }; +} +async function collectTabs() { + const tabs = await api.tabs.query({}); + const data = tabs.map((t) => ({ url: t.url || t.pendingUrl, title: t.title || "", pinned: t.pinned, index: t.index, windowId: t.windowId })).filter((t) => t.url); + return { data, count: data.length }; +} +async function collectSessions() { + const s = await api.sessions.getRecentlyClosed({ maxResults: 25 }); + const data = s.map((x) => { + if (x.tab) return { type: "tab", url: x.tab.url, title: x.tab.title || "" }; + if (x.window) return { type: "window", tabs: (x.window.tabs || []).map((t) => ({ url: t.url, title: t.title || "" })) }; + return null; + }).filter(Boolean); + return { data, count: countSessions(data) }; +} +async function collectReadingList() { + const items = await api.readingList.query({}); + const data = items.map((i) => ({ url: i.url, title: i.title || "", hasBeenRead: i.hasBeenRead, creationTime: i.creationTime, lastUpdateTime: i.lastUpdateTime })); + return { data, count: data.length }; +} +async function collectExtensions() { + const all = await api.management.getAll(); + const data = all.filter((e) => e.type === "extension" && e.id !== api.runtime.id) + .map((e) => ({ id: e.id, name: e.name, version: e.version, enabled: e.enabled, description: e.description, homepageUrl: e.homepageUrl, installType: e.installType })); + return { data, count: data.length }; +} + +async function importBookmarks(tree, onProg) { + const total = countBookmarks(tree); let done = 0, imported = 0, skipped = 0; + const root = await api.bookmarks.create({ title: `Portage Import — ${new Date().toLocaleString()}` }); + async function walk(nodes, parentId) { + for (const node of nodes) { + if (node.url) { + try { if (!IMPORTABLE.test(node.url)) skipped++; else { await api.bookmarks.create({ parentId, title: node.title || node.url, url: node.url }); imported++; } } + catch { skipped++; } + if (++done % 50 === 0) { onProg(done, total); await tick(); } + } else if (node.children) { + let pid = parentId; + if (node.title) { const f = await api.bookmarks.create({ parentId, title: node.title }); pid = f.id; } + await walk(node.children, pid); + } + } + } + await walk(tree, root.id); onProg(total, total); return { imported, skipped }; +} +async function importHistory(items, onProg) { + const total = items.length; let imported = 0, skipped = 0; + for (let i = 0; i < total; i++) { + const it = items[i]; + try { + if (!IMPORTABLE.test(it.url)) skipped++; + else { + const details = IS_FIREFOX ? { url: it.url, title: it.title || undefined, visitTime: it.lastVisitTime || Date.now() } : { url: it.url }; + await api.history.addUrl(details); imported++; + } + } catch { skipped++; } + if (i % 100 === 0) { onProg(i, total); await tick(); } + } + onProg(total, total); return { imported, skipped }; +} +function mapSameSite(s) { return s === "strict" ? "strict" : s === "lax" ? "lax" : "no_restriction"; } +async function importCookies(items, onProg) { + const total = items.length; let imported = 0, skipped = 0; + for (let i = 0; i < total; i++) { + const c = items[i]; + try { + const host = (c.domain || "").replace(/^\./, ""); + if (!host) skipped++; + else { + const url = (c.secure ? "https" : "http") + "://" + host + (c.path || "/"); + const d = { url, name: c.name, value: c.value, path: c.path || "/", secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: mapSameSite(c.sameSite) }; + if (!c.hostOnly) d.domain = c.domain; + if (!c.session && c.expirationDate) d.expirationDate = c.expirationDate; + await api.cookies.set(d); imported++; + } + } catch { skipped++; } + if (i % 100 === 0) { onProg(i, total); await tick(); } + } + onProg(total, total); return { imported, skipped }; +} +async function importTabs(items, onProg) { + const total = items.length; let imported = 0, skipped = 0; + for (let i = 0; i < total; i++) { + const t = items[i]; + try { if (!IMPORTABLE.test(t.url)) skipped++; else { await api.tabs.create({ url: t.url, pinned: !!t.pinned, active: false }); imported++; } } + catch { skipped++; } + onProg(i + 1, total); if (i % 10 === 0) await tick(); + } + return { imported, skipped }; +} +async function importSessions(list, onProg) { + const flat = []; + for (const s of list) { if (s.type === "window") for (const t of (s.tabs || [])) flat.push(t); else flat.push(s); } + return importTabs(flat, onProg); +} +async function importReadingList(items, onProg) { + const total = items.length; let imported = 0, skipped = 0; + if (HAS_READINGLIST && api.readingList.addEntry) { + for (let i = 0; i < total; i++) { + const it = items[i]; + try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.readingList.addEntry({ url: it.url, title: it.title || it.url, hasBeenRead: !!it.hasBeenRead }); imported++; } } + catch { skipped++; } + onProg(i + 1, total); if (i % 50 === 0) await tick(); + } + } else { + const folder = await api.bookmarks.create({ title: "Reading List (from Portage)" }); + for (let i = 0; i < total; i++) { + const it = items[i]; + try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.bookmarks.create({ parentId: folder.id, title: it.title || it.url, url: it.url }); imported++; } } + catch { skipped++; } + onProg(i + 1, total); if (i % 50 === 0) await tick(); + } + } + return { imported, skipped }; +} + +const CATS = { + bookmarks: { label: "Bookmarks", unit: "bookmarks", icon: SVG.bookmarks, level: "full", desc: "Full bookmark tree, folders and all", importFx: "Created in a 'Portage Import' folder", collect: collectBookmarks, count: countBookmarks, importRun: importBookmarks }, + history: { label: "History", unit: "pages", icon: SVG.history, level: "full", desc: "Pages you've visited, with timestamps", importFx: IS_FIREFOX ? "Re-added with titles & times" : "Re-added as visits", collect: collectHistory, count: (d) => d.length, importRun: importHistory }, + cookies: { label: "Cookies", unit: "cookies", icon: SVG.cookies, level: "full", desc: "Site cookies — keeps you signed in", importFx: "Re-set so logins carry over", collect: collectCookies, count: (d) => d.length, importRun: importCookies }, + tabs: { label: "Open tabs", unit: "tabs", icon: SVG.tabs, level: "full", desc: "Tabs open right now, every window", importFx: "Reopened in this window", collect: collectTabs, count: (d) => d.length, importRun: importTabs }, + sessions: { label: "Recently closed", unit: "sessions", icon: SVG.sessions, level: "partial", desc: "Tabs & windows you closed recently", importFx: "Reopened as tabs", collect: collectSessions, count: countSessions, importRun: importSessions }, + readingList: { label: "Reading list", unit: "entries", icon: SVG.reading, level: HAS_READINGLIST ? "full" : "partial", desc: "Pages saved to read later", importFx: HAS_READINGLIST ? "Added to the reading list" : "Saved to a bookmark folder", collect: collectReadingList, count: (d) => d.length, importRun: importReadingList, needsReadingList: true }, + extensions: { label: "Extensions", unit: "installed", icon: SVG.extensions, level: "checklist", desc: "Installed add-ons → reinstall checklist", importFx: "Can't auto-install — use the .txt checklist", collect: collectExtensions, count: (d) => d.length, importRun: null, special: true }, +}; +const ORDER = ["bookmarks", "history", "cookies", "tabs", "sessions", "readingList", "extensions"]; + +const exState = {}; +let scanned = false; +const exGrid = document.getElementById("exportGrid"); + +function exportCategoryIds() { return ORDER.filter((id) => !(CATS[id].needsReadingList && !HAS_READINGLIST)); } + +function makeCard(id, c, opts) { + const el = document.createElement("div"); + el.className = "card"; + el.dataset.id = id; + el.innerHTML = ` +
+ +
+
+
${iconSvg(c.icon)}
+

${c.label}

${opts.desc}
+
+
${opts.count ?? 0}${c.unit}
+
+
${opts.msg}
+
${iconSvg(SVG.arrow)}${opts.fx}
+
`; + return el; +} + +function renderExportCards() { + exGrid.innerHTML = ""; + exportCategoryIds().forEach((id, idx) => { + const c = CATS[id]; + exState[id] = { data: null, count: 0, selected: true, status: "idle" }; + const el = makeCard(id, c, { checked: true, toggleDisabled: true, desc: c.desc, count: 0, dot: "empty", msg: "Not scanned yet", fx: c.special ? "Saved as a .txt checklist" : c.importFx }); + el.style.animationDelay = idx * 45 + "ms"; + exGrid.appendChild(el); + }); + exGrid.addEventListener("change", (e) => { + const id = e.target.dataset?.toggle; if (!id) return; + exState[id].selected = e.target.checked; + exGrid.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !e.target.checked); + updateExportTotals(); + }); +} + +function setBar(id, pct) { const e = document.querySelector(`[data-bar="${id}"]`); if (e) e.style.width = pct + "%"; } +function setDot(id, cls) { const e = document.querySelector(`[data-dot="${id}"]`); if (e) e.className = "dot " + cls; } +function setMsg(id, m) { const e = document.querySelector(`[data-msg="${id}"]`); if (e) e.textContent = m; } +function countUp(id, to) { + const el = document.querySelector(`[data-n="${id}"]`); const t0 = performance.now(); + (function f(t) { const p = Math.min(1, (t - t0) / 600); el.textContent = Math.round((1 - Math.pow(1 - p, 3)) * to).toLocaleString(); if (p < 1) requestAnimationFrame(f); })(performance.now()); +} + +async function scanAll() { + document.getElementById("scan").disabled = true; + document.getElementById("scanbarWrap").classList.remove("hidden"); + document.getElementById("exStats").classList.remove("hidden"); + document.getElementById("actionbar").classList.remove("show"); + const ids = exportCategoryIds(); + let done = 0; + for (const id of ids) { + const c = CATS[id], st = exState[id]; + setDot(id, "scanning"); setMsg(id, "Scanning…"); setBar(id, 18); + document.getElementById("scanStage").textContent = "Scanning " + c.label.toLowerCase() + "…"; + try { + const { data, count } = await c.collect(); + st.data = data; st.count = count; st.status = "done"; setBar(id, 100); countUp(id, count); + if (count === 0) { setDot(id, "empty"); setMsg(id, "Nothing found"); } + else { setDot(id, "done"); setMsg(id, `Collected ${count.toLocaleString()} ${c.unit}`); } + if (c.special && count > 0) addExtTxtButton(id, data); + } catch (err) { + st.status = "error"; st.data = null; st.count = 0; setBar(id, 100); setDot(id, "error"); setMsg(id, "Unavailable — " + err.message); st.selected = false; + } + const cb = document.querySelector(`[data-toggle="${id}"]`); + const ok = st.status === "done" && st.count > 0; + cb.disabled = !ok; cb.checked = ok; st.selected = ok; + document.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !ok); + done++; + const pct = Math.round((done / ids.length) * 100); + document.getElementById("scanFill").style.width = pct + "%"; + document.getElementById("scanPct").textContent = pct + "%"; + } + scanned = true; + document.getElementById("scanStage").textContent = "Scan complete"; + document.getElementById("scan").disabled = false; + document.getElementById("scanLabel").textContent = "Re-scan"; + document.getElementById("actionbar").classList.add("show"); + updateExportTotals(); + toast("Scan complete — review and export", "check"); +} + +function addExtTxtButton(id, list) { + const slot = document.querySelector(`[data-extra="${id}"]`); + slot.innerHTML = `
`; + document.getElementById("extTxtNow").addEventListener("click", () => { + saveFile(`portage-${SOURCE}-extensions-${stamp()}.txt`, extensionsTxt(list), "text/plain"); + toast("Extension checklist saved", "download"); + }); +} + +function buildBundle() { + const data = {}, counts = {}; + for (const id of exportCategoryIds()) { + const st = exState[id]; + if (st.selected && st.data != null) { data[id] = st.data; counts[id] = st.count; } + } + return { portage: { format: "portage-bundle", version: 1, source: SOURCE, generator: `Portage ${SOURCE_LABEL} 2.0.0`, exportedAt: new Date().toISOString(), userAgent: navigator.userAgent }, meta: { counts }, data }; +} + +function updateExportTotals() { + let items = 0, migratable = 0, cats = 0; + for (const id of exportCategoryIds()) { + const st = exState[id], c = CATS[id]; + if (st.selected && st.count > 0) { cats++; items += st.count; if (c.importRun) migratable += st.count; } + } + document.getElementById("totItems").textContent = items.toLocaleString(); + document.getElementById("totMigratable").textContent = migratable.toLocaleString(); + document.getElementById("totSelected").textContent = cats; + document.getElementById("abItems").textContent = items.toLocaleString(); + document.getElementById("abCats").textContent = cats; + document.getElementById("totSize").textContent = humanSize(new Blob([JSON.stringify(buildBundle())]).size); + document.getElementById("export").disabled = cats === 0; +} + +function clearScannedData() { + for (const id of exportCategoryIds()) { + const st = exState[id]; + st.data = null; st.count = 0; st.selected = false; st.status = "cleared"; + setBar(id, 0); setDot(id, "empty"); setMsg(id, "Cleared from memory — re-scan to export again"); + const n = document.querySelector(`[data-n="${id}"]`); if (n) n.textContent = "0"; + const cb = document.querySelector(`[data-toggle="${id}"]`); if (cb) { cb.checked = false; cb.disabled = true; } + const card = document.querySelector(`.card[data-id="${id}"]`); if (card) card.classList.add("disabled"); + const extra = document.querySelector(`[data-extra="${id}"]`); if (extra) extra.innerHTML = ""; + } + scanned = false; + document.getElementById("scanLabel").textContent = `Scan ${SOURCE_LABEL}`; + document.getElementById("actionbar").classList.remove("show"); + updateExportTotals(); +} + +async function exportBundle() { + const encOn = document.getElementById("encToggle").checked; + const pass = document.getElementById("encPass").value; + const pass2 = document.getElementById("encPass2").value; + if (encOn) { + if (!pass) { toast("Enter a passphrase, or switch off encryption", "lock"); document.getElementById("encPass").focus(); return; } + if (pass.length < 6) { toast("Use a longer passphrase (6+ characters)", "lock"); return; } + if (pass !== pass2) { toast("Passphrases don't match", "lock"); document.getElementById("encPass2").focus(); return; } + } + const btn = document.getElementById("export"); const orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ' ' + (encOn ? "Encrypting…" : "Building…"); + let ok = false, extra = ""; + try { + const obj = buildBundle(); + const json = encOn ? JSON.stringify(await encryptBundle(obj, pass)) : JSON.stringify(obj, null, 2); + saveFile(`portage-${SOURCE}-${stamp()}.portage.json`, json, "application/json"); + const ext = exState.extensions; + if (ext && ext.selected && ext.count > 0) { + saveFile(`portage-${SOURCE}-extensions-${stamp()}.txt`, extensionsTxt(ext.data), "text/plain"); + extra = " + extension checklist"; + } + ok = true; + } catch (err) { toast("Export failed: " + err.message, "lock"); } + btn.innerHTML = orig; + if (ok) { + document.getElementById("encPass").value = ""; document.getElementById("encPass2").value = ""; + document.getElementById("encStrengthBar").style.width = "0%"; document.getElementById("encStrengthLabel").textContent = "—"; + clearScannedData(); + toast(`Bundle saved${encOn ? " (encrypted)" : ""}${extra} — data wiped from memory`, "check"); + } else { btn.disabled = false; } +} +async function copyJson() { + try { await navigator.clipboard.writeText(JSON.stringify(buildBundle(), null, 2)); } + catch (err) { toast("Copy failed: " + err.message, "check"); return; } + clearScannedData(); toast("Plaintext bundle copied — data wiped from memory", "check"); +} + +function initEncryption() { + const toggle = document.getElementById("encToggle"); + const body = document.getElementById("encBody"); + const pass = document.getElementById("encPass"); + const bar = document.getElementById("encStrengthBar"); + const label = document.getElementById("encStrengthLabel"); + toggle.addEventListener("change", () => body.classList.toggle("hidden", !toggle.checked)); + document.getElementById("encReveal").addEventListener("click", () => { pass.type = pass.type === "password" ? "text" : "password"; }); + pass.addEventListener("input", () => { const s = passStrength(pass.value); bar.style.width = s.pct + "%"; label.textContent = s.label; }); +} + +let bundle = null; +const imState = {}; +const imGrid = document.getElementById("importGrid"); +const dropzone = document.getElementById("dropzone"); +const fileInput = document.getElementById("file"); + +dropzone.addEventListener("click", () => fileInput.click()); +fileInput.addEventListener("change", (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); }); +["dragenter", "dragover"].forEach((ev) => dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.add("drag"); })); +["dragleave", "drop"].forEach((ev) => dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.remove("drag"); })); +dropzone.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) loadFile(f); }); + +async function loadFile(file) { + let parsed; + try { parsed = JSON.parse(await file.text()); } + catch { toast("Couldn't read that file — is it a Portage bundle?", "check"); return; } + const fmt = parsed?.portage?.format; + if (fmt === "portage-bundle") { bundle = parsed; showReview(); toast("Bundle loaded — review and import", "check"); } + else if (fmt === "portage-bundle-encrypted") { openDecryptModal(parsed); } + else { toast("Not a Portage bundle.", "check"); } +} + +function openDecryptModal(wrapper) { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + const src = (wrapper.portage.source || "another browser").replace(/^./, (m) => m.toUpperCase()); + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + const input = overlay.querySelector("#decPass"); + const err = overlay.querySelector("#decErr"); + const go = overlay.querySelector("#decGo"); + setTimeout(() => input.focus(), 50); + overlay.querySelector("#decReveal").addEventListener("click", () => { input.type = input.type === "password" ? "text" : "password"; }); + overlay.querySelector("#decCancel").addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); + async function attempt() { + if (!input.value) return; + err.classList.remove("show"); + go.disabled = true; const orig = go.innerHTML; go.innerHTML = ' Decrypting…'; + try { + bundle = await decryptBundle(wrapper, input.value); + overlay.remove(); showReview(); toast("Bundle decrypted — review and import", "check"); + } catch { go.disabled = false; go.innerHTML = orig; err.classList.add("show"); input.select(); } + } + go.addEventListener("click", attempt); + input.addEventListener("keydown", (e) => { if (e.key === "Enter") attempt(); }); +} + +function showReview() { + document.getElementById("loadStep").classList.add("hidden"); + document.getElementById("reviewStep").classList.remove("hidden"); + const p = bundle.portage; + document.getElementById("bundleTitle").textContent = `Bundle from ${(p.source || "?").replace(/^./, (m) => m.toUpperCase())}`; + document.getElementById("bundleMeta").textContent = `exported ${new Date(p.exportedAt).toLocaleString()} · ${p.generator || "Portage"}`; + + imGrid.innerHTML = ""; let totalItems = 0; + ORDER.forEach((id, idx) => { + const c = CATS[id]; const data = bundle.data?.[id]; + const present = Array.isArray(data) ? data.length > 0 : !!data; + if (!present) return; + const count = c.count(data); totalItems += count; + const importable = !!c.importRun; + imState[id] = { count, selected: importable }; + const el = makeCard(id, c, { + checked: importable, toggleDisabled: !importable, desc: c.desc, + count: count.toLocaleString(), dot: importable ? "done" : "empty", + msg: importable ? "Ready to import" : "Reinstall manually", fx: c.importFx, + }); + el.style.animationDelay = idx * 45 + "ms"; + if (!importable) el.classList.add("disabled"); + imGrid.appendChild(el); + if (c.special) renderExtList(id, data); + }); + + imGrid.addEventListener("change", (e) => { + const id = e.target.dataset?.toggle; if (!id || !imState[id]) return; + imState[id].selected = e.target.checked; + imGrid.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !e.target.checked); + updateImportTotals(); + }); + + document.getElementById("imItems").textContent = totalItems.toLocaleString(); + updateImportTotals(); +} + +function renderExtList(id, list) { + const card = imGrid.querySelector(`.card[data-id="${id}"]`); + card.classList.add("wide"); + const slot = card.querySelector(`[data-extra="${id}"]`); + const rows = list.map((e, i) => ` +
${i + 1} + ${escapeHtml(e.name)} v${escapeHtml(e.version || "?")} + Firefox + Chrome +
`).join(""); + slot.innerHTML = ` +
+
${rows}
`; + document.getElementById("extTxtDl").addEventListener("click", () => { + saveFile(`portage-extensions-${stamp()}.txt`, extensionsTxt(list), "text/plain"); + toast("Extension checklist saved", "download"); + }); +} + +function updateImportTotals() { + let sel = 0; + for (const id in imState) if (imState[id].selected) sel += imState[id].count; + document.getElementById("imSelected").textContent = sel.toLocaleString(); + document.getElementById("importBtn").disabled = sel === 0; +} + +async function runImport() { + const btn = document.getElementById("importBtn"); const orig = btn.innerHTML; + btn.disabled = true; btn.innerHTML = ' Importing…'; + document.getElementById("impbarWrap").classList.remove("hidden"); + const todo = ORDER.filter((id) => imState[id]?.selected && CATS[id].importRun); + let imported = 0, skipped = 0, ci = 0; + for (const id of todo) { + const c = CATS[id]; + setDot(id, "scanning"); setMsg(id, "Importing…"); + document.getElementById("impStage").textContent = "Importing " + c.label.toLowerCase() + "…"; + try { + const res = await c.importRun(bundle.data[id], (d, t) => setBar(id, t ? Math.round((d / t) * 100) : 100)); + imported += res.imported; skipped += res.skipped; + setBar(id, 100); setDot(id, "done"); setMsg(id, `Imported ${res.imported.toLocaleString()}` + (res.skipped ? ` · ${res.skipped} skipped` : "")); + } catch (err) { setDot(id, "error"); setMsg(id, "Failed — " + err.message); } + ci++; + const pct = Math.round((ci / todo.length) * 100); + document.getElementById("impFill").style.width = pct + "%"; + document.getElementById("impPct").textContent = pct + "%"; + document.getElementById("imImported").textContent = imported.toLocaleString(); + document.getElementById("imSkipped").textContent = skipped.toLocaleString(); + } + bundle = null; + document.getElementById("impStage").textContent = "Import complete · bundle wiped from memory"; + btn.innerHTML = orig; btn.disabled = true; + toast(`Done — ${imported.toLocaleString()} items imported into ${SOURCE_LABEL}; bundle wiped`, "check"); +} + +function resetImport() { + bundle = null; fileInput.value = ""; + document.getElementById("reviewStep").classList.add("hidden"); + document.getElementById("loadStep").classList.remove("hidden"); + document.getElementById("impbarWrap").classList.add("hidden"); +} + +function setMode(mode) { + document.querySelectorAll("#modeSeg button").forEach((b) => b.classList.toggle("active", b.dataset.mode === mode)); + const exporting = mode === "export"; + document.getElementById("exportPanel").classList.toggle("hidden", !exporting); + document.getElementById("importPanel").classList.toggle("hidden", exporting); + document.getElementById("actionbar").classList.toggle("show", exporting && scanned); +} + +document.getElementById("modeSeg").addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) setMode(b.dataset.mode); }); +document.getElementById("scan").addEventListener("click", scanAll); +document.getElementById("export").addEventListener("click", exportBundle); +document.getElementById("copyJson").addEventListener("click", copyJson); +document.getElementById("importBtn").addEventListener("click", runImport); +document.getElementById("reset").addEventListener("click", resetImport); + +renderExportCards(); +initEncryption(); diff --git a/chrome/icons/icon128.png b/chrome/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..7d16361ff7329f563eaadffb802310abae06f8d8 GIT binary patch literal 4707 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 FV1gyQ;r{>t literal 0 HcmV?d00001 diff --git a/chrome/manifest.json b/chrome/manifest.json new file mode 100644 index 0000000..e859279 --- /dev/null +++ b/chrome/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "Portage (Chrome)", + "version": "2.0.0", + "description": "Universal browser data swap. Export bookmarks, history, cookies, tabs, sessions and reading list to a portable JSON bundle, then import into any browser running Portage — Chrome to Firefox, or the same browser on a new machine. Also exports your extension list as a reinstall checklist.", + "homepage_url": "https://github.com/pvrzz/Portage/", + "permissions": [ + "history", + "bookmarks", + "cookies", + "tabs", + "sessions", + "management", + "readingList" + ], + "host_permissions": [""], + "action": { + "default_popup": "popup.html", + "default_title": "Portage", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + } +} diff --git a/chrome/popup.html b/chrome/popup.html new file mode 100644 index 0000000..cdc9cb2 --- /dev/null +++ b/chrome/popup.html @@ -0,0 +1,50 @@ + + + + + + + + Portage + + + + + + diff --git a/chrome/popup.js b/chrome/popup.js new file mode 100644 index 0000000..84d8c99 --- /dev/null +++ b/chrome/popup.js @@ -0,0 +1,42 @@ +/* +Portage by pvrz +https://github.com/pvrzz/Portage/ +File Name: popup.js +*/ + +const api = typeof browser !== "undefined" ? browser : chrome; +const IS_FIREFOX = navigator.userAgent.includes("Firefox"); +document.getElementById("sub").textContent = "Export & Import · " + (IS_FIREFOX ? "Firefox" : "Chrome"); + +function fmt(n) { + if (n === null || n === undefined) return "·"; + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"; + return String(n); +} + +async function quickCounts() { + try { + const tree = await api.bookmarks.getTree(); + let books = 0; + (function walk(nodes) { for (const n of nodes) { if (n.url) books++; if (n.children) walk(n.children); } })(tree); + document.getElementById("s-book").textContent = fmt(books); + } catch { document.getElementById("s-book").textContent = "—"; } + + try { + const since = Date.now() - 90 * 864e5; + const items = await api.history.search({ text: "", startTime: since, maxResults: 100000 }); + document.getElementById("s-hist").textContent = fmt(items.length); + } catch { document.getElementById("s-hist").textContent = "—"; } + + try { + const tabs = await api.tabs.query({}); + document.getElementById("s-tabs").textContent = fmt(tabs.length); + } catch { document.getElementById("s-tabs").textContent = "—"; } +} + +document.getElementById("open").addEventListener("click", async () => { + await api.tabs.create({ url: api.runtime.getURL("dashboard.html") }); + window.close(); +}); + +quickCounts(); diff --git a/chrome/styles.css b/chrome/styles.css new file mode 100644 index 0000000..f81df83 --- /dev/null +++ b/chrome/styles.css @@ -0,0 +1,412 @@ +/* +Portage by pvrz +https://github.com/pvrzz/Portage/ +File Name: styles.css +*/ + +:root { + --ink-900: #18181b; + --ink-800: #27272a; + --ink-700: #3f3f46; + --ink-600: #52525b; + --ink-500: #71717a; + --ink-400: #a1a1aa; + --ink-300: #d4d4d8; + --ink-200: #e4e4e7; + --ink-100: #f4f4f5; + --paper: #ffffff; + --bg: #fafafa; + --line: #e7e7ea; + + --accent: #18181b; + --accent-contrast: #fafafa; + + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 1px 2px rgba(24,24,27,.04), 0 8px 24px rgba(24,24,27,.06); + --shadow-sm: 0 1px 2px rgba(24,24,27,.06); + --ease: cubic-bezier(.22,.61,.36,1); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-mode]) { + --ink-900: #fafafa; + --ink-800: #f4f4f5; + --ink-700: #e4e4e7; + --ink-600: #d4d4d8; + --ink-500: #a1a1aa; + --ink-400: #71717a; + --ink-300: #52525b; + --ink-200: #3f3f46; + --ink-100: #27272a; + --paper: #1c1c1f; + --bg: #131316; + --line: #2a2a2e; + --accent: #fafafa; + --accent-contrast: #18181b; + --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.5); + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + } +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + +body { + font-family: var(--font); + color: var(--ink-900); + background: var(--bg); + font-size: 14px; + line-height: 1.5; + letter-spacing: -0.01em; + transition: background .3s var(--ease), color .3s var(--ease); +} + +::selection { background: var(--accent); color: var(--accent-contrast); } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--ink-300); border-radius: 99px; border: 3px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: var(--ink-400); } + +h1, h2, h3 { font-weight: 650; letter-spacing: -0.02em; color: var(--ink-900); } +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.muted { color: var(--ink-500); } +.tiny { font-size: 12px; } + +body.popup { width: 320px; padding: 0; overflow: hidden; } +.popup-card { padding: 18px; } +.popup-head { + display: flex; align-items: center; gap: 11px; + padding-bottom: 14px; border-bottom: 1px solid var(--line); +} +.brandmark { + width: 38px; height: 38px; border-radius: 10px; + background: var(--accent); color: var(--accent-contrast); + display: grid; place-items: center; flex: none; + transition: background .3s var(--ease); +} +.brandmark svg { width: 22px; height: 22px; } +.brand-text strong { font-size: 15px; display: block; } +.brand-text span { font-size: 11.5px; color: var(--ink-500); text-transform: uppercase; letter-spacing: .08em; } + +.popup-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 16px 0; } +.popup-stat { + background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius-sm); + padding: 10px; text-align: center; +} +.popup-stat .n { font-family: var(--mono); font-size: 17px; font-weight: 600; display: block; } +.popup-stat .l { font-size: 10.5px; color: var(--ink-500); text-transform: uppercase; letter-spacing: .06em; } + +.popup-note { + display: flex; gap: 9px; align-items: flex-start; + background: var(--ink-100); border-radius: var(--radius-sm); + padding: 11px; font-size: 12px; color: var(--ink-600); margin-bottom: 14px; +} +.popup-note svg { width: 16px; height: 16px; flex: none; margin-top: 1px; stroke: var(--ink-500); } + +.shell { max-width: 1080px; margin: 0 auto; padding: 0 24px 80px; } + +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 14px; + padding: 18px 0; margin-bottom: 8px; + background: linear-gradient(var(--bg) 70%, transparent); + backdrop-filter: blur(6px); +} +.topbar .brandmark { width: 44px; height: 44px; border-radius: 12px; } +.topbar .brandmark svg { width: 26px; height: 26px; } +.topbar h1 { font-size: 19px; } +.topbar .sub { font-size: 12px; color: var(--ink-500); } +.topbar .spacer { flex: 1; } + +.hero { + background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); + padding: 22px 24px; margin-bottom: 18px; box-shadow: var(--shadow-sm); + display: flex; align-items: center; gap: 20px; flex-wrap: wrap; +} +.hero .hero-copy { flex: 1; min-width: 240px; } +.hero h2 { font-size: 17px; margin-bottom: 4px; } +.hero p { color: var(--ink-600); font-size: 13px; max-width: 60ch; } + +.scanbar-wrap { margin: 16px 0 22px; } +.scanbar-label { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-500); margin-bottom: 7px; } +.scanbar { height: 8px; background: var(--ink-200); border-radius: 99px; overflow: hidden; } +.scanbar > i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 99px; transition: width .5s var(--ease); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 14px; } + +.card { + position: relative; overflow: hidden; + background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); + padding: 16px; box-shadow: var(--shadow-sm); + transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s var(--ease); + opacity: 0; transform: translateY(8px); + animation: rise .45s var(--ease) forwards; +} +@keyframes rise { to { opacity: 1; transform: none; } } +.card:hover { box-shadow: var(--shadow); border-color: var(--ink-300); } +.card.disabled { opacity: .55; } +.card.unavailable { opacity: .5; } + +.card-top { display: flex; align-items: flex-start; gap: 12px; padding-right: 42px; } +.card-icon { width: 38px; height: 38px; border-radius: 10px; flex: none; background: var(--ink-100); display: grid; place-items: center; } +.card-icon svg { width: 20px; height: 20px; stroke: var(--ink-800); } +.card-title { flex: 1; min-width: 0; } +.card-title h3 { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.card-title .desc { font-size: 12px; color: var(--ink-500); margin-top: 2px; line-height: 1.45; } + +.card-count { display: flex; align-items: baseline; gap: 6px; margin: 14px 0 4px; } +.card-count .n { font-family: var(--mono); font-size: 26px; font-weight: 600; letter-spacing: -.03em; } +.card-count .u { font-size: 12px; color: var(--ink-500); } + +.card-bar { height: 4px; background: var(--ink-200); border-radius: 99px; overflow: hidden; margin-top: 10px; } +.card-bar > i { display: block; height: 100%; width: 0%; background: var(--ink-400); transition: width .4s var(--ease); } + +.card-state { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-500); margin-top: 10px; min-width: 0; } +.card-state + .card-state { margin-top: 6px; } +.card-state svg { width: 13px; height: 13px; flex: none; } +.card-state > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.dot { width: 7px; height: 7px; border-radius: 99px; background: var(--ink-300); flex: none; } +.dot.scanning { background: var(--ink-600); animation: pulse 1s infinite; } +.dot.done { background: var(--accent); } +.dot.empty { background: var(--ink-300); } +.dot.error { background: var(--accent); box-shadow: 0 0 0 3px var(--ink-200); } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } } + +.card-toggle { position: absolute; top: 14px; right: 14px; } +.switch { position: relative; display: inline-block; width: 38px; height: 22px; cursor: pointer; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch .track { position: absolute; inset: 0; background: var(--ink-300); border-radius: 99px; transition: background .2s var(--ease); } +.switch .track::before { + content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; + background: #fff; border-radius: 99px; transition: transform .2s var(--ease); box-shadow: 0 1px 2px rgba(0,0,0,.25); +} +.switch input:checked + .track { background: var(--accent); } +.switch input:checked + .track::before { transform: translateX(16px); } +.switch input:disabled + .track { opacity: .4; cursor: not-allowed; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + font: inherit; font-weight: 550; font-size: 13.5px; + padding: 10px 18px; border-radius: var(--radius-sm); border: 1px solid transparent; + cursor: pointer; transition: transform .12s var(--ease), background .15s, filter .15s, opacity .15s; + user-select: none; white-space: nowrap; +} +.btn:active { transform: scale(.97); } +.btn svg { width: 16px; height: 16px; } +.btn-primary { background: var(--accent); color: var(--accent-contrast); } +.btn-primary:hover { filter: brightness(.92); } +.btn-ghost { background: transparent; color: var(--ink-800); border-color: var(--line); } +.btn-ghost:hover { background: var(--ink-100); } +.btn-block { width: 100%; } +.btn:disabled { opacity: .45; cursor: not-allowed; transform: none; filter: none; } + +.icon-btn { + width: 38px; height: 38px; border-radius: var(--radius-sm); flex: none; + display: grid; place-items: center; cursor: pointer; + background: transparent; border: 1px solid var(--line); color: var(--ink-700); + transition: background .15s, color .15s, border-color .15s; +} +.icon-btn:hover { background: var(--ink-100); color: var(--ink-900); } +.icon-btn svg { width: 18px; height: 18px; } + +.callout { + display: flex; gap: 12px; align-items: flex-start; + background: var(--paper); border: 1px solid var(--line); border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); padding: 14px 16px; margin: 18px 0; box-shadow: var(--shadow-sm); +} +.callout svg { width: 18px; height: 18px; flex: none; margin-top: 1px; stroke: var(--ink-800); } +.callout h4 { font-size: 13px; margin-bottom: 3px; } +.callout p { font-size: 12.5px; color: var(--ink-600); } +.callout code { font-family: var(--mono); font-size: 11.5px; background: var(--ink-100); padding: 1px 5px; border-radius: 5px; } + +.section-title { + display: flex; align-items: center; gap: 10px; + font-size: 12px; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-500); + margin: 28px 0 12px; +} +.section-title::after { content: ""; flex: 1; height: 1px; background: var(--line); } + +.actionbar { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; + background: var(--paper); border-top: 1px solid var(--line); + box-shadow: 0 -4px 20px rgba(24,24,27,.06); + transform: translateY(110%); transition: transform .35s var(--ease); +} +.actionbar.show { transform: none; } +.actionbar-inner { max-width: 1080px; margin: 0 auto; padding: 14px 24px; display: flex; align-items: center; gap: 16px; } +.actionbar .summary { font-size: 13px; color: var(--ink-600); } +.actionbar .summary b { color: var(--ink-900); font-family: var(--mono); } +.actionbar .spacer { flex: 1; } + +.toast-wrap { position: fixed; top: 16px; right: 16px; z-index: 60; display: flex; flex-direction: column; gap: 10px; } +.toast { + display: flex; align-items: center; gap: 10px; + background: var(--accent); color: var(--accent-contrast); + padding: 12px 16px; border-radius: var(--radius-sm); box-shadow: var(--shadow); + font-size: 13px; min-width: 240px; animation: toastin .3s var(--ease); +} +.toast svg { width: 17px; height: 17px; flex: none; } +@keyframes toastin { from { opacity: 0; transform: translateX(20px); } } + +.spinner { width: 15px; height: 15px; border: 2px solid var(--ink-300); border-top-color: var(--accent); border-radius: 99px; animation: spin .7s linear infinite; } +.btn-primary .spinner { border-color: rgba(127,127,127,.4); border-top-color: var(--accent-contrast); } +@keyframes spin { to { transform: rotate(360deg); } } + +.statstrip { display: flex; gap: 0; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--paper); margin-bottom: 4px; } +.statstrip .s { flex: 1; padding: 14px 16px; border-right: 1px solid var(--line); } +.statstrip .s:last-child { border-right: 0; } +.statstrip .s .n { font-family: var(--mono); font-size: 20px; font-weight: 600; display: block; } +.statstrip .s .l { font-size: 11px; color: var(--ink-500); text-transform: uppercase; letter-spacing: .06em; } + +.dropzone { + border: 2px dashed var(--ink-300); border-radius: var(--radius); + padding: 40px 24px; text-align: center; background: var(--paper); + transition: border-color .2s, background .2s; cursor: pointer; +} +.dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--ink-100); } +.dropzone .dz-icon { width: 48px; height: 48px; margin: 0 auto 14px; display: grid; place-items: center; border-radius: 14px; background: var(--ink-100); } +.dropzone .dz-icon svg { width: 26px; height: 26px; stroke: var(--ink-700); } +.dropzone h3 { font-size: 15px; margin-bottom: 4px; } +.dropzone p { font-size: 12.5px; color: var(--ink-500); } + +.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--ink-100); border: 1px solid var(--line); border-radius: 10px; } +.seg button { + font: inherit; font-size: 13px; font-weight: 550; color: var(--ink-500); + padding: 6px 16px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; + display: inline-flex; align-items: center; gap: 7px; transition: color .15s, background .2s var(--ease); +} +.seg button svg { width: 15px; height: 15px; } +.seg button.active { background: var(--paper); color: var(--ink-900); box-shadow: var(--shadow-sm); } + +.srcbadge { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; + color: var(--ink-600); background: var(--ink-100); border: 1px solid var(--line); + padding: 4px 10px; border-radius: 99px; +} + +.ext-actions { display: flex; gap: 8px; margin-top: 12px; } +.ext-list { margin-top: 12px; max-height: 280px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); } +.ext-row { display: flex; gap: 10px; align-items: baseline; padding: 9px 12px; border-bottom: 1px solid var(--line); font-size: 12.5px; } +.ext-row:last-child { border-bottom: 0; } +.ext-row .idx { font-family: var(--mono); color: var(--ink-400); flex: none; width: 24px; } +.ext-row .nm { font-weight: 550; } +.ext-row .ver { font-family: var(--mono); color: var(--ink-400); font-size: 11px; } +.ext-row a { color: var(--ink-600); text-decoration: none; border-bottom: 1px dotted var(--ink-300); } +.ext-row a:hover { color: var(--ink-900); } +.card.wide { grid-column: 1 / -1; } + +.enc-panel { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); margin-bottom: 18px; overflow: hidden; } +.enc-head { display: flex; align-items: center; gap: 14px; padding: 16px 18px; } +.enc-head .card-icon { background: var(--ink-100); } +.enc-head .enc-titles { flex: 1; min-width: 0; } +.enc-head h3 { font-size: 14px; display: flex; align-items: center; gap: 8px; } +.enc-head .desc { font-size: 12px; color: var(--ink-500); margin-top: 2px; } +.badge-rec { + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; + background: var(--accent); color: var(--accent-contrast); padding: 2px 7px; border-radius: 99px; +} +.enc-body { padding: 0 18px 18px; display: grid; gap: 12px; border-top: 1px solid var(--line); padding-top: 16px; } +.enc-body.hidden { display: none; } +.field label { display: block; font-size: 11.5px; font-weight: 600; color: var(--ink-600); margin-bottom: 6px; } +.pass-row { position: relative; display: flex; } +.input { + width: 100%; font: inherit; font-size: 13.5px; color: var(--ink-900); + background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm); + padding: 10px 40px 10px 12px; transition: border-color .15s, box-shadow .15s; +} +.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +.pass-row .reveal { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + width: 30px; height: 30px; display: grid; place-items: center; + background: transparent; border: 0; cursor: pointer; color: var(--ink-500); border-radius: 6px; +} +.pass-row .reveal:hover { color: var(--ink-900); background: var(--ink-100); } +.pass-row .reveal svg { width: 16px; height: 16px; } +.strength { display: flex; align-items: center; gap: 10px; } +.strength-bar { flex: 1; height: 5px; background: var(--ink-200); border-radius: 99px; overflow: hidden; } +.strength-bar > i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 99px; transition: width .25s var(--ease); } +.strength-label { font-size: 11px; color: var(--ink-500); min-width: 56px; text-align: right; } +.enc-note { display: flex; gap: 8px; align-items: flex-start; font-size: 11.5px; color: var(--ink-500); } +.enc-note svg { width: 14px; height: 14px; flex: none; margin-top: 1px; stroke: var(--ink-500); } + +.modal-overlay { + position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; + background: color-mix(in srgb, var(--ink-900) 45%, transparent); backdrop-filter: blur(3px); + animation: fadein .2s var(--ease); +} +@keyframes fadein { from { opacity: 0; } } +.modal { + width: min(420px, calc(100vw - 40px)); background: var(--paper); + border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); + padding: 22px; animation: rise .3s var(--ease); +} +.modal .modal-icon { width: 44px; height: 44px; border-radius: 12px; background: var(--ink-100); display: grid; place-items: center; margin-bottom: 14px; } +.modal .modal-icon svg { width: 24px; height: 24px; stroke: var(--ink-800); } +.modal h3 { font-size: 16px; margin-bottom: 4px; } +.modal p { font-size: 12.5px; color: var(--ink-600); margin-bottom: 16px; } +.modal .err { font-size: 12px; color: var(--ink-900); margin-top: 10px; display: none; } +.modal .err.show { display: block; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } + +.theme-wrap { position: relative; } +.theme-pop { + position: absolute; right: 0; top: 46px; z-index: 40; width: 268px; + background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); + padding: 16px; transform-origin: top right; animation: rise .2s var(--ease); +} +.theme-pop.hidden { display: none; } +.theme-pop .tg { margin-bottom: 16px; } +.theme-pop .tg:last-child { margin-bottom: 0; } +.theme-pop .tg > label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--ink-500); margin-bottom: 9px; } +.pills { display: flex; gap: 6px; } +.pill { + flex: 1; font: inherit; font-size: 12.5px; font-weight: 550; color: var(--ink-600); + padding: 8px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--bg); cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all .15s var(--ease); +} +.pill svg { width: 14px; height: 14px; } +.pill:hover { border-color: var(--ink-300); } +.pill.active { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } +.accent-row { display: flex; gap: 6px; align-items: center; } +.swatch { + width: 38px; height: 38px; border-radius: var(--radius-sm); border: 1px solid var(--line); + padding: 0; cursor: pointer; overflow: hidden; flex: none; position: relative; background: var(--bg); +} +.swatch input { position: absolute; inset: -6px; width: calc(100% + 12px); height: calc(100% + 12px); border: 0; padding: 0; cursor: pointer; background: transparent; } +.swatch-preview { position: absolute; inset: 4px; border-radius: 5px; pointer-events: none; } + +.footer { + display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; + margin: 40px 0 28px; padding-top: 22px; border-top: 1px solid var(--line); + font-size: 12.5px; color: var(--ink-500); +} +.footer a { color: var(--ink-600); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; transition: color .15s; } +.footer a:hover { color: var(--ink-900); } +.footer svg { width: 15px; height: 15px; } +.footer .sep { color: var(--ink-300); } +.footer .heart { color: var(--accent); display: inline-flex; vertical-align: middle; } +.footer .heart svg { width: 13px; height: 13px; animation: beat 1.4s var(--ease) infinite; } +@keyframes beat { 0%,100% { transform: scale(1); } 15% { transform: scale(1.25); } 30% { transform: scale(1); } } + +.popup-foot { + display: flex; align-items: center; justify-content: center; gap: 9px; + margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); + font-size: 11.5px; color: var(--ink-500); +} +.popup-foot a { color: var(--ink-600); text-decoration: none; display: inline-flex; align-items: center; gap: 5px; } +.popup-foot a:hover { color: var(--ink-900); } +.popup-foot svg { width: 13px; height: 13px; } +.popup-foot .heart { color: var(--accent); } + +.hidden { display: none !important; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .001s !important; transition-duration: .001s !important; } +} diff --git a/chrome/theme.js b/chrome/theme.js new file mode 100644 index 0000000..54c1d53 --- /dev/null +++ b/chrome/theme.js @@ -0,0 +1,140 @@ +/* +Portage by pvrz +https://github.com/pvrzz/Portage/ +File Name: theme.js +*/ + +(function () { + const KEY = "portage:theme"; + const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); + + function load() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } } + function save(s) { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch {} } + + const state = Object.assign({ mode: "system", accent: null }, load()); + + function hexToRgb(h) { + h = h.replace("#", ""); + if (h.length === 3) h = h.split("").map((c) => c + c).join(""); + const n = parseInt(h, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + function rgbToHex(r, g, b) { + const t = (v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0"); + return "#" + t(r) + t(g) + t(b); + } + function hexToHsl(hex) { + let { r, g, b } = hexToRgb(hex); + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h = 0, s = 0; const l = (max + min) / 2; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = (g - b) / d + (g < b ? 6 : 0); + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + } + return { h, s, l }; + } + function hslToHex(h, s, l) { + h = ((h % 360) + 360) % 360; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255); + } + function luminance(hex) { + const { r, g, b } = hexToRgb(hex); + const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + } + + const LIGHT = { "--bg": 98, "--paper": 100, "--line": 91, "--ink-100": 96, "--ink-200": 90, "--ink-300": 83, "--ink-400": 64, "--ink-500": 46, "--ink-600": 37, "--ink-700": 28, "--ink-800": 18, "--ink-900": 11 }; + const DARK = { "--bg": 8, "--paper": 11, "--line": 19, "--ink-100": 16, "--ink-200": 25, "--ink-300": 34, "--ink-400": 46, "--ink-500": 63, "--ink-600": 74, "--ink-700": 83, "--ink-800": 90, "--ink-900": 96 }; + + function effectiveDark() { + if (state.mode === "dark") return true; + if (state.mode === "light") return false; + return matchMedia("(prefers-color-scheme: dark)").matches; + } + + function apply() { + const root = document.documentElement; + const dark = effectiveDark(); + root.setAttribute("data-mode", dark ? "dark" : "light"); + + const accent = state.accent; + const stops = dark ? DARK : LIGHT; + const hsl = accent ? hexToHsl(accent) : { h: 0, s: 0, l: 0.5 }; + const tint = accent ? Math.min(hsl.s * 0.5, 0.14) : 0; + + for (const k in stops) { + const isSurface = k === "--bg" || k === "--paper"; + const s = isSurface ? tint * 0.6 : tint; + root.style.setProperty(k, hslToHex(hsl.h, s, stops[k] / 100)); + } + + let aHex, aContrast; + if (accent) { + aHex = hslToHex(hsl.h, clamp(hsl.s, 0.45, 0.95), clamp(hsl.l, 0.42, 0.64)); + aContrast = luminance(aHex) > 0.42 ? "#101012" : "#ffffff"; + } else { + aHex = dark ? hslToHex(0, 0, DARK["--ink-900"] / 100) : hslToHex(0, 0, LIGHT["--ink-900"] / 100); + aContrast = dark ? hslToHex(0, 0, DARK["--bg"] / 100) : hslToHex(0, 0, LIGHT["--bg"] / 100); + } + root.style.setProperty("--accent", aHex); + root.style.setProperty("--accent-contrast", aContrast); + } + + try { + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (state.mode === "system") { apply(); refresh(); } }); + } catch {} + + apply(); + + let els = null; + function refresh() { + if (!els) return; + els.modePills.forEach((p) => p.classList.toggle("active", p.dataset.mode === state.mode)); + els.defaultPill.classList.toggle("active", !state.accent); + const c = state.accent || "#18181b"; + els.accentInput.value = c; + els.preview.style.background = state.accent ? c : "linear-gradient(135deg, var(--ink-300), var(--ink-700))"; + } + + function initControls() { + const btn = document.getElementById("themeBtn"); + const pop = document.getElementById("themePop"); + if (!btn || !pop) return; + els = { + modePills: Array.from(pop.querySelectorAll("[data-mode]")), + defaultPill: pop.querySelector("#accentDefault"), + accentInput: pop.querySelector("#accentInput"), + preview: pop.querySelector("#accentPreview"), + }; + + btn.addEventListener("click", (e) => { e.stopPropagation(); pop.classList.toggle("hidden"); }); + document.addEventListener("click", (e) => { if (!pop.contains(e.target) && e.target !== btn) pop.classList.add("hidden"); }); + pop.addEventListener("click", (e) => e.stopPropagation()); + + els.modePills.forEach((p) => p.addEventListener("click", () => { state.mode = p.dataset.mode; save(state); apply(); refresh(); })); + els.defaultPill.addEventListener("click", () => { state.accent = null; save(state); apply(); refresh(); }); + els.accentInput.addEventListener("input", () => { state.accent = els.accentInput.value; save(state); apply(); refresh(); }); + + refresh(); + } + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initControls); + else initControls(); + + window.PortageTheme = { apply, set(p) { Object.assign(state, p); save(state); apply(); refresh(); }, get() { return Object.assign({}, state); } }; +})();