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:
parent
5de9e291b0
commit
8755e2c503
14 changed files with 574 additions and 298 deletions
|
|
@ -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
47
crates/archivr-server/static/assets/index-DekfKifP.js
Normal file
47
crates/archivr-server/static/assets/index-DekfKifP.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
32
frontend/src/components/PreviewModal.jsx
Normal file
32
frontend/src/components/PreviewModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/PreviewPage.jsx
Normal file
85
frontend/src/components/PreviewPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue