1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 11:15:41 +02:00
archivr/frontend/src/api.js
TheGeneralist d610d37793
feat: entry previews (#24)
* feat: entry previews (video, tweet, article, iframe, image, audio bar)

- Add PreviewPanel dispatch hub routing by entity_kind + artifact extension
- VideoPreview: HTML5 <video> for YouTube/Instagram/TikTok/Reddit/X posts
- TweetPreview: tweet card, thread, and X article renderer (ported from x-article-renderer)
- IframePreview: sandboxed iframe for SingleFile web pages and PDFs
- ImagePreview: image viewer with click-to-open-fullsize
- AudioBar: persistent fixed-bottom player (Spotify-style) that survives entry navigation
- Lift entryDetail to App.jsx, shared between PreviewPanel and ContextRail
- 3-column layout (workspace | 300px preview | 340px rail) when preview active
- Stale-guard fixes: seq incremented before early returns in all async effects
- handleRearchive: capture startSeq/entryUid at call time, guard every async resume
- TweetPreview: reset loading/error/tweets before early-return branches

* feat: entry previews — tweet/thread/article/video/audio/image/iframe/pdf

- PreviewModal: modal overlay with new-tab link (↗) and keyboard close
- PreviewPanel: routes by entity_kind + primary_media extension to the
  correct viewer (tweet/video/audio/pdf/html/image/fallback)
- TweetPreview: full X-style tweet, thread, and article renderer with
  local artifact map for archived media (CDN fallback)
- AudioBar: persistent fixed bottom player, triggered via ContextRail
  Play button; body.has-audio-bar pads content above it
- VideoPreview, IframePreview, ImagePreview: inline viewers
- PreviewPage: standalone /preview/:archiveId/:entryUid route
- ContextRail: Play/Preview buttons; isAudio/isPreviewable detection
- App.jsx: preview modal state, currentAudio state, preview route guard,
  has-audio-bar body class effect
- routes.rs: CSP updated (media-src self blob https; frame-ancestors self;
  Google Fonts + external images/scripts whitelisted)
- styles.css: preview modal, tweet-wrap scroll (min-height:0), audio bar
  body padding, newtab button, preview panel flex layout

* style: tighten article/tweet preview spacing

- aMeta padding: 14px → 10px
- article title marginBottom: 10px → 8px
- aAuthorRow marginBottom: 10px → 8px
- bH1 top margin: 20px → 16px
- bH2 top margin: 18px → 14px
- bHr margin: 20px → 14px
- .preview-tweet-wrap padding: 20px → 12px (already committed)

More content visible above the fold in both modal and standalone views.

* feat: tweet/article preview quality pass

HTML entities: decode &gt; &lt; &amp; 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

* fix: move artifact fetch to api.js; stop Escape propagation from lightbox

- Export fetchEntryArtifacts(archiveId, entryUid, indices) from api.js
  using Promise.all + getJson (follows project convention: all /api calls
  go through api.js, never inline fetch in components)
- TweetPreview: import fetchEntryArtifacts, replace inline Promise.all
- MediaLightbox keydown handler: stopPropagation + preventDefault for
  Escape/ArrowLeft/ArrowRight so the parent PreviewModal window listener
  does not also fire and close the modal behind the lightbox

* fix: iframe/page preview height chain and toolbar UX

Problem: changing .preview-modal from height to max-height broke iframe
previews - <iframe style='flex:1'> needs a concrete ancestor height, which
max-height alone doesn't supply when content is shorter than the cap.

Fix - CSS:
  .preview-modal--full { height: 88vh } applied to non-tweet modals
  .preview-modal--full .preview-modal-body { max-height: none }
  .preview-iframe-toolbar span: remove text-transform/letter-spacing
    (was uppercasing the URL/title in shouty caps)

Fix - PreviewModal: className adds --full when entity_kind is not
  tweet/tweet_thread; tweet previews keep shrink-to-fit behavior.

Fix - PreviewPanel: pass title + original_url from summary to IframePreview
  for both HTML and PDF; wrappers use flex:1/minHeight:0 not height:100%.

Fix - IframePreview:
  - Accept title + originalUrl props; show originalUrl in toolbar (falls
    back to artifact src only when original_url absent); show title above
    URL when available
  - flex:1 + minHeight:0 instead of height:100% on the wrap div
  - Single unified layout for page + pdf (both just show the iframe)

* feat: expand t.co links; linkify bare URLs in tweet and article text

Frontend:
- resolveEntityBounds: try multiple candidate strings in order (u.url
  first, since that's the t.co short URL that appears in full_text)
- normalizeUrlAnn: multi-candidate search; href = expanded > url,
  display = display_url > expanded > url
- linkifyText(): regex linkifier for entity-less bare URLs; trims
  trailing punctuation [.,;:!?)] before linking; used in both
  renderTweetTextJSX and renderInlineJSX including their early-return
  paths (anns.length === 0) that previously bypassed linkification
- renderInlineJSX: fix mention href mention.name → screen_name;
  replace t.co segment text with url.display when entity covers it

Scraper (vendor/twitter/scrape_user_tweet_contents.py):
- extract_tweet_data: when note_tweet text is used, pull urls/mentions/
  hashtags/symbols from note_result.entity_set (correct indices for the
  note text); keep media from legacy.entities (no note media downloads)

* feat: server-side t.co resolver + frontend augmentation

Server (routes.rs):
  POST /api/util/resolve-tco — unauthenticated, accepts JSON array of
  https://t.co/<alphanumeric> URLs only (strict regex validation, no SSRF
  via input), capped at 50 per batch, 3 s timeout, redirect(Policy::none)
  so the server only ever touches t.co itself. HEAD first, GET fallback if
  HEAD returns no Location. Location sanitized to http/https only —
  javascript:/data:/etc. fall back to the original t.co.

