1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

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
This commit is contained in:
TheGeneralist 2026-07-11 19:45:13 +02:00
parent 5de9e291b0
commit 8755e2c503
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
14 changed files with 574 additions and 298 deletions

View file

@ -91,36 +91,61 @@ 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(
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'; \
img-src 'self' data: blob:; \
font-src 'self'; \
connect-src 'self'; \
frame-ancestors 'none'",
),
);
headers.insert( headers.insert(
axum::http::header::HeaderName::from_static("permissions-policy"), axum::http::header::HeaderName::from_static("permissions-policy"),
axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=(), autoplay=()"),
); );
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
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-BlLU3Wcz.js"></script> <script type="module" crossorigin src="/assets/index-DekfKifP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEaPUEBm.css"> <link rel="stylesheet" crossorigin href="/assets/index-Czw8P_Ze.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -12,13 +12,20 @@ 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 PreviewPanel from './components/PreviewPanel' import PreviewModal from './components/PreviewModal'
import AudioBar from './components/AudioBar' 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']
@ -96,8 +103,6 @@ export default function App() {
const [entryDetail, setEntryDetail] = useState(null) const [entryDetail, setEntryDetail] = useState(null)
const detailSeqRef = useRef(0) const detailSeqRef = useRef(0)
// Persistent audio bar state
const [currentAudio, setCurrentAudio] = useState(null) // { entry, src, archiveId }
const humanizeTags = currentUser?.humanize_slugs ?? false; const humanizeTags = currentUser?.humanize_slugs ?? false;
// Fetch entry detail whenever selected entry changes // Fetch entry detail whenever selected entry changes
@ -192,7 +197,9 @@ 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)
@ -292,31 +299,33 @@ 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 handleSetAudio = useCallback(({ entry, src, archiveId: aid }) => { const handleOpenPreview = useCallback(() => {
setCurrentAudio({ entry, src, archiveId: aid }) 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), [])
const handleCloseAudio = useCallback(() => { // Close stale modal when selection changes (audio persists intentionally)
setCurrentAudio(null) useEffect(() => {
}, []) setPreviewEntryUid(null)
}, [selectedEntry])
// Compute whether the selected entry has a previewable artifact // Toggle body class so fixed AudioBar doesn't obscure scrollable content
const VIDEO_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv']) useEffect(() => {
const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac']) document.body.classList.toggle('has-audio-bar', !!currentAudio)
const PREVIEW_EXTS = new Set([...VIDEO_EXTS, ...AUDIO_EXTS, 'pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp']) return () => document.body.classList.remove('has-audio-bar')
const hasPreview = view === 'archive' && selectedEntry && (() => { }, [currentAudio])
if (selectedEntry.entity_kind === 'tweet' || selectedEntry.entity_kind === 'tweet_thread') return true
if (!entryDetail) return false
const pm = entryDetail.artifacts.find(a => a.artifact_role === 'primary_media')
if (!pm) return false
const ext = pm.relpath.split('.').pop().toLowerCase()
return PREVIEW_EXTS.has(ext)
})()
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 }}>
@ -329,7 +338,7 @@ export default function App() {
onViewChange={handleViewChange} onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick} onCaptureClick={handleCaptureClick}
/> />
<main className={`app-shell${hasPreview ? ' has-preview' : ''}${currentAudio ? ' audio-playing' : ''}`}> <main className="app-shell">
<div className="workspace"> <div className="workspace">
{view === 'archive' && ( {view === 'archive' && (
<div className="toolbar"> <div className="toolbar">
@ -396,16 +405,6 @@ export default function App() {
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} /> <SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} />
)} )}
</div> </div>
{hasPreview && (
<div className="preview-pane">
<PreviewPanel
archiveId={archiveId}
entry={selectedEntry}
detail={entryDetail}
onSetAudio={handleSetAudio}
/>
</div>
)}
<ContextRail <ContextRail
archiveId={archiveId} archiveId={archiveId}
selectedEntry={selectedEntry} selectedEntry={selectedEntry}
@ -417,13 +416,23 @@ export default function App() {
onEntryDeleted={handleEntryDeleted} onEntryDeleted={handleEntryDeleted}
humanizeTags={humanizeTags} humanizeTags={humanizeTags}
onDetailRefresh={handleDetailRefresh} onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview}
onPlay={handlePlay}
/> />
</main> </main>
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (
<PreviewModal
archiveId={archiveId}
entry={selectedEntry}
detail={entryDetail}
onClose={handleClosePreview}
/>
)}
{currentAudio && ( {currentAudio && (
<AudioBar <AudioBar
entry={currentAudio.entry} entry={currentAudio.entry}
src={currentAudio.src} src={currentAudio.src}
archiveId={currentAudio.archiveId} archiveId={archiveId}
onClose={handleCloseAudio} onClose={handleCloseAudio}
/> />
)} )}

