a tool for shared writing and social publishing
1"use client";
2import { useState, createContext, useContext, useEffect } from "react";
3import { useSearchParams } from "next/navigation";
4import { Header } from "../PageHeader";
5import { Footer } from "components/ActionBar/Footer";
6import { Sidebar } from "components/ActionBar/Sidebar";
7import {
8 DesktopNavigation,
9 MobileNavigation,
10 navPages,
11 NotificationButton,
12} from "components/ActionBar/Navigation";
13import { create } from "zustand";
14import { Popover } from "components/Popover";
15import { Checkbox } from "components/Checkbox";
16import { Separator } from "components/Layout";
17import { CloseTiny } from "components/Icons/CloseTiny";
18import { MediaContents } from "components/Media";
19import { SortSmall } from "components/Icons/SortSmall";
20import { TabsSmall } from "components/Icons/TabsSmall";
21import { Input } from "components/Input";
22import { SearchTiny } from "components/Icons/SearchTiny";
23import { InterfaceState, useIdentityData } from "components/IdentityProvider";
24import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState";
25import Link from "next/link";
26import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
29export type DashboardState = {
30 display?: "grid" | "list";
31 sort?: "created" | "alphabetical";
32 filter: {
33 drafts: boolean;
34 published: boolean;
35 docs: boolean;
36 templates: boolean;
37 };
38};
39
40type DashboardStore = {
41 dashboards: { [id: string]: DashboardState };
42 setDashboard: (id: string, partial: Partial<DashboardState>) => void;
43};
44
45const defaultDashboardState: DashboardState = {
46 display: undefined,
47 sort: undefined,
48 filter: { drafts: false, published: false, docs: false, templates: false },
49};
50
51export const useDashboardStore = create<DashboardStore>((set, get) => ({
52 dashboards: {},
53 setDashboard: (id: string, partial: Partial<DashboardState>) => {
54 set((state) => ({
55 dashboards: {
56 ...state.dashboards,
57 [id]: {
58 ...(state.dashboards[id] || defaultDashboardState),
59 ...partial,
60 },
61 },
62 }));
63 },
64}));
65
66const DashboardIdContext = createContext<string | null>(null);
67
68export const useDashboardId = () => {
69 const id = useContext(DashboardIdContext);
70 if (!id) {
71 throw new Error("useDashboardId must be used within a DashboardLayout");
72 }
73 return id;
74};
75
76export const useDashboardState = () => {
77 const id = useDashboardId();
78 let { identity } = useIdentityData();
79 let localState = useDashboardStore(
80 (state) => state.dashboards[id] || defaultDashboardState,
81 );
82 if (!identity) return localState;
83 let metadata = identity.interface_state as InterfaceState;
84 return metadata?.dashboards?.[id] || defaultDashboardState;
85};
86
87export const useSetDashboardState = () => {
88 const id = useDashboardId();
89 let { identity, mutate } = useIdentityData();
90 const setDashboard = useDashboardStore((state) => state.setDashboard);
91 return async (partial: Partial<DashboardState>) => {
92 if (!identity) return setDashboard(id, partial);
93
94 let interface_state = (identity.interface_state as InterfaceState) || {};
95 let newDashboardState = {
96 ...defaultDashboardState,
97 ...interface_state.dashboards?.[id],
98 ...partial,
99 };
100 mutate(
101 {
102 ...identity,
103 interface_state: {
104 ...interface_state,
105 dashboards: {
106 ...interface_state.dashboards,
107 [id]: newDashboardState,
108 },
109 },
110 },
111 { revalidate: false },
112 );
113 await updateIdentityInterfaceState({
114 ...interface_state,
115 dashboards: {
116 [id]: newDashboardState,
117 },
118 });
119 };
120};
121
122export function DashboardLayout<
123 T extends {
124 [name: string]: {
125 content: React.ReactNode;
126 controls: React.ReactNode;
127 };
128 },
129>(props: {
130 id: string;
131 cardBorderHidden: boolean;
132 tabs: T;
133 defaultTab: keyof T;
134 currentPage: navPages;
135 publication?: string;
136 actions: React.ReactNode;
137}) {
138 const searchParams = useSearchParams();
139 const tabParam = searchParams.get("tab");
140
141 // Initialize tab from search param if valid, otherwise use default
142 const initialTab =
143 tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab;
144 let [tab, setTab] = useState<keyof T>(initialTab);
145
146 // Custom setter that updates both state and URL
147 const setTabWithUrl = (newTab: keyof T) => {
148 setTab(newTab);
149 const params = new URLSearchParams(searchParams.toString());
150 params.set("tab", newTab as string);
151 const newUrl = `${window.location.pathname}?${params.toString()}`;
152 window.history.replaceState(null, "", newUrl);
153 };
154
155 let { content, controls } = props.tabs[tab];
156 let { ref } = usePreserveScroll<HTMLDivElement>(
157 `dashboard-${props.id}-${tab as string}`,
158 );
159
160 let [headerState, setHeaderState] = useState<"default" | "controls">(
161 "default",
162 );
163 return (
164 <DashboardIdContext.Provider value={props.id}>
165 <div
166 className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`}
167 >
168 <MediaContents mobile={false}>
169 <div className="flex flex-col gap-3 my-6">
170 <DesktopNavigation
171 currentPage={props.currentPage}
172 publication={props.publication}
173 />
174 {props.actions && <Sidebar alwaysOpen>{props.actions}</Sidebar>}
175 </div>
176 </MediaContents>
177 <div
178 className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `}
179 ref={ref}
180 id="home-content"
181 >
182 {Object.keys(props.tabs).length <= 1 && !controls ? null : (
183 <>
184 <Header cardBorderHidden={props.cardBorderHidden}>
185 {headerState === "default" ? (
186 <>
187 {Object.keys(props.tabs).length > 1 && (
188 <div className="pubDashTabs flex flex-row gap-1">
189 {Object.keys(props.tabs).map((t) => {
190 return (
191 <Tab
192 key={t}
193 name={t}
194 selected={t === tab}
195 onSelect={() => setTabWithUrl(t)}
196 />
197 );
198 })}
199 </div>
200 )}
201 {props.publication && (
202 <button
203 className={`sm:hidden block text-tertiary`}
204 onClick={() => {
205 setHeaderState("controls");
206 }}
207 >
208 <SortSmall />
209 </button>
210 )}
211 <div
212 className={`sm:block ${props.publication && "hidden"} grow`}
213 >
214 {controls}
215 </div>
216 </>
217 ) : (
218 <>
219 {controls}
220 <button
221 className="text-tertiary"
222 onClick={() => {
223 setHeaderState("default");
224 }}
225 >
226 <TabsSmall />
227 </button>
228 </>
229 )}
230 </Header>
231 </>
232 )}
233 {content}
234 </div>
235 <Footer>
236 <MobileNavigation
237 currentPage={props.currentPage}
238 publication={props.publication}
239 />
240 {props.actions && (
241 <>
242 <Separator />
243 {props.actions}
244 </>
245 )}
246 </Footer>
247 </div>
248 </DashboardIdContext.Provider>
249 );
250}
251
252export const HomeDashboardControls = (props: {
253 searchValue: string;
254 setSearchValueAction: (searchValue: string) => void;
255 hasBackgroundImage: boolean;
256 defaultDisplay: Exclude<DashboardState["display"], undefined>;
257 hasPubs: boolean;
258 hasTemplates: boolean;
259}) => {
260 let { display, sort } = useDashboardState();
261 display = display || props.defaultDisplay;
262 let setState = useSetDashboardState();
263
264 let { identity } = useIdentityData();
265
266 return (
267 <div className="dashboardControls w-full flex gap-4">
268 {identity && (
269 <SearchInput
270 searchValue={props.searchValue}
271 setSearchValue={props.setSearchValueAction}
272 hasBackgroundImage={props.hasBackgroundImage}
273 />
274 )}
275 <div className="flex gap-2 w-max shrink-0 items-center text-sm text-tertiary">
276 <DisplayToggle setState={setState} display={display} />
277 <Separator classname="h-4 min-h-4!" />
278
279 {props.hasPubs || props.hasTemplates ? (
280 <>
281 {props.hasPubs}
282 {props.hasTemplates}
283 <FilterOptions
284 hasPubs={props.hasPubs}
285 hasTemplates={props.hasTemplates}
286 />
287 <Separator classname="h-4 min-h-4!" />{" "}
288 </>
289 ) : null}
290 <SortToggle setState={setState} sort={sort} />
291 </div>
292 </div>
293 );
294};
295
296export const PublicationDashboardControls = (props: {
297 searchValue: string;
298 setSearchValueAction: (searchValue: string) => void;
299 hasBackgroundImage: boolean;
300 defaultDisplay: Exclude<DashboardState["display"], undefined>;
301}) => {
302 let { display, sort } = useDashboardState();
303 display = display || props.defaultDisplay;
304 let setState = useSetDashboardState();
305 return (
306 <div className="dashboardControls w-full flex gap-4">
307 <SearchInput
308 searchValue={props.searchValue}
309 setSearchValue={props.setSearchValueAction}
310 hasBackgroundImage={props.hasBackgroundImage}
311 />
312 <div className="flex gap-2 w-max shrink-0 items-center text-sm text-tertiary">
313 <DisplayToggle setState={setState} display={display} />
314 <Separator classname="h-4 min-h-4!" />
315 <SortToggle setState={setState} sort={sort} />
316 </div>
317 </div>
318 );
319};
320
321const SortToggle = (props: {
322 setState: (partial: Partial<DashboardState>) => Promise<void>;
323 sort: string | undefined;
324}) => {
325 return (
326 <button
327 onClick={() =>
328 props.setState({
329 sort: props.sort === "created" ? "alphabetical" : "created",
330 })
331 }
332 >
333 Sort: {props.sort === "created" ? "Created On" : "A to Z"}
334 </button>
335 );
336};
337
338const DisplayToggle = (props: {
339 setState: (partial: Partial<DashboardState>) => Promise<void>;
340 display: string | undefined;
341}) => {
342 return (
343 <button
344 onClick={() => {
345 props.setState({
346 display: props.display === "list" ? "grid" : "list",
347 });
348 }}
349 >
350 {props.display === "list" ? "List" : "Grid"}
351 </button>
352 );
353};
354
355function Tab(props: {
356 name: string;
357 selected: boolean;
358 onSelect: () => void;
359 href?: string;
360}) {
361 return (
362 <div
363 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
364 onClick={() => props.onSelect()}
365 >
366 {props.name}
367 {props.href && <ExternalLinkTiny />}
368 </div>
369 );
370}
371
372const FilterOptions = (props: { hasPubs: boolean; hasTemplates: boolean }) => {
373 let { filter } = useDashboardState();
374 let setState = useSetDashboardState();
375 let filterCount = Object.values(filter).filter(Boolean).length;
376
377 return (
378 <Popover
379 className="text-sm px-2! py-1!"
380 trigger={<div>Filter {filterCount > 0 && `(${filterCount})`}</div>}
381 >
382 {props.hasPubs && (
383 <>
384 <Checkbox
385 small
386 checked={filter.drafts}
387 onChange={(e) =>
388 setState({
389 filter: { ...filter, drafts: !!e.target.checked },
390 })
391 }
392 >
393 Drafts
394 </Checkbox>
395 <Checkbox
396 small
397 checked={filter.published}
398 onChange={(e) =>
399 setState({
400 filter: { ...filter, published: !!e.target.checked },
401 })
402 }
403 >
404 Published
405 </Checkbox>
406 </>
407 )}
408
409 {props.hasTemplates && (
410 <>
411 <Checkbox
412 small
413 checked={filter.templates}
414 onChange={(e) =>
415 setState({
416 filter: { ...filter, templates: !!e.target.checked },
417 })
418 }
419 >
420 Templates
421 </Checkbox>
422 </>
423 )}
424 <Checkbox
425 small
426 checked={filter.docs}
427 onChange={(e) =>
428 setState({
429 filter: { ...filter, docs: !!e.target.checked },
430 })
431 }
432 >
433 Docs
434 </Checkbox>
435 <hr className="border-border-light mt-1 mb-0.5" />
436 <button
437 className="flex gap-1 items-center -mx-[2px] text-tertiary"
438 onClick={() => {
439 setState({
440 filter: {
441 docs: false,
442 published: false,
443 drafts: false,
444 templates: false,
445 },
446 });
447 }}
448 >
449 <CloseTiny className="scale-75" /> Clear
450 </button>
451 </Popover>
452 );
453};
454
455const SearchInput = (props: {
456 searchValue: string;
457 setSearchValue: (searchValue: string) => void;
458 hasBackgroundImage: boolean;
459}) => {
460 return (
461 <div className="relative grow shrink-0">
462 <Input
463 className={`dashboardSearchInput
464 appearance-none! outline-hidden!
465 w-full min-w-0 text-primary relative pl-7 pr-1 -my-px
466 border rounded-md border-transparent focus-within:border-border
467 bg-transparent ${props.hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `}
468 type="text"
469 id="pubName"
470 size={1}
471 placeholder="search..."
472 value={props.searchValue}
473 onChange={(e) => {
474 props.setSearchValue(e.currentTarget.value);
475 }}
476 />
477 <div className="absolute left-[6px] top-[4px] text-tertiary">
478 <SearchTiny />
479 </div>
480 </div>
481 );
482};