diff --git a/frontend/src/api.js b/frontend/src/api.js index 42eed59..2e73e34 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,5 +1,5 @@ -async function getJson(url) { - const response = await fetch(url); +async function getJson(url, options) { + const response = await fetch(url, options); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } @@ -33,8 +33,8 @@ export async function fetchEntryDetail(archiveId, entryUid) { // Summaries are generated on demand, never at capture time. GET is safe for // public sessions (the server applies the same visibility gate as entry detail). -export async function fetchEntrySummary(archiveId, entryUid) { - return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`); +export async function fetchEntrySummary(archiveId, entryUid, { signal } = {}) { + return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`, { signal }); } // Kicks off generation. Resolves to either an existing completed summary (200) @@ -42,13 +42,14 @@ export async function fetchEntrySummary(archiveId, entryUid) { // caller polls fetchEntrySummary either way. // The server returns 400 with the exact missing env var name when a provider is // unconfigured, so its body is surfaced verbatim rather than replaced. -export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false } = {}) { +export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false, signal } = {}) { const resp = await fetch( `/api/archives/${archiveId}/entries/${entryUid}/summary`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, force, include_images: includeImages }), + signal, } ); if (!resp.ok) { @@ -63,6 +64,15 @@ export async function requestEntrySummary(archiveId, entryUid, { provider, force return resp.json(); } +// Text artifacts are served by the same entry-artifact endpoint as previews. +// Keep credentials explicit because this helper is also used by public/private +// archive views, and preserve the previous concise HTTP error contract. +export async function fetchArtifactText(src, { signal } = {}) { + const response = await fetch(src, { credentials: 'same-origin', signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); +} + export async function fetchEntryChildren(archiveId, entryUid) { return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`); } diff --git a/frontend/src/components/CaptureDialog.jsx b/frontend/src/components/CaptureDialog.jsx index c4d0111..88a7ee9 100644 --- a/frontend/src/components/CaptureDialog.jsx +++ b/frontend/src/components/CaptureDialog.jsx @@ -681,8 +681,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on files.forEach(file => { const newItem = makeFileItem(file.name) setItems(prev => { - // Replace a sole empty URL row with the file item; otherwise append - if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) { + // Only a normal URL row can be replaced. Text drafts deliberately use + // an empty compatibility locator, but their title/body must survive a + // file attachment and remain independently archivable. + if (prev.length === 1 && !prev[0].kind && !prev[0].locator.trim()) { return [newItem] } return [...prev, newItem] diff --git a/frontend/src/components/ContextRail.jsx b/frontend/src/components/ContextRail.jsx index 1e52aba..6f466d6 100644 --- a/frontend/src/components/ContextRail.jsx +++ b/frontend/src/components/ContextRail.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useLayoutEffect, useRef } from 'react' import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api' import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils' @@ -74,6 +74,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se }) const [includeSummaryImages, setIncludeSummaryImages] = useState(false) const summaryPollRef = useRef(null) + const summaryPollAbortRef = useRef(null) + const summaryGenerateAbortRef = useRef(null) + const summarySelectionRef = useRef(null) // ── Bulk-panel state ──────────────────────────────────────────────────── const isBulk = selectedUids?.size >= 2 @@ -127,14 +130,22 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se // Seed the summary from the entry detail payload and stop any poll left over // from the previously selected entry. - useEffect(() => { + useLayoutEffect(() => { + const selectionKey = archiveId && detail?.summary?.entry_uid + ? `${archiveId}:${detail.summary.entry_uid}` + : null + summarySelectionRef.current = selectionKey clearInterval(summaryPollRef.current) summaryPollRef.current = null + summaryPollAbortRef.current?.abort() + summaryPollAbortRef.current = null + summaryGenerateAbortRef.current?.abort() + summaryGenerateAbortRef.current = null setSummary(detail?.latest_summary ?? null) setSummaryError('') setSummaryBusy(false) setIncludeSummaryImages(false) - }, [detail?.summary?.entry_uid]) + }, [archiveId, detail?.summary?.entry_uid]) // Poll only while the latest summary is non-terminal. Anchoring the effect on // the status (rather than starting a timer inside the click handler) means a @@ -146,40 +157,60 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se if (summaryStatus !== 'pending' && summaryStatus !== 'running') return if (!archiveId || !detail?.summary?.entry_uid) return const entryUid = detail.summary.entry_uid - summaryPollRef.current = setInterval(async () => { + const selectionKey = `${archiveId}:${entryUid}` + const controller = new AbortController() + summaryPollAbortRef.current = controller + const poll = async () => { try { - const res = await fetchEntrySummary(archiveId, entryUid) + const res = await fetchEntrySummary(archiveId, entryUid, { signal: controller.signal }) + if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return setSummary(res.summary ?? null) const st = res.summary?.status if (st !== 'pending' && st !== 'running') { - clearInterval(summaryPollRef.current) - summaryPollRef.current = null + clearInterval(intervalId) + if (summaryPollRef.current === intervalId) summaryPollRef.current = null setSummaryBusy(false) if (st === 'completed') onDetailRefresh?.() } - } catch { + } catch (e) { + if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return // A transient poll failure is not worth tearing the section down; the // next tick retries, and a real failure lands as status === 'failed'. } - }, SUMMARY_POLL_MS) + } + const intervalId = setInterval(poll, SUMMARY_POLL_MS) + summaryPollRef.current = intervalId return () => { - clearInterval(summaryPollRef.current) - summaryPollRef.current = null + clearInterval(intervalId) + if (summaryPollRef.current === intervalId) summaryPollRef.current = null + controller.abort() + if (summaryPollAbortRef.current === controller) summaryPollAbortRef.current = null } }, [summaryStatus, archiveId, detail?.summary?.entry_uid]) - useEffect(() => () => clearInterval(summaryPollRef.current), []) + useEffect(() => () => { + clearInterval(summaryPollRef.current) + summaryPollAbortRef.current?.abort() + summaryGenerateAbortRef.current?.abort() + }, []) async function handleGenerateSummary(force = false) { if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return + const entryUid = detail.summary.entry_uid + const selectionKey = `${archiveId}:${entryUid}` + const controller = new AbortController() + summaryGenerateAbortRef.current?.abort() + summaryGenerateAbortRef.current = controller setSummaryBusy(true) setSummaryError('') try { - const res = await requestEntrySummary(archiveId, detail.summary.entry_uid, { + const res = await requestEntrySummary(archiveId, entryUid, { provider: summaryProvider, force, includeImages: includeSummaryImages, + signal: controller.signal, }) + if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return if (res.status === 'completed') { // 200 cache hit: the response *is* the row, no polling needed. setSummary(res) @@ -191,8 +222,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se setSummary({ ...(res ?? {}), status: 'pending' }) } } catch (e) { + if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return setSummaryError(e.message || 'Summary request failed') setSummaryBusy(false) + } finally { + if (summaryGenerateAbortRef.current === controller) summaryGenerateAbortRef.current = null } } @@ -402,7 +436,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se ] : [] const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac']) - const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp']) + const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','md','markdown','txt','jpg','jpeg','png','gif','webp','avif','svg','bmp']) const primaryMediaIdx = detail ? detail.artifacts.findIndex(a => a.artifact_role === 'primary_media') : -1 const primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : '' diff --git a/frontend/src/components/TextPreview.jsx b/frontend/src/components/TextPreview.jsx index 6befaab..ae18418 100644 --- a/frontend/src/components/TextPreview.jsx +++ b/frontend/src/components/TextPreview.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { fetchArtifactText } from '../api' /** * Renders the primary_media artifact of a text/document entry as plain text. @@ -13,17 +14,15 @@ export default function TextPreview({ src, mime, title }) { const [error, setError] = useState(null) useEffect(() => { - let cancelled = false + const controller = new AbortController() setText(null) setError(null) - fetch(src, { credentials: 'same-origin' }) - .then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`) - return r.text() + fetchArtifactText(src, { signal: controller.signal }) + .then(setText) + .catch(e => { + if (e.name !== 'AbortError') setError(e.message || String(e)) }) - .then(t => { if (!cancelled) setText(t) }) - .catch(e => { if (!cancelled) setError(e.message || String(e)) }) - return () => { cancelled = true } + return () => controller.abort() }, [src]) if (error) {