import { Fragment, useMemo, useState } from "react"; import { v7 } from "uuid"; import { useIsBlockSelected, useUIState } 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 { ChapterGrid, ChapterShelf, } from "app/(app)/(published)/lish/[did]/[publication]/PublicationPostsChapterList"; import { buildChapterCards } from "src/utils/chapterGrouping"; import { buildPostsListIndex, resolveReaderControls, sortPostsForList, filterPostsByTags, type PostsListView, } from "src/utils/postsListPagination"; import { getFirstParagraph } from "src/utils/getFirstParagraph"; import type { PublicationPostsListPost } from "src/utils/buildPublicationPosts"; import { ToggleWithLabel } from "components/Toggle"; import { BlockSettings } from "./SettingsTriggerButton"; import { PlaceholderText } from "./PostSizeIcons"; import { BlockSettingOptions } from "./BlockSettingOptions"; import { CloseTiny } from "components/Icons/CloseTiny"; import { EmptyState } from "components/EmptyState"; import { ShortcutKey } from "components/Layout"; import { HelpSmall } from "components/Icons/HelpSmall"; import { CustomizeTutorialTooltip } from "app/(app)/(identity)/lish/[did]/[publication]/edit/CustomizeTutorialTooltip"; export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => { let isSelected = useIsBlockSelected(props.entityID); let isFocused = useUIState( (s) => s.focusedEntity?.entityID === props.entityID, ); if (props.preview) { return ( ); } return ( } > ); }; function usePostsListReaderFlags(entityID: string) { let readerControls = useEntity(entityID, "posts-list/reader-controls"); let readerSearch = useEntity(entityID, "posts-list/reader-search"); let readerTagFilter = useEntity(entityID, "posts-list/reader-tag-filter"); let readerSort = useEntity(entityID, "posts-list/reader-sort"); return { readerControls: readerControls?.data.value, readerSearch: readerSearch?.data.value, readerTagFilter: readerTagFilter?.data.value, readerSort: readerSort?.data.value, }; } function PostsListBlockContent({ entityID }: { entityID: string }) { let { data } = usePublicationData(); let publicationRecord = useNormalizedPublicationRecord(); let { rootEntity } = useReplicache(); // The draft's theme facts are the width the editor is rendering at; the // publication record only catches up on publish. let pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; let viewFact = useEntity(entityID, "posts-list/view"); let view: PostsListView = viewFact?.data.value ?? "medium"; let readerFlags = usePostsListReaderFlags(entityID); let readerControls = useMemo( () => (view === "chapter" ? undefined : resolveReaderControls(readerFlags)), [ view, readerFlags.readerControls, readerFlags.readerSearch, readerFlags.readerTagFilter, readerFlags.readerSort, ], ); let highlightFirstFact = useEntity( entityID, "posts-list/highlight-first-post", ); let highlightFirst = highlightFirstFact?.data.value ?? false; let showPageCount = useEntity(entityID, "posts-list/show-page-count")?.data.value ?? true; 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; let listData = useMemo(() => { if (!data?.documents) return null; let ordered: PublicationPostsListPost[] = 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, })), ); return { posts: ordered, latestPost: ordered[0], chapterCards: view === "chapter" && data.publication ? buildChapterCards(ordered, data.publication) : undefined, readerIndex: readerControls ? buildPostsListIndex(ordered, (p) => getFirstParagraph(p.record)) : undefined, }; }, [data?.documents, data?.publication, filterTags, view, readerControls]); if (data === undefined) return ; if (!data?.publication) return ; if (!listData || listData.posts.length === 0) return (
You haven't published any posts yet! When you do, they'll show here. 0 ? limit : 3)} pageWidth={pageWidth ?? publicationRecord?.theme?.pageWidth} />
); // 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 ( p.uri)} index={listData.readerIndex} knownPosts={listData.posts} view={view} highlightFirstPost={highlightFirst} limit={limit} readerControls={readerControls} disableLinks pageWidth={pageWidth} /> ); } function PostsListPlaceholder() { return (
{[0, 1, 2].map((i) => (

))}
); } const placeholderBar = "bg-border-light rounded-[2px]"; const placeholderImage = "bg-border bg-cover bg-center rounded-md"; const placeholderImageStyle = { backgroundImage: "url(/imagePlaceholder.png)", backgroundBlendMode: "hard-light", }; function PostsListEmptyPlaceholder(props: { view: PostsListView; highlightFirst: boolean; count: number; pageWidth?: number; }) { if (props.view === "chapter") { return (
{props.highlightFirst && ( <>
Latest

)} {Array.from({ length: 3 }, (_, i) => ( ))}
); } let widePage = (props.pageWidth ?? 0) >= 768; return (
{Array.from({ length: props.count }, (_, i) => ( {props.highlightFirst && i === 0 ? ( ) : props.view === "small" ? ( ) : ( )}
))}
); } function PlaceholderPostSmall() { return (
); } function PlaceholderPostLarge(props: { widePage: boolean }) { return (
); } function PlaceholderPostChapter() { return (
); } function PlaceholderPostMedium() { return (
); } 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 showPageCount = useEntity(props.entityID, "posts-list/show-page-count")?.data.value ?? true; 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 readerFlags = usePostsListReaderFlags(props.entityID); let readerControlsEnabled = !!readerFlags.readerControls; let [filterByTagEnabled, setFilterByTagEnabled] = useState( () => selectedTags.length > 0, ); let [limitEnabled, setLimitEnabled] = useState(() => !!limit && limit > 0); let [chapterHelpOpen, setChapterHelpOpen] = useState(false); let setReaderFlag = ( attribute: | "posts-list/reader-controls" | "posts-list/reader-search" | "posts-list/reader-tag-filter" | "posts-list/reader-sort", value: boolean, ) => { if (!rep) return; return rep.mutate.assertFact({ entity: props.entityID, attribute, data: { type: "boolean", value }, }); }; 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

