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