1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: entry previews (video, tweet, article, iframe, image, audio bar)

- Add PreviewPanel dispatch hub routing by entity_kind + artifact extension
- VideoPreview: HTML5 <video> for YouTube/Instagram/TikTok/Reddit/X posts
- TweetPreview: tweet card, thread, and X article renderer (ported from x-article-renderer)
- IframePreview: sandboxed iframe for SingleFile web pages and PDFs
- ImagePreview: image viewer with click-to-open-fullsize
- AudioBar: persistent fixed-bottom player (Spotify-style) that survives entry navigation
- Lift entryDetail to App.jsx, shared between PreviewPanel and ContextRail
- 3-column layout (workspace | 300px preview | 340px rail) when preview active
- Stale-guard fixes: seq incremented before early returns in all async effects
- handleRearchive: capture startSeq/entryUid at call time, guard every async resume
- TweetPreview: reset loading/error/tweets before early-return branches
This commit is contained in:
TheGeneralist 2026-07-11 18:29:36 +02:00
parent 2779afee2d
commit 5de9e291b0
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
13 changed files with 1974 additions and 69 deletions

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

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, createContext } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api'
import LoginPage from './components/LoginPage.jsx'
import SetupPage from './components/SetupPage.jsx'
@ -12,6 +12,8 @@ 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 AudioBar from './components/AudioBar'
import { displayPath } from './utils'
import ToastStack from './components/ToastStack'
@ -89,8 +91,26 @@ export default function App() {
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
)
// Entry detail (shared between PreviewPanel and ContextRail)
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
useEffect(() => {
const seq = ++detailSeqRef.current
setEntryDetail(null)
if (!selectedEntry || !archiveId) return
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
if (seq !== detailSeqRef.current) return
setEntryDetail(det)
}).catch(() => {})
}, [selectedEntry, archiveId])
// Persist captureDialogOpen to sessionStorage
useEffect(() => {
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
@ -217,8 +237,22 @@ export default function App() {
setSelectedEntry(prev =>
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
)
setEntryDetail(prev =>
prev && prev.summary.entry_uid === entryUid
? { ...prev, summary: { ...prev.summary, title: newTitle } }
: prev
)
}, [])
const handleDetailRefresh = useCallback(() => {
if (!archiveId || !selectedEntry) return
const seq = ++detailSeqRef.current
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
if (seq !== detailSeqRef.current) return
setEntryDetail(det)
}).catch(() => {})
}, [archiveId, selectedEntry])
const handleEntryDeleted = useCallback((entryUid) => {
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
@ -259,6 +293,27 @@ export default function App() {
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
}, [])
const handleSetAudio = useCallback(({ entry, src, archiveId: aid }) => {
setCurrentAudio({ entry, src, archiveId: aid })
}, [])
const handleCloseAudio = useCallback(() => {
setCurrentAudio(null)
}, [])
// 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)
})()
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'); }} />;
@ -274,7 +329,7 @@ export default function App() {
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className="app-shell">
<main className={`app-shell${hasPreview ? ' has-preview' : ''}${currentAudio ? ' audio-playing' : ''}`}>
<div className="workspace">
{view === 'archive' && (
<div className="toolbar">
@ -341,17 +396,37 @@ 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}
detail={entryDetail}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
onEntryDeleted={handleEntryDeleted}
humanizeTags={humanizeTags}
onDetailRefresh={handleDetailRefresh}
/>
</main>
{currentAudio && (
<AudioBar
entry={currentAudio.entry}
src={currentAudio.src}
archiveId={currentAudio.archiveId}
onClose={handleCloseAudio}
/>
)}
<CaptureDialog
open={captureDialogOpen}
archiveId={archiveId}

View file

@ -0,0 +1,223 @@
import { useEffect, useRef, useState } from 'react';
import { sourceIconSvg } from '../utils';
function formatTime(secs) {
if (!isFinite(secs) || isNaN(secs)) return '--:--';
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export default function AudioBar({ entry, src, archiveId, onClose }) {
const audioRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(NaN);
const [volume, setVolume] = useState(1.0);
// Auto-play whenever src changes
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);
}, [src]);
// Sync volume to audio element
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume;
}
}, [volume]);
function handlePlayPause() {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play().catch(() => {});
}
}
function handleSeek(e) {
const audio = audioRef.current;
if (!audio || !isFinite(duration)) return;
const t = Number(e.target.value);
audio.currentTime = t;
setCurrentTime(t);
}
function handleVolumeChange(e) {
setVolume(Number(e.target.value));
}
const title = entry?.title || entry?.entry_uid || 'Unknown';
const kind = entry?.source_kind || 'other';
return (
<>
{/* Hidden audio element */}
<audio
ref={audioRef}
src={src || undefined}
preload="auto"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? NaN)}
onDurationChange={() => setDuration(audioRef.current?.duration ?? NaN)}
style={{ display: 'none' }}
/>
{/* Fixed bottom bar */}
<div
className="audio-bar"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
background: 'var(--paper-3)',
borderTop: '1px solid var(--line)',
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '0 16px',
height: '56px',
fontFamily: 'var(--sans)',
}}
>
{/* Left: icon + title */}
<div
className="audio-bar-info"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minWidth: 0,
flex: '0 1 220px',
overflow: 'hidden',
}}
>
<span
className="source-icon"
style={{ flexShrink: 0, width: '18px', height: '18px', display: 'flex', alignItems: 'center' }}
dangerouslySetInnerHTML={{ __html: sourceIconSvg(kind) }}
/>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.85rem',
color: 'var(--ink)',
}}
title={title}
>
{title}
</span>
</div>
{/* Center: play/pause + seek + time */}
<div
className="audio-bar-controls"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
flex: '1 1 0',
minWidth: 0,
}}
>
<button
onClick={handlePlayPause}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
color: 'var(--ink)',
flexShrink: 0,
fontSize: '1.2rem',
lineHeight: 1,
}}
>
{isPlaying ? '⏸' : '▶'}
</button>
<span
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
>
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={isFinite(duration) ? duration : 0}
step={0.1}
value={isFinite(currentTime) ? currentTime : 0}
onChange={handleSeek}
aria-label="Seek"
style={{ flex: 1, minWidth: 0, accentColor: 'var(--accent)' }}
/>
<span
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
>
{formatTime(duration)}
</span>
</div>
{/* Right: volume + close */}
<div
className="audio-bar-right"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: '0 1 160px',
}}
>
<span style={{ fontSize: '0.85rem', color: 'var(--muted)', flexShrink: 0 }}>🔊</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolumeChange}
aria-label="Volume"
style={{ width: '80px', accentColor: 'var(--accent)' }}
/>
<button
onClick={onClose}
aria-label="Close audio player"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
color: 'var(--muted)',
fontSize: '1rem',
lineHeight: 1,
marginLeft: '4px',
}}
>
</button>
</div>
</div>
</>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
@ -11,8 +11,7 @@ const ExternalIcon = () => (
</svg>
)
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags }) {
const [detail, setDetail] = useState(null)
export default function ContextRail({ archiveId, selectedEntry, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags, onDetailRefresh }) {
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
@ -26,11 +25,11 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
const rearchivePollRef = useRef(null)
useEffect(() => {
const seq = ++selectSeqRef.current
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
setRearchiveState('idle')
setRearchiveError('')
if (!selectedEntry || !archiveId) {
setDetail(null)
setTags([])
setEntryCollections([])
return
@ -38,16 +37,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
setEditingTitle(false)
setTitleDraft('')
titleCancelRef.current = false
const seq = ++selectSeqRef.current
setDetail(null)
setTags([])
Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
listEntryCollections(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs, ecs]) => {
]).then(([tgs, ecs]) => {
if (seq !== selectSeqRef.current) return
setDetail(det)
setTags(tgs)
setEntryCollections(ecs)
}).catch(() => {})
@ -63,7 +58,6 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
const newTitle = titleDraft.trim() || null
try {
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
setDetail(prev => prev ? { ...prev, summary: { ...prev.summary, title: newTitle } } : prev)
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
} catch {
// silently revert
@ -111,39 +105,44 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
async function handleRearchive() {
if (!selectedEntry || !archiveId || rearchiveState === 'running') return
// Capture identity at start so closure comparisons are stable
const startSeq = selectSeqRef.current
const entryUid = selectedEntry.entry_uid
setRearchiveState('running')
setRearchiveError('')
try {
const { job_uid } = await rearchiveEntry(archiveId, selectedEntry.entry_uid)
// Poll for job completion at 500ms same interval as CaptureDialog
const { job_uid } = await rearchiveEntry(archiveId, entryUid)
// If selection changed while waiting for the kick-off response, bail.
if (selectSeqRef.current !== startSeq) return
rearchivePollRef.current = setInterval(async () => {
try {
const job = await pollCaptureJob(archiveId, job_uid)
if (job.status === 'completed') {
clearInterval(rearchivePollRef.current)
rearchivePollRef.current = null
if (selectSeqRef.current !== startSeq) return
setRearchiveState('done')
// Refresh entry detail so artifact list updates
const [det, tgs] = await Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
])
setDetail(det)
setTags(tgs)
const updated = await fetchEntryTags(archiveId, entryUid)
if (selectSeqRef.current !== startSeq) return
setTags(updated)
onDetailRefresh?.()
} else if (job.status === 'failed') {
clearInterval(rearchivePollRef.current)
rearchivePollRef.current = null
if (selectSeqRef.current !== startSeq) return
setRearchiveState('error')
setRearchiveError(job.error_text || 'Re-archive failed.')
}
} catch {
clearInterval(rearchivePollRef.current)
rearchivePollRef.current = null
if (selectSeqRef.current !== startSeq) return
setRearchiveState('error')
setRearchiveError('Network error while polling.')
}
}, 500)
} catch (e) {
if (selectSeqRef.current !== startSeq) return
setRearchiveState('error')
setRearchiveError(e.message || 'Failed to start re-archive.')
}

View file

@ -0,0 +1,81 @@
export default function IframePreview({ src, type }) {
return (
<div
className="preview-iframe-wrap"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
{type === 'page' ? (
<>
<div
className="preview-iframe-toolbar"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 10px',
borderBottom: '1px solid var(--line-soft)',
background: 'var(--paper-2)',
flexShrink: 0,
}}
>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.8rem',
color: 'var(--muted)',
fontFamily: 'var(--sans)',
}}
>
{src}
</span>
<a
href={src}
target="_blank"
rel="noreferrer noopener"
style={{
fontSize: '0.8rem',
color: 'var(--accent)',
textDecoration: 'none',
whiteSpace: 'nowrap',
fontFamily: 'var(--sans)',
}}
>
Open in new tab
</a>
</div>
<iframe
src={src}
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
style={{ flex: 1, border: 'none', width: '100%' }}
title="Page preview"
/>
</>
) : (
<>
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid var(--line-soft)',
background: 'var(--paper-2)',
flexShrink: 0,
fontSize: '0.85rem',
color: 'var(--muted)',
fontFamily: 'var(--sans)',
}}
>
PDF Document
</div>
<iframe
src={src}
style={{ flex: 1, border: 'none', width: '100%' }}
title="PDF preview"
/>
</>
)}
</div>
);
}