View file

@ -15,18 +15,13 @@ export default function AudioBar({ entry, src, archiveId, onClose }) {
const [duration, setDuration] = useState(NaN); const [duration, setDuration] = useState(NaN);
const [volume, setVolume] = useState(1.0); const [volume, setVolume] = useState(1.0);
// Auto-play whenever src changes // Load and reset state whenever src changes play only on explicit user action
useEffect(() => { useEffect(() => {
if (!audioRef.current || !src) return; if (!audioRef.current || !src) return;
audioRef.current.load(); audioRef.current.load();
audioRef.current.play().then(() => {
setIsPlaying(true);
}).catch(() => {
// Autoplay blocked silently remain paused
setIsPlaying(false);
});
setCurrentTime(0); setCurrentTime(0);
setDuration(NaN); setDuration(NaN);
setIsPlaying(false);
}, [src]); }, [src]);
// Sync volume to audio element // Sync volume to audio element

View file

@ -11,7 +11,7 @@ const ExternalIcon = () => (
</svg> </svg>
) )
export default function ContextRail({ archiveId, selectedEntry, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags, onDetailRefresh }) { export default function ContextRail({ archiveId, selectedEntry, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) {
const [tags, setTags] = useState([]) const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('') const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([]) const [entryCollections, setEntryCollections] = useState([])
@ -156,6 +156,20 @@ 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>
@ -207,6 +221,17 @@ 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">

View file

@ -48,7 +48,8 @@ export default function IframePreview({ src, type }) {
</div> </div>
<iframe <iframe
src={src} src={src}
sandbox="allow-scripts allow-same-origin allow-popups" sandbox="allow-same-origin allow-popups"
allow="autoplay 'none'"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
style={{ flex: 1, border: 'none', width: '100%' }} style={{ flex: 1, border: 'none', width: '100%' }}
title="Page preview" title="Page preview"
@ -71,6 +72,7 @@ export default function IframePreview({ src, type }) {
</div> </div>
<iframe <iframe
src={src} src={src}
allow="autoplay 'none'"
style={{ flex: 1, border: 'none', width: '100%' }} style={{ flex: 1, border: 'none', width: '100%' }}
title="PDF preview" title="PDF preview"
/> />

View file

@ -0,0 +1,32 @@
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" 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>
)
}

View file

@ -0,0 +1,85 @@
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>
)
}

View file

@ -1,4 +1,3 @@
import { useEffect, useMemo } from 'react';
import VideoPreview from './VideoPreview'; import VideoPreview from './VideoPreview';
import IframePreview from './IframePreview'; import IframePreview from './IframePreview';
import ImagePreview from './ImagePreview'; import ImagePreview from './ImagePreview';
@ -8,21 +7,7 @@ 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 AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']); const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
export default function PreviewPanel({ archiveId, entry, detail, onSetAudio }) { export default function PreviewPanel({ archiveId, entry, detail }) {
// Compute audio URL upfront so useEffect can depend on it
const audioInfo = useMemo(() => {
if (!detail || !entry) return null;
const { artifacts, summary } = detail;
const idx = artifacts.findIndex(a => a.artifact_role === 'primary_media');
if (idx === -1) return null;
const ext = artifacts[idx].relpath.split('.').pop().toLowerCase();
if (!AUDIO_EXTS.has(ext)) return null;
return { entry, src: `/api/archives/${archiveId}/entries/${summary.entry_uid}/artifacts/${idx}`, archiveId };
}, [detail, entry, archiveId]);
useEffect(() => {
if (audioInfo && onSetAudio) onSetAudio(audioInfo);
}, [audioInfo]);
if (!entry) { if (!entry) {
return ( return (
@ -51,7 +36,7 @@ export default function PreviewPanel({ archiveId, entry, detail, onSetAudio }) {
// 1. Tweet / tweet thread // 1. Tweet / tweet thread
if (entityKind === 'tweet' || entityKind === 'tweet_thread') { if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
return ( return (
<div className="preview-panel"> <div className="preview-tweet-wrap">
<TweetPreview <TweetPreview
archiveId={archiveId} archiveId={archiveId}
entryUid={entryUid} entryUid={entryUid}
@ -102,33 +87,15 @@ export default function PreviewPanel({ archiveId, entry, detail, onSetAudio }) {
); );
} }
// 4. Audio set audio bar and also show inline player + message // 4. Audio inline player (AudioBar handles persistent playback via rail Play button)
if (AUDIO_EXTS.has(ext)) { if (AUDIO_EXTS.has(ext)) {
return ( return (
<div <div className="preview-panel preview-panel--audio" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', padding: '24px', fontFamily: 'var(--sans)' }}>
className="preview-panel preview-panel--audio" <span style={{ fontSize: '2rem' }}>🎵</span>
style={{ <span style={{ color: 'var(--ink)', fontSize: '0.95rem', fontWeight: 600 }}>
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '24px',
fontFamily: 'var(--sans)',
}}
>
<span style={{ fontSize: '2.5rem' }}>🎵</span>
<span style={{ color: 'var(--ink)', fontSize: '1rem', fontWeight: 600 }}>
{summary.title || entryUid} {summary.title || entryUid}
</span> </span>
<span style={{ color: 'var(--muted)', fontSize: '0.85rem' }}> <audio src={primaryMediaUrl} controls style={{ marginTop: '8px', width: '100%', maxWidth: '400px' }} />
Playing in audio bar below
</span>
<audio
src={primaryMediaUrl}
controls
style={{ marginTop: '12px', width: '100%', maxWidth: '400px' }}
/>
</div> </div>
); );
} }

View file

@ -20,7 +20,7 @@ const S = {
border: '1px solid var(--line)', border: '1px solid var(--line)',
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'hidden',
maxWidth: '598px', maxWidth: '560px',
margin: '0 auto', margin: '0 auto',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
}, },
@ -28,44 +28,44 @@ const S = {
border: '1px solid var(--line)', border: '1px solid var(--line)',
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'hidden',
maxWidth: '598px', maxWidth: '560px',
margin: '0 auto', margin: '0 auto',
background: 'var(--paper)', background: 'var(--paper)',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
}, },
tweetRow: { tweetRow: {
display: 'flex', display: 'flex',
gap: '12px', gap: '10px',
padding: '16px', padding: '10px 12px',
}, },
tweetRowThread: { tweetRowThread: {
display: 'flex', display: 'flex',
gap: '12px', gap: '10px',
padding: '16px 16px 0', padding: '10px 12px 0',
}, },
leftCol: { leftCol: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
flexShrink: 0, flexShrink: 0,
width: '40px', width: '36px',
}, },
rightCol: { rightCol: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
paddingBottom: '16px', paddingBottom: '8px',
}, },
avatar: { avatar: {
width: '40px', width: '36px',
height: '40px', height: '36px',
borderRadius: '50%', borderRadius: '50%',
objectFit: 'cover', objectFit: 'cover',
flexShrink: 0, flexShrink: 0,
display: 'block', display: 'block',
}, },
avatarPh: { avatarPh: {
width: '40px', width: '36px',
height: '40px', height: '36px',
borderRadius: '50%', borderRadius: '50%',
background: 'var(--line)', background: 'var(--line)',
flexShrink: 0, flexShrink: 0,
@ -75,7 +75,7 @@ const S = {
width: '2px', width: '2px',
background: 'var(--line-soft, var(--line))', background: 'var(--line-soft, var(--line))',
margin: '4px 0', margin: '4px 0',
minHeight: '16px', minHeight: '12px',
borderRadius: '1px', borderRadius: '1px',
}, },
authorRow: { authorRow: {
@ -83,34 +83,34 @@ const S = {
alignItems: 'baseline', alignItems: 'baseline',
gap: '4px', gap: '4px',
flexWrap: 'wrap', flexWrap: 'wrap',
marginBottom: '6px', marginBottom: '4px',
lineHeight: '1.3', lineHeight: '1.3',
}, },
authorName: { authorName: {
fontWeight: '700', fontWeight: '700',
fontSize: '15px', fontSize: '14px',
color: 'var(--ink)', color: 'var(--ink)',
}, },
authorHandle: { authorHandle: {
fontSize: '14px', fontSize: '13px',
color: 'var(--muted)', color: 'var(--muted)',
}, },
datePart: { datePart: {
fontSize: '14px', fontSize: '13px',
color: 'var(--muted)', color: 'var(--muted)',
}, },
tweetText: { tweetText: {
fontSize: '15px', fontSize: '14px',
lineHeight: '1.6', lineHeight: '1.5',
color: 'var(--ink)', color: 'var(--ink)',
whiteSpace: 'pre-line', whiteSpace: 'pre-line',
marginBottom: '10px', marginBottom: '8px',
wordBreak: 'break-word', wordBreak: 'break-word',
}, },
stats: { stats: {
display: 'flex', display: 'flex',
gap: '16px', gap: '12px',
fontSize: '14px', fontSize: '13px',
color: 'var(--muted)', color: 'var(--muted)',
}, },
link: { link: {
@ -118,20 +118,38 @@ const S = {
textDecoration: 'none', textDecoration: 'none',
}, },
loading: { loading: {
padding: '32px 16px', padding: '24px 16px',
textAlign: 'center', textAlign: 'center',
color: 'var(--muted)', color: 'var(--muted)',
fontSize: '15px', fontSize: '14px',
}, },
error: { error: {
padding: '32px 16px', padding: '24px 16px',
textAlign: 'center', textAlign: 'center',
color: 'var(--alert)', color: 'var(--alert)',
fontSize: '15px', fontSize: '14px',
},
mediaGrid: {
marginBottom: '8px',
borderRadius: '10px',
overflow: 'hidden',
border: '1px solid var(--line)',
},
mediaImg: {
display: 'block',
width: '100%',
objectFit: 'cover',
maxHeight: '260px',
},
mediaVideo: {
display: 'block',
width: '100%',
maxHeight: '260px',
background: '#000',
}, },
// Article // Article
article: { article: {
maxWidth: '598px', maxWidth: '560px',
margin: '0 auto', margin: '0 auto',
border: '1px solid var(--line)', border: '1px solid var(--line)',
borderRadius: '12px', borderRadius: '12px',
@ -143,48 +161,48 @@ const S = {
width: '100%', width: '100%',
display: 'block', display: 'block',
objectFit: 'cover', objectFit: 'cover',
maxHeight: '420px', maxHeight: '280px',
}, },
aMeta: { aMeta: {
padding: '20px 16px 0', padding: '14px 14px 0',
}, },
aTweetTitle: { aTweetTitle: {
fontSize: '23px', fontSize: '20px',
fontWeight: '800', fontWeight: '800',
letterSpacing: '-0.3px', letterSpacing: '-0.3px',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.3', lineHeight: '1.3',
marginBottom: '16px', marginBottom: '10px',
}, },
aAuthorRow: { aAuthorRow: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '8px',
marginBottom: '16px', marginBottom: '10px',
}, },
aAvatar: { aAvatar: {
width: '40px', width: '36px',
height: '40px', height: '36px',
borderRadius: '50%', borderRadius: '50%',
objectFit: 'cover', objectFit: 'cover',
flexShrink: 0, flexShrink: 0,
display: 'block', display: 'block',
}, },
aAvatarPh: { aAvatarPh: {
width: '40px', width: '36px',
height: '40px', height: '36px',
borderRadius: '50%', borderRadius: '50%',
background: 'var(--line)', background: 'var(--line)',
flexShrink: 0, flexShrink: 0,
}, },
aAuthorName: { aAuthorName: {
fontSize: '15px', fontSize: '14px',
fontWeight: '700', fontWeight: '700',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.3', lineHeight: '1.3',
}, },
aAuthorSub: { aAuthorSub: {
fontSize: '15px', fontSize: '13px',
color: 'var(--muted)', color: 'var(--muted)',
lineHeight: '1.3', lineHeight: '1.3',
}, },
@ -194,92 +212,92 @@ const S = {
margin: '0', margin: '0',
}, },
aBody: { aBody: {
padding: '4px 16px 24px', padding: '4px 14px 16px',
}, },
// Article blocks // Article blocks
bH1: { bH1: {
fontSize: '26px', fontSize: '22px',
fontWeight: '800', fontWeight: '800',
letterSpacing: '-0.4px', letterSpacing: '-0.4px',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.25', lineHeight: '1.25',
margin: '24px 0 10px', margin: '20px 0 8px',
}, },
bH2: { bH2: {
fontSize: '20px', fontSize: '18px',
fontWeight: '700', fontWeight: '700',
letterSpacing: '-0.2px', letterSpacing: '-0.2px',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.3', lineHeight: '1.3',
margin: '22px 0 8px', margin: '18px 0 6px',
}, },
bP: { bP: {
fontSize: '17px', fontSize: '15px',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.7', lineHeight: '1.65',
marginBottom: '16px', marginBottom: '12px',
marginTop: '0', marginTop: '0',
}, },
bSpacer: { bSpacer: {
height: '6px', height: '4px',
display: 'block', display: 'block',
}, },
bQuote: { bQuote: {
borderLeft: '3px solid var(--line)', borderLeft: '3px solid var(--line)',
padding: '2px 14px', padding: '2px 12px',
margin: '14px 0', margin: '12px 0',
color: 'var(--muted)', color: 'var(--muted)',
fontSize: '17px', fontSize: '15px',
lineHeight: '1.65', lineHeight: '1.6',
}, },
bHr: { bHr: {
border: 'none', border: 'none',
borderTop: '1px solid var(--line)', borderTop: '1px solid var(--line)',
margin: '28px 0', margin: '20px 0',
}, },
bImg: { bImg: {
width: '100%', width: '100%',
display: 'block', display: 'block',
borderRadius: '12px', borderRadius: '8px',
margin: '16px 0', margin: '12px 0',
}, },
bUl: { bUl: {
margin: '10px 0 16px', margin: '8px 0 12px',
paddingLeft: '28px', paddingLeft: '24px',
}, },
bOl: { bOl: {
margin: '10px 0 16px', margin: '8px 0 12px',
paddingLeft: '28px', paddingLeft: '24px',
}, },
bLi: { bLi: {
fontSize: '17px', fontSize: '15px',
color: 'var(--ink)', color: 'var(--ink)',
lineHeight: '1.65', lineHeight: '1.6',
marginBottom: '6px', marginBottom: '4px',
}, },
bTweet: { bTweet: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '10px',
border: '1px solid var(--line)', border: '1px solid var(--line)',
borderRadius: '12px', borderRadius: '10px',
padding: '14px 16px', padding: '10px 14px',
margin: '14px 0', margin: '12px 0',
color: 'var(--muted)', color: 'var(--muted)',
fontSize: '15px', fontSize: '14px',
textDecoration: 'none', textDecoration: 'none',
}, },
bMdPre: { bMdPre: {
borderRadius: '8px', borderRadius: '8px',
margin: '12px 0', margin: '10px 0',
overflow: 'auto', overflow: 'auto',
background: 'var(--paper-3, var(--field))', background: 'var(--paper-3, var(--field))',
padding: '14px 16px', padding: '12px 14px',
}, },
bMdCode: { bMdCode: {
fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace", fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: '13px', fontSize: '12px',
lineHeight: '1.65', lineHeight: '1.6',
color: 'var(--ink)', color: 'var(--ink)',
background: 'transparent', background: 'transparent',
display: 'block', display: 'block',
@ -288,12 +306,33 @@ const S = {
fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace", fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",
fontSize: '0.875em', fontSize: '0.875em',
background: 'var(--paper-3, var(--field))', background: 'var(--paper-3, var(--field))',
padding: '2px 6px', padding: '1px 5px',
borderRadius: '4px', borderRadius: '4px',
color: 'var(--ink)', color: 'var(--ink)',
}, },
}; };
// Artifact URL helpers
// Build relpath artifact API URL map from the artifacts array so archived
// copies are preferred over CDN URLs (avatars, media, cover images).
function buildArtifactMap(archiveId, entryUid, artifacts) {
const map = {};
if (!artifacts) return map;
artifacts.forEach((a, idx) => {
if (a.relpath) {
map[a.relpath] = `/api/archives/${archiveId}/entries/${entryUid}/artifacts/${idx}`;
}
});
return map;
}
// Return the archived artifact URL if available, otherwise fall back to cdnUrl.
function resolveUrl(localPath, cdnUrl, artifactMap) {
if (localPath && artifactMap[localPath]) return artifactMap[localPath];
return cdnUrl || null;
}
// SVG X logo // SVG X logo
function XLogo() { function XLogo() {
@ -473,7 +512,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions) {
// Article atomic block renderer // Article atomic block renderer
// Port of renderAtomic() from x-article-renderer. // Port of renderAtomic() from x-article-renderer.
function renderAtomicJSX(block) { function renderAtomicJSX(block, artifactMap) {
const entities = block.resolved_entities || []; const entities = block.resolved_entities || [];
if (entities.length === 0) return null; if (entities.length === 0) return null;
@ -482,10 +521,10 @@ function renderAtomicJSX(block) {
case 'divider': case 'divider':
return <hr key={i} style={S.bHr} />; return <hr key={i} style={S.bHr} />;
case 'media': case 'media': {
return e.url const src = resolveUrl(e.local_path, e.url, artifactMap);
? <img key={i} src={e.url} style={S.bImg} loading="lazy" alt="" /> return src ? <img key={i} src={src} style={S.bImg} loading="lazy" alt="" /> : null;
: null; }
case 'tweet': case 'tweet':
return e.tweet_id ? ( return e.tweet_id ? (
@ -538,7 +577,7 @@ function renderAtomicJSX(block) {
// Article single block renderer // Article single block renderer
// Port of renderBlock() from x-article-renderer. // Port of renderBlock() from x-article-renderer.
function renderBlockJSX(block, key) { function renderBlockJSX(block, key, artifactMap) {
const type = block.type || ''; const type = block.type || '';
const text = block.text || ''; const text = block.text || '';
const styleRanges = block.inline_style_ranges || []; const styleRanges = block.inline_style_ranges || [];
@ -550,7 +589,6 @@ function renderBlockJSX(block, key) {
return <h1 key={key} style={S.bH1}>{inner}</h1>; return <h1 key={key} style={S.bH1}>{inner}</h1>;
case 'header-two': { case 'header-two': {
// X's editor sometimes stores a tweet URL as the sole content of an H2.
const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/); const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);
if (m) { if (m) {
return ( return (
@ -576,13 +614,12 @@ function renderBlockJSX(block, key) {
case 'blockquote': case 'blockquote':
return <blockquote key={key} style={S.bQuote}>{inner}</blockquote>; return <blockquote key={key} style={S.bQuote}>{inner}</blockquote>;
// List items the wrapper ul/ol is added by renderBlocksJSX.
case 'unordered-list-item': case 'unordered-list-item':
case 'ordered-list-item': case 'ordered-list-item':
return <li key={key} style={S.bLi}>{inner}</li>; return <li key={key} style={S.bLi}>{inner}</li>;
case 'atomic': case 'atomic':
return <span key={key}>{renderAtomicJSX(block)}</span>; return <span key={key}>{renderAtomicJSX(block, artifactMap)}</span>;
default: default:
return text ? <p key={key} style={S.bP}>{inner}</p> : null; return text ? <p key={key} style={S.bP}>{inner}</p> : null;
@ -593,7 +630,7 @@ function renderBlockJSX(block, key) {
// Port of renderBlocks() from x-article-renderer. // Port of renderBlocks() from x-article-renderer.
// Groups consecutive same-type list items into a single ul/ol. // Groups consecutive same-type list items into a single ul/ol.
function renderBlocksJSX(blocks) { function renderBlocksJSX(blocks, artifactMap) {
const items = []; const items = [];
let i = 0; let i = 0;
@ -604,7 +641,7 @@ function renderBlocksJSX(blocks) {
const startIdx = i; const startIdx = i;
const listItems = []; const listItems = [];
while (i < blocks.length && blocks[i].type === 'unordered-list-item') { while (i < blocks.length && blocks[i].type === 'unordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i)); listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
i++; i++;
} }
items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>); items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
@ -612,12 +649,12 @@ function renderBlocksJSX(blocks) {
const startIdx = i; const startIdx = i;
const listItems = []; const listItems = [];
while (i < blocks.length && blocks[i].type === 'ordered-list-item') { while (i < blocks.length && blocks[i].type === 'ordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i)); listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
i++; i++;
} }
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>); items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
} else { } else {
items.push(renderBlockJSX(b, i)); items.push(renderBlockJSX(b, i, artifactMap));
i++; i++;
} }
} }
@ -627,9 +664,8 @@ function renderBlocksJSX(blocks) {
// Article renderer // Article renderer
function ArticleRenderer({ article, tweetAuthor }) { function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
const cover = article.cover_media || {}; const cover = article.cover_media || {};
// article.author is preferred; fall back to the tweet-level author.
const author = article.author || tweetAuthor || {}; const author = article.author || tweetAuthor || {};
const date = article.first_published_at_secs const date = article.first_published_at_secs
? fmtDate(article.first_published_at_secs) ? fmtDate(article.first_published_at_secs)
@ -637,18 +673,21 @@ function ArticleRenderer({ article, tweetAuthor }) {
const handlePart = author.screen_name ? `@${author.screen_name}` : ''; const handlePart = author.screen_name ? `@${author.screen_name}` : '';
const subLine = [handlePart, date].filter(Boolean).join(' · '); const subLine = [handlePart, date].filter(Boolean).join(' · ');
const coverSrc = resolveUrl(cover.local_path, cover.url, artifactMap);
const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
return ( return (
<div style={S.article}> <div style={S.article}>
{cover.url && ( {coverSrc && (
<img src={cover.url} style={S.aCover} alt="Article cover" /> <img src={coverSrc} style={S.aCover} alt="Article cover" />
)} )}
<div style={S.aMeta}> <div style={S.aMeta}>
{article.title && ( {article.title && (
<div style={S.aTweetTitle}>{article.title}</div> <div style={S.aTweetTitle}>{article.title}</div>
)} )}
<div style={S.aAuthorRow}> <div style={S.aAuthorRow}>
{author.avatar_url {avatarSrc
? <img src={author.avatar_url} style={S.aAvatar} alt={author.name || ''} /> ? <img src={avatarSrc} style={S.aAvatar} alt={author.name || ''} />
: <div style={S.aAvatarPh} /> : <div style={S.aAvatarPh} />
} }
<div> <div>
@ -663,7 +702,7 @@ function ArticleRenderer({ article, tweetAuthor }) {
</div> </div>
<hr style={S.aDivider} /> <hr style={S.aDivider} />
<div style={S.aBody}> <div style={S.aBody}>
{renderBlocksJSX(article.blocks || [])} {renderBlocksJSX(article.blocks || [], artifactMap)}
</div> </div>
</div> </div>
); );
@ -673,27 +712,30 @@ function ArticleRenderer({ article, tweetAuthor }) {
// Renders one tweet. When isInThread, omits bottom padding from the row and // Renders one tweet. When isInThread, omits bottom padding from the row and
// shows a thread connector line below the avatar (except on the last card). // shows a thread connector line below the avatar (except on the last card).
function TweetCard({ tweet, isInThread, isLast }) { function TweetCard({ tweet, isInThread, isLast, artifactMap }) {
const author = tweet.author || {}; const author = tweet.author || {};
const date = tweet.created_at_secs ? fmtDate(tweet.created_at_secs) : ''; const date = tweet.created_at_secs ? fmtDate(tweet.created_at_secs) : '';
const entities = tweet.entities || {}; const entities = tweet.entities || {};
const avatarUrl = author.avatar_url; const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
const showConnector = isInThread && !isLast; const showConnector = isInThread && !isLast;
const rowStyle = isInThread ? S.tweetRowThread : S.tweetRow; const rowStyle = isInThread ? S.tweetRowThread : S.tweetRow;
// Prefer extended_entities for video/multi-photo, fall back to entities.media
const media = (tweet.extended_entities?.media?.length
? tweet.extended_entities.media
: entities.media) || [];
return ( return (
<div style={rowStyle}> <div style={rowStyle}>
{/* Left column: avatar + optional thread connector line */}
<div style={S.leftCol}> <div style={S.leftCol}>
{avatarUrl {avatarSrc
? <img src={avatarUrl} style={S.avatar} alt={author.name || ''} /> ? <img src={avatarSrc} style={S.avatar} alt={author.name || ''} />
: <div style={S.avatarPh} /> : <div style={S.avatarPh} />
} }
{showConnector && <div style={S.threadLine} />} {showConnector && <div style={S.threadLine} />}
</div> </div>
{/* Right column: author row, tweet text, stats */}
<div style={S.rightCol}> <div style={S.rightCol}>
<div style={S.authorRow}> <div style={S.authorRow}>
<span style={S.authorName}> <span style={S.authorName}>
@ -711,6 +753,42 @@ function TweetCard({ tweet, isInThread, isLast }) {
{renderTweetTextJSX(tweet.full_text || '', entities)} {renderTweetTextJSX(tweet.full_text || '', entities)}
</div> </div>
{media.length > 0 && (
<div style={S.mediaGrid}>
{media.map((m, i) => {
if (m.type === 'photo') {
// Local archived file preferred; CDN thumbnail as fallback
const src = resolveUrl(m.local_path, m.media_url_https, artifactMap);
if (!src) return null;
return (
<img key={i} src={src} style={S.mediaImg}
alt={m.alt_text || ''} loading="lazy" />
);
}
if (m.type === 'video' || m.type === 'animated_gif') {
// Local archived file preferred; fall back to best-bitrate CDN mp4.
// m.media_url_https is a thumbnail image, NOT a video don't use it.
const videoSrc = (m.local_path && artifactMap[m.local_path])
|| (() => {
const variants = m.video_info?.variants || [];
return variants
.filter(v => v.content_type === 'video/mp4')
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]?.url;
})();
if (!videoSrc) return null;
return (
<video key={i} src={videoSrc} style={S.mediaVideo}
controls
loop={m.type === 'animated_gif'}
muted={m.type === 'animated_gif'}
/>
);
}
return null;
})}
</div>
)}
{(tweet.retweet_count > 0 || tweet.favorite_count > 0) && ( {(tweet.retweet_count > 0 || tweet.favorite_count > 0) && (
<div style={S.stats}> <div style={S.stats}>
{tweet.favorite_count > 0 && ( {tweet.favorite_count > 0 && (
@ -764,30 +842,20 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
}) })
) )
) )
.then(data => { .then(data => { if (!cancelled) setTweets(data); })
if (!cancelled) setTweets(data); .catch(e => { if (!cancelled) setError(e.message || 'Failed to load tweet.'); })
}) .finally(() => { if (!cancelled) setLoading(false); });
.catch(e => {
if (!cancelled) setError(e.message || 'Failed to load tweet.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [archiveId, entryUid, artifacts]); }, [archiveId, entryUid, artifacts]);
if (loading) { if (loading) return <div style={S.loading}>Loading</div>;
return <div style={S.loading}>Loading</div>; if (error) return <div style={S.error}>Error: {error}</div>;
}
if (error) {
return <div style={S.error}>Error: {error}</div>;
}
if (tweets.length === 0) return null; if (tweets.length === 0) return null;
// Thread: multiple tweet cards joined by a connector line. // Build relpath artifact URL map once for this entry
const artifactMap = buildArtifactMap(archiveId, entryUid, artifacts);
if (entityKind === 'tweet_thread') { if (entityKind === 'tweet_thread') {
return ( return (
<div style={S.threadOuter}> <div style={S.threadOuter}>
@ -797,22 +865,21 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
tweet={tweet} tweet={tweet}
isInThread isInThread
isLast={i === tweets.length - 1} isLast={i === tweets.length - 1}
artifactMap={artifactMap}
/> />
))} ))}
</div> </div>
); );
} }
// Single tweet check for X Article first.
const tweet = tweets[0]; const tweet = tweets[0];
if (tweet.is_article && tweet.article) { if (tweet.is_article && tweet.article) {
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} />; return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} />;
} }
return ( return (
<div style={S.card}> <div style={S.card}>
<TweetCard tweet={tweet} isInThread={false} isLast /> <TweetCard tweet={tweet} isInThread={false} isLast artifactMap={artifactMap} />
</div> </div>
); );
} }

View file

@ -1906,29 +1906,11 @@ select {
ENTRY PREVIEWS ENTRY PREVIEWS
*/ */
/* ── 3-column layout when preview is active ─────────────────────── */
.app-shell.has-preview {
grid-template-columns: minmax(0, 1fr) 300px 340px;
}
.app-shell.audio-playing {
padding-bottom: 72px;
}
/* ── Preview pane ─────────────────────────────────────────────────── */
.preview-pane {
min-width: 0;
overflow: auto;
border-left: 1px solid var(--line);
background: var(--paper);
display: flex;
flex-direction: column;
}
.preview-panel { .preview-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
height: 100%;
} }
.preview-panel-loading, .preview-panel-loading,
.preview-panel-empty { .preview-panel-empty {
@ -1966,7 +1948,6 @@ select {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
height: 100%;
} }
.preview-iframe-toolbar { .preview-iframe-toolbar {
display: flex; display: flex;
@ -2029,8 +2010,9 @@ select {
/* ── Tweet preview wrap ───────────────────────────────────────────── */ /* ── Tweet preview wrap ───────────────────────────────────────────── */
.preview-tweet-wrap { .preview-tweet-wrap {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 20px 16px; padding: 12px 16px;
background: var(--paper-2); background: var(--paper-2);
} }
@ -2340,15 +2322,102 @@ select {
} }
.audio-bar-close:hover { background: var(--field); color: var(--ink); } .audio-bar-close:hover { background: var(--field); color: var(--ink); }
/* ── Responsive: hide preview on small screens ─────────────────── */
@media (max-width: 1100px) { /* ── Preview modal ────────────────────────────────────────────────── */
.app-shell.has-preview { .preview-modal-backdrop {
grid-template-columns: minmax(0, 1fr) 340px; position: fixed;
} inset: 0;
.preview-pane { display: none; } z-index: 200;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
} }
@media (max-width: 900px) { .preview-modal {
.app-shell.has-preview { background: var(--paper);
grid-template-columns: 1fr; border-radius: 12px;
} box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
width: 90vw;
max-width: 1100px;
height: 88vh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.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: auto;
display: flex;
flex-direction: column;
}
/* 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; }