a tool for shared writing and social publishing
at update/delete-blocks 466 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"; 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};