View file

@ -0,0 +1,30 @@
export default function ImagePreview({ src, alt }) {
return (
<div
className="preview-image-wrap"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
background: 'var(--paper-2)',
overflow: 'hidden',
}}
>
<a href={src} target="_blank" rel="noreferrer noopener" style={{ display: 'contents' }}>
<img
src={src}
alt={alt || ''}
style={{
objectFit: 'contain',
maxHeight: '100%',
maxWidth: '100%',
display: 'block',
cursor: 'pointer',
}}
/>
</a>
</div>
);
}

View file

@ -0,0 +1,184 @@
import { useEffect, useMemo } from 'react';
import VideoPreview from './VideoPreview';
import IframePreview from './IframePreview';
import ImagePreview from './ImagePreview';
import TweetPreview from './TweetPreview';
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'mkv', 'avi', 'm4v', 'ogv']);
const AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
export default function PreviewPanel({ archiveId, entry, detail, 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]);
if (!entry) {
return (
<div className="preview-panel preview-panel--empty">
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
Select an entry to preview
</span>
</div>
);
}
if (!detail) {
return (
<div className="preview-panel preview-panel--loading">
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
Loading
</span>
</div>
);
}
const { summary, artifacts } = detail;
const entryUid = summary.entry_uid;
const entityKind = summary.entity_kind;
// 1. Tweet / tweet thread
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
return (
<div className="preview-panel">
<TweetPreview
archiveId={archiveId}
entryUid={entryUid}
artifacts={artifacts}
entityKind={entityKind}
/>
</div>
);
}
// 2. Find primary_media artifact
const primaryMediaIndex = artifacts.findIndex(a => a.artifact_role === 'primary_media');
if (primaryMediaIndex === -1) {
return (
<div className="preview-panel preview-panel--no-preview">
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
No preview available
</span>
{artifacts.length > 0 && (
<ul
style={{
marginTop: '12px',
paddingLeft: '20px',
fontSize: '0.8rem',
color: 'var(--muted-2)',
fontFamily: 'var(--sans)',
}}
>
{artifacts.map((a, i) => (
<li key={i}>{a.artifact_role}: {a.relpath}</li>
))}
</ul>
)}
</div>
);
}
const primaryArtifact = artifacts[primaryMediaIndex];
const primaryMediaUrl = `/api/archives/${archiveId}/entries/${entryUid}/artifacts/${primaryMediaIndex}`;
const ext = primaryArtifact.relpath.split('.').pop().toLowerCase();
// 3. Video
if (VIDEO_EXTS.has(ext)) {
return (
<div className="preview-panel">
<VideoPreview src={primaryMediaUrl} />
</div>
);
}
// 4. Audio set audio bar and also show inline player + message
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 }}>
{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' }}
/>
</div>
);
}
// 5. PDF
if (ext === 'pdf') {
return (
<div className="preview-panel" style={{ height: '100%' }}>
<IframePreview src={primaryMediaUrl} type="pdf" />
</div>
);
}
// 6. HTML page
if (ext === 'html' || ext === 'htm') {
return (
<div className="preview-panel" style={{ height: '100%' }}>
<IframePreview src={primaryMediaUrl} type="page" />
</div>
);
}
// 7. Image
if (IMAGE_EXTS.has(ext)) {
return (
<div className="preview-panel" style={{ height: '100%' }}>
<ImagePreview src={primaryMediaUrl} alt={summary.title || 'Image'} />
</div>
);
}
// 8. Fallback
return (
<div className="preview-panel preview-panel--no-preview">
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
No preview available
</span>
<ul
style={{
marginTop: '12px',
paddingLeft: '20px',
fontSize: '0.8rem',
color: 'var(--muted-2)',
fontFamily: 'var(--sans)',
}}
>
{artifacts.map((a, i) => (
<li key={i}>{a.artifact_role}: {a.relpath}</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,818 @@
import { useState, useEffect } from 'react';
// Date helper
function fmtDate(secs) {
if (!secs) return '';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(secs * 1000));
}
// Inline style definitions
const S = {
// Tweet card
card: {
background: 'var(--paper)',
border: '1px solid var(--line)',
borderRadius: '12px',
overflow: 'hidden',
maxWidth: '598px',
margin: '0 auto',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
},
threadOuter: {
border: '1px solid var(--line)',
borderRadius: '12px',
overflow: 'hidden',
maxWidth: '598px',
margin: '0 auto',
background: 'var(--paper)',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
},
tweetRow: {
display: 'flex',
gap: '12px',
padding: '16px',
},
tweetRowThread: {
display: 'flex',
gap: '12px',
padding: '16px 16px 0',
},
leftCol: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flexShrink: 0,
width: '40px',
},
rightCol: {
flex: 1,
minWidth: 0,
paddingBottom: '16px',
},
avatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
display: 'block',
},
avatarPh: {
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--line)',
flexShrink: 0,
},
threadLine: {
flex: 1,
width: '2px',
background: 'var(--line-soft, var(--line))',
margin: '4px 0',
minHeight: '16px',
borderRadius: '1px',
},
authorRow: {
display: 'flex',
alignItems: 'baseline',
gap: '4px',
flexWrap: 'wrap',
marginBottom: '6px',
lineHeight: '1.3',
},
authorName: {
fontWeight: '700',
fontSize: '15px',
color: 'var(--ink)',
},
authorHandle: {
fontSize: '14px',
color: 'var(--muted)',
},
datePart: {
fontSize: '14px',
color: 'var(--muted)',
},
tweetText: {
fontSize: '15px',
lineHeight: '1.6',
color: 'var(--ink)',
whiteSpace: 'pre-line',
marginBottom: '10px',
wordBreak: 'break-word',
},
stats: {
display: 'flex',
gap: '16px',
fontSize: '14px',
color: 'var(--muted)',
},
link: {
color: 'var(--accent)',
textDecoration: 'none',
},
loading: {
padding: '32px 16px',
textAlign: 'center',
color: 'var(--muted)',
fontSize: '15px',
},
error: {
padding: '32px 16px',
textAlign: 'center',
color: 'var(--alert)',
fontSize: '15px',
},
// Article
article: {
maxWidth: '598px',
margin: '0 auto',
border: '1px solid var(--line)',
borderRadius: '12px',
overflow: 'hidden',
background: 'var(--paper)',
fontFamily: 'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
},
aCover: {
width: '100%',
display: 'block',
objectFit: 'cover',
maxHeight: '420px',
},
aMeta: {
padding: '20px 16px 0',
},
aTweetTitle: {
fontSize: '23px',
fontWeight: '800',
letterSpacing: '-0.3px',
color: 'var(--ink)',
lineHeight: '1.3',
marginBottom: '16px',
},
aAuthorRow: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
},
aAvatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
display: 'block',
},
aAvatarPh: {
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--line)',
flexShrink: 0,
},
aAuthorName: {
fontSize: '15px',
fontWeight: '700',
color: 'var(--ink)',
lineHeight: '1.3',
},
aAuthorSub: {
fontSize: '15px',
color: 'var(--muted)',
lineHeight: '1.3',
},
aDivider: {
border: 'none',
borderTop: '1px solid var(--line)',
margin: '0',
},
aBody: {
padding: '4px 16px 24px',
},
// Article blocks
bH1: {
fontSize: '26px',
fontWeight: '800',
letterSpacing: '-0.4px',
color: 'var(--ink)',
lineHeight: '1.25',
margin: '24px 0 10px',
},
bH2: {
fontSize: '20px',
fontWeight: '700',
letterSpacing: '-0.2px',
color: 'var(--ink)',
lineHeight: '1.3',
margin: '22px 0 8px',
},
bP: {
fontSize: '17px',
color: 'var(--ink)',
lineHeight: '1.7',
marginBottom: '16px',
marginTop: '0',
},
bSpacer: {
height: '6px',
display: 'block',
},
bQuote: {
borderLeft: '3px solid var(--line)',
padding: '2px 14px',
margin: '14px 0',
color: 'var(--muted)',
fontSize: '17px',
lineHeight: '1.65',
},
bHr: {
border: 'none',
borderTop: '1px solid var(--line)',
margin: '28px 0',
},
bImg: {
width: '100%',
display: 'block',
borderRadius: '12px',
margin: '16px 0',
},
bUl: {
margin: '10px 0 16px',
paddingLeft: '28px',
},
bOl: {
margin: '10px 0 16px',
paddingLeft: '28px',
},
bLi: {
fontSize: '17px',
color: 'var(--ink)',
lineHeight: '1.65',
marginBottom: '6px',
},
bTweet: {
display: 'flex',
alignItems: 'center',
gap: '12px',
border: '1px solid var(--line)',
borderRadius: '12px',
padding: '14px 16px',
margin: '14px 0',
color: 'var(--muted)',
fontSize: '15px',
textDecoration: 'none',
},
bMdPre: {
borderRadius: '8px',
margin: '12px 0',
overflow: 'auto',
background: 'var(--paper-3, var(--field))',
padding: '14px 16px',
},
bMdCode: {
fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: '13px',
lineHeight: '1.65',
color: 'var(--ink)',
background: 'transparent',
display: 'block',
},
iCode: {
fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",
fontSize: '0.875em',
background: 'var(--paper-3, var(--field))',
padding: '2px 6px',
borderRadius: '4px',
color: 'var(--ink)',
},
};
// SVG X logo
function XLogo() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
style={{ flexShrink: 0, color: 'var(--ink)' }}
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
</svg>
);
}
// Tweet text renderer
// Splits full_text at entity boundaries, replacing t.co URLs with display_url
// links and @mentions with x.com links. Returns an array of React nodes.
// white-space: pre-line on the container preserves newlines in plain segments.
function renderTweetTextJSX(fullText, entities) {
if (!fullText) return null;
const urls = (entities.urls || []).filter(
u => u.fromIndex != null && u.toIndex != null
);
const mentions = (entities.user_mentions || []).filter(
m => m.fromIndex != null && m.toIndex != null
);
if (urls.length === 0 && mentions.length === 0) return fullText;
const anns = [
...urls.map(u => ({
s: u.fromIndex,
e: u.toIndex,
kind: 'url',
href: u.expanded_url,
display: u.display_url,
})),
...mentions.map(m => ({
s: m.fromIndex,
e: m.toIndex,
kind: 'mention',
screen_name: m.screen_name,
})),
];
const pts = new Set([0, fullText.length]);
for (const a of anns) {
if (a.s >= 0 && a.s <= fullText.length) pts.add(a.s);
if (a.e >= 0 && a.e <= fullText.length) pts.add(a.e);
}
const sorted = [...pts].sort((a, b) => a - b);
return sorted.slice(0, -1).map((s, i) => {
const e = sorted[i + 1];
const seg = fullText.slice(s, e);
const active = anns.filter(a => a.s <= s && a.e >= e);
const url = active.find(a => a.kind === 'url');
if (url) {
return (
<a key={i} href={url.href} target="_blank" rel="noopener noreferrer" style={S.link}>
{url.display || seg}
</a>
);
}
const mention = active.find(a => a.kind === 'mention');
if (mention) {
return (
<a
key={i}
href={`https://x.com/${mention.screen_name}`}
target="_blank"
rel="noopener noreferrer"
style={S.link}
>
{seg}
</a>
);
}
return <span key={i}>{seg}</span>;
});
}
// Article inline text renderer
// Port of renderInline() from x-article-renderer.
// Splits block text at style-range, URL, and mention boundaries, returning
// an array of React nodes with the appropriate wrappers applied.
function renderInlineJSX(text, styleRanges, urls, mentions) {
if (!text) return null;
styleRanges = styleRanges || [];
urls = urls || [];
mentions = mentions || [];
const anns = [];
for (const r of styleRanges) {
if (r.length > 0)
anns.push({ s: r.offset, e: r.offset + r.length, kind: 'style', style: r.style });
}
for (const u of urls)
anns.push({ s: u.fromIndex, e: u.toIndex, kind: 'url', href: u.text });
for (const m of mentions)
anns.push({ s: m.fromIndex, e: m.toIndex, kind: 'mention', name: m.text });
if (anns.length === 0) return text;
const pts = new Set([0, text.length]);
for (const a of anns) {
if (a.s >= 0 && a.s <= text.length) pts.add(a.s);
if (a.e >= 0 && a.e <= text.length) pts.add(a.e);
}
const sorted = [...pts].sort((a, b) => a - b);
return sorted.slice(0, -1).map((s, i) => {
const e = sorted[i + 1];
const active = anns.filter(a => a.s <= s && a.e >= e);
const seg = text.slice(s, e);
// Handle newlines within the segment by inserting <br /> elements.
let content;
if (seg.includes('\n')) {
const parts = seg.split('\n');
content = parts.flatMap((p, pi) =>
pi < parts.length - 1 ? [p, <br key={pi} />] : [p]
);
} else {
content = seg;
}
// Apply inline styles innermost first (matching Draft.js precedence).
if (active.some(a => a.kind === 'style' && a.style === 'Code'))
content = <code style={S.iCode}>{content}</code>;
if (active.some(a => a.kind === 'style' && a.style === 'Bold'))
content = <strong>{content}</strong>;
if (active.some(a => a.kind === 'style' && a.style === 'Italic'))
content = <em>{content}</em>;
if (active.some(a => a.kind === 'style' && a.style === 'Underline'))
content = <u>{content}</u>;
if (active.some(a => a.kind === 'style' && a.style === 'Strikethrough'))
content = <s>{content}</s>;
// Links wrap outermost.
const url = active.find(a => a.kind === 'url');
if (url) {
content = (
<a href={url.href} target="_blank" rel="noopener noreferrer" style={S.link}>
{content}
</a>
);
}
const mention = active.find(a => a.kind === 'mention');
if (mention) {
content = (
<a
href={`https://x.com/${mention.name}`}
target="_blank"
rel="noopener noreferrer"
style={S.link}
>
{content}
</a>
);
}
return <span key={i}>{content}</span>;
});
}
// Article atomic block renderer
// Port of renderAtomic() from x-article-renderer.
function renderAtomicJSX(block) {
const entities = block.resolved_entities || [];
if (entities.length === 0) return null;
return entities.map((e, i) => {
switch (e.type) {
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 'tweet':
return e.tweet_id ? (
<a
key={i}
href={`https://x.com/i/status/${e.tweet_id}`}
target="_blank"
rel="noopener noreferrer"
style={S.bTweet}
>
<XLogo />
View post on X
</a>
) : null;
case 'link':
return e.url ? (
<p key={i} style={S.bP}>
<a href={e.url} target="_blank" rel="noopener noreferrer" style={S.link}>
{e.url}
</a>
</p>
) : null;
case 'markdown': {
const md = e.markdown ?? e.data?.markdown ?? '';
return (
<pre key={i} style={S.bMdPre}>
<code style={S.bMdCode}>{md}</code>
</pre>
);
}
case 'emoji':
return e.url ? (
<img
key={i}
src={e.url}
alt=""
style={{ height: '1.2em', verticalAlign: 'middle', margin: '0 1px' }}
/>
) : null;
default:
return null;
}
});
}
// Article single block renderer
// Port of renderBlock() from x-article-renderer.
function renderBlockJSX(block, key) {
const type = block.type || '';
const text = block.text || '';
const styleRanges = block.inline_style_ranges || [];
const data = block.data || {};
const inner = renderInlineJSX(text, styleRanges, data.urls || [], data.mentions || []);
switch (type) {
case 'header-one':
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 (
<a
key={key}
href={`https://x.com/i/status/${m[1]}`}
target="_blank"
rel="noopener noreferrer"
style={S.bTweet}
>
<XLogo />
View post on X
</a>
);
}
return <h2 key={key} style={S.bH2}>{inner}</h2>;
}
case 'unstyled':
if (!text.trim()) return <span key={key} style={S.bSpacer} />;
return <p key={key} style={S.bP}>{inner}</p>;
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>;
default:
return text ? <p key={key} style={S.bP}>{inner}</p> : null;
}
}
// Article block list renderer
// Port of renderBlocks() from x-article-renderer.
// Groups consecutive same-type list items into a single ul/ol.
function renderBlocksJSX(blocks) {
const items = [];
let i = 0;
while (i < blocks.length) {
const b = blocks[i];
if (b.type === 'unordered-list-item') {
const startIdx = i;
const listItems = [];
while (i < blocks.length && blocks[i].type === 'unordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i));
i++;
}
items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
} else if (b.type === 'ordered-list-item') {
const startIdx = i;
const listItems = [];
while (i < blocks.length && blocks[i].type === 'ordered-list-item') {
listItems.push(renderBlockJSX(blocks[i], i));
i++;
}
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
} else {
items.push(renderBlockJSX(b, i));
i++;
}
}
return items;
}
// Article renderer
function ArticleRenderer({ article, tweetAuthor }) {
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)
: '';
const handlePart = author.screen_name ? `@${author.screen_name}` : '';
const subLine = [handlePart, date].filter(Boolean).join(' · ');
return (
<div style={S.article}>
{cover.url && (
<img src={cover.url} 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 || ''} />
: <div style={S.aAvatarPh} />
}
<div>
<div style={S.aAuthorName}>
{author.name || author.screen_name || 'Unknown'}
</div>
{subLine && (
<div style={S.aAuthorSub}>{subLine}</div>
)}
</div>
</div>
</div>
<hr style={S.aDivider} />
<div style={S.aBody}>
{renderBlocksJSX(article.blocks || [])}
</div>
</div>
);
}
// Tweet card
// 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 }) {
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 showConnector = isInThread && !isLast;
const rowStyle = isInThread ? S.tweetRowThread : S.tweetRow;
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 || ''} />
: <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}>
{author.name || author.screen_name || 'Unknown'}
</span>
{author.screen_name && (
<span style={S.authorHandle}>@{author.screen_name}</span>
)}
{date && (
<span style={S.datePart}>· {date}</span>
)}
</div>
<div style={S.tweetText}>
{renderTweetTextJSX(tweet.full_text || '', entities)}
</div>
{(tweet.retweet_count > 0 || tweet.favorite_count > 0) && (
<div style={S.stats}>
{tweet.favorite_count > 0 && (
<span> {tweet.favorite_count.toLocaleString()}</span>
)}
{tweet.retweet_count > 0 && (
<span>🔁 {tweet.retweet_count.toLocaleString()}</span>
)}
</div>
)}
</div>
</div>
);
}
// TweetPreview
export default function TweetPreview({ archiveId, entryUid, artifacts, entityKind }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tweets, setTweets] = useState([]);
useEffect(() => {
setLoading(true);
setError(null);
setTweets([]);
if (!artifacts || !archiveId || !entryUid) {
setLoading(false);
return;
}
const tweetArtifacts = artifacts
.map((a, index) => ({ ...a, index }))
.filter(a => a.artifact_role === 'raw_tweet_json');
if (tweetArtifacts.length === 0) {
setError('No tweet data found.');
setLoading(false);
return;
}
let cancelled = false;
Promise.all(
tweetArtifacts.map(a =>
fetch(`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${a.index}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
)
)
.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 (tweets.length === 0) return null;
// Thread: multiple tweet cards joined by a connector line.
if (entityKind === 'tweet_thread') {
return (
<div style={S.threadOuter}>
{tweets.map((tweet, i) => (
<TweetCard
key={tweet.id || i}
tweet={tweet}
isInThread
isLast={i === tweets.length - 1}
/>
))}
</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 (
<div style={S.card}>
<TweetCard tweet={tweet} isInThread={false} isLast />
</div>
);
}

View file

@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
export default function VideoPreview({ src }) {
const videoRef = useRef(null);
useEffect(() => {
if (videoRef.current) {
videoRef.current.load();
}
}, [src]);
return (
<div
className="preview-video-wrap"
style={{
background: '#111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
minHeight: '240px',
}}
>
{src ? (
<video
ref={videoRef}
controls
autoPlay={false}
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
>
<source src={src} />
Your browser does not support the video element.
</video>
) : (
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
No video available
</span>
)}
</div>
);
}

View file

@ -1901,3 +1901,454 @@ select {
margin: 0 0 8px;
outline: none;
}
/*
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 {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--muted-2);
font-size: 14px;
text-align: center;
padding: 24px;
}
/* ── Video preview ────────────────────────────────────────────────── */
.preview-video-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #0d0d0b;
min-height: 200px;
}
.preview-video-wrap video {
max-width: 100%;
max-height: 100%;
width: 100%;
display: block;
}
/* ── Iframe preview ───────────────────────────────────────────────── */
.preview-iframe-wrap {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.preview-iframe-toolbar {
display: flex;
align-items: center;
padding: 6px 12px;
background: var(--paper-3);
border-bottom: 1px solid var(--line);
gap: 8px;
flex-shrink: 0;
}
.preview-iframe-toolbar span {
font-size: 11px;
color: var(--muted-2);
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 600;
}
.preview-iframe-toolbar a {
margin-left: auto;
color: var(--link);
font-size: 12px;
text-decoration: none;
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: var(--r2);
background: var(--field);
transition: border-color 0.12s;
}
.preview-iframe-toolbar a:hover {
border-color: var(--accent);
text-decoration: none;
}
.preview-iframe-wrap iframe {
flex: 1;
width: 100%;
border: none;
background: white;
min-height: 0;
}
/* ── Image preview ────────────────────────────────────────────────── */
.preview-image-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--paper-2);
}
.preview-image-wrap img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--r3);
cursor: zoom-in;
display: block;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
}
/* ── Tweet preview wrap ───────────────────────────────────────────── */
.preview-tweet-wrap {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
background: var(--paper-2);
}
/* ── Tweet card ───────────────────────────────────────────────────── */
.tw-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
background: var(--paper);
max-width: 560px;
margin: 0 auto 12px;
}
.tw-thread {
max-width: 560px;
margin: 0 auto;
}
.tw-thread-item {
display: flex;
gap: 0;
margin-bottom: 0;
}
.tw-thread-connector {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 52px;
padding-top: 2px;
}
.tw-thread-avatar-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.tw-thread-line {
width: 2px;
background: var(--line);
flex: 1;
min-height: 16px;
margin: 4px 0;
}
.tw-thread-body {
flex: 1;
min-width: 0;
padding: 0 0 16px 12px;
}
.tw-thread-body:last-child { padding-bottom: 0; }
.tw-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
display: block;
}
.tw-avatar-ph {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--paper-2);
border: 1px solid var(--line);
flex-shrink: 0;
}
.tw-header {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.tw-author-name { font-size: 14.5px; font-weight: 700; color: var(--ink); }
.tw-author-handle { font-size: 13px; color: var(--muted-2); }
.tw-dot { color: var(--muted-2); font-size: 13px; }
.tw-date { font-size: 13px; color: var(--muted-2); }
.tw-text {
font-size: 15px;
line-height: 1.65;
color: var(--ink);
white-space: pre-line;
word-break: break-word;
margin-bottom: 10px;
}
.tw-text a { color: var(--link); text-decoration: none; }
.tw-text a:hover { text-decoration: underline; }
.tw-stats {
display: flex;
gap: 20px;
color: var(--muted-2);
font-size: 13px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line-soft);
}
.tw-stats span { display: flex; align-items: center; gap: 4px; }
/* ── X Article styles ─────────────────────────────────────────────── */
.tw-article {
max-width: 598px;
margin: 0 auto;
}
.tw-article-cover {
width: 100%;
display: block;
object-fit: cover;
max-height: 380px;
}
.tw-article-meta { padding: 20px 16px 0; }
.tw-article-title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.3px;
color: var(--ink);
line-height: 1.3;
margin-bottom: 14px;
}
.tw-article-author-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.tw-article-author-name { font-size: 14px; font-weight: 700; color: var(--ink); }
.tw-article-author-sub { font-size: 13px; color: var(--muted-2); }
.tw-article-divider { border: none; border-top: 1px solid var(--line); margin: 0; }
.tw-article-body { padding: 8px 16px 60px; }
.tw-b-h1 {
font-size: 24px; font-weight: 800; letter-spacing: -0.3px;
color: var(--ink); line-height: 1.25; margin: 22px 0 10px;
}
.tw-b-h2 {
font-size: 19px; font-weight: 700; letter-spacing: -0.15px;
color: var(--ink); line-height: 1.3; margin: 20px 0 8px;
}
.tw-b-p { font-size: 16px; color: var(--ink); line-height: 1.72; margin-bottom: 14px; }
.tw-b-p:empty { display: none; }
.tw-b-spacer { height: 6px; display: block; }
.tw-b-blockquote {
border-left: 3px solid var(--line);
padding: 2px 14px;
margin: 14px 0;
color: var(--muted);
font-size: 16px;
line-height: 1.65;
}
.tw-b-hr { border: none; border-top: 1px solid var(--line); margin: 24px 0; }
.tw-b-img { width: 100%; display: block; border-radius: 10px; margin: 14px 0; }
.tw-b-ul, .tw-b-ol { margin: 10px 0 14px; padding-left: 26px; }
.tw-b-li { font-size: 16px; color: var(--ink); line-height: 1.65; margin-bottom: 6px; }
.tw-b-code {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.875em;
background: var(--paper-2);
padding: 2px 5px;
border-radius: 4px;
color: var(--ink);
}
.tw-b-pre {
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 12px 0;
}
.tw-b-pre code {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
.tw-b-tweet-link {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 14px;
margin: 12px 0;
color: var(--muted);
font-size: 14px;
text-decoration: none;
background: var(--paper-3);
transition: background 0.12s;
}
.tw-b-tweet-link:hover { background: var(--field); }
.tw-b-tweet-link svg { flex-shrink: 0; color: var(--ink); }
/* ── Audio bar ────────────────────────────────────────────────────── */
.audio-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 72px;
background: var(--paper);
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 20px;
padding: 0 24px;
z-index: 100;
box-shadow: 0 -2px 16px rgba(0,0,0,0.08);
}
.audio-bar-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 0 0 200px;
}
.audio-bar-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.audio-bar-icon svg { width: 20px; height: 20px; }
.audio-bar-title {
font-size: 13px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
font-weight: 500;
}
.audio-bar-controls {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.audio-bar-play-btn {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--accent);
color: var(--paper);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.12s, transform 0.1s;
}
.audio-bar-play-btn:hover { background: var(--accent-2); }
.audio-bar-play-btn:active { transform: scale(0.93); }
.audio-bar-play-btn svg { width: 15px; height: 15px; }
.audio-bar-seek {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.audio-bar-seek input[type="range"] {
flex: 1;
accent-color: var(--accent);
height: 4px;
cursor: pointer;
min-width: 0;
}
.audio-bar-time {
font-size: 11.5px;
color: var(--muted-2);
white-space: nowrap;
min-width: 80px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.audio-bar-right {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
.audio-bar-volume {
display: flex;
align-items: center;
gap: 6px;
}
.audio-bar-volume svg { width: 14px; height: 14px; color: var(--muted-2); flex-shrink: 0; }
.audio-bar-volume input[type="range"] {
width: 72px;
accent-color: var(--accent);
cursor: pointer;
}
.audio-bar-close {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--muted-2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
padding: 0;
line-height: 1;
transition: background 0.12s, color 0.12s;
}
.audio-bar-close:hover { background: var(--field); color: var(--ink); }
/* ── 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; }
}
@media (max-width: 900px) {
.app-shell.has-preview {
grid-template-columns: 1fr;
}
}