a tool for shared writing and social publishing
at test/unknown-marks 484 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} 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};