a tool for shared writing and social publishing
at update/reader 486 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 { 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};