mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Db35_tmT.js"></script>
|
<script type="module" crossorigin src="/assets/index-B7dq_VAM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-nbPbdgtG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -464,23 +464,6 @@ export default function App() {
|
||||||
setPreviewEntryUid(null)
|
setPreviewEntryUid(null)
|
||||||
}, [selectedEntry])
|
}, [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
|
// Toggle body class so fixed AudioBar doesn't obscure scrollable content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle('has-audio-bar', !!currentAudio)
|
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 [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
|
||||||
const [rearchiveError, setRearchiveError] = useState('')
|
const [rearchiveError, setRearchiveError] = useState('')
|
||||||
const rearchivePollRef = useRef(null)
|
const rearchivePollRef = useRef(null)
|
||||||
const [fontsOpen, setFontsOpen] = useState(false)
|
|
||||||
useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid])
|
|
||||||
|
|
||||||
// ── Bulk-panel state ────────────────────────────────────────────────────
|
// ── Bulk-panel state ────────────────────────────────────────────────────
|
||||||
const isBulk = selectedUids?.size >= 2
|
const isBulk = selectedUids?.size >= 2
|
||||||
|
|
@ -401,63 +399,30 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.artifacts.length > 0 && (() => {
|
{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">
|
||||||
<div className="rail-section-heading">
|
<div className="rail-section-heading">
|
||||||
Artifacts <span className="num">{detail.artifacts.length}</span>
|
Artifacts <span className="num">{detail.artifacts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="artifact-list">
|
<ul className="artifact-list">
|
||||||
{others.map(renderRow)}
|
{detail.artifacts.map((artifact, index) => (
|
||||||
{fonts.length > 0 && (
|
<li key={index}>
|
||||||
<li className="artifact-group">
|
<a
|
||||||
<button
|
href={`/api/archives/${archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`}
|
||||||
type="button"
|
target="_blank"
|
||||||
className="artifact-group-header artifact-link"
|
rel="noopener noreferrer"
|
||||||
aria-expanded={fontsOpen}
|
className="artifact-link"
|
||||||
onClick={() => setFontsOpen(o => !o)}
|
|
||||||
>
|
>
|
||||||
<span className="artifact-name">
|
<span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
|
||||||
<span aria-hidden="true" className={`artifact-group-chevron${fontsOpen ? ' open' : ''}`}>›</span>
|
<span className="artifact-size">
|
||||||
{` fonts (${fonts.length})`}
|
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="artifact-size">{formatBytes(fontTotalBytes)}</span>
|
</a>
|
||||||
</button>
|
|
||||||
{fontsOpen && (
|
|
||||||
<ul className="artifact-list artifact-group-body">
|
|
||||||
{fonts.map(renderRow)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})()}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,8 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
||||||
const [detail, setDetail] = useState(null)
|
const [detail, setDetail] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
setXArticle(false)
|
|
||||||
fetchEntryDetail(archiveId, entryUid)
|
fetchEntryDetail(archiveId, entryUid)
|
||||||
.then(d => { setDetail(d); setLoading(false) })
|
.then(d => { setDetail(d); setLoading(false) })
|
||||||
.catch(e => { setError(e?.message || 'Failed to load entry'); 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',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
background: xArticle ? '#000' : 'var(--paper)',
|
background: 'var(--paper)',
|
||||||
fontFamily: 'var(--sans)',
|
fontFamily: 'var(--sans)',
|
||||||
}}>
|
}}>
|
||||||
{/* Topbar — sticky + blur when showing an X article, matching a-topbar in the renderer */}
|
{/* Minimal topbar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
padding: xArticle ? '8px 12px' : '10px 16px',
|
padding: '10px 16px',
|
||||||
borderBottom: xArticle ? 'none' : '1px solid var(--line)',
|
borderBottom: '1px solid var(--line)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
position: xArticle ? 'sticky' : 'relative',
|
background: 'var(--paper-2)',
|
||||||
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',
|
|
||||||
}}>
|
}}>
|
||||||
<a
|
<a
|
||||||
href="/"
|
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>
|
>← Archive</a>
|
||||||
<span style={{
|
<span style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: xArticle ? '#e7e9ea' : 'var(--ink)',
|
color: 'var(--ink)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|
@ -80,15 +55,13 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
||||||
href={originalUrl}
|
href={originalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
>Original ↗</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={xArticle
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
? { flex: 1 }
|
|
||||||
: { flex: 1, minHeight: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--muted)', fontSize: '14px' }}>
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--muted)', fontSize: '14px' }}>
|
||||||
Loading…
|
Loading…
|
||||||
|
|
@ -104,8 +77,6 @@ export default function PreviewPage({ archiveId, entryUid }) {
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
entry={detail.summary}
|
entry={detail.summary}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
fullPage
|
|
||||||
onXArticle={setXArticle}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
|
||||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
|
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
|
||||||
|
|
||||||
export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXArticle }) {
|
export default function PreviewPanel({ archiveId, entry, detail }) {
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -35,20 +35,16 @@ export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXAr
|
||||||
|
|
||||||
// 1. Tweet / tweet thread
|
// 1. Tweet / tweet thread
|
||||||
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
|
if (entityKind === 'tweet' || entityKind === 'tweet_thread') {
|
||||||
const tweetPreview = (
|
return (
|
||||||
|
<div className="preview-tweet-wrap">
|
||||||
<TweetPreview
|
<TweetPreview
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
entryUid={entryUid}
|
entryUid={entryUid}
|
||||||
artifacts={artifacts}
|
artifacts={artifacts}
|
||||||
entityKind={entityKind}
|
entityKind={entityKind}
|
||||||
fullPage={fullPage}
|
|
||||||
onXArticle={onXArticle}
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
// 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 ────────────────────────────────────────────────────────
|
// ── Artifact URL helpers ────────────────────────────────────────────────────────
|
||||||
// Build relpath → artifact API URL map from the artifacts array so archived
|
// Build relpath → artifact API URL map from the artifacts array so archived
|
||||||
// copies are preferred over CDN URLs (avatars, media, cover images).
|
// 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
|
// Splits block text at style-range, URL, and mention boundaries, returning
|
||||||
// an array of React nodes with the appropriate wrappers applied.
|
// 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;
|
if (!text) return null;
|
||||||
styleRanges = styleRanges || [];
|
styleRanges = styleRanges || [];
|
||||||
urls = urls || [];
|
urls = urls || [];
|
||||||
|
|
@ -688,7 +610,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
||||||
if (ann) anns.push(ann);
|
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]);
|
const pts = new Set([0, text.length]);
|
||||||
for (const a of anns) {
|
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).
|
// Apply inline styles innermost first (matching Draft.js precedence).
|
||||||
if (active.some(a => a.kind === 'style' && a.style === 'Code'))
|
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'))
|
if (active.some(a => a.kind === 'style' && a.style === 'Bold'))
|
||||||
content = <strong>{content}</strong>;
|
content = <strong>{content}</strong>;
|
||||||
if (active.some(a => a.kind === 'style' && a.style === 'Italic'))
|
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.
|
// human-readable display URL; otherwise keep the styled anchor text.
|
||||||
const isTco = /^https?:\/\/t\.co\//i.test(content);
|
const isTco = /^https?:\/\/t\.co\//i.test(content);
|
||||||
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}
|
{isTco ? url.display : content}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
@ -745,14 +667,14 @@ function renderInlineJSX(text, styleRanges, urls, mentions, st = S) {
|
||||||
href={`https://x.com/${mention.screen_name}`}
|
href={`https://x.com/${mention.screen_name}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={st.link}
|
style={S.link}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</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.
|
// Port of renderAtomic() from x-article-renderer.
|
||||||
|
|
||||||
function renderAtomicJSX(block, artifactMap, opts) {
|
function renderAtomicJSX(block, artifactMap, opts) {
|
||||||
const st = opts?.st || S;
|
|
||||||
const entities = block.resolved_entities || [];
|
const entities = block.resolved_entities || [];
|
||||||
if (entities.length === 0) return null;
|
if (entities.length === 0) return null;
|
||||||
|
|
||||||
return entities.map((e, i) => {
|
return entities.map((e, i) => {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return <hr key={i} style={st.bHr} />;
|
return <hr key={i} style={S.bHr} />;
|
||||||
|
|
||||||
case 'media': {
|
case 'media': {
|
||||||
const src = resolveUrl(e.local_path, e.url, artifactMap);
|
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>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -798,7 +719,7 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
||||||
href={`https://x.com/i/status/${e.tweet_id}`}
|
href={`https://x.com/i/status/${e.tweet_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={st.bTweet}
|
style={S.bTweet}
|
||||||
>
|
>
|
||||||
<XLogo />
|
<XLogo />
|
||||||
View post on X
|
View post on X
|
||||||
|
|
@ -807,8 +728,8 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
||||||
|
|
||||||
case 'link':
|
case 'link':
|
||||||
return e.url ? (
|
return e.url ? (
|
||||||
<p key={i} style={st.bP}>
|
<p key={i} style={S.bP}>
|
||||||
<a href={e.url} target="_blank" rel="noopener noreferrer" style={st.link}>
|
<a href={e.url} target="_blank" rel="noopener noreferrer" style={S.link}>
|
||||||
{e.url}
|
{e.url}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -817,8 +738,8 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
||||||
case 'markdown': {
|
case 'markdown': {
|
||||||
const md = e.markdown ?? e.data?.markdown ?? '';
|
const md = e.markdown ?? e.data?.markdown ?? '';
|
||||||
return (
|
return (
|
||||||
<pre key={i} style={st.bMdPre}>
|
<pre key={i} style={S.bMdPre}>
|
||||||
<code style={st.bMdCode}>{md}</code>
|
<code style={S.bMdCode}>{md}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -843,16 +764,15 @@ function renderAtomicJSX(block, artifactMap, opts) {
|
||||||
// Port of renderBlock() from x-article-renderer.
|
// Port of renderBlock() from x-article-renderer.
|
||||||
|
|
||||||
function renderBlockJSX(block, key, artifactMap, opts) {
|
function renderBlockJSX(block, key, artifactMap, opts) {
|
||||||
const st = opts?.st || S;
|
|
||||||
const type = block.type || '';
|
const type = block.type || '';
|
||||||
const text = block.text || '';
|
const text = block.text || '';
|
||||||
const styleRanges = block.inline_style_ranges || [];
|
const styleRanges = block.inline_style_ranges || [];
|
||||||
const data = block.data || {};
|
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) {
|
switch (type) {
|
||||||
case 'header-one':
|
case 'header-one':
|
||||||
return <h1 key={key} style={st.bH1}>{inner}</h1>;
|
return <h1 key={key} style={S.bH1}>{inner}</h1>;
|
||||||
|
|
||||||
case 'header-two': {
|
case 'header-two': {
|
||||||
const m = text.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);
|
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]}`}
|
href={`https://x.com/i/status/${m[1]}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={st.bTweet}
|
style={S.bTweet}
|
||||||
>
|
>
|
||||||
<XLogo />
|
<XLogo />
|
||||||
View post on X
|
View post on X
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <h2 key={key} style={st.bH2}>{inner}</h2>;
|
return <h2 key={key} style={S.bH2}>{inner}</h2>;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'unstyled':
|
case 'unstyled':
|
||||||
if (!text.trim()) return <span key={key} style={st.bSpacer} />;
|
if (!text.trim()) return <span key={key} style={S.bSpacer} />;
|
||||||
return <p key={key} style={st.bP}>{inner}</p>;
|
return <p key={key} style={S.bP}>{inner}</p>;
|
||||||
|
|
||||||
case 'blockquote':
|
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 'unordered-list-item':
|
||||||
case 'ordered-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':
|
case 'atomic':
|
||||||
return <span key={key}>{renderAtomicJSX(block, artifactMap, opts)}</span>;
|
return <span key={key}>{renderAtomicJSX(block, artifactMap, opts)}</span>;
|
||||||
|
|
||||||
default:
|
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.
|
// Groups consecutive same-type list items into a single ul/ol.
|
||||||
|
|
||||||
function renderBlocksJSX(blocks, artifactMap, opts) {
|
function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||||
const st = opts?.st || S;
|
|
||||||
const items = [];
|
const items = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
|
|
@ -911,7 +830,7 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||||
i++;
|
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') {
|
} else if (b.type === 'ordered-list-item') {
|
||||||
const startIdx = i;
|
const startIdx = i;
|
||||||
const listItems = [];
|
const listItems = [];
|
||||||
|
|
@ -919,7 +838,7 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
items.push(<ol key={`ol-${startIdx}`} style={st.bOl}>{listItems}</ol>);
|
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
|
||||||
} else {
|
} else {
|
||||||
items.push(renderBlockJSX(b, i, artifactMap, opts));
|
items.push(renderBlockJSX(b, i, artifactMap, opts));
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -931,15 +850,9 @@ function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||||
|
|
||||||
// ── Article renderer ───────────────────────────────────────────────────────────
|
// ── Article renderer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ArticleRenderer({ article, tweetAuthor, artifactMap, fullPage, onXArticle }) {
|
function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
||||||
const [lightboxSrc, setLightboxSrc] = useState(null);
|
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 cover = article.cover_media || {};
|
||||||
const author = article.author || tweetAuthor || {};
|
const author = article.author || tweetAuthor || {};
|
||||||
const date = article.first_published_at_secs
|
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 coverSrc = resolveUrl(cover.local_path, cover.url, artifactMap);
|
||||||
const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
|
const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
|
||||||
|
|
||||||
const body = (
|
return (
|
||||||
<>
|
<div style={S.article}>
|
||||||
{lightboxSrc && (
|
{lightboxSrc && (
|
||||||
<MediaLightbox
|
<MediaLightbox
|
||||||
items={[{ src: lightboxSrc, alt: '' }]}
|
items={[{ src: lightboxSrc, alt: '' }]}
|
||||||
|
|
@ -968,44 +881,34 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap, fullPage, onXArtic
|
||||||
style={{ display: 'block', cursor: 'zoom-in' }}
|
style={{ display: 'block', cursor: 'zoom-in' }}
|
||||||
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setLightboxSrc(coverSrc); } }}
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div style={st.aMeta}>
|
<div style={S.aMeta}>
|
||||||
{article.title && (
|
{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
|
{avatarSrc
|
||||||
? <img src={avatarSrc} style={st.aAvatar} alt={author.name || ''} />
|
? <img src={avatarSrc} style={S.aAvatar} alt={author.name || ''} />
|
||||||
: <div style={st.aAvatarPh} />
|
: <div style={S.aAvatarPh} />
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<div style={st.aAuthorName}>
|
<div style={S.aAuthorName}>
|
||||||
{author.name || author.screen_name || 'Unknown'}
|
{author.name || author.screen_name || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
{subLine && (
|
{subLine && (
|
||||||
<div style={st.aAuthorSub}>{subLine}</div>
|
<div style={S.aAuthorSub}>{subLine}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr style={st.aDivider} />
|
<hr style={S.aDivider} />
|
||||||
<div style={st.aBody}>
|
<div style={S.aBody}>
|
||||||
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc, st })}
|
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fullPage) {
|
|
||||||
return (
|
|
||||||
<div style={SF.article}>
|
|
||||||
<div style={SF.articleInner}>{body}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <div style={S.article}>{body}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tweet card ─────────────────────────────────────────────────────────────────
|
// ── Tweet card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1245,7 +1148,7 @@ function TweetCard({ tweet, isInThread, isLast, artifactMap }) {
|
||||||
|
|
||||||
// ── TweetPreview ───────────────────────────────────────────────────────────────
|
// ── 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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [tweets, setTweets] = useState([]);
|
const [tweets, setTweets] = useState([]);
|
||||||
|
|
@ -1355,7 +1258,7 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
|
||||||
|
|
||||||
const tweet = tweets[0];
|
const tweet = tweets[0];
|
||||||
if (tweet.is_article && tweet.article) {
|
if (tweet.is_article && tweet.article) {
|
||||||
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} fullPage={fullPage} onXArticle={onXArticle} />;
|
return <ArticleRenderer article={tweet.article} tweetAuthor={tweet.author} artifactMap={artifactMap} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -515,22 +515,6 @@ select {
|
||||||
.artifact-name { font-size: 13px; color: var(--link); min-width: 0; word-break: break-word; }
|
.artifact-name { font-size: 13px; color: var(--link); min-width: 0; word-break: break-word; }
|
||||||
.artifact-link:hover .artifact-name { text-decoration: underline; }
|
.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-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 */
|
||||||
.tags-wrap { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 12px; }
|
.tags-wrap { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 12px; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue