mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-08-17 18:35:41 +02:00
1362 lines
53 KiB
HTML
1362 lines
53 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<!-- Link Bootstrap Icons CDN -->
|
|
<link rel="stylesheet" href="https://jsdelivr.net">
|
|
<title>Jieyab89 SOCMINT X — Archives</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--surface: #1a1d27;
|
|
--surface2: #21253a;
|
|
--border: #2a2d3a;
|
|
--text: #e8eaf0;
|
|
--muted: #8890a4;
|
|
--accent: #5865f2;
|
|
--accent-bg: #1e2240;
|
|
--success: #22c55e;
|
|
--danger: #ef4444;
|
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; min-height: 100vh; }
|
|
|
|
/* ── Header ── */
|
|
header {
|
|
padding: 14px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--surface);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
header h1 { font-size: 14px; font-weight: 600; }
|
|
header .sep { color: var(--border); }
|
|
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
|
|
.hamburger-btn {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
border-radius: 5px;
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
padding: 5px 9px;
|
|
font-size: 16px;
|
|
line-height: 1;
|
|
transition: all 0.15s;
|
|
}
|
|
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
|
|
.nav-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 12px;
|
|
margin-top: 6px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 7px;
|
|
padding: 5px;
|
|
min-width: 175px;
|
|
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
|
|
z-index: 200;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
.nav-menu.hidden { display: none; }
|
|
.nav-menu a {
|
|
padding: 7px 12px;
|
|
border-radius: 5px;
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
font-size: 13px;
|
|
transition: all 0.12s;
|
|
display: block;
|
|
}
|
|
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
|
|
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
|
|
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
|
|
|
|
/* Help popup — fixed bottom-left, same icon/colors as the graph page's legend toggle */
|
|
.help-toggle {
|
|
position: fixed;
|
|
bottom: 14px;
|
|
left: 14px;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: #FFFFFF;
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
z-index: 251;
|
|
transition: all 0.15s;
|
|
}
|
|
.back-to-top {
|
|
position: fixed;
|
|
bottom: 14px;
|
|
right: 14px;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
color: #FFFFFF;
|
|
font-size: 17px;
|
|
cursor: pointer;
|
|
z-index: 251;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.4);
|
|
transition: all 0.15s;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.back-to-top:hover { border-color: var(--accent); }
|
|
.back-to-top.visible { display: flex; }
|
|
.help-toggle:hover { color: var(--text); border-color: var(--accent); }
|
|
.help-popup {
|
|
position: fixed;
|
|
bottom: 50px;
|
|
left: 14px;
|
|
background: rgba(26,29,39,0.96);
|
|
border: 1px solid var(--border);
|
|
border-radius: 7px;
|
|
padding: 10px 14px;
|
|
width: 300px;
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border) transparent;
|
|
font-size: 12px;
|
|
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
|
|
z-index: 250;
|
|
}
|
|
.help-popup.hidden { display: none; }
|
|
.help-popup h4 {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--muted);
|
|
margin: 10px 0 6px;
|
|
}
|
|
.help-popup h4:first-child { margin-top: 0; }
|
|
.help-row { display: flex; align-items: center; gap: 8px; margin: 5px 0; color: var(--text); line-height: 1.4; }
|
|
.help-row .age-badge, .help-row .source-badge { flex-shrink: 0; }
|
|
.help-note { color: var(--muted); margin-top: 8px; line-height: 1.5; }
|
|
.param-heading {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--muted);
|
|
margin: 8px 0 3px;
|
|
padding-top: 6px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.param-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
|
.param-row { color: var(--muted); font-size: 10.5px; line-height: 1.5; margin: 2px 0; }
|
|
.param-key {
|
|
display: inline-block;
|
|
color: var(--accent);
|
|
background: var(--accent-bg);
|
|
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
|
font-size: 10px;
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
/* ── Layout ── */
|
|
.layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 49px); }
|
|
@media (max-width: 760px) { .layout { grid-template-columns: 1fr; } }
|
|
|
|
/* ── Sidebar ── */
|
|
.sidebar {
|
|
border-right: 1px solid var(--border);
|
|
background: var(--surface);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.sidebar-head {
|
|
padding: 14px 16px 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
.sidebar-count { font-size: 11px; color: var(--muted); background: var(--bg); padding: 2px 7px; border-radius: 10px; }
|
|
.sidebar-search {
|
|
width: 100%;
|
|
padding: 6px 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
border-radius: 6px;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.sidebar-search:focus { outline: none; border-color: var(--accent); }
|
|
.sidebar-search::placeholder { color: var(--muted); }
|
|
.archive-list { overflow-y: auto; flex: 1; padding: 8px; }
|
|
.archive-entry {
|
|
padding: 10px 12px;
|
|
border-radius: 7px;
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
transition: all 0.12s;
|
|
margin-bottom: 4px;
|
|
}
|
|
.archive-entry:hover { background: var(--surface2); border-color: var(--border); }
|
|
.archive-entry.active { background: var(--accent-bg); border-color: var(--accent); }
|
|
.ae-tool {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.ae-tool .tool-pill {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
background: var(--accent-bg);
|
|
color: #818cf8;
|
|
margin-right: 4px;
|
|
font-weight: 500;
|
|
}
|
|
.ae-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
.ae-date { font-size: 10.5px; color: var(--muted); opacity: 0.75; margin-top: 2px; }
|
|
.ae-stats { font-size: 11px; color: var(--muted); display: flex; gap: 8px; margin-top: 3px; }
|
|
.ae-stat { display: flex; align-items: center; gap: 3px; }
|
|
.no-archives {
|
|
padding: 40px 16px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* ── Viewer ── */
|
|
.viewer { display: flex; flex-direction: column; overflow: hidden; }
|
|
.viewer-top {
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--surface);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.viewer-meta { flex: 1; }
|
|
.viewer-meta-title { font-size: 13px; font-weight: 600; }
|
|
.viewer-meta-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
|
.search-bar {
|
|
padding: 6px 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
border-radius: 6px;
|
|
width: 220px;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.search-bar:focus { outline: none; border-color: var(--accent); }
|
|
.search-bar::placeholder { color: var(--muted); }
|
|
.result-count { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
.btn-delete {
|
|
padding: 5px 12px;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.btn-delete:hover { color: var(--danger); border-color: var(--danger); }
|
|
|
|
.cards-area { padding: 16px 18px; overflow-y: auto; flex: 1; }
|
|
.cards-grid { display: grid; gap: 8px; }
|
|
.load-more-note {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
padding: 14px 0 2px;
|
|
}
|
|
|
|
/* ── Cards ── */
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
transition: border-color 0.12s;
|
|
}
|
|
.card:hover { border-color: #3a3d50; }
|
|
|
|
/* Profile header — cover photo, avatar, name/handle, bio. Same rules as
|
|
the live search page: only rendered when the item actually carries any
|
|
of that (a tweet's embedded author, a bare user/follower/retweeter
|
|
record); everything else keeps the plain row list it always had. */
|
|
.card-header { margin-bottom: 10px; }
|
|
.card-header.has-banner { margin: -12px -14px 10px; }
|
|
.card-banner {
|
|
height: 64px;
|
|
background-size: cover;
|
|
background-position: center;
|
|
background-color: var(--bg);
|
|
}
|
|
.card-header-row { display: flex; align-items: flex-end; gap: 10px; }
|
|
.card-header.has-banner .card-header-row { padding: 0 14px; margin-top: -26px; }
|
|
.card-avatar {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 2px solid var(--surface);
|
|
background: var(--bg);
|
|
flex-shrink: 0;
|
|
}
|
|
.card-avatar-fallback {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
color: var(--muted);
|
|
}
|
|
.card-identity { min-width: 0; padding-bottom: 3px; }
|
|
.card-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.card-handle { font-size: 12px; color: var(--muted); }
|
|
.verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }
|
|
.card-bio { margin-top: 8px; font-size: 12px; color: var(--text); opacity: 0.85; line-height: 1.45; }
|
|
.card-header.has-banner .card-bio { padding: 0 14px; }
|
|
|
|
/* Compact byline — tweets/replies get just this instead of the full
|
|
banner+bio header above, which is reserved for records that ARE a user
|
|
rather than a tweet someone wrote. */
|
|
.card-byline { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
.card-byline .card-avatar { width: 26px; height: 26px; border-width: 1px; }
|
|
.card-byline .card-avatar-fallback { font-size: 11px; }
|
|
.card-byline .card-identity { display: flex; align-items: baseline; gap: 6px; padding-bottom: 0; min-width: 0; }
|
|
.card-byline .card-name { font-size: 13px; max-width: 55%; }
|
|
.card-byline .card-handle { flex-shrink: 0; }
|
|
|
|
/* Retweeted-content rows — subtle highlight, same as the live search page */
|
|
.card-row.rt-origin .card-key { color: var(--accent); }
|
|
.card-row.rt-origin .card-val { color: #c7d2fe; }
|
|
|
|
.reply-context { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
|
|
|
|
/* Inline expand-and-save — replies/retweets fetched here get folded into
|
|
this archive's own item list and checkpointed back to disk immediately,
|
|
same as the live pages but persisted right away since there's no
|
|
separate "Archive" button on this read view. */
|
|
.reply-expand-btn, .retweet-expand-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
margin-top: 10px;
|
|
margin-right: 6px;
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 4px 10px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
}
|
|
.retweet-expand-btn { color: #f59e0b; }
|
|
.reply-expand-btn:hover:not(:disabled), .retweet-expand-btn:hover:not(:disabled) { border-color: currentColor; }
|
|
.reply-expand-btn:disabled, .retweet-expand-btn:disabled { opacity: 0.5; cursor: wait; }
|
|
|
|
.reply-thread, .retweet-thread {
|
|
margin: 10px 0 2px 16px;
|
|
padding-left: 14px;
|
|
border-left: 2px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.reply-thread.hidden, .retweet-thread.hidden { display: none; }
|
|
|
|
.reply-load-more-btn {
|
|
align-self: flex-start;
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
padding: 2px 0;
|
|
}
|
|
.reply-load-more-btn:disabled { opacity: 0.5; cursor: wait; text-decoration: none; }
|
|
|
|
.save-indicator {
|
|
display: inline-block;
|
|
margin-top: 10px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
.save-indicator.ok { color: var(--success); }
|
|
.save-indicator.err { color: var(--danger); }
|
|
|
|
.card-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 4px 0;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid rgba(42,45,58,0.6);
|
|
}
|
|
.card-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
.card-row:first-child { padding-top: 0; }
|
|
.card-key {
|
|
color: var(--muted);
|
|
min-width: 100px;
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
padding-top: 2px;
|
|
font-weight: 500;
|
|
}
|
|
.card-val { color: var(--text); word-break: break-word; flex: 1; }
|
|
.card-val.clamp {
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
.card-link { color: var(--accent); text-decoration: none; font-size: 12px; }
|
|
.card-link:hover { text-decoration: underline; }
|
|
|
|
/* Drill-down links on reply / retweet counts — same as the live search page */
|
|
.drill-link {
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.drill-link:hover { color: #818cf8; }
|
|
|
|
.source-badge {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 2px 9px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.source-badge.src-cookie { color: #a78bfa; border-color: #4c3a8f; background: #1e1535; }
|
|
.source-badge.src-xquik { color: #8891f7; border-color: #34348a; background: var(--accent-bg); }
|
|
.source-badge.src-wayback { color: #f59e0b; border-color: #78350f; background: #1c0e02; }
|
|
.source-badge.src-cse { color: #f87171; border-color: #7f1d1d; background: #2a0d0d; }
|
|
|
|
.age-badge {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 2px 9px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.age-badge.age-new { color: var(--danger); border-color: #7f1d1d; background: #1c0505; }
|
|
.age-badge.age-recent { color: #f59e0b; border-color: #78350f; background: #1c0e02; }
|
|
.age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }
|
|
|
|
/* What kind of X/Twitter page a Google CSE / Wayback result actually is —
|
|
a keyword match linking to x.com/someone reads as "just a profile" with
|
|
no way to tell a tweet permalink apart from a bare profile page or an
|
|
unrelated page otherwise. */
|
|
.content-type-badge {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 2px 9px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.content-type-badge.ct-tweet { color: var(--success); border-color: #14532d; background: #052011; }
|
|
.content-type-badge.ct-profile { color: #a78bfa; border-color: #4c3a8f; background: #1e1535; }
|
|
.content-type-badge.ct-twitter_other { color: #f59e0b; border-color: #78350f; background: #1c0e02; }
|
|
.content-type-badge.ct-other { color: var(--muted); border-color: var(--border); background: var(--bg); }
|
|
|
|
/* ── Media ── */
|
|
.card-media {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid rgba(42,45,58,0.6);
|
|
}
|
|
.media-item { position: relative; display: inline-block; }
|
|
.media-thumb {
|
|
display: block;
|
|
width: 130px;
|
|
height: 86px;
|
|
object-fit: cover;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.media-thumb:hover { opacity: 0.82; }
|
|
.media-video {
|
|
display: block;
|
|
width: 220px;
|
|
height: 140px;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
background: #000;
|
|
}
|
|
.media-badge {
|
|
position: absolute;
|
|
bottom: 5px; left: 5px;
|
|
background: rgba(0,0,0,0.72);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Empty / loading states ── */
|
|
.empty-state {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
padding: 60px 20px;
|
|
text-align: center;
|
|
}
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 14px; height: 14px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.7s linear infinite;
|
|
vertical-align: middle;
|
|
margin-right: 6px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
<script src="{{ url_for('static', filename='js/card_constants.js') }}" nonce="{{ g.csp_nonce }}"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>Jieyab89 SOCMINT X</h1>
|
|
<span class="sep">|</span>
|
|
<span class="sub">Saved Archives</span>
|
|
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">☰</button>
|
|
<nav id="navMenu" class="nav-menu hidden">
|
|
<a href="/">Home</a>
|
|
<a href="/graph">Graph</a>
|
|
<hr class="nav-divider">
|
|
<a href="/archives" class="current">Archives</a>
|
|
<a href="/analytics">Analytics</a>
|
|
<hr class="nav-divider">
|
|
<a href="/cases">Cases</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- Legend / help popup (floats over the page, bottom-left) -->
|
|
<button id="helpToggle" class="help-toggle" aria-label="Legend / help" title="Legend / help">?</button>
|
|
<button id="backToTop" class="back-to-top" aria-label="Back to top" title="Back to top">↑</button>
|
|
<div id="helpPopup" class="help-popup hidden">
|
|
<h4>Source</h4>
|
|
<div class="help-row"><span class="source-badge src-cookie">Twitter Cookie</span> Live via your cookie session</div>
|
|
<div class="help-row"><span class="source-badge src-xquik">Xquik API</span> Live via the xquik API</div>
|
|
<div class="help-row"><span class="source-badge src-wayback">Wayback Machine</span> Archived snapshot</div>
|
|
<div class="help-row"><span class="source-badge src-cse">Google CSE</span> Live web result via Google Custom Search</div>
|
|
<h4>Content type — Google CSE / Wayback only</h4>
|
|
<div class="help-row"><span class="content-type-badge ct-tweet">Tweet</span> A specific tweet permalink</div>
|
|
<div class="help-row"><span class="content-type-badge ct-profile">Profile page</span> A bare account profile, not a specific post</div>
|
|
<div class="help-row"><span class="content-type-badge ct-twitter_other">Other X/Twitter page</span> Some other X page</div>
|
|
<div class="help-row"><span class="content-type-badge ct-other">External page (non-X)</span> A site off X entirely (Google CSE only)</div>
|
|
<h4>Account age</h4>
|
|
<div class="help-row"><span class="age-badge age-new">New account</span> Created < 30 days ago</div>
|
|
<div class="help-row"><span class="age-badge age-recent">Recent account</span> Created < 1 year ago</div>
|
|
<div class="help-row"><span class="age-badge age-established">Established account</span> Created 1+ year ago</div>
|
|
<div class="help-note">
|
|
Derived straight from the account's numeric ID — no external lookup.
|
|
A tilde (~) means the id predates X's Snowflake ID scheme, so the date
|
|
is an estimate, not an exact decode. "Unknown" means the id falls in a
|
|
gap we have no reliable way to date at all.
|
|
</div>
|
|
<h4>What each parameter means</h4>
|
|
{% include "_field_glossary.html" %}
|
|
</div>
|
|
|
|
<div class="layout">
|
|
|
|
<!-- Sidebar: archive list -->
|
|
<div class="sidebar">
|
|
<div class="sidebar-head">
|
|
<span class="sidebar-title">Archives</span>
|
|
<span class="sidebar-count" id="archiveCount">—</span>
|
|
</div>
|
|
<div style="padding:8px 12px 0">
|
|
<input type="text" class="sidebar-search" id="archiveSearch" placeholder="Search archives…">
|
|
</div>
|
|
<div class="archive-list" id="archiveList">
|
|
<div class="no-archives"><span class="spinner"></span> Loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main viewer -->
|
|
<div class="viewer">
|
|
<div class="viewer-top" id="viewerTop" style="display:none">
|
|
<div class="viewer-meta">
|
|
<div class="viewer-meta-title" id="viewerTitle">—</div>
|
|
<div class="viewer-meta-sub" id="viewerSub">—</div>
|
|
</div>
|
|
<input type="text" class="search-bar" id="searchInput" placeholder="Search in archive…">
|
|
<span class="result-count" id="resultCount"></span>
|
|
<a class="btn-delete" id="viewGraphBtn" href="#" target="_blank" rel="noopener noreferrer" style="text-decoration:none;display:inline-block">View as Graph ↗</a>
|
|
<button class="btn-delete" id="deleteBtn">Delete</button>
|
|
</div>
|
|
|
|
<div class="cards-area">
|
|
<div id="cardsBox">
|
|
<div class="empty-state">Select an archive from the sidebar to view its contents.</div>
|
|
</div>
|
|
<div id="cardsSentinel" style="height:1px"></div>
|
|
<div class="load-more-note" id="cardsLoadStatus" style="display:none"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
// Hamburger nav
|
|
(function() {
|
|
var btn = document.getElementById('navToggle');
|
|
var menu = document.getElementById('navMenu');
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
menu.classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', function() { menu.classList.add('hidden'); });
|
|
menu.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}());
|
|
|
|
// Help popup
|
|
(function() {
|
|
var btn = document.getElementById('helpToggle');
|
|
var popup = document.getElementById('helpPopup');
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
popup.classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', function() { popup.classList.add('hidden'); });
|
|
popup.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}());
|
|
|
|
// Back to top — the page itself is what scrolls (the sidebar/viewer split
|
|
// is a grid column layout, not a fixed-height scroll pane).
|
|
(function() {
|
|
var btn = document.getElementById('backToTop');
|
|
window.addEventListener('scroll', function() {
|
|
btn.classList.toggle('visible', window.scrollY > 400);
|
|
});
|
|
btn.addEventListener('click', function() {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
}());
|
|
|
|
let allItems = [];
|
|
let activeId = null;
|
|
let activeMeta = {}; // meta.json for the open archive — tool/query, needed to checkpoint-save expansions back to it
|
|
let allArchives = []; // full list from the server; archiveSearch filters this client-side
|
|
|
|
// ── Reply/retweet expand-and-save cooldown ──────────────────────────────────
|
|
// Mirrors index.html's throttle handling — every expand here is a cookie-mode
|
|
// call, sharing the backend's single 5s-per-source cookie clock with every
|
|
// other tool, so a fast double-click here still respects it.
|
|
const THROTTLE_SECONDS = 5;
|
|
let cooldownUntil = { cookie: 0 };
|
|
|
|
function stampCooldown(sources) {
|
|
(sources || []).forEach(source => { cooldownUntil[source] = Date.now() + THROTTLE_SECONDS * 1000; });
|
|
}
|
|
|
|
const archiveList = document.getElementById('archiveList');
|
|
const archiveCount = document.getElementById('archiveCount');
|
|
const archiveSearch = document.getElementById('archiveSearch');
|
|
const viewerTop = document.getElementById('viewerTop');
|
|
const viewerTitle = document.getElementById('viewerTitle');
|
|
const viewerSub = document.getElementById('viewerSub');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const resultCount = document.getElementById('resultCount');
|
|
const deleteBtn = document.getElementById('deleteBtn');
|
|
const cardsBox = document.getElementById('cardsBox');
|
|
|
|
// ── Resilient fetch — auto-retry transient failures ─────────────────────────
|
|
// `res.json()` throws a raw SyntaxError ("unexpected character at line 1
|
|
// column 1...") whenever the response body isn't valid JSON — a dropped/
|
|
// truncated connection (flaky wifi, the device sleeping mid-request) or a
|
|
// server-side hiccup can both do this, and previously nothing here caught
|
|
// it: the promise just rejected, the "Loading…" spinner never went away,
|
|
// and the failure showed up nowhere the user could see WHY. This retries a
|
|
// few times with a short backoff (self-heals a one-off network blip
|
|
// automatically — no user action needed) before surfacing a real,
|
|
// human-readable error. A well-formed {ok:false,...} response is NOT
|
|
// retried here — that's the server correctly telling you something (e.g.
|
|
// "Archive not found"), retrying wouldn't change the answer; only fetch/
|
|
// parse failures (the connection or the body itself was the problem) do.
|
|
async function fetchJsonRetry(url, opts, attempts = 3) {
|
|
let lastErr;
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
const res = await fetch(url, opts);
|
|
return await res.json();
|
|
} catch (e) {
|
|
lastErr = e;
|
|
if (i < attempts - 1) await new Promise(r => setTimeout(r, 500 * (i + 1)));
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
|
// The server already returns archives newest-first (by archived_at/updated_at,
|
|
// not folder name — folder names are prefixed by tool_type so sorting by name
|
|
// doesn't actually sort chronologically once more than one tool's been used).
|
|
|
|
async function loadSidebar() {
|
|
let json;
|
|
try {
|
|
json = await fetchJsonRetry('/api/archive/list');
|
|
} catch (e) {
|
|
archiveList.innerHTML = `<div class="no-archives" style="color:var(--danger)">Couldn't load archives — ${esc(e.message || String(e))}<br><button type="button" class="reply-expand-btn" id="retrySidebarBtn" style="margin-top:8px">Retry</button></div>`;
|
|
document.getElementById('retrySidebarBtn').addEventListener('click', loadSidebar);
|
|
return;
|
|
}
|
|
|
|
if (!json.ok || !json.archives.length) {
|
|
allArchives = [];
|
|
archiveList.innerHTML = json.ok
|
|
? '<div class="no-archives">No archives yet.<br>Enable Auto Archive in the tool and run a search.</div>'
|
|
: `<div class="no-archives" style="color:var(--danger)">${esc(json.error)}</div>`;
|
|
archiveCount.textContent = '0';
|
|
return;
|
|
}
|
|
|
|
allArchives = json.archives;
|
|
renderArchiveList(archiveSearch.value);
|
|
|
|
// Auto-open the newest one
|
|
loadArchive(allArchives[0].id);
|
|
}
|
|
|
|
function archiveMatchText(a) {
|
|
return [a.tool || '', queryLabel(a.query || {}), a.id || ''].join(' ').toLowerCase();
|
|
}
|
|
|
|
function renderArchiveList(filterText = '') {
|
|
const q = filterText.toLowerCase().trim();
|
|
const filtered = q ? allArchives.filter(a => archiveMatchText(a).includes(q)) : allArchives;
|
|
|
|
archiveCount.textContent = q ? `${filtered.length} / ${allArchives.length}` : allArchives.length;
|
|
|
|
if (!filtered.length) {
|
|
archiveList.innerHTML = '<div class="no-archives">No archives match your search.</div>';
|
|
return;
|
|
}
|
|
|
|
archiveList.innerHTML = filtered.map(a => entryHtml(a)).join('');
|
|
archiveList.querySelectorAll('.archive-entry').forEach(el => {
|
|
el.classList.toggle('active', el.dataset.id === activeId);
|
|
el.addEventListener('click', () => loadArchive(el.dataset.id));
|
|
});
|
|
}
|
|
|
|
archiveSearch.addEventListener('input', () => renderArchiveList(archiveSearch.value));
|
|
|
|
function entryHtml(a) {
|
|
const tool = (a.tool || 'unknown').replace(/_/g, ' ');
|
|
const date = a.archived_at ? a.archived_at.replace('T', ' ') : '—';
|
|
const query = queryLabel(a.query || {});
|
|
return `
|
|
<div class="archive-entry" data-id="${esc(a.id)}">
|
|
<div class="ae-tool">${esc(tool)}</div>
|
|
<div class="ae-date">${esc(date)}</div>
|
|
${query ? `<div class="ae-meta">${esc(query)}</div>` : ''}
|
|
<div class="ae-stats">
|
|
<span class="ae-stat">· ${a.total_items || 0} items</span>
|
|
<span class="ae-stat">· ${a.media_count || 0} media</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function queryLabel(q) {
|
|
return q.searchQuery || q.targetUsername || q.targetTweetId || q.targetCommunityId || q.query || '';
|
|
}
|
|
|
|
// ── Viewer ────────────────────────────────────────────────────────────────────
|
|
|
|
async function loadArchive(id) {
|
|
activeId = id;
|
|
|
|
// Highlight sidebar entry
|
|
archiveList.querySelectorAll('.archive-entry').forEach(el => {
|
|
el.classList.toggle('active', el.dataset.id === id);
|
|
});
|
|
|
|
cardsBox.innerHTML = '<div class="empty-state"><span class="spinner"></span> Loading…</div>';
|
|
viewerTop.style.display = 'none';
|
|
searchInput.value = '';
|
|
|
|
let json;
|
|
try {
|
|
json = await fetchJsonRetry(`/api/archive/${id}/results`);
|
|
} catch (e) {
|
|
cardsBox.innerHTML = `<div class="empty-state" style="color:var(--danger)">Couldn't load this archive — ${esc(e.message || String(e))}<br><button type="button" class="reply-expand-btn" id="retryArchiveBtn" style="margin-top:8px">Retry</button></div>`;
|
|
document.getElementById('retryArchiveBtn').addEventListener('click', () => loadArchive(id));
|
|
return;
|
|
}
|
|
if (!json.ok) {
|
|
cardsBox.innerHTML = `<div class="empty-state" style="color:var(--danger)">${esc(json.error)}</div>`;
|
|
return;
|
|
}
|
|
|
|
allItems = json.results || [];
|
|
activeMeta = json.meta || {};
|
|
|
|
const tool = (activeMeta.tool || 'unknown').replace(/_/g, ' ');
|
|
const query = queryLabel(activeMeta.query || {});
|
|
viewerTitle.textContent = tool + (query ? ` — "${query}"` : '');
|
|
viewerSub.textContent = [
|
|
activeMeta.archived_at ? activeMeta.archived_at.replace('T', ' ') : '',
|
|
`${allItems.length} items`,
|
|
activeMeta.media_count ? `${activeMeta.media_count} media files` : '',
|
|
].filter(Boolean).join(' · ');
|
|
|
|
viewerTop.style.display = '';
|
|
document.getElementById('viewGraphBtn').href = '/graph?archiveId=' + encodeURIComponent(id);
|
|
renderCards(allItems, '');
|
|
}
|
|
|
|
// ── Search ────────────────────────────────────────────────────────────────────
|
|
// Debounced — same reasoning as analytics.html's item search: re-filtering
|
|
// and re-rendering the full card list (avatars, media, everything) on every
|
|
// single keystroke gets janky once an archive has hundreds/thousands of items.
|
|
let cardSearchDebounce = null;
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(cardSearchDebounce);
|
|
cardSearchDebounce = setTimeout(() => renderCards(allItems, searchInput.value), 180);
|
|
});
|
|
|
|
function flatText(obj) {
|
|
if (!obj || typeof obj !== 'object') return String(obj ?? '');
|
|
return Object.values(obj).map(flatText).join(' ');
|
|
}
|
|
|
|
// ── Cards ─────────────────────────────────────────────────────────────────────
|
|
|
|
// PRIORITY / DRILLABLE / SOURCE_CLASS / AGE_LABELS / buildCardHeader come
|
|
// from static/js/card_constants.js, loaded above — shared with index.html,
|
|
// so a record's avatar/name/handle/banner/bio render identically whether
|
|
// you're looking at a live result or a saved archive.
|
|
const SKIP = ['archived_media', 'profile_image_url', 'profile_banner_url', 'entities', 'extended_entities', 'urls', 'media', 'indices'];
|
|
const CLAMP = new Set(['text','full_text','content','description','bio','article_text','retweeted_text','retweeted_by_bio','post_text','quoted_text']);
|
|
|
|
// ── Auto-scroll — same mechanism index.html's live search results use
|
|
// (IntersectionObserver watching a sentinel, root: null since — per this
|
|
// page's own back-to-top comment above — the PAGE is what actually scrolls
|
|
// here, .cards-area's overflow:auto never gets a bounded height to actually
|
|
// clip against). Building the card HTML itself is cheap (measured well
|
|
// under 20ms for 200+ cards) — what actually made a big first batch feel
|
|
// slow is every card's <img> avatar firing its own request to Twitter's CDN
|
|
// at once; 50 of those landing simultaneously is what dragged out the
|
|
// initial paint, not the rendering logic. 10 keeps that burst small; the
|
|
// tighter rootMargin below (vs. what analytics.html's plain-text item list
|
|
// uses) matters just as much — a bigger margin pre-triggers the NEXT
|
|
// image-heavy batch before you've actually scrolled toward it.
|
|
const CARD_BATCH = 10;
|
|
let filteredCardItems = [];
|
|
let cardRenderCount = 0;
|
|
|
|
function updateCardsLoadStatus() {
|
|
const status = document.getElementById('cardsLoadStatus');
|
|
const remaining = filteredCardItems.length - cardRenderCount;
|
|
if (remaining > 0) {
|
|
status.style.display = '';
|
|
status.textContent = `Showing ${cardRenderCount} of ${filteredCardItems.length} — scroll for more…`;
|
|
} else {
|
|
status.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function loadMoreCards() {
|
|
if (cardRenderCount >= filteredCardItems.length) return;
|
|
const prev = cardRenderCount;
|
|
cardRenderCount = Math.min(cardRenderCount + CARD_BATCH, filteredCardItems.length);
|
|
const grid = cardsBox.querySelector('.cards-grid');
|
|
if (!grid) return;
|
|
grid.insertAdjacentHTML('beforeend', filteredCardItems.slice(prev, cardRenderCount).map(it => buildCard(it)).join(''));
|
|
updateCardsLoadStatus();
|
|
}
|
|
|
|
function renderCards(items, query) {
|
|
const q = query.toLowerCase().trim();
|
|
const filtered = q ? items.filter(i => flatText(i).toLowerCase().includes(q)) : items;
|
|
|
|
resultCount.textContent = filtered.length + (q ? ' found' : ' results');
|
|
|
|
filteredCardItems = filtered;
|
|
cardRenderCount = Math.min(CARD_BATCH, filtered.length);
|
|
|
|
if (!filtered.length) {
|
|
cardsBox.innerHTML = '<div class="empty-state">No matching results.</div>';
|
|
updateCardsLoadStatus();
|
|
return;
|
|
}
|
|
cardsBox.innerHTML = '<div class="cards-grid">' + filtered.slice(0, cardRenderCount).map(it => buildCard(it)).join('') + '</div>';
|
|
updateCardsLoadStatus();
|
|
}
|
|
|
|
// #cardsSentinel and .cards-area are static page elements (only #cardsBox's
|
|
// innerHTML gets replaced on every render), so — unlike analytics.html's
|
|
// dashboard, which gets rebuilt from scratch per archive — this observer is
|
|
// bound exactly once, right here, and just keeps working across archive
|
|
// switches and searches.
|
|
const cardsObserver = new IntersectionObserver((entries) => {
|
|
if (entries.some(e => e.isIntersecting)) loadMoreCards();
|
|
}, { root: null, rootMargin: '100px' });
|
|
cardsObserver.observe(document.getElementById('cardsSentinel'));
|
|
|
|
// Same "Replying to @x @y" trim index.html's cards apply — reply_to_mentions
|
|
// (from cookie_client.py) lists exactly which leading @mentions x.com itself
|
|
// hides from the rendered tweet body. Display-only: item.text in the
|
|
// archived JSON is untouched.
|
|
function stripLeadingMentions(text, mentions) {
|
|
if (!text || !mentions || !mentions.length) return text;
|
|
let rest = text;
|
|
for (const m of mentions) {
|
|
const re = new RegExp('^\\s*@' + String(m).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
|
const match = rest.match(re);
|
|
if (!match) break;
|
|
rest = rest.slice(match[0].length);
|
|
}
|
|
rest = rest.replace(/^\s+/, '');
|
|
return rest || text;
|
|
}
|
|
|
|
// nested=true for a reply/retweet rendered inside another card's expanded
|
|
// thread — same recursive shape index.html's buildCard() uses, so a reply
|
|
// that itself has replies still gets its own Expand button.
|
|
function buildCard(item, nested = false) {
|
|
if (typeof item !== 'object' || !item) return '';
|
|
|
|
const tweetId = item.id ? String(item.id) : null;
|
|
const entries = Object.entries(item);
|
|
const { html: header, usedFields: headerFields } = buildCardHeader(item);
|
|
// Same rule as the live search page: order follows PRIORITY's own sequence,
|
|
// and nothing gets capped off — a saved archive should show exactly the
|
|
// same fields, in the same order, as when it was first fetched.
|
|
const priKeys = new Set(entries.map(([k]) => k).filter(k => PRIORITY.includes(k)));
|
|
const pri = PRIORITY.filter(k => priKeys.has(k) && !headerFields.includes(k)).map(k => [k, item[k]]);
|
|
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k) && !headerFields.includes(k));
|
|
|
|
const rows = [...pri, ...rest].map(([k, v]) => {
|
|
let display;
|
|
// Drillable check first — reply_count / retweet_count on an archived
|
|
// tweet still link out to a fresh live extraction, same as a live result.
|
|
if (DRILLABLE[k] && tweetId) {
|
|
const _p = new URLSearchParams({ tool: DRILLABLE[k], id: tweetId, autorun: '1' });
|
|
const lbl = (v !== null && v !== undefined) ? esc(String(v)) : '—';
|
|
display = `<a class="drill-link" href="/?${_p}" target="_blank" rel="noopener noreferrer">${lbl} ↗</a>`;
|
|
} else if (v === null || v === undefined) {
|
|
display = `<span style="color:var(--muted)">—</span>`;
|
|
} else if (k === 'tweet_url' || k === 'archive_url' || k === 'result_url' || k === 'preview_image') {
|
|
display = `<a href="${esc(String(v))}" target="_blank" rel="noopener" class="card-link">${esc(String(v))}</a>`;
|
|
} else if (k === 'source') {
|
|
const cls = SOURCE_CLASS[v] || '';
|
|
display = `<span class="source-badge ${cls}">${esc(String(v))}</span>`;
|
|
} else if (k === 'account_age_flag') {
|
|
display = `<span class="age-badge age-${esc(String(v))}">${esc(AGE_LABELS[v] || v)}</span>`;
|
|
} else if (k === 'content_type') {
|
|
display = `<span class="content-type-badge ct-${esc(String(v))}">${esc(CONTENT_TYPE_LABELS[v] || String(v))}</span>`;
|
|
} else if (k === 'text' && item.reply_to_mentions && item.reply_to_mentions.length) {
|
|
const badge = `<div class="reply-context">↩ Replying to ${item.reply_to_mentions.map(m => '@' + esc(m)).join(', ')}</div>`;
|
|
const clean = stripLeadingMentions(String(v), item.reply_to_mentions);
|
|
display = `${badge}<span class="clamp">${esc(clean)}</span>`;
|
|
} else if (typeof v === 'object') {
|
|
const s = JSON.stringify(v);
|
|
display = `<span style="color:var(--muted);font-size:11px">${esc(s.length > 100 ? s.slice(0,100)+'…' : s)}</span>`;
|
|
} else {
|
|
const cls = CLAMP.has(k) ? ' clamp' : '';
|
|
display = `<span class="${cls}">${esc(String(v))}</span>`;
|
|
}
|
|
const rtCls = (k.startsWith('retweeted_') || k.startsWith('quoted_')) ? ' rt-origin' : '';
|
|
return `<div class="card-row${rtCls}"><div class="card-key">${esc(k)}</div><div class="card-val">${display}</div></div>`;
|
|
}).join('');
|
|
|
|
const media = buildMedia(item);
|
|
|
|
// Expand-in-place — same trigger rule as index.html (id + a positive
|
|
// count), but the fetched result here also gets folded into this
|
|
// archive's saved results.json as soon as it lands (see saveExpansion()),
|
|
// since this view has no separate "Archive" button to click afterward.
|
|
const replyCount = Number(item.reply_count);
|
|
const canReplies = tweetId && Number.isFinite(replyCount) && replyCount > 0;
|
|
const rtCount = Number(item.retweet_count);
|
|
const canRetweets = tweetId && Number.isFinite(rtCount) && rtCount > 0;
|
|
|
|
const replyBtn = canReplies
|
|
? `<button type="button" class="reply-expand-btn" data-tweet-id="${esc(tweetId)}" data-count="${replyCount}">↩ Expand ${replyCount} repl${replyCount === 1 ? 'y' : 'ies'}</button>`
|
|
: '';
|
|
const retweetBtn = canRetweets
|
|
? `<button type="button" class="retweet-expand-btn" data-tweet-id="${esc(tweetId)}" data-count="${rtCount}">↗ Expand ${rtCount} retweet${rtCount === 1 ? '' : 's'}</button>`
|
|
: '';
|
|
|
|
const cardCls = nested ? 'card reply-card' : 'card';
|
|
const card = `<div class="${cardCls}">${header}${rows}${media}${replyBtn}${retweetBtn}<span class="save-indicator" data-role="save-indicator"></span></div>`;
|
|
if (!canReplies && !canRetweets) return card;
|
|
|
|
const threads = [
|
|
canReplies ? `<div class="reply-thread hidden" data-kind="reply" data-tweet-id="${esc(tweetId)}"></div>` : '',
|
|
canRetweets ? `<div class="retweet-thread hidden" data-kind="retweet" data-tweet-id="${esc(tweetId)}"></div>` : '',
|
|
].join('');
|
|
return `<div class="card-thread-wrap">${card}${threads}</div>`;
|
|
}
|
|
|
|
// Cookie mode: item.media = [{type, thumb, url}]. API mode: raw Twitter
|
|
// shape under extended_entities.media / entities.media. Same normalization
|
|
// index.html's extractMedia() applies — needed here for items fetched
|
|
// through an inline expand, which haven't been through archive.py's
|
|
// download pipeline yet (no archived_media of their own until the
|
|
// checkpoint save that follows lands).
|
|
function extractMedia(item) {
|
|
if (Array.isArray(item.media) && item.media.length) {
|
|
const first = item.media[0];
|
|
if (first && typeof first === 'object' && ('thumb' in first || 'url' in first)) {
|
|
return item.media;
|
|
}
|
|
}
|
|
const src = (item.extended_entities && item.extended_entities.media)
|
|
|| (item.entities && item.entities.media);
|
|
if (!Array.isArray(src)) return [];
|
|
return src.map(m => {
|
|
const mtype = m.type || 'photo';
|
|
const thumb = m.media_url_https || m.media_url || '';
|
|
let url = thumb;
|
|
if (mtype === 'video' || mtype === 'animated_gif') {
|
|
const variants = (m.video_info && m.video_info.variants) || [];
|
|
const mp4s = variants.filter(v => v.content_type === 'video/mp4');
|
|
if (mp4s.length) url = mp4s.reduce((b, v) => (v.bitrate||0) > (b.bitrate||0) ? v : b).url;
|
|
}
|
|
return { type: mtype, thumb, url };
|
|
}).filter(m => m.thumb);
|
|
}
|
|
|
|
function buildMedia(item) {
|
|
// Already-archived item: serve from local disk via the media route.
|
|
const paths = item.archived_media;
|
|
if (Array.isArray(paths) && paths.length) {
|
|
const archiveId = activeId;
|
|
const html = paths.map(p => {
|
|
// p = "media/filename.ext"
|
|
const filename = p.replace(/^media\//, '');
|
|
const src = `/api/archive/${encodeURIComponent(archiveId)}/media/${encodeURIComponent(filename)}`;
|
|
const isVideo = filename.endsWith('.mp4');
|
|
const isGif = filename.includes('animated_gif');
|
|
|
|
if (isVideo || isGif) {
|
|
const loop = isGif ? 'loop muted' : '';
|
|
const label = isGif ? '<span class="media-badge">GIF</span>' : '';
|
|
return `<div class="media-item">
|
|
<video class="media-video" controls ${loop} preload="none">
|
|
<source src="${src}" type="video/mp4">
|
|
</video>${label}
|
|
</div>`;
|
|
}
|
|
return `<a href="${src}" target="_blank" rel="noopener" class="media-item">
|
|
<img src="${src}" class="media-thumb" alt="media" loading="lazy">
|
|
</a>`;
|
|
}).join('');
|
|
return `<div class="card-media">${html}</div>`;
|
|
}
|
|
|
|
// Freshly expanded item, not yet checkpointed to disk — render straight
|
|
// from the source (video routed through the same CDN proxy the live
|
|
// search page uses), same as index.html's renderMedia().
|
|
const mediaList = extractMedia(item);
|
|
if (!mediaList.length) return '';
|
|
const html = mediaList.map(m => {
|
|
if (m.type === 'video' || m.type === 'animated_gif') {
|
|
const proxied = `/api/video?url=${encodeURIComponent(m.url)}`;
|
|
const loop = m.type === 'animated_gif' ? 'loop muted' : '';
|
|
const poster = m.thumb ? `poster="${esc(m.thumb)}"` : '';
|
|
const label = m.type === 'animated_gif' ? '<span class="media-badge">GIF</span>' : '';
|
|
return `<div class="media-item">
|
|
<video class="media-video" controls ${loop} ${poster} preload="none">
|
|
<source src="${proxied}" type="video/mp4">
|
|
</video>${label}
|
|
</div>`;
|
|
}
|
|
return `<a href="${esc(m.url)}" target="_blank" rel="noopener" class="media-item">
|
|
<img src="${esc(m.thumb)}" class="media-thumb" alt="media" loading="lazy">
|
|
</a>`;
|
|
}).join('');
|
|
return `<div class="card-media">${html}</div>`;
|
|
}
|
|
|
|
// ── Inline expand-and-save (replies / retweets) ─────────────────────────────
|
|
// Same interaction as index.html's reply threads and graph.html's node
|
|
// expand, adapted to this read-only view: there's no separate "Archive"
|
|
// button here, so a successful expand immediately checkpoints the fetched
|
|
// items back into THIS SAME archive's results.json via archive.py's
|
|
// update() (which skips re-downloading any media already on disk) — that's
|
|
// what makes an expanded comment/reply/retweet count as saved digital
|
|
// evidence rather than something that only ever existed in the browser tab.
|
|
|
|
const EXPAND_KIND = {
|
|
reply: {
|
|
toolType: 'tweet_replies_extractor', threadSel: '.reply-thread', btnSel: '.reply-expand-btn',
|
|
label: n => `↩ Expand ${n} repl${n === 1 ? 'y' : 'ies'}`, hideLabel: '▲ Hide replies',
|
|
loadMoreLabel: 'Load more replies…', noneLabel: 'No replies found',
|
|
},
|
|
retweet: {
|
|
toolType: 'tweet_retweeters_extractor', threadSel: '.retweet-thread', btnSel: '.retweet-expand-btn',
|
|
label: n => `↗ Expand ${n} retweet${n === 1 ? '' : 's'}`, hideLabel: '▲ Hide retweets',
|
|
loadMoreLabel: 'Load more retweets…', noneLabel: 'No retweeters found',
|
|
},
|
|
};
|
|
|
|
cardsBox.addEventListener('click', (e) => {
|
|
const replyBtn = e.target.closest('.reply-expand-btn');
|
|
if (replyBtn) { toggleThread(replyBtn, 'reply'); return; }
|
|
const rtBtn = e.target.closest('.retweet-expand-btn');
|
|
if (rtBtn) { toggleThread(rtBtn, 'retweet'); return; }
|
|
const moreBtn = e.target.closest('.reply-load-more-btn');
|
|
if (moreBtn) { loadMoreThread(moreBtn); }
|
|
});
|
|
|
|
function armCountdown(btn, msRemaining, resetLabel) {
|
|
const secs = Math.max(1, Math.ceil(msRemaining / 1000));
|
|
btn.disabled = true;
|
|
btn.textContent = `Wait ${secs}s…`;
|
|
setTimeout(() => { btn.disabled = false; btn.textContent = resetLabel(); }, msRemaining);
|
|
}
|
|
|
|
async function toggleThread(btn, kind) {
|
|
const cfg = EXPAND_KIND[kind];
|
|
const wrap = btn.closest('.card-thread-wrap');
|
|
const thread = wrap.querySelector(`:scope > ${cfg.threadSel}`);
|
|
|
|
// Already fetched once — just show/hide, no new request (mirrors
|
|
// index.html's toggleReplyThread()).
|
|
if (thread.dataset.loaded === '1') {
|
|
const nowHidden = thread.classList.toggle('hidden');
|
|
btn.textContent = nowHidden ? cfg.label(Number(btn.dataset.count)) : cfg.hideLabel;
|
|
return;
|
|
}
|
|
|
|
const tweetId = btn.dataset.tweetId;
|
|
const remaining = cooldownUntil.cookie - Date.now();
|
|
if (remaining > 0) {
|
|
armCountdown(btn, remaining, () => cfg.label(Number(btn.dataset.count)));
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Loading…';
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ toolType: cfg.toolType, mode: 'cookie', count: 20, targetTweetId: tweetId }),
|
|
});
|
|
const json = await res.json();
|
|
stampCooldown(['cookie']);
|
|
|
|
if (!json.ok) {
|
|
if (res.status === 429 && json.retryAfter) {
|
|
armCountdown(btn, json.retryAfter * 1000, () => cfg.label(Number(btn.dataset.count)));
|
|
return;
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Failed — retry';
|
|
return;
|
|
}
|
|
|
|
const items = Array.isArray(json.data) ? json.data : [json.data];
|
|
thread.innerHTML = items.map(it => buildCard(it, true)).join('');
|
|
thread.dataset.loaded = '1';
|
|
if (json.nextCursor) {
|
|
thread.dataset.cursor = json.nextCursor;
|
|
thread.insertAdjacentHTML('beforeend',
|
|
`<button type="button" class="reply-load-more-btn" data-kind="${kind}" data-tweet-id="${esc(tweetId)}">${cfg.loadMoreLabel}</button>`);
|
|
}
|
|
thread.classList.remove('hidden');
|
|
btn.disabled = !items.length;
|
|
btn.textContent = items.length ? cfg.hideLabel : cfg.noneLabel;
|
|
|
|
// Fold the newly-fetched items into this archive's own dataset and
|
|
// checkpoint-save immediately — this is the "counts as evidence" step.
|
|
if (items.length) {
|
|
allItems = allItems.concat(items);
|
|
saveExpansion(wrap.querySelector('[data-role="save-indicator"]'));
|
|
}
|
|
} catch (err) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Failed — retry';
|
|
}
|
|
}
|
|
|
|
async function loadMoreThread(moreBtn) {
|
|
const kind = moreBtn.dataset.kind;
|
|
const cfg = EXPAND_KIND[kind];
|
|
const thread = moreBtn.closest(cfg.threadSel);
|
|
const tweetId = moreBtn.dataset.tweetId;
|
|
const cursor = thread.dataset.cursor;
|
|
if (!cursor) { moreBtn.remove(); return; }
|
|
|
|
const remaining = cooldownUntil.cookie - Date.now();
|
|
if (remaining > 0) {
|
|
armCountdown(moreBtn, remaining, () => cfg.loadMoreLabel);
|
|
return;
|
|
}
|
|
|
|
moreBtn.disabled = true;
|
|
moreBtn.textContent = 'Loading…';
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ toolType: cfg.toolType, mode: 'cookie', count: 20, targetTweetId: tweetId, cursor }),
|
|
});
|
|
const json = await res.json();
|
|
stampCooldown(['cookie']);
|
|
|
|
if (!json.ok) {
|
|
if (res.status === 429 && json.retryAfter) {
|
|
armCountdown(moreBtn, json.retryAfter * 1000, () => cfg.loadMoreLabel);
|
|
return;
|
|
}
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = 'Failed — retry';
|
|
return;
|
|
}
|
|
|
|
const items = Array.isArray(json.data) ? json.data : [json.data];
|
|
moreBtn.insertAdjacentHTML('beforebegin', items.map(it => buildCard(it, true)).join(''));
|
|
if (json.nextCursor) {
|
|
thread.dataset.cursor = json.nextCursor;
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = cfg.loadMoreLabel;
|
|
} else {
|
|
moreBtn.remove();
|
|
}
|
|
|
|
if (items.length) {
|
|
allItems = allItems.concat(items);
|
|
const wrap = moreBtn.closest('.card-thread-wrap');
|
|
saveExpansion(wrap.querySelector('[data-role="save-indicator"]'));
|
|
}
|
|
} catch (err) {
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = 'Failed — retry';
|
|
}
|
|
}
|
|
|
|
// Checkpoints allItems (the archive's original contents plus everything
|
|
// expanded so far) back into the SAME archive_id — archive.py's update()
|
|
// overwrites results.json/meta.json in place and skips re-downloading any
|
|
// media already on disk, so this is cheap enough to call after every expand.
|
|
let savingCheckpoint = false;
|
|
async function saveExpansion(indicatorEl) {
|
|
if (!activeId || savingCheckpoint) return;
|
|
savingCheckpoint = true;
|
|
if (indicatorEl) { indicatorEl.textContent = 'Saving to archive…'; indicatorEl.className = 'save-indicator'; }
|
|
|
|
try {
|
|
const res = await fetch('/api/archive', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
toolType: activeMeta.tool || 'unknown',
|
|
data: allItems,
|
|
queryInfo: activeMeta.query || {},
|
|
archiveId: activeId,
|
|
}),
|
|
});
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error);
|
|
if (indicatorEl) { indicatorEl.textContent = '✓ Saved to archive'; indicatorEl.className = 'save-indicator ok'; }
|
|
|
|
// Keep the sidebar's item count in sync without a full re-fetch.
|
|
const entry = allArchives.find(a => a.id === activeId);
|
|
if (entry) { entry.total_items = allItems.length; renderArchiveList(archiveSearch.value); }
|
|
viewerSub.textContent = [
|
|
activeMeta.archived_at ? activeMeta.archived_at.replace('T', ' ') : '',
|
|
`${allItems.length} items`,
|
|
activeMeta.media_count ? `${activeMeta.media_count} media files` : '',
|
|
].filter(Boolean).join(' · ');
|
|
} catch (e) {
|
|
if (indicatorEl) { indicatorEl.textContent = 'Save failed: ' + e.message; indicatorEl.className = 'save-indicator err'; }
|
|
} finally {
|
|
savingCheckpoint = false;
|
|
}
|
|
}
|
|
|
|
// ── Delete ────────────────────────────────────────────────────────────────────
|
|
|
|
deleteBtn.addEventListener('click', async () => {
|
|
if (!activeId) return;
|
|
if (!confirm('Delete this archive? This will permanently remove all saved files and media.')) return;
|
|
|
|
const res = await fetch(`/api/archive/${activeId}`, { method: 'DELETE' });
|
|
const json = await res.json();
|
|
if (!json.ok) { alert('Delete failed: ' + json.error); return; }
|
|
|
|
activeId = null;
|
|
allItems = [];
|
|
activeMeta = {};
|
|
cardsBox.innerHTML = '<div class="empty-state">Archive deleted. Select another from the sidebar.</div>';
|
|
viewerTop.style.display = 'none';
|
|
loadSidebar();
|
|
});
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
}
|
|
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
|
|
loadSidebar();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|