1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +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.
/// HSTS is intentionally omitted — that belongs at the reverse-proxy layer.
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 headers = response.headers_mut();
headers.insert(
axum::http::header::HeaderName::from_static("x-content-type-options"),
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(
axum::http::header::HeaderName::from_static("referrer-policy"),
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(
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
}

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 name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-BlLU3Wcz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEaPUEBm.css">
<script type="module" crossorigin src="/assets/index-DekfKifP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Czw8P_Ze.css">
</head>
<body>
<div id="root"></div>

View file

@ -12,13 +12,20 @@ import TagsView from './components/TagsView'
import CollectionsView from './components/CollectionsView'
import SettingsView from './components/SettingsView'
import ContextRail from './components/ContextRail'
import PreviewPanel from './components/PreviewPanel'
import PreviewModal from './components/PreviewModal'
import AudioBar from './components/AudioBar'
import PreviewPage from './components/PreviewPage'
import { displayPath } from './utils'
import ToastStack from './components/ToastStack'
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 SETTINGS_TABS = ['profile','tokens','instance','storage']
@ -96,8 +103,6 @@ export default function App() {
const [entryDetail, setEntryDetail] = useState(null)
const detailSeqRef = useRef(0)
// Persistent audio bar state
const [currentAudio, setCurrentAudio] = useState(null) // { entry, src, archiveId }
const humanizeTags = currentUser?.humanize_slugs ?? false;
// Fetch entry detail whenever selected entry changes
@ -192,7 +197,9 @@ export default function App() {
}, [archiveId])
// Sync view + settingsTab URL
// Sync view + settingsTab URL (skip when serving a standalone preview page)
useEffect(() => {
if (PREVIEW_ROUTE) return
const path = locationPath(view, settingsTab)
if (window.location.pathname !== path) {
history.pushState(null, '', path)
@ -292,31 +299,33 @@ export default function App() {
setUblockWarningIgnored(true)
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 }) => {
setCurrentAudio({ entry, src, archiveId: aid })
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), [])
const handleCloseAudio = useCallback(() => {
setCurrentAudio(null)
}, [])
// Close stale modal when selection changes (audio persists intentionally)
useEffect(() => {
setPreviewEntryUid(null)
}, [selectedEntry])
// Compute whether the selected entry has a previewable artifact
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 PREVIEW_EXTS = new Set([...VIDEO_EXTS, ...AUDIO_EXTS, 'pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp'])
const hasPreview = view === 'archive' && selectedEntry && (() => {
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)
})()
// 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 === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
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 (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
@ -329,7 +338,7 @@ export default function App() {
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className={`app-shell${hasPreview ? ' has-preview' : ''}${currentAudio ? ' audio-playing' : ''}`}>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="toolbar">
@ -396,16 +405,6 @@ export default function App() {
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} />
)}
</div>
{hasPreview && (
<div className="preview-pane">
<PreviewPanel
archiveId={archiveId}
entry={selectedEntry}
detail={entryDetail}
onSetAudio={handleSetAudio}
/>
</div>
)}
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
@ -417,13 +416,23 @@ export default function App() {
onEntryDeleted={handleEntryDeleted}
humanizeTags={humanizeTags}
onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview}
onPlay={handlePlay}
/>
</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={currentAudio.archiveId}
archiveId={archiveId}
onClose={handleCloseAudio}
/>
)}

View file

