Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
26 kB · 744 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745import { 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 ( <BlockLayout isSelected={isSelected} className="border-none! rounded-none!" > <PostsListPlaceholder /> </BlockLayout> ); }
return ( <BlockLayout isSelected={isSelected} // The block draws no border of its own to clip against, and chapter // covers hover an outline that sits outside their border box — clipping // here would shave it off the grid's outer cards. className="border-none! p-0! rounded-none! overflow-visible!" extraOptions={ <CustomizeTutorialTooltip target="posts-list" className="flex" blockFocused={isFocused} > <PostsListSettingsButton entityID={props.entityID} /> </CustomizeTutorialTooltip> } > <PostsListBlockContent entityID={props.entityID} /> </BlockLayout> );};
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 <PostsListPlaceholder />; if (!data?.publication) return <PostsListPlaceholder />;
if (!listData || listData.posts.length === 0) return ( <div className="flex flex-col gap-2"> <EmptyState container="light" className="px-2 py-1! mb-1"> You haven't published any posts yet! When you do, they'll show here. </EmptyState> <PostsListEmptyPlaceholder view={view} highlightFirst={highlightFirst} count={Math.min(3, limit && limit > 0 ? limit : 3)} pageWidth={pageWidth ?? publicationRecord?.theme?.pageWidth} /> </div> );
// 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 ( <ChapterShelf publication={data.publication} publicationRecord={publicationRecord} cards={listData.chapterCards ?? []} latestPost={listData.latestPost} highlightLatest={highlightFirst} showPageCount={showPageCount} disableLinks /> ); }
return ( <PaginatedPublicationPostsList publication={data.publication} publicationRecord={publicationRecord} uris={listData.readerIndex ? undefined : listData.posts.map((p) => p.uri)} index={listData.readerIndex} knownPosts={listData.posts} view={view} highlightFirstPost={highlightFirst} limit={limit} readerControls={readerControls} disableLinks pageWidth={pageWidth} /> );}
function PostsListPlaceholder() { return ( <div className="publicationPostList w-full flex flex-col gap-4"> {[0, 1, 2].map((i) => ( <div key={i} className="flex w-full grow flex-col"> <div className="flex flex-col gap-2"> <div className="h-5 w-2/3 bg-border-light rounded animate-pulse" /> <div className="h-4 w-full bg-border-light rounded animate-pulse" /> </div> <div className="pt-2"> <div className="h-3 w-24 bg-border-light rounded animate-pulse" /> </div> <hr className="last:hidden border-border-light mt-4" /> </div> ))} </div> );}
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 ( <div className="w-full" aria-hidden> {props.highlightFirst && ( <> <div className="text-sm uppercase font-bold text-tertiary pb-1"> Latest </div> <div className="block-border px-3"> <PlaceholderPostMedium /> </div> <hr className="border-border-light my-4" /> </> )} <ChapterGrid pageWidth={props.pageWidth}> {Array.from({ length: 3 }, (_, i) => ( <PlaceholderPostChapter key={i} /> ))} </ChapterGrid> </div> ); }
let widePage = (props.pageWidth ?? 0) >= 768; return ( <div className="w-full flex flex-col gap-2 py-2" aria-hidden> {Array.from({ length: props.count }, (_, i) => ( <Fragment key={i}> {props.highlightFirst && i === 0 ? ( <PlaceholderPostLarge widePage={widePage} /> ) : props.view === "small" ? ( <PlaceholderPostSmall /> ) : ( <PlaceholderPostMedium /> )} <hr className="last:hidden border-border-light" /> </Fragment> ))} </div> );}
function PlaceholderPostSmall() { return ( <div className="flex flex-col gap-2 py-2"> <div className={`h-5 w-2/3 ${placeholderBar}`} /> <div className={`h-3 w-24 ${placeholderBar}`} /> </div> );}
function PlaceholderPostLarge(props: { widePage: boolean }) { return ( <div className={`flex flex-col w-full ${props.widePage ? "sm:flex-row sm:gap-4" : ""}`} > <div style={placeholderImageStyle} className={`shrink-0 ${placeholderImage} ${props.widePage ? "sm:h-[254px] aspect-[3/2]" : "w-full aspect-[1.91/1]"}`} /> <div className="flex flex-col gap-2 grow py-2"> <div className={`h-6 w-3/4 ${placeholderBar}`} /> <div className={`h-4 w-full ${placeholderBar}`} /> <div className={`h-4 w-5/6 ${placeholderBar}`} /> <div className={`h-3 w-24 mt-2 ${placeholderBar}`} /> </div> </div> );}
function PlaceholderPostChapter() { return ( <div className="flex flex-col gap-2 min-w-0"> <div className={`w-full aspect-2/3 ${placeholderImage}`} style={placeholderImageStyle} /> <div className={`h-4 w-3/4 ${placeholderBar}`} /> <div className={`h-3 w-12 ${placeholderBar}`} /> </div> );}
function PlaceholderPostMedium() { return ( <div className="flex w-full items-start"> <div className="flex flex-col gap-2 grow min-w-0 py-2 pr-3"> <div className={`h-5 w-2/3 ${placeholderBar}`} /> <div className={`h-4 w-full ${placeholderBar}`} /> <div className={`h-4 w-4/5 ${placeholderBar}`} /> <div className={`h-3 w-24 mt-2 ${placeholderBar}`} /> </div> <div className={`shrink-0 w-24 sm:w-36 aspect-square my-2 ${placeholderImage}`} style={placeholderImageStyle} /> </div> );}
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<string>(); 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 ( <BlockSettings label="Post List" className="w-md"> <div className="flex flex-col gap-2"> <div> <h3>List Layout</h3> </div> <BlockSettingOptions<PostsListView> 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" && ( <button type="button" className="absolute -bottom-3 right-[calc(50%+0.5rem)] sm:right-1 bg-accent-1 text-accent-2 rounded-full " aria-expanded={chapterHelpOpen} onMouseDown={(e) => e.preventDefault()} onClick={() => setChapterHelpOpen(!chapterHelpOpen)} > <HelpSmall className="scale-75" /> </button> )} </BlockSettingOptions> </div> {view === "chapter" && chapterHelpOpen && ( <div className="light-container p-2 text-tertiary text-sm leading-snug flex flex-col gap-1.5"> <p>Group posts into chapters based on titles</p>{" "} <hr className="border-dashed" /> <p> Posts titled <br /> "Ch1 - Pg1" <br /> "Ch1 - Pg2" <br /> "Ch1 - Pg3" <br /> will group into a chapter named "Ch1". </p> <p className="font-bold"> Separate chapter names and page titles with <br /> <ShortcutKey>/</ShortcutKey> <ShortcutKey>,</ShortcutKey>{" "} <ShortcutKey>:</ShortcutKey> or <ShortcutKey>-</ShortcutKey>. <br /> Do not use these characters in chapter names! </p> </div> )} <ToggleWithLabel label="Highlight Latest Post" toggle={highlightFirst} onToggle={() => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/highlight-first-post", data: { type: "boolean", value: !highlightFirst }, }); }} /> {view === "chapter" && ( <ToggleWithLabel label="Show Page Count" toggle={showPageCount} onToggle={() => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/show-page-count", data: { type: "boolean", value: !showPageCount }, }); }} /> )}
{view !== "chapter" && ( <div className="flex flex-col gap-1"> <ToggleWithLabel label="Limit Posts" toggle={limitEnabled} onToggle={() => { if (limitEnabled) { clearLimit(); setLimitEnabled(false); } else { setLimitEnabled(true); if (!limit || limit < 1) setLimit(5); } }} /> {limitEnabled && ( <div className="flex items-center gap-2 text-secondary text-sm"> <span>Show only</span> <input type="number" min={1} value={limit ?? 5} onMouseDown={(e) => 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" /> <span>posts</span> </div> )} </div> )} <hr className="border-border-light my-1" />
<div className="flex flex-col gap-2"> <h3>Settings</h3> <div className="tagFilter flex flex-col gap-1 "> <ToggleWithLabel label="Filter by Tag" toggle={filterByTagEnabled} onToggle={() => { 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 && ( <button type="button" className="self-end text-tertiary hover:text-accent-contrast text-sm" onMouseDown={(e) => e.preventDefault()} onClick={() => undoManager.withUndoGroup(async () => { if (!rep) return; for (let fact of filterTagFacts) await rep.mutate.retractFact({ factID: fact.id }); }) } > clear </button> )} {filterByTagEnabled ? ( <div className="tagList light-container p-2"> {allTags.length === 0 ? ( <div className="text-tertiary italic text-sm">no tags yet</div> ) : ( <div className="flex flex-wrap gap-2"> {allTags.map((tag) => { let selectedFact = filterTagFacts.find( (f) => f.data.value === tag, ); let isSelected = !!selectedFact; return ( <button key={tag} type="button" aria-pressed={isSelected} onMouseDown={(e) => e.preventDefault()} onClick={() => { if (!rep) return; if (selectedFact) { rep.mutate.retractFact({ factID: selectedFact.id, }); } else { rep.mutate.assertFact({ id: v7(), entity: props.entityID, attribute: "posts-list/filter-tag", data: { type: "string", value: tag }, }); } }} className={`tag flex items-center text-xs rounded-md border px-1 py-0.5 ${ isSelected ? "bg-accent-1 border-accent-1 font-bold text-accent-2" : "bg-bg-page border-border text-tertiary" }`} > {tag} {isSelected ? ( <CloseTiny className="scale-75 text-accent-2" /> ) : null} </button> ); })} </div> )} </div> ) : null} </div>
{view !== "chapter" && ( <div className="readerControls flex flex-col gap-1"> <ToggleWithLabel label="Enable Reader Controls" toggle={readerControlsEnabled} onToggle={() => 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 && ( <div className="flex flex-col gap-1 py-1 px-2 opaque-container text-secondary"> {( [ { 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) => ( <ToggleWithLabel key={option.attribute} label={option.label} toggle={option.value} onToggle={() => setReaderFlag(option.attribute, !option.value) } /> ))} </div> )} </div> )} </div> </BlockSettings> );}
const SmallIcon = ({ selected }: { selected: boolean }) => { return ( <div className={`flex flex-col w-full overflow-hidden opaque-container border-tertiary! divide-y divide-border-light ${selected && "border-accent-contrast!"}`} > {[0, 1, 2].map((i) => ( <div key={i} className="flex flex-col gap-1 p-1.5 sm:p-2"> {PlaceholderText("lg")} <div className="flex justify-between mt-1 w-full"> {PlaceholderText("sm", "60%")} </div> </div> ))} </div> );};
const ChapterIcon = ({ selected }: { selected: boolean }) => { return ( <div className={`grid grid-cols-2 gap-1.5 w-full overflow-hidden opaque-container border-tertiary! py-2 px-4 sm:px-2 ${selected && "border-accent-contrast!"}`} > {[0, 1].map((i) => ( <div key={i} className="flex flex-col gap-1"> <div className="w-full aspect-2/3 bg-border border border-border bg-cover bg-center rounded-[2px]" style={{ backgroundImage: "url(/imagePlaceholder.png)", backgroundBlendMode: "hard-light", }} /> {PlaceholderText("sm", "80%")} </div> ))} </div> );};
const MedIcon = ({ selected }: { selected: boolean }) => { return ( <div className={`flex flex-col w-full overflow-hidden opaque-container border-tertiary! divide-y divide-border-light ${selected && "border-accent-contrast!"}`} > {[0, 1].map((i) => ( <div key={i} className="flex w-full"> <div className="flex flex-col gap-1 p-1.5 sm:p-2 grow min-w-0"> {PlaceholderText("lg")} {PlaceholderText("md")} {PlaceholderText("md", "80%")} <div className="flex justify-between mt-1 sm:mt-2 w-full"> {PlaceholderText("sm", "60%")} </div> </div> <div className="aspect-square h-[44px] sm:h-[68px] bg-border border-l border-border shrink-0 bg-cover bg-center" style={{ backgroundImage: "url(/imagePlaceholder.png)", backgroundBlendMode: "hard-light", }} /> </div> ))} </div> );};