diff --git a/src/components/explore/tabs/CollectionsTab.tsx b/src/components/explore/tabs/CollectionsTab.tsx index 93fb87b..8fecba5 100644 --- a/src/components/explore/tabs/CollectionsTab.tsx +++ b/src/components/explore/tabs/CollectionsTab.tsx @@ -1,16 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { - ChevronDown, - ChevronRight, - ChevronsDownUp, - ChevronsUpDown, - Check, - Pin, - PinOff, -} from 'lucide-react'; +import { ChevronsDownUp, ChevronsUpDown, Pin } from 'lucide-react'; import { describeRepo } from '@/utils/atproto/pdsClient'; import { encodeRepo } from '@/utils/atproto/urls'; import type { IdentityBundle } from '@/utils/atproto/identity'; @@ -26,90 +17,9 @@ import { pinTargetFor, togglePinnedLexicon, } from '@/utils/preferences'; - -type SubGroup = { - /** 3rd NSID segment, e.g. "feed", "graph", "actor". */ - key: string; - /** Composite key used as a stable React + collapsed-state id. */ - fullKey: string; - items: string[]; -}; - -type MajorGroup = { - /** First two NSID segments, e.g. "app.bsky", "is.dame". */ - key: string; - /** 3-segment NSIDs that live directly under the major group (no sub). */ - directItems: string[]; - /** 4+ segment NSIDs grouped by their 3rd segment. */ - subgroups: SubGroup[]; - /** Total leaf NSIDs across direct + all sub-groups. */ - totalCount: number; -}; - -/** - * Two-level hierarchical grouping. - * - * `app.bsky.feed.post` → major `app.bsky`, sub `feed`, leaf - * `app.bsky.feed.post.shit` → major `app.bsky`, sub `feed`, leaf - * `app.bsky.actor.profile` → major `app.bsky`, sub `actor`, leaf - * `is.dame.now` → major `is.dame`, no sub, direct leaf - * - * For NSIDs with fewer than 4 segments there's no third segment to sub-group - * by, so they sort under the major group as direct leaves above the sub-groups. - */ -function groupHierarchically(list: string[], filterStr: string): MajorGroup[] { - const f = filterStr.trim().toLowerCase(); - const filtered = f ? list.filter((nsid) => nsid.toLowerCase().includes(f)) : list; - - // Two-pass: bucket by major, then by sub within each major. - const majors = new Map< - string, - { direct: string[]; subs: Map } - >(); - for (const nsid of filtered) { - const segs = nsid.split('.'); - const major = segs.length >= 2 ? `${segs[0]}.${segs[1]}` : nsid; - if (!majors.has(major)) majors.set(major, { direct: [], subs: new Map() }); - const bucket = majors.get(major)!; - if (segs.length >= 4) { - const subKey = segs[2]; - if (!bucket.subs.has(subKey)) bucket.subs.set(subKey, []); - bucket.subs.get(subKey)!.push(nsid); - } else { - bucket.direct.push(nsid); - } - } - - // Sort + materialize. Sub-groups with a single item are hoisted up to - // the major's direct list — a collapsible group containing one row is - // pure wrapper noise. - return Array.from(majors.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([majorKey, bucket]) => { - const hoistedDirect = [...bucket.direct]; - const subgroups: SubGroup[] = []; - for (const [subKey, items] of bucket.subs.entries()) { - if (items.length === 1) { - hoistedDirect.push(items[0]); - } else { - subgroups.push({ - key: subKey, - fullKey: `${majorKey}.${subKey}`, - items: items.sort(), - }); - } - } - subgroups.sort((a, b) => a.key.localeCompare(b.key)); - const totalCount = - hoistedDirect.length + subgroups.reduce((acc, s) => acc + s.items.length, 0); - return { - key: majorKey, - directItems: hoistedDirect.sort(), - subgroups, - totalCount, - }; - }); -} +import { groupHierarchically, pinnedKey } from './collectionGrouping'; +import GroupHeader from './GroupHeader'; +import LeafRow from './LeafRow'; export default function CollectionsTab({ identity }: { identity: IdentityBundle }) { const [collections, setCollections] = useState(null); @@ -632,270 +542,6 @@ export default function CollectionsTab({ identity }: { identity: IdentityBundle ); } -function GroupHeader({ - open, - onToggle, - prefix, - dimPrefix, - count, - emphasize, - indent, - pinnable, - pinned, - onTogglePin, -}: { - open: boolean; - onToggle: () => void; - prefix: string; - /** Leading slice of `prefix` to render dimmed because it's inherited from a parent group. */ - dimPrefix?: string; - count: number; - emphasize?: boolean; - indent?: boolean; - /** Show the pin/unpin button that pins the whole `prefix.*` group. */ - pinnable?: boolean; - pinned?: boolean; - onTogglePin?: () => void; -}) { - const hasDim = dimPrefix && prefix.startsWith(dimPrefix); - const dim = hasDim ? dimPrefix : ''; - const tail = hasDim ? prefix.slice(dimPrefix.length) : prefix; - const groupNsid = `${prefix}${PIN_GROUP_SUFFIX}`; - return ( -
- - {pinnable && onTogglePin && ( - - )} -
- ); -} - -/** - * Open-state key for a pinned group block, namespaced so it can't collide - * with a main-list group key of the same prefix. - */ -function pinnedKey(entry: string): string { - return `pinned:${entry}`; -} - -function LeafRow({ - nsid, - href, - deepIndent, - dimPrefix, - baseBg, - inCommon, - pinnable, - pinned, - onTogglePin, -}: { - nsid: string; - href: string; - deepIndent?: boolean; - /** Leading slice of `nsid` that's redundant given the parent group; rendered dimmed. */ - dimPrefix?: string; - /** Resting background for zebra striping; mouseleave restores to this. */ - baseBg: string; - /** Signed-in viewer also has records in this collection — show a marker. */ - inCommon?: boolean; - /** Show the pin/unpin button (only when prefs say pins apply on this repo). */ - pinnable?: boolean; - pinned?: boolean; - onTogglePin?: () => void; -}) { - const hasDim = dimPrefix && nsid.startsWith(dimPrefix); - const dim = hasDim ? dimPrefix : ''; - const tail = hasDim ? nsid.slice(dimPrefix.length) : nsid; - return ( -
  • { - e.currentTarget.style.background = 'var(--bg-tertiary)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = baseBg; - }} - > - { - e.currentTarget.style.color = 'var(--text-accent)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-primary)'; - }} - > - - {dim && {dim}} - {tail} - - {inCommon && ( - - you - - )} - - {pinnable && onTogglePin && ( - - )} -
  • - ); -} - function listStyle(): React.CSSProperties { return { listStyle: 'none', diff --git a/src/components/explore/tabs/GroupHeader.tsx b/src/components/explore/tabs/GroupHeader.tsx new file mode 100644 index 0000000..1fe080d --- /dev/null +++ b/src/components/explore/tabs/GroupHeader.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { ChevronDown, ChevronRight, Pin, PinOff } from 'lucide-react'; +import { PIN_GROUP_SUFFIX } from '@/utils/preferences'; + +/** + * Collapsible header row for a lexicon group (a major `app.bsky` block or a + * nested `app.bsky.feed` sub-group) in the collections list. Shows the group + * prefix with a `.*` suffix, a member count, and — when pinnable — a + * pin/unpin button that pins the whole `prefix.*` group. Purely presentational; + * owns the open/pinned state and the toggle handlers. + */ +export default function GroupHeader({ + open, + onToggle, + prefix, + dimPrefix, + count, + emphasize, + indent, + pinnable, + pinned, + onTogglePin, +}: { + open: boolean; + onToggle: () => void; + prefix: string; + /** Leading slice of `prefix` to render dimmed because it's inherited from a parent group. */ + dimPrefix?: string; + count: number; + emphasize?: boolean; + indent?: boolean; + /** Show the pin/unpin button that pins the whole `prefix.*` group. */ + pinnable?: boolean; + pinned?: boolean; + onTogglePin?: () => void; +}) { + const hasDim = dimPrefix && prefix.startsWith(dimPrefix); + const dim = hasDim ? dimPrefix : ''; + const tail = hasDim ? prefix.slice(dimPrefix.length) : prefix; + const groupNsid = `${prefix}${PIN_GROUP_SUFFIX}`; + return ( +
    + + {pinnable && onTogglePin && ( + + )} +
    + ); +} diff --git a/src/components/explore/tabs/LeafRow.tsx b/src/components/explore/tabs/LeafRow.tsx new file mode 100644 index 0000000..c37b94e --- /dev/null +++ b/src/components/explore/tabs/LeafRow.tsx @@ -0,0 +1,141 @@ +'use client'; + +import Link from 'next/link'; +import { Check, Pin, PinOff } from 'lucide-react'; + +/** + * A single lexicon (leaf NSID) row in the collections list — a link to the + * collection's records, with an optional "you" marker when the signed-in + * viewer also has records there and an optional pin/unpin button. Purely + * presentational; owns the pinned state and toggle handler. + */ +export default function LeafRow({ + nsid, + href, + deepIndent, + dimPrefix, + baseBg, + inCommon, + pinnable, + pinned, + onTogglePin, +}: { + nsid: string; + href: string; + deepIndent?: boolean; + /** Leading slice of `nsid` that's redundant given the parent group; rendered dimmed. */ + dimPrefix?: string; + /** Resting background for zebra striping; mouseleave restores to this. */ + baseBg: string; + /** Signed-in viewer also has records in this collection — show a marker. */ + inCommon?: boolean; + /** Show the pin/unpin button (only when prefs say pins apply on this repo). */ + pinnable?: boolean; + pinned?: boolean; + onTogglePin?: () => void; +}) { + const hasDim = dimPrefix && nsid.startsWith(dimPrefix); + const dim = hasDim ? dimPrefix : ''; + const tail = hasDim ? nsid.slice(dimPrefix.length) : nsid; + return ( +
  • { + e.currentTarget.style.background = 'var(--bg-tertiary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = baseBg; + }} + > + { + e.currentTarget.style.color = 'var(--text-accent)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = 'var(--text-primary)'; + }} + > + + {dim && {dim}} + {tail} + + {inCommon && ( + + you + + )} + + {pinnable && onTogglePin && ( + + )} +
  • + ); +} diff --git a/src/components/explore/tabs/collectionGrouping.ts b/src/components/explore/tabs/collectionGrouping.ts new file mode 100644 index 0000000..3a0cc6f --- /dev/null +++ b/src/components/explore/tabs/collectionGrouping.ts @@ -0,0 +1,91 @@ +export type SubGroup = { + /** 3rd NSID segment, e.g. "feed", "graph", "actor". */ + key: string; + /** Composite key used as a stable React + collapsed-state id. */ + fullKey: string; + items: string[]; +}; + +export type MajorGroup = { + /** First two NSID segments, e.g. "app.bsky", "is.dame". */ + key: string; + /** 3-segment NSIDs that live directly under the major group (no sub). */ + directItems: string[]; + /** 4+ segment NSIDs grouped by their 3rd segment. */ + subgroups: SubGroup[]; + /** Total leaf NSIDs across direct + all sub-groups. */ + totalCount: number; +}; + +/** + * Two-level hierarchical grouping. + * + * `app.bsky.feed.post` → major `app.bsky`, sub `feed`, leaf + * `app.bsky.feed.post.shit` → major `app.bsky`, sub `feed`, leaf + * `app.bsky.actor.profile` → major `app.bsky`, sub `actor`, leaf + * `is.dame.now` → major `is.dame`, no sub, direct leaf + * + * For NSIDs with fewer than 4 segments there's no third segment to sub-group + * by, so they sort under the major group as direct leaves above the sub-groups. + */ +export function groupHierarchically(list: string[], filterStr: string): MajorGroup[] { + const f = filterStr.trim().toLowerCase(); + const filtered = f ? list.filter((nsid) => nsid.toLowerCase().includes(f)) : list; + + // Two-pass: bucket by major, then by sub within each major. + const majors = new Map< + string, + { direct: string[]; subs: Map } + >(); + for (const nsid of filtered) { + const segs = nsid.split('.'); + const major = segs.length >= 2 ? `${segs[0]}.${segs[1]}` : nsid; + if (!majors.has(major)) majors.set(major, { direct: [], subs: new Map() }); + const bucket = majors.get(major)!; + if (segs.length >= 4) { + const subKey = segs[2]; + if (!bucket.subs.has(subKey)) bucket.subs.set(subKey, []); + bucket.subs.get(subKey)!.push(nsid); + } else { + bucket.direct.push(nsid); + } + } + + // Sort + materialize. Sub-groups with a single item are hoisted up to + // the major's direct list — a collapsible group containing one row is + // pure wrapper noise. + return Array.from(majors.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([majorKey, bucket]) => { + const hoistedDirect = [...bucket.direct]; + const subgroups: SubGroup[] = []; + for (const [subKey, items] of bucket.subs.entries()) { + if (items.length === 1) { + hoistedDirect.push(items[0]); + } else { + subgroups.push({ + key: subKey, + fullKey: `${majorKey}.${subKey}`, + items: items.sort(), + }); + } + } + subgroups.sort((a, b) => a.key.localeCompare(b.key)); + const totalCount = + hoistedDirect.length + subgroups.reduce((acc, s) => acc + s.items.length, 0); + return { + key: majorKey, + directItems: hoistedDirect.sort(), + subgroups, + totalCount, + }; + }); +} + +/** + * Open-state key for a pinned group block, namespaced so it can't collide + * with a main-list group key of the same prefix. + */ +export function pinnedKey(entry: string): string { + return `pinned:${entry}`; +}