@ -15,18 +15,13 @@ export default function AudioBar({ entry, src, archiveId, onClose }) {
const [duration, setDuration] = useState(NaN);
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(() => {
if (!audioRef.current || !src) return;
audioRef.current.load();
audioRef.current.play().then(() => {
setIsPlaying(true);
}).catch(() => {
// Autoplay blocked silently remain paused
setIsPlaying(false);
});
setCurrentTime(0);
setDuration(NaN);
setIsPlaying(false);
}, [src]);
// Sync volume to audio element

View file

@ -11,7 +11,7 @@ const ExternalIcon = () => (
</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 [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
@ -156,6 +156,20 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
['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 (
<aside className="context-rail">
<div className="rail-eyebrow">Context</div>
@ -207,6 +221,17 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
</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">
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
<div key={label} className="meta-item">

View file

@ -48,7 +48,8 @@ export default function IframePreview({ src, type }) {
</div>
<iframe
src={src}
sandbox="allow-scripts allow-same-origin allow-popups"
sandbox="allow-same-origin allow-popups"
allow="autoplay 'none'"
referrerPolicy="no-referrer"
style={{ flex: 1, border: 'none', width: '100%' }}
title="Page preview"
@ -71,6 +72,7 @@ export default function IframePreview({ src, type }) {
</div>
<iframe
src={src}
allow="autoplay 'none'"
style={{ flex: 1, border: 'none', width: '100%' }}
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 IframePreview from './IframePreview';
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 IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
export default function PreviewPanel({ archiveId, entry, detail, onSetAudio }) {
// 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]);
export default function PreviewPanel({ archiveId, entry, detail }) {
if (!entry) {
return (
@ -51,7 +36,7 @@ export default function PreviewPanel({ archiveId, entry, detail, onSetAudio }) {
// 1. Tweet / tweet thread
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
return (
<div className="preview-panel">
<div className="preview-tweet-wrap">
<TweetPreview
archiveId={archiveId}
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)) {
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: '2.5rem' }}>🎵</span>
<span style={{ color: 'var(--ink)', fontSize: '1rem', fontWeight: 600 }}>
<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>
<span style={{ color: 'var(--muted)', fontSize: '0.85rem' }}>
Playing in audio bar below
</span>
<audio
src={primaryMediaUrl}
controls
style={{ marginTop: '12px', width: '100%', maxWidth: '400px' }}
/>
<audio src={primaryMediaUrl} controls style={{ marginTop: '8px', width: '100%', maxWidth: '400px' }} />
</div>
);
}

View file

@ -20,7 +20,7 @@ const S = {
border: '1px solid var(--line)',
borderRadius: '12px',
overflow: 'hidden',
maxWidth: '598px',
maxWidth: '560px',
margin: '0 auto',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
},
@ -28,44 +28,44 @@ const S = {
border: '1px solid var(--line)',
borderRadius: '12px',
overflow: 'hidden',
maxWidth: '598px',
maxWidth: '560px',
margin: '0 auto',
background: 'var(--paper)',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
},
tweetRow: {
display: 'flex',
gap: '12px',
padding: '16px',
gap: '10px',
padding: '10px 12px',
},
tweetRowThread: {
display: 'flex',
gap: '12px',
padding: '16px 16px 0',
gap: '10px',
padding: '10px 12px 0',
},
leftCol: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flexShrink: 0,
width: '40px',
width: '36px',
},
rightCol: {
flex: 1,
minWidth: 0,
paddingBottom: '16px',
paddingBottom: '8px',
},
avatar: {
width: '40px',
height: '40px',
width: '36px',
height: '36px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
display: 'block',
},
avatarPh: {
width: '40px',
height: '40px',
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'var(--line)',
flexShrink: 0,
@ -75,7 +75,7 @@ const S = {
width: '2px',
background: 'var(--line-soft, var(--line))',
margin: '4px 0',
minHeight: '16px',
minHeight: '12px',
borderRadius: '1px',
},
authorRow: {
@ -83,34 +83,34 @@ const S = {
alignItems: 'baseline',
gap: '4px',
flexWrap: 'wrap',
marginBottom: '6px',
marginBottom: '4px',
lineHeight: '1.3',
},
authorName: {
fontWeight: '700',
fontSize: '15px',
fontSize: '14px',
color: 'var(--ink)',
},
authorHandle: {
fontSize: '14px',
fontSize: '13px',
color: 'var(--muted)',
},
datePart: {
fontSize: '14px',
fontSize: '13px',
color: 'var(--muted)',
},
tweetText: {
fontSize: '15px',
lineHeight: '1.6',
fontSize: '14px',
lineHeight: '1.5',
color: 'var(--ink)',
whiteSpace: 'pre-line',
marginBottom: '10px',
marginBottom: '8px',
wordBreak: 'break-word',
},
stats: {
display: 'flex',
gap: '16px',
fontSize: '14px',
gap: '12px',
fontSize: '13px',
color: 'var(--muted)',
},
link: {
@ -118,20 +118,38 @@ const S = {
textDecoration: 'none',
},
loading: {
padding: '32px 16px',
padding: '24px 16px',
textAlign: 'center',
color: 'var(--muted)',
fontSize: '15px',
fontSize: '14px',
},
error: {
padding: '32px 16px',
padding: '24px 16px',
textAlign: 'center',
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: {
maxWidth: '598px',
maxWidth: '560px',
margin: '0 auto',
border: '1px solid var(--line)',
borderRadius: '12px',
@ -143,48 +161,48 @@ const S = {
width: '100%',
display: 'block',
objectFit: 'cover',
maxHeight: '420px',
maxHeight: '280px',
},
aMeta: {
padding: '20px 16px 0',
padding: '14px 14px 0',
},
aTweetTitle: {
fontSize: '23px',
fontSize: '20px',
fontWeight: '800',
letterSpacing: '-0.3px',
color: 'var(--ink)',
lineHeight: '1.3',
marginBottom: '16px',
marginBottom: '10px',
},
aAuthorRow: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
gap: '8px',
marginBottom: '10px',
},
aAvatar: {
width: '40px',
height: '40px',
width: '36px',
height: '36px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
display: 'block',
},
aAvatarPh: {
width: '40px',
height: '40px',
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'var(--line)',
flexShrink: 0,
},
aAuthorName: {
fontSize: '15px',
fontSize: '14px',
fontWeight: '700',
color: 'var(--ink)',
lineHeight: '1.3',
},
aAuthorSub: {
fontSize: '15px',
fontSize: '13px',
color: 'var(--muted)',
lineHeight: '1.3',
},
@ -194,92 +212,92 @@ const S = {
margin: '0',
},
aBody: {
padding: '4px 16px 24px',
padding: '4px 14px 16px',
},
// Article blocks
bH1: {
fontSize: '26px',
fontSize: '22px',
fontWeight: '800',
letterSpacing: '-0.4px',
color: 'var(--ink)',
lineHeight: '1.25',
margin: '24px 0 10px',
margin: '20px 0 8px',
},
bH2: {
fontSize: '20px',
fontSize: '18px',
fontWeight: '700',
letterSpacing: '-0.2px',
color: 'var(--ink)',
lineHeight: '1.3',
margin: '22px 0 8px',
margin: '18px 0 6px',
},
bP: {
fontSize: '17px',
fontSize: '15px',
color: 'var(--ink)',
lineHeight: '1.7',
marginBottom: '16px',
lineHeight: '1.65',
marginBottom: '12px',
marginTop: '0',
},
bSpacer: {
height: '6px',
height: '4px',
display: 'block',
},
bQuote: {
borderLeft: '3px solid var(--line)',
padding: '2px 14px',
margin: '14px 0',
padding: '2px 12px',
margin: '12px 0',
color: 'var(--muted)',
fontSize: '17px',
lineHeight: '1.65',
fontSize: '15px',
lineHeight: '1.6',
},
bHr: {
border: 'none',
borderTop: '1px solid var(--line)',
margin: '28px 0',
margin: '20px 0',
},
bImg: {
width: '100%',
display: 'block',
borderRadius: '12px',
margin: '16px 0',
borderRadius: '8px',
margin: '12px 0',
},
bUl: {
margin: '10px 0 16px',
paddingLeft: '28px',
margin: '8px 0 12px',
paddingLeft: '24px',
},
bOl: {
margin: '10px 0 16px',
paddingLeft: '28px',
margin: '8px 0 12px',
paddingLeft: '24px',
},
bLi: {
fontSize: '17px',
fontSize: '15px',
color: 'var(--ink)',
lineHeight: '1.65',
marginBottom: '6px',
lineHeight: '1.6',
marginBottom: '4px',
},
bTweet: {
display: 'flex',
alignItems: 'center',
gap: '12px',
gap: '10px',
border: '1px solid var(--line)',
borderRadius: '12px',
padding: '14px 16px',
margin: '14px 0',
borderRadius: '10px',
padding: '10px 14px',
margin: '12px 0',
color: 'var(--muted)',
fontSize: '15px',
fontSize: '14px',
textDecoration: 'none',
},
bMdPre: {
borderRadius: '8px',
margin: '12px 0',
margin: '10px 0',
overflow: 'auto',
background: 'var(--paper-3, var(--field))',
padding: '14px 16px',
padding: '12px 14px',
},
bMdCode: {
fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: '13px',
lineHeight: '1.65',
fontSize: '12px',
lineHeight: '1.6',
color: 'var(--ink)',
background: 'transparent',
display: 'block',
@ -288,12 +306,33 @@ const S = {
fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",
fontSize: '0.875em',
background: 'var(--paper-3, var(--field))',
padding: '2px 6px',
padding: '1px 5px',
borderRadius: '4px',
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
function XLogo() {
@ -473,7 +512,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions) {
// Article atomic block renderer
// Port of renderAtomic() from x-article-renderer.
function renderAtomicJSX(block) {
function renderAtomicJSX(block, artifactMap) {
const entities = block.resolved_entities || [];
if (entities.length === 0) return null;
@ -482,10 +521,10 @@ function renderAtomicJSX(block) {
case 'divider':
return <hr key={i} style={S.bHr} />;
case 'media':
return e.url
? <img key={i} src={e.url} style={S.bImg} loading="lazy" alt="" />
: null;
case 'media': {
const src = resolveUrl(e.local_path, e.url, artifactMap);
return src ? <img key={i} src={src} style={S.bImg} loading="lazy" alt="" /> : null;
}
case 'tweet':
return e.tweet_id ? (
@ -538,7 +577,7 @@ function renderAtomicJSX(block) {
// Article single block renderer
// Port of renderBlock() from x-article-renderer.
function renderBlockJSX(block, key) {
function renderBlockJSX(block, key, artifactMap) {
const type = block.type || '';
const text = block.text || '';
const styleRanges = block.inline_style_ranges || [];
@ -550,7 +589,6 @@ function renderBlockJSX(block, key) {
return <h1 key={key} style={S.bH1}>{inner}</h1>;
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+)/);
if (m) {
return (
@ -576,13 +614,12 @@ function renderBlockJSX(block, key) {
case '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 'ordered-list-item':
return <li key={key} style={S.bLi}>{inner}</li>;
case 'atomic':
return <span key={key}>{renderAtomicJSX(block)}</span>;
return <span key={key}>{renderAtomicJSX(block, artifactMap)}</span>;
default:
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.
// Groups consecutive same-type list items into a single ul/ol.
function renderBlocksJSX(blocks) {
function renderBlocksJSX(blocks, artifactMap) {
const items = [];
let i = 0;
@ -604,7 +641,7 @@ function renderBlocksJSX(blocks) {
const startIdx = i;
const listItems = [];
while (i < blocks.length && blocks[i].type === 'unordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i));
listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
i++;
}
items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
@ -612,12 +649,12 @@ function renderBlocksJSX(blocks) {
const startIdx = i;
const listItems = [];
while (i < blocks.length && blocks[i].type === 'ordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i));
listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
i++;
}
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
} else {
items.push(renderBlockJSX(b, i));
items.push(renderBlockJSX(b, i, artifactMap));
i++;
}
}
@ -627,9 +664,8 @@ function renderBlocksJSX(blocks) {
// Article renderer
function ArticleRenderer({ article, tweetAuthor }) {
function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
const cover = article.cover_media || {};
// article.author is preferred; fall back to the tweet-level author.
const author = article.author || tweetAuthor || {};
const date = 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 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 (
<div style={S.article}>
{cover.url && (
<img src={cover.url} style={S.aCover} alt="Article cover" />
{coverSrc && (
<img src={coverSrc} style={S.aCover} alt="Article cover" />
)}
<div style={S.aMeta}>
{article.title && (
<div style={S.aTweetTitle}>{article.title}</div>
)}
<div style={S.aAuthorRow}>
{author.avatar_url
? <img src={author.avatar_url} style={S.aAvatar} alt={author.name || ''} />
{avatarSrc
? <img src={avatarSrc} style={S.aAvatar} alt={author.name || ''} />
: <div style={S.aAvatarPh} />
}
<div>
@ -663,7 +702,7 @@ function ArticleRenderer({ article, tweetAuthor }) {
</div>
<hr style={S.aDivider} />
<div style={S.aBody}>
{renderBlocksJSX(article.blocks || [])}
{renderBlocksJSX(article.blocks || [], artifactMap)}
</div>
</div>
);
@ -673,27 +712,30 @@ function ArticleRenderer({ article, tweetAuthor }) {
// 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).
function TweetCard({ tweet, isInThread, isLast }) {
function TweetCard({ tweet, isInThread, isLast, artifactMap }) {
const author = tweet.author || {};
const date = tweet.created_at_secs ? fmtDate(tweet.created_at_secs) : '';
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 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 (
<div style={rowStyle}>
{/* Left column: avatar + optional thread connector line */}
<div style={S.leftCol}>
{avatarUrl
? <img src={avatarUrl} style={S.avatar} alt={author.name || ''} />
{avatarSrc
? <img src={avatarSrc} style={S.avatar} alt={author.name || ''} />
: <div style={S.avatarPh} />
}
{showConnector && <div style={S.threadLine} />}
</div>
{/* Right column: author row, tweet text, stats */}
<div style={S.rightCol}>
<div style={S.authorRow}>
<span style={S.authorName}>
@ -711,6 +753,42 @@ function TweetCard({ tweet, isInThread, isLast }) {
{renderTweetTextJSX(tweet.full_text || '', entities)}
</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) && (
<div style={S.stats}>
{tweet.favorite_count > 0 && (
@ -764,30 +842,20 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
})
)
)
.then(data => {
if (!cancelled) setTweets(data);
})
.catch(e => {
if (!cancelled) setError(e.message || 'Failed to load tweet.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
.then(data => { if (!cancelled) setTweets(data); })
.catch(e => { if (!cancelled) setError(e.message || 'Failed to load tweet.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [archiveId, entryUid, artifacts]);
if (loading) {
return <div style={S.loading}>Loading</div>;
}
if (error) {
return <div style={S.error}>Error: {error}</div>;
}
if (loading) return <div style={S.loading}>Loading</div>;
if (error) return <div style={S.error}>Error: {error}</div>;
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') {
return (
<div style={S.threadOuter}>
@ -797,22 +865,21 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
tweet={tweet}
isInThread
isLast={i === tweets.length - 1}
artifactMap={artifactMap}
/>
))}
</div>
);
}
// Single tweet check for X Article first.
const tweet = tweets[0];
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 (
<div style={S.card}>
<TweetCard tweet={tweet} isInThread={false} isLast />
<TweetCard tweet={tweet} isInThread={false} isLast artifactMap={artifactMap} />
</div>
);
}

View file

@ -1906,29 +1906,11 @@ select {
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 {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.preview-panel-loading,
.preview-panel-empty {
@ -1966,7 +1948,6 @@ select {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.preview-iframe-toolbar {
display: flex;
@ -2029,8 +2010,9 @@ select {
/* ── Tweet preview wrap ───────────────────────────────────────────── */
.preview-tweet-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 16px;
padding: 12px 16px;
background: var(--paper-2);
}
@ -2340,15 +2322,102 @@ select {
}
.audio-bar-close:hover { background: var(--field); color: var(--ink); }
/* ── Responsive: hide preview on small screens ─────────────────── */
@media (max-width: 1100px) {
.app-shell.has-preview {
grid-template-columns: minmax(0, 1fr) 340px;
}
.preview-pane { display: none; }
/* ── 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;
}
@media (max-width: 900px) {
.app-shell.has-preview {
grid-template-columns: 1fr;
}
.preview-modal {
background: var(--paper);
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; }