Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
16 kB · 440 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441import { useMemo, useState } from "react";import { useUIState } from "src/useUIState";import { useEntity, useReplicache } from "src/replicache";import { BlockProps, BlockLayout } from "./Block";import { usePublicationData, useNormalizedPublicationRecord,} from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider";import { PaginatedPublicationPostsList } from "app/(app)/lish/[did]/[publication]/PaginatedPublicationPostsList";import { POSTS_LIST_PAGE_SIZE, postsListFilterKey, sortPostsForList, filterPostsByTags, type LoadPostsBatch,} from "app/(app)/lish/[did]/[publication]/postsListPagination";import type { PublicationPostsListPost } from "app/(app)/lish/[did]/[publication]/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";
type PostsListView = "small" | "medium";
export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => { let isSelected = useUIState( (s) => !!s.selectedBlocks.find((b) => b.value === props.value), );
if (props.preview) { return ( <BlockLayout isSelected={isSelected} className="border-none! rounded-none!" > <PostsListPlaceholder /> </BlockLayout> ); }
return ( <BlockLayout isSelected={isSelected} className="border-none! p-0! rounded-none!" extraOptions={<PostsListSettingsButton entityID={props.entityID} />} > <PostsListBlockContent entityID={props.entityID} /> </BlockLayout> );};
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. let listData = useMemo(() => { if (!data?.documents) return null; let posts: PublicationPostsListPost[] = sortPostsForList( filterPostsByTags(data.documents, filterTags), ).map((d) => ({ uri: d.uri, record: d.record, commentsCount: d.commentsCount, mentionsCount: d.mentionsCount, recommendsCount: d.recommendsCount, })); let byUri = new Map(posts.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: posts.map((p) => p.uri), initialPosts: posts.slice(0, POSTS_LIST_PAGE_SIZE), loadBatch, }; }, [data?.documents, filterTags]);
if (data === undefined) return <PostsListPlaceholder />; if (!data?.publication) return <PostsListPlaceholder />;
if (!listData || listData.uris.length === 0) return ( <EmptyState container="none"> You haven't published any posts yet! When you do, they'll show here. </EmptyState> );
return ( <PaginatedPublicationPostsList publication={data.publication} publicationRecord={publicationRecord} listId={`${data.publication.uri}:${postsListFilterKey(filterTags)}`} uris={listData.uris} initialPosts={listData.initialPosts} loadBatch={listData.loadBatch} view={view} highlightFirstPost={highlightFirst} limit={limit} /> );}
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> );}
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 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 ( <Popover asChild side="top" align="end" sideOffset={6} className="w-md" trigger={<SettingsTriggerButton aria-label="Posts List Settings" />} > <div className="flex flex-col gap-3 text-primary py-1 min-w-[220px]"> <div className="flex flex-col gap-2"> <div> <h3>List Layout</h3> </div> <div className="flex sm:flex-row flex-col sm:gap-1 gap-2 w-full items-stretch"> {( [ { value: "small", Icon: SmallIcon }, { value: "medium", Icon: MedIcon }, ] as { value: PostsListView; Icon: (props: { selected: boolean }) => React.ReactNode; }[] ).map((option) => { let selected = view === option.value || (option.value === "medium" && view !== "small"); return ( <button className={`PostBlockSizeSettingOption text-left flex flex-col flex-1 pt-1 p-2 outline-2 outline-offset-1 border ${selected ? "accent-container outline-accent-contrast border-accent-contrast " : "opaque-container outline-transparent"}`} key={option.value} type="button" aria-pressed={selected} onMouseDown={(e) => e.preventDefault()} onClick={() => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/view", data: { type: "posts-list-view-union", value: option.value, }, }); }} > <div className="text-xs font-bold text-secondary uppercase pb-1"> {option.value} </div> <div className="flex items-center grow w-full "> <option.Icon selected={selected} /> </div> </button> ); })} </div> </div> <Toggle toggle={highlightFirst} onToggle={() => { if (!rep) return; rep.mutate.assertFact({ entity: props.entityID, attribute: "posts-list/highlight-first-post", data: { type: "boolean", value: !highlightFirst }, }); }} > <strong>Highlight First Post</strong> </Toggle> <div className="flex flex-col gap-1"> <Toggle toggle={limitEnabled} onToggle={() => { if (limitEnabled) { clearLimit(); setLimitEnabled(false); } else { setLimitEnabled(true); if (!limit || limit < 1) setLimit(5); } }} > <strong>Limit Posts</strong> </Toggle> {limitEnabled && ( <div className="flex items-center gap-2 ml-8 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 "> <div className="flex flex-row items-baseline justify-between gap-4"> <Toggle 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); } }} > <strong>Filter by Tag</strong> </Toggle> {filterByTagEnabled && selectedTags.length > 0 && ( <button type="button" className="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> )} </div> {filterByTagEnabled ? ( <div className="tagList light-container p-2 ml-8"> {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({ 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> </div> </div> </Popover> );}
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-2"> {PlaceholderText("lg")} <div className="flex justify-between mt-1 w-full"> {PlaceholderText("sm", "60%")} </div> </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-2 grow min-w-0"> {PlaceholderText("lg")} {PlaceholderText("md")} {PlaceholderText("md", "80%")} <div className="flex justify-between mt-2 w-full"> {PlaceholderText("sm", "60%")} </div> </div> <div className="aspect-square h-[68px] bg-border border-l border-border shrink-0 bg-cover bg-center" style={{ backgroundImage: "url(/imagePlaceholder.png)", backgroundBlendMode: "hard-light", }} /> </div> ))} </div> );};