import { useMemo, useState } from "react"; import { useIsBlockSelected } from "src/useUIState"; import { useEntity, useReplicache } from "src/replicache"; import { BlockProps, BlockLayout } from "./Block"; import { usePublicationData, useNormalizedPublicationRecord, } from "app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { PaginatedPublicationPostsList } from "app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList"; import { ChapterShelf } from "app/(app)/(published)/lish/[did]/[publication]/PublicationPostsChapterList"; import { buildChapterCards } from "src/utils/chapterGrouping"; import { POSTS_LIST_PAGE_SIZE, postsListFilterKey, sortPostsForList, filterPostsByTags, type LoadPostsBatch, type PostsListView, } from "src/utils/postsListPagination"; import type { PublicationPostsListPost } from "src/utils/buildPublicationPosts"; import { Popover } from "components/Popover"; import { Toggle } from "components/Toggle"; import { SettingsTriggerButton } from "./SettingsTriggerButton"; import { PlaceholderText } from "./PostSizeIcons"; import { CloseTiny } from "components/Icons/CloseTiny"; import { EmptyState } from "components/EmptyState"; import { ShortcutKey } from "components/Layout"; import { HelpSmall } from "components/Icons/HelpSmall"; export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => { let isSelected = useIsBlockSelected(props.entityID); if (props.preview) { return ( ); } return ( } > ); }; function PostsListBlockContent({ entityID }: { entityID: string }) { let { data } = usePublicationData(); let publicationRecord = useNormalizedPublicationRecord(); let viewFact = useEntity(entityID, "posts-list/view"); let view: PostsListView = viewFact?.data.value ?? "medium"; let highlightFirstFact = useEntity( entityID, "posts-list/highlight-first-post", ); let highlightFirst = highlightFirstFact?.data.value ?? false; let filterTagFacts = useEntity(entityID, "posts-list/filter-tag"); let filterTags = useMemo( () => filterTagFacts.map((f) => f.data.value), [filterTagFacts], ); let limitFact = useEntity(entityID, "posts-list/limit"); let limit = limitFact?.data.value; // The dashboard already loads every document, so order/filter that in-memory // set, hand the paginated list the full URI ordering, and resolve each batch // locally — no extra round trips, just windowed rendering. Chapter view // groups the same set into prebuilt cards instead, mirroring what the SSR // page ships. let listData = useMemo(() => { if (!data?.documents) return null; let ordered = sortPostsForList( filterPostsByTags(data.documents, filterTags).map((d) => ({ uri: d.uri, record: d.record, commentsCount: d.commentsCount, mentionsCount: d.mentionsCount, recommendsCount: d.recommendsCount, membersOnly: d.membersOnly, })), ); let byUri = new Map(ordered.map((p) => [p.uri, p])); let loadBatch: LoadPostsBatch = async (batch) => batch .map((u) => byUri.get(u)) .filter((p): p is PublicationPostsListPost => p !== undefined); return { uris: ordered.map((p) => p.uri), initialPosts: ordered.slice(0, POSTS_LIST_PAGE_SIZE), latestPost: ordered[0], chapterCards: view === "chapter" && data.publication ? buildChapterCards(ordered, data.publication) : undefined, loadBatch, }; }, [data?.documents, data?.publication, filterTags, view]); if (data === undefined) return ; if (!data?.publication) return ; if (!listData || listData.uris.length === 0) return ( You haven't published any posts yet! When you do, they'll show here. ); // The editor lays the list out rather than reading it, so both branches // disable links — clicking a post shouldn't navigate away from the page // being customized. if (view === "chapter") { return ( ); } return ( ); } function PostsListPlaceholder() { return (
{[0, 1, 2].map((i) => (

))}
); } function PostsListSettingsButton(props: { entityID: string }) { let { rep, undoManager } = useReplicache(); let { data } = usePublicationData(); let viewFact = useEntity(props.entityID, "posts-list/view"); let view: PostsListView = viewFact?.data.value ?? "medium"; let highlightFirstFact = useEntity( props.entityID, "posts-list/highlight-first-post", ); let highlightFirst = highlightFirstFact?.data.value ?? false; let filterTagFacts = useEntity(props.entityID, "posts-list/filter-tag"); let selectedTags = useMemo( () => filterTagFacts.map((f) => f.data.value), [filterTagFacts], ); let limitFact = useEntity(props.entityID, "posts-list/limit"); let limit = limitFact?.data.value; let [filterByTagEnabled, setFilterByTagEnabled] = useState( () => selectedTags.length > 0, ); let [limitEnabled, setLimitEnabled] = useState(() => !!limit && limit > 0); let [chapterHelpOpen, setChapterHelpOpen] = useState(false); let setLimit = (value: number) => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/limit", data: { type: "number", value }, }); }; let clearLimit = () => { if (!rep || !limitFact) return; rep.mutate.retractFact({ factID: limitFact.id }); }; let allTags = useMemo(() => { let tagSet = new Set(); for (let doc of data?.documents ?? []) { for (let tag of doc.record.tags ?? []) tagSet.add(tag); } return Array.from(tagSet).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }), ); }, [data?.documents]); return ( } >

List Layout

{( [ { value: "small", Icon: SmallIcon }, { value: "medium", Icon: MedIcon }, { value: "chapter", Icon: ChapterIcon }, ] as { value: PostsListView; Icon: (props: { selected: boolean }) => React.ReactNode; }[] ).map((option) => { let selected = view === option.value; return ( ); })} {/* A sibling of the layout options rather than a child: the chapter option is itself a button, and the row's last column is the chapter icon, so its bottom right corner is this row's. */} {view === "chapter" && ( )}
{view === "chapter" && chapterHelpOpen && (

Group posts into chapters based on titles

{" "}

Posts titled
"Ch1 - Pg1"
"Ch1 - Pg2"
"Ch1 - Pg3"
will group into a chapter named "Ch1".

Separate chapter names and page titles with
/ ,{" "} : or -.
Do not use these characters in chapter names!

)} { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/highlight-first-post", data: { type: "boolean", value: !highlightFirst }, }); }} > Highlight Latest Post {view !== "chapter" && (
{ if (limitEnabled) { clearLimit(); setLimitEnabled(false); } else { setLimitEnabled(true); if (!limit || limit < 1) setLimit(5); } }} > Limit Posts {limitEnabled && (
Show only e.stopPropagation()} onChange={(e) => { let next = Math.max(1, Math.floor(Number(e.target.value))); if (Number.isFinite(next)) setLimit(next); }} className="input-tag w-16 border border-border rounded px-1 py-0.5 bg-bg-page" /> posts
)}
)}

