a tool for shared writing and social publishing
0

Configure Feed

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

refactored publish pages

celine (Apr 16, 2026, 11:22 PM EDT) cdee8ec0 315ca72d

+367 -167
+17 -4
components/Tags.tsx
··· 39 39 export const TagSelector = (props: { 40 40 selectedTags: string[]; 41 41 setSelectedTags: (tags: string[]) => void; 42 + rightAlign?: boolean; 42 43 }) => { 43 44 return ( 44 45 <div className="flex flex-col gap-2 text-primary"> 45 46 <TagSearchInput 46 47 selectedTags={props.selectedTags} 47 48 setSelectedTags={props.setSelectedTags} 49 + rightAlign={props.rightAlign} 48 50 /> 49 51 {props.selectedTags.length > 0 ? ( 50 - <div className="flex flex-wrap gap-2 "> 52 + <div 53 + className={`flex flex-wrap gap-2 ${props.rightAlign && "justify-end"}`} 54 + > 51 55 {props.selectedTags.map((tag) => ( 52 56 <Tag 53 57 key={tag} ··· 62 66 ))} 63 67 </div> 64 68 ) : ( 65 - <div className="text-tertiary italic text-sm h-6">no tags selected</div> 69 + <div 70 + className={`text-tertiary italic text-sm h-6 ${props.rightAlign && "text-right"}`} 71 + > 72 + no tags selected 73 + </div> 66 74 )} 67 75 </div> 68 76 ); 69 77 }; 70 78 71 79 export const TagSearchInput = (props: { 80 + rightAlign?: boolean; 72 81 selectedTags: string[]; 73 82 setSelectedTags: (tags: string[]) => void; 74 83 }) => { ··· 203 212 }} 204 213 /> 205 214 {props.selectedTags.length > 0 ? ( 206 - <div className="flex flex-wrap gap-2 pb-[6px]"> 215 + <div 216 + className={`flex flex-wrap gap-2 pb-[6px] ${props.rightAlign && "justify-end"}`} 217 + > 207 218 {props.selectedTags.map((tag) => ( 208 219 <Tag 209 220 key={tag} ··· 218 229 ))} 219 230 </div> 220 231 ) : ( 221 - <div className="text-tertiary italic text-sm h-6"> 232 + <div 233 + className={`text-tertiary italic text-sm h-6 ${props.rightAlign && "text-right"}`} 234 + > 222 235 no tags selected 223 236 </div> 224 237 )}
+1 -1
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 304 304 /> 305 305 {editorState?.doc.textContent.length === 0 && ( 306 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 307 - Write a post to share your writing! 307 + post something... 308 308 </div> 309 309 )} 310 310 <div
+197 -162
app/[leaflet_id]/publish/PublishPost.tsx
··· 2 2 import { publishToPublication } from "actions/publishToPublication"; 3 3 import { DotLoader } from "components/utils/DotLoader"; 4 4 import { useState, useRef } from "react"; 5 - import { ButtonPrimary } from "components/Buttons"; 6 - import { Radio } from "components/Checkbox"; 5 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 7 6 import { useParams } from "next/navigation"; 8 7 import Link from "next/link"; 9 8 10 9 import type { NormalizedPublication } from "src/utils/normalizeRecords"; 11 10 import { publishPostToBsky } from "./publishBskyPost"; 11 + import { ShareOptions, type ShareState } from "./ShareOptions"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 16 import { useSubscribe } from "src/replicache/useSubscribe"; 17 - import { 18 - BlueskyPostEditorProsemirror, 19 - editorStateToFacetedText, 20 - } from "./BskyPostEditorProsemirror"; 17 + import { editorStateToFacetedText } from "./BskyPostEditorProsemirror"; 21 18 import { EditorState } from "prosemirror-state"; 22 19 import { TagSelector } from "../../../components/Tags"; 23 20 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; ··· 28 25 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 29 26 import { Separator } from "react-aria-components"; 30 27 import { setHours, setMinutes } from "date-fns"; 28 + import { 29 + ThemeBackgroundProvider, 30 + ThemeProvider, 31 + } from "components/ThemeManager/ThemeProvider"; 32 + import { useEntity } from "src/replicache"; 33 + import { LeafletContent } from "app/(home-pages)/home/LeafletList/LeafletContent"; 31 34 32 35 type Props = { 33 36 title: string; ··· 68 71 } & Props, 69 72 ) => { 70 73 let editorStateRef = useRef<EditorState | null>(null); 74 + let [state, setState] = useState<"post-details" | "share-options">( 75 + "post-details", 76 + ); 71 77 let [charCount, setCharCount] = useState(0); 72 - let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 78 + let [shareState, setShareState] = useState<ShareState>({ 79 + bluesky: true, 80 + postToReaders: true, 81 + email: true, 82 + quiet: false, 83 + }); 73 84 let [isLoading, setIsLoading] = useState(false); 74 85 let [oauthError, setOauthError] = useState< 75 86 import("src/atproto-oauth").OAuthSessionError | null ··· 82 93 tx.get<string[]>("publication_tags"), 83 94 ); 84 95 let [localTags, setLocalTags] = useState<string[]>([]); 96 + let [showTagSelector, setShowTagSelector] = useState(false); 85 97 86 98 let [localPublishedAt, setLocalPublishedAt] = useState<Date | undefined>( 87 99 undefined, ··· 152 164 let [text, facets] = editorStateRef.current 153 165 ? editorStateToFacetedText(editorStateRef.current) 154 166 : []; 155 - if (shareOption === "bluesky") { 167 + if (shareState.bluesky) { 156 168 let bskyResult = await publishPostToBsky({ 157 169 facets: facets || [], 158 170 text: text || "", ··· 181 193 }} 182 194 > 183 195 <div className="frosted-container flex flex-col gap-3 sm:p-3 p-4"> 184 - <PublishingTo 185 - publication_uri={props.publication_uri} 186 - record={props.record} 187 - /> 188 - <hr className="border-border" /> 189 - 190 - <BackdateOptions 191 - publishedAt={localPublishedAt} 192 - setPublishedAt={setLocalPublishedAt} 193 - /> 194 - <hr className="border-border " /> 195 - 196 - <div className="flex flex-col gap-2"> 197 - <h4>Tags</h4> 198 - <TagSelector 199 - selectedTags={currentTags} 200 - setSelectedTags={handleTagsChange} 201 - /> 202 - </div> 203 - <hr className="border-border" /> 204 - <ShareOptions 205 - setShareOption={setShareOption} 206 - shareOption={shareOption} 207 - charCount={charCount} 208 - setCharCount={setCharCount} 209 - editorStateRef={editorStateRef} 210 - {...props} 211 - /> 212 - <hr className="border-border mb-2" /> 213 - 214 - <div className="flex flex-col gap-2"> 215 - <div className="flex justify-between"> 216 - <Link 217 - className="hover:no-underline! font-bold" 218 - href={`/${params.leaflet_id}`} 219 - > 220 - Back 221 - </Link> 222 - <ButtonPrimary 223 - type="submit" 224 - className="place-self-end h-[30px]" 225 - disabled={charCount > 300} 226 - > 227 - {isLoading ? ( 228 - <DotLoader className="h-[23px]" /> 229 - ) : ( 230 - "Publish this Post!" 231 - )} 232 - </ButtonPrimary> 233 - </div> 234 - {oauthError && ( 235 - <OAuthErrorMessage 236 - error={oauthError} 237 - className="text-right text-sm text-accent-contrast" 196 + {state === "post-details" ? ( 197 + <> 198 + <h2>Publish: Post Details</h2> 199 + <PublishingTo 200 + publication_uri={props.publication_uri} 201 + record={props.record} 238 202 /> 239 - )} 240 - </div> 203 + <hr className="border-border-light" /> 204 + 205 + <BackdateOptions 206 + publishedAt={localPublishedAt} 207 + setPublishedAt={setLocalPublishedAt} 208 + /> 209 + <hr className="border-border-light" /> 210 + 211 + <div className="flex justify-between gap-4"> 212 + <div className="text-tertiary">Tags</div> 213 + <div className="grow "> 214 + {currentTags.length !== 0 || showTagSelector === true ? ( 215 + <div className="sm:w-sm sm:justify-self-end"> 216 + <TagSelector 217 + rightAlign 218 + selectedTags={currentTags} 219 + setSelectedTags={handleTagsChange} 220 + /> 221 + </div> 222 + ) : ( 223 + <button 224 + type="button" 225 + className="hover:underline font-bold text-secondary float-end" 226 + onClick={() => { 227 + setShowTagSelector(true); 228 + }} 229 + > 230 + No Tags 231 + </button> 232 + )} 233 + </div> 234 + </div> 235 + <hr className="border-border-light" /> 236 + <div className="flex justify-between sm:flex-row flex-col gap-4"> 237 + <div className="text-tertiary shrink-0">Social Preview</div> 238 + <div className="opaque-container !border-border overflow-hidden flex flex-col w-full sm:max-w-sm rounded-lg!"> 239 + <SocialPreviewImage 240 + rootEntity={props.root_entity} 241 + did={props.profile.did} 242 + coverImageCid={replicacheCoverImage ?? null} 243 + /> 244 + <hr className="border-border" /> 245 + <div className="flex flex-col p-2 gap-0.5"> 246 + <div className="font-bold line-clamp-1"> 247 + {props.title || "Untitled"} 248 + </div> 249 + {props.description && ( 250 + <div className="text-sm text-tertiary line-clamp-2"> 251 + {props.description} 252 + </div> 253 + )} 254 + <hr className="border-border-light mt-1 mb-0.5" /> 255 + <div className="text-xs text-tertiary"> 256 + {(props.record?.url || "leaflet.pub").replace( 257 + /^https?:\/\//, 258 + "", 259 + )} 260 + </div> 261 + </div> 262 + </div> 263 + </div> 264 + <hr className="border-border mb-2" /> 265 + 266 + <div className="flex justify-between"> 267 + <Link 268 + className="hover:no-underline! font-bold" 269 + href={`/${params.leaflet_id}`} 270 + > 271 + Back 272 + </Link> 273 + <ButtonSecondary 274 + type="button" 275 + className="place-self-end h-[30px]" 276 + onClick={() => setState("share-options")} 277 + > 278 + Next: Share 279 + </ButtonSecondary> 280 + </div> 281 + </> 282 + ) : ( 283 + <> 284 + <h2>Publish: Share Options</h2> 285 + <ShareOptions 286 + shareState={shareState} 287 + setShareState={setShareState} 288 + charCount={charCount} 289 + setCharCount={setCharCount} 290 + editorStateRef={editorStateRef} 291 + title={props.title} 292 + profile={props.profile} 293 + description={props.description} 294 + record={props.record} 295 + /> 296 + <hr className="border-border mb-2" /> 297 + 298 + <div className="flex flex-col gap-2"> 299 + <div className="flex justify-between"> 300 + <button 301 + type="button" 302 + className="font-bold text-accent-contrast" 303 + onClick={() => setState("post-details")} 304 + > 305 + Back 306 + </button> 307 + <ButtonPrimary 308 + type="submit" 309 + className="place-self-end h-[30px]" 310 + disabled={charCount > 300} 311 + > 312 + {isLoading ? ( 313 + <DotLoader className="h-[23px]" /> 314 + ) : ( 315 + "Publish this Post!" 316 + )} 317 + </ButtonPrimary> 318 + </div> 319 + {oauthError && ( 320 + <OAuthErrorMessage 321 + error={oauthError} 322 + className="text-right text-sm text-accent-contrast" 323 + /> 324 + )} 325 + </div> 326 + </> 327 + )} 241 328 </div> 242 329 </form> 243 330 </div> 331 + ); 332 + }; 333 + 334 + const SocialPreviewImage = (props: { 335 + rootEntity: string; 336 + did: string; 337 + coverImageCid: string | null; 338 + }) => { 339 + const firstPage = useEntity(props.rootEntity, "root/page")[0]; 340 + const page = firstPage?.data.value || props.rootEntity; 341 + 342 + if (props.coverImageCid) { 343 + return ( 344 + <img 345 + src={`/api/atproto_images?did=${props.did}&cid=${props.coverImageCid}`} 346 + className="w-full object-cover aspect-video" 347 + alt="" 348 + /> 349 + ); 350 + } 351 + 352 + return ( 353 + <ThemeProvider local entityID={props.rootEntity} className="w-full!"> 354 + <ThemeBackgroundProvider entityID={props.rootEntity}> 355 + <div 356 + inert 357 + className="w-full aspect-video overflow-hidden flex items-end pointer-events-none" 358 + > 359 + <div 360 + className="leafletContentWrapper h-full w-[60%] pt-3 361 + overflow-clip mx-auto" 362 + > 363 + <LeafletContent entityID={page} isOnScreen={true} /> 364 + </div> 365 + </div> 366 + </ThemeBackgroundProvider> 367 + </ThemeProvider> 244 368 ); 245 369 }; 246 370 ··· 305 429 306 430 return ( 307 431 <div className="flex justify-between gap-2"> 308 - <h4>Publish Date</h4> 432 + <div className="text-tertiary">Publish Date</div> 309 433 <Popover 310 434 className="w-64 px-2!" 311 435 trigger={ 312 436 props.publishedAt ? ( 313 - <div className="text-secondary">{formattedDate}</div> 437 + <div className="text-secondary font-bold hover:underline"> 438 + {formattedDate} 439 + </div> 314 440 ) : ( 315 - <div className="text-tertiary italic">now</div> 441 + <div className="text-secondary font-bold hover:underline">Now</div> 316 442 ) 317 443 } 318 444 > ··· 332 458 ); 333 459 }; 334 460 335 - const ShareOptions = (props: { 336 - shareOption: "quiet" | "bluesky"; 337 - setShareOption: (option: typeof props.shareOption) => void; 338 - charCount: number; 339 - setCharCount: (c: number) => void; 340 - editorStateRef: React.MutableRefObject<EditorState | null>; 341 - title: string; 342 - profile: ProfileViewDetailed; 343 - description: string; 344 - record?: NormalizedPublication | null; 345 - }) => { 346 - return ( 347 - <div className="flex flex-col gap-2"> 348 - <h4>Share and Notify</h4> 349 - <Radio 350 - checked={props.shareOption === "quiet"} 351 - onChange={(e) => { 352 - if (e.target === e.currentTarget) { 353 - props.setShareOption("quiet"); 354 - } 355 - }} 356 - name="share-options" 357 - id="share-quietly" 358 - value="Share Quietly" 359 - > 360 - <div className="flex flex-col"> 361 - <div className="font-bold">Share Quietly</div> 362 - <div className="text-sm text-tertiary font-normal"> 363 - No one will be notified about this post 364 - </div> 365 - </div> 366 - </Radio> 367 - <Radio 368 - checked={props.shareOption === "bluesky"} 369 - onChange={(e) => { 370 - if (e.target === e.currentTarget) { 371 - props.setShareOption("bluesky"); 372 - } 373 - }} 374 - name="share-options" 375 - id="share-bsky" 376 - value="Share on Bluesky" 377 - > 378 - <div className="flex flex-col"> 379 - <div className="font-bold">Share on Bluesky</div> 380 - <div className="text-sm text-tertiary font-normal"> 381 - Pub subscribers will be updated via a custom Bluesky feed 382 - </div> 383 - </div> 384 - </Radio> 385 - <div 386 - className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 387 - > 388 - <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 389 - <div className="flex gap-2"> 390 - <img 391 - className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 392 - src={props.profile.avatar} 393 - /> 394 - <div className="flex flex-col w-full"> 395 - <div className="flex gap-2 "> 396 - <p className="font-bold">{props.profile.displayName}</p> 397 - <p className="text-tertiary">@{props.profile.handle}</p> 398 - </div> 399 - <div className="flex flex-col"> 400 - <BlueskyPostEditorProsemirror 401 - editorStateRef={props.editorStateRef} 402 - onCharCountChange={props.setCharCount} 403 - /> 404 - </div> 405 - <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 406 - <div className="flex flex-col p-2"> 407 - <div className="font-bold">{props.title}</div> 408 - <div className="text-tertiary">{props.description}</div> 409 - <hr className="border-border mt-2 mb-1" /> 410 - <p className="text-xs text-tertiary"> 411 - {props.record?.url?.replace(/^https?:\/\//, "")} 412 - </p> 413 - </div> 414 - </div> 415 - <div className="text-xs text-secondary italic place-self-end pt-2"> 416 - {props.charCount}/300 417 - </div> 418 - </div> 419 - </div> 420 - </div> 421 - </div> 422 - </div> 423 - ); 424 - }; 425 - 426 461 const PublishingTo = (props: { 427 462 publication_uri?: string; 428 463 record?: NormalizedPublication | null; 429 464 }) => { 430 465 if (props.publication_uri && props.record) { 431 466 return ( 432 - <div className="flex flex-col gap-1"> 433 - <h3>Publishing to</h3> 434 - <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 435 - <PubIcon record={props.record} uri={props.publication_uri} /> 467 + <div className="flex justify-between gap-4"> 468 + <div className="text-tertiary">Publishing to</div> 469 + <div className="flex gap-2 items-center "> 436 470 <div className="font-bold text-secondary">{props.record.name}</div> 471 + <PubIcon record={props.record} uri={props.publication_uri} /> 437 472 </div> 438 473 </div> 439 474 );
+152
app/[leaflet_id]/publish/ShareOptions.tsx
··· 1 + "use client"; 2 + import { Checkbox } from "components/Checkbox"; 3 + import { BlueskyPostEditorProsemirror } from "./BskyPostEditorProsemirror"; 4 + import { EditorState } from "prosemirror-state"; 5 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 6 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 7 + 8 + export type ShareState = { 9 + bluesky: boolean; 10 + postToReaders: boolean; 11 + email: boolean; 12 + quiet: boolean; 13 + }; 14 + 15 + type Props = { 16 + shareState: ShareState; 17 + setShareState: (state: ShareState) => void; 18 + charCount: number; 19 + setCharCount: (c: number) => void; 20 + editorStateRef: React.MutableRefObject<EditorState | null>; 21 + title: string; 22 + profile: ProfileViewDetailed; 23 + description: string; 24 + record?: NormalizedPublication | null; 25 + subscriberCount?: number; 26 + }; 27 + 28 + export function ShareOptions(props: Props) { 29 + const { shareState, setShareState } = props; 30 + const handleChange = ( 31 + key: keyof Omit<ShareState, "quiet">, 32 + checked: boolean, 33 + ) => { 34 + setShareState({ ...shareState, [key]: checked, quiet: false }); 35 + }; 36 + 37 + const handleQuietChange = (checked: boolean) => { 38 + if (checked) { 39 + setShareState({ 40 + bluesky: false, 41 + postToReaders: false, 42 + email: false, 43 + quiet: true, 44 + }); 45 + } else { 46 + setShareState({ ...shareState, quiet: false }); 47 + } 48 + }; 49 + 50 + return ( 51 + <div className="flex flex-col gap-2"> 52 + <Checkbox 53 + className="gap-4!" 54 + checked={shareState.email} 55 + onChange={(e) => handleChange("email", e.target.checked)} 56 + > 57 + <div className="flex flex-col"> 58 + <div> 59 + Email{" "} 60 + {props.subscriberCount !== undefined 61 + ? `${props.subscriberCount} ` 62 + : ""} 63 + Subscribers 64 + </div> 65 + </div> 66 + </Checkbox> 67 + 68 + <Checkbox 69 + className="gap-4!" 70 + checked={shareState.postToReaders} 71 + onChange={(e) => handleChange("postToReaders", e.target.checked)} 72 + > 73 + <div className="flex flex-col"> 74 + <div>Post to Readers</div> 75 + <div className="text-sm text-tertiary font-normal"> 76 + This post will show up in Leaflet feeds and other Standard Site 77 + enabled reader feeds 78 + </div> 79 + </div> 80 + </Checkbox> 81 + 82 + <Checkbox 83 + className="gap-4!" 84 + checked={shareState.bluesky} 85 + onChange={(e) => handleChange("bluesky", e.target.checked)} 86 + > 87 + <div className="flex flex-col"> 88 + <div>Share on Bluesky</div> 89 + <div className="text-sm text-tertiary font-normal"> 90 + Pub subscribers will be updated via a custom Bluesky feed 91 + </div> 92 + </div> 93 + </Checkbox> 94 + <div 95 + className={`w-full pl-7 pb-2 ${!shareState.bluesky ? "opacity-50" : ""}`} 96 + > 97 + <div className="opaque-container border-border! py-2 px-3 text-sm rounded-lg!"> 98 + <div className="flex gap-2"> 99 + <img 100 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 101 + src={props.profile.avatar} 102 + /> 103 + <div className="flex flex-col min-w-0 w-full"> 104 + <div className="flex gap-2"> 105 + <p className="font-bold">{props.profile.displayName}</p> 106 + <p className="text-tertiary">@{props.profile.handle}</p> 107 + </div> 108 + <div className="flex flex-col"> 109 + <BlueskyPostEditorProsemirror 110 + editorStateRef={props.editorStateRef} 111 + onCharCountChange={props.setCharCount} 112 + /> 113 + </div> 114 + <div className="opaque-container text-secondary overflow-hidden flex flex-col mt-4 w-full"> 115 + <div className="flex flex-col p-2"> 116 + <div className="font-bold truncate min-w-0 w-full "> 117 + {props.title} 118 + </div> 119 + <div className="text-tertiary line-clamp-3"> 120 + {props.description} 121 + </div> 122 + <hr className="border-border mt-2 mb-1" /> 123 + <p className="text-xs text-tertiary"> 124 + {props.record?.url?.replace(/^https?:\/\//, "")} 125 + </p> 126 + </div> 127 + </div> 128 + <div className="text-xs text-secondary italic place-self-end pt-2"> 129 + {props.charCount}/300 130 + </div> 131 + </div> 132 + </div> 133 + </div> 134 + </div> 135 + 136 + <hr className="border-border-light my-1" /> 137 + 138 + <Checkbox 139 + className="gap-4!" 140 + checked={shareState.quiet} 141 + onChange={(e) => handleQuietChange(e.target.checked)} 142 + > 143 + <div className="flex flex-col"> 144 + <div>Post Quietly</div> 145 + <div className="text-sm text-tertiary font-normal"> 146 + No one will be notified about this post 147 + </div> 148 + </div> 149 + </Checkbox> 150 + </div> 151 + ); 152 + }