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