mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: tweet/article preview quality pass
HTML entities: decode > < & etc. on sliced segments only (entity offsets index the stored string; decoding before slicing shifts them) Image lightbox: click any tweet/thread/article image to open full-screen viewer; cmd+click follows <a> to open in new tab; arrow-key + ‹ › nav; Escape closes; 1/N counter; ↗ open-in-new-tab link Multi-image grid: 2 photos → side-by-side (180px rows); 3 → left spans both rows; 4 → 2×2 (140px rows); single image unchanged Empty media grid ghost: build photos/videoItems arrays first, render .mediaGrid div only when at least one item resolved (advisory: never gate on raw media.length when map items can all return null) QT indicator: ↻ QT badge on tweet.is_quote_status === true entries Modal shrinks for short content: height: 88vh → max-height: 88vh; .preview-modal-body gets max-height: calc(88vh - 52px) so long threads still scroll (advisory: don't rely on flex:1 once parent has no fixed height) Video scrollbar leak: .preview-modal-body overflow: auto → hidden; each child (tweet-wrap, video-wrap, iframe) manages its own scroll surface Styled thin scrollbar on .preview-tweet-wrap (matches workspace rail) ArticleRenderer: cover image and body images are lightbox-clickable; opts thread through renderBlocksJSX → renderBlockJSX → renderAtomicJSX
This commit is contained in:
parent
060dcfb531
commit
e7f58bb7ad
5 changed files with 378 additions and 101 deletions
File diff suppressed because one or more lines are too long
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-CGUwr6HO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Czw8P_Ze.css">
|
||||
<script type="module" crossorigin src="/assets/index-J6UnJptz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CLkuyO9A.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ function fmtDate(secs) {
|
|||
}).format(new Date(secs * 1000));
|
||||
}
|
||||
|
||||
// Decode common HTML entities Twitter stores in full_text.
|
||||
// MUST be called on already-sliced segments, not on the raw string before
|
||||
// entity-offset arithmetic, because entity offsets are into the stored text.
|
||||
function decodeEnt(str) {
|
||||
if (!str || !str.includes('&')) return str;
|
||||
// & first so &gt; → > → > (handles double-encoded entities)
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── Inline style definitions ───────────────────────────────────────────────────
|
||||
|
||||
const S = {
|
||||
|
|
@ -310,6 +325,93 @@ const S = {
|
|||
borderRadius: '4px',
|
||||
color: 'var(--ink)',
|
||||
},
|
||||
// ── QT badge ──
|
||||
qtBadge: {
|
||||
fontSize: '11px',
|
||||
color: 'var(--muted)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
marginLeft: '4px',
|
||||
letterSpacing: '0.03em',
|
||||
flexShrink: 0,
|
||||
},
|
||||
// ── Lightbox ──
|
||||
lightboxBackdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
},
|
||||
lightboxContent: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
},
|
||||
lightboxToolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
lightboxImg: {
|
||||
maxWidth: '88vw',
|
||||
maxHeight: '80vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '6px',
|
||||
display: 'block',
|
||||
},
|
||||
lightboxNav: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
lightboxNavBtn: {
|
||||
background: 'rgba(255,255,255,0.18)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
lightboxBtn: {
|
||||
background: 'rgba(255,255,255,0.18)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
lightboxLink: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
},
|
||||
lightboxCounter: {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: '13px',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Artifact URL helpers ────────────────────────────────────────────────────────
|
||||
|
|
@ -365,7 +467,7 @@ function renderTweetTextJSX(fullText, entities) {
|
|||
m => m.fromIndex != null && m.toIndex != null
|
||||
);
|
||||
|
||||
if (urls.length === 0 && mentions.length === 0) return fullText;
|
||||
if (urls.length === 0 && mentions.length === 0) return decodeEnt(fullText);
|
||||
|
||||
const anns = [
|
||||
...urls.map(u => ({
|
||||
|
|
@ -419,7 +521,7 @@ function renderTweetTextJSX(fullText, entities) {
|
|||
);
|
||||
}
|
||||
|
||||
return <span key={i}>{seg}</span>;
|
||||
return <span key={i}>{decodeEnt(seg)}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +614,7 @@ function renderInlineJSX(text, styleRanges, urls, mentions) {
|
|||
// ── Article atomic block renderer ──────────────────────────────────────────────
|
||||
// Port of renderAtomic() from x-article-renderer.
|
||||
|
||||
function renderAtomicJSX(block, artifactMap) {
|
||||
function renderAtomicJSX(block, artifactMap, opts) {
|
||||
const entities = block.resolved_entities || [];
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
|
|
@ -523,7 +625,24 @@ function renderAtomicJSX(block, artifactMap) {
|
|||
|
||||
case 'media': {
|
||||
const src = resolveUrl(e.local_path, e.url, artifactMap);
|
||||
return src ? <img key={i} src={src} style={S.bImg} loading="lazy" alt="" /> : null;
|
||||
if (!src) return null;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', cursor: 'zoom-in' }}
|
||||
onClick={ev => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
opts?.onImgClick?.(src);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={src} style={S.bImg} loading="lazy" alt="" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
case 'tweet':
|
||||
|
|
@ -577,7 +696,7 @@ function renderAtomicJSX(block, artifactMap) {
|
|||
// ── Article single block renderer ──────────────────────────────────────────────
|
||||
// Port of renderBlock() from x-article-renderer.
|
||||
|
||||
function renderBlockJSX(block, key, artifactMap) {
|
||||
function renderBlockJSX(block, key, artifactMap, opts) {
|
||||
const type = block.type || '';
|
||||
const text = block.text || '';
|
||||
const styleRanges = block.inline_style_ranges || [];
|
||||
|
|
@ -619,7 +738,7 @@ function renderBlockJSX(block, key, artifactMap) {
|
|||
return <li key={key} style={S.bLi}>{inner}</li>;
|
||||
|
||||
case 'atomic':
|
||||
return <span key={key}>{renderAtomicJSX(block, artifactMap)}</span>;
|
||||
return <span key={key}>{renderAtomicJSX(block, artifactMap, opts)}</span>;
|
||||
|
||||
default:
|
||||
return text ? <p key={key} style={S.bP}>{inner}</p> : null;
|
||||
|
|
@ -630,7 +749,7 @@ function renderBlockJSX(block, key, artifactMap) {
|
|||
// Port of renderBlocks() from x-article-renderer.
|
||||
// Groups consecutive same-type list items into a single ul/ol.
|
||||
|
||||
function renderBlocksJSX(blocks, artifactMap) {
|
||||
function renderBlocksJSX(blocks, artifactMap, opts) {
|
||||
const items = [];
|
||||
let i = 0;
|
||||
|
||||
|
|
@ -641,7 +760,7 @@ function renderBlocksJSX(blocks, artifactMap) {
|
|||
const startIdx = i;
|
||||
const listItems = [];
|
||||
while (i < blocks.length && blocks[i].type === 'unordered-list-item') {
|
||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
|
||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||
i++;
|
||||
}
|
||||
items.push(<ul key={`ul-${startIdx}`} style={S.bUl}>{listItems}</ul>);
|
||||
|
|
@ -649,12 +768,12 @@ function renderBlocksJSX(blocks, artifactMap) {
|
|||
const startIdx = i;
|
||||
const listItems = [];
|
||||
while (i < blocks.length && blocks[i].type === 'ordered-list-item') {
|
||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap));
|
||||
listItems.push(renderBlockJSX(blocks[i], i, artifactMap, opts));
|
||||
i++;
|
||||
}
|
||||
items.push(<ol key={`ol-${startIdx}`} style={S.bOl}>{listItems}</ol>);
|
||||
} else {
|
||||
items.push(renderBlockJSX(b, i, artifactMap));
|
||||
items.push(renderBlockJSX(b, i, artifactMap, opts));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
|
@ -665,6 +784,8 @@ function renderBlocksJSX(blocks, artifactMap) {
|
|||
// ── Article renderer ───────────────────────────────────────────────────────────
|
||||
|
||||
function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
||||
const [lightboxSrc, setLightboxSrc] = useState(null);
|
||||
|
||||
const cover = article.cover_media || {};
|
||||
const author = article.author || tweetAuthor || {};
|
||||
const date = article.first_published_at_secs
|
||||
|
|
@ -678,8 +799,23 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
|||
|
||||
return (
|
||||
<div style={S.article}>
|
||||
{lightboxSrc && (
|
||||
<MediaLightbox
|
||||
items={[{ src: lightboxSrc, alt: '' }]}
|
||||
startIndex={0}
|
||||
onClose={() => setLightboxSrc(null)}
|
||||
/>
|
||||
)}
|
||||
{coverSrc && (
|
||||
<img src={coverSrc} style={S.aCover} alt="Article cover" />
|
||||
<a
|
||||
href={coverSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', cursor: 'zoom-in' }}
|
||||
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setLightboxSrc(coverSrc); } }}
|
||||
>
|
||||
<img src={coverSrc} style={S.aCover} alt="Article cover" />
|
||||
</a>
|
||||
)}
|
||||
<div style={S.aMeta}>
|
||||
{article.title && (
|
||||
|
|
@ -702,7 +838,7 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
|||
</div>
|
||||
<hr style={S.aDivider} />
|
||||
<div style={S.aBody}>
|
||||
{renderBlocksJSX(article.blocks || [], artifactMap)}
|
||||
{renderBlocksJSX(article.blocks || [], artifactMap, { onImgClick: setLightboxSrc })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -712,95 +848,214 @@ function ArticleRenderer({ article, tweetAuthor, artifactMap }) {
|
|||
// 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).
|
||||
|
||||
|
||||
// ── Photo grid ─────────────────────────────────────────────────────────────────
|
||||
// Renders 1–4 photos in Twitter-style grid. Regular click = lightbox;
|
||||
// Cmd/Ctrl+click follows the <a> href to open the image in a new tab.
|
||||
function PhotoGrid({ photos, onOpen }) {
|
||||
const n = photos.length;
|
||||
if (n === 0) return null;
|
||||
|
||||
if (n === 1) {
|
||||
return (
|
||||
<a
|
||||
href={photos[0].src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block' }}
|
||||
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(0); } }}
|
||||
>
|
||||
<img src={photos[0].src} alt={photos[0].alt || ''} style={S.mediaImg} loading="lazy" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 → two columns, 1 row; 3 → left spans 2 rows; 4 → 2×2
|
||||
const rowH = n <= 2 ? '180px' : '140px';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: n === 2 ? rowH : `${rowH} ${rowH}`,
|
||||
gap: '2px',
|
||||
}}>
|
||||
{photos.map((ph, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={ph.src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
gridRow: (n === 3 && i === 0) ? 'span 2' : undefined,
|
||||
}}
|
||||
onClick={e => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); onOpen(i); } }}
|
||||
>
|
||||
<img
|
||||
src={ph.src}
|
||||
alt={ph.alt || ''}
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Media lightbox ─────────────────────────────────────────────────────────────
|
||||
// position:fixed escapes any overflow/stacking context.
|
||||
function MediaLightbox({ items, startIndex, onClose }) {
|
||||
const [idx, setIdx] = useState(startIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowRight') setIdx(i => Math.min(i + 1, items.length - 1));
|
||||
if (e.key === 'ArrowLeft') setIdx(i => Math.max(i - 1, 0));
|
||||
};
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose, items.length]);
|
||||
|
||||
const item = items[idx];
|
||||
return (
|
||||
<div style={S.lightboxBackdrop} onClick={onClose}>
|
||||
<div style={S.lightboxContent} onClick={e => e.stopPropagation()}>
|
||||
<div style={S.lightboxToolbar}>
|
||||
{items.length > 1 && (
|
||||
<span style={S.lightboxCounter}>{idx + 1} / {items.length}</span>
|
||||
)}
|
||||
<a
|
||||
href={item.src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={S.lightboxLink}
|
||||
title="Open in new tab"
|
||||
>↗</a>
|
||||
<button style={S.lightboxBtn} onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
<img src={item.src} alt={item.alt || ''} style={S.lightboxImg} />
|
||||
{items.length > 1 && (
|
||||
<div style={S.lightboxNav}>
|
||||
<button
|
||||
style={S.lightboxNavBtn}
|
||||
onClick={() => setIdx(i => Math.max(i - 1, 0))}
|
||||
disabled={idx === 0}
|
||||
>‹</button>
|
||||
<button
|
||||
style={S.lightboxNavBtn}
|
||||
onClick={() => setIdx(i => Math.min(i + 1, items.length - 1))}
|
||||
disabled={idx === items.length - 1}
|
||||
>›</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweetCard({ tweet, isInThread, isLast, artifactMap }) {
|
||||
const [lightboxIdx, setLightboxIdx] = useState(null);
|
||||
|
||||
const author = tweet.author || {};
|
||||
const date = tweet.created_at_secs ? fmtDate(tweet.created_at_secs) : '';
|
||||
const entities = tweet.entities || {};
|
||||
const avatarSrc = resolveUrl(author.avatar_local_path, author.avatar_url, artifactMap);
|
||||
const showConnector = isInThread && !isLast;
|
||||
|
||||
const isQT = tweet.is_quote_status === true;
|
||||
const rowStyle = isInThread ? S.tweetRowThread : S.tweetRow;
|
||||
|
||||
// Prefer extended_entities for video/multi-photo, fall back to entities.media
|
||||
const media = (tweet.extended_entities?.media?.length
|
||||
// Build resolved media lists first; skip the grid entirely if every item resolves to null.
|
||||
const rawMedia = (tweet.extended_entities?.media?.length
|
||||
? tweet.extended_entities.media
|
||||
: entities.media) || [];
|
||||
|
||||
const photos = [];
|
||||
const videoItems = [];
|
||||
for (const m of rawMedia) {
|
||||
if (m.type === 'photo') {
|
||||
const src = resolveUrl(m.local_path, m.media_url_https, artifactMap);
|
||||
if (src) photos.push({ kind: 'photo', src, alt: m.alt_text || '' });
|
||||
} else if (m.type === 'video' || m.type === 'animated_gif') {
|
||||
const src = (m.local_path && artifactMap[m.local_path])
|
||||
|| (() => {
|
||||
const variants = m.video_info?.variants || [];
|
||||
return variants
|
||||
.filter(v => v.content_type === 'video/mp4')
|
||||
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]?.url;
|
||||
})();
|
||||
if (src) videoItems.push({ kind: m.type === 'animated_gif' ? 'gif' : 'video', src });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<div style={S.leftCol}>
|
||||
{avatarSrc
|
||||
? <img src={avatarSrc} style={S.avatar} alt={author.name || ''} />
|
||||
: <div style={S.avatarPh} />
|
||||
}
|
||||
{showConnector && <div style={S.threadLine} />}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<>
|
||||
{lightboxIdx !== null && (
|
||||
<MediaLightbox
|
||||
items={photos}
|
||||
startIndex={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
/>
|
||||
)}
|
||||
<div style={rowStyle}>
|
||||
<div style={S.leftCol}>
|
||||
{avatarSrc
|
||||
? <img src={avatarSrc} style={S.avatar} alt={author.name || ''} />
|
||||
: <div style={S.avatarPh} />
|
||||
}
|
||||
{showConnector && <div style={S.threadLine} />}
|
||||
</div>
|
||||
|
||||
<div style={S.tweetText}>
|
||||
{renderTweetTextJSX(tweet.full_text || '', entities)}
|
||||
<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>}
|
||||
{isQT && (
|
||||
<span style={S.qtBadge} title="Quote tweet">↻ QT</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={S.tweetText}>
|
||||
{renderTweetTextJSX(tweet.full_text || '', entities)}
|
||||
</div>
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div style={S.mediaGrid}>
|
||||
<PhotoGrid photos={photos} onOpen={i => setLightboxIdx(i)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoItems.map((v, i) => (
|
||||
<div key={i} style={S.mediaGrid}>
|
||||
<video
|
||||
src={v.src}
|
||||
style={S.mediaVideo}
|
||||
controls
|
||||
loop={v.kind === 'gif'}
|
||||
muted={v.kind === 'gif'}
|
||||
autoPlay={v.kind === 'gif'}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{media.length > 0 && (
|
||||
<div style={S.mediaGrid}>
|
||||
{media.map((m, i) => {
|
||||
if (m.type === 'photo') {
|
||||
// Local archived file preferred; CDN thumbnail as fallback
|
||||
const src = resolveUrl(m.local_path, m.media_url_https, artifactMap);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<img key={i} src={src} style={S.mediaImg}
|
||||
alt={m.alt_text || ''} loading="lazy" />
|
||||
);
|
||||
}
|
||||
if (m.type === 'video' || m.type === 'animated_gif') {
|
||||
// Local archived file preferred; fall back to best-bitrate CDN mp4.
|
||||
// m.media_url_https is a thumbnail image, NOT a video — don't use it.
|
||||
const videoSrc = (m.local_path && artifactMap[m.local_path])
|
||||
|| (() => {
|
||||
const variants = m.video_info?.variants || [];
|
||||
return variants
|
||||
.filter(v => v.content_type === 'video/mp4')
|
||||
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]?.url;
|
||||
})();
|
||||
if (!videoSrc) return null;
|
||||
return (
|
||||
<video key={i} src={videoSrc} style={S.mediaVideo}
|
||||
controls
|
||||
loop={m.type === 'animated_gif'}
|
||||
muted={m.type === 'animated_gif'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(tweet.retweet_count > 0 || tweet.favorite_count > 0) && (
|
||||
<div style={S.stats}>
|
||||
{tweet.favorite_count > 0 && (
|
||||
<span>❤️ {tweet.favorite_count.toLocaleString()}</span>
|
||||
)}
|
||||
{tweet.retweet_count > 0 && (
|
||||
<span>🔁 {tweet.retweet_count.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2014,6 +2014,22 @@ select {
|
|||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
background: var(--paper-2);
|
||||
/* styled scrollbar matching the workspace rail */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
.preview-tweet-wrap::-webkit-scrollbar { width: 8px; }
|
||||
.preview-tweet-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||
.preview-tweet-wrap::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.preview-tweet-wrap::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-2);
|
||||
background-clip: padding-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* ── Tweet card ───────────────────────────────────────────────────── */
|
||||
|
|
@ -2340,7 +2356,7 @@ select {
|
|||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
||||
width: 90vw;
|
||||
max-width: 1100px;
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -2383,9 +2399,15 @@ select {
|
|||
.preview-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* overflow: hidden — each child (tweet-wrap, video-wrap, iframe, image)
|
||||
manages its own scroll. This also prevents the spurious scrollbar that
|
||||
appears when a video's native controls briefly expand the layout. */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Cap so long threads/articles stay scrollable even when modal has no
|
||||
fixed height (shrinks for short tweets). 52px ≈ header padding+border. */
|
||||
max-height: calc(88vh - 52px);
|
||||
}
|
||||
/* Preview button in context rail */
|
||||
.rail-preview-btn {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue