a tool for shared writing and social publishing
1"use client";
2
3import { getHomeDocs, HomeDoc } from "./storage";
4import useSWR from "swr";
5import {
6 Fact,
7 PermissionToken,
8 ReplicacheProvider,
9 useEntity,
10} from "src/replicache";
11import { LeafletListItem } from "./LeafletList/LeafletListItem";
12import { useIdentityData } from "components/IdentityProvider";
13import type { Attribute } from "src/replicache/attributes";
14import { callRPC } from "app/api/rpc/client";
15import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
16import { HomeSmall } from "components/Icons/HomeSmall";
17import {
18 HomeDashboardControls,
19 DashboardLayout,
20 DashboardState,
21 useDashboardState,
22} from "components/PageLayouts/DashboardLayout";
23import { Actions } from "./Actions/Actions";
24import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
25import { Json } from "supabase/database.types";
26import { useTemplateState } from "./Actions/CreateNewButton";
27import { CreateNewLeafletButton } from "./Actions/CreateNewButton";
28import { ActionButton } from "components/ActionBar/ActionButton";
29import { AddTiny } from "components/Icons/AddTiny";
30import {
31 get_leaflet_data,
32 GetLeafletDataReturnType,
33} from "app/api/rpc/[command]/get_leaflet_data";
34import { useEffect, useRef, useState } from "react";
35import { Input } from "components/Input";
36import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
37import {
38 ButtonPrimary,
39 ButtonSecondary,
40 ButtonTertiary,
41} from "components/Buttons";
42import { AddSmall } from "components/Icons/AddSmall";
43import { PublishIllustration } from "app/[leaflet_id]/publish/PublishIllustration/PublishIllustration";
44import { PubListEmptyIllo } from "components/ActionBar/Publications";
45import { theme } from "tailwind.config";
46import Link from "next/link";
47import { DiscoverIllo } from "./HomeEmpty/DiscoverIllo";
48import { WelcomeToLeafletIllo } from "./HomeEmpty/WelcomeToLeafletIllo";
49import {
50 DiscoverBanner,
51 HomeEmptyState,
52 PublicationBanner,
53} from "./HomeEmpty/HomeEmpty";
54
55type Leaflet = {
56 added_at: string;
57 token: PermissionToken & {
58 leaflets_in_publications?: Exclude<
59 GetLeafletDataReturnType["result"]["data"],
60 null
61 >["leaflets_in_publications"];
62 };
63};
64
65export const HomeLayout = (props: {
66 entityID: string;
67 titles: { [root_entity: string]: string };
68 initialFacts: {
69 [root_entity: string]: Fact<Attribute>[];
70 };
71}) => {
72 let hasBackgroundImage = !!useEntity(
73 props.entityID,
74 "theme/background-image",
75 );
76 let cardBorderHidden = !!useCardBorderHidden(props.entityID);
77
78 let [searchValue, setSearchValue] = useState("");
79 let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
80
81 useDebouncedEffect(
82 () => {
83 setDebouncedSearchValue(searchValue);
84 },
85 200,
86 [searchValue],
87 );
88
89 let { identity } = useIdentityData();
90
91 let hasPubs = !identity || identity.publications.length === 0 ? false : true;
92 let hasTemplates =
93 useTemplateState((s) => s.templates).length === 0 ? false : true;
94
95 return (
96 <DashboardLayout
97 id="home"
98 cardBorderHidden={cardBorderHidden}
99 currentPage="home"
100 defaultTab="home"
101 actions={<Actions />}
102 tabs={{
103 home: {
104 controls: (
105 <HomeDashboardControls
106 defaultDisplay={"grid"}
107 searchValue={searchValue}
108 setSearchValueAction={setSearchValue}
109 hasBackgroundImage={hasBackgroundImage}
110 hasPubs={hasPubs}
111 hasTemplates={hasTemplates}
112 />
113 ),
114 content: (
115 <HomeLeafletList
116 titles={props.titles}
117 initialFacts={props.initialFacts}
118 cardBorderHidden={cardBorderHidden}
119 searchValue={debouncedSearchValue}
120 />
121 ),
122 },
123 }}
124 />
125 );
126};
127
128export function HomeLeafletList(props: {
129 titles: { [root_entity: string]: string };
130 initialFacts: {
131 [root_entity: string]: Fact<Attribute>[];
132 };
133 searchValue: string;
134 cardBorderHidden: boolean;
135}) {
136 let { identity } = useIdentityData();
137 let { data: initialFacts } = useSWR(
138 "home-leaflet-data",
139 async () => {
140 if (identity) {
141 let { result } = await callRPC("getFactsFromHomeLeaflets", {
142 tokens: identity.permission_token_on_homepage.map(
143 (ptrh) => ptrh.permission_tokens.root_entity,
144 ),
145 });
146 let titles = {
147 ...result.titles,
148 ...identity.permission_token_on_homepage.reduce(
149 (acc, tok) => {
150 let title =
151 tok.permission_tokens.leaflets_in_publications[0]?.title;
152 if (title) acc[tok.permission_tokens.root_entity] = title;
153 return acc;
154 },
155 {} as { [k: string]: string },
156 ),
157 };
158 return { ...result, titles };
159 }
160 },
161 { fallbackData: { facts: props.initialFacts, titles: props.titles } },
162 );
163
164 let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), {
165 fallbackData: [],
166 });
167 let leaflets: Leaflet[] = identity
168 ? identity.permission_token_on_homepage.map((ptoh) => ({
169 added_at: ptoh.created_at,
170 token: ptoh.permission_tokens as PermissionToken,
171 }))
172 : localLeaflets
173 .sort((a, b) => (a.added_at > b.added_at ? -1 : 1))
174 .filter((d) => !d.hidden)
175 .map((ll) => ll);
176
177 return leaflets.length === 0 ? (
178 <HomeEmptyState />
179 ) : (
180 <>
181 <LeafletList
182 defaultDisplay="grid"
183 searchValue={props.searchValue}
184 leaflets={leaflets}
185 titles={initialFacts?.titles || {}}
186 cardBorderHidden={props.cardBorderHidden}
187 initialFacts={initialFacts?.facts || {}}
188 showPreview
189 />
190 <div className="spacer h-4 w-full bg-transparent shrink-0 " />
191
192 {leaflets.filter((l) => !!l.token.leaflets_in_publications).length ===
193 0 && <PublicationBanner small />}
194 <DiscoverBanner small />
195 </>
196 );
197}
198
199export function LeafletList(props: {
200 leaflets: Leaflet[];
201 titles: { [root_entity: string]: string };
202 defaultDisplay: Exclude<DashboardState["display"], undefined>;
203 initialFacts: {
204 [root_entity: string]: Fact<Attribute>[];
205 };
206 searchValue: string;
207 cardBorderHidden: boolean;
208 showPreview?: boolean;
209}) {
210 let { identity } = useIdentityData();
211 let { display } = useDashboardState();
212
213 display = display || props.defaultDisplay;
214
215 let searchedLeaflets = useSearchedLeaflets(
216 props.leaflets,
217 props.titles,
218 props.searchValue,
219 );
220
221 return (
222 <div
223 className={`
224 leafletList
225 w-full
226 ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `}
227 >
228 {props.leaflets.map(({ token: leaflet, added_at }, index) => (
229 <ReplicacheProvider
230 disablePull
231 initialFactsOnly={!!identity}
232 key={leaflet.id}
233 rootEntity={leaflet.root_entity}
234 token={leaflet}
235 name={leaflet.root_entity}
236 initialFacts={props.initialFacts?.[leaflet.root_entity] || []}
237 >
238 <StaticLeafletDataContext
239 value={{
240 ...leaflet,
241 leaflets_in_publications: leaflet.leaflets_in_publications || [],
242 blocked_by_admin: null,
243 custom_domain_routes: [],
244 }}
245 >
246 <LeafletListItem
247 title={props?.titles?.[leaflet.root_entity] || "Untitled"}
248 token={leaflet}
249 draft={!!leaflet.leaflets_in_publications?.length}
250 published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)}
251 publishedAt={
252 leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents
253 ?.indexed_at
254 }
255 leaflet_id={leaflet.root_entity}
256 loggedIn={!!identity}
257 display={display}
258 added_at={added_at}
259 cardBorderHidden={props.cardBorderHidden}
260 index={index}
261 showPreview={props.showPreview}
262 isHidden={
263 !searchedLeaflets.some(
264 (sl) => sl.token.root_entity === leaflet.root_entity,
265 )
266 }
267 />
268 </StaticLeafletDataContext>
269 </ReplicacheProvider>
270 ))}
271 </div>
272 );
273}
274
275function useSearchedLeaflets(
276 leaflets: Leaflet[],
277 titles: { [root_entity: string]: string },
278 searchValue: string,
279) {
280 let { sort, filter } = useDashboardState();
281
282 let sortedLeaflets = leaflets.sort((a, b) => {
283 if (sort === "alphabetical") {
284 if (titles[a.token.root_entity] === titles[b.token.root_entity]) {
285 return a.added_at > b.added_at ? -1 : 1;
286 } else {
287 return titles[a.token.root_entity].toLocaleLowerCase() >
288 titles[b.token.root_entity].toLocaleLowerCase()
289 ? 1
290 : -1;
291 }
292 } else {
293 return a.added_at === b.added_at
294 ? a.token.root_entity > b.token.root_entity
295 ? -1
296 : 1
297 : a.added_at > b.added_at
298 ? -1
299 : 1;
300 }
301 });
302
303 let allTemplates = useTemplateState((s) => s.templates);
304 let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => {
305 let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc);
306 let drafts = !!leaflet.leaflets_in_publications?.length && !published;
307 let docs = !leaflet.leaflets_in_publications?.length;
308 let templates = !!allTemplates.find((t) => t.id === leaflet.id);
309 // If no filters are active, show all
310 if (
311 !filter.drafts &&
312 !filter.published &&
313 !filter.docs &&
314 !filter.templates
315 )
316 return true;
317
318 return (
319 (filter.drafts && drafts) ||
320 (filter.published && published) ||
321 (filter.docs && docs) ||
322 (filter.templates && templates)
323 );
324 });
325 if (searchValue === "") return filteredLeaflets;
326 let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => {
327 return titles[leaflet.root_entity]
328 ?.toLowerCase()
329 .includes(searchValue.toLowerCase());
330 });
331
332 return searchedLeaflets;
333}