diff --git a/src/components/explore/CollectionEditBar.tsx b/src/components/explore/CollectionEditBar.tsx new file mode 100644 index 0000000..1527e76 --- /dev/null +++ b/src/components/explore/CollectionEditBar.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { type RefObject } from 'react'; +import { Trash2 } from 'lucide-react'; +import { HOURLY_POINT_BUDGET } from '@/utils/atproto/writeThrottle'; +import DeleteProgressBar from './DeleteProgressBar'; +import { selectionButtonStyle } from './collectionListHelpers'; + +/** + * The in-page bulk-edit toolbar shown while selection mode is active on a + * collection: Select/Deselect chips, a running "N selected" count, and the + * delete affordance, which walks through delete → confirm → in-flight + * (progress bar + Stop) states. Purely presentational — every count, flag, and + * handler is supplied by , which owns the selection and the + * delete engine. Its condensed twin lives in , driven from the + * same published snapshot. + */ +export default function CollectionEditBar({ + editBarRef, + recordsLength, + selectedSize, + allSelected, + deleting, + confirmingDelete, + deleteProgress, + deleteWaitSec, + willPace, + onSelectAll, + onDeselectAll, + onRequestDelete, + onConfirmDelete, + onCancelDelete, + onStop, +}: { + editBarRef: RefObject; + recordsLength: number; + selectedSize: number; + allSelected: boolean; + deleting: boolean; + confirmingDelete: boolean; + deleteProgress: { done: number; total: number } | null; + deleteWaitSec: number | null; + willPace: boolean; + onSelectAll: () => void; + onDeselectAll: () => void; + onRequestDelete: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onStop: () => void; +}) { + return ( +
+ + + + {selectedSize} selected + + + {!confirmingDelete ? ( + + ) : deleting && deleteProgress ? ( + <> + + {deleteWaitSec != null + ? `Paced under the rate limit — resuming in ${deleteWaitSec}s` + : 'Deleting…'} + + + + + ) : ( + <> + + Delete {selectedSize} record{selectedSize === 1 ? '' : 's'}? This cannot be + undone. + {willPace && + ` Aturi will pace this under Bluesky's ~${HOURLY_POINT_BUDGET.toLocaleString()}/hour write limit, so it may pause partway.`} + + + + + )} +
+ ); +} diff --git a/src/components/explore/CollectionExplorer.tsx b/src/components/explore/CollectionExplorer.tsx index 642b18a..7e08eae 100644 --- a/src/components/explore/CollectionExplorer.tsx +++ b/src/components/explore/CollectionExplorer.tsx @@ -1,31 +1,39 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; -import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Pause, Play, FilePenLine, Trash2, X, Plus, Loader2 } from 'lucide-react'; +import { Pause, Play, FilePenLine, X, Plus, Loader2 } from 'lucide-react'; import { listRecordsPage, type AtRecord } from '@/utils/atproto/pdsClient'; import { msUntilBudget, recordSpend, pointsAvailable, - HOURLY_POINT_BUDGET, } from '@/utils/atproto/writeThrottle'; import { encodeRepo, rkeyFromAtUri } from '@/utils/atproto/urls'; import { resolveIdentifier, type IdentityBundle } from '@/utils/atproto/identity'; -import { previewFor } from '@/utils/atproto/previewExtractors'; -import { tidToDate, formatTidRelative } from '@/utils/atproto/tid'; import { createJetstreamConnection, type JetstreamCommit, } from '@/utils/atproto/jetstream'; import AppearIn from './AppearIn'; import Breadcrumb from './Breadcrumb'; -import DeleteProgressBar from './DeleteProgressBar'; +import CollectionEditBar from './CollectionEditBar'; +import CollectionRecordRow from './CollectionRecordRow'; import NotFoundPanel from '@/components/NotFoundPanel'; import SignInPanel from './SignInPanel'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; import { useEditBar } from './EditBarContext'; +import { + RECORDS_PER_PAGE, + APPLY_WRITES_MAX, + THROTTLE_TICK_MS, + NAV_OFFSET_PX, + REVEAL_HYSTERESIS_PX, + sleep, + listColumns, + formatCount, + rateLimitResetMinutes, +} from './collectionListHelpers'; type Props = { repo: string; @@ -73,95 +81,6 @@ export default function CollectionExplorer({ repo, collection }: Props) { return ; } -// listRecords' XRPC max — request the full page on each call so users see -// as many records as possible per fetch. -const RECORDS_PER_PAGE = 100; - -// Keep the record-count stat compact once a repo has paged in a lot of rows: -// 1000 -> "1k", 1400 -> "1.4k", 12300 -> "12.3k", 1_000_000 -> "1m". Counts -// under 1k render verbatim. Lowercased to sit with the explorer's quiet, -// terminal-flavoured typography. -const compactCountFormatter = new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 1, -}); -function formatCount(n: number): string { - return n < 1000 ? String(n) : compactCountFormatter.format(n).toLowerCase(); -} - -// com.atproto.repo.applyWrites caps a batch at 200 operations (lexicon -// maxLength), so a larger selection is split into chunks. Each chunk lands as -// one atomic repo commit instead of one commit per record. -const APPLY_WRITES_MAX = 200; - -// Deletes run one batch at a time. Throughput is dominated by the write-rate -// throttle (below) once a selection is large, and sequential batches keep the -// pacing accounting and the "resuming in Ns" countdown simple and exact. -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// While paused for the throttle, re-check the budget on this cadence so the -// countdown ticks and a Stop press is picked up within a second. -const THROTTLE_TICK_MS = 1000; - -// Pull minutes-until-reset out of a PDS 429 so the delete UI can say when the -// write budget frees up. Bluesky sends `ratelimit-reset` as an absolute -// unix-seconds timestamp; `retry-after` (seconds from now) is the fallback. -// Returns null when neither header is present or parseable. -function rateLimitResetMinutes(err: unknown): number | null { - const headers = (err as { headers?: Record } | null)?.headers; - if (!headers) return null; - const reset = headers['ratelimit-reset']; - if (reset) { - const ms = Number(reset) * 1000 - Date.now(); - if (Number.isFinite(ms) && ms > 0) return Math.max(1, Math.ceil(ms / 60000)); - } - const retryAfter = headers['retry-after']; - if (retryAfter) { - const secs = Number(retryAfter); - if (Number.isFinite(secs) && secs > 0) return Math.max(1, Math.ceil(secs / 60)); - } - return null; -} - -// Reveal/retract thresholds for dropping the condensed edit bar into the nav, -// matching the breadcrumb's behaviour: the top ~96px counts as occluded by the -// sticky nav, and a dead band keeps the reveal from strobing at the boundary -// (showing the bar grows the nav, which nudges the page back across the line). -const NAV_OFFSET_PX = 96; -const REVEAL_HYSTERESIS_PX = 72; - -// Row layout. The whole list is one CSS grid so the rkey and data-preview -// columns line up across every row: the
    defines the columns and each row -// re-adopts them with `grid-template-columns: subgrid`. The rkey track hugs its -// content but is capped at 30ch — a shared column is only as wide as its widest -// member, so this bounds how far one long rkey can push every preview in — past -// which a long rkey wraps onto a second line (see the wrap rule below) -// rather than shoving the preview off-screen; the `1fr` preview takes the rest. -// -// Sizing to content is the point: a fixed `minmax(_, 30ch)` always *reserves* -// its 30ch max (grid grows fixed tracks to their limit and skips the flexible -// `1fr`), which on a narrow phone viewport left the preview squeezed into a -// sliver on the right. Selection mode prepends a checkbox, adding a leading -// `auto` track. -const RKEY_COLUMN = 'fit-content(30ch)'; -const listColumns = (editing: boolean) => - editing ? `auto ${RKEY_COLUMN} 1fr` : `${RKEY_COLUMN} 1fr`; - -// Shared look for the quiet "Select all" / "Deselect all" buttons in the -// bulk-edit toolbar — neutral chips that dim when their action is a no-op. -function selectionButtonStyle(disabled: boolean): CSSProperties { - return { - padding: '0.4rem 0.75rem', - background: 'var(--bg-tertiary)', - color: 'var(--text-secondary)', - border: '1px solid var(--border-medium)', - fontFamily: 'var(--font-serif)', - fontSize: '0.8125rem', - cursor: disabled ? 'not-allowed' : 'pointer', - opacity: disabled ? 0.5 : 1, - }; -} - function CollectionList({ identity, collection, @@ -698,148 +617,23 @@ function CollectionList({ {editing && ( -
    - - - - {selected.size} selected - - - {!confirmingDelete ? ( - - ) : deleting && deleteProgress ? ( - <> - - {deleteWaitSec != null - ? `Paced under the rate limit — resuming in ${deleteWaitSec}s` - : 'Deleting…'} - - - - - ) : ( - <> - - Delete {selected.size} record{selected.size === 1 ? '' : 's'}? This cannot be - undone. - {willPace && - ` Aturi will pace this under Bluesky's ~${HOURLY_POINT_BUDGET.toLocaleString()}/hour write limit, so it may pause partway.`} - - - - - )} -
    + )} {signInOpen && !session && (
    - {records.map((rec) => { - const rkey = rkeyFromAtUri(rec.uri) || ''; - // TID-derived timestamps are decoded client-side from the rkey - // itself — no extra PDS call. Non-TID rkeys (custom strings, - // singletons like "self") return null and we just hide the chip. - const tidDate = tidToDate(rkey); - const isSelected = selected.has(rec.uri); - const rowInner = ( - <> -
    - - {rkey} - - {tidDate && ( - - )} -
    - - {previewFor(rec.value)} - - - ); - return ( -
  • - {editing ? ( - // Selection mode: the row becomes a checkbox label so clicking - // anywhere toggles selection (native), and navigation is - // suppressed while the visitor is choosing what to delete. - - ) : ( - { - e.currentTarget.style.background = 'var(--bg-tertiary)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'transparent'; - }} - > - {rowInner} - - )} -
  • - ); - })} + {records.map((rec) => ( + + ))}
diff --git a/src/components/explore/CollectionRecordRow.tsx b/src/components/explore/CollectionRecordRow.tsx new file mode 100644 index 0000000..a32a17d --- /dev/null +++ b/src/components/explore/CollectionRecordRow.tsx @@ -0,0 +1,153 @@ +'use client'; + +import Link from 'next/link'; +import type { AtRecord } from '@/utils/atproto/pdsClient'; +import { rkeyFromAtUri } from '@/utils/atproto/urls'; +import { tidToDate, formatTidRelative } from '@/utils/atproto/tid'; +import { previewFor } from '@/utils/atproto/previewExtractors'; + +/** + * One record row in a collection listing. Browsing mode renders a to the + * record page; selection mode swaps it for a checkbox