mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
* feat: entry previews (video, tweet, article, iframe, image, audio bar)
- Add PreviewPanel dispatch hub routing by entity_kind + artifact extension
- VideoPreview: HTML5 <video> for YouTube/Instagram/TikTok/Reddit/X posts
- TweetPreview: tweet card, thread, and X article renderer (ported from x-article-renderer)
- IframePreview: sandboxed iframe for SingleFile web pages and PDFs
- ImagePreview: image viewer with click-to-open-fullsize
- AudioBar: persistent fixed-bottom player (Spotify-style) that survives entry navigation
- Lift entryDetail to App.jsx, shared between PreviewPanel and ContextRail
- 3-column layout (workspace | 300px preview | 340px rail) when preview active
- Stale-guard fixes: seq incremented before early returns in all async effects
- handleRearchive: capture startSeq/entryUid at call time, guard every async resume
- TweetPreview: reset loading/error/tweets before early-return branches
* feat: entry previews — tweet/thread/article/video/audio/image/iframe/pdf
- PreviewModal: modal overlay with new-tab link (↗) and keyboard close
- PreviewPanel: routes by entity_kind + primary_media extension to the
correct viewer (tweet/video/audio/pdf/html/image/fallback)
- TweetPreview: full X-style tweet, thread, and article renderer with
local artifact map for archived media (CDN fallback)
- AudioBar: persistent fixed bottom player, triggered via ContextRail
Play button; body.has-audio-bar pads content above it
- VideoPreview, IframePreview, ImagePreview: inline viewers
- PreviewPage: standalone /preview/:archiveId/:entryUid route
- ContextRail: Play/Preview buttons; isAudio/isPreviewable detection
- App.jsx: preview modal state, currentAudio state, preview route guard,
has-audio-bar body class effect
- routes.rs: CSP updated (media-src self blob https; frame-ancestors self;
Google Fonts + external images/scripts whitelisted)
- styles.css: preview modal, tweet-wrap scroll (min-height:0), audio bar
body padding, newtab button, preview panel flex layout
* style: tighten article/tweet preview spacing
- aMeta padding: 14px → 10px
- article title marginBottom: 10px → 8px
- aAuthorRow marginBottom: 10px → 8px
- bH1 top margin: 20px → 16px
- bH2 top margin: 18px → 14px
- bHr margin: 20px → 14px
- .preview-tweet-wrap padding: 20px → 12px (already committed)
More content visible above the fold in both modal and standalone views.
* feat: tweet/article preview quality pass
HTML entities: decode > < & etc. on sliced segments only
(entity offsets index the stored string; decoding before slicing shifts them)
Image lightbox: click any tweet/thread/article image to open full-screen
viewer; cmd+click follows <a> to open in new tab; arrow-key + ‹ › nav;
Escape closes; 1/N counter; ↗ open-in-new-tab link
Multi-image grid: 2 photos → side-by-side (180px rows); 3 → left spans
both rows; 4 → 2×2 (140px rows); single image unchanged
Empty media grid ghost: build photos/videoItems arrays first, render
.mediaGrid div only when at least one item resolved (advisory: never gate
on raw media.length when map items can all return null)
QT indicator: ↻ QT badge on tweet.is_quote_status === true entries
Modal shrinks for short content: height: 88vh → max-height: 88vh;
.preview-modal-body gets max-height: calc(88vh - 52px) so long threads
still scroll (advisory: don't rely on flex:1 once parent has no fixed height)
Video scrollbar leak: .preview-modal-body overflow: auto → hidden; each
child (tweet-wrap, video-wrap, iframe) manages its own scroll surface
Styled thin scrollbar on .preview-tweet-wrap (matches workspace rail)
ArticleRenderer: cover image and body images are lightbox-clickable;
opts thread through renderBlocksJSX → renderBlockJSX → renderAtomicJSX
* fix: move artifact fetch to api.js; stop Escape propagation from lightbox
- Export fetchEntryArtifacts(archiveId, entryUid, indices) from api.js
using Promise.all + getJson (follows project convention: all /api calls
go through api.js, never inline fetch in components)
- TweetPreview: import fetchEntryArtifacts, replace inline Promise.all
- MediaLightbox keydown handler: stopPropagation + preventDefault for
Escape/ArrowLeft/ArrowRight so the parent PreviewModal window listener
does not also fire and close the modal behind the lightbox
* fix: iframe/page preview height chain and toolbar UX
Problem: changing .preview-modal from height to max-height broke iframe
previews - <iframe style='flex:1'> needs a concrete ancestor height, which
max-height alone doesn't supply when content is shorter than the cap.
Fix - CSS:
.preview-modal--full { height: 88vh } applied to non-tweet modals
.preview-modal--full .preview-modal-body { max-height: none }
.preview-iframe-toolbar span: remove text-transform/letter-spacing
(was uppercasing the URL/title in shouty caps)
Fix - PreviewModal: className adds --full when entity_kind is not
tweet/tweet_thread; tweet previews keep shrink-to-fit behavior.
Fix - PreviewPanel: pass title + original_url from summary to IframePreview
for both HTML and PDF; wrappers use flex:1/minHeight:0 not height:100%.
Fix - IframePreview:
- Accept title + originalUrl props; show originalUrl in toolbar (falls
back to artifact src only when original_url absent); show title above
URL when available
- flex:1 + minHeight:0 instead of height:100% on the wrap div
- Single unified layout for page + pdf (both just show the iframe)
* feat: expand t.co links; linkify bare URLs in tweet and article text
Frontend:
- resolveEntityBounds: try multiple candidate strings in order (u.url
first, since that's the t.co short URL that appears in full_text)
- normalizeUrlAnn: multi-candidate search; href = expanded > url,
display = display_url > expanded > url
- linkifyText(): regex linkifier for entity-less bare URLs; trims
trailing punctuation [.,;:!?)] before linking; used in both
renderTweetTextJSX and renderInlineJSX including their early-return
paths (anns.length === 0) that previously bypassed linkification
- renderInlineJSX: fix mention href mention.name → screen_name;
replace t.co segment text with url.display when entity covers it
Scraper (vendor/twitter/scrape_user_tweet_contents.py):
- extract_tweet_data: when note_tweet text is used, pull urls/mentions/
hashtags/symbols from note_result.entity_set (correct indices for the
note text); keep media from legacy.entities (no note media downloads)
* feat: server-side t.co resolver + frontend augmentation
Server (routes.rs):
POST /api/util/resolve-tco — unauthenticated, accepts JSON array of
https://t.co/<alphanumeric> URLs only (strict regex validation, no SSRF
via input), capped at 50 per batch, 3 s timeout, redirect(Policy::none)
so the server only ever touches t.co itself. HEAD first, GET fallback if
HEAD returns no Location. Location sanitized to http/https only —
javascript:/data:/etc. fall back to the original t.co.
api.js:
resolveTcoUrls(urls) — project-convention wrapper for the new endpoint.
Returns {} on failure (callers degrade gracefully to bare t.co links).
TweetPreview.jsx:
After tweet data loads, per-tweet range-based coverage detection:
builds covered [start,end) from existing entity fromIndex/toIndex or
indices fallback, then finds regex matches whose span is NOT covered.
Resolves unique uncovered t.co URLs via resolveTcoUrls(), synthesises
one entity per occurrence (with exact fromIndex/toIndex so normalizeUrlAnn
gets correct bounds even for duplicate t.co URLs in the same tweet).
Augments entities.urls before setTweets() so all rendering paths
see expanded URLs.
* feat: suppress rendered media attachment URLs from tweet text
renderTweetTextJSX now accepts skipSpans=[] as third param.
Skip-span boundaries are added to the pts split set so a trailing
media t.co inside a plain segment still gets isolated and suppressed—
not re-linked by linkifyText. Early return only when both anns and
skipSpans are empty.
TweetCard computes mediaSkipSpans after building photos/videoItems:
for each rawMedia item whose src resolved (photo src match; any video),
resolveEntityBounds(m, ft, m.url) gives the precise [s,e] span using
indices/fromIndex first, indexOf fallback—then the span is passed to
renderTweetTextJSX so the t.co attachment URL is silently dropped.
153 lines
4.6 KiB
JavaScript
153 lines
4.6 KiB
JavaScript
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>
|
|
);
|
|
}
|