api.js:
  resolveTcoUrls(urls) — project-convention wrapper for the new endpoint.
  Returns {} on failure (callers degrade gracefully to bare t.co links).

TweetPreview.jsx:
  After tweet data loads, per-tweet range-based coverage detection:
  builds covered [start,end) from existing entity fromIndex/toIndex or
  indices fallback, then finds regex matches whose span is NOT covered.
  Resolves unique uncovered t.co URLs via resolveTcoUrls(), synthesises
  one entity per occurrence (with exact fromIndex/toIndex so normalizeUrlAnn
  gets correct bounds even for duplicate t.co URLs in the same tweet).
  Augments entities.urls before setTweets() so all rendering paths
  see expanded URLs.

* feat: suppress rendered media attachment URLs from tweet text

renderTweetTextJSX now accepts skipSpans=[] as third param.
Skip-span boundaries are added to the pts split set so a trailing
media t.co inside a plain segment still gets isolated and suppressed—
not re-linked by linkifyText. Early return only when both anns and
skipSpans are empty.

TweetCard computes mediaSkipSpans after building photos/videoItems:
for each rawMedia item whose src resolved (photo src match; any video),
resolveEntityBounds(m, ft, m.url) gives the precise [s,e] span using
indices/fromIndex first, indexOf fallback—then the span is passed to
renderTweetTextJSX so the t.co attachment URL is silently dropped.
2026-07-12 16:23:04 +02:00

464 lines
16 KiB
JavaScript

