mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
Compare commits
No commits in common. "72cf7ff642a68bb6197e1b9689c4f619219872b1" and "2779afee2dd36271c20a5384c98cfc71f0220885" have entirely different histories.
72cf7ff642
...
2779afee2d
22 changed files with 93 additions and 2817 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -133,7 +133,6 @@ dependencies = [
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand",
|
"rand",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
24
Dockerfile
24
Dockerfile
|
|
@ -54,7 +54,6 @@ FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
unzip \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
chromium \
|
chromium \
|
||||||
|
|
@ -76,27 +75,6 @@ RUN python3 -m venv /opt/archivr-venv \
|
||||||
yt-dlp \
|
yt-dlp \
|
||||||
twitter-api-client
|
twitter-api-client
|
||||||
|
|
||||||
# Download Chromium extensions used during headless captures.
|
|
||||||
# uBlock Origin Lite (MV3) — ad/tracker blocking.
|
|
||||||
# I Still Don't Care About Cookies (MV3) — cookie-banner dismissal.
|
|
||||||
RUN mkdir -p \
|
|
||||||
/usr/local/lib/archivr/extensions/ublock-origin-lite \
|
|
||||||
/usr/local/lib/archivr/extensions/istilldontcareaboutcookies \
|
|
||||||
&& curl -fsSL \
|
|
||||||
"https://github.com/uBlockOrigin/uBOL-home/releases/download/2026.705.2152/uBOLite_2026.705.2152.chromium.zip" \
|
|
||||||
-o /tmp/ublock.zip \
|
|
||||||
&& echo "e136ef0d86e43a40ee54ad7b4de01b2c305c81ff4ee9ffef8766ee19b2eee174 /tmp/ublock.zip" | sha256sum -c - \
|
|
||||||
&& unzip -q /tmp/ublock.zip -d /usr/local/lib/archivr/extensions/ublock-origin-lite \
|
|
||||||
&& rm /tmp/ublock.zip \
|
|
||||||
&& curl -fsSL \
|
|
||||||
"https://github.com/OhMyGuus/I-Still-Dont-Care-About-Cookies/releases/download/v1.1.9/ISDCAC-chrome-source.zip" \
|
|
||||||
-o /tmp/isdcac.zip \
|
|
||||||
&& echo "8f70ab947cb2d274f4022a970f5dd3cecd8ec02b060e05187bef9ee3cb18bbcb /tmp/isdcac.zip" | sha256sum -c - \
|
|
||||||
&& unzip -q /tmp/isdcac.zip -d /usr/local/lib/archivr/extensions/istilldontcareaboutcookies \
|
|
||||||
&& rm /tmp/isdcac.zip \
|
|
||||||
&& test -f /usr/local/lib/archivr/extensions/istilldontcareaboutcookies/manifest.json \
|
|
||||||
|| { echo "ERROR: manifest.json not at ISDCAC extension root; zip structure may have changed"; exit 1; }
|
|
||||||
|
|
||||||
# Server and CLI binaries (CLI is needed to run `archivr init` on first setup)
|
# Server and CLI binaries (CLI is needed to run `archivr init` on first setup)
|
||||||
COPY --from=builder /build/target/release/archivr-server /usr/local/bin/archivr-server
|
COPY --from=builder /build/target/release/archivr-server /usr/local/bin/archivr-server
|
||||||
COPY --from=builder /build/target/release/archivr /usr/local/bin/archivr
|
COPY --from=builder /build/target/release/archivr /usr/local/bin/archivr
|
||||||
|
|
@ -117,8 +95,6 @@ ENV ARCHIVR_STATIC_DIR=/usr/share/archivr-server/static \
|
||||||
ARCHIVR_TWEET_PYTHON=/opt/archivr-venv/bin/python3 \
|
ARCHIVR_TWEET_PYTHON=/opt/archivr-venv/bin/python3 \
|
||||||
ARCHIVR_TWEET_SCRAPER=/usr/local/lib/archivr/scrape_user_tweet_contents.py \
|
ARCHIVR_TWEET_SCRAPER=/usr/local/lib/archivr/scrape_user_tweet_contents.py \
|
||||||
ARCHIVR_YT_DLP=/opt/archivr-venv/bin/yt-dlp \
|
ARCHIVR_YT_DLP=/opt/archivr-venv/bin/yt-dlp \
|
||||||
ARCHIVR_UBLOCK_EXT=/usr/local/lib/archivr/extensions/ublock-origin-lite \
|
|
||||||
ARCHIVR_COOKIE_EXT=/usr/local/lib/archivr/extensions/istilldontcareaboutcookies \
|
|
||||||
ARCHIVR_CHROME_ARGS=--no-sandbox
|
ARCHIVR_CHROME_ARGS=--no-sandbox
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ serde_json.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
reqwest.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -91,61 +91,36 @@ async fn setup_guard(
|
||||||
/// Tower middleware: injects HTTP security response headers on every response.
|
/// Tower middleware: injects HTTP security response headers on every response.
|
||||||
/// HSTS is intentionally omitted — that belongs at the reverse-proxy layer.
|
/// HSTS is intentionally omitted — that belongs at the reverse-proxy layer.
|
||||||
async fn security_headers(req: Request, next: Next) -> Response {
|
async fn security_headers(req: Request, next: Next) -> Response {
|
||||||
// Capture path before consuming req for next.run()
|
|
||||||
let is_artifact = req.uri().path().contains("/artifacts/");
|
|
||||||
let mut response = next.run(req).await;
|
let mut response = next.run(req).await;
|
||||||
let headers = response.headers_mut();
|
let headers = response.headers_mut();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
axum::http::header::HeaderName::from_static("x-content-type-options"),
|
axum::http::header::HeaderName::from_static("x-content-type-options"),
|
||||||
axum::http::HeaderValue::from_static("nosniff"),
|
axum::http::HeaderValue::from_static("nosniff"),
|
||||||
);
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::HeaderName::from_static("x-frame-options"),
|
||||||
|
axum::http::HeaderValue::from_static("DENY"),
|
||||||
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
axum::http::header::HeaderName::from_static("referrer-policy"),
|
axum::http::header::HeaderName::from_static("referrer-policy"),
|
||||||
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
);
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
axum::http::header::HeaderName::from_static("permissions-policy"),
|
axum::http::header::HeaderName::from_static("content-security-policy"),
|
||||||
axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=(), autoplay=()"),
|
axum::http::HeaderValue::from_static(
|
||||||
|
"default-src 'self'; \
|
||||||
|
script-src 'self'; \
|
||||||
|
style-src 'self' 'unsafe-inline'; \
|
||||||
|
img-src 'self' data: blob:; \
|
||||||
|
font-src 'self'; \
|
||||||
|
connect-src 'self'; \
|
||||||
|
frame-ancestors 'none'",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::HeaderName::from_static("permissions-policy"),
|
||||||
|
axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||||
);
|
);
|
||||||
if is_artifact {
|
|
||||||
// Artifact responses are iframed by the preview modal (sandboxed, no allow-scripts).
|
|
||||||
// When opened directly in a new tab scripts must still be blocked so archived
|
|
||||||
// pages cannot make same-origin API calls with the user's session.
|
|
||||||
// Only styles, images, fonts and media need to be relaxed for rendering.
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::HeaderName::from_static("content-security-policy"),
|
|
||||||
axum::http::HeaderValue::from_static(
|
|
||||||
"default-src 'none'; \
|
|
||||||
script-src 'none'; \
|
|
||||||
style-src 'self' 'unsafe-inline' https:; \
|
|
||||||
img-src 'self' data: blob: https:; \
|
|
||||||
font-src 'self' https:; \
|
|
||||||
media-src 'self' blob:; \
|
|
||||||
connect-src 'none'; \
|
|
||||||
frame-ancestors 'self'",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::HeaderName::from_static("x-frame-options"),
|
|
||||||
axum::http::HeaderValue::from_static("DENY"),
|
|
||||||
);
|
|
||||||
// Main app CSP — allow Google Fonts and external images for tweet previews
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::HeaderName::from_static("content-security-policy"),
|
|
||||||
axum::http::HeaderValue::from_static(
|
|
||||||
"default-src 'self'; \
|
|
||||||
script-src 'self'; \
|
|
||||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
|
|
||||||
img-src 'self' data: blob: https:; \
|
|
||||||
font-src 'self' https://fonts.gstatic.com; \
|
|
||||||
media-src 'self' blob: https:; \
|
|
||||||
connect-src 'self'; \
|
|
||||||
frame-src 'self'; \
|
|
||||||
frame-ancestors 'none'",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +303,6 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
"/api/archives/:archive_id/blob-cleanup",
|
"/api/archives/:archive_id/blob-cleanup",
|
||||||
get(blob_cleanup_scan_handler).delete(blob_cleanup_delete_handler),
|
get(blob_cleanup_scan_handler).delete(blob_cleanup_delete_handler),
|
||||||
)
|
)
|
||||||
.route("/api/util/resolve-tco", post(resolve_tco_handler))
|
|
||||||
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
||||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||||
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
||||||
|
|
@ -1909,74 +1883,6 @@ async fn delete_collection_handler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── t.co resolver ──────────────────────────────────────────────────────────────
|
|
||||||
// POST /api/util/resolve-tco
|
|
||||||
// Body: JSON array of t.co short URLs (max 50).
|
|
||||||
// Returns: JSON object mapping each input URL to its expanded destination.
|
|
||||||
//
|
|
||||||
// Security:
|
|
||||||
// - Input restricted to https://t.co/<alphanumeric token> only (no SSRF via input).
|
|
||||||
// - redirect(Policy::none()): makes ONE HEAD to t.co, reads Location header, never
|
|
||||||
// fetches the expanded destination (no open-proxy).
|
|
||||||
// - 3 s timeout, 1-hop max.
|
|
||||||
// - No auth required (t.co is public; no data exposed).
|
|
||||||
|
|
||||||
static TCO_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
|
|
||||||
regex::Regex::new(r"^https://t\.co/[A-Za-z0-9]+$").unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
async fn resolve_tco_handler(
|
|
||||||
Json(urls): Json<Vec<String>>,
|
|
||||||
) -> Result<Json<std::collections::HashMap<String, String>>, ApiError> {
|
|
||||||
const MAX_BATCH: usize = 50;
|
|
||||||
let urls: Vec<String> = urls
|
|
||||||
.into_iter()
|
|
||||||
.filter(|u| TCO_RE.is_match(u))
|
|
||||||
.take(MAX_BATCH)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.timeout(Duration::from_secs(3))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ApiError::internal(&format!("http client: {e}")))?;
|
|
||||||
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
let futs: Vec<_> = urls.iter().map(|url| {
|
|
||||||
let client = client.clone();
|
|
||||||
let url = url.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// Try HEAD first; fall back to GET if HEAD returns no Location.
|
|
||||||
// Neither follows redirects (Policy::none), so the server only
|
|
||||||
// ever connects to t.co itself — never to the destination.
|
|
||||||
// Only accept http/https destinations — never javascript:, data:, etc.
|
|
||||||
let safe_location = |resp: reqwest::Response| {
|
|
||||||
resp.headers()
|
|
||||||
.get(reqwest::header::LOCATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
|
|
||||||
};
|
|
||||||
let expanded = match client.head(&url).send().await.ok().and_then(safe_location) {
|
|
||||||
Some(loc) => loc,
|
|
||||||
None => match client.get(&url).send().await.ok().and_then(safe_location) {
|
|
||||||
Some(loc) => loc,
|
|
||||||
None => url.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(url, expanded)
|
|
||||||
})
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
for fut in futs {
|
|
||||||
if let Ok((k, v)) = fut.await {
|
|
||||||
map.insert(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(map))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
45
crates/archivr-server/static/assets/index-DiHAMIVH.js
Normal file
45
crates/archivr-server/static/assets/index-DiHAMIVH.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Gm5awz10.js"></script>
|
<script type="module" crossorigin src="/assets/index-DiHAMIVH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C89vW3ol.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DwI288Sc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback, useRef, createContext } from 'react'
|
import { useState, useEffect, useCallback, useRef, createContext } from 'react'
|
||||||
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api'
|
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api'
|
||||||
import LoginPage from './components/LoginPage.jsx'
|
import LoginPage from './components/LoginPage.jsx'
|
||||||
import SetupPage from './components/SetupPage.jsx'
|
import SetupPage from './components/SetupPage.jsx'
|
||||||
|
|
||||||
|
|
@ -12,20 +12,11 @@ import TagsView from './components/TagsView'
|
||||||
import CollectionsView from './components/CollectionsView'
|
import CollectionsView from './components/CollectionsView'
|
||||||
import SettingsView from './components/SettingsView'
|
import SettingsView from './components/SettingsView'
|
||||||
import ContextRail from './components/ContextRail'
|
import ContextRail from './components/ContextRail'
|
||||||
import PreviewModal from './components/PreviewModal'
|
|
||||||
import AudioBar from './components/AudioBar'
|
|
||||||
import PreviewPage from './components/PreviewPage'
|
|
||||||
import { displayPath } from './utils'
|
import { displayPath } from './utils'
|
||||||
import ToastStack from './components/ToastStack'
|
import ToastStack from './components/ToastStack'
|
||||||
|
|
||||||
export const AuthContext = createContext(null);
|
export const AuthContext = createContext(null);
|
||||||
|
|
||||||
// Detect /preview/:archiveId/:entryUid at load time (static — no navigation)
|
|
||||||
const PREVIEW_ROUTE = (() => {
|
|
||||||
const m = window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/)
|
|
||||||
return m ? { archiveId: m[1], entryUid: m[2] } : null
|
|
||||||
})()
|
|
||||||
|
|
||||||
const VIEWS = ['archive','tags','collections','runs','admin','settings']
|
const VIEWS = ['archive','tags','collections','runs','admin','settings']
|
||||||
const SETTINGS_TABS = ['profile','tokens','instance','storage']
|
const SETTINGS_TABS = ['profile','tokens','instance','storage']
|
||||||
|
|
||||||
|
|
@ -98,24 +89,8 @@ export default function App() {
|
||||||
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
|
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// ── Entry detail (shared between PreviewPanel and ContextRail) ──────────
|
|
||||||
const [entryDetail, setEntryDetail] = useState(null)
|
|
||||||
const detailSeqRef = useRef(0)
|
|
||||||
|
|
||||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||||
|
|
||||||
// Fetch entry detail whenever selected entry changes
|
|
||||||
useEffect(() => {
|
|
||||||
const seq = ++detailSeqRef.current
|
|
||||||
setEntryDetail(null)
|
|
||||||
if (!selectedEntry || !archiveId) return
|
|
||||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
|
|
||||||
if (seq !== detailSeqRef.current) return
|
|
||||||
setEntryDetail(det)
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [selectedEntry, archiveId])
|
|
||||||
|
|
||||||
// Persist captureDialogOpen to sessionStorage
|
// Persist captureDialogOpen to sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
|
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
|
||||||
|
|
@ -197,9 +172,7 @@ export default function App() {
|
||||||
}, [archiveId])
|
}, [archiveId])
|
||||||
|
|
||||||
// Sync view + settingsTab → URL
|
// Sync view + settingsTab → URL
|
||||||
// Sync view + settingsTab → URL (skip when serving a standalone preview page)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (PREVIEW_ROUTE) return
|
|
||||||
const path = locationPath(view, settingsTab)
|
const path = locationPath(view, settingsTab)
|
||||||
if (window.location.pathname !== path) {
|
if (window.location.pathname !== path) {
|
||||||
history.pushState(null, '', path)
|
history.pushState(null, '', path)
|
||||||
|
|
@ -244,22 +217,8 @@ export default function App() {
|
||||||
setSelectedEntry(prev =>
|
setSelectedEntry(prev =>
|
||||||
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
|
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
|
||||||
)
|
)
|
||||||
setEntryDetail(prev =>
|
|
||||||
prev && prev.summary.entry_uid === entryUid
|
|
||||||
? { ...prev, summary: { ...prev.summary, title: newTitle } }
|
|
||||||
: prev
|
|
||||||
)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDetailRefresh = useCallback(() => {
|
|
||||||
if (!archiveId || !selectedEntry) return
|
|
||||||
const seq = ++detailSeqRef.current
|
|
||||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
|
|
||||||
if (seq !== detailSeqRef.current) return
|
|
||||||
setEntryDetail(det)
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [archiveId, selectedEntry])
|
|
||||||
|
|
||||||
const handleEntryDeleted = useCallback((entryUid) => {
|
const handleEntryDeleted = useCallback((entryUid) => {
|
||||||
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
|
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
|
||||||
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
|
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
|
||||||
|
|
@ -299,33 +258,10 @@ export default function App() {
|
||||||
setUblockWarningIgnored(true)
|
setUblockWarningIgnored(true)
|
||||||
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
|
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
|
||||||
}, [])
|
}, [])
|
||||||
const [previewEntryUid, setPreviewEntryUid] = useState(null)
|
|
||||||
const [currentAudio, setCurrentAudio] = useState(null)
|
|
||||||
|
|
||||||
const handleOpenPreview = useCallback(() => {
|
|
||||||
if (selectedEntry) setPreviewEntryUid(selectedEntry.entry_uid)
|
|
||||||
}, [selectedEntry])
|
|
||||||
const handleClosePreview = useCallback(() => setPreviewEntryUid(null), [])
|
|
||||||
const handlePlay = useCallback((src, entry) => {
|
|
||||||
setCurrentAudio({ src, entry })
|
|
||||||
}, [])
|
|
||||||
const handleCloseAudio = useCallback(() => setCurrentAudio(null), [])
|
|
||||||
|
|
||||||
// Close stale modal when selection changes (audio persists intentionally)
|
|
||||||
useEffect(() => {
|
|
||||||
setPreviewEntryUid(null)
|
|
||||||
}, [selectedEntry])
|
|
||||||
|
|
||||||
// Toggle body class so fixed AudioBar doesn't obscure scrollable content
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.toggle('has-audio-bar', !!currentAudio)
|
|
||||||
return () => document.body.classList.remove('has-audio-bar')
|
|
||||||
}, [currentAudio])
|
|
||||||
|
|
||||||
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
||||||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||||||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||||||
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
|
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
|
||||||
|
|
@ -408,34 +344,14 @@ export default function App() {
|
||||||
<ContextRail
|
<ContextRail
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
selectedEntry={selectedEntry}
|
selectedEntry={selectedEntry}
|
||||||
detail={entryDetail}
|
|
||||||
onTagFilterSet={handleTagFilterSet}
|
onTagFilterSet={handleTagFilterSet}
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
onTagsRefresh={handleTagsRefresh}
|
onTagsRefresh={handleTagsRefresh}
|
||||||
onEntryTitleChange={handleEntryTitleChange}
|
onEntryTitleChange={handleEntryTitleChange}
|
||||||
onEntryDeleted={handleEntryDeleted}
|
onEntryDeleted={handleEntryDeleted}
|
||||||
humanizeTags={humanizeTags}
|
humanizeTags={humanizeTags}
|
||||||
onDetailRefresh={handleDetailRefresh}
|
|
||||||
onOpenPreview={handleOpenPreview}
|
|
||||||
onPlay={handlePlay}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (
|
|
||||||
<PreviewModal
|
|
||||||
archiveId={archiveId}
|
|
||||||
entry={selectedEntry}
|
|
||||||
detail={entryDetail}
|
|
||||||
onClose={handleClosePreview}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{currentAudio && (
|
|
||||||
<AudioBar
|
|
||||||
entry={currentAudio.entry}
|
|
||||||
src={currentAudio.src}
|
|
||||||
archiveId={archiveId}
|
|
||||||
onClose={handleCloseAudio}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CaptureDialog
|
<CaptureDialog
|
||||||
open={captureDialogOpen}
|
open={captureDialogOpen}
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
|
|
|
||||||
|
|
@ -25,30 +25,6 @@ export async function fetchEntryDetail(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch multiple artifact JSON payloads for an entry in parallel.
|
|
||||||
// Returns a Promise<Array> preserving index order.
|
|
||||||
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
|
|
||||||
return Promise.all(
|
|
||||||
indices.map(idx =>
|
|
||||||
getJson(`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${idx}`)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve t.co short URLs server-side (HEAD→GET, no-follow).
|
|
||||||
// Returns a map { [tcoUrl]: expandedUrl }.
|
|
||||||
// Silently returns {} on failure — callers fall back to plain t.co links.
|
|
||||||
export async function resolveTcoUrls(urls) {
|
|
||||||
if (!urls || urls.length === 0) return {};
|
|
||||||
const res = await fetch('/api/util/resolve-tco', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(urls),
|
|
||||||
});
|
|
||||||
if (!res.ok) return {};
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEntryTitle(archiveId, entryUid, title) {
|
export async function updateEntryTitle(archiveId, entryUid, title) {
|
||||||
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}`, {
|
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { sourceIconSvg } from '../utils';
|
|
||||||
|
|
||||||
function formatTime(secs) {
|
|
||||||
if (!isFinite(secs) || isNaN(secs)) return '--:--';
|
|
||||||
const m = Math.floor(secs / 60);
|
|
||||||
const s = Math.floor(secs % 60);
|
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AudioBar({ entry, src, archiveId, onClose }) {
|
|
||||||
const audioRef = useRef(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(NaN);
|
|
||||||
const [volume, setVolume] = useState(1.0);
|
|
||||||
|
|
||||||
// Load and reset state whenever src changes — play only on explicit user action
|
|
||||||
useEffect(() => {
|
|
||||||
if (!audioRef.current || !src) return;
|
|
||||||
audioRef.current.load();
|
|
||||||
setCurrentTime(0);
|
|
||||||
setDuration(NaN);
|
|
||||||
setIsPlaying(false);
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
// Sync volume to audio element
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = volume;
|
|
||||||
}
|
|
||||||
}, [volume]);
|
|
||||||
|
|
||||||
function handlePlayPause() {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
if (isPlaying) {
|
|
||||||
audio.pause();
|
|
||||||
} else {
|
|
||||||
audio.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSeek(e) {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio || !isFinite(duration)) return;
|
|
||||||
const t = Number(e.target.value);
|
|
||||||
audio.currentTime = t;
|
|
||||||
setCurrentTime(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleVolumeChange(e) {
|
|
||||||
setVolume(Number(e.target.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = entry?.title || entry?.entry_uid || 'Unknown';
|
|
||||||
const kind = entry?.source_kind || 'other';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Hidden audio element */}
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={src || undefined}
|
|
||||||
preload="auto"
|
|
||||||
onPlay={() => setIsPlaying(true)}
|
|
||||||
onPause={() => setIsPlaying(false)}
|
|
||||||
onEnded={() => setIsPlaying(false)}
|
|
||||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
|
||||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? NaN)}
|
|
||||||
onDurationChange={() => setDuration(audioRef.current?.duration ?? NaN)}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Fixed bottom bar */}
|
|
||||||
<div
|
|
||||||
className="audio-bar"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 100,
|
|
||||||
background: 'var(--paper-3)',
|
|
||||||
borderTop: '1px solid var(--line)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '16px',
|
|
||||||
padding: '0 16px',
|
|
||||||
height: '56px',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left: icon + title */}
|
|
||||||
<div
|
|
||||||
className="audio-bar-info"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
minWidth: 0,
|
|
||||||
flex: '0 1 220px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="source-icon"
|
|
||||||
style={{ flexShrink: 0, width: '18px', height: '18px', display: 'flex', alignItems: 'center' }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: sourceIconSvg(kind) }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
color: 'var(--ink)',
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: play/pause + seek + time */}
|
|
||||||
<div
|
|
||||||
className="audio-bar-controls"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
flex: '1 1 0',
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handlePlayPause}
|
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
color: 'var(--ink)',
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPlaying ? '⏸' : '▶'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{formatTime(currentTime)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={isFinite(duration) ? duration : 0}
|
|
||||||
step={0.1}
|
|
||||||
value={isFinite(currentTime) ? currentTime : 0}
|
|
||||||
onChange={handleSeek}
|
|
||||||
aria-label="Seek"
|
|
||||||
style={{ flex: 1, minWidth: 0, accentColor: 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{formatTime(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: volume + close */}
|
|
||||||
<div
|
|
||||||
className="audio-bar-right"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
flex: '0 1 160px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '0.85rem', color: 'var(--muted)', flexShrink: 0 }}>🔊</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
aria-label="Volume"
|
|
||||||
style={{ width: '80px', accentColor: 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close audio player"
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
marginLeft: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
|
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||||
|
|
@ -11,7 +11,8 @@ const ExternalIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function ContextRail({ archiveId, selectedEntry, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) {
|
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags }) {
|
||||||
|
const [detail, setDetail] = useState(null)
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [assignInput, setAssignInput] = useState('')
|
const [assignInput, setAssignInput] = useState('')
|
||||||
const [entryCollections, setEntryCollections] = useState([])
|
const [entryCollections, setEntryCollections] = useState([])
|
||||||
|
|
@ -25,11 +26,11 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
const rearchivePollRef = useRef(null)
|
const rearchivePollRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const seq = ++selectSeqRef.current
|
|
||||||
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
|
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
|
||||||
setRearchiveState('idle')
|
setRearchiveState('idle')
|
||||||
setRearchiveError('')
|
setRearchiveError('')
|
||||||
if (!selectedEntry || !archiveId) {
|
if (!selectedEntry || !archiveId) {
|
||||||
|
setDetail(null)
|
||||||
setTags([])
|
setTags([])
|
||||||
setEntryCollections([])
|
setEntryCollections([])
|
||||||
return
|
return
|
||||||
|
|
@ -37,12 +38,16 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
setEditingTitle(false)
|
setEditingTitle(false)
|
||||||
setTitleDraft('')
|
setTitleDraft('')
|
||||||
titleCancelRef.current = false
|
titleCancelRef.current = false
|
||||||
|
const seq = ++selectSeqRef.current
|
||||||
|
setDetail(null)
|
||||||
setTags([])
|
setTags([])
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||||
listEntryCollections(archiveId, selectedEntry.entry_uid),
|
listEntryCollections(archiveId, selectedEntry.entry_uid),
|
||||||
]).then(([tgs, ecs]) => {
|
]).then(([det, tgs, ecs]) => {
|
||||||
if (seq !== selectSeqRef.current) return
|
if (seq !== selectSeqRef.current) return
|
||||||
|
setDetail(det)
|
||||||
setTags(tgs)
|
setTags(tgs)
|
||||||
setEntryCollections(ecs)
|
setEntryCollections(ecs)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
@ -58,6 +63,7 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
const newTitle = titleDraft.trim() || null
|
const newTitle = titleDraft.trim() || null
|
||||||
try {
|
try {
|
||||||
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
|
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
|
||||||
|
setDetail(prev => prev ? { ...prev, summary: { ...prev.summary, title: newTitle } } : prev)
|
||||||
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
|
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
|
||||||
} catch {
|
} catch {
|
||||||
// silently revert
|
// silently revert
|
||||||
|
|
@ -105,44 +111,39 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
|
|
||||||
async function handleRearchive() {
|
async function handleRearchive() {
|
||||||
if (!selectedEntry || !archiveId || rearchiveState === 'running') return
|
if (!selectedEntry || !archiveId || rearchiveState === 'running') return
|
||||||
// Capture identity at start so closure comparisons are stable
|
|
||||||
const startSeq = selectSeqRef.current
|
|
||||||
const entryUid = selectedEntry.entry_uid
|
|
||||||
setRearchiveState('running')
|
setRearchiveState('running')
|
||||||
setRearchiveError('')
|
setRearchiveError('')
|
||||||
try {
|
try {
|
||||||
const { job_uid } = await rearchiveEntry(archiveId, entryUid)
|
const { job_uid } = await rearchiveEntry(archiveId, selectedEntry.entry_uid)
|
||||||
// If selection changed while waiting for the kick-off response, bail.
|
// Poll for job completion at 500ms — same interval as CaptureDialog
|
||||||
if (selectSeqRef.current !== startSeq) return
|
|
||||||
rearchivePollRef.current = setInterval(async () => {
|
rearchivePollRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const job = await pollCaptureJob(archiveId, job_uid)
|
const job = await pollCaptureJob(archiveId, job_uid)
|
||||||
if (job.status === 'completed') {
|
if (job.status === 'completed') {
|
||||||
clearInterval(rearchivePollRef.current)
|
clearInterval(rearchivePollRef.current)
|
||||||
rearchivePollRef.current = null
|
rearchivePollRef.current = null
|
||||||
if (selectSeqRef.current !== startSeq) return
|
|
||||||
setRearchiveState('done')
|
setRearchiveState('done')
|
||||||
const updated = await fetchEntryTags(archiveId, entryUid)
|
// Refresh entry detail so artifact list updates
|
||||||
if (selectSeqRef.current !== startSeq) return
|
const [det, tgs] = await Promise.all([
|
||||||
setTags(updated)
|
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||||
onDetailRefresh?.()
|
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||||
|
])
|
||||||
|
setDetail(det)
|
||||||
|
setTags(tgs)
|
||||||
} else if (job.status === 'failed') {
|
} else if (job.status === 'failed') {
|
||||||
clearInterval(rearchivePollRef.current)
|
clearInterval(rearchivePollRef.current)
|
||||||
rearchivePollRef.current = null
|
rearchivePollRef.current = null
|
||||||
if (selectSeqRef.current !== startSeq) return
|
|
||||||
setRearchiveState('error')
|
setRearchiveState('error')
|
||||||
setRearchiveError(job.error_text || 'Re-archive failed.')
|
setRearchiveError(job.error_text || 'Re-archive failed.')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(rearchivePollRef.current)
|
clearInterval(rearchivePollRef.current)
|
||||||
rearchivePollRef.current = null
|
rearchivePollRef.current = null
|
||||||
if (selectSeqRef.current !== startSeq) return
|
|
||||||
setRearchiveState('error')
|
setRearchiveState('error')
|
||||||
setRearchiveError('Network error while polling.')
|
setRearchiveError('Network error while polling.')
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (selectSeqRef.current !== startSeq) return
|
|
||||||
setRearchiveState('error')
|
setRearchiveState('error')
|
||||||
setRearchiveError(e.message || 'Failed to start re-archive.')
|
setRearchiveError(e.message || 'Failed to start re-archive.')
|
||||||
}
|
}
|
||||||
|
|
@ -156,20 +157,6 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
['Root', detail.structured_root_relpath],
|
['Root', detail.structured_root_relpath],
|
||||||
] : []
|
] : []
|
||||||
|
|
||||||
const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac'])
|
|
||||||
const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp'])
|
|
||||||
const primaryMediaIdx = detail ? detail.artifacts.findIndex(a => a.artifact_role === 'primary_media') : -1
|
|
||||||
const primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null
|
|
||||||
const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : ''
|
|
||||||
const isAudio = primaryMedia && AUDIO_EXTS.has(pmExt)
|
|
||||||
const primaryMediaUrl = (primaryMediaIdx >= 0 && selectedEntry)
|
|
||||||
? `/api/archives/${archiveId}/entries/${selectedEntry.entry_uid}/artifacts/${primaryMediaIdx}`
|
|
||||||
: null
|
|
||||||
const isPreviewable = detail && !isAudio && (
|
|
||||||
(detail.summary.entity_kind === 'tweet' || detail.summary.entity_kind === 'tweet_thread') ||
|
|
||||||
(primaryMedia && PREVIEW_EXTS.has(pmExt))
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="context-rail">
|
<aside className="context-rail">
|
||||||
<div className="rail-eyebrow">Context</div>
|
<div className="rail-eyebrow">Context</div>
|
||||||
|
|
@ -221,17 +208,6 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAudio && onPlay && (
|
|
||||||
<button className="rail-preview-btn" onClick={() => onPlay(primaryMediaUrl, selectedEntry)}>
|
|
||||||
▶ Play
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isPreviewable && onOpenPreview && (
|
|
||||||
<button className="rail-preview-btn" onClick={onOpenPreview}>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="meta-list">
|
<div className="meta-list">
|
||||||
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
|
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
|
||||||
<div key={label} className="meta-item">
|
<div key={label} className="meta-item">
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
export default function IframePreview({ src, type, title, originalUrl }) {
|
|
||||||
const displayUrl = originalUrl || src;
|
|
||||||
const displayTitle = title || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="preview-iframe-wrap"
|
|
||||||
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="preview-iframe-toolbar"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '2px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderBottom: '1px solid var(--line-soft)',
|
|
||||||
background: 'var(--paper-2)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayTitle && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--ink)',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{displayTitle}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<span style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
}}>
|
|
||||||
{displayUrl}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={src}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{type === 'pdf' ? 'Open PDF ↗' : 'Open in new tab ↗'}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<iframe
|
|
||||||
src={src}
|
|
||||||
sandbox="allow-same-origin allow-popups"
|
|
||||||
allow="autoplay 'none'"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
style={{ flex: 1, border: 'none', width: '100%', minHeight: 0 }}
|
|
||||||
title={displayTitle || (type === 'pdf' ? 'PDF preview' : 'Page preview')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
export default function ImagePreview({ src, alt }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="preview-image-wrap"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
background: 'var(--paper-2)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a href={src} target="_blank" rel="noreferrer noopener" style={{ display: 'contents' }}>
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt || ''}
|
|
||||||
style={{
|
|
||||||
objectFit: 'contain',
|
|
||||||
maxHeight: '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
display: 'block',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { useEffect } from 'react'
|
|
||||||
import PreviewPanel from './PreviewPanel'
|
|
||||||
|
|
||||||
export default function PreviewModal({ archiveId, entry, detail, onClose }) {
|
|
||||||
// Close on Escape key
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
return () => window.removeEventListener('keydown', handler)
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="preview-modal-backdrop" onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className={`preview-modal${
|
|
||||||
detail?.summary?.entity_kind === 'tweet' ||
|
|
||||||
detail?.summary?.entity_kind === 'tweet_thread'
|
|
||||||
? ''
|
|
||||||
: ' preview-modal--full'
|
|
||||||
}`}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="preview-modal-header">
|
|
||||||
<span className="preview-modal-title">{entry?.title || entry?.entry_uid || 'Preview'}</span>
|
|
||||||
<a
|
|
||||||
className="preview-modal-newtab"
|
|
||||||
href={`/preview/${archiveId}/${entry?.entry_uid}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Open in new tab"
|
|
||||||
>↗</a>
|
|
||||||
<button className="preview-modal-close" onClick={onClose} aria-label="Close preview">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="preview-modal-body">
|
|
||||||
<PreviewPanel archiveId={archiveId} entry={entry} detail={detail} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { fetchEntryDetail } from '../api'
|
|
||||||
import PreviewPanel from './PreviewPanel'
|
|
||||||
|
|
||||||
// Standalone full-page preview — rendered when the URL is /preview/:archiveId/:entryUid.
|
|
||||||
// Auth uses the existing session cookie; no login flow needed here since the
|
|
||||||
// main app will have set one.
|
|
||||||
export default function PreviewPage({ archiveId, entryUid }) {
|
|
||||||
const [detail, setDetail] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchEntryDetail(archiveId, entryUid)
|
|
||||||
.then(d => { setDetail(d); setLoading(false) })
|
|
||||||
.catch(e => { setError(e?.message || 'Failed to load entry'); setLoading(false) })
|
|
||||||
}, [archiveId, entryUid])
|
|
||||||
|
|
||||||
const title = detail?.summary?.title || entryUid
|
|
||||||
const originalUrl = detail?.summary?.original_url
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'var(--paper)',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
}}>
|
|
||||||
{/* Minimal topbar */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderBottom: '1px solid var(--line)',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'var(--paper-2)',
|
|
||||||
}}>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
|
||||||
>← Archive</a>
|
|
||||||
<span style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--ink)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>{title}</span>
|
|
||||||
{originalUrl && (
|
|
||||||
<a
|
|
||||||
href={originalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
|
||||||
>Original ↗</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{loading && (
|
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--muted)', fontSize: '14px' }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--alert)', fontSize: '14px' }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{detail && (
|
|
||||||
<PreviewPanel
|
|
||||||
archiveId={archiveId}
|
|
||||||
entry={detail.summary}
|
|
||||||
detail={detail}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
import VideoPreview from './VideoPreview';
|
|
||||||
import IframePreview from './IframePreview';
|
|
||||||
import ImagePreview from './ImagePreview';
|
|
||||||
import TweetPreview from './TweetPreview';
|
|
||||||
|
|
||||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'mkv', 'avi', 'm4v', 'ogv']);
|
|
||||||
const AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
|
|
||||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
|
|
||||||
|
|
||||||
export default function PreviewPanel({ archiveId, entry, detail }) {
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel preview-panel--empty">
|
|
||||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
|
||||||
Select an entry to preview
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!detail) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel preview-panel--loading">
|
|
||||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
|
||||||
Loading…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { summary, artifacts } = detail;
|
|
||||||
const entryUid = summary.entry_uid;
|
|
||||||
const entityKind = summary.entity_kind;
|
|
||||||
|
|
||||||
// 1. Tweet / tweet thread
|
|
||||||
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
|
|
||||||
return (
|
|
||||||
<div className="preview-tweet-wrap">
|
|
||||||
<TweetPreview
|
|
||||||
archiveId={archiveId}
|
|
||||||
entryUid={entryUid}
|
|
||||||
artifacts={artifacts}
|
|
||||||
entityKind={entityKind}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Find primary_media artifact
|
|
||||||
const primaryMediaIndex = artifacts.findIndex(a => a.artifact_role === 'primary_media');
|
|
||||||
if (primaryMediaIndex === -1) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel preview-panel--no-preview">
|
|
||||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
|
||||||
No preview available
|
|
||||||
</span>
|
|
||||||
{artifacts.length > 0 && (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
paddingLeft: '20px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--muted-2)',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{artifacts.map((a, i) => (
|
|
||||||
<li key={i}>{a.artifact_role}: {a.relpath}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryArtifact = artifacts[primaryMediaIndex];
|
|
||||||
const primaryMediaUrl = `/api/archives/${archiveId}/entries/${entryUid}/artifacts/${primaryMediaIndex}`;
|
|
||||||
const ext = primaryArtifact.relpath.split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
// 3. Video
|
|
||||||
if (VIDEO_EXTS.has(ext)) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel">
|
|
||||||
<VideoPreview src={primaryMediaUrl} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Audio — inline player (AudioBar handles persistent playback via rail Play button)
|
|
||||||
if (AUDIO_EXTS.has(ext)) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel preview-panel--audio" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', padding: '24px', fontFamily: 'var(--sans)' }}>
|
|
||||||
<span style={{ fontSize: '2rem' }}>🎵</span>
|
|
||||||
<span style={{ color: 'var(--ink)', fontSize: '0.95rem', fontWeight: 600 }}>
|
|
||||||
{summary.title || entryUid}
|
|
||||||
</span>
|
|
||||||
<audio src={primaryMediaUrl} controls style={{ marginTop: '8px', width: '100%', maxWidth: '400px' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. PDF
|
|
||||||
if (ext === 'pdf') {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel" style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<IframePreview src={primaryMediaUrl} type="pdf"
|
|
||||||
title={summary.title} originalUrl={summary.original_url} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. HTML page
|
|
||||||
if (ext === 'html' || ext === 'htm') {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel" style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<IframePreview src={primaryMediaUrl} type="page"
|
|
||||||
title={summary.title} originalUrl={summary.original_url} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Image
|
|
||||||
if (IMAGE_EXTS.has(ext)) {
|
|
||||||
return (
|
|
||||||
<div className="preview-panel" style={{ height: '100%' }}>
|
|
||||||
<ImagePreview src={primaryMediaUrl} alt={summary.title || 'Image'} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Fallback
|
|
||||||
return (
|
|
||||||
<div className="preview-panel preview-panel--no-preview">
|
|
||||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
|
||||||
No preview available
|
|
||||||
</span>
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
paddingLeft: '20px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--muted-2)',
|
|
||||||
fontFamily: 'var(--sans)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{artifacts.map((a, i) => (
|
|
||||||
<li key={i}>{a.artifact_role}: {a.relpath}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +0,0 @@
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export default function VideoPreview({ src }) {
|
|
||||||
const videoRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.load();
|
|
||||||
}
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="preview-video-wrap"
|
|
||||||
style={{
|
|
||||||
background: '#111',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: '240px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
controls
|
|
||||||
autoPlay={false}
|
|
||||||
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
|
|
||||||
>
|
|
||||||
<source src={src} />
|
|
||||||
Your browser does not support the video element.
|
|
||||||
</video>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
|
||||||
No video available
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1901,553 +1901,3 @@ select {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════════
|
|
||||||
ENTRY PREVIEWS
|
|
||||||
════════════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.preview-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.preview-panel-loading,
|
|
||||||
.preview-panel-empty {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--muted-2);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Video preview ────────────────────────────────────────────────── */
|
|
||||||
.preview-video-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #0d0d0b;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
.preview-video-wrap video {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Iframe preview ───────────────────────────────────────────────── */
|
|
||||||
.preview-iframe-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.preview-iframe-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--paper-2);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* never uppercase the toolbar text */
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
}
|
|
||||||
.preview-iframe-toolbar span {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-2);
|
|
||||||
}
|
|
||||||
.preview-iframe-toolbar a {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--link);
|
|
||||||
font-size: 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--r2);
|
|
||||||
background: var(--field);
|
|
||||||
transition: border-color 0.12s;
|
|
||||||
}
|
|
||||||
.preview-iframe-toolbar a:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.preview-iframe-wrap iframe {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: white;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Image preview ────────────────────────────────────────────────── */
|
|
||||||
.preview-image-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--paper-2);
|
|
||||||
}
|
|
||||||
.preview-image-wrap img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: var(--r3);
|
|
||||||
cursor: zoom-in;
|
|
||||||
display: block;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tweet preview wrap ───────────────────────────────────────────── */
|
|
||||||
.preview-tweet-wrap {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--paper-2);
|
|
||||||
/* styled scrollbar matching the workspace rail */
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--line) transparent;
|
|
||||||
}
|
|
||||||
.preview-tweet-wrap::-webkit-scrollbar { width: 8px; }
|
|
||||||
.preview-tweet-wrap::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.preview-tweet-wrap::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
.preview-tweet-wrap::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--muted-2);
|
|
||||||
background-clip: padding-box;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tweet card ───────────────────────────────────────────────────── */
|
|
||||||
.tw-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--paper);
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto 12px;
|
|
||||||
}
|
|
||||||
.tw-thread {
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.tw-thread-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.tw-thread-connector {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 52px;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
.tw-thread-avatar-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tw-thread-line {
|
|
||||||
width: 2px;
|
|
||||||
background: var(--line);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 16px;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
.tw-thread-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 0 16px 12px;
|
|
||||||
}
|
|
||||||
.tw-thread-body:last-child { padding-bottom: 0; }
|
|
||||||
.tw-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.tw-avatar-ph {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--paper-2);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tw-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.tw-author-name { font-size: 14.5px; font-weight: 700; color: var(--ink); }
|
|
||||||
.tw-author-handle { font-size: 13px; color: var(--muted-2); }
|
|
||||||
.tw-dot { color: var(--muted-2); font-size: 13px; }
|
|
||||||
.tw-date { font-size: 13px; color: var(--muted-2); }
|
|
||||||
.tw-text {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--ink);
|
|
||||||
white-space: pre-line;
|
|
||||||
word-break: break-word;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.tw-text a { color: var(--link); text-decoration: none; }
|
|
||||||
.tw-text a:hover { text-decoration: underline; }
|
|
||||||
.tw-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
color: var(--muted-2);
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--line-soft);
|
|
||||||
}
|
|
||||||
.tw-stats span { display: flex; align-items: center; gap: 4px; }
|
|
||||||
|
|
||||||
/* ── X Article styles ─────────────────────────────────────────────── */
|
|
||||||
.tw-article {
|
|
||||||
max-width: 598px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.tw-article-cover {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
max-height: 380px;
|
|
||||||
}
|
|
||||||
.tw-article-meta { padding: 20px 16px 0; }
|
|
||||||
.tw-article-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
color: var(--ink);
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.tw-article-author-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.tw-article-author-name { font-size: 14px; font-weight: 700; color: var(--ink); }
|
|
||||||
.tw-article-author-sub { font-size: 13px; color: var(--muted-2); }
|
|
||||||
.tw-article-divider { border: none; border-top: 1px solid var(--line); margin: 0; }
|
|
||||||
.tw-article-body { padding: 8px 16px 60px; }
|
|
||||||
.tw-b-h1 {
|
|
||||||
font-size: 24px; font-weight: 800; letter-spacing: -0.3px;
|
|
||||||
color: var(--ink); line-height: 1.25; margin: 22px 0 10px;
|
|
||||||
}
|
|
||||||
.tw-b-h2 {
|
|
||||||
font-size: 19px; font-weight: 700; letter-spacing: -0.15px;
|
|
||||||
color: var(--ink); line-height: 1.3; margin: 20px 0 8px;
|
|
||||||
}
|
|
||||||
.tw-b-p { font-size: 16px; color: var(--ink); line-height: 1.72; margin-bottom: 14px; }
|
|
||||||
.tw-b-p:empty { display: none; }
|
|
||||||
.tw-b-spacer { height: 6px; display: block; }
|
|
||||||
.tw-b-blockquote {
|
|
||||||
border-left: 3px solid var(--line);
|
|
||||||
padding: 2px 14px;
|
|
||||||
margin: 14px 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
.tw-b-hr { border: none; border-top: 1px solid var(--line); margin: 24px 0; }
|
|
||||||
.tw-b-img { width: 100%; display: block; border-radius: 10px; margin: 14px 0; }
|
|
||||||
.tw-b-ul, .tw-b-ol { margin: 10px 0 14px; padding-left: 26px; }
|
|
||||||
.tw-b-li { font-size: 16px; color: var(--ink); line-height: 1.65; margin-bottom: 6px; }
|
|
||||||
.tw-b-code {
|
|
||||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
background: var(--paper-2);
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.tw-b-pre {
|
|
||||||
background: var(--paper-2);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
.tw-b-pre code {
|
|
||||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.tw-b-tweet-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin: 12px 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--paper-3);
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.tw-b-tweet-link:hover { background: var(--field); }
|
|
||||||
.tw-b-tweet-link svg { flex-shrink: 0; color: var(--ink); }
|
|
||||||
|
|
||||||
/* ── Audio bar ────────────────────────────────────────────────────── */
|
|
||||||
.audio-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 72px;
|
|
||||||
background: var(--paper);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 0 24px;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 -2px 16px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
.audio-bar-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 0 0 200px;
|
|
||||||
}
|
|
||||||
.audio-bar-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.audio-bar-icon svg { width: 20px; height: 20px; }
|
|
||||||
.audio-bar-title {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ink);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.audio-bar-controls {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.audio-bar-play-btn {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--paper);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.12s, transform 0.1s;
|
|
||||||
}
|
|
||||||
.audio-bar-play-btn:hover { background: var(--accent-2); }
|
|
||||||
.audio-bar-play-btn:active { transform: scale(0.93); }
|
|
||||||
.audio-bar-play-btn svg { width: 15px; height: 15px; }
|
|
||||||
.audio-bar-seek {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.audio-bar-seek input[type="range"] {
|
|
||||||
flex: 1;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
height: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.audio-bar-time {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--muted-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.audio-bar-right {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.audio-bar-volume {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.audio-bar-volume svg { width: 14px; height: 14px; color: var(--muted-2); flex-shrink: 0; }
|
|
||||||
.audio-bar-volume input[type="range"] {
|
|
||||||
width: 72px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.audio-bar-close {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted-2);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.audio-bar-close:hover { background: var(--field); color: var(--ink); }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Preview modal ────────────────────────────────────────────────── */
|
|
||||||
.preview-modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.preview-modal {
|
|
||||||
background: var(--paper);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
|
||||||
width: 90vw;
|
|
||||||
max-width: 1100px;
|
|
||||||
max-height: 88vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
/* Full-height variant for iframe/PDF/video/image (needs explicit height
|
|
||||||
so flex children like <iframe style="flex:1"> have a concrete container). */
|
|
||||||
.preview-modal--full {
|
|
||||||
height: 88vh;
|
|
||||||
}
|
|
||||||
.preview-modal--full .preview-modal-body {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
.preview-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.preview-modal-title {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.preview-modal-close {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted-2);
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.preview-modal-close:hover { background: var(--field); color: var(--ink); }
|
|
||||||
.preview-modal-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
/* overflow: hidden — each child (tweet-wrap, video-wrap, iframe, image)
|
|
||||||
manages its own scroll. This also prevents the spurious scrollbar that
|
|
||||||
appears when a video's native controls briefly expand the layout. */
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
/* Cap so long threads/articles stay scrollable even when modal has no
|
|
||||||
fixed height (shrinks for short tweets). 52px ≈ header padding+border. */
|
|
||||||
max-height: calc(88vh - 52px);
|
|
||||||
}
|
|
||||||
/* Preview button in context rail */
|
|
||||||
.rail-preview-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--paper);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--r3);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.rail-preview-btn:hover { background: var(--accent-2); }
|
|
||||||
/* Open-in-new-tab button in preview modal header */
|
|
||||||
.preview-modal-newtab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--r2);
|
|
||||||
color: var(--muted-2);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.preview-modal-newtab:hover { background: var(--field); color: var(--ink); }
|
|
||||||
/* Push content above the fixed AudioBar when it is visible */
|
|
||||||
body.has-audio-bar { padding-bottom: 56px; }
|
|
||||||
|
|
|
||||||
Binary file not shown.
23
vendor/twitter/scrape_user_tweet_contents.py
vendored
23
vendor/twitter/scrape_user_tweet_contents.py
vendored
|
|
@ -493,23 +493,14 @@ def extract_tweet_data(
|
||||||
# Extract is_quote_status (bare)
|
# Extract is_quote_status (bare)
|
||||||
tweet_data["is_quote_status"] = legacy.get("is_quote_status", False)
|
tweet_data["is_quote_status"] = legacy.get("is_quote_status", False)
|
||||||
|
|
||||||
# Extract entities - when note_tweet text is used, its entity_set has the
|
# Extract entities (always included)
|
||||||
# correct indices for that text; legacy.entities indices match legacy.full_text
|
entities = legacy.get("entities", {})
|
||||||
# (truncated) and will be wrong/missing for long tweets.
|
|
||||||
note_result = (
|
|
||||||
tweet_result.get("note_tweet", {})
|
|
||||||
.get("note_tweet_results", {})
|
|
||||||
.get("result", {})
|
|
||||||
)
|
|
||||||
note_ents = note_result.get("entity_set", {}) if note_tweet_text else {}
|
|
||||||
legacy_ents = legacy.get("entities", {})
|
|
||||||
tweet_data["entities"] = {
|
tweet_data["entities"] = {
|
||||||
"hashtags": note_ents.get("hashtags", legacy_ents.get("hashtags", [])),
|
"hashtags": entities.get("hashtags", []),
|
||||||
"urls": note_ents.get("urls", legacy_ents.get("urls", [])),
|
"urls": entities.get("urls", []),
|
||||||
"user_mentions": note_ents.get("user_mentions", legacy_ents.get("user_mentions", [])),
|
"user_mentions": entities.get("user_mentions", []),
|
||||||
"symbols": note_ents.get("symbols", legacy_ents.get("symbols", [])),
|
"symbols": entities.get("symbols", []),
|
||||||
# media always from legacy (note_tweet has no media downloads)
|
"media": entities.get("media", []) if not bare_scrape else [],
|
||||||
"media": legacy_ents.get("media", []) if not bare_scrape else [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract optional fields if not bare scrape
|
# Extract optional fields if not bare scrape
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue