mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
* feat(core): add requires_auth to collections; include name in entry-collection memberships
- Add `requires_auth INTEGER NOT NULL DEFAULT 1` column to the
collections DDL and as an idempotent ALTER TABLE migration in
initialize_schema (archive DB), not initialize_auth_schema.
- CollectionRecord and CollectionSummary gain `requires_auth: bool`.
- create_collection() and update_collection() accept the new field.
- get_entry_collection_memberships() now returns collection name as the
third tuple element; EntryCollectionMembership gains a `name` field
so the sidebar can show human-readable names instead of raw UIDs.
* feat(server): conditional auth for public collections; add requires_auth + original_url to API
- CreateCollectionBody gains requires_auth (default true).
- PatchCollectionBody gains requires_auth: Option<bool>.
- get_collection_handler: load record first, then skip auth.require_auth()
when record.requires_auth == false so public collections are accessible
to unauthenticated callers; caller_bits falls back to ROLE_GUEST (1)
so only visibility_bits=3 entries are returned to guests.
- Collection JSON response includes requires_auth and each entry now
includes original_url for use by the public collection page.
- list_collections_handler keeps require_auth (management UI).
* feat(frontend): public collection page at /c/:archiveId/:collUid
- Detect PUBLIC_COLL_ROUTE at module load time (like PREVIEW_ROUTE) and
return <PublicCollectionPage> before any auth checks so unauthenticated
users can view public collections without hitting the login gate.
- PublicCollectionPage fetches via getCollection() and renders the
server-filtered entry list (no client-side bitmask filtering - the
server already applies caller_bits=GUEST for unauthenticated requests).
Entry titles link to original_url when present; fall back to plain
text when original_url is null.
- api.js createCollection() gains requiresAuth param (default true),
sent as requires_auth in the request body.
- Storybook story covers WithEntries, Empty, and LoadError states.
* feat(frontend): collections view improvements
- addVis in the 'Add entry' form now syncs to the selected collection's
default_visibility_bits via useEffect on collDetail, so the default
matches the collection's configured entry visibility.
- Rename 'Default visibility' label to 'Entries\' default visibility'
in both the detail pane and the create form to distinguish it from
the new collection-level access setting.
- Add 'Require authentication to view' checkbox in the detail pane
backed by a PATCH to requires_auth; reads collDetail?.requires_auth
with fallback to the list-level selected record.
- Create form gains a matching requires_auth checkbox (default: true),
passed as 5th arg to createCollection().
* feat(frontend): context rail collection improvements
- Show collection name (c.name) instead of raw UID in the sidebar
Collections section; names now come from the updated
EntryCollectionMembership API response.
- Fix horizontal overflow on long collection names: coll-name gains
overflow:hidden + text-overflow:ellipsis + white-space:nowrap +
min-width:0; coll-row gets overflow:hidden.
- Single-entry 'Add to collection' UI: dropdown + button inside the
Collections rail section lets users add the current entry to any
non-default collection without multi-selecting. After add, the
membership list refreshes automatically.
- Collections section now shows even when entryCollections is empty,
as long as non-default collections exist (so the add form is
accessible for un-membered entries).
- Bulk 'Add to collection' now uses the target collection's
default_visibility_bits instead of hardcoded 2 (Users only).
- Both bulk and single-entry dropdowns filter out slug='_default_'
to match the backend rejection in add_entry_to_collection_handler.
- Collections list is now fetched on archiveId change (not just on
bulk mode entry) so it is available for single-entry mode too.
* build(frontend): update static assets
* feat(frontend): public collection link UX + app-styled public page
CollectionsView:
- When a collection has requires_auth=false, show a read-only URL input
and Copy button below the auth checkbox so the public link is
immediately discoverable. The input auto-selects on focus so manual
copy always works. Copy button tries navigator.clipboard.writeText
first; falls back to execCommand('copy') for HTTP deployments where
the Clipboard API is unavailable in non-secure contexts.
PublicCollectionPage:
- Rewritten to use the app's CSS classes and variables instead of
bare inline styles, so it visually matches the main archive UI.
Dark topbar (.pub-coll-topbar) with brand + collection name, paper
background body, entry list via .coll-entries-list / .coll-entry-row /
.coll-entry-info / .coll-entry-kind — the same classes used in the
authenticated Collections view.
styles.css:
- .coll-public-link-row / -wrap / -input / .coll-copy-btn for the
new link field in CollectionsView detail pane.
- .pub-coll-* classes for the public page layout and typography.
* feat(core): add get_collection_by_slug; scope search to active collection
- get_collection_by_slug(): new function mirroring get_collection_by_uid
but matching on slug, used to resolve the _default_ collection when no
?collection param is supplied.
- SearchEntriesQuery gains collection_id: Option<i64>. When set, the
search SQL adds an EXISTS subquery that checks collection_entries cef
for both membership (cef.collection_id = ?) and visibility bits in
that specific collection — preventing cross-collection visibility
leaks where an entry is public in one collection but private in the
current one. Without collection_id the original cross-collection
visibility fallback is kept.
* feat(server): collection-scoped entries/search with uniform auth gate
All entry listing and search now route through the active collection:
list_entries (?collection=<uid>|main|<omitted>):
- Resolves the target collection; omitted or 'main' resolves to _default_.
- Checks requires_auth on that collection; gates auth conditionally.
- Returns list_entries_for_collection() — same EntrySummary shape.
search_entries_handler:
- Same collection resolution + conditional auth as list_entries.
- Sets search_query.collection_id so SQL scopes membership + visibility
to the specific collection, not cross-collection fallback.
list_collections_handler:
- Dropped require_auth() — collection summaries (name/slug/uid/
requires_auth/default_visibility_bits) are public metadata needed for
the guest collection-switcher dropdown.
Tests:
- list_collections_requires_auth → list_collections_is_public (200).
- list_entries_requires_auth and search coverage still pass.
* feat(frontend): integrate collection switching into main Archive view
Replaces the standalone /c/:archiveId/:collUid public page with a
unified main-view approach where all collection logic lives at /.
URL param:
- ?collection=<uid> selects a collection; omitted or 'main' = default.
- 'main' is normalized to null in parseLocation() so the dropdown shows
'All entries' and the URL stays clean.
Collection switcher (Topbar):
- Dropdown always visible (guests need it to navigate public collections).
- Non-default collections only (All entries = no param = _default_).
- Guest selecting an auth-required collection calls onSignInClick().
- handleCollectionChange checks both named and _default_ requires_auth
before proceeding, redirecting guests to login if needed.
listCollections fetched for all users (guests too) since the endpoint
is now public; used to populate the switcher without auth.
Public-session mode (authenticated state, no currentUser):
- Auth gate: fetchArchives() + fetchEntries() with collection param;
401 falls through to login, 200 proceeds as guest.
- auth:expired suppressed when !currentUser.
- fetchEntryDetail skipped; ContextRail shows entry summary + sign-in prompt.
- ContextRail selection effect skips tag/collection API calls.
- runs/tags not fetched in guest mode.
- Child row expansion disabled in EntryRow (hasChildren = false).
api.js:
- fetchEntries/searchEntries both thread ?collection=<uid> to server.
Deleted: PublicCollectionPage.jsx, PublicCollectionPage.stories.jsx,
copy-link UI from CollectionsView, pub-coll-*/copy-link CSS.
* build(frontend): update static assets
* feat(core): add is_entry_publicly_accessible; checks entry+parent vs public collections
* feat(server): allow guests to fetch detail/children/artifacts for public entries
* feat(frontend): guest collection dropdown filtering; public entry detail without auth wall
* build(frontend): update static assets
* test(server): public entry detail/artifact/children contract for guests
* feat(server): filter auth-required collections from guest list_collections response
174 lines
6.2 KiB
JavaScript
174 lines
6.2 KiB
JavaScript
import { useState } from 'react';
|
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
|
import { fetchEntryChildren } from '../api';
|
|
|
|
function ChildRow({ entry, index, onRowClick, selectedUids }) {
|
|
const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid);
|
|
const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid);
|
|
|
|
const cls = ['child-entry-row',
|
|
index % 2 === 0 ? 'child-entry-row--light' : 'child-entry-row--dark',
|
|
isSelected && 'is-selected',
|
|
isMultiSelected && 'is-multi-selected',
|
|
].filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<div
|
|
className={cls}
|
|
tabIndex={0}
|
|
data-entry-uid={entry.entry_uid}
|
|
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
|
onClick={e => onRowClick(entry, e)}
|
|
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
|
>
|
|
<div className="col-check" aria-hidden="true" />
|
|
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
|
<div className="col-title">
|
|
<span className="source-icon">
|
|
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
|
</span>
|
|
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
|
</div>
|
|
<div className="col-type">
|
|
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
|
</div>
|
|
<div className="col-size">
|
|
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
|
</div>
|
|
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, isPublicSession }) {
|
|
const [favFailed, setFavFailed] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [children, setChildren] = useState(null);
|
|
const [childrenLoading, setChildrenLoading] = useState(false);
|
|
|
|
const showFavicon =
|
|
entry.source_kind === 'web' &&
|
|
entry.entity_kind === 'page' &&
|
|
entry.has_favicon &&
|
|
archiveId &&
|
|
!favFailed;
|
|
|
|
const icon = showFavicon ? (
|
|
<img
|
|
src={`/api/archives/${archiveId}/entries/${entry.entry_uid}/favicon`}
|
|
width="16"
|
|
height="16"
|
|
alt=""
|
|
onError={() => setFavFailed(true)}
|
|
style={{ objectFit: 'contain' }}
|
|
/>
|
|
) : (
|
|
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
|
);
|
|
|
|
const checked = isSelected || isMultiSelected;
|
|
const hasChildren = entry.child_count > 0 && !isPublicSession;
|
|
|
|
function handleCheckboxClick(e) {
|
|
e.stopPropagation();
|
|
onRowClick(entry, { ctrlKey: true, metaKey: false, shiftKey: false, preventDefault() {} });
|
|
}
|
|
|
|
async function handleExpandClick(e) {
|
|
e.stopPropagation();
|
|
if (expanded) {
|
|
setExpanded(false);
|
|
return;
|
|
}
|
|
setExpanded(true);
|
|
if (children === null && !childrenLoading) {
|
|
setChildrenLoading(true);
|
|
try {
|
|
const result = await fetchEntryChildren(archiveId, entry.entry_uid);
|
|
setChildren(result);
|
|
} catch (_) {
|
|
setChildren([]);
|
|
} finally {
|
|
setChildrenLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
const outerClass = [
|
|
'entry-row-outer',
|
|
rowIndex % 2 === 0 ? 'entry-row-outer--light' : 'entry-row-outer--dark',
|
|
isSelected && 'is-selected',
|
|
isMultiSelected && 'is-multi-selected',
|
|
].filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<div className={outerClass} data-entry-uid={entry.entry_uid}>
|
|
<div
|
|
className="entry-row-main"
|
|
tabIndex={0}
|
|
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
|
onClick={e => onRowClick(entry, e)}
|
|
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
|
>
|
|
<div className="col-check">
|
|
<button
|
|
type="button"
|
|
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
|
aria-pressed={checked}
|
|
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
|
onClick={handleCheckboxClick}
|
|
onKeyDown={e => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
|
<div className="col-title">
|
|
{hasChildren && (
|
|
<button
|
|
type="button"
|
|
className={`entry-expand-btn${expanded ? ' is-expanded' : ''}`}
|
|
aria-label={expanded ? 'Collapse children' : `Expand ${entry.child_count} items`}
|
|
aria-expanded={expanded}
|
|
onClick={handleExpandClick}
|
|
onKeyDown={e => e.stopPropagation()}
|
|
/>
|
|
)}
|
|
<span className="source-icon">{icon}</span>
|
|
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
|
{hasChildren && (
|
|
<span className="child-count-badge" aria-hidden="true">{entry.child_count}</span>
|
|
)}
|
|
</div>
|
|
<div className="col-type">
|
|
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
|
</div>
|
|
<div className="col-size">
|
|
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
|
{entry.cached_bytes > 0 && entry.cacheable_bytes > 0 && (
|
|
<span className="size-cached-pct" title={`${formatBytes(entry.cached_bytes)} already on disk from an earlier entry`}>
|
|
{Math.round(entry.cached_bytes / entry.cacheable_bytes * 100)}% cached
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
|
</div>
|
|
{expanded && (
|
|
<>
|
|
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
|
|
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
|
|
{children && children
|
|
.filter(c => !deletedUids?.has(c.entry_uid))
|
|
.map((child, idx) => (
|
|
<ChildRow
|
|
key={child.entry_uid}
|
|
entry={child}
|
|
index={idx}
|
|
onRowClick={onRowClick}
|
|
selectedUids={selectedUids}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|