fix: archive.is Suche via HTML-Parse, Button-Farben gefixt

This commit is contained in:
2026-06-12 01:42:42 +02:00
parent 38c27ac5bb
commit 902d5fd99e
2 changed files with 146 additions and 137 deletions

View File

@@ -7,60 +7,71 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
// ── Hilfsfunktion: folgt Redirects, gibt finale URL zurück ─────────────── // ── HTTP GET, gibt finalUrl + statusCode + body (optional) zurück ─────────
function fetchFollowRedirects(url, redirectCount = 0) { function fetchPage(url, opts = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (redirectCount > 8) return reject(new Error('Zu viele Redirects')); if ((opts.redirects || 0) > 8) return reject(new Error('Zu viele Redirects'));
const mod = url.startsWith('https') ? https : http; const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, { const req = mod.get(url, {
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'de,en;q=0.5', 'Accept-Language': 'de,en;q=0.5',
}, },
timeout: 12000, timeout: 12000,
}, (res) => { }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
let location = res.headers.location; let loc = res.headers.location;
if (!location.startsWith('http')) { if (!loc.startsWith('http')) {
try { try { const b = new URL(url); loc = loc.startsWith('/') ? `${b.protocol}//${b.host}${loc}` : `${b.protocol}//${b.host}/${loc}`; }
const base = new URL(url); catch { return reject(new Error('Ungültige Redirect-URL')); }
location = location.startsWith('/')
? `${base.protocol}//${base.host}${location}`
: `${base.protocol}//${base.host}/${location}`;
} catch { return reject(new Error('Ungültige Redirect-URL')); }
} }
res.resume(); res.resume();
return fetchFollowRedirects(location, redirectCount + 1).then(resolve).catch(reject); return fetchPage(loc, { ...opts, redirects: (opts.redirects||0)+1 }).then(resolve).catch(reject);
} }
if (opts.bodyNeeded) {
let body = '';
res.setEncoding('utf8');
res.on('data', d => { if (body.length < 50000) body += d; });
res.on('end', () => resolve({ statusCode: res.statusCode, finalUrl: url, body }));
} else {
res.resume(); res.resume();
resolve({ statusCode: res.statusCode, finalUrl: url }); resolve({ statusCode: res.statusCode, finalUrl: url });
}
}); });
req.on('error', reject); req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
}); });
} }
// ── Prüfe ob archive.is einen Snapshot hat ──────────────────────────────── // ── Archivsuche über archive.is ───────────────────────────────────────────
// archive.is/newest/URL leitet auf archive.is/HASH weiter wenn ein Snapshot existiert. // archive.is/?url=ZIEL gibt HTML zurück mit Links zu archivierten Snapshots.
// Wenn kein Snapshot → Redirect zurück auf archive.is/ (Root) oder /newest/... bleibt stehen. // Wir parsen den ersten Snapshot-Link daraus.
async function checkArchive(archiveUrl) { async function findArchiveSnapshot(targetUrl) {
const searchUrl = `https://archive.is/?url=${encodeURIComponent(targetUrl)}`;
try { try {
const result = await fetchFollowRedirects(archiveUrl); const result = await fetchPage(searchUrl, { bodyNeeded: true });
const final = result.finalUrl; if (result.statusCode !== 200 || !result.body) return null;
// Kein Redirect = nichts gefunden (blieb auf gleicher URL)
if (final === archiveUrl) return { found: false }; // Snapshots stehen als Links in der Form href="/ABCDE" oder href="https://archive.is/ABCDE"
// Snapshot-URL hat die Form https://archive.is/ABCDE (alphanumeric hash, 4-8 Zeichen) // Wir suchen den neuesten (ersten in der Liste archive.is sortiert newest first)
if (/https?:\/\/archive\.(is|ph|today|fo|li|vn|md|gg)\/[a-zA-Z0-9]{4,10}$/.test(final)) { const hashPattern = /href="(?:https?:\/\/archive\.(?:is|ph|today|fo|li|vn|md|gg))?\/([a-zA-Z0-9]{4,10})"/g;
return { found: true, resolvedUrl: final }; let match;
const hashes = new Set();
while ((match = hashPattern.exec(result.body)) !== null) {
const h = match[1];
// Blacklist bekannter Nicht-Snapshot-Pfade
if (['newest','oldest','submit','search','about','faq','rss'].includes(h)) continue;
hashes.add(h);
} }
// archive.is Root oder Submit-Seite = kein Treffer if (hashes.size === 0) return null;
if (/archive\.[a-z]+\/?$/.test(final)) return { found: false };
if (final.includes('/submit')) return { found: false }; // Erster Hash = neuester Snapshot
// Sonst: irgendwohin weitergeleitet → als Treffer werten const newestHash = [...hashes][0];
return { found: true, resolvedUrl: final }; return `https://archive.is/${newestHash}`;
} catch { } catch {
return { found: false }; return null;
} }
} }
@@ -72,17 +83,10 @@ router.post('/check', authenticate, async (req, res) => {
let targetUrl = url.trim(); let targetUrl = url.trim();
if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl;
const newestUrl = `https://archive.is/newest/${targetUrl}`;
const oldestUrl = `https://archive.is/oldest/${targetUrl}`;
try { try {
const newest = await checkArchive(newestUrl); const snapshotUrl = await findArchiveSnapshot(targetUrl);
if (newest.found) { if (snapshotUrl) {
return res.json({ archiveUrl: newest.resolvedUrl || newestUrl, type: 'newest' }); return res.json({ archiveUrl: snapshotUrl, type: 'newest' });
}
const oldest = await checkArchive(oldestUrl);
if (oldest.found) {
return res.json({ archiveUrl: oldest.resolvedUrl || oldestUrl, type: 'oldest' });
} }
return res.status(404).json({ error: 'Kein Archiv für diese URL gefunden' }); return res.status(404).json({ error: 'Kein Archiv für diese URL gefunden' });
} catch (err) { } catch (err) {
@@ -103,17 +107,12 @@ router.post('/pdf', authenticate, async (req, res) => {
try { try {
let puppeteer, chromium; let puppeteer, chromium;
try { try {
puppeteer = require('puppeteer-core'); puppeteer = require('puppeteer-core');
chromium = require('@sparticuz/chromium'); chromium = require('@sparticuz/chromium');
} catch { } catch {
try { try { puppeteer = require('puppeteer'); chromium = null; }
puppeteer = require('puppeteer'); catch { return res.status(500).json({ error: 'PDF-Rendering nicht verfügbar puppeteer nicht installiert' }); }
chromium = null;
} catch {
return res.status(500).json({ error: 'PDF-Rendering nicht verfügbar puppeteer nicht installiert' });
}
} }
const launchOptions = chromium const launchOptions = chromium
@@ -130,9 +129,7 @@ router.post('/pdf', authenticate, async (req, res) => {
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
await page.goto(archiveUrl, { waitUntil: 'networkidle2', timeout: 30000 }); await page.goto(archiveUrl, { waitUntil: 'networkidle2', timeout: 30000 });
await page.pdf({ await page.pdf({
path: tmpFile, path: tmpFile, format: 'A4', printBackground: true,
format: 'A4',
printBackground: true,
margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' }, margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' },
}); });
await browser.close(); await browser.close();
@@ -144,7 +141,6 @@ router.post('/pdf', authenticate, async (req, res) => {
stream.pipe(res); stream.pipe(res);
stream.on('end', () => fs.unlink(tmpFile, () => {})); stream.on('end', () => fs.unlink(tmpFile, () => {}));
stream.on('error', () => fs.unlink(tmpFile, () => {})); stream.on('error', () => fs.unlink(tmpFile, () => {}));
} catch (err) { } catch (err) {
if (browser) try { await browser.close(); } catch {} if (browser) try { await browser.close(); } catch {}
fs.unlink(tmpFile, () => {}); fs.unlink(tmpFile, () => {});

View File

@@ -1,16 +1,14 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { S, api } from '../lib.js'; import { S, api } from '../lib.js';
// ── Farben ─────────────────────────────────────────────────────────────────
const C = { const C = {
accent: '#f59e0b', accent: '#f59e0b',
success: '#4ade80', success: '#4ade80',
error: '#f87171', error: '#f87171',
muted: 'rgba(255,255,255,0.4)', muted: '#6b7280',
dim: 'rgba(255,255,255,0.07)', dim: '#1f2937',
}; };
// ── Hilfsfunktion: Domain aus URL extrahieren ──────────────────────────────
function getDomain(url) { function getDomain(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } try { return new URL(url).hostname.replace(/^www\./, ''); }
catch { return url; } catch { return url; }
@@ -18,14 +16,13 @@ function getDomain(url) {
export default function PaywallKiller() { export default function PaywallKiller() {
const [inputUrl, setInputUrl] = useState(''); const [inputUrl, setInputUrl] = useState('');
const [status, setStatus] = useState('idle'); // idle | checking | found | notfound | pdf | error const [status, setStatus] = useState('idle');
const [archiveUrl, setArchiveUrl] = useState(''); const [archiveUrl, setArchiveUrl] = useState('');
const [archiveType, setArchiveType] = useState(''); const [archiveType, setArchiveType] = useState('');
const [errMsg, setErrMsg] = useState(''); const [errMsg, setErrMsg] = useState('');
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
// ── URL prüfen ────────────────────────────────────────────────────────────
async function handleCheck() { async function handleCheck() {
const url = inputUrl.trim(); const url = inputUrl.trim();
if (!url) return; if (!url) return;
@@ -48,7 +45,6 @@ export default function PaywallKiller() {
} }
} }
// ── PDF herunterladen ─────────────────────────────────────────────────────
async function handlePdf() { async function handlePdf() {
setPdfLoading(true); setPdfLoading(true);
try { try {
@@ -61,12 +57,10 @@ export default function PaywallKiller() {
}, },
body: JSON.stringify({ archiveUrl }), body: JSON.stringify({ archiveUrl }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'PDF-Fehler'); throw new Error(data.error || 'PDF-Fehler');
} }
const blob = await res.blob(); const blob = await res.blob();
const objUrl = URL.createObjectURL(blob); const objUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -84,7 +78,6 @@ export default function PaywallKiller() {
} }
} }
// ── Reset ─────────────────────────────────────────────────────────────────
function handleReset() { function handleReset() {
setInputUrl(''); setInputUrl('');
setStatus('idle'); setStatus('idle');
@@ -95,7 +88,6 @@ export default function PaywallKiller() {
setTimeout(() => inputRef.current?.focus(), 50); setTimeout(() => inputRef.current?.focus(), 50);
} }
// ── Paste aus Clipboard ───────────────────────────────────────────────────
async function handlePaste() { async function handlePaste() {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
@@ -109,14 +101,15 @@ export default function PaywallKiller() {
const isFound = status === 'found'; const isFound = status === 'found';
const isNotFound = status === 'notfound'; const isNotFound = status === 'notfound';
const isError = status === 'error'; const isError = status === 'error';
const hasResult = isFound || isNotFound || isError;
return ( return (
<div style={{ padding: '20px 16px', maxWidth: 660, margin: '0 auto' }}> <div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}>
{/* Header */} {/* Header */}
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 22 }}>🔓</span> <span style={{ fontSize: 20 }}>🔓</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}> <span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>
PAYWALL-KILLER PAYWALL-KILLER
</span> </span>
@@ -126,9 +119,11 @@ export default function PaywallKiller() {
</div> </div>
</div> </div>
{/* URL-Eingabe */} {/* Eingabe */}
<div style={{ ...S.card, marginBottom: 16 }}> <div style={{ ...S.card, marginBottom: 16 }}>
<div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div> <div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div>
{/* Input + Paste-Button */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input <input
ref={inputRef} ref={inputRef}
@@ -138,10 +133,25 @@ export default function PaywallKiller() {
placeholder="https://www.zeitung.de/artikel/..." placeholder="https://www.zeitung.de/artikel/..."
style={{ ...S.inp, flex: 1 }} style={{ ...S.inp, flex: 1 }}
/> />
<button onClick={handlePaste} style={{ ...S.btn(C.muted, true), padding: '5px 12px' }} title="Aus Zwischenablage einfügen"> <button
onClick={handlePaste}
title="Aus Zwischenablage einfügen"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 6,
padding: '0 12px',
color: '#fff',
cursor: 'pointer',
fontSize: 14,
flexShrink: 0,
}}
>
📋 📋
</button> </button>
</div> </div>
{/* Action-Buttons */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button
onClick={handleCheck} onClick={handleCheck}
@@ -149,15 +159,28 @@ export default function PaywallKiller() {
style={{ style={{
...S.btn(C.accent), ...S.btn(C.accent),
flex: 1, flex: 1,
opacity: (isChecking || !inputUrl.trim()) ? 0.5 : 1, opacity: (isChecking || !inputUrl.trim()) ? 0.45 : 1,
cursor: (isChecking || !inputUrl.trim()) ? 'default' : 'pointer', cursor: (isChecking || !inputUrl.trim()) ? 'default' : 'pointer',
}} }}
> >
{isChecking ? '⏳ Suche Archiv...' : '🔍 Archiv suchen'} {isChecking ? '⏳ Suche Archiv...' : '🔍 Archiv suchen'}
</button> </button>
{(isFound || isNotFound || isError) && ( {hasResult && (
<button onClick={handleReset} style={S.btn(C.muted, false)}> <button
Zurücksetzen onClick={handleReset}
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.14)',
borderRadius: 6,
padding: '8px 14px',
color: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontFamily: 'monospace',
fontSize: 12,
whiteSpace: 'nowrap',
}}
>
Reset
</button> </button>
)} )}
</div> </div>
@@ -165,26 +188,25 @@ export default function PaywallKiller() {
{/* Gefunden */} {/* Gefunden */}
{isFound && ( {isFound && (
<div style={{ ...S.card, borderColor: `${C.success}44`, marginBottom: 16 }}> <div style={{ ...S.card, borderColor: `${C.success}55`, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 16 }}></span> <span></span>
<span style={{ color: C.success, fontFamily: 'monospace', fontSize: 12 }}> <span style={{ color: C.success, fontFamily: 'monospace', fontSize: 12 }}>
Archiv gefunden Archiv gefunden
</span>
<span style={{ <span style={{
marginLeft: 8, background: `${C.accent}20`,
background: `${C.accent}22`, border: `1px solid ${C.accent}55`,
border: `1px solid ${C.accent}44`,
borderRadius: 4, borderRadius: 4,
padding: '1px 6px', padding: '1px 7px',
fontSize: 10, fontSize: 10,
color: C.accent, color: C.accent,
fontFamily: 'monospace',
}}> }}>
{archiveType === 'newest' ? 'neuestes' : 'ältestes'} Archiv {archiveType === 'newest' ? 'neuester Snapshot' : 'ältester Snapshot'}
</span>
</span> </span>
</div> </div>
{/* Archive-URL anzeigen */}
<div style={{ <div style={{
background: 'rgba(255,255,255,0.03)', background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.08)',
@@ -193,18 +215,18 @@ export default function PaywallKiller() {
marginBottom: 14, marginBottom: 14,
wordBreak: 'break-all', wordBreak: 'break-all',
}}> }}>
<a href={archiveUrl} target="_blank" rel="noreferrer" style={{ color: C.accent, fontSize: 11, fontFamily: 'monospace' }}> <a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ color: C.accent, fontSize: 11, fontFamily: 'monospace' }}>
{archiveUrl} {archiveUrl}
</a> </a>
</div> </div>
{/* Aktions-Buttons */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a <a
href={archiveUrl} href={archiveUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ ...S.btn(C.success), textDecoration: 'none', display: 'inline-block' }} style={{ ...S.btn(C.success), textDecoration: 'none' }}
> >
🌐 Im Browser öffnen 🌐 Im Browser öffnen
</a> </a>
@@ -221,10 +243,9 @@ export default function PaywallKiller() {
</button> </button>
</div> </div>
{/* PDF-Hinweis */}
{pdfLoading && ( {pdfLoading && (
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginTop: 10 }}> <div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginTop: 10 }}>
Seite wird geladen und als PDF gerendert. Das kann 1530 Sekunden dauern... Seite wird gerendert, das dauert 1530 Sekunden...
</div> </div>
)} )}
</div> </div>
@@ -234,16 +255,15 @@ export default function PaywallKiller() {
{isNotFound && ( {isNotFound && (
<div style={{ ...S.card, borderColor: `${C.error}44` }}> <div style={{ ...S.card, borderColor: `${C.error}44` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 16 }}>📭</span> <span>📭</span>
<span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}> <span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}>
Kein Archiv gefunden Kein Archiv gefunden
</span> </span>
</div> </div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}> <div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6, marginBottom: 12 }}>
Für <strong style={{ color: 'rgba(255,255,255,0.6)' }}>{getDomain(inputUrl)}</strong> existiert Für <strong style={{ color: 'rgba(255,255,255,0.55)' }}>{getDomain(inputUrl)}</strong> wurde
kein Eintrag auf archive.is. Der Artikel wurde möglicherweise noch nie archiviert. kein Snapshot auf archive.is gefunden.
</div> </div>
<div style={{ marginTop: 12 }}>
<a <a
href={`https://archive.is/${inputUrl.trim()}`} href={`https://archive.is/${inputUrl.trim()}`}
target="_blank" target="_blank"
@@ -253,14 +273,13 @@ export default function PaywallKiller() {
🔗 Manuell auf archive.is prüfen 🔗 Manuell auf archive.is prüfen
</a> </a>
</div> </div>
</div>
)} )}
{/* Fehler */} {/* Fehler */}
{isError && ( {isError && (
<div style={{ ...S.card, borderColor: `${C.error}44` }}> <div style={{ ...S.card, borderColor: `${C.error}44` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}></span> <span></span>
<span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}> <span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}>
{errMsg} {errMsg}
</span> </span>
@@ -268,34 +287,28 @@ export default function PaywallKiller() {
</div> </div>
)} )}
{/* Info-Sektion */} {/* Idle-Info */}
{status === 'idle' && ( {status === 'idle' && (
<div style={{ ...S.card, marginTop: 8 }}> <div style={{ ...S.card }}>
<div style={{ ...S.head, marginBottom: 10 }}>WIE ES FUNKTIONIERT</div> <div style={{ ...S.head, marginBottom: 10 }}>SO FUNKTIONIERT ES</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[ {[
['1', 'URL des gesperrten Artikels einfügen'], ['1', 'URL des gesperrten Artikels einfügen'],
['2', 'Archiv auf archive.is suchen (newest → oldest)'], ['2', 'Archiv auf archive.is suchen'],
['3', 'Archivierte Version im Browser öffnen oder als PDF speichern'], ['3', 'Im Browser öffnen oder als PDF speichern'],
].map(([num, text]) => ( ].map(([num, text]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}> <div key={num} style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 8 }}>
<span style={{ <span style={{
background: `${C.accent}22`, background: `${C.accent}20`,
border: `1px solid ${C.accent}44`, border: `1px solid ${C.accent}44`,
borderRadius: '50%', borderRadius: '50%',
width: 20, height: 20, width: 20, height: 20, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, color: C.accent, fontFamily: 'monospace', flexShrink: 0, fontSize: 10, color: C.accent, fontFamily: 'monospace',
}}> }}>{num}</span>
{num} <span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace' }}>{text}</span>
</span>
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>
{text}
</span>
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
</div> </div>
); );