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