diff --git a/README.md b/README.md index 8bdc557..acd83d0 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,19 @@ > **How long?** Main pages request up to 100 bookmarks with 2.5–3.75 seconds between pages. Folder pages, network time, and retries add to this. The progress panel shows collected counts and elapsed time; the total is not known in advance. +## Full-page bookmark library + +Click the extension toolbar icon to open `library.html`, a full-tab library that reads the extension's existing cumulative archives directly. The original collection/export page remains available through **同步与备份** (Sync and backup), with a link back to the library. + +- Table and card layouts with a separate full-text, quote, image, and video preview pane. +- Case-insensitive search across text, authors, quoted text, folders, and URLs; quoted phrases and `-excluded` terms are supported. Press `/` to focus search. +- Folder, media-type, and availability filters; chronological and like-count sorting; 50 results per page. +- Switch between saved accounts or refresh the local archive after a scan. +- Open an xarchive JSON file as a read-only preview without replacing the saved archive. Imported previews are not persisted across reloads. +- Download the filtered results as a JSON snapshot explicitly marked as partial coverage. + +The initial library UI is in Simplified Chinese. No new extension permissions, hosted services, or build step are required. Remote media loads from the URLs already present in the archive; unavailable media cannot be recovered. The existing standalone viewer remains available. + ## Updating the extension ```bash diff --git a/background.js b/background.js index 7b0dbb8..168a766 100644 --- a/background.js +++ b/background.js @@ -61,9 +61,9 @@ chrome.webRequest.onSendHeaders.addListener( ['requestHeaders', 'extraHeaders'] ); -/** Open the options page when the extension icon is clicked. */ +/** Open the full-page bookmark library when the extension icon is clicked. */ chrome.action.onClicked.addListener(() => { - chrome.runtime.openOptionsPage(); + chrome.tabs.create({ url: chrome.runtime.getURL('library.html') }); }); /** diff --git a/lib/library-search.mjs b/lib/library-search.mjs new file mode 100644 index 0000000..c919de8 --- /dev/null +++ b/lib/library-search.mjs @@ -0,0 +1,68 @@ +export const escapeHTML = (s) => + String(s ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +export function safeURL(s) { + try { + const u = new URL(s); + return ["https:", "http:"].includes(u.protocol) ? u.href : ""; + } catch { + return ""; + } +} +export function filterBookmarks( + all, + { query = "", folder = "", media = "", status = "", sort = "new" } = {}, +) { + const terms = (query.match(/-?"[^"]+"|\S+/g) || []) + .map((t) => ({ + negative: t.startsWith("-"), + text: (t.startsWith("-") ? t.slice(1) : t) + .replaceAll('"', "") + .toLowerCase(), + })) + .filter((t) => t.text); + return all + .filter((b) => { + if (folder === "__unsorted" && b.folders?.length) return false; + if (folder && folder !== "__unsorted" && !b.folders?.includes(folder)) + return false; + if (status && b.status !== status) return false; + const m = b.media || []; + if (media === "text" && m.length) return false; + if (media === "photo" && !m.some((x) => x.type === "photo")) return false; + if ( + media === "video" && + !m.some((x) => ["video", "animated_gif"].includes(x.type)) + ) + return false; + const text = [ + b.full_text, + b.author?.name, + b.author?.screen_name, + b.quoted_tweet?.full_text, + ...(b.folders || []), + ...(b.entities?.urls || []).flatMap((u) => [ + u.url, + u.expanded_url, + u.display_url, + ]), + ] + .join(" ") + .toLowerCase(); + return terms.every((t) => + t.negative ? !text.includes(t.text) : text.includes(t.text), + ); + }) + .sort((a, b) => + sort === "likes" + ? (b.metrics?.likes || 0) - (a.metrics?.likes || 0) + : sort === "old" + ? (Date.parse(a.created_at) || 0) - (Date.parse(b.created_at) || 0) + : (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0), + ); +} diff --git a/library.css b/library.css new file mode 100644 index 0000000..dbcfb8a --- /dev/null +++ b/library.css @@ -0,0 +1,496 @@ +:root { + color-scheme: dark; + --bg: #101113; + --panel: #17191c; + --line: #2b2e33; + --muted: #9299a4; + --ink: #eef0f3; + --accent: #c8f36b; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: + 14px/1.6 -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + height: 100vh; + display: flex; + overflow: hidden; +} +button, +input, +select { + font: inherit; +} +button, +a, +select { + transition: background 0.15s; +} +button, +select { + border: 1px solid var(--line); + border-radius: 8px; + background: #1b1e22; + color: var(--ink); + padding: 8px 12px; +} +button { + cursor: pointer; +} +button:hover { + background: #30353b; +} +button:disabled { + opacity: 0.35; + cursor: default; +} +a { + color: var(--accent); + text-decoration: none; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.sidebar { + width: 218px; + flex-shrink: 0; + border-right: 1px solid var(--line); + padding: 27px 18px; + display: flex; + flex-direction: column; + background: #141618; +} +.brand { + display: flex; + gap: 9px; + align-items: center; + color: var(--ink); + font-size: 22px; +} +.brand span { + font-size: 8px; + letter-spacing: 1px; + border: 1px solid #535b45; + padding: 2px 4px; + color: var(--accent); +} +.local { + color: var(--accent); + font-size: 11px; + margin: 17px 0 32px; +} +.caption, +h3 { + font-size: 11px; + color: var(--muted); + letter-spacing: 1px; +} +h3 { + margin-top: 28px; +} +.sidebar select { + width: 100%; + margin-top: 9px; +} +nav { + overflow: auto; +} +nav button { + display: flex; + justify-content: space-between; + width: 100%; + border: 0; + background: none; + text-align: left; + margin-bottom: 5px; +} +nav button.active { + background: #293221; + color: var(--accent); +} +nav button span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +nav button small { + margin-left: 8px; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 20px; +} +.sidebar-bottom button, +.sidebar-bottom a { + display: block; + width: 100%; + margin: 8px 0; + text-align: left; +} +.sidebar-bottom a { + padding: 8px 12px; +} +.sidebar-bottom p { + font-size: 11px; + color: var(--muted); + padding: 12px; +} +main { + flex: 1; + min-width: 0; + padding: 25px 28px 0; + display: flex; + flex-direction: column; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} +.eyebrow { + font-size: 10px; + color: var(--muted); + letter-spacing: 2px; +} +h1 { + font-size: 27px; + line-height: 1.3; + margin: 6px 0; +} +h1 span { + font-size: 14px; + color: var(--accent); + vertical-align: middle; + margin-left: 9px; + background: #293221; + padding: 3px 9px; + border-radius: 20px; +} +.searchbox { + display: flex; + align-items: center; + gap: 12px; + background: var(--panel); + border: 1px solid #41473c; + border-radius: 10px; + padding: 0 16px; +} +.searchbox > span { + font-size: 28px; + color: var(--accent); +} +input[type="search"] { + width: 100%; + height: 49px; + background: none; + border: none; + color: var(--ink); + outline: none; +} +.searchbox:focus-within { + border-color: var(--accent); +} +kbd { + color: var(--muted); + border: 1px solid var(--line); + padding: 0 6px; + border-radius: 4px; +} +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.view-switch { + display: flex; + gap: 3px; + margin-right: auto; +} +.view-switch button[aria-pressed="true"] { + color: var(--accent); + background: #293221; +} +#notice { + font-size: 11px; + color: var(--muted); + margin: 12px 0; + min-height: 18px; +} +.workspace { + display: flex; + flex: 1; + min-height: 0; + border-top: 1px solid var(--line); +} +.results { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +#list { + overflow: auto; + flex: 1; +} +table { + border-collapse: collapse; + width: 100%; + table-layout: fixed; +} +th { + position: sticky; + top: 0; + background: #17191c; + text-align: left; + font-weight: 500; + color: var(--muted); + font-size: 11px; + padding: 12px 10px; + z-index: 1; +} +td { + padding: 16px 10px; + border-bottom: 1px solid #24272b; + vertical-align: top; +} +th:first-child { + width: 45px; +} +th:nth-child(2) { + width: 130px; +} +th:last-child { + width: 84px; +} +tr[data-id] { + cursor: pointer; +} +tr[data-id]:hover { + background: #1d2125; +} +tr.selected { + background: #222a1d; +} +td .snippet { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.7; +} +td small, +.byline small { + color: var(--muted); + display: block; + font-size: 11px; +} +.author { + display: flex; + gap: 8px; + align-items: center; + overflow: hidden; +} +.avatar { + width: 30px; + height: 30px; + object-fit: cover; + border-radius: 50%; + background: #333; + flex-shrink: 0; +} +.author b { + font-size: 12px; + word-break: break-word; +} +mark { + background: #c8f36b; + color: #172108; + border-radius: 2px; +} +footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + border-top: 1px solid var(--line); + padding: 13px 8px; + color: var(--muted); + font-size: 11px; +} +footer div { + display: flex; + align-items: center; + gap: 9px; +} +footer button { + font-size: 11px; + padding: 5px 8px; +} +#detail { + width: 40%; + min-width: 320px; + max-width: 640px; + border-left: 1px solid var(--line); + padding: 24px; + overflow: auto; + background: #131517; + resize: horizontal; +} +.empty { + text-align: center; + color: var(--muted); + padding: 70px 20px; +} +.empty > span { + font-size: 40px; + color: var(--accent); +} +.empty h2 { + font-size: 18px; + color: var(--ink); +} +.detail-top { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 22px; +} +.detail-top .avatar { + width: 42px; + height: 42px; +} +.detail-top a { + margin-left: auto; + font-size: 12px; + white-space: nowrap; +} +.body-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 15px; + line-height: 1.95; +} +.media img, +.media video { + max-width: 100%; + border-radius: 10px; + display: block; + margin: 15px auto; +} +.quote { + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px; + margin: 20px 0; +} +.metrics { + display: flex; + gap: 18px; + color: var(--muted); + font-size: 12px; + margin: 20px 0; +} +.tag { + display: inline-block; + padding: 2px 7px; + border-radius: 5px; + background: #262c23; + color: #b6c69c; + font-size: 10px; + margin: 5px 4px 0 0; +} +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); + gap: 12px; + padding: 12px; +} +.card { + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + text-align: left; + cursor: pointer; +} +.card.selected { + border-color: var(--accent); +} +.card .snippet { + margin-top: 12px; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; +} +.card img.cover { + width: 100%; + height: 150px; + object-fit: cover; + border-radius: 7px; + margin-top: 14px; +} +@media (max-width: 1100px) { + .sidebar { + width: 175px; + padding: 20px 12px; + } + main { + padding: 20px 16px 0; + } + #detail { + min-width: 290px; + width: 42%; + padding: 16px; + } + th:nth-child(2) { + width: 100px; + } + th:last-child { + width: 65px; + } +} +@media (max-width: 780px) { + .sidebar { + width: 145px; + } + #detail { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 75%; + max-width: none; + z-index: 3; + box-shadow: -20px 0 80px #0009; + } + #detail:not(.open) { + display: none; + } + .filters { + gap: 5px; + } + header button { + font-size: 11px; + } +} +.close-detail { + float: right; + margin: 0 0 12px 12px; +} +.error { + color: #f2aa9e !important; +} diff --git a/library.html b/library.html new file mode 100644 index 0000000..ed30408 --- /dev/null +++ b/library.html @@ -0,0 +1,88 @@ + + +
+ + +| # | 作者 | 推文 | 发布日期 |
|---|---|---|---|
| ${page * size + i + 1} | ${author(b)} | ${highlight(b.full_text || b.unavailable_reason || "推文不可用")} ${(b.folders || []).map((f) => `${esc(f)}`).join("")} | ${date(b)} |
试试更短的关键词,或重置筛选条件
Keep a growing archive of your X.com bookmarks