a tool for shared writing and social publishing
at main 489 lines 15 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 { 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};