mirror of
https://github.com/pvrzz/Portage.git
synced 2026-08-12 03:46:30 +00:00
Add files via upload
This commit is contained in:
@@ -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") });
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Portage by pvrz
|
||||
https://github.com/pvrzz/Portage/
|
||||
File Name: dashboard.html
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="theme.js"></script>
|
||||
<title>Portage</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-wrap" id="toasts"></div>
|
||||
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brandmark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 18V6"/><path d="m4 10 4-4 4 4"/><path d="M16 6v12"/><path d="m12 14 4 4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Portage</h1>
|
||||
<div class="sub">A universal browser data swap — any browser to any browser</div>
|
||||
</div>
|
||||
<span class="srcbadge" id="srcBadge">This browser</span>
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="theme-wrap">
|
||||
<button class="icon-btn" id="themeBtn" title="Theme" aria-label="Theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<div class="theme-pop hidden" id="themePop">
|
||||
<div class="tg">
|
||||
<label>Appearance</label>
|
||||
<div class="pills">
|
||||
<button class="pill" data-mode="system"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>System</button>
|
||||
<button class="pill" data-mode="light"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/></svg>Light</button>
|
||||
<button class="pill" data-mode="dark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg">
|
||||
<label>Accent color</label>
|
||||
<div class="accent-row">
|
||||
<button class="pill" id="accentDefault" style="flex:1">Default · monochrome</button>
|
||||
<button class="swatch" id="accentSwatch" title="Pick a custom color">
|
||||
<input type="color" id="accentInput" value="#4f46e5">
|
||||
<span class="swatch-preview" id="accentPreview"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seg" id="modeSeg">
|
||||
<button data-mode="export" class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="m6 10 6-6 6 6"/><path d="M4 20h16"/></svg>
|
||||
Export
|
||||
</button>
|
||||
<button data-mode="import">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12"/><path d="m6 10 6 6 6-6"/><path d="M4 20h16"/></svg>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="exportPanel">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<h2 id="exHeroTitle">Scan this browser, then carry your data across</h2>
|
||||
<p>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 <b>any</b> 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.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="scan">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<span id="scanLabel">Scan my browser</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="enc-panel" id="encPanel">
|
||||
<div class="enc-head">
|
||||
<div class="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<div class="enc-titles">
|
||||
<h3>Encrypt this bundle <span class="badge-rec">Recommended</span></h3>
|
||||
<div class="desc">AES-256-GCM with PBKDF2. The file is unreadable on disk without your passphrase.</div>
|
||||
</div>
|
||||
<label class="switch" title="Toggle encryption"><input type="checkbox" id="encToggle" checked><span class="track"></span></label>
|
||||
</div>
|
||||
<div class="enc-body" id="encBody">
|
||||
<div class="field">
|
||||
<label for="encPass">Passphrase</label>
|
||||
<div class="pass-row">
|
||||
<input class="input" type="password" id="encPass" placeholder="Enter a strong passphrase" autocomplete="new-password" spellcheck="false">
|
||||
<button class="reveal" id="encReveal" type="button" title="Show or hide" aria-label="Show or hide passphrase">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="strength" style="margin-top:10px;">
|
||||
<div class="strength-bar"><i id="encStrengthBar"></i></div>
|
||||
<span class="strength-label" id="encStrengthLabel">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="encPass2">Confirm passphrase</label>
|
||||
<div class="pass-row">
|
||||
<input class="input" type="password" id="encPass2" placeholder="Re-enter to confirm" autocomplete="new-password" spellcheck="false" style="padding-right:12px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="enc-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
<span>There is no recovery. If you lose this passphrase, nobody — including you — can open the bundle.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="scanbar-wrap hidden" id="scanbarWrap">
|
||||
<div class="scanbar-label"><span id="scanStage">Preparing…</span><span id="scanPct" class="mono">0%</span></div>
|
||||
<div class="scanbar"><i id="scanFill"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="statstrip hidden" id="exStats">
|
||||
<div class="s"><span class="n mono" id="totItems">0</span><span class="l">Total items</span></div>
|
||||
<div class="s"><span class="n mono" id="totMigratable">0</span><span class="l">Migratable</span></div>
|
||||
<div class="s"><span class="n mono" id="totSize">0 KB</span><span class="l">Bundle size</span></div>
|
||||
<div class="s"><span class="n mono" id="totSelected">0</span><span class="l">Selected</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Your data</div>
|
||||
<div class="grid" id="exportGrid"></div>
|
||||
|
||||
<div class="callout" style="margin-top:20px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<div>
|
||||
<h4>Your bundle can contain cookies and session tokens</h4>
|
||||
<p>Keep encryption on (above) so the <code>.portage.json</code> is unreadable on disk. Portage holds your data only in this page's memory and <b>wipes it after export</b> — it's never written to extension storage. Either way, delete the file once you've imported.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="importPanel" class="hidden">
|
||||
<div id="loadStep">
|
||||
<div class="dropzone" id="dropzone">
|
||||
<div class="dz-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
</div>
|
||||
<h3>Drop a <span class="mono">.portage.json</span> bundle here</h3>
|
||||
<p>…or click to choose a bundle exported from any browser — Chrome, Firefox, or the same one. Encrypted bundles will ask for your passphrase.</p>
|
||||
<input type="file" id="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reviewStep" class="hidden">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<h2 id="bundleTitle">Bundle loaded</h2>
|
||||
<p id="bundleMeta" class="mono"></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-ghost" id="reset">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
Load another
|
||||
</button>
|
||||
<button class="btn btn-primary" id="importBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12"/><path d="m6 10 6 6 6-6"/><path d="M4 20h16"/></svg>
|
||||
Import selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scanbar-wrap hidden" id="impbarWrap">
|
||||
<div class="scanbar-label"><span id="impStage">Importing…</span><span id="impPct" class="mono">0%</span></div>
|
||||
<div class="scanbar"><i id="impFill"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="statstrip">
|
||||
<div class="s"><span class="n mono" id="imItems">0</span><span class="l">In bundle</span></div>
|
||||
<div class="s"><span class="n mono" id="imSelected">0</span><span class="l">Selected</span></div>
|
||||
<div class="s"><span class="n mono" id="imImported">0</span><span class="l">Imported</span></div>
|
||||
<div class="s"><span class="n mono" id="imSkipped">0</span><span class="l">Skipped / failed</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">What's in this bundle</div>
|
||||
<div class="grid" id="importGrid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<a href="https://github.com/pvrzz/Portage/" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
github.com/pvrzz/Portage
|
||||
</a>
|
||||
<span class="sep">·</span>
|
||||
<span>developed by <a href="https://pvrz.lol/" target="_blank" rel="noopener noreferrer">pvrz</a>
|
||||
<span class="heart" title="made with love"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/></svg></span>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="actionbar" id="actionbar">
|
||||
<div class="actionbar-inner">
|
||||
<div class="summary">Ready to export <b id="abItems">0</b> items across <b id="abCats">0</b> categories.</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn btn-ghost" id="copyJson">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button class="btn btn-primary" id="export">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
Export bundle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
|
||||
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
cookies: '<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/>',
|
||||
tabs: '<rect width="18" height="14" x="3" y="5" rx="2"/><path d="M3 9h18"/>',
|
||||
sessions: '<path d="M3 3v5h5"/><path d="M3 8a9 9 0 1 0 3-6.7L3 4"/><path d="M12 7v5l3 2"/>',
|
||||
reading: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
extensions: '<path d="M14 7h2a2 2 0 0 1 2 2v2m-4 7H6a2 2 0 0 1-2-2v-4m0-4a2 2 0 0 1 2-2 2 2 0 0 0 2-2 2 2 0 0 1 4 0 2 2 0 0 0 2 2"/><circle cx="18" cy="16" r="3"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
arrow: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
};
|
||||
const iconSvg = (i) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${i}</svg>`;
|
||||
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) + `<span>${msg}</span>`;
|
||||
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 = `
|
||||
<div class="card-toggle">
|
||||
<label class="switch" title="Include"><input type="checkbox" data-toggle="${id}" ${opts.checked ? "checked" : ""} ${opts.toggleDisabled ? "disabled" : ""}><span class="track"></span></label>
|
||||
</div>
|
||||
<div class="card-top">
|
||||
<div class="card-icon">${iconSvg(c.icon)}</div>
|
||||
<div class="card-title"><h3>${c.label}</h3><div class="desc">${opts.desc}</div></div>
|
||||
</div>
|
||||
<div class="card-count"><span class="n" data-n="${id}">${opts.count ?? 0}</span><span class="u">${c.unit}</span></div>
|
||||
<div class="card-bar"><i data-bar="${id}"></i></div>
|
||||
<div class="card-state"><span class="dot ${opts.dot}" data-dot="${id}"></span><span data-msg="${id}">${opts.msg}</span></div>
|
||||
<div class="card-state">${iconSvg(SVG.arrow)}<span class="muted">${opts.fx}</span></div>
|
||||
<div data-extra="${id}"></div>`;
|
||||
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 = `<div class="ext-actions"><button class="btn btn-ghost" id="extTxtNow">${iconSvg(SVG.download)} Save list (.txt)</button></div>`;
|
||||
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 = '<span class="spinner"></span> ' + (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 = `
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-icon">${iconSvg(SVG.lock)}</div>
|
||||
<h3>Encrypted bundle</h3>
|
||||
<p>This bundle from ${escapeHtml(src)} is encrypted with AES-256. Enter the passphrase to open it.</p>
|
||||
<div class="pass-row">
|
||||
<input class="input" type="password" id="decPass" placeholder="Passphrase" autocomplete="off" spellcheck="false">
|
||||
<button class="reveal" id="decReveal" type="button" aria-label="Show or hide">${iconSvg(SVG.eye)}</button>
|
||||
</div>
|
||||
<div class="err" id="decErr">Incorrect passphrase — try again.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" id="decCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="decGo">Decrypt & open</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = '<span class="spinner"></span> 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) => `
|
||||
<div class="ext-row"><span class="idx">${i + 1}</span>
|
||||
<span style="flex:1"><span class="nm">${escapeHtml(e.name)}</span> <span class="ver">v${escapeHtml(e.version || "?")}</span></span>
|
||||
<a href="https://addons.mozilla.org/firefox/search/?q=${encodeURIComponent(e.name)}" target="_blank" rel="noopener">Firefox</a>
|
||||
<a href="https://chromewebstore.google.com/search/${encodeURIComponent(e.name)}" target="_blank" rel="noopener">Chrome</a>
|
||||
</div>`).join("");
|
||||
slot.innerHTML = `
|
||||
<div class="ext-actions"><button class="btn btn-ghost" id="extTxtDl">${iconSvg(SVG.download)} Download checklist (.txt)</button></div>
|
||||
<div class="ext-list">${rows}</div>`;
|
||||
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 = '<span class="spinner"></span> 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();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 685 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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": ["<all_urls>"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Portage by pvrz
|
||||
https://github.com/pvrzz/Portage/
|
||||
File Name: popup.html
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="theme.js"></script>
|
||||
<title>Portage</title>
|
||||
</head>
|
||||
<body class="popup">
|
||||
<div class="popup-card">
|
||||
<div class="popup-head">
|
||||
<div class="brandmark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 18V6"/><path d="m4 10 4-4 4 4"/><path d="M16 6v12"/><path d="m12 14 4 4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<strong>Portage</strong>
|
||||
<span id="sub">Export & Import</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="popup-stats">
|
||||
<div class="popup-stat"><span class="n" id="s-book">·</span><span class="l">Bookmarks</span></div>
|
||||
<div class="popup-stat"><span class="n" id="s-hist">·</span><span class="l">History</span></div>
|
||||
<div class="popup-stat"><span class="n" id="s-tabs">·</span><span class="l">Tabs</span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" id="open">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
||||
Open Portage
|
||||
</button>
|
||||
|
||||
<div class="popup-foot">
|
||||
<a href="https://github.com/pvrzz/Portage/" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<span class="sep">·</span>
|
||||
<span>by <a href="https://pvrz.lol/" target="_blank" rel="noopener noreferrer">pvrz</a> <span class="heart">♥</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
+140
@@ -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); } };
|
||||
})();
|
||||
Reference in New Issue
Block a user