options={[ { value: "small", Icon: SmallIcon }, { value: "medium", Icon: MedIcon }, { value: "chapter", Icon: ChapterIcon }, ]} value={view} halfWidthOnMobile onSelect={(value) => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/view", data: { type: "posts-list-view-union", value }, }); }} > {/* A sibling of the layout options rather than a child: the chapter option is itself a button. On desktop it's the row's last column, so its bottom right corner is the row's; on mobile it wraps to the first column of the second row. */} {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 }, }); }} /> {view === "chapter" && ( { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/show-page-count", data: { type: "boolean", value: !showPageCount }, }); }} /> )} {view !== "chapter" && (
{ if (limitEnabled) { clearLimit(); setLimitEnabled(false); } else { setLimitEnabled(true); if (!limit || limit < 1) setLimit(5); } }} /> {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); } }} /> {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}
{view !== "chapter" && (
undoManager.withUndoGroup(async () => { if (readerControlsEnabled) { await setReaderFlag("posts-list/reader-controls", false); } else { // Turning the feature on should show all three rather // than an empty control row. await setReaderFlag("posts-list/reader-controls", true); await setReaderFlag("posts-list/reader-search", true); await setReaderFlag("posts-list/reader-tag-filter", true); await setReaderFlag("posts-list/reader-sort", true); } }) } /> {readerControlsEnabled && (
{( [ { label: "Search", attribute: "posts-list/reader-search", value: readerFlags.readerSearch ?? true, }, { label: "Filter by Tag", attribute: "posts-list/reader-tag-filter", value: readerFlags.readerTagFilter ?? true, }, { label: "Sort", attribute: "posts-list/reader-sort", value: readerFlags.readerSort ?? true, }, ] as const ).map((option) => ( setReaderFlag(option.attribute, !option.value) } /> ))}
)}
)}
); } 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%")}
))}
); };