mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
No commits in common. "a4de5064956a32925a71406bc4345337adbc747b" and "cd463d28109e6e826a18f2ba38072dd75e684ff9" have entirely different histories.
a4de506495
...
cd463d2810
11 changed files with 133 additions and 331 deletions
47
crates/archivr-server/static/assets/index-B7dq_VAM.js
Normal file
47
crates/archivr-server/static/assets/index-B7dq_VAM.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-nbPbdgtG.css
Normal file
1
crates/archivr-server/static/assets/index-nbPbdgtG.css
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-Db35_tmT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
|
||||
<script type="module" crossorigin src="/assets/index-B7dq_VAM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nbPbdgtG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -464,23 +464,6 @@ export default function App() {
|
|||
setPreviewEntryUid(null)
|
||||
}, [selectedEntry])
|
||||
|
||||
// Esc: deselect current entry/entries, unless a modal or input has focus.
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
if (captureDialogOpen || previewEntryUid) return
|
||||
const tag = document.activeElement?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (selectedUids.size > 0) {
|
||||
e.preventDefault()
|
||||
setSelectedUids(new Set())
|
||||
document.activeElement?.blur?.()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [captureDialogOpen, previewEntryUid, selectedUids])
|
||||
|
||||
// Toggle body class so fixed AudioBar doesn't obscure scrollable content
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('has-audio-bar', !!currentAudio)
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
const [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
|
||||
const [rearchiveError, setRearchiveError] = useState('')
|
||||
const rearchivePollRef = useRef(null)
|
||||
const [fontsOpen, setFontsOpen] = useState(false)
|
||||
useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid])
|
||||
|
||||
// ── Bulk-panel state ────────────────────────────────────────────────────
|
||||
const isBulk = selectedUids?.size >= 2
|
||||
|
|
@ -401,63 +399,30 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
))}
|
||||
</div>
|
||||
|
||||
{detail.artifacts.length > 0 && (() => {
|
||||
const indexed = detail.artifacts.map((a, i) => ({ ...a, _idx: i }))
|
||||
const fonts = indexed.filter(a => a.artifact_role === 'font')
|
||||
const others = indexed.filter(a => a.artifact_role !== 'font')
|
||||
const fontTotalBytes = fonts.reduce((s, a) => s + (a.byte_size || 0), 0)
|
||||
const entryUid = detail.summary.entry_uid
|
||||
const renderRow = (artifact) => (
|
||||
<li key={artifact._idx}>
|
||||
<a
|
||||
href={`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${artifact._idx}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="artifact-link"
|
||||
>
|
||||
<span className="artifact-name">
|
||||
{artifact.artifact_role === 'font'
|
||||
? artifact.relpath.split('/').pop()
|
||||
: artifact.artifact_role.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="artifact-size">
|
||||
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
return (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">
|
||||
Artifacts <span className="num">{detail.artifacts.length}</span>
|
||||
</div>
|
||||
<ul className="artifact-list">
|
||||
{others.map(renderRow)}
|
||||
{fonts.length > 0 && (
|
||||
<li className="artifact-group">
|
||||
<button
|
||||
type="button"
|
||||
className="artifact-group-header artifact-link"
|
||||
aria-expanded={fontsOpen}
|
||||
onClick={() => setFontsOpen(o => !o)}
|
||||
>
|
||||
<span className="artifact-name">
|
||||
<span aria-hidden="true" className={`artifact-group-chevron${fontsOpen ? ' open' : ''}`}>›</span>
|
||||
{` fonts (${fonts.length})`}
|
||||
</span>
|
||||
<span className="artifact-size">{formatBytes(fontTotalBytes)}</span>
|
||||
</button>
|
||||
{fontsOpen && (
|
||||
<ul className="artifact-list artifact-group-body">
|
||||
{fonts.map(renderRow)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{detail.artifacts.length > 0 && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">
|
||||
Artifacts <span className="num">{detail.artifacts.length}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<ul className="artifact-list">
|
||||
{detail.artifacts.map((artifact, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href={`/api/archives/${archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="artifact-link"
|
||||
>
|
||||
<span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
|
||||
<span className="artifact-size">
|
||||
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,28 +9,8 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
|||
const [detail, setDetail] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
// Set to true by ArticleRenderer when the entry turns out to be an X article,
|
||||
// triggering the X dark palette on the page shell.
|
||||
const [xArticle, setXArticle] = useState(false)
|
||||
|
||||
// When displaying an X article, make the document scroll (not an inner div)
|
||||
// and apply color-scheme:dark so native scrollbars match the dark palette.
|
||||
useEffect(() => {
|
||||
if (!xArticle) return
|
||||
const html = document.documentElement
|
||||
const body = document.body
|
||||
const prevScheme = html.style.colorScheme
|
||||
const prevBg = body.style.background
|
||||
html.style.colorScheme = 'dark'
|
||||
body.style.background = '#000'
|
||||
return () => {
|
||||
html.style.colorScheme = prevScheme
|
||||
body.style.background = prevBg
|
||||
}
|
||||
}, [xArticle])
|
||||
|
||||
useEffect(() => {
|
||||
setXArticle(false)
|
||||
fetchEntryDetail(archiveId, entryUid)
|
||||
.then(d => { setDetail(d); setLoading(false) })
|
||||
.catch(e => { setError(e?.message || 'Failed to load entry'); setLoading(false) })
|
||||
|
|
@ -44,33 +24,28 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
|||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: xArticle ? '#000' : 'var(--paper)',
|
||||
background: 'var(--paper)',
|
||||
fontFamily: 'var(--sans)',
|
||||
}}>
|
||||
{/* Topbar — sticky + blur when showing an X article, matching a-topbar in the renderer */}
|
||||
{/* Minimal topbar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: xArticle ? '8px 12px' : '10px 16px',
|
||||
borderBottom: xArticle ? 'none' : '1px solid var(--line)',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
flexShrink: 0,
|
||||
position: xArticle ? 'sticky' : 'relative',
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
background: xArticle ? 'rgba(0,0,0,0.82)' : 'var(--paper-2)',
|
||||
backdropFilter: xArticle ? 'blur(12px)' : 'none',
|
||||
WebkitBackdropFilter: xArticle ? 'blur(12px)' : 'none',
|
||||
background: 'var(--paper-2)',
|
||||
}}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ color: xArticle ? '#1d9bf0' : 'var(--accent)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
||||
style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
||||
>← Archive</a>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: xArticle ? '#e7e9ea' : 'var(--ink)',
|
||||
color: 'var(--ink)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
@ -80,15 +55,13 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
|||
href={originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: xArticle ? '#71767b' : 'var(--muted)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontSize: '13px', flexShrink: 0 }}
|
||||
>Original ↗</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={xArticle
|
||||
? { flex: 1 }
|
||||
: { flex: 1, minHeight: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<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…
|
||||
|
|
@ -104,8 +77,6 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
|||
archiveId={archiveId}
|
||||
entry={detail.summary}
|
||||
detail={detail}
|
||||
fullPage
|
||||
onXArticle={setXArticle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +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, fullPage, onXArticle }) {
|
||||
export default function PreviewPanel({ archiveId, entry, detail }) {
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
|
|
@ -35,20 +35,16 @@ export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXAr
|
|||
|
||||
// 1. Tweet / tweet thread
|
||||
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
|
||||
const tweetPreview = (
|
||||
<TweetPreview
|
||||
archiveId={archiveId}
|
||||
entryUid={entryUid}
|
||||
artifacts={artifacts}
|
||||
entityKind={entityKind}
|
||||
fullPage={fullPage}
|
||||
onXArticle={onXArticle}
|
||||
/>
|
||||
return (
|
||||
<div className="preview-tweet-wrap">
|
||||
<TweetPreview
|
||||
archiveId={archiveId}
|
||||
entryUid={entryUid}
|
||||
artifacts={artifacts}
|
||||
entityKind={entityKind}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// In fullPage mode the parent (PreviewPage) is the scroll container;
|
||||
// skip preview-tweet-wrap to avoid nested overflow/scroll.
|
||||
if (fullPage) return tweetPreview;
|
||||
return <div className="preview-tweet-wrap">{tweetPreview}</div>;
|
||||
}
|
||||
|
||||
// 2. Find primary_media artifact
|
||||
|
|
|
|||
|
|
@ -415,84 +415,6 @@ const S = {
|
|||
},
|
||||
};
|
||||
|
||||
// ── X dark palette (mirroring x-article-renderer) ──────────────────────────────
|
||||
const X = {
|
||||
bg: '#000000',
|
||||
border: '#2f3336',
|
||||
text: '#e7e9ea',
|
||||
dim: '#71767b',
|
||||
accent: '#1d9bf0',
|
||||
codeBg: '#16181c',
|
||||
};
|
||||
|
||||
// ── Full-page X article styles (mirrors x-article-renderer CSS) ──────────────────
|
||||
// Used only when ArticleRenderer is rendered from PreviewPage (fullPage=true).
|
||||
// Colors are hard-coded to X's dark palette; layout matches the standalone renderer.
|
||||
const SF = {
|
||||
// Article container — fills the scrollable page area, dark bg
|
||||
article: {
|
||||
background: X.bg,
|
||||
color: X.text,
|
||||
minHeight: '100%',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
},
|
||||
// Inner content — constrained to 598px, matches #article-content in renderer
|
||||
articleInner: {
|
||||
maxWidth: '598px',
|
||||
margin: '0 auto',
|
||||
paddingBottom: '80px',
|
||||
},
|
||||
aCover: { width: '100%', display: 'block', objectFit: 'cover', maxHeight: '420px' },
|
||||
aMeta: { padding: '20px 16px 0' },
|
||||
aTweetTitle: {
|
||||
fontSize: '34px', fontWeight: '800', letterSpacing: '-0.5px',
|
||||
color: X.text, lineHeight: '44px', 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: X.border, flexShrink: 0 },
|
||||
aAuthorName: { fontSize: '15px', fontWeight: '700', color: X.text, lineHeight: '1.3' },
|
||||
aAuthorSub: { fontSize: '15px', color: X.dim, lineHeight: '1.3' },
|
||||
aDivider: { border: 'none', borderTop: `1px solid ${X.border}`, margin: '0' },
|
||||
aBody: { padding: '4px 16px 0' },
|
||||
// Block styles
|
||||
bH1: {
|
||||
fontSize: '26px', fontWeight: '800', letterSpacing: '-0.4px',
|
||||
color: X.text, lineHeight: '36px', margin: '24px 0 10px',
|
||||
},
|
||||
bH2: {
|
||||
fontSize: '26px', fontWeight: '800', letterSpacing: '-0.4px',
|
||||
color: X.text, lineHeight: '36px', margin: '24px 0 10px',
|
||||
},
|
||||
bP: { fontSize: '17px', color: X.text, lineHeight: '1.5', marginBottom: '16px', marginTop: '0' },
|
||||
bSpacer: { height: '6px', display: 'block' },
|
||||
bQuote: { borderLeft: `3px solid ${X.border}`, padding: '2px 14px', margin: '14px 0', color: X.dim, fontSize: '17px', lineHeight: '1.5' },
|
||||
bHr: { border: 'none', borderTop: `1px solid ${X.border}`, 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: X.text, lineHeight: '1.5', marginBottom: '6px' },
|
||||
bTweet: {
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
border: `1px solid ${X.border}`, borderRadius: '12px',
|
||||
padding: '14px 16px', margin: '14px 0',
|
||||
color: X.dim, fontSize: '15px', textDecoration: 'none',
|
||||
},
|
||||
bMdPre: { borderRadius: '12px', margin: '12px 0', overflow: 'auto', background: '#1e2029' },
|
||||
bMdCode: {
|
||||
fontFamily: "ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
|
||||
fontSize: '13px', lineHeight: '1.65',
|
||||
padding: '18px 20px', display: 'block',
|
||||
color: X.text, background: 'transparent',
|
||||
},
|
||||
iCode: {
|
||||
fontFamily: "ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",
|
||||
fontSize: '0.875em', background: X.codeBg,
|
||||
padding: '2px 6px', borderRadius: '4px', color: '#e6edf3',
|
||||
},
|
||||
link: { color: X.accent, textDecoration: 'none' },
|
||||
};
|
||||
|
||||
// ── 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).
|
||||
|
|
@ -668,7 +590,7 @@ function renderTweetTextJSX(fullText, entities, skipSpans = []) {
|
|||
// 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, st = S) {
|
||||
function renderInlineJSX(text, styleRanges, urls, mentions) {
|
||||
if (!text) return null;
|
||||
styleRanges = styleRanges || [];
|
||||
urls = urls || [];
|
||||
|
|
@ -688,7 +610,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
|||
if (ann) anns.push(ann);
|
||||
}
|
||||
|
||||
if (anns.length === 0) return linkifyText(text, st.link);
|
||||
if (anns.length === 0) return linkifyText(text, S.link);
|
||||
|
||||
const pts = new Set([0, text.length]);
|
||||
for (const a of anns) {
|
||||
|
|
@ -715,7 +637,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
|||
|
||||
// Apply inline styles innermost first (matching Draft.js precedence).
|
||||
if (active.some(a => a.kind === 'style' && a.style === 'Code'))
|
||||
content = <code style={st.iCode}>{content}</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'))
|
||||
|
|
@ -732,7 +654,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
|||
// human-readable display URL; otherwise keep the styled anchor text.
|
||||
const isTco = /^https?:\/\/t\.co\//i.test(content);
|
||||
content = (
|
||||
<a href={url.href} target="_blank" rel="noopener noreferrer" style={st.link}>
|
||||
<a href={url.href} target="_blank" rel="noopener noreferrer" style={S.link}>
|
||||
{isTco ? url.display : content}
|
||||
</a>
|
||||
);
|
||||
|
|
@ -745,14 +667,14 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
|||
href={`https://x.com/${mention.screen_name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={st.link}
|
||||
style={S.link}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={i}>{typeof content === 'string' ? linkifyText(content, st.link) : content}</span>;
|
||||
return <span key={i}>{typeof content === 'string' ? linkifyText(content, S.link) : content}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -760,14 +682,13 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
|||
// Port of renderAtomic() from x-article-renderer.
|
||||
|
||||
function renderAtomicJSX(block, artifactMap, opts) {
|
||||
const st = opts?.st || S;
|
||||
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={st.bHr} />;
|
||||
return <hr key={i} style={S.bHr} />;
|
||||
|
||||
case 'media': {
|
||||
const src = resolveUrl(e.local_path, e.url, artifactMap);
|
||||
|
|
@ -786,7 +707,7 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<img src={src} style={st.bImg} loading="lazy" alt="" />
|
||||
<img src={src} style={S.bImg} loading="lazy" alt="" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -798,7 +719,7 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
|||
href={`https://x.com/i/status/${e.tweet_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={st.bTweet}
|
||||
style={S.bTweet}
|
||||
>
|
||||
<XLogo />
|
||||
View post on X
|
||||
|
|
@ -807,8 +728,8 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
|||
|
||||
case 'link':
|
||||
return e.url ? (
|
||||
<p key={i} style={st.bP}>
|
||||
<a href={e.url} target="_blank" rel="noopener noreferrer" style={st.link}>
|
||||
<p key={i} style={S.bP}>
|
||||
<a href={e.url} target="_blank" rel="noopener noreferrer" style={S.link}>
|
||||
{e.url}
|
||||
</a>
|
||||
</p>
|
||||
|
|
@ -817,8 +738,8 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
|||
case 'markdown': {
|
||||
const md = e.markdown ?? e.data?.markdown ?? '';
|
||||
return (
|
||||
<pre key={i} style={st.bMdPre}>
|
||||
<code style={st.bMdCode}>{md}</code>
|
||||
<pre key={i} style={S.bMdPre}>
|
||||
<code style={S.bMdCode}>{md}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
|
@ -843,16 +764,15 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
|||
// Port of renderBlock() from x-article-renderer.
|
||||
|
||||
function renderBlockJSX(block, key, artifactMap, opts) {
|
||||
const st = opts?.st || S;
|
||||
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 || [], st);
|
||||
const inner = renderInlineJSX(text, styleRanges, data.urls || [], data.mentions || []);
|
||||
|
||||
switch (type) {
|
||||
case 'header-one':
|
||||
return <h1 key={key} style={st.bH1}>{inner}</h1>;
|
||||
return <h1 key={key} style={S.bH1}>{inner}</h1>;
|
||||
|
||||
case 'header-two': {
|
||||
const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);
|
||||
|
|
@ -863,32 +783,32 @@ function renderBlockJSX(block, key, artifactMap, opts) {
|
|||
href={`https://x.com/i/status/${m[1]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={st.bTweet}
|
||||
style={S.bTweet}
|
||||
>
|
||||
<XLogo />
|
||||
View post on X
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <h2 key={key} style={st.bH2}>{inner}</h2>;
|
||||
return <h2 key={key} style={S.bH2}>{inner}</h2>;
|
||||
}
|
||||
|
||||
case 'unstyled':
|
||||
if (!text.trim()) return <span key={key} style={st.bSpacer} />;
|
||||
return <p key={key} style={st.bP}>{inner}</p>;
|
||||
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={st.bQuote}>{inner}</blockquote>;
|
||||
return <blockquote key={key} style={S.bQuote}>{inner}</blockquote>;
|
||||
|
||||
case 'unordered-list-item':
|
||||
case 'ordered-list-item':
|
||||
return <li key={key} style={st.bLi}>{inner}</li>;
|
||||
return <li key={key} style={S.bLi}>{inner}</li>;
|
||||
|
||||
case 'atomic':
|
||||
return <span key={key}>{renderAtomicJSX(block, artifactMap, opts)}</span>;
|
||||
|
||||
default:
|
||||
return text ? <p key={key} style={st.bP}>{inner}</p> : null;
|
||||
return text ? <p key={key} style={S.bP}>{inner}</p> : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -897,7 +817,6 @@ function renderBlockJSX(block, key, artifactMap, opts) {
|
|||
// Groups consecutive same-type list items into a single ul/ol.
|
||||
|
||||
function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||
const st = opts?.st || S;
|
||||
const items = [];
|
||||
let i = 0;
|
||||
|
||||
|
|
@ -911,7 +830,7 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
|||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||
i++;
|
||||
}
|
||||
items.push(<ul key={`ul-${startIdx}`} style={st.bUl}>{listItems}</ul>);
|
||||
items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
|
||||
} else if (b.type === 'ordered-list-item') {
|
||||
const startIdx = i;
|
||||
const listItems = [];
|
||||
|
|
@ -919,7 +838,7 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
|||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||
i++;
|
||||
}
|
||||
items.push(<ol key={`ol-${startIdx}`} style={st.bOl}>{listItems}</ol>);
|
||||
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
|
||||
} else {
|
||||
items.push(renderBlockJSX(b, i, artifactMap, opts));
|
||||
i++;
|
||||
|
|
@ -931,15 +850,9 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
|||
|
||||
// ── Article renderer ───────────────────────────────────────────────────────────
|
||||
|
||||
function ArticleRenderer({ article, tweetAuthor, artifactMap, fullPage, onXArticle }) {
|
||||
function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
||||
const [lightboxSrc, setLightboxSrc] = useState(null);
|
||||
|
||||
// Notify the page shell (PreviewPage) that it should switch to X dark palette.
|
||||
useEffect(() => {
|
||||
if (fullPage) onXArticle?.(true);
|
||||
}, [fullPage, onXArticle]);
|
||||
|
||||
const st = fullPage ? SF : S;
|
||||
const cover = article.cover_media || {};
|
||||
const author = article.author || tweetAuthor || {};
|
||||
const date = article.first_published_at_secs
|
||||
|
|
@ -951,8 +864,8 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap, fullPage, onXArtic
|
|||
const coverSrc = resolveUrl(cover.local_path, cover.url, artifactMap);
|
||||
const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
return (
|
||||
<div style={S.article}>
|
||||
{lightboxSrc && (
|
||||
<MediaLightbox
|
||||
items={[{ src: lightboxSrc, alt: '' }]}
|
||||
|
|
@ -968,44 +881,34 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap, fullPage, onXArtic
|
|||
style={{ display: 'block', cursor: 'zoom-in' }}
|
||||
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setLightboxSrc(coverSrc); } }}
|
||||
>
|
||||
<img src={coverSrc} style={st.aCover} alt="Article cover" />
|
||||
<img src={coverSrc} style={S.aCover} alt="Article cover" />
|
||||
</a>
|
||||
)}
|
||||
<div style={st.aMeta}>
|
||||
<div style={S.aMeta}>
|
||||
{article.title && (
|
||||
<div style={st.aTweetTitle}>{article.title}</div>
|
||||
<div style={S.aTweetTitle}>{article.title}</div>
|
||||
)}
|
||||
<div style={st.aAuthorRow}>
|
||||
<div style={S.aAuthorRow}>
|
||||
{avatarSrc
|
||||
? <img src={avatarSrc} style={st.aAvatar} alt={author.name || ''} />
|
||||
: <div style={st.aAvatarPh} />
|
||||
? <img src={avatarSrc} style={S.aAvatar} alt={author.name || ''} />
|
||||
: <div style={S.aAvatarPh} />
|
||||
}
|
||||
<div>
|
||||
<div style={st.aAuthorName}>
|
||||
<div style={S.aAuthorName}>
|
||||
{author.name || author.screen_name || 'Unknown'}
|
||||
</div>
|
||||
{subLine && (
|
||||
<div style={st.aAuthorSub}>{subLine}</div>
|
||||
<div style={S.aAuthorSub}>{subLine}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr style={st.aDivider} />
|
||||
<div style={st.aBody}>
|
||||
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc, st })}
|
||||
<hr style={S.aDivider} />
|
||||
<div style={S.aBody}>
|
||||
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc })}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div style={SF.article}>
|
||||
<div style={SF.articleInner}>{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div style={S.article}>{body}</div>;
|
||||
}
|
||||
|
||||
// ── Tweet card ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1245,7 +1148,7 @@ function TweetCard({ tweet, isInThread, isLast, artifactMap }) {
|
|||
|
||||
// ── TweetPreview ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TweetPreview({ archiveId, entryUid, artifacts, entityKind, fullPage, onXArticle }) {
|
||||
export default function TweetPreview({ archiveId, entryUid, artifacts, entityKind }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [tweets, setTweets] = useState([]);
|
||||
|
|
@ -1355,7 +1258,7 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
|
|||
|
||||
const tweet = tweets[0];
|
||||
if (tweet.is_article && tweet.article) {
|
||||
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} fullPage={fullPage} onXArticle={onXArticle} />;
|
||||
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -515,22 +515,6 @@ select {
|
|||
.artifact-name { font-size: 13px; color: var(--link); min-width: 0; word-break: break-word; }
|
||||
.artifact-link:hover .artifact-name { text-decoration: underline; }
|
||||
.artifact-size { font-size: 11.5px; color: var(--muted-2); flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
.artifact-group { list-style: none; }
|
||||
.artifact-group-header {
|
||||
width: 100%; background: none; border: none; border-bottom: 1px solid var(--line-soft);
|
||||
cursor: pointer; text-align: left; color: inherit; font: inherit;
|
||||
}
|
||||
.artifact-group-header:hover .artifact-name { text-decoration: underline; }
|
||||
.artifact-group-chevron {
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
transition: transform 0.15s ease;
|
||||
transform: rotate(0deg);
|
||||
transform-origin: 45% 50%;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.artifact-group-chevron.open { transform: rotate(90deg); }
|
||||
.artifact-group-body { padding-left: 14px; }
|
||||
|
||||
/* tags */
|
||||
.tags-wrap { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 12px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue