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