a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

update pub pages layout and add options

Jared Pereira (May 28, 2026, 4:35 PM EDT) 39bafdf2 e77a7233

+244 -160
+30
actions/updatePublicationPage.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export async function updatePublicationPage(args: { 6 + publication_uri: string; 7 + page_id: number; 8 + title: string; 9 + path: string; 10 + }): Promise<{ success: boolean }> { 11 + let identity = await getIdentityData(); 12 + if (!identity || !identity.atp_did) return { success: false }; 13 + 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("uri, identity_did") 17 + .eq("uri", args.publication_uri) 18 + .single(); 19 + if (!publication || publication.identity_did !== identity.atp_did) 20 + return { success: false }; 21 + 22 + let { error } = await supabaseServerClient 23 + .from("publication_pages") 24 + .update({ title: args.title, path: args.path }) 25 + .eq("id", args.page_id) 26 + .eq("publication", args.publication_uri); 27 + if (error) return { success: false }; 28 + 29 + return { success: true }; 30 + }
+25 -54
app/(app)/lish/[did]/[publication]/PublicationContent.tsx
··· 1 1 import React from "react"; 2 2 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 3 3 import { PublicationAuthor } from "./PublicationAuthor"; 4 - import { PublicationHeader } from "./PublicationHeader"; 5 - import { PublicationNav } from "./PublicationNav"; 6 - import { PublicationStickyHeader } from "./PublicationStickyHeader"; 7 - import { PublicationFullHeader } from "./PublicationFullHeader"; 8 4 import { 9 5 normalizePublicationRecord, 10 6 normalizeDocumentRecord, ··· 91 87 <PublicationHomeLayout 92 88 uri={publication.uri} 93 89 showPageBackground={!!showPageBackground} 94 - stickyHeader={ 95 - navPages.length > 0 ? ( 96 - <PublicationStickyHeader 97 - nav={ 98 - <PublicationNav 99 - publicationUrl={getPublicationURL(publication)} 100 - pages={navPages} 101 - activePath="/" 102 - /> 103 - } 104 - > 105 - <PublicationHeader 106 - variant="inline" 107 - iconUrl={ 108 - record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined 109 - } 110 - publicationName={publication.name} 111 - /> 112 - </PublicationStickyHeader> 113 - ) : ( 114 - <PublicationFullHeader> 115 - <PublicationHeader 116 - iconUrl={ 117 - record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined 118 - } 119 - publicationName={publication.name} 120 - description={record?.description} 121 - author={ 122 - profile ? ( 123 - <PublicationAuthor 124 - did={profile.did} 125 - displayName={profile.displayName} 126 - handle={profile.handle} 127 - /> 128 - ) : undefined 129 - } 130 - subscribeButton={ 131 - <div className="max-w-sm mx-auto"> 132 - <SubscribeInput 133 - publicationUri={publication.uri} 134 - publicationUrl={record?.url} 135 - publicationName={record?.name ?? publication.name} 136 - publicationDescription={record?.description} 137 - newsletterMode={newsletterMode} 138 - /> 139 - </div> 140 - } 141 - /> 142 - </PublicationFullHeader> 143 - ) 90 + iconUrl={record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined} 91 + publicationName={publication.name} 92 + description={record?.description} 93 + navPages={navPages} 94 + publicationUrl={getPublicationURL(publication)} 95 + activePath="/" 96 + author={ 97 + profile ? ( 98 + <PublicationAuthor 99 + did={profile.did} 100 + displayName={profile.displayName} 101 + handle={profile.handle} 102 + /> 103 + ) : undefined 104 + } 105 + subscribeButton={ 106 + <div className="max-w-sm mx-auto"> 107 + <SubscribeInput 108 + publicationUri={publication.uri} 109 + publicationUrl={record?.url} 110 + publicationName={record?.name ?? publication.name} 111 + publicationDescription={record?.description} 112 + newsletterMode={newsletterMode} 113 + /> 114 + </div> 144 115 } 145 116 > 146 117 <PublicationPostsList
-11
app/(app)/lish/[did]/[publication]/PublicationFullHeader.tsx
··· 1 - import React from "react"; 2 - 3 - export function PublicationFullHeader(props: { children: React.ReactNode }) { 4 - return ( 5 - <div className="pubFullHeader shrink-0"> 6 - <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 7 - {props.children} 8 - </div> 9 - </div> 10 - ); 11 - }
+45 -2
app/(app)/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 1 1 "use client"; 2 2 3 + import React from "react"; 3 4 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 5 + import { PublicationHeader } from "./PublicationHeader"; 6 + import { PublicationNav, type PublicationNavPage } from "./PublicationNav"; 7 + import { PublicationStickyHeader } from "./PublicationStickyHeader"; 4 8 5 9 export function PublicationHomeLayout(props: { 6 10 uri: string; 7 11 showPageBackground: boolean; 8 - stickyHeader?: React.ReactNode; 12 + iconUrl?: string; 13 + publicationName: string; 14 + description?: string; 15 + author?: React.ReactNode; 16 + subscribeButton?: React.ReactNode; 17 + navPages: PublicationNavPage[]; 18 + publicationUrl: string; 19 + activePath: string; 9 20 children: React.ReactNode; 10 21 }) { 11 22 let { ref } = usePreserveScroll<HTMLDivElement>(props.uri); 23 + let hasNav = props.navPages.length > 0; 24 + 25 + let header = hasNav ? ( 26 + <PublicationStickyHeader 27 + nav={ 28 + <PublicationNav 29 + publicationUrl={props.publicationUrl} 30 + pages={props.navPages} 31 + activePath={props.activePath} 32 + /> 33 + } 34 + > 35 + <PublicationHeader 36 + variant="inline" 37 + iconUrl={props.iconUrl} 38 + publicationName={props.publicationName} 39 + /> 40 + </PublicationStickyHeader> 41 + ) : ( 42 + <div className="pubFullHeader shrink-0"> 43 + <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 44 + <PublicationHeader 45 + iconUrl={props.iconUrl} 46 + publicationName={props.publicationName} 47 + description={props.description} 48 + author={props.author} 49 + subscribeButton={props.subscribeButton} 50 + /> 51 + </div> 52 + </div> 53 + ); 54 + 12 55 let inner = ( 13 56 <> 14 - {props.stickyHeader} 57 + {header} 15 58 <div className="pubContent sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pb-5"> 16 59 {props.children} 17 60 </div>
+1 -7
app/(app)/lish/[did]/[publication]/PublicationStickyHeader.tsx
··· 3 3 import { usePathname } from "next/navigation"; 4 4 5 5 export function PublicationStickyHeader(props: { 6 - sticky?: boolean; 7 6 nav: React.ReactNode; 8 7 children: React.ReactNode; 9 8 }) { 10 9 let ref = useRef<HTMLDivElement>(null); 11 10 let pathname = usePathname(); 12 - let sticky = props.sticky ?? true; 13 11 14 12 useEffect(() => { 15 13 let el = ref.current; ··· 62 60 return ( 63 61 <div 64 62 ref={ref} 65 - className={ 66 - sticky 67 - ? "pubStickyHeader sticky top-0 z-10 bg-bg-page shrink-0" 68 - : "pubStickyHeader shrink-0" 69 - } 63 + className="pubStickyHeader sticky top-0 z-10 bg-bg-page shrink-0" 70 64 > 71 65 <div 72 66 className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4"
+9 -38
app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
··· 23 23 import { 24 24 type PublicationPostsListPost, 25 25 } from "../PublicationPostsList"; 26 - import { PublicationNav } from "../PublicationNav"; 27 - import { PublicationHeader } from "../PublicationHeader"; 28 26 import { PublicationHomeLayout } from "../PublicationHomeLayout"; 29 - import { PublicationStickyHeader } from "../PublicationStickyHeader"; 30 - import { PublicationFullHeader } from "../PublicationFullHeader"; 31 27 import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 32 28 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 33 29 ··· 180 176 <PublicationHomeLayout 181 177 uri={publication.uri} 182 178 showPageBackground={showPageBackground} 183 - stickyHeader={ 184 - navPages.length > 0 ? ( 185 - <PublicationStickyHeader 186 - nav={ 187 - <PublicationNav 188 - publicationUrl={getPublicationURL(publication)} 189 - pages={navPages} 190 - activePath={page.path} 191 - /> 192 - } 193 - > 194 - <PublicationHeader 195 - variant="inline" 196 - iconUrl={ 197 - normalizedPublication?.icon 198 - ? blobRefToSrc(normalizedPublication.icon.ref, did) 199 - : undefined 200 - } 201 - publicationName={publication.name} 202 - /> 203 - </PublicationStickyHeader> 204 - ) : ( 205 - <PublicationFullHeader> 206 - <PublicationHeader 207 - iconUrl={ 208 - normalizedPublication?.icon 209 - ? blobRefToSrc(normalizedPublication.icon.ref, did) 210 - : undefined 211 - } 212 - publicationName={publication.name} 213 - description={normalizedPublication?.description} 214 - /> 215 - </PublicationFullHeader> 216 - ) 179 + iconUrl={ 180 + normalizedPublication?.icon 181 + ? blobRefToSrc(normalizedPublication.icon.ref, did) 182 + : undefined 217 183 } 184 + publicationName={publication.name} 185 + description={normalizedPublication?.description} 186 + navPages={navPages} 187 + publicationUrl={getPublicationURL(publication)} 188 + activePath={page.path} 218 189 > 219 190 <div className="pubPageContent pt-6"> 220 191 <PostContent
+2 -5
app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext, useEffect, useMemo } from "react"; 6 - import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 5 + import { createContext, useContext, useMemo } from "react"; 6 + import useSWR, { SWRConfig, KeyedMutator } from "swr"; 7 7 import { produce, Draft as ImmerDraft } from "immer"; 8 8 import { 9 9 normalizePublicationRecord, ··· 24 24 children: React.ReactNode; 25 25 }) { 26 26 let key = `publication-data-${props.publication_did}-${props.publication_rkey}`; 27 - useEffect(() => { 28 - mutate(key, props.publication_data); 29 - }, [props.publication_data]); 30 27 return ( 31 28 <PublicationContext 32 29 value={{ name: props.publication_rkey, did: props.publication_did }}
+120 -28
app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx
··· 25 25 import { ButtonPrimary, ButtonSecondary, ButtonTertiary } from "components/Buttons"; 26 26 import { InputWithLabel } from "components/Input"; 27 27 import { Popover } from "components/Popover"; 28 - import { Modal } from "components/Modal"; 29 28 import { AddTiny } from "components/Icons/AddTiny"; 30 - import { CloseTiny } from "components/Icons/CloseTiny"; 29 + import { EditTiny } from "components/Icons/EditTiny"; 31 30 import { createPublicationPage } from "actions/createPublicationPage"; 32 31 import { deletePublicationPage } from "actions/deletePublicationPage"; 32 + import { updatePublicationPage } from "actions/updatePublicationPage"; 33 33 import { reorderPublicationPages } from "actions/reorderPublicationPages"; 34 34 import { 35 35 usePublicationData, ··· 161 161 active={isActive} 162 162 publicationUri={publicationUri} 163 163 canDelete={sortedPages.length > 1} 164 + onUpdated={(updated) => { 165 + mutatePublicationData(mutate, (draft) => { 166 + let p = draft.publication?.publication_pages.find( 167 + (p) => p.id === page.id, 168 + ); 169 + if (p) { 170 + p.title = updated.title; 171 + p.path = updated.path; 172 + } 173 + }); 174 + if (isActive && updated.path !== page.path) 175 + router.push(hrefForPath(updated.path)); 176 + }} 164 177 onDeleted={() => { 165 178 mutatePublicationData(mutate, (draft) => { 166 179 let pages = draft.publication?.publication_pages; ··· 231 244 active: boolean; 232 245 publicationUri: string | undefined; 233 246 canDelete: boolean; 247 + onUpdated: (updated: { title: string; path: string }) => void; 234 248 onDeleted: () => void; 235 249 }) { 236 250 let { ··· 242 256 transition, 243 257 isDragging, 244 258 } = useSortable({ id: props.page.id }); 245 - let [confirmOpen, setConfirmOpen] = useState(false); 259 + let [popoverOpen, setPopoverOpen] = useState(false); 260 + let [mode, setMode] = useState<"edit" | "confirm">("edit"); 261 + let [name, setName] = useState(props.page.title); 262 + let [path, setPath] = useState(props.page.path ?? "/"); 263 + let [saving, setSaving] = useState(false); 246 264 let [deleting, setDeleting] = useState(false); 247 265 248 266 let style = { ··· 251 269 zIndex: isDragging ? 1 : undefined, 252 270 }; 253 271 272 + function handleOpenChange(o: boolean) { 273 + setPopoverOpen(o); 274 + if (o) { 275 + setName(props.page.title); 276 + setPath(props.page.path ?? "/"); 277 + setMode("edit"); 278 + } 279 + } 280 + 281 + async function handleSave(e: React.FormEvent) { 282 + e.preventDefault(); 283 + if (!props.publicationUri || saving) return; 284 + let trimmedPath = path.trim(); 285 + if (!trimmedPath) return; 286 + if (!trimmedPath.startsWith("/")) trimmedPath = "/" + trimmedPath; 287 + let trimmedTitle = name.trim(); 288 + setSaving(true); 289 + let result = await updatePublicationPage({ 290 + publication_uri: props.publicationUri, 291 + page_id: props.page.id, 292 + title: trimmedTitle, 293 + path: trimmedPath, 294 + }); 295 + setSaving(false); 296 + if (!result.success) return; 297 + setPopoverOpen(false); 298 + props.onUpdated({ title: trimmedTitle, path: trimmedPath }); 299 + } 300 + 254 301 async function handleDelete() { 255 302 if (!props.publicationUri || deleting) return; 256 303 setDeleting(true); ··· 260 307 }); 261 308 setDeleting(false); 262 309 if (!result.success) return; 263 - setConfirmOpen(false); 310 + setPopoverOpen(false); 264 311 props.onDeleted(); 265 312 } 266 313 ··· 299 346 > 300 347 {props.page.title || props.page.path || "/"} 301 348 </SpeedyLink> 302 - {props.canDelete && ( 303 - <Modal 304 - open={confirmOpen} 305 - onOpenChange={setConfirmOpen} 349 + <Popover 306 350 asChild 351 + align="end" 352 + open={popoverOpen} 353 + onOpenChange={handleOpenChange} 354 + className="w-64" 307 355 trigger={ 308 356 <button 309 357 type="button" 310 - aria-label="Delete page" 358 + aria-label="Edit page" 311 359 className="shrink-0 mx-1 p-0.5 rounded text-inherit opacity-0 group-hover/sortable-tab:opacity-100 focus:opacity-100 hover:bg-border-light" 312 360 > 313 - <CloseTiny className="w-3 h-3" /> 361 + <EditTiny className="w-3 h-3" /> 314 362 </button> 315 363 } 316 - title="Delete page?" 317 364 > 318 - <div className="text-secondary text-sm pb-3"> 319 - This will permanently delete{" "} 320 - <span className="font-bold text-primary"> 321 - {props.page.title || props.page.path || "/"} 322 - </span> 323 - . 324 - </div> 325 - <div className="flex gap-2 justify-end items-center"> 326 - <ButtonTertiary onClick={() => setConfirmOpen(false)}> 327 - Nevermind 328 - </ButtonTertiary> 329 - <ButtonPrimary onClick={handleDelete} disabled={deleting}> 330 - {deleting ? "Deleting..." : "Delete"} 331 - </ButtonPrimary> 332 - </div> 333 - </Modal> 334 - )} 365 + {mode === "edit" ? ( 366 + <form 367 + onSubmit={handleSave} 368 + onKeyDown={(e) => { 369 + if (e.key === "Tab") e.stopPropagation(); 370 + }} 371 + className="flex flex-col gap-2" 372 + > 373 + <InputWithLabel 374 + label="Name" 375 + type="text" 376 + name="page-title" 377 + autoComplete="off" 378 + value={name} 379 + onChange={(e) => setName(e.currentTarget.value)} 380 + autoFocus 381 + /> 382 + <InputWithLabel 383 + label="Path" 384 + type="text" 385 + name="page-path" 386 + autoComplete="off" 387 + value={path} 388 + onChange={(e) => setPath(e.currentTarget.value)} 389 + placeholder="/about" 390 + /> 391 + <div className="flex gap-2 justify-between items-center"> 392 + {props.canDelete ? ( 393 + <ButtonTertiary 394 + type="button" 395 + onClick={() => setMode("confirm")} 396 + > 397 + Delete 398 + </ButtonTertiary> 399 + ) : ( 400 + <div /> 401 + )} 402 + <ButtonPrimary type="submit" disabled={saving}> 403 + {saving ? "Saving..." : "Save"} 404 + </ButtonPrimary> 405 + </div> 406 + </form> 407 + ) : ( 408 + <div className="flex flex-col gap-2"> 409 + <div className="text-secondary text-sm pb-1"> 410 + This will permanently delete{" "} 411 + <span className="font-bold text-primary"> 412 + {props.page.title || props.page.path || "/"} 413 + </span> 414 + . 415 + </div> 416 + <div className="flex gap-2 justify-end items-center"> 417 + <ButtonTertiary type="button" onClick={() => setMode("edit")}> 418 + Cancel 419 + </ButtonTertiary> 420 + <ButtonPrimary onClick={handleDelete} disabled={deleting}> 421 + {deleting ? "Deleting..." : "Delete"} 422 + </ButtonPrimary> 423 + </div> 424 + </div> 425 + )} 426 + </Popover> 335 427 </div> 336 428 ); 337 429 }
+12 -15
app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx
··· 12 12 import { PublicationPagesNav } from "./PublicationPagesNav"; 13 13 import { PublicationEditHeader } from "./PublicationEditHeader"; 14 14 import { PublicationHeader } from "../../PublicationHeader"; 15 - import { PublicationStickyHeader } from "../../PublicationStickyHeader"; 16 15 import { PublicationEditDirtyProvider } from "./dirtyContext"; 17 16 18 17 export async function generateMetadata(props: { ··· 99 98 publicationName={params.publication} 100 99 /> 101 100 <div className="pubWrapper flex flex-col grow min-h-0 bg-bg-page rounded-t-lg overflow-hidden"> 102 - <PublicationStickyHeader 103 - sticky={false} 104 - nav={ 105 - <PublicationPagesNav 106 - did={params.did} 107 - publicationName={params.publication} 101 + <div className="shrink-0"> 102 + <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 103 + <PublicationHeader 104 + variant="inline" 105 + iconUrl={iconUrl} 106 + publicationName={publication.name} 107 + description={record?.description} 108 108 /> 109 - } 110 - > 111 - <PublicationHeader 112 - variant="inline" 113 - iconUrl={iconUrl} 114 - publicationName={publication.name} 115 - description={record?.description} 109 + </div> 110 + <PublicationPagesNav 111 + did={params.did} 112 + publicationName={params.publication} 116 113 /> 117 - </PublicationStickyHeader> 114 + </div> 118 115 <div className="grow min-h-0 flex flex-col">{props.children}</div> 119 116 </div> 120 117 </div>