mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 11:15:41 +02:00
* 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 > < & 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.
464 lines
16 KiB
JavaScript
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;
|
|
};
|