async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
export async function fetchArchives() {
return getJson("/api/archives");
}
export async function fetchEntries(archiveId) {
return getJson(`/api/archives/${archiveId}/entries`);
}
export async function searchEntries(archiveId, q, tag) {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (tag) params.set("tag", tag);
return getJson(`/api/archives/${archiveId}/entries/search?${params}`);
}
export async function fetchEntryDetail(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
}
// Fetch multiple artifact JSON payloads for an entry in parallel.
// Returns a Promise<Array> preserving index order.
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
return Promise.all(
indices.map(idx =>
getJson(`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${idx}`)
)
);
}
// Resolve t.co short URLs server-side (HEAD→GET, no-follow).
// Returns a map { [tcoUrl]: expandedUrl }.
// Silently returns {} on failure — callers fall back to plain t.co links.
export async function resolveTcoUrls(urls) {
if (!urls || urls.length === 0) return {};
const res = await fetch('/api/util/resolve-tco', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(urls),
});
if (!res.ok) return {};
return res.json();
}
export async function updateEntryTitle(archiveId, entryUid, title) {
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title ?? null }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEntryTags(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
}
export async function assignTag(archiveId, entryUid, tagPath) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/tags`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_path: tagPath }),
}
);
if (!resp.ok) throw new Error(`Failed to add tag (${resp.status})`);
}
export async function removeTag(archiveId, entryUid, tagUid) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/tags/${tagUid}`,
{ method: "DELETE" }
);
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
}
export async function deleteEntry(archiveId, entryUid) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}`,
{ method: 'DELETE' }
);
if (!resp.ok) throw new Error(`Delete failed (${resp.status})`);
}
export async function renameTag(archiveId, tagUid, name) {
const res = await fetch(
`/api/archives/${archiveId}/tags/${tagUid}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}
);
if (!res.ok) throw new Error(await res.text());
return res.json(); // returns the updated Tag: { tag_uid, name, slug, full_path }
}
export async function deleteTag(archiveId, tagUid) {
const res = await fetch(
`/api/archives/${archiveId}/tags/${tagUid}`,
{ method: 'DELETE' }
);
if (!res.ok) throw new Error(await res.text());
}
export async function fetchRuns(archiveId) {
return getJson(`/api/archives/${archiveId}/runs`);
}
export async function fetchTags(archiveId) {
return getJson(`/api/archives/${archiveId}/tags`);
}
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool, reader_mode?: bool } — per-capture overrides
if (extensions) {
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled
if (typeof extensions.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
}
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json(); // { job_uid, status: "pending" }
}
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
export async function probeCapture(archiveId, locator) {
return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`);
}
export async function pollCaptureJob(archiveId, jobUid) {
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
}
// ── Auth helpers ─────────────────────────────────────────────────────────────
export async function checkSetup() {
const r = await fetch('/api/auth/setup');
const data = await r.json();
return data.setup_required === true;
}
export async function doSetup(username, password) {
const r = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Setup failed');
return r.json();
}
export async function login(username, password) {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Login failed');
return r.json();
}
export async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
}
export async function fetchMe() {
const r = await fetch('/api/auth/me');
if (r.status === 401) return null;
return r.json();
}
// ── Profile & settings helpers ───────────────────────────────────────────────
export async function updateProfile(displayName) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: displayName }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function patchMe(patch) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
}
export async function changePassword(currentPassword, newPassword) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (!res.ok) {
let msg = await res.text();
try { msg = JSON.parse(msg).error ?? msg; } catch {}
throw new Error(msg);
}
}
export async function listTokens() {
return getJson('/api/auth/tokens');
}
export async function createToken(name) {
const res = await fetch('/api/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteToken(tokenUid) {
const res = await fetch(`/api/auth/tokens/${tokenUid}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function getInstanceSettings() {
return getJson('/api/admin/instance-settings');
}
export async function updateInstanceSettings(patch) {
const res = await fetch('/api/admin/instance-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
}
// ── Admin helpers ─────────────────────────────────────────────────────────────
export async function listAdminUsers() {
return getJson('/api/admin/users');
}
export async function createAdminUser(username, password, email) {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, email: email || undefined }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
export async function setUserStatus(userUid, status) {
const res = await fetch(`/api/admin/users/${userUid}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
export async function assignRole(userUid, roleSlug) {
const res = await fetch(`/api/admin/users/${userUid}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_slug: roleSlug }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
export async function removeRole(userUid, roleSlug) {
const res = await fetch(`/api/admin/users/${userUid}/roles/${encodeURIComponent(roleSlug)}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 204) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
export async function listRoles() {
return getJson('/api/admin/roles');
}
export async function createRole(slug, name) {
const res = await fetch('/api/admin/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, name }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
// ── Collection helpers ─────────────────────────────────────────────────────
export async function listCollections(archiveId) {
return getJson(`/api/archives/${archiveId}/collections`);
}
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getCollection(archiveId, collUid) {
return getJson(`/api/archives/${archiveId}/collections/${collUid}`);
}
export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function removeEntryFromCollection(archiveId, collUid, entryUid) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'DELETE',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function listEntryCollections(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`);
}
export async function updateCollection(archiveId, collUid, patch) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
}
export async function deleteCollection(archiveId, collUid) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, { method: 'DELETE' })
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
}
// ── Blob / orphan cleanup ─────────────────────────────────────────────────────
export async function scanOrphanBlobs(archiveId) {
return getJson(`/api/archives/${archiveId}/blob-cleanup`)
}
export async function deleteOrphanBlobs(archiveId) {
const res = await fetch(`/api/archives/${archiveId}/blob-cleanup`, { method: 'DELETE' })
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
return res.json()
}
// ── Re-archive ────────────────────────────────────────────────────────────────
export async function rearchiveEntry(archiveId, entryUid) {
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}/rearchive`, {
method: 'POST',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || `rearchive failed: ${res.status}`)
}
return res.json() // { job_uid, status: 'pending' }
}
// ── Cookie rules ──────────────────────────────────────────────────────────────
export async function listCookieRules() {
return getJson('/api/admin/cookie-rules')
}
export async function createCookieRule(urlPattern, patternKind, cookiesJson) {
const res = await fetch('/api/admin/cookie-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url_pattern: urlPattern || null,
pattern_kind: patternKind,
cookies_json: cookiesJson,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || `HTTP ${res.status}`)
}
return res.json()
}
export async function updateCookieRule(ruleUid, patch) {
const res = await fetch(`/api/admin/cookie-rules/${ruleUid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || `HTTP ${res.status}`)
}
}
export async function deleteCookieRule(ruleUid) {
const res = await fetch(`/api/admin/cookie-rules/${ruleUid}`, { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || `HTTP ${res.status}`)
}
}
// ── 401 interceptor ───────────────────────────────────────────────────────────
const _origFetch = window.fetch;
window.fetch = async (...args) => {
const r = await _origFetch(...args);
if (r.status === 401) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
if (!url.includes('/api/auth/')) {
window.dispatchEvent(new CustomEvent('auth:expired'));
}
}
return r;
};