mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +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.
450 lines
16 KiB
JavaScript
450 lines
16 KiB
JavaScript
import { useState, useEffect, useCallback, useRef, createContext } from 'react'
|
||
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api'
|
||
import LoginPage from './components/LoginPage.jsx'
|
||
import SetupPage from './components/SetupPage.jsx'
|
||
|
||
import Topbar from './components/Topbar'
|
||
import CaptureDialog from './components/CaptureDialog'
|
||
import EntriesView from './components/EntriesView'
|
||
import RunsView from './components/RunsView'
|
||
import AdminView from './components/AdminView'
|
||
import TagsView from './components/TagsView'
|
||
import CollectionsView from './components/CollectionsView'
|
||
import SettingsView from './components/SettingsView'
|
||
import ContextRail from './components/ContextRail'
|
||
import PreviewModal from './components/PreviewModal'
|
||
import AudioBar from './components/AudioBar'
|
||
import PreviewPage from './components/PreviewPage'
|
||
import { displayPath } from './utils'
|
||
import ToastStack from './components/ToastStack'
|
||
|
||
export const AuthContext = createContext(null);
|
||
|
||
// Detect /preview/:archiveId/:entryUid at load time (static — no navigation)
|
||
const PREVIEW_ROUTE = (() => {
|
||
const m = window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/)
|
||
return m ? { archiveId: m[1], entryUid: m[2] } : null
|
||
})()
|
||
|
||
const VIEWS = ['archive','tags','collections','runs','admin','settings']
|
||
const SETTINGS_TABS = ['profile','tokens','instance','storage']
|
||
|
||
function parseLocation() {
|
||
const parts = window.location.pathname.split('/').filter(Boolean)
|
||
const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive'
|
||
const settingsTab = (view === 'settings' && SETTINGS_TABS.includes(parts[1])) ? parts[1] : 'profile'
|
||
return { view, settingsTab }
|
||
}
|
||
|
||
function locationPath(view, settingsTab) {
|
||
if (view === 'archive') return '/'
|
||
if (view === 'settings') return `/settings/${settingsTab}`
|
||
return `/${view}`
|
||
}
|
||
|
||
export default function App() {
|
||
const [authState, setAuthState] = useState('loading');
|
||
const [currentUser, setCurrentUser] = useState(null);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
const needsSetup = await checkSetup();
|
||
if (needsSetup) { setAuthState('setup'); return; }
|
||
const user = await fetchMe();
|
||
if (!user) { setAuthState('login'); return; }
|
||
setCurrentUser(user);
|
||
setAuthState('authenticated');
|
||
})();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handler = () => { setCurrentUser(null); setAuthState('login'); };
|
||
window.addEventListener('auth:expired', handler);
|
||
return () => window.removeEventListener('auth:expired', handler);
|
||
}, []);
|
||
|
||
// Sync URL → state on back/forward
|
||
useEffect(() => {
|
||
const handler = () => {
|
||
const { view, settingsTab } = parseLocation()
|
||
setView(view)
|
||
setSettingsTab(settingsTab)
|
||
}
|
||
window.addEventListener('popstate', handler)
|
||
return () => window.removeEventListener('popstate', handler)
|
||
}, [])
|
||
|
||
const [archives, setArchives] = useState([])
|
||
const [archiveId, setArchiveId] = useState(null)
|
||
const [entries, setEntries] = useState([])
|
||
const [selectedEntryUid, setSelectedEntryUid] = useState(null)
|
||
const [selectedEntry, setSelectedEntry] = useState(null)
|
||
const [tagFilter, setTagFilter] = useState(null)
|
||
const [view, setView] = useState(() => parseLocation().view)
|
||
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [resultCount, setResultCount] = useState('')
|
||
const [searchBusy, setSearchBusy] = useState(false)
|
||
const [runs, setRuns] = useState([])
|
||
const [tagNodes, setTagNodes] = useState([])
|
||
const [captureDialogOpen, setCaptureDialogOpen] = useState(() => {
|
||
const saved = sessionStorage.getItem('captureDialogOpen')
|
||
return saved === 'true'
|
||
})
|
||
|
||
const [toasts, setToasts] = useState([])
|
||
const toastIdRef = useRef(0)
|
||
const [ublockWarningIgnored, setUblockWarningIgnored] = useState(
|
||
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
|
||
)
|
||
|
||
|
||
// ── Entry detail (shared between PreviewPanel and ContextRail) ──────────
|
||
const [entryDetail, setEntryDetail] = useState(null)
|
||
const detailSeqRef = useRef(0)
|
||
|
||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||
|
||
// Fetch entry detail whenever selected entry changes
|
||
useEffect(() => {
|
||
const seq = ++detailSeqRef.current
|
||
setEntryDetail(null)
|
||
if (!selectedEntry || !archiveId) return
|
||
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
|
||
if (seq !== detailSeqRef.current) return
|
||
setEntryDetail(det)
|
||
}).catch(() => {})
|
||
}, [selectedEntry, archiveId])
|
||
|
||
// Persist captureDialogOpen to sessionStorage
|
||
useEffect(() => {
|
||
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
|
||
}, [captureDialogOpen])
|
||
|
||
const loadEntries = useCallback(async (aid, q, tag) => {
|
||
if (!aid) return
|
||
setSearchBusy(true)
|
||
try {
|
||
let results
|
||
if (q || tag) {
|
||
results = await searchEntries(aid, q, tag)
|
||
} else {
|
||
results = await fetchEntries(aid)
|
||
}
|
||
setEntries(results)
|
||
setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`)
|
||
} catch {
|
||
setEntries([])
|
||
setResultCount('Search failed. Try again.')
|
||
} finally {
|
||
setSearchBusy(false)
|
||
}
|
||
}, [])
|
||
|
||
// Load archives once authenticated (re-runs when authState changes so
|
||
// it triggers correctly after first login or a session refresh).
|
||
useEffect(() => {
|
||
if (authState !== 'authenticated') return
|
||
fetchArchives().then(list => {
|
||
setArchives(list)
|
||
if (list.length > 0) {
|
||
const first = list[0].id
|
||
setArchiveId(first)
|
||
}
|
||
})
|
||
}, [authState])
|
||
|
||
// Archive change: parallel load entries + runs + tags
|
||
useEffect(() => {
|
||
if (!archiveId) return
|
||
setTagFilter(null)
|
||
setSelectedEntry(null)
|
||
setSelectedEntryUid(null)
|
||
Promise.all([
|
||
loadEntries(archiveId, '', null),
|
||
fetchRuns(archiveId).then(setRuns),
|
||
fetchTags(archiveId).then(setTagNodes),
|
||
])
|
||
}, [archiveId]) // intentionally not including loadEntries to avoid re-running on its recreation
|
||
|
||
// Debounced search
|
||
useEffect(() => {
|
||
if (archiveId === null) return
|
||
const timer = setTimeout(() => {
|
||
loadEntries(archiveId, searchQuery, tagFilter)
|
||
}, 300)
|
||
return () => clearTimeout(timer)
|
||
}, [searchQuery, archiveId]) // tagFilter handled separately below
|
||
|
||
// Tag filter applied: switch to archive view and reload.
|
||
// Only reset view when tagFilter is non-null; archive change alone (tagFilter=null)
|
||
// must not stomp the URL-initialised view on first load.
|
||
useEffect(() => {
|
||
if (archiveId === null) return
|
||
if (tagFilter !== null) setView('archive')
|
||
loadEntries(archiveId, searchQuery, tagFilter)
|
||
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire
|
||
|
||
const handleArchiveChange = useCallback((id) => {
|
||
setArchiveId(id)
|
||
}, [])
|
||
|
||
const handleViewChange = useCallback((name) => {
|
||
setView(name)
|
||
if (name === 'tags' && archiveId) {
|
||
fetchTags(archiveId).then(setTagNodes)
|
||
}
|
||
}, [archiveId])
|
||
|
||
// Sync view + settingsTab → URL
|
||
// Sync view + settingsTab → URL (skip when serving a standalone preview page)
|
||
useEffect(() => {
|
||
if (PREVIEW_ROUTE) return
|
||
const path = locationPath(view, settingsTab)
|
||
if (window.location.pathname !== path) {
|
||
history.pushState(null, '', path)
|
||
}
|
||
}, [view, settingsTab])
|
||
|
||
const selectEntry = useCallback((entry) => {
|
||
setSelectedEntryUid(entry ? entry.entry_uid : null)
|
||
setSelectedEntry(entry)
|
||
}, [])
|
||
|
||
const handleTagFilterSet = useCallback((fullPath) => {
|
||
setTagFilter(fullPath)
|
||
}, [])
|
||
|
||
const handleClearTagFilter = useCallback(() => {
|
||
setTagFilter(null)
|
||
}, [])
|
||
|
||
const handleTagsRefresh = useCallback(() => {
|
||
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
||
}, [archiveId])
|
||
|
||
const handleTagRenamed = useCallback((oldFullPath, newFullPath) => {
|
||
if (tagFilter === oldFullPath) {
|
||
setTagFilter(newFullPath);
|
||
} else if (tagFilter?.startsWith(oldFullPath + '/')) {
|
||
setTagFilter(newFullPath + tagFilter.slice(oldFullPath.length));
|
||
}
|
||
}, [tagFilter]);
|
||
|
||
const handleTagDeleted = useCallback((deletedFullPath) => {
|
||
if (tagFilter === deletedFullPath || tagFilter?.startsWith(deletedFullPath + '/')) {
|
||
setTagFilter(null);
|
||
}
|
||
}, [tagFilter]);
|
||
|
||
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
|
||
setEntries(prev => prev.map(e =>
|
||
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
|
||
))
|
||
setSelectedEntry(prev =>
|
||
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
|
||
)
|
||
setEntryDetail(prev =>
|
||
prev && prev.summary.entry_uid === entryUid
|
||
? { ...prev, summary: { ...prev.summary, title: newTitle } }
|
||
: prev
|
||
)
|
||
}, [])
|
||
|
||
const handleDetailRefresh = useCallback(() => {
|
||
if (!archiveId || !selectedEntry) return
|
||
const seq = ++detailSeqRef.current
|
||
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
|
||
if (seq !== detailSeqRef.current) return
|
||
setEntryDetail(det)
|
||
}).catch(() => {})
|
||
}, [archiveId, selectedEntry])
|
||
|
||
const handleEntryDeleted = useCallback((entryUid) => {
|
||
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
|
||
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
|
||
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
|
||
}, [])
|
||
|
||
const handleCaptureClick = useCallback(() => {
|
||
setCaptureDialogOpen(true)
|
||
}, [])
|
||
|
||
const handleCaptureClose = useCallback(() => {
|
||
setCaptureDialogOpen(false)
|
||
}, [])
|
||
|
||
const handleCaptured = useCallback(() => {
|
||
if (!archiveId) return
|
||
Promise.all([
|
||
loadEntries(archiveId, searchQuery, tagFilter),
|
||
fetchRuns(archiveId).then(setRuns),
|
||
])
|
||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||
|
||
const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
|
||
// Only suppress per-item ublock/cookie warnings (those carry a locator).
|
||
// Batch summary warnings (locator = null) must always show.
|
||
if (type === 'warning' && ublockWarningIgnored && locator) return
|
||
const id = ++toastIdRef.current
|
||
setToasts(prev => [...prev, { id, text, locator, type, headline }])
|
||
}, [ublockWarningIgnored])
|
||
|
||
const handleDismissToast = useCallback((id) => {
|
||
setToasts(prev => prev.filter(t => t.id !== id))
|
||
}, [])
|
||
|
||
const handleIgnoreUblock = useCallback(() => {
|
||
sessionStorage.setItem('ublockWarningIgnored', 'true')
|
||
setUblockWarningIgnored(true)
|
||
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
|
||
}, [])
|
||
const [previewEntryUid, setPreviewEntryUid] = useState(null)
|
||
const [currentAudio, setCurrentAudio] = useState(null)
|
||
|
||
const handleOpenPreview = useCallback(() => {
|
||
if (selectedEntry) setPreviewEntryUid(selectedEntry.entry_uid)
|
||
}, [selectedEntry])
|
||
const handleClosePreview = useCallback(() => setPreviewEntryUid(null), [])
|
||
const handlePlay = useCallback((src, entry) => {
|
||
setCurrentAudio({ src, entry })
|
||
}, [])
|
||
const handleCloseAudio = useCallback(() => setCurrentAudio(null), [])
|
||
|
||
// Close stale modal when selection changes (audio persists intentionally)
|
||
useEffect(() => {
|
||
setPreviewEntryUid(null)
|
||
}, [selectedEntry])
|
||
|
||
// Toggle body class so fixed AudioBar doesn't obscure scrollable content
|
||
useEffect(() => {
|
||
document.body.classList.toggle('has-audio-bar', !!currentAudio)
|
||
return () => document.body.classList.remove('has-audio-bar')
|
||
}, [currentAudio])
|
||
|
||
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
|
||
<>
|
||
<Topbar
|
||
archives={archives}
|
||
archiveId={archiveId}
|
||
onArchiveChange={handleArchiveChange}
|
||
view={view}
|
||
onViewChange={handleViewChange}
|
||
onCaptureClick={handleCaptureClick}
|
||
/>
|
||
<main className="app-shell">
|
||
<div className="workspace">
|
||
{view === 'archive' && (
|
||
<div className="toolbar">
|
||
<div className="search-field">
|
||
<span className="ico" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||
<circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>
|
||
</svg>
|
||
</span>
|
||
<input
|
||
className="search-input"
|
||
type="search"
|
||
aria-label="Search archive"
|
||
aria-busy={searchBusy}
|
||
placeholder="Search titles, URLs, types, tags…"
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
/>
|
||
<span className="kbd">⌘K</span>
|
||
</div>
|
||
<span className="result-count">
|
||
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
|
||
{tagFilter && (
|
||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||
× {humanizeTags ? displayPath(tagFilter) : tagFilter}
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{view === 'archive' && (
|
||
<EntriesView
|
||
entries={entries}
|
||
selectedEntryUid={selectedEntryUid}
|
||
onSelectEntry={selectEntry}
|
||
archiveId={archiveId}
|
||
tagFilter={tagFilter}
|
||
onClearTagFilter={handleClearTagFilter}
|
||
searchQuery={searchQuery}
|
||
onSearchChange={setSearchQuery}
|
||
resultCount={resultCount}
|
||
searchBusy={searchBusy}
|
||
/>
|
||
)}
|
||
{view === 'runs' && <RunsView runs={runs} />}
|
||
{view === 'admin' && <AdminView archives={archives} />}
|
||
{view === 'tags' && (
|
||
<TagsView
|
||
archiveId={archiveId}
|
||
tagNodes={tagNodes}
|
||
tagFilter={tagFilter}
|
||
onTagFilterSet={handleTagFilterSet}
|
||
onViewChange={handleViewChange}
|
||
onTagRenamed={handleTagRenamed}
|
||
onTagDeleted={handleTagDeleted}
|
||
onTagsRefresh={handleTagsRefresh}
|
||
humanizeTags={humanizeTags}
|
||
/>
|
||
)}
|
||
{view === 'collections' && (
|
||
<CollectionsView archiveId={archiveId} />
|
||
)}
|
||
{view === 'settings' && (
|
||
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} />
|
||
)}
|
||
</div>
|
||
<ContextRail
|
||
archiveId={archiveId}
|
||
selectedEntry={selectedEntry}
|
||
detail={entryDetail}
|
||
onTagFilterSet={handleTagFilterSet}
|
||
tagNodes={tagNodes}
|
||
onTagsRefresh={handleTagsRefresh}
|
||
onEntryTitleChange={handleEntryTitleChange}
|
||
onEntryDeleted={handleEntryDeleted}
|
||
humanizeTags={humanizeTags}
|
||
onDetailRefresh={handleDetailRefresh}
|
||
onOpenPreview={handleOpenPreview}
|
||
onPlay={handlePlay}
|
||
/>
|
||
</main>
|
||
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (
|
||
<PreviewModal
|
||
archiveId={archiveId}
|
||
entry={selectedEntry}
|
||
detail={entryDetail}
|
||
onClose={handleClosePreview}
|
||
/>
|
||
)}
|
||
{currentAudio && (
|
||
<AudioBar
|
||
entry={currentAudio.entry}
|
||
src={currentAudio.src}
|
||
archiveId={archiveId}
|
||
onClose={handleCloseAudio}
|
||
/>
|
||
)}
|
||
<CaptureDialog
|
||
open={captureDialogOpen}
|
||
archiveId={archiveId}
|
||
onClose={handleCaptureClose}
|
||
onCaptured={handleCaptured}
|
||
onToast={handleToast}
|
||
/>
|
||
<ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} />
|
||
</>
|
||
</AuthContext.Provider>
|
||
)
|
||
}
|