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";
28import { Tab } from "components/Tab";
29
30export type DashboardState = {
31 display?: "grid" | "list";
32 sort?: "created" | "alphabetical";
33 filter: {
34 drafts: boolean;
35 published: boolean;
36 docs: boolean;
37 archived: boolean;
38 };
39};
40
41type DashboardStore = {
42 dashboards: { [id: string]: DashboardState };
43 setDashboard: (id: string, partial: Partial<DashboardState>) => void;
44};
45
46const defaultDashboardState: DashboardState = {
47 display: undefined,
48 sort: undefined,
49 filter: {
50 drafts: false,
51 published: false,
52 docs: false,
53 archived: false,
54 },
55};
56
57export const useDashboardStore = create<DashboardStore>((set, get) => ({
58 dashboards: {},
59 setDashboard: (id: string, partial: Partial<DashboardState>) => {
60 set((state) => ({
61 dashboards: {
62 ...state.dashboards,
63 [id]: {
64 ...(state.dashboards[id] || defaultDashboardState),
65 ...partial,
66 },
67 },
68 }));
69 },
70}));
71
72const DashboardIdContext = createContext<string | null>(null);
73
74export const useDashboardId = () => {
75 const id = useContext(DashboardIdContext);
76 if (!id) {
77 throw new Error("useDashboardId must be used within a DashboardLayout");
78 }
79 return id;
80};
81
82export const useDashboardState = () => {
83 const id = useDashboardId();
84 let { identity } = useIdentityData();
85 let localState = useDashboardStore(
86 (state) => state.dashboards[id] || defaultDashboardState,
87 );
88 if (!identity) return localState;
89 let metadata = identity.interface_state as InterfaceState;
90 return metadata?.dashboards?.[id] || defaultDashboardState;
91};
92
93export const useSetDashboardState = () => {
94 const id = useDashboardId();
95 let { identity, mutate } = useIdentityData();
96 const setDashboard = useDashboardStore((state) => state.setDashboard);
97 return async (partial: Partial<DashboardState>) => {
98 if (!identity) return setDashboard(id, partial);
99
100 let interface_state = (identity.interface_state as InterfaceState) || {};
101 let newDashboardState = {
102 ...defaultDashboardState,
103 ...interface_state.dashboards?.[id],
104 ...partial,
105 };
106 mutate(
107 {
108 ...identity,
109 interface_state: {
110 ...interface_state,
111 dashboards: {
112 ...interface_state.dashboards,
113 [id]: newDashboardState,
114 },
115 },
116 },
117 { revalidate: false },
118 );
119 await updateIdentityInterfaceState({
120 ...interface_state,
121 dashboards: {
122 [id]: newDashboardState,
123 },
124 });
125 };
126};
127
128export function DashboardLayout<
129 T extends {
130 [name: string]: {
131 content: React.ReactNode;
132 controls: React.ReactNode;
133 };
134 },
135>(props: {
136 id: string;
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-3 px-3 sm:pt-8 sm:pb-3 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>
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
358const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
359 let { filter } = useDashboardState();
360 let setState = useSetDashboardState();
361 let filterCount = Object.values(filter).filter(Boolean).length;
362
363 return (
364 <Popover
365 className="text-sm px-2! py-1!"
366 trigger={<div>Filter {filterCount > 0 && `(${filterCount})`}</div>}
367 >
368 {props.hasPubs && (
369 <>
370 <Checkbox
371 small
372 checked={filter.drafts}
373 onChange={(e) =>
374 setState({
375 filter: { ...filter, drafts: !!e.target.checked },
376 })
377 }
378 >
379 Drafts
380 </Checkbox>
381 <Checkbox
382 small
383 checked={filter.published}
384 onChange={(e) =>
385 setState({
386 filter: { ...filter, published: !!e.target.checked },
387 })
388 }
389 >
390 Published
391 </Checkbox>
392 </>
393 )}
394
395 {props.hasArchived && (
396 <Checkbox
397 small
398 checked={filter.archived}
399 onChange={(e) =>
400 setState({
401 filter: { ...filter, archived: !!e.target.checked },
402 })
403 }
404 >
405 Archived
406 </Checkbox>
407 )}
408 <Checkbox
409 small
410 checked={filter.docs}
411 onChange={(e) =>
412 setState({
413 filter: { ...filter, docs: !!e.target.checked },
414 })
415 }
416 >
417 Docs
418 </Checkbox>
419 <hr className="border-border-light mt-1 mb-0.5" />
420 <button
421 className="flex gap-1 items-center -mx-[2px] text-tertiary"
422 onClick={() => {
423 setState({
424 filter: {
425 docs: false,
426 published: false,
427 drafts: false,
428 archived: false,
429 },
430 });
431 }}
432 >
433 <CloseTiny className="scale-75" /> Clear
434 </button>
435 </Popover>
436 );
437};
438
439const SearchInput = (props: {
440 searchValue: string;
441 setSearchValue: (searchValue: string) => void;
442 hasBackgroundImage: boolean;
443}) => {
444 return (
445 <div className="relative grow shrink-0">
446 <Input
447 className={`dashboardSearchInput
448 appearance-none! outline-hidden!
449 w-full min-w-0 text-primary relative pl-7 pr-1 -my-px
450 border rounded-md border-transparent focus-within:border-border
451 bg-transparent ${props.hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `}
452 type="text"
453 id="pubName"
454 size={1}
455 placeholder="search..."
456 value={props.searchValue}
457 onChange={(e) => {
458 props.setSearchValue(e.currentTarget.value);
459 }}
460 />
461 <div className="absolute left-[6px] top-[4px] text-tertiary">
462 <SearchTiny />
463 </div>
464 </div>
465 );
466};