Settings

{ if (filterByTagEnabled) { // turning off the filter clears any selected tags undoManager.withUndoGroup(async () => { if (!rep) return; for (let fact of filterTagFacts) await rep.mutate.retractFact({ factID: fact.id }); }); setFilterByTagEnabled(false); } else { setFilterByTagEnabled(true); } }} > Filter by Tag {filterByTagEnabled && selectedTags.length > 0 && ( )}
{filterByTagEnabled ? (
{allTags.length === 0 ? (
no tags yet
) : (
{allTags.map((tag) => { let selectedFact = filterTagFacts.find( (f) => f.data.value === tag, ); let isSelected = !!selectedFact; return ( ); })}
)}
) : null}
); } const SmallIcon = ({ selected }: { selected: boolean }) => { return (
{[0, 1, 2].map((i) => (
{PlaceholderText("lg")}
{PlaceholderText("sm", "60%")}
))}
); }; const ChapterIcon = ({ selected }: { selected: boolean }) => { return (
{[0, 1].map((i) => (
{PlaceholderText("sm", "80%")}
))}
); }; const MedIcon = ({ selected }: { selected: boolean }) => { return (
{[0, 1].map((i) => (
{PlaceholderText("lg")} {PlaceholderText("md")} {PlaceholderText("md", "80%")}
{PlaceholderText("sm", "60%")}
))}
); };