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