a tool for shared writing and social publishing
0

Configure Feed

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

Refactor/pub settings (#288)

* WIP move everything in pub setting popover to tab

* started organizing the pub setting page

* pub settings: refine PostSettings layout, add DashboardContainer, and CSS container class

* just moving the content to the side

* remove unused code, make the settings page look nice

* scrolling save button on settings

* move theme setter to a new page, adjusted post and pub pages so that i
can extract them and use dummy data to fill them in

* making page background works in the theme preview

* adjusting stuff to make the pages fit right on mobile

* added hex and numpads to the themesetter on mobile so that the keyboard
doesn't pop up and mess things up

* grab bag of little fixes to make things smoother

* add a theme presetter

* adjusting layout and popover behavior

* remove stray console log

* little tweaks to the pub settings

* a few more tweaks

* in the theme setter, moved the theme menu to the top of the page, added
a back button that drops a warning if there are unsaved changes in the
theme

* fixed bug in back button

* add copy and paste functions to the hexkeyboard

* fixed missing handles, added some fake posts to pub preview if the user
doesn't have any

* some stuff to unify darft and published post paddings, also new filler
content in the previews

* fix some small things

* making the settings more legible, adding unwire delete pub button

* add delete publication funcationality

* redirect to home and simplify delete function

* move some components from PubPageContent to PubContent

* small fixes

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by

Jared Pereira
celine
and committed by
GitHub
(Apr 15, 2026, 11:33 PM EDT) 48e6740e 44e7c34a

+3422 -994
+2 -11
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 48 48 ref={previewRef} 49 49 className={`relative flex gap-3 w-full 50 50 ${props.isHidden ? "hidden" : "flex"} 51 - ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border! relative"}`} 52 52 style={{ 53 53 backgroundColor: cardBorderHidden 54 54 ? "transparent" ··· 84 84 className={` 85 85 relative 86 86 flex flex-col gap-1 p-1 h-52 w-full 87 - block-border border-border! hover:outline-border 87 + block-border border-border! hover:outline-border! 88 88 ${props.isHidden ? "hidden" : "flex"} 89 89 `} 90 90 style={{ ··· 111 111 </div> 112 112 ); 113 113 }; 114 - 115 - const LeafletLink = (props: { id: string; className: string }) => { 116 - return ( 117 - <SpeedyLink 118 - href={`/${props.id}`} 119 - className={`no-underline hover:no-underline! text-primary ${props.className}`} 120 - /> 121 - ); 122 - };
+1 -1
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 28 28 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 29 29 {props.bskyPostText && ( 30 30 <pre 31 - style={{ wordBreak: "break-word" }} 31 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 32 32 className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 33 33 > 34 34 {props.bskyPostText}
+3 -8
app/(home-pages)/notifications/Notification.tsx
··· 23 23 <div 24 24 className={`relative flex flex-col w-full pb-3 sm:pb-4 pt-2 ${ 25 25 cardBorderHidden 26 - ? " first:pt-0! " 27 - : " block-border border-border! hover:outline-border sm:px-4 px-3 pl-2 sm:pl-3 " 26 + ? "first:pt-0! " 27 + : "container rounded-lg! block-border hover:outline-border! border-border! sm:px-4 px-3 pl-2 sm:pl-3 " 28 28 }`} 29 - style={{ 30 - backgroundColor: cardBorderHidden 31 - ? "transparent" 32 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 33 - }} 34 29 > 35 30 <a 36 31 href={props.href} ··· 102 97 <div className=" flex gap-2 text-sm w-full "> 103 98 <Avatar src={props.avatar} displayName={props.displayName} /> 104 99 <pre 105 - style={{ wordBreak: "break-word" }} 100 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 106 101 className={`whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6 ${props.className}`} 107 102 > 108 103 <BaseTextBlock
+9 -7
app/(home-pages)/notifications/NotificationList.tsx
··· 12 12 import { MentionNotification } from "./MentionNotification"; 13 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 14 import { RecommendNotification } from "./RecommendNotification"; 15 + import { EmptyState } from "components/EmptyState"; 15 16 16 17 export function NotificationList({ 17 18 notifications, ··· 30 31 31 32 if (notifications.length === 0) 32 33 return ( 33 - <div className="w-full text-sm flex flex-col gap-1 container italic text-tertiary text-center sm:p-4 p-3"> 34 - <div className="text-base font-bold">no notifications yet...</div> 35 - Here, you&apos;ll find notifications about new follows, comments, 36 - mentions, and replies! 37 - </div> 34 + <EmptyState 35 + title="no notifications yet..." 36 + description="Here, you'll find notifications about new follows, comments, mentions, and replies!" 37 + /> 38 38 ); 39 39 return ( 40 - <div className="max-w-prose mx-auto w-full"> 41 - <div className={`flex flex-col gap-2`}> 40 + <div className="max-w-prose w-full"> 41 + <div className={`flex flex-col gap-3`}> 42 + <h1 className="sm:text-xl text-lg">Notifications</h1> 43 + 42 44 {notifications.map((n) => { 43 45 if (n.type === "comment") { 44 46 if (n.parentData) return <ReplyNotification key={n.id} {...n} />;
+2 -5
app/(home-pages)/notifications/QuoteNotification.tsx
··· 27 27 content={ 28 28 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 29 29 <div className="flex gap-2 text-sm w-full"> 30 - <Avatar 31 - src={author.avatar} 32 - displayName={displayName} 33 - /> 30 + <Avatar src={author.avatar} displayName={displayName} /> 34 31 <pre 35 - style={{ wordBreak: "break-word" }} 32 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 36 33 className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 37 34 > 38 35 {postText}
+2 -1
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { PostListing } from "components/PostListing"; 4 5 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 6 import type { Cursor } from "./getProfilePosts"; ··· 64 65 const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 66 66 67 if (allPosts.length === 0 && !isValidating) { 67 - return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + return <EmptyState title="No posts yet" />; 68 69 } 69 70 70 71 return (
+1 -1
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 45 45 46 46 return ( 47 47 <div 48 - className={`profileHeader flex flex-col relative `} 48 + className={`profileHeader flex flex-col relative pb-6`} 49 49 id="profile-header" 50 50 > 51 51 {!props.popover && <ProfileLinks handle={props.profile.handle || ""} />}
+3 -3
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 10 10 className={` 11 11 ${ 12 12 cardBorderHidden 13 - ? "" 14 - : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4" 13 + ? "bg-transparent" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg! container px-3 sm:px-4 sm:-mt-2" 15 15 } 16 - max-w-prose mx-auto w-full 16 + max-w-prose w-full 17 17 flex flex-col pb-3 18 18 text-center 19 19 `}
+5 -3
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
··· 41 41 const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 42 43 43 return ( 44 - <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 pt-6"> 44 + <div 45 + className={`flex flex-col w-full sticky z-20 ${cardBorderHidden ? "top-3 sm:top-0" : "top-3"} `} 46 + > 45 47 <div 46 48 style={ 47 49 scrollPosWithinTabContent < 20 ··· 62 64 scrollPosWithinTabContent < 20 63 65 ? { 64 66 backgroundColor: !cardBorderHidden 65 - ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 67 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60})` 66 68 : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 69 paddingLeft: !cardBorderHidden 68 70 ? "4px" ··· 110 112 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 113 props.selected 112 114 ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 - : "text-tertiary" 115 + : "text-secondary" 114 116 }`} 115 117 > 116 118 {props.name}
+3 -4
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { useEffect, useRef, useMemo } from "react"; 4 5 import useSWRInfinite from "swr/infinite"; 5 6 import { AppBskyActorProfile, AtUri } from "@atproto/api"; ··· 79 80 const allComments = data ? data.flatMap((page) => page.comments) : []; 80 81 81 82 if (allComments.length === 0 && !isValidating) { 82 - return ( 83 - <div className="text-tertiary text-center py-4">No comments yet</div> 84 - ); 83 + return <EmptyState title="No comments yet" className="py-4" />; 85 84 } 86 85 87 86 return ( ··· 206 205 </div> 207 206 )} 208 207 <pre 209 - style={{ wordBreak: "break-word" }} 208 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 210 209 className="whitespace-pre-wrap text-secondary" 211 210 > 212 211 <BaseTextBlock
+2 -3
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 1 1 "use client"; 2 2 3 + import { EmptyState } from "components/EmptyState"; 3 4 import { useEffect, useRef } from "react"; 4 5 import useSWRInfinite from "swr/infinite"; 5 6 import { PubListing } from "app/(home-pages)/p/[didOrHandle]/PubListing"; ··· 75 76 : []; 76 77 77 78 if (allSubscriptions.length === 0 && !isValidating) { 78 - return ( 79 - <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 - ); 79 + return <EmptyState title="No subscriptions yet" />; 81 80 } 82 81 83 82 return (
+2 -5
app/(home-pages)/reader/GlobalContent.tsx
··· 2 2 import { use } from "react"; 3 3 import useSWR from "swr"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 + import { EmptyState } from "components/EmptyState"; 5 6 import { PostListing } from "components/PostListing"; 6 7 import type { Post } from "./getReaderFeed"; 7 8 import { ··· 33 34 let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 34 35 35 36 if (posts.length === 0) { 36 - return ( 37 - <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 38 - Nothing trending right now. Check back soon! 39 - </div> 40 - ); 37 + return <EmptyState title="Nothing trending right now. Check back soon!" />; 41 38 } 42 39 43 40 return (
+2 -5
app/(home-pages)/reader/NewContent.tsx
··· 1 1 "use client"; 2 2 3 3 import { use } from "react"; 4 + import { EmptyState } from "components/EmptyState"; 4 5 import type { Cursor, Post } from "./getReaderFeed"; 5 6 import useSWRInfinite from "swr/infinite"; 6 7 import { getNewFeed } from "./getNewFeed"; ··· 65 66 const allPosts = data ? data.flatMap((page) => page.posts) : []; 66 67 67 68 if (allPosts.length === 0) { 68 - return ( 69 - <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 70 - No posts yet. Check back soon! 71 - </div> 72 - ); 69 + return <EmptyState title="No posts yet. Check back soon!" />; 73 70 } 74 71 75 72 return (
+10 -6
app/(home-pages)/reader/ReaderMentionsContent.tsx
··· 2 2 import useSWR from "swr"; 3 3 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 4 import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 5 + import { EmptyState } from "components/EmptyState"; 5 6 import { DotLoader } from "components/utils/DotLoader"; 6 7 7 8 async function fetchBskyPosts(uris: string[]): Promise<PostView[]> { ··· 17 18 quotesAndMentions: { uri: string; link?: string }[]; 18 19 }) { 19 20 const uris = props.quotesAndMentions.map((q) => q.uri); 20 - const key = uris.length > 0 21 - ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 22 - : null; 21 + const key = 22 + uris.length > 0 23 + ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 24 + : null; 23 25 24 26 const { data: bskyPosts, isLoading } = useSWR(key, () => 25 27 fetchBskyPosts(uris), ··· 27 29 28 30 if (props.quotesAndMentions.length === 0) { 29 31 return ( 30 - <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 31 - <div className="font-bold">no mentions yet!</div> 32 - </div> 32 + <EmptyState 33 + title="No mentions yet!" 34 + container="opaque" 35 + className="gap-0.5 p-[6px]!" 36 + /> 33 37 ); 34 38 } 35 39
+1 -1
app/(home-pages)/reader/layout.tsx
··· 57 57 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ 58 58 isActive(tab.href) 59 59 ? "text-accent-2 bg-accent-1 font-bold -mb-px" 60 - : "text-tertiary" 60 + : "text-secondary" 61 61 }`} 62 62 > 63 63 {tab.name}
+14 -26
app/(home-pages)/tag/[tag]/page.tsx
··· 1 1 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 - import { Tag } from "components/Tags"; 3 2 import { PostListing } from "components/PostListing"; 4 3 import { getDocumentsByTag } from "./getDocumentsByTag"; 5 - import { TagTiny } from "components/Icons/TagTiny"; 6 4 import { Metadata } from "next"; 7 5 8 6 export async function generateMetadata(props: { ··· 41 39 posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 42 40 }) => { 43 41 return ( 44 - <div className="max-w-prose mx-auto w-full grow shrink-0"> 45 - <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 46 - <TagHeader tag={props.tag} postCount={props.posts.length} /> 47 - </div> 48 - <div className="pt-6 flex flex-col gap-3"> 42 + <div className="max-w-prose w-full grow shrink-0"> 43 + <h1 className="sm:text-xl text-lg">Tag: {props.tag}</h1> 44 + 45 + <div className="pt-4 flex flex-col gap-4"> 49 46 {props.posts.length === 0 ? ( 50 - <EmptyState tag={props.tag} /> 47 + <NoPostsForTag tag={props.tag} /> 51 48 ) : ( 52 - props.posts.map((post) => ( 53 - <PostListing key={post.documents.uri} {...post} /> 54 - )) 49 + <> 50 + <div className="text-tertiary text-sm px-3"> 51 + {props.posts.length} {props.posts.length === 1 ? "post" : "posts"} 52 + </div> 53 + {props.posts.map((post) => ( 54 + <PostListing key={post.documents.uri} {...post} /> 55 + ))} 56 + </> 55 57 )} 56 58 </div> 57 59 </div> 58 60 ); 59 61 }; 60 62 61 - const TagHeader = (props: { tag: string; postCount: number }) => { 62 - return ( 63 - <div className="flex flex-col leading-tight items-center"> 64 - <div className="flex items-center gap-3 text-xl font-bold text-primary"> 65 - <TagTiny className="scale-150" /> 66 - <h1>{props.tag}</h1> 67 - </div> 68 - <div className="text-tertiary text-sm"> 69 - {props.postCount} {props.postCount === 1 ? "post" : "posts"} 70 - </div> 71 - </div> 72 - ); 73 - }; 74 - 75 - const EmptyState = (props: { tag: string }) => { 63 + const NoPostsForTag = (props: { tag: string }) => { 76 64 return ( 77 65 <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 78 66 <div className="text-tertiary">
+8 -1
app/globals.css
··· 473 473 @apply ml-2; 474 474 } 475 475 476 + .container { 477 + @apply border; 478 + @apply border-border-light; 479 + @apply rounded-md; 480 + background: rgba(var(--bg-page), var(--bg-page-alpha)); 481 + } 482 + 476 483 .transparent-container { 477 484 @apply border; 478 485 @apply border-border-light; 479 486 @apply rounded-md; 480 487 } 481 488 482 - .container { 489 + .frosted-container { 483 490 background: rgba(var(--bg-page), 0.75); 484 491 @apply border; 485 492 @apply border-bg-page;
+253
app/lish/[did]/[publication]/PublicationContent.tsx
··· 1 + import React from "react"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { 4 + getPublicationURL, 5 + getDocumentURL, 6 + } from "app/lish/createPub/getPublicationURL"; 7 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 8 + import { InteractionPreview } from "components/InteractionsPreview"; 9 + import { LocalizedDate } from "./LocalizedDate"; 10 + import { PublicationHomeLayout } from "./PublicationHomeLayout"; 11 + import { PublicationAuthor } from "./PublicationAuthor"; 12 + import { 13 + normalizePublicationRecord, 14 + normalizeDocumentRecord, 15 + } from "src/utils/normalizeRecords"; 16 + import { getFirstParagraph } from "src/utils/getFirstParagraph"; 17 + import { FontLoader } from "components/FontLoader"; 18 + import { SpeedyLink } from "components/SpeedyLink"; 19 + 20 + type FakePost = { 21 + title: string; 22 + description: string; 23 + date: React.ReactNode; 24 + }; 25 + 26 + export const PublicationContent = ({ 27 + record, 28 + publication, 29 + did, 30 + profile, 31 + showPageBackground, 32 + fakePosts, 33 + }: { 34 + record: ReturnType<typeof normalizePublicationRecord>; 35 + publication: { 36 + uri: string; 37 + name: string; 38 + identity_did: string; 39 + record: unknown; 40 + publication_subscriptions: { identity: string }[]; 41 + documents_in_publications: { 42 + documents: { 43 + uri: string; 44 + data: unknown; 45 + comments_on_documents: { count: number }[]; 46 + document_mentions_in_bsky: { count: number }[]; 47 + recommends_on_documents: { count: number }[]; 48 + } | null; 49 + }[]; 50 + }; 51 + did: string; 52 + profile: { did: string; displayName?: string; handle: string } | undefined; 53 + showPageBackground: boolean | undefined; 54 + fakePosts?: FakePost[]; 55 + }) => { 56 + return ( 57 + <> 58 + <FontLoader 59 + headingFontId={record?.theme?.headingFont} 60 + bodyFontId={record?.theme?.bodyFont} 61 + /> 62 + <PublicationHomeLayout 63 + uri={publication.uri} 64 + showPageBackground={!!showPageBackground} 65 + > 66 + <PublicationHeader 67 + iconUrl={ 68 + record?.icon 69 + ? `/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}` 70 + : undefined 71 + } 72 + publicationName={publication.name} 73 + description={record?.description} 74 + author={ 75 + profile ? ( 76 + <PublicationAuthor 77 + did={profile.did} 78 + displayName={profile.displayName} 79 + handle={profile.handle} 80 + /> 81 + ) : undefined 82 + } 83 + subscribeButton={ 84 + <SubscribeWithBluesky 85 + base_url={getPublicationURL(publication)} 86 + pubName={publication.name} 87 + pub_uri={publication.uri} 88 + subscribers={publication.publication_subscriptions} 89 + /> 90 + } 91 + /> 92 + <div className="publicationPostList w-full flex flex-col gap-4"> 93 + {fakePosts && 94 + fakePosts.map((post, i) => ( 95 + <PublicationPostItem 96 + key={i} 97 + title={post.title} 98 + description={post.description} 99 + date={post.date} 100 + /> 101 + ))} 102 + {!fakePosts && 103 + publication.documents_in_publications 104 + .filter((d) => !!d?.documents) 105 + .sort((a, b) => { 106 + const aRecord = normalizeDocumentRecord(a.documents?.data); 107 + const bRecord = normalizeDocumentRecord(b.documents?.data); 108 + const aDate = aRecord?.publishedAt 109 + ? new Date(aRecord.publishedAt) 110 + : new Date(0); 111 + const bDate = bRecord?.publishedAt 112 + ? new Date(bRecord.publishedAt) 113 + : new Date(0); 114 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 115 + }) 116 + .map((doc) => { 117 + if (!doc.documents) return null; 118 + const doc_record = normalizeDocumentRecord(doc.documents.data); 119 + if (!doc_record) return null; 120 + let uri = new AtUri(doc.documents.uri); 121 + let quotes = 122 + doc.documents.document_mentions_in_bsky[0].count || 0; 123 + let comments = 124 + record?.preferences?.showComments === false 125 + ? 0 126 + : doc.documents.comments_on_documents[0].count || 0; 127 + let recommends = 128 + doc.documents.recommends_on_documents?.[0]?.count || 0; 129 + let tags = doc_record.tags || []; 130 + 131 + const docUrl = getDocumentURL( 132 + doc_record, 133 + doc.documents.uri, 134 + publication, 135 + ); 136 + return ( 137 + <React.Fragment key={doc.documents?.uri}> 138 + <PublicationPostItem 139 + href={docUrl} 140 + title={doc_record.title} 141 + description={ 142 + doc_record.description || getFirstParagraph(doc_record) 143 + } 144 + date={ 145 + doc_record.publishedAt ? ( 146 + <LocalizedDate 147 + dateString={doc_record.publishedAt} 148 + options={{ 149 + year: "numeric", 150 + month: "long", 151 + day: "2-digit", 152 + }} 153 + /> 154 + ) : undefined 155 + } 156 + interactions={ 157 + <InteractionPreview 158 + quotesCount={quotes} 159 + commentsCount={comments} 160 + recommendsCount={recommends} 161 + documentUri={doc.documents.uri} 162 + tags={tags} 163 + postUrl={docUrl} 164 + showComments={ 165 + record?.preferences?.showComments !== false 166 + } 167 + showMentions={ 168 + record?.preferences?.showMentions !== false 169 + } 170 + showRecommends={ 171 + record?.preferences?.showRecommends !== false 172 + } 173 + /> 174 + } 175 + /> 176 + </React.Fragment> 177 + ); 178 + })} 179 + </div> 180 + </PublicationHomeLayout> 181 + </> 182 + ); 183 + }; 184 + 185 + export function PublicationHeader(props: { 186 + iconUrl?: string; 187 + publicationName: string; 188 + description?: string; 189 + author?: React.ReactNode; 190 + subscribeButton?: React.ReactNode; 191 + }) { 192 + return ( 193 + <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 194 + {props.iconUrl && ( 195 + <div 196 + className="shrink-0 w-10 h-10 rounded-full mx-auto" 197 + style={{ 198 + backgroundImage: `url(${props.iconUrl})`, 199 + backgroundRepeat: "no-repeat", 200 + backgroundPosition: "center", 201 + backgroundSize: "cover", 202 + }} 203 + /> 204 + )} 205 + <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 206 + {props.publicationName} 207 + </h2> 208 + <p className="sm:text-lg text-secondary">{props.description} </p> 209 + {props.author} 210 + <div className="sm:pt-4 pt-4">{props.subscribeButton}</div> 211 + </div> 212 + ); 213 + } 214 + 215 + export function PublicationPostItem(props: { 216 + href?: string; 217 + title?: string; 218 + description?: string; 219 + date?: React.ReactNode; 220 + interactions?: React.ReactNode; 221 + }) { 222 + const content = ( 223 + <> 224 + {props.title && <h3 className="text-primary">{props.title}</h3>} 225 + <p className="italic text-secondary line-clamp-3">{props.description}</p> 226 + </> 227 + ); 228 + 229 + return ( 230 + <> 231 + <div className="flex w-full grow flex-col "> 232 + {props.href ? ( 233 + <SpeedyLink 234 + href={props.href} 235 + className="publishedPost no-underline! flex flex-col" 236 + > 237 + {content} 238 + </SpeedyLink> 239 + ) : ( 240 + <div className="publishedPost no-underline! flex flex-col"> 241 + {content} 242 + </div> 243 + )} 244 + 245 + <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 246 + <p className="text-sm text-tertiary ">{props.date} </p> 247 + {props.interactions} 248 + </div> 249 + </div> 250 + <hr className="last:hidden border-border-light" /> 251 + </> 252 + ); 253 + }
+2 -2
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 11 11 return ( 12 12 <div 13 13 ref={props.showPageBackground ? null : ref} 14 - className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 14 + className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto px-0 py-2" : "w-full overflow-y-scroll"}`} 15 15 > 16 16 <div 17 17 ref={!props.showPageBackground ? null : ref} 18 - className={`pub max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 18 + className={`pubContent sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 19 19 > 20 20 {props.children} 21 21 </div>
+37 -25
app/lish/[did]/[publication]/UpgradeModal.tsx
··· 3 3 import { useState } from "react"; 4 4 import { createCheckoutSession } from "actions/createCheckoutSession"; 5 5 import { DotLoader } from "components/utils/DotLoader"; 6 + import { ToggleGroup } from "components/ToggleGroup"; 6 7 7 8 export const UpgradeContent = () => { 8 9 let [cadence, setCadence] = useState<"year" | "month">("year"); ··· 37 38 </div> 38 39 <div className="sm:w-64 w-full accent-container flex justify-center items-center"> 39 40 <div className="flex flex-col justify-center text-center py-6"> 40 - <div className="flex gap-2 mb-3 p-1 bg-accent-contrast rounded-lg text-sm"> 41 - <button 42 - className={`px-1 rounded-md ${cadence === "year" ? "bg-bg-page font-bold text-accent-contrast" : "bg-transparent text-bg-page"}`} 43 - onClick={() => setCadence("year")} 44 - > 45 - Yearly 46 - </button> 47 - <button 48 - className={`px-1 rounded-md ${cadence === "month" ? "bg-bg-page font-bold text-accent-contrast" : "bg-transparent text-bg-page"}`} 49 - onClick={() => setCadence("month")} 50 - > 51 - Monthly 52 - </button> 53 - </div> 41 + <ToggleGroup 42 + className="mb-3" 43 + value={cadence} 44 + onChange={setCadence} 45 + options={[ 46 + { value: "year", label: "Yearly" }, 47 + { value: "month", label: "Monthly" }, 48 + ]} 49 + /> 54 50 <div className="flex gap-1 items-baseline justify-center"> 55 51 <div className="text-2xl font-bold leading-tight"> 56 52 {cadence === "year" ? "$120" : "$12"} ··· 90 86 ); 91 87 }; 92 88 93 - export const InlineUpgrade = () => { 89 + export const InlineUpgradeToPro = (props: { compact?: boolean }) => { 94 90 return ( 95 - <div className="text-center p-2 accent-container text-secondary"> 96 - <UpgradeModal 97 - asChild 98 - trigger={ 99 - <ButtonPrimary compact fullWidth className="text-sm"> 100 - Upgrade to Leaflet Pro! 101 - </ButtonPrimary> 102 - } 103 - /> 104 - <div className="text-sm leading-snug text-tertiary pt-1"> 91 + <div className="text-center text-secondary"> 92 + <UpgradeToProButton fullWidth compact={props.compact} /> 93 + <div 94 + className={`${props.compact ? `text-sm` : "text-base"} leading-snug text-tertiary pt-2`} 95 + > 105 96 Analytics for all your pubs! <br /> Emails and membership coming soon. 106 97 </div> 107 98 </div> 108 99 ); 109 100 }; 101 + 102 + export const UpgradeToProButton = (props: { 103 + fullWidth?: boolean; 104 + compact?: boolean; 105 + }) => { 106 + return ( 107 + <UpgradeModal 108 + asChild 109 + trigger={ 110 + <ButtonPrimary 111 + type="button" 112 + compact={props.compact} 113 + fullWidth={props.fullWidth} 114 + className={props.compact ? `text-sm` : "text-base"} 115 + > 116 + Upgrade to Leaflet Pro! 117 + </ButtonPrimary> 118 + } 119 + /> 120 + ); 121 + };
+3 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 134 134 const tagCount = tags?.length || 0; 135 135 136 136 let interactionsAvailable = 137 - props.showComments || props.showMentions || props.showRecommends; 137 + props.showComments || 138 + (props.showMentions && props.quotesCount > 0) || 139 + props.showRecommends; 138 140 139 141 return ( 140 142 <div
+4 -3
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 41 41 did, 42 42 profile, 43 43 preferences, 44 - pubRecord, 45 - theme, 46 44 prerenderedCodeBlocks, 47 45 bskyPostData, 48 46 pollData, ··· 73 71 pageOptions={pageOptions} 74 72 footnoteSideColumn={ 75 73 !props.hasContentToRight ? ( 76 - <PublishedFootnoteSideColumn footnotes={footnotes} fullPageScroll={fullPageScroll} /> 74 + <PublishedFootnoteSideColumn 75 + footnotes={footnotes} 76 + fullPageScroll={fullPageScroll} 77 + /> 77 78 ) : undefined 78 79 } 79 80 >
+5 -3
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 69 69 return ( 70 70 <div 71 71 //The postContent class is important for QuoteHandler 72 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 72 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-0 ${className}`} 73 73 > 74 74 {blocks.map((b, index) => { 75 75 return ( ··· 162 162 ) 163 163 alignment = "text-center justify-center"; 164 164 165 + let isHeading = PubLeafletBlocksHeader.isMain(b.block); 166 + 165 167 let className = ` 166 168 postBlockWrapper 167 169 min-h-7 168 - mt-1 mb-2 170 + ${isFirst ? "mt-0" : "mt-1"} ${isLast ? "mb-3 sm:mb-4" : isHeading ? "mb-0!" : "mb-2"} 169 171 ${isList && "isListItem mb-0! "} 170 172 ${alignment} 171 173 `; ··· 390 392 return ( 391 393 // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. 392 394 <blockquote 393 - className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 395 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-3! pt-3!" : "mt-1!"}`} 394 396 {...blockProps} 395 397 > 396 398 <TextBlock
+2 -2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 124 124 {props.pubLink} 125 125 </div> 126 126 {props.postTitle && ( 127 - <h2 className="postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent"> 127 + <h1 className="postTitle text-2xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent"> 128 128 {props.postTitle} 129 - </h2> 129 + </h1> 130 130 )} 131 131 {props.postDescription ? ( 132 132 <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-2">
-2
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 1 "use client"; 2 2 3 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 - import { PublicationSettingsButton } from "./settings/PublicationSettings"; 5 4 import { ActionButton } from "components/ActionBar/ActionButton"; 6 5 import { ShareSmall } from "components/Icons/ShareSmall"; 7 6 import { Menu, MenuItem } from "components/Menu"; ··· 22 21 <> 23 22 <NewDraftActionButton publication={props.publication} /> 24 23 <PublicationShareButton /> 25 - <PublicationSettingsButton publication={props.publication} /> 26 24 </> 27 25 ); 28 26 };
+12 -1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 14 14 import { type NormalizedPublication } from "src/utils/normalizeRecords"; 15 15 import { PublicationAnalytics } from "./PublicationAnalytics"; 16 16 import { useCanSeePro } from "src/hooks/useEntitlement"; 17 + import { SettingsContent } from "./settings/SettingsContent"; 18 + import { SettingsTiny } from "components/Icons/SettingsTiny"; 17 19 18 20 export default function PublicationDashboard({ 19 21 publication, ··· 67 69 ), 68 70 controls: null, 69 71 }, 70 - Subscribers: { 72 + Subs: { 71 73 content: ( 72 74 <PublicationSubscribers 73 75 showPageBackground={!!record.theme?.showPageBackground} ··· 87 89 }, 88 90 } 89 91 : {}), 92 + Settings: { 93 + icon: <SettingsTiny />, 94 + content: ( 95 + <SettingsContent 96 + showPageBackground={!!record.theme?.showPageBackground} 97 + /> 98 + ), 99 + controls: null, 100 + }, 90 101 }} 91 102 actions={<Actions publication={publication.uri} />} 92 103 currentPage="pub"
+5 -6
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { EditTiny } from "components/Icons/EditTiny"; 4 + import { EmptyState } from "components/EmptyState"; 4 5 5 6 import { 6 7 usePublicationData, ··· 28 29 const pubRecord = useNormalizedPublicationRecord(); 29 30 if (!publication) return null; 30 31 if (!documents || documents.length === 0) 31 - return ( 32 - <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 33 - Nothing's been published yet... 34 - </div> 35 - ); 32 + return <EmptyState title="Nothing's been published yet..." />; 36 33 37 34 // Sort by publishedAt (most recent first) 38 35 const sortedDocuments = [...documents].sort((a, b) => { ··· 89 86 <div className="flex justify-between gap-2"> 90 87 <a className="hover:no-underline!" target="_blank" href={docUrl}> 91 88 <h3 className="text-primary grow leading-snug"> 92 - {doc.record.title} 89 + {doc.record.title === "" || doc.record.title === undefined 90 + ? "Untitled" 91 + : doc.record.title} 93 92 </h3> 94 93 </a> 95 94 <div className="flex justify-start align-top flex-row gap-1">
+94
app/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx
··· 1 + import { useRef } from "react"; 2 + import { Input } from "components/Input"; 3 + import { AddTiny } from "components/Icons/AddTiny"; 4 + import { DashboardContainer } from "./SettingsContent"; 5 + import { EditTiny } from "components/Icons/EditTiny"; 6 + 7 + export function GeneralSettings(props: { 8 + nameValue: string; 9 + setNameValue: (v: string) => void; 10 + descriptionValue: string; 11 + setDescriptionValue: (v: string) => void; 12 + iconPreview: string | null; 13 + setIconPreview: (v: string | null) => void; 14 + setIconFile: (f: File | null) => void; 15 + }) { 16 + let fileInputRef = useRef<HTMLInputElement>(null); 17 + 18 + return ( 19 + <DashboardContainer section="General Settings"> 20 + 21 + <div className="flex flex-col gap-2 pb-2"> 22 + <p className=" text-secondary font-bold"> 23 + Logo <span className="font-normal">(optional)</span> 24 + </p> 25 + <div className="relative w-fit"> 26 + {props.iconPreview && ( 27 + <div 28 + className="absolute top-0 -right-1 rounded-full bg-accent-1 text-accent-2 p-1 cursor-pointer" 29 + onClick={() => fileInputRef.current?.click()} 30 + > 31 + <EditTiny /> 32 + </div> 33 + )} 34 + <div 35 + className={`w-16 h-16 rounded-full flex items-center justify-center cursor-pointer ${props.iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 36 + onClick={() => fileInputRef.current?.click()} 37 + > 38 + {props.iconPreview ? ( 39 + <img 40 + src={props.iconPreview} 41 + alt="Logo preview" 42 + className="w-full h-full rounded-full object-cover" 43 + /> 44 + ) : ( 45 + <AddTiny className="text-accent-1" /> 46 + )} 47 + </div> 48 + </div> 49 + <input 50 + type="file" 51 + accept="image/*" 52 + className="hidden" 53 + ref={fileInputRef} 54 + onChange={(e) => { 55 + const file = e.target.files?.[0]; 56 + if (file) { 57 + props.setIconFile(file); 58 + const reader = new FileReader(); 59 + reader.onload = (ev) => { 60 + props.setIconPreview(ev.target?.result as string); 61 + }; 62 + reader.readAsDataURL(file); 63 + } 64 + }} 65 + /> 66 + </div> 67 + 68 + <label> 69 + <p className="text-secondary pt-3 font-bold">Publication Name</p> 70 + <Input 71 + className="input-with-border w-full text-primary max-w-prose" 72 + type="text" 73 + id="pubName" 74 + value={props.nameValue} 75 + onChange={(e) => props.setNameValue(e.currentTarget.value)} 76 + /> 77 + </label> 78 + 79 + <label> 80 + <p className="text-secondary pt-3 font-bold"> 81 + Description <span className="font-normal">(optional)</span> 82 + </p> 83 + <Input 84 + textarea 85 + className="input-with-border w-full text-primary max-w-prose" 86 + rows={3} 87 + id="pubDescription" 88 + value={props.descriptionValue} 89 + onChange={(e) => props.setDescriptionValue(e.currentTarget.value)} 90 + /> 91 + </label> 92 + </DashboardContainer> 93 + ); 94 + }
+35 -13
app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx
··· 7 7 import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 8 import { PRODUCT_DEFINITION } from "stripe/products"; 9 9 10 - export const ManageProSubscription = (props: {}) => { 10 + export const ManageProSubscription = (props: { compact?: boolean }) => { 11 11 const [loading, setLoading] = useState(false); 12 12 const [error, setError] = useState<string | null>(null); 13 13 const { identity } = useIdentityData(); ··· 29 29 setLoading(false); 30 30 } 31 31 } 32 + if (props.compact) { 33 + return ( 34 + <div className="text-secondary font-bold flex flex-col gap-1 justify-end"> 35 + <ButtonPrimary 36 + className="" 37 + compact 38 + onClick={handleManageBilling} 39 + disabled={loading} 40 + > 41 + {loading ? <DotLoader /> : "Manage Billing"} 42 + </ButtonPrimary> 43 + <div className="text-tertiary text-sm font-normal"> 44 + {subscription?.status === "canceled" 45 + ? "Your subscription has ended" 46 + : subscription?.status === "canceling" 47 + ? `Access until ${renewalDate}` 48 + : `Renews ${renewalDate}`} 49 + </div> 32 50 51 + {error && <div className="text-sm text-red-500 mt-2">{error}</div>} 52 + </div> 53 + ); 54 + } 33 55 return ( 34 56 <div> 35 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 36 - Manage Subscription 37 - </div> 38 - <div className="text-secondary text-center flex flex-col justify-center gap-2 py-2"> 57 + <div className="text-secondary text-center flex flex-col leading-snug justify-center gap-2 py-2"> 39 58 <div> 40 - You have a <br /> 41 - {PRODUCT_DEFINITION.name} subscription 42 - <div className="text-lg font-bold text-primary"> 59 + You are subscribed to 60 + <br /> 61 + <div className="text-xl font-bold text-primary pb-1"> 43 62 {PRODUCT_DEFINITION.name} 44 63 </div> 45 - {subscription?.status === "canceled" 46 - ? "Your subscription has ended" 47 - : subscription?.status === "canceling" 48 - ? `Access until ${renewalDate}` 49 - : `Renews on ${renewalDate}`} 64 + <div className="text-tertiary italic text-sm"> 65 + {subscription?.status === "canceled" 66 + ? "Your subscription has ended" 67 + : subscription?.status === "canceling" 68 + ? `Access until ${renewalDate}` 69 + : `Renews ${renewalDate}`} 70 + </div> 50 71 </div> 51 72 <ButtonPrimary 52 73 className="mx-auto" 74 + type="button" 53 75 compact 54 76 onClick={handleManageBilling} 55 77 disabled={loading}
-128
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 - import { 2 - usePublicationData, 3 - useNormalizedPublicationRecord, 4 - } from "../PublicationSWRProvider"; 5 - import { PubSettingsHeader } from "./PublicationSettings"; 6 - import { useState } from "react"; 7 - import { Toggle } from "components/Toggle"; 8 - import { updatePublication } from "app/lish/createPub/updatePublication"; 9 - import { useToaster } from "components/Toast"; 10 - import { mutate } from "swr"; 11 - 12 - export const PostOptions = (props: { 13 - backToMenu: () => void; 14 - loading: boolean; 15 - setLoading: (l: boolean) => void; 16 - }) => { 17 - let { data } = usePublicationData(); 18 - 19 - let { publication: pubData } = data || {}; 20 - const record = useNormalizedPublicationRecord(); 21 - 22 - let [showComments, setShowComments] = useState( 23 - record?.preferences?.showComments === undefined 24 - ? true 25 - : record.preferences.showComments, 26 - ); 27 - let [showMentions, setShowMentions] = useState( 28 - record?.preferences?.showMentions === undefined 29 - ? true 30 - : record.preferences.showMentions, 31 - ); 32 - let [showRecommends, setShowRecommends] = useState( 33 - record?.preferences?.showRecommends === undefined 34 - ? true 35 - : record.preferences.showRecommends, 36 - ); 37 - let [showPrevNext, setShowPrevNext] = useState( 38 - record?.preferences?.showPrevNext === undefined 39 - ? true 40 - : record.preferences.showPrevNext, 41 - ); 42 - 43 - let toast = useToaster(); 44 - return ( 45 - <form 46 - onSubmit={async (e) => { 47 - if (!pubData || !record) return; 48 - e.preventDefault(); 49 - props.setLoading(true); 50 - let data = await updatePublication({ 51 - name: record.name, 52 - uri: pubData.uri, 53 - preferences: { 54 - showInDiscover: 55 - record?.preferences?.showInDiscover === undefined 56 - ? true 57 - : record.preferences.showInDiscover, 58 - showComments: showComments, 59 - showMentions: showMentions, 60 - showPrevNext: showPrevNext, 61 - showRecommends: showRecommends, 62 - }, 63 - }); 64 - toast({ type: "success", content: <strong>Posts Updated!</strong> }); 65 - props.setLoading(false); 66 - mutate("publication-data"); 67 - }} 68 - className="text-primary flex flex-col" 69 - > 70 - <PubSettingsHeader 71 - loading={props.loading} 72 - setLoadingAction={props.setLoading} 73 - backToMenuAction={props.backToMenu} 74 - > 75 - Post Options 76 - </PubSettingsHeader> 77 - <h4 className="mb-1">Layout</h4> 78 - <Toggle 79 - toggle={showPrevNext} 80 - onToggle={() => { 81 - setShowPrevNext(!showPrevNext); 82 - }} 83 - > 84 - <div className="font-bold">Show Prev/Next Buttons</div> 85 - </Toggle> 86 - <hr className="my-2 border-border-light" /> 87 - <h4 className="mb-1">Interactions</h4> 88 - <div className="flex flex-col gap-2"> 89 - <Toggle 90 - toggle={showComments} 91 - onToggle={() => { 92 - setShowComments(!showComments); 93 - }} 94 - > 95 - <div className="font-bold">Show Comments</div> 96 - </Toggle> 97 - 98 - <Toggle 99 - toggle={showMentions} 100 - onToggle={() => { 101 - setShowMentions(!showMentions); 102 - }} 103 - > 104 - <div className="flex flex-col justify-start"> 105 - <div className="font-bold">Show Mentions</div> 106 - <div className="text-tertiary text-sm leading-tight"> 107 - Display a list of Bluesky mentions about your post 108 - </div> 109 - </div> 110 - </Toggle> 111 - 112 - <Toggle 113 - toggle={showRecommends} 114 - onToggle={() => { 115 - setShowRecommends(!showRecommends); 116 - }} 117 - > 118 - <div className="flex flex-col justify-start"> 119 - <div className="font-bold">Show Recommends</div> 120 - <div className="text-tertiary text-sm leading-tight"> 121 - Allow readers to recommend/like your post 122 - </div> 123 - </div> 124 - </Toggle> 125 - </div> 126 - </form> 127 - ); 128 - };
+83
app/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx
··· 1 + import { Toggle } from "components/Toggle"; 2 + import { DashboardContainer } from "./SettingsContent"; 3 + 4 + export function PostSettings(props: { 5 + showComments: boolean; 6 + setShowComments: (v: boolean) => void; 7 + showMentions: boolean; 8 + setShowMentions: (v: boolean) => void; 9 + showRecommends: boolean; 10 + setShowRecommends: (v: boolean) => void; 11 + showPrevNext: boolean; 12 + setShowPrevNext: (v: boolean) => void; 13 + showInDiscover: boolean; 14 + setShowInDiscover: (v: boolean) => void; 15 + }) { 16 + return ( 17 + <> 18 + <DashboardContainer section="Discovery"> 19 + <Toggle 20 + toggle={props.showInDiscover} 21 + onToggle={() => props.setShowInDiscover(!props.showInDiscover)} 22 + > 23 + <div className="flex flex-col justify-start"> 24 + <div className="font-bold text-secondary">Make Public</div> 25 + <div className="text-tertiary text-sm leading-tight"> 26 + Your posts will appear in{" "} 27 + <a href="/reader" target="_blank"> 28 + Leaflet Reader 29 + </a>{" "} 30 + and show up in search and tags. 31 + </div> 32 + </div> 33 + </Toggle> 34 + </DashboardContainer> 35 + 36 + <DashboardContainer section="Post Layout"> 37 + <Toggle 38 + toggle={props.showPrevNext} 39 + onToggle={() => props.setShowPrevNext(!props.showPrevNext)} 40 + > 41 + <div className="font-bold text-secondary"> 42 + Show Prev/Next Buttons on Post 43 + </div> 44 + </Toggle> 45 + </DashboardContainer> 46 + 47 + <DashboardContainer section="Post Interactions"> 48 + <div className="flex flex-col gap-2"> 49 + <Toggle 50 + toggle={props.showComments} 51 + onToggle={() => props.setShowComments(!props.showComments)} 52 + > 53 + <div className="font-bold text-secondary">Show Comments</div> 54 + </Toggle> 55 + 56 + <Toggle 57 + toggle={props.showMentions} 58 + onToggle={() => props.setShowMentions(!props.showMentions)} 59 + > 60 + <div className="flex flex-col justify-start"> 61 + <div className="font-bold text-secondary">Show Mentions</div> 62 + <div className="text-tertiary text-sm leading-tight"> 63 + Display a list of Bluesky mentions about your post 64 + </div> 65 + </div> 66 + </Toggle> 67 + 68 + <Toggle 69 + toggle={props.showRecommends} 70 + onToggle={() => props.setShowRecommends(!props.showRecommends)} 71 + > 72 + <div className="flex flex-col justify-start"> 73 + <div className="font-bold text-secondary">Show Recommends</div> 74 + <div className="text-tertiary text-sm leading-tight"> 75 + Allow readers to recommend/like your post 76 + </div> 77 + </div> 78 + </Toggle> 79 + </div> 80 + </DashboardContainer> 81 + </> 82 + ); 83 + }
+374
app/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { mutate } from "swr"; 5 + import { useDomainStatus } from "components/Domains/useDomainStatus"; 6 + import { 7 + getDomainAssignment, 8 + describeAssignment, 9 + } from "components/Domains/domainAssignment"; 10 + import { 11 + useIdentityData, 12 + mutateIdentityData, 13 + } from "components/IdentityProvider"; 14 + import { ButtonPrimary } from "components/Buttons"; 15 + import { 16 + usePublicationData, 17 + useNormalizedPublicationRecord, 18 + } from "../PublicationSWRProvider"; 19 + import { updatePublicationBasePath } from "app/lish/createPub/updatePublication"; 20 + import { 21 + assignDomainToPublication, 22 + removeDomainAssignment, 23 + } from "actions/domains"; 24 + import { PinTiny } from "components/Icons/PinTiny"; 25 + import { LoadingTiny } from "components/Icons/LoadingTiny"; 26 + import { UnlinkTiny } from "components/Icons/UnlinkTiny"; 27 + import { DotLoader } from "components/utils/DotLoader"; 28 + import { useToaster } from "components/Toast"; 29 + import type { CustomDomain } from "components/Domains/DomainList"; 30 + 31 + export const PubDomainSettings = () => { 32 + let { data, mutate: mutatePubData } = usePublicationData(); 33 + let { publication: pubData } = data || {}; 34 + let record = useNormalizedPublicationRecord(); 35 + let { identity, mutate: mutateIdentity } = useIdentityData(); 36 + let toaster = useToaster(); 37 + let basePath = record?.url?.replace(/^https?:\/\//, "") || ""; 38 + 39 + let pubDomains = pubData?.publication_domains || []; 40 + let pubDomainNames = new Set(pubDomains.map((d) => d.domain)); 41 + 42 + if (!pubData) return null; 43 + 44 + return ( 45 + <> 46 + <div className="flex flex-col gap-1"> 47 + <h4>This Publication&apos;s Domains</h4> 48 + <div className="text-xs text-tertiary -mb-1">DEFAULT</div> 49 + {pubDomains 50 + .filter((d) => d.domain === basePath) 51 + .map((d) => ( 52 + <PubDomainRow 53 + key={d.domain} 54 + domain={d.domain} 55 + publication_uri={pubData.uri} 56 + basePath={basePath} 57 + mutatePubData={mutatePubData} 58 + mutateIdentity={mutateIdentity} 59 + toaster={toaster} 60 + /> 61 + ))} 62 + {pubDomains.filter((d) => d.domain !== basePath).length !== 0 && ( 63 + <> 64 + <div className="text-xs text-tertiary pt-1">ALTERNATES</div> 65 + {pubDomains 66 + .filter((d) => d.domain !== basePath) 67 + .map((d) => ( 68 + <PubDomainRow 69 + key={d.domain} 70 + domain={d.domain} 71 + publication_uri={pubData.uri} 72 + basePath={basePath} 73 + mutatePubData={mutatePubData} 74 + mutateIdentity={mutateIdentity} 75 + toaster={toaster} 76 + /> 77 + ))} 78 + </> 79 + )} 80 + 81 + <hr className="my-2 border-border-light" /> 82 + <h4>Available Domains</h4> 83 + {(() => { 84 + let availableDomains = (identity?.custom_domains || []) 85 + .filter((d) => !pubDomainNames.has(d.domain)) 86 + .filter( 87 + (d) => 88 + d.publication_domains.length === 0 && 89 + d.custom_domain_routes.length === 0, 90 + ); 91 + return availableDomains.length > 0 ? ( 92 + <> 93 + {availableDomains.map((d) => ( 94 + <UnassignedDomainRow 95 + key={d.domain} 96 + domainData={d} 97 + publication_uri={pubData.uri} 98 + mutatePubData={mutatePubData} 99 + onAssigned={() => { 100 + mutateIdentity(); 101 + mutate("publication-data"); 102 + }} 103 + /> 104 + ))} 105 + <div className="text-sm text-tertiary pt-0.5"> 106 + Add new domains from your profile settings! 107 + </div> 108 + </> 109 + ) : ( 110 + <div className="text-sm text-tertiary"> 111 + <strong>No available domains!</strong> 112 + <br /> 113 + Add new domains from your profile settings! 114 + </div> 115 + ); 116 + })()} 117 + </div> 118 + </> 119 + ); 120 + }; 121 + 122 + function PubDomainRow(props: { 123 + domain: string; 124 + publication_uri: string; 125 + basePath: string; 126 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 127 + mutateIdentity: ReturnType<typeof useIdentityData>["mutate"]; 128 + toaster: ReturnType<typeof useToaster>; 129 + }) { 130 + let { pending } = useDomainStatus(props.domain); 131 + let [loading, setLoading] = useState(false); 132 + let [unlinking, setUnlinking] = useState(false); 133 + let toaster = props.toaster; 134 + 135 + return ( 136 + <div className=" opaque-container text-secondary relative w-full flex items-center justify-between px-[6px] py-1 border rounded-md border-border-light"> 137 + <span className="pr-8 truncate text-left">{props.domain}</span> 138 + <div className="flex justify-end items-center"> 139 + {pending ? ( 140 + <div className="px-1 py-0.5 flex gap-1 items-center"> 141 + <p className="w-max pl-1 font-bold text-sm text-tertiary"> 142 + pending 143 + </p> 144 + <LoadingTiny className="animate-spin text-accent-contrast" /> 145 + </div> 146 + ) : ( 147 + <> 148 + {props.basePath !== props.domain && ( 149 + <div className="flex gap-1"> 150 + {!props.domain.endsWith(".leaflet.pub") && ( 151 + <button 152 + type="button" 153 + disabled={unlinking} 154 + className="text-tertiary hover:text-accent-contrast shrink-0" 155 + onClick={async () => { 156 + setUnlinking(true); 157 + props.mutatePubData( 158 + (current) => { 159 + if (!current) return current; 160 + let pub = current.publication; 161 + if (!pub) return current; 162 + return { 163 + ...current, 164 + publication: { 165 + ...pub, 166 + publication_domains: ( 167 + pub.publication_domains || [] 168 + ).filter((d) => d.domain !== props.domain), 169 + }, 170 + }; 171 + }, 172 + { revalidate: false }, 173 + ); 174 + mutateIdentityData(props.mutateIdentity, (draft) => { 175 + let domain = draft.custom_domains.find( 176 + (d) => d.domain === props.domain, 177 + ); 178 + if (domain) { 179 + domain.publication_domains = []; 180 + } 181 + }); 182 + toaster({ 183 + content: ( 184 + <div> 185 + Unlinked <strong>{props.domain}</strong> 186 + </div> 187 + ), 188 + type: "success", 189 + }); 190 + await removeDomainAssignment({ domain: props.domain }); 191 + props.mutatePubData(); 192 + props.mutateIdentity(); 193 + setUnlinking(false); 194 + }} 195 + > 196 + {unlinking ? ( 197 + <DotLoader className="h-[16px]! text-xs" /> 198 + ) : ( 199 + <UnlinkTiny /> 200 + )} 201 + </button> 202 + )} 203 + <button 204 + type="button" 205 + disabled={loading} 206 + onClick={async () => { 207 + setLoading(true); 208 + props.mutatePubData( 209 + (current) => { 210 + if (!current) return current; 211 + let pub = current.publication; 212 + if (!pub?.record) return current; 213 + let rec = pub.record as Record<string, unknown>; 214 + if (typeof rec.url !== "string") return current; 215 + let protocol = 216 + rec.url.match(/^https?:\/\//)?.[0] || "https://"; 217 + return { 218 + ...current, 219 + publication: { 220 + ...pub, 221 + record: { ...rec, url: protocol + props.domain }, 222 + }, 223 + }; 224 + }, 225 + { revalidate: false }, 226 + ); 227 + toaster({ 228 + content: ( 229 + <div> 230 + Default domain set to <strong>{props.domain}</strong> 231 + </div> 232 + ), 233 + type: "success", 234 + }); 235 + await updatePublicationBasePath({ 236 + uri: props.publication_uri, 237 + base_path: props.domain, 238 + }); 239 + props.mutatePubData(); 240 + setLoading(false); 241 + }} 242 + className="hover:text-accent-contrast" 243 + > 244 + {loading ? ( 245 + <DotLoader className="h-[18px]! text-xs" /> 246 + ) : ( 247 + <PinTiny className="text-tertiary hover:text-accent-contrast shrink-0" /> 248 + )} 249 + </button> 250 + </div> 251 + )} 252 + </> 253 + )} 254 + </div> 255 + </div> 256 + ); 257 + } 258 + 259 + function UnassignedDomainRow(props: { 260 + domainData: CustomDomain; 261 + publication_uri: string; 262 + mutatePubData: ReturnType<typeof usePublicationData>["mutate"]; 263 + onAssigned: () => void; 264 + }) { 265 + let { pending } = useDomainStatus(props.domainData.domain); 266 + let { mutate: mutateIdentity } = useIdentityData(); 267 + let assignment = getDomainAssignment(props.domainData); 268 + let [confirming, setConfirming] = useState(false); 269 + let [loading, setLoading] = useState(false); 270 + 271 + async function doAssign() { 272 + setLoading(true); 273 + try { 274 + mutateIdentityData(mutateIdentity, (draft) => { 275 + let domain = draft.custom_domains.find( 276 + (d) => d.domain === props.domainData.domain, 277 + ); 278 + if (domain) { 279 + domain.custom_domain_routes = []; 280 + let pub = draft.publications?.find( 281 + (p) => p.uri === props.publication_uri, 282 + ); 283 + domain.publication_domains = [ 284 + { 285 + publication: props.publication_uri, 286 + domain: props.domainData.domain, 287 + identity: "", 288 + created_at: new Date().toISOString(), 289 + publications: pub ? { name: pub.name } : null, 290 + }, 291 + ]; 292 + } 293 + }); 294 + props.mutatePubData( 295 + (current) => { 296 + if (!current) return current; 297 + let pub = current.publication; 298 + if (!pub) return current; 299 + return { 300 + ...current, 301 + publication: { 302 + ...pub, 303 + publication_domains: [ 304 + ...(pub.publication_domains || []), 305 + { 306 + publication: props.publication_uri, 307 + domain: props.domainData.domain, 308 + created_at: new Date().toISOString(), 309 + identity: "", 310 + }, 311 + ], 312 + }, 313 + }; 314 + }, 315 + { revalidate: false }, 316 + ); 317 + setConfirming(false); 318 + props.onAssigned(); 319 + await assignDomainToPublication({ 320 + domain: props.domainData.domain, 321 + publication_uri: props.publication_uri, 322 + }); 323 + } finally { 324 + setLoading(false); 325 + } 326 + } 327 + 328 + return ( 329 + <div className="opaque-container text-tertiary w-full flex flex-col gap-1 px-[6px] py-1 border rounded-md border-border-light border-dashed"> 330 + <div className="flex items-center justify-between"> 331 + <span className="truncate text-left">{props.domainData.domain}</span> 332 + {pending ? ( 333 + <div className="text-tertiary animate-pulse text-sm">unverified</div> 334 + ) : confirming ? null : ( 335 + <button 336 + className="text-accent-contrast text-xs font-bold" 337 + type="button" 338 + disabled={loading} 339 + onClick={() => { 340 + if (assignment.type === "document") { 341 + setConfirming(true); 342 + } else { 343 + doAssign(); 344 + } 345 + }} 346 + > 347 + {loading ? <DotLoader className="h-[18px]! text-xs" /> : "assign"} 348 + </button> 349 + )} 350 + </div> 351 + {confirming && ( 352 + <div className="text-xs border-t border-border-light pt-1 flex flex-col gap-1"> 353 + <p className="text-secondary"> 354 + This domain is currently assigned to{" "} 355 + {describeAssignment(assignment)}. Assigning it here will remove that 356 + assignment. 357 + </p> 358 + <div className="flex gap-2 justify-end"> 359 + <button 360 + className="text-accent-contrast" 361 + onMouseDown={() => setConfirming(false)} 362 + type="button" 363 + > 364 + Cancel 365 + </button> 366 + <ButtonPrimary compact disabled={loading} onMouseDown={doAssign}> 367 + {loading ? <DotLoader /> : "Reassign"} 368 + </ButtonPrimary> 369 + </div> 370 + </div> 371 + )} 372 + </div> 373 + ); 374 + }
-166
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 1 - "use client"; 2 - 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { Popover } from "components/Popover"; 5 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 - import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 - import { useIsMobile } from "src/hooks/isMobile"; 9 - import { useState } from "react"; 10 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 - import { theme } from "tailwind.config"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { DotLoader } from "components/utils/DotLoader"; 14 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 - import { PostOptions } from "./PostOptions"; 16 - import { PublicationDomains } from "components/Domains/PublicationDomains"; 17 - import { usePublicationData } from "../PublicationSWRProvider"; 18 - 19 - type menuState = "menu" | "pub-settings" | "theme" | "post-settings" | "domains"; 20 - 21 - export function PublicationSettingsButton(props: { publication: string }) { 22 - let isMobile = useIsMobile(); 23 - let [state, setState] = useState<menuState>("menu"); 24 - let [loading, setLoading] = useState(false); 25 - 26 - return ( 27 - <Popover 28 - asChild 29 - onOpenChange={() => setState("menu")} 30 - side={isMobile ? "top" : "right"} 31 - align={isMobile ? "center" : "start"} 32 - className={`flex flex-col max-w-xs w-[1000px] ${state === "theme" && "bg-white!"} pb-0!`} 33 - arrowFill={theme.colors["border-light"]} 34 - trigger={ 35 - <ActionButton 36 - id="pub-settings-button" 37 - icon=<SettingsSmall /> 38 - label="Settings" 39 - smallOnMobile 40 - /> 41 - } 42 - > 43 - {state === "pub-settings" ? ( 44 - <EditPubForm 45 - backToMenuAction={() => setState("menu")} 46 - loading={loading} 47 - setLoadingAction={setLoading} 48 - /> 49 - ) : state === "theme" ? ( 50 - <PubThemeSetter 51 - backToMenu={() => setState("menu")} 52 - loading={loading} 53 - setLoading={setLoading} 54 - /> 55 - ) : state === "post-settings" ? ( 56 - <PostOptions 57 - backToMenu={() => setState("menu")} 58 - loading={loading} 59 - setLoading={setLoading} 60 - /> 61 - ) : state === "domains" ? ( 62 - <PublicationDomainsView backToMenu={() => setState("menu")} /> 63 - ) : ( 64 - <PubSettingsMenu 65 - state={state} 66 - setState={setState} 67 - loading={loading} 68 - setLoading={setLoading} 69 - /> 70 - )} 71 - <div className="spacer h-2 w-full" aria-hidden /> 72 - </Popover> 73 - ); 74 - } 75 - 76 - const PubSettingsMenu = (props: { 77 - state: menuState; 78 - setState: (s: menuState) => void; 79 - loading: boolean; 80 - setLoading: (l: boolean) => void; 81 - }) => { 82 - let menuItemClassName = 83 - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 84 - 85 - return ( 86 - <div className="flex flex-col gap-0.5"> 87 - <PubSettingsHeader>Settings</PubSettingsHeader> 88 - <button 89 - className={menuItemClassName} 90 - type="button" 91 - onClick={() => { 92 - props.setState("pub-settings"); 93 - }} 94 - > 95 - Publication Settings 96 - <ArrowRightTiny /> 97 - </button> 98 - <button 99 - className={menuItemClassName} 100 - type="button" 101 - onClick={() => props.setState("post-settings")} 102 - > 103 - Post Settings 104 - <ArrowRightTiny /> 105 - </button> 106 - <button 107 - className={menuItemClassName} 108 - type="button" 109 - onClick={() => props.setState("theme")} 110 - > 111 - Theme and Layout 112 - <ArrowRightTiny /> 113 - </button> 114 - <button 115 - className={menuItemClassName} 116 - type="button" 117 - onClick={() => props.setState("domains")} 118 - > 119 - Domains 120 - <ArrowRightTiny /> 121 - </button> 122 - </div> 123 - ); 124 - }; 125 - 126 - function PublicationDomainsView(props: { backToMenu: () => void }) { 127 - let { data } = usePublicationData(); 128 - let { publication: pubData } = data || {}; 129 - if (!pubData) return null; 130 - return ( 131 - <PublicationDomains 132 - backToMenu={props.backToMenu} 133 - publication_uri={pubData.uri} 134 - /> 135 - ); 136 - } 137 - 138 - export const PubSettingsHeader = (props: { 139 - backToMenuAction?: () => void; 140 - loading?: boolean; 141 - setLoadingAction?: (l: boolean) => void; 142 - children: React.ReactNode; 143 - }) => { 144 - return ( 145 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 146 - {props.children} 147 - {props.backToMenuAction && ( 148 - <div className="flex gap-2"> 149 - <button 150 - type="button" 151 - onClick={() => { 152 - props.backToMenuAction && props.backToMenuAction(); 153 - }} 154 - > 155 - <GoBackSmall className="text-accent-contrast" /> 156 - </button> 157 - {props.setLoadingAction && ( 158 - <ButtonPrimary compact type="submit"> 159 - {props.loading ? <DotLoader /> : "Update"} 160 - </ButtonPrimary> 161 - )} 162 - </div> 163 - )} 164 - </div> 165 - ); 166 - };
+373
app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useEffect, useMemo } from "react"; 4 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 5 + import { DotLoader } from "components/utils/DotLoader"; 6 + import { useToaster } from "components/Toast"; 7 + import { mutate } from "swr"; 8 + import { 9 + usePublicationData, 10 + useNormalizedPublicationRecord, 11 + } from "../PublicationSWRProvider"; 12 + import { updatePublication } from "app/lish/createPub/updatePublication"; 13 + import { PubDomainSettings } from "./PubDomainSettings"; 14 + import { GeneralSettings } from "./GeneralSettings"; 15 + import { PostSettings } from "./PostSettings"; 16 + import { ThemeSettings } from "./ThemeSettings"; 17 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 18 + import { ManageProSubscription } from "./ManageProSubscription"; 19 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 20 + import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; 21 + import { Modal } from "components/Modal"; 22 + import { Input } from "components/Input"; 23 + import { deletePublication } from "./deletePublication"; 24 + import { useRouter } from "next/navigation"; 25 + import { 26 + isOAuthSessionError, 27 + OAuthErrorMessage, 28 + } from "components/OAuthError"; 29 + 30 + type SettingsView = "all" | "theme"; 31 + 32 + export function SettingsContent(props: { showPageBackground: boolean }) { 33 + let { data } = usePublicationData(); 34 + let { publication: pubData } = data || {}; 35 + let isPro = useIsPro(); 36 + let canSeePro = useCanSeePro(); 37 + let record = useNormalizedPublicationRecord(); 38 + let [loading, setLoading] = useState(false); 39 + let toast = useToaster(); 40 + 41 + let [nameValue, setNameValue] = useState(record?.name || ""); 42 + let [descriptionValue, setDescriptionValue] = useState( 43 + record?.description || "", 44 + ); 45 + let [iconFile, setIconFile] = useState<File | null>(null); 46 + let [iconPreview, setIconPreview] = useState<string | null>(null); 47 + 48 + let [showInDiscover, setShowInDiscover] = useState( 49 + record?.preferences?.showInDiscover === undefined 50 + ? true 51 + : record.preferences.showInDiscover, 52 + ); 53 + 54 + // --- Post Settings state --- 55 + let [showComments, setShowComments] = useState( 56 + record?.preferences?.showComments === undefined 57 + ? true 58 + : record.preferences.showComments, 59 + ); 60 + let [showMentions, setShowMentions] = useState( 61 + record?.preferences?.showMentions === undefined 62 + ? true 63 + : record.preferences.showMentions, 64 + ); 65 + let [showRecommends, setShowRecommends] = useState( 66 + record?.preferences?.showRecommends === undefined 67 + ? true 68 + : record.preferences.showRecommends, 69 + ); 70 + let [showPrevNext, setShowPrevNext] = useState( 71 + record?.preferences?.showPrevNext === undefined 72 + ? true 73 + : record.preferences.showPrevNext, 74 + ); 75 + 76 + // Sync from server data 77 + useEffect(() => { 78 + if (!pubData || !pubData.record || !record) return; 79 + setNameValue(record.name); 80 + setDescriptionValue(record.description || ""); 81 + if (record.icon) 82 + setIconPreview( 83 + `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, 84 + ); 85 + }, [pubData, record]); 86 + 87 + let hasUnsavedChanges = useMemo(() => { 88 + if (!record) return false; 89 + if (nameValue !== (record.name || "")) return true; 90 + if (descriptionValue !== (record.description || "")) return true; 91 + if (iconFile !== null) return true; 92 + 93 + let savedShowInDiscover = 94 + record.preferences?.showInDiscover === undefined 95 + ? true 96 + : record.preferences.showInDiscover; 97 + if (showInDiscover !== savedShowInDiscover) return true; 98 + 99 + let savedShowComments = 100 + record.preferences?.showComments === undefined 101 + ? true 102 + : record.preferences.showComments; 103 + if (showComments !== savedShowComments) return true; 104 + 105 + let savedShowMentions = 106 + record.preferences?.showMentions === undefined 107 + ? true 108 + : record.preferences.showMentions; 109 + if (showMentions !== savedShowMentions) return true; 110 + 111 + let savedShowRecommends = 112 + record.preferences?.showRecommends === undefined 113 + ? true 114 + : record.preferences.showRecommends; 115 + if (showRecommends !== savedShowRecommends) return true; 116 + 117 + let savedShowPrevNext = 118 + record.preferences?.showPrevNext === undefined 119 + ? true 120 + : record.preferences.showPrevNext; 121 + if (showPrevNext !== savedShowPrevNext) return true; 122 + 123 + return false; 124 + }, [ 125 + record, 126 + nameValue, 127 + descriptionValue, 128 + iconFile, 129 + showInDiscover, 130 + showComments, 131 + showMentions, 132 + showRecommends, 133 + showPrevNext, 134 + ]); 135 + 136 + return ( 137 + <form 138 + className="flex flex-col w-full pb-8" 139 + onSubmit={async (e) => { 140 + e.preventDefault(); 141 + if (!pubData) return; 142 + setLoading(true); 143 + await updatePublication({ 144 + uri: pubData.uri, 145 + name: nameValue, 146 + description: descriptionValue, 147 + iconFile: iconFile, 148 + preferences: { 149 + showInDiscover, 150 + showComments, 151 + showMentions, 152 + showPrevNext, 153 + showRecommends, 154 + }, 155 + }); 156 + toast({ type: "success", content: "Settings saved!" }); 157 + setLoading(false); 158 + mutate("publication-data"); 159 + }} 160 + > 161 + <div className="flex flex-col gap-6 relative "> 162 + {/* ── General Settings ── */} 163 + <GeneralSettings 164 + nameValue={nameValue} 165 + setNameValue={setNameValue} 166 + descriptionValue={descriptionValue} 167 + setDescriptionValue={setDescriptionValue} 168 + iconPreview={iconPreview} 169 + setIconPreview={setIconPreview} 170 + setIconFile={setIconFile} 171 + /> 172 + 173 + <DashboardContainer section="Theme and Layout"> 174 + <ThemeSettings /> 175 + </DashboardContainer> 176 + 177 + {/* ── Post Settings ── */} 178 + <PostSettings 179 + showComments={showComments} 180 + setShowComments={setShowComments} 181 + showMentions={showMentions} 182 + setShowMentions={setShowMentions} 183 + showRecommends={showRecommends} 184 + setShowRecommends={setShowRecommends} 185 + showPrevNext={showPrevNext} 186 + setShowPrevNext={setShowPrevNext} 187 + showInDiscover={showInDiscover} 188 + setShowInDiscover={setShowInDiscover} 189 + /> 190 + 191 + <DashboardContainer section="Domains"> 192 + <div className="text-secondary"> 193 + <PubDomainSettings /> 194 + </div> 195 + </DashboardContainer> 196 + 197 + <DashboardContainer section="Leaflet Pro" className="pb-4"> 198 + {canSeePro && !isPro ? ( 199 + <UpgradeToProButton /> 200 + ) : ( 201 + <ManageProSubscription compact /> 202 + )} 203 + </DashboardContainer> 204 + <div className="flex flex-col gap-1"> 205 + <hr className="border-border border-2" /> 206 + <hr className="border-border border-2" /> 207 + </div> 208 + 209 + <DashboardContainer section="DANGER!" className="pb-4"> 210 + <DeletePublication /> 211 + </DashboardContainer> 212 + 213 + {hasUnsavedChanges && <SettingsFooter loading={loading} />} 214 + </div> 215 + </form> 216 + ); 217 + } 218 + 219 + function SettingsFooter(props: { loading: boolean }) { 220 + let [distanceFromBottom, setDistanceFromBottom] = useState<number>(Infinity); 221 + 222 + useEffect(() => { 223 + const scrollContainer = document.getElementById("home-content"); 224 + if (!scrollContainer) return; 225 + 226 + const handleScroll = () => { 227 + const dist = 228 + scrollContainer.scrollHeight - 229 + scrollContainer.scrollTop - 230 + scrollContainer.clientHeight; 231 + setDistanceFromBottom(dist); 232 + }; 233 + 234 + handleScroll(); 235 + scrollContainer.addEventListener("scroll", handleScroll); 236 + return () => scrollContainer.removeEventListener("scroll", handleScroll); 237 + }, []); 238 + 239 + const threshold = 100; 240 + // ratio: 1 = far from bottom (full margin), 0 = at bottom (no margin) 241 + const ratio = Math.min(distanceFromBottom / threshold, 1); 242 + const mx = ratio * 8; // 8px = mx-2 243 + 244 + return ( 245 + <div 246 + className="settingsFooter sticky bottom-0 z-10" 247 + style={{ 248 + paddingLeft: `${mx}px`, 249 + paddingRight: `${mx}px`, 250 + paddingBottom: `${mx}px`, 251 + }} 252 + > 253 + <div className="bg-[rgb(var(--accent-1))] text-accent-2 text-sm rounded-md border-border-light pr-1 pl-2 flex justify-between items-center py-1"> 254 + You have unsaved updates! 255 + <ButtonSecondary type="submit" disabled={props.loading}> 256 + {props.loading ? <DotLoader /> : "Update Pub"} 257 + </ButtonSecondary> 258 + </div> 259 + </div> 260 + ); 261 + } 262 + 263 + export const DashboardContainer = (props: { 264 + children: React.ReactNode; 265 + className?: string; 266 + section?: string; 267 + }) => { 268 + let cardBorderHidden = useCardBorderHidden(); 269 + return ( 270 + <div 271 + className={`container flex flex-col rounded-lg! gap-2 p-3 sm:px-4 ${!cardBorderHidden ? "" : "bg-transparent!"} ${props.className}`} 272 + > 273 + {props.section && ( 274 + <> 275 + <h3 className="font-bold text-primary">{props.section}</h3> 276 + <hr className="-mt-1 mb-2 border-border-light" /> 277 + </> 278 + )} 279 + {props.children} 280 + </div> 281 + ); 282 + }; 283 + 284 + let pluralize = (n: number, word: string) => 285 + `${n} ${word}${n === 1 ? "" : "s"}`; 286 + 287 + const DeletePublication = () => { 288 + let [value, setValue] = useState(""); 289 + let [deleting, setDeleting] = useState(false); 290 + let record = useNormalizedPublicationRecord(); 291 + let { data: pub } = usePublicationData(); 292 + let postCount = pub?.documents?.length ?? 0; 293 + let draftCount = pub?.drafts?.length ?? 0; 294 + let subCount = pub?.publication?.publication_subscriptions?.length ?? 0; 295 + let toaster = useToaster(); 296 + let router = useRouter(); 297 + let pubUri = pub?.publication?.uri; 298 + 299 + let onDelete = async () => { 300 + if (!pubUri || record?.name !== value || deleting) return; 301 + setDeleting(true); 302 + let result = await deletePublication(pubUri); 303 + if (!result.success) { 304 + setDeleting(false); 305 + toaster({ 306 + type: "error", 307 + content: isOAuthSessionError(result.error) ? ( 308 + <OAuthErrorMessage error={result.error} /> 309 + ) : typeof result.error === "string" ? ( 310 + result.error 311 + ) : ( 312 + "Failed to delete publication" 313 + ), 314 + }); 315 + return; 316 + } 317 + toaster({ 318 + type: "success", 319 + content: `${record?.name ?? "Publication"} deleted`, 320 + }); 321 + router.push("/home"); 322 + }; 323 + 324 + return ( 325 + <Modal 326 + asChild 327 + className="text-center" 328 + trigger={<ButtonPrimary>Delete Publication</ButtonPrimary>} 329 + title="Are you sure?" 330 + > 331 + <div className="text-secondary flex flex-col max-w-prose"> 332 + <div className="pb-3 text-left"> 333 + This will permanently delete: 334 + <ul className="list-disc pl-5 pt-1"> 335 + <li>This publication and its settings</li> 336 + <li> 337 + {pluralize(postCount, "published post")} 338 + {postCount > 0 ? " (removed from your PDS)" : ""} 339 + </li> 340 + <li>{pluralize(draftCount, "draft")}</li> 341 + <li>All associated records on your PDS</li> 342 + </ul> 343 + {subCount > 0 && ( 344 + <div className="pt-2"> 345 + {pluralize(subCount, "subscriber")} will lose access. 346 + </div> 347 + )} 348 + <div className="pt-2 font-bold text-primary"> 349 + This cannot be undone. 350 + </div> 351 + </div> 352 + <div className="font-bold pb-1"> 353 + Enter the name of this publication to confirm 354 + </div> 355 + 356 + <Input 357 + className="input-with-border w-full mb-3 text-primary max-w-prose" 358 + placeholder={record?.name ? record.name : "Publication Name"} 359 + type="text" 360 + value={value} 361 + onChange={(e) => setValue(e.currentTarget.value)} 362 + /> 363 + <ButtonPrimary 364 + className="mx-auto mb-1" 365 + disabled={record?.name !== value || deleting || !pubUri} 366 + onClick={onDelete} 367 + > 368 + {deleting ? <DotLoader /> : "Delete Publication"} 369 + </ButtonPrimary> 370 + </div> 371 + </Modal> 372 + ); 373 + };
+21
app/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx
··· 1 + "use client"; 2 + 3 + import { GoToArrow } from "components/Icons/GoToArrow"; 4 + import { SpeedyLink } from "components/SpeedyLink"; 5 + import { useParams } from "next/navigation"; 6 + 7 + export function ThemeSettings() { 8 + let params = useParams<{ did: string; publication: string }>(); 9 + let href = `/lish/${params.did}/${params.publication}/theme-settings`; 10 + 11 + return ( 12 + <> 13 + <SpeedyLink 14 + className="text-left flex gap-2 items-center text-accent-contrast font-bold no-underline! w-fit" 15 + href={href} 16 + > 17 + Customize Theme <GoToArrow /> 18 + </SpeedyLink> 19 + </> 20 + ); 21 + }
+124
app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 + import { AtUri } from "@atproto/syntax"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { drizzle } from "drizzle-orm/node-postgres"; 9 + import { 10 + entities, 11 + permission_tokens, 12 + permission_token_rights, 13 + } from "drizzle/schema"; 14 + import { eq, inArray } from "drizzle-orm"; 15 + import { pool } from "supabase/pool"; 16 + import { revalidatePath } from "next/cache"; 17 + 18 + export async function deletePublication( 19 + publication_uri: string, 20 + ): Promise< 21 + { success: true } | { success: false; error: string | OAuthSessionError } 22 + > { 23 + let identity = await getIdentityData(); 24 + if (!identity || !identity.atp_did) { 25 + return { success: false, error: "Not authenticated" }; 26 + } 27 + 28 + let pubUri = new AtUri(publication_uri); 29 + if (pubUri.host !== identity.atp_did) { 30 + return { success: false, error: "Not authorized" }; 31 + } 32 + 33 + const sessionResult = await restoreOAuthSession(identity.atp_did); 34 + if (!sessionResult.ok) { 35 + return { success: false, error: sessionResult.error }; 36 + } 37 + let credentialSession = sessionResult.value; 38 + let agent = new AtpBaseClient( 39 + credentialSession.fetchHandler.bind(credentialSession), 40 + ); 41 + 42 + // Collect these BEFORE deleting the publication row — cascading deletes would remove the join rows. 43 + let [docs, drafts] = await Promise.all([ 44 + supabaseServerClient 45 + .from("documents_in_publications") 46 + .select("document") 47 + .eq("publication", publication_uri), 48 + supabaseServerClient 49 + .from("leaflets_in_publications") 50 + .select("leaflet") 51 + .eq("publication", publication_uri), 52 + ]); 53 + let documentUris = Array.from( 54 + new Set([...(docs.data ?? []).map((r) => r.document)]), 55 + ); 56 + let draftTokenIds = (drafts.data ?? []).map((r) => r.leaflet); 57 + 58 + let pdsDeletes = Promise.all([ 59 + ...documentUris.flatMap((docUri) => { 60 + let u = new AtUri(docUri); 61 + if (u.host !== credentialSession.did) return []; 62 + return [ 63 + agent.pub.leaflet.document 64 + .delete({ repo: credentialSession.did, rkey: u.rkey }) 65 + .catch(() => {}), 66 + agent.site.standard.document 67 + .delete({ repo: credentialSession.did, rkey: u.rkey }) 68 + .catch(() => {}), 69 + ]; 70 + }), 71 + agent.pub.leaflet.publication 72 + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) 73 + .catch(() => {}), 74 + agent.site.standard.publication 75 + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) 76 + .catch(() => {}), 77 + ]); 78 + 79 + let draftDeletes = async () => { 80 + if (draftTokenIds.length === 0) return; 81 + const client = await pool.connect(); 82 + try { 83 + const db = drizzle(client); 84 + await db.transaction(async (tx) => { 85 + let tokens = await tx 86 + .select() 87 + .from(permission_tokens) 88 + .leftJoin( 89 + permission_token_rights, 90 + eq(permission_tokens.id, permission_token_rights.token), 91 + ) 92 + .where(inArray(permission_tokens.id, draftTokenIds)); 93 + let entitySets = tokens 94 + .map((t) => t.permission_token_rights?.entity_set) 95 + .filter((s): s is string => !!s); 96 + if (entitySets.length > 0) { 97 + await tx.delete(entities).where(inArray(entities.set, entitySets)); 98 + } 99 + await tx 100 + .delete(permission_tokens) 101 + .where(inArray(permission_tokens.id, draftTokenIds)); 102 + }); 103 + } finally { 104 + client.release(); 105 + } 106 + }; 107 + 108 + await Promise.all([pdsDeletes, draftDeletes()]); 109 + 110 + // Delete document rows before publication rows — publication cascade would leave orphaned docs. 111 + if (documentUris.length > 0) { 112 + await supabaseServerClient 113 + .from("documents") 114 + .delete() 115 + .in("uri", documentUris); 116 + } 117 + await supabaseServerClient 118 + .from("publications") 119 + .delete() 120 + .eq("uri", publication_uri); 121 + 122 + revalidatePath("/lish/[did]/[publication]", "layout"); 123 + return { success: true }; 124 + }
+24 -166
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 2 import { 4 3 getPublicationURL, 5 4 getDocumentURL, 6 5 } from "app/lish/createPub/getPublicationURL"; 7 6 import { BskyAgent } from "@atproto/api"; 8 7 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 9 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 10 8 import React from "react"; 9 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 10 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 11 + import { PublicationContent } from "./PublicationContent"; 11 12 import { 12 - PublicationBackgroundProvider, 13 13 PublicationThemeProvider, 14 + PublicationBackgroundProvider, 14 15 } from "components/ThemeManager/PublicationThemeProvider"; 15 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 - import { SpeedyLink } from "components/SpeedyLink"; 17 - import { InteractionPreview } from "components/InteractionsPreview"; 18 - import { LocalizedDate } from "./LocalizedDate"; 19 - import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 - import { PublicationAuthor } from "./PublicationAuthor"; 21 - import { Separator } from "components/Layout"; 22 - import { 23 - normalizePublicationRecord, 24 - normalizeDocumentRecord, 25 - } from "src/utils/normalizeRecords"; 26 - import { getFirstParagraph } from "src/utils/getFirstParagraph"; 27 - import { FontLoader } from "components/FontLoader"; 28 16 29 17 export default async function Publication(props: { 30 18 params: Promise<{ publication: string; did: string }>; 31 19 }) { 32 20 let params = await props.params; 33 - let did = decodeURIComponent(params.did); 21 + const did = decodeURIComponent(params.did); 34 22 if (!did) return <PubNotFound />; 35 - let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 36 - let publication_name = decodeURIComponent(params.publication); 37 - let [{ data: publications }, { data: profile }] = await Promise.all([ 23 + const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 24 + const publication_name = decodeURIComponent(params.publication); 25 + const [{ data: publications }, { data: profile }] = await Promise.all([ 38 26 supabaseServerClient 39 27 .from("publications") 40 28 .select( ··· 54 42 .limit(1), 55 43 agent.getProfile({ actor: did }), 56 44 ]); 57 - let publication = publications?.[0]; 45 + const publication = publications?.[0]; 58 46 59 47 const record = normalizePublicationRecord(publication?.record); 60 48 61 - let showPageBackground = record?.theme?.showPageBackground; 49 + const showPageBackground = record?.theme?.showPageBackground; 62 50 63 51 if (!publication) return <PubNotFound />; 64 52 try { 65 53 return ( 66 - <> 67 - <FontLoader 68 - headingFontId={record?.theme?.headingFont} 69 - bodyFontId={record?.theme?.bodyFont} 70 - /> 71 - <PublicationThemeProvider 54 + <PublicationThemeProvider 55 + theme={record?.theme} 56 + pub_creator={publication.identity_did} 57 + > 58 + <PublicationBackgroundProvider 72 59 theme={record?.theme} 73 60 pub_creator={publication.identity_did} 74 61 > 75 - <PublicationBackgroundProvider 76 - theme={record?.theme} 77 - pub_creator={publication.identity_did} 78 - > 79 - <PublicationHomeLayout 80 - uri={publication.uri} 81 - showPageBackground={!!showPageBackground} 82 - > 83 - <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 84 - {record?.icon && ( 85 - <div 86 - className="shrink-0 w-10 h-10 rounded-full mx-auto" 87 - style={{ 88 - backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 89 - backgroundRepeat: "no-repeat", 90 - backgroundPosition: "center", 91 - backgroundSize: "cover", 92 - }} 93 - /> 94 - )} 95 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 96 - {publication.name} 97 - </h2> 98 - <p className="sm:text-lg text-secondary"> 99 - {record?.description}{" "} 100 - </p> 101 - {profile && ( 102 - <PublicationAuthor 103 - did={profile.did} 104 - displayName={profile.displayName} 105 - handle={profile.handle} 106 - /> 107 - )} 108 - <div className="sm:pt-4 pt-4"> 109 - <SubscribeWithBluesky 110 - base_url={getPublicationURL(publication)} 111 - pubName={publication.name} 112 - pub_uri={publication.uri} 113 - subscribers={publication.publication_subscriptions} 114 - /> 115 - </div> 116 - </div> 117 - <div className="publicationPostList w-full flex flex-col gap-4"> 118 - {publication.documents_in_publications 119 - .filter((d) => !!d?.documents) 120 - .sort((a, b) => { 121 - const aRecord = normalizeDocumentRecord(a.documents?.data); 122 - const bRecord = normalizeDocumentRecord(b.documents?.data); 123 - const aDate = aRecord?.publishedAt 124 - ? new Date(aRecord.publishedAt) 125 - : new Date(0); 126 - const bDate = bRecord?.publishedAt 127 - ? new Date(bRecord.publishedAt) 128 - : new Date(0); 129 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 130 - }) 131 - .map((doc) => { 132 - if (!doc.documents) return null; 133 - const doc_record = normalizeDocumentRecord( 134 - doc.documents.data, 135 - ); 136 - if (!doc_record) return null; 137 - let uri = new AtUri(doc.documents.uri); 138 - let quotes = 139 - doc.documents.document_mentions_in_bsky[0].count || 0; 140 - let comments = 141 - record?.preferences?.showComments === false 142 - ? 0 143 - : doc.documents.comments_on_documents[0].count || 0; 144 - let recommends = 145 - doc.documents.recommends_on_documents?.[0]?.count || 0; 146 - let tags = doc_record.tags || []; 147 - 148 - const docUrl = getDocumentURL( 149 - doc_record, 150 - doc.documents.uri, 151 - publication, 152 - ); 153 - return ( 154 - <React.Fragment key={doc.documents?.uri}> 155 - <div className="flex w-full grow flex-col "> 156 - <SpeedyLink 157 - href={docUrl} 158 - className="publishedPost no-underline! flex flex-col" 159 - > 160 - {doc_record.title && ( 161 - <h3 className="text-primary"> 162 - {doc_record.title} 163 - </h3> 164 - )} 165 - <p className="italic text-secondary line-clamp-3"> 166 - {doc_record.description || 167 - getFirstParagraph(doc_record)} 168 - </p> 169 - </SpeedyLink> 170 - 171 - <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 172 - <p className="text-sm text-tertiary "> 173 - {doc_record.publishedAt && ( 174 - <LocalizedDate 175 - dateString={doc_record.publishedAt} 176 - options={{ 177 - year: "numeric", 178 - month: "long", 179 - day: "2-digit", 180 - }} 181 - /> 182 - )}{" "} 183 - </p> 184 - 185 - <InteractionPreview 186 - quotesCount={quotes} 187 - commentsCount={comments} 188 - recommendsCount={recommends} 189 - documentUri={doc.documents.uri} 190 - tags={tags} 191 - postUrl={docUrl} 192 - showComments={ 193 - record?.preferences?.showComments !== false 194 - } 195 - showMentions={ 196 - record?.preferences?.showMentions !== false 197 - } 198 - showRecommends={ 199 - record?.preferences?.showRecommends !== false 200 - } 201 - /> 202 - </div> 203 - </div> 204 - <hr className="last:hidden border-border-light" /> 205 - </React.Fragment> 206 - ); 207 - })} 208 - </div> 209 - </PublicationHomeLayout> 210 - </PublicationBackgroundProvider> 211 - </PublicationThemeProvider> 212 - </> 62 + <PublicationContent 63 + record={record} 64 + publication={publication} 65 + did={did} 66 + profile={profile} 67 + showPageBackground={showPageBackground} 68 + /> 69 + </PublicationBackgroundProvider> 70 + </PublicationThemeProvider> 213 71 ); 214 72 } catch (e) { 215 73 console.log(e);
+150
app/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 1 + "use client"; 2 + 3 + import { LinearDocumentPage } from "app/lish/[did]/[publication]/[rkey]/LinearDocumentPage"; 4 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 5 + import { 6 + DocumentProvider, 7 + type PublicationContext, 8 + } from "contexts/DocumentContext"; 9 + import { useIdentityData } from "components/IdentityProvider"; 10 + import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import type { PubLeafletPagesLinearDocument } from "lexicons/api"; 12 + import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 13 + import type { DocumentContextValue } from "contexts/DocumentContext"; 14 + import { 15 + usePublicationData, 16 + useNormalizedPublicationRecord, 17 + } from "../dashboard/PublicationSWRProvider"; 18 + import { fakeBlocks, fakePage } from "./postPreviewFakeBlocks"; 19 + 20 + const FAKE_DID = "did:plc:fake-preview-user"; 21 + const FAKE_DOC_URI = 22 + "at://did:plc:fake-preview-user/site.standard.document/preview"; 23 + 24 + const fakeNormalizedDocument = { 25 + $type: "site.standard.document" as const, 26 + title: "Building your Dream Theme!", 27 + description: "A short description of this preview post.", 28 + publishedAt: new Date().toISOString(), 29 + site: "at://did:plc:fake-preview-user/site.standard.publication/preview", 30 + tags: ["preview", "theme"], 31 + }; 32 + 33 + function makeFakeDocument( 34 + publication?: { 35 + uri: string; 36 + name: string; 37 + identity_did: string; 38 + record: unknown; 39 + } | null, 40 + ): NonNullable<PostPageData> { 41 + return { 42 + data: {}, 43 + uri: FAKE_DOC_URI, 44 + normalizedDocument: fakeNormalizedDocument, 45 + normalizedPublication: null, 46 + quotesAndMentions: [], 47 + theme: null, 48 + prevNext: undefined, 49 + publication: publication || null, 50 + comments: [], 51 + comments_on_documents: [], 52 + mentions: [], 53 + document_mentions_in_bsky: [], 54 + leaflets_in_publications: [], 55 + leafletId: null, 56 + recommendsCount: 0, 57 + documents_in_publications: publication 58 + ? [{ publications: publication }] 59 + : [], 60 + recommends_on_documents: [], 61 + } as unknown as NonNullable<PostPageData>; 62 + } 63 + 64 + export function PostPreview(props: { 65 + showPageBackground: boolean; 66 + pageWidth: number; 67 + }) { 68 + let { identity } = useIdentityData(); 69 + let { data } = usePublicationData(); 70 + let { publication } = data || {}; 71 + let record = useNormalizedPublicationRecord(); 72 + let preferences = record?.preferences; 73 + let profileRecord = identity?.bsky_profiles 74 + ?.record as unknown as ProfileViewDetailed; 75 + 76 + let profile = profileRecord ?? { 77 + did: FAKE_DID, 78 + handle: "preview.bsky.social", 79 + displayName: "Preview Author", 80 + }; 81 + 82 + let pubInfo: PublicationContext = publication 83 + ? { 84 + uri: publication.uri, 85 + name: publication.name, 86 + identity_did: publication.identity_did, 87 + record: publication.record as NonNullable<PublicationContext>["record"], 88 + publication_subscriptions: ( 89 + publication.publication_subscriptions || [] 90 + ).map((s) => ({ 91 + created_at: s.created_at, 92 + identity: s.identity, 93 + publication: s.publication, 94 + record: s.record, 95 + uri: s.uri, 96 + })), 97 + } 98 + : null; 99 + 100 + let fakeDocument = makeFakeDocument(pubInfo); 101 + 102 + let fakeDocumentContextValue: DocumentContextValue = { 103 + uri: FAKE_DOC_URI, 104 + normalizedDocument: fakeNormalizedDocument, 105 + normalizedPublication: null, 106 + theme: undefined, 107 + prevNext: undefined, 108 + quotesAndMentions: [], 109 + publication: pubInfo, 110 + comments: [], 111 + mentions: [], 112 + leafletId: null, 113 + recommendsCount: 0, 114 + }; 115 + 116 + return ( 117 + <DocumentProvider value={fakeDocumentContextValue}> 118 + <LeafletContentProvider 119 + value={{ 120 + pages: [ 121 + fakePage as PubLeafletPagesLinearDocument.Main & { 122 + $type: string; 123 + }, 124 + ], 125 + }} 126 + > 127 + <div className="w-fit h-full py-2 sm:py-6"> 128 + <LinearDocumentPage 129 + document={fakeDocument} 130 + did={profile.did || FAKE_DID} 131 + profile={profile as ProfileViewDetailed} 132 + preferences={{ 133 + showComments: preferences?.showComments, 134 + showMentions: preferences?.showMentions, 135 + showRecommends: preferences?.showRecommends, 136 + showPrevNext: preferences?.showPrevNext, 137 + }} 138 + prerenderedCodeBlocks={new Map()} 139 + bskyPostData={[]} 140 + pollData={[]} 141 + document_uri={FAKE_DOC_URI} 142 + fullPageScroll={!props.showPageBackground} 143 + hasPageBackground={props.showPageBackground} 144 + blocks={fakeBlocks} 145 + /> 146 + </div> 147 + </LeafletContentProvider> 148 + </DocumentProvider> 149 + ); 150 + }
+99
app/lish/[did]/[publication]/theme-settings/PubPreview.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + usePublicationData, 5 + useNormalizedPublicationRecord, 6 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 7 + import { useIdentityData } from "components/IdentityProvider"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { PublicationContent } from "../PublicationContent"; 10 + import { LocalizedDate } from "../LocalizedDate"; 11 + 12 + export function PubPreview(props: { 13 + showPageBackground: boolean; 14 + pageWidth: number; 15 + }) { 16 + let { data } = usePublicationData(); 17 + let { publication } = data || {}; 18 + let { identity } = useIdentityData(); 19 + let record = useNormalizedPublicationRecord(); 20 + 21 + let profileRecord = identity?.bsky_profiles 22 + ?.record as unknown as ProfileViewDetailed; 23 + 24 + let did = publication?.identity_did || ""; 25 + 26 + let profile = identity?.bsky_profiles 27 + ? { 28 + did: identity.bsky_profiles.did, 29 + displayName: profileRecord?.displayName, 30 + handle: identity.bsky_profiles.handle || "", 31 + } 32 + : undefined; 33 + 34 + if (!publication) return null; 35 + 36 + const hasPosts = publication.documents_in_publications.some( 37 + (d) => !!d?.documents, 38 + ); 39 + const today = new Date(); 40 + const yesterday = new Date(today); 41 + yesterday.setDate(today.getDate() - 1); 42 + const dayBefore = new Date(today); 43 + dayBefore.setDate(today.getDate() - 2); 44 + 45 + const dateOptions: Intl.DateTimeFormatOptions = { 46 + year: "numeric", 47 + month: "long", 48 + day: "2-digit", 49 + }; 50 + 51 + const fakePosts = !hasPosts 52 + ? undefined 53 + : [ 54 + { 55 + title: "Your Personal Antheme", 56 + description: 57 + "Welcome to the Publication Theme Setter. This is how posts will appear in your publication", 58 + date: ( 59 + <LocalizedDate 60 + dateString={today.toISOString()} 61 + options={dateOptions} 62 + /> 63 + ), 64 + }, 65 + { 66 + title: "The Theme of the Crop", 67 + description: 68 + "This is the place to make your publication look and feel like home. It looks great!", 69 + date: ( 70 + <LocalizedDate 71 + dateString={yesterday.toISOString()} 72 + options={dateOptions} 73 + /> 74 + ), 75 + }, 76 + { 77 + title: "Reams and Reams of Colorful Themes!", 78 + description: 79 + "So happy to have you. There's so much cool stuff happening here, including this publication :)", 80 + date: ( 81 + <LocalizedDate 82 + dateString={dayBefore.toISOString()} 83 + options={dateOptions} 84 + /> 85 + ), 86 + }, 87 + ]; 88 + 89 + return ( 90 + <PublicationContent 91 + record={record} 92 + publication={publication} 93 + did={did} 94 + profile={profile} 95 + showPageBackground={props.showPageBackground} 96 + fakePosts={fakePosts} 97 + /> 98 + ); 99 + }
+277
app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useRef, useState } from "react"; 4 + import { useParams, useRouter } from "next/navigation"; 5 + import { 6 + BaseThemeProvider, 7 + CardBorderHiddenContext, 8 + } from "components/ThemeManager/ThemeProvider"; 9 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 + import { DotLoader } from "components/utils/DotLoader"; 11 + import { ToggleGroup } from "components/ToggleGroup"; 12 + import { PaintSmall } from "components/Icons/PaintSmall"; 13 + import { Popover } from "components/Popover"; 14 + import { 15 + usePubThemeEditorState, 16 + PubThemePickerPanel, 17 + } from "components/ThemeManager/PubThemeSetter"; 18 + import { PubPreview } from "./PubPreview"; 19 + import { PostPreview } from "./PostPreview"; 20 + import { PublicationBackgroundProvider } from "components/ThemeManager/PublicationThemeProvider"; 21 + import { 22 + usePublicationData, 23 + useNormalizedPublicationRecord, 24 + } from "../dashboard/PublicationSWRProvider"; 25 + import { Separator } from "components/Layout"; 26 + import { GoToArrow } from "components/Icons/GoToArrow"; 27 + import Link from "next/link"; 28 + 29 + export function ThemeSettingsContent() { 30 + let toolbarRef = useRef<HTMLDivElement>(null); 31 + let [previewMode, setPreviewMode] = useState<"post" | "pub">("post"); 32 + let params = useParams<{ did: string; publication: string }>(); 33 + let { data } = usePublicationData(); 34 + let { publication } = data || {}; 35 + let record = useNormalizedPublicationRecord(); 36 + let state = usePubThemeEditorState(); 37 + let { 38 + localPubTheme, 39 + headingFont, 40 + bodyFont, 41 + image, 42 + pageWidth, 43 + pubBGImage, 44 + leafletBGRepeat, 45 + showPageBackground, 46 + changes, 47 + } = state; 48 + 49 + let settingsHref = `/lish/${params.did}/${params.publication}/dashboard?tab=Settings`; 50 + 51 + let hasUnsavedChanges = 52 + changes || 53 + headingFont !== record?.theme?.headingFont || 54 + bodyFont !== record?.theme?.bodyFont || 55 + pageWidth !== (record?.theme?.pageWidth || 624) || 56 + showPageBackground !== !!record?.theme?.showPageBackground; 57 + 58 + return ( 59 + <CardBorderHiddenContext.Provider value={!showPageBackground}> 60 + <BaseThemeProvider 61 + local 62 + {...localPubTheme} 63 + headingFontId={headingFont} 64 + bodyFontId={bodyFont} 65 + hasBackgroundImage={!!image} 66 + pageWidth={pageWidth} 67 + > 68 + <div className="w-full h-screen flex flex-col overflow-hidden"> 69 + {/* Theme Setter Panel */} 70 + <div 71 + ref={toolbarRef} 72 + className="themeSetterControls bg-accent-1 items-center -mb-2 pb-[10px] pt-2 " 73 + > 74 + <div className=" sm:w-[var(--page-width-units)] mx-auto flex justify-between items-center px-3 sm:px-4 "> 75 + <BackToPubButton 76 + hasUnsavedChanges={hasUnsavedChanges} 77 + settingsHref={settingsHref} 78 + localPubTheme={localPubTheme} 79 + headingFont={headingFont} 80 + bodyFont={bodyFont} 81 + image={image} 82 + pageWidth={pageWidth} 83 + /> 84 + <div className="flex gap-1 items-center "> 85 + <ToggleGroup 86 + value={previewMode} 87 + optionClassName="text-base! py-1! px-2!" 88 + onChange={setPreviewMode} 89 + options={[ 90 + { value: "post", label: "Post" }, 91 + { value: "pub", label: "Pub" }, 92 + ]} 93 + /> 94 + <Separator classname="h-8! mr-1" /> 95 + <PubThemePopover state={state} toolbarRef={toolbarRef} /> 96 + </div> 97 + </div> 98 + </div> 99 + 100 + {/* Full-page Preview */} 101 + <PublicationBackgroundProvider 102 + className="rounded-t-lg grow! min-h-0 overflow-y-auto" 103 + theme={record?.theme} 104 + pub_creator={publication?.identity_did || ""} 105 + localBgImage={pubBGImage} 106 + localBgImageRepeat={leafletBGRepeat} 107 + > 108 + <div 109 + className="mx-auto h-full w-fit" 110 + onClickCapture={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + > 115 + {previewMode === "pub" ? ( 116 + <PubPreview 117 + showPageBackground={showPageBackground} 118 + pageWidth={pageWidth} 119 + /> 120 + ) : ( 121 + <PostPreview 122 + showPageBackground={showPageBackground} 123 + pageWidth={pageWidth} 124 + /> 125 + )} 126 + </div> 127 + </PublicationBackgroundProvider> 128 + </div> 129 + </BaseThemeProvider> 130 + </CardBorderHiddenContext.Provider> 131 + ); 132 + } 133 + 134 + const BackToPubButton = (props: { 135 + hasUnsavedChanges: boolean; 136 + settingsHref: string; 137 + localPubTheme: ReturnType<typeof usePubThemeEditorState>["localPubTheme"]; 138 + headingFont: ReturnType<typeof usePubThemeEditorState>["headingFont"]; 139 + bodyFont: ReturnType<typeof usePubThemeEditorState>["bodyFont"]; 140 + image: ReturnType<typeof usePubThemeEditorState>["image"]; 141 + pageWidth: ReturnType<typeof usePubThemeEditorState>["pageWidth"]; 142 + }) => { 143 + let router = useRouter(); 144 + let [open, setOpen] = useState(false); 145 + if (props.hasUnsavedChanges) 146 + return ( 147 + <Popover 148 + open={open} 149 + onOpenChange={setOpen} 150 + align="start" 151 + side="bottom" 152 + className="w-76 !p-0" 153 + trigger={ 154 + <div className="flex gap-2 items-center font-bold text-accent-2"> 155 + <GoToArrow className="rotate-180 text-accent-2" /> 156 + Back <span className="sm:block hidden">To Settings</span> 157 + </div> 158 + } 159 + > 160 + <BaseThemeProvider 161 + local 162 + {...props.localPubTheme} 163 + headingFontId={props.headingFont} 164 + bodyFontId={props.bodyFont} 165 + hasBackgroundImage={!!props.image} 166 + pageWidth={props.pageWidth} 167 + > 168 + <div className="flex flex-col p-3"> 169 + <h4 className="text-primary">Discard unsaved changes?</h4> 170 + <p className="text-sm text-tertiary"> 171 + You have unsaved changes to your theme. Leaving the page will lose 172 + your edits! 173 + </p> 174 + <div className="flex gap-4 w-full pt-3"> 175 + <ButtonPrimary 176 + className="shrink-0" 177 + onClick={() => router.push(props.settingsHref)} 178 + > 179 + Discard and Leave 180 + </ButtonPrimary> 181 + 182 + <button 183 + className="text-accent-contrast font-bold" 184 + onClick={() => setOpen(false)} 185 + > 186 + Nevermind 187 + </button> 188 + </div> 189 + </div> 190 + </BaseThemeProvider> 191 + </Popover> 192 + ); 193 + else 194 + return ( 195 + <Link className="no-underline!" href={props.settingsHref}> 196 + <div className="flex gap-2 text-accent-2 items-center font-bold"> 197 + <GoToArrow className="rotate-180 text-accent-2" /> 198 + Back <span className="sm:block hidden">To Settings</span> 199 + </div> 200 + </Link> 201 + ); 202 + }; 203 + 204 + const PubThemePopover = ({ 205 + state, 206 + toolbarRef, 207 + }: { 208 + state: ReturnType<typeof usePubThemeEditorState>; 209 + toolbarRef: React.RefObject<HTMLDivElement | null>; 210 + }) => { 211 + let { 212 + localPubTheme, 213 + headingFont, 214 + bodyFont, 215 + image, 216 + pageWidth, 217 + submitTheme, 218 + toaster, 219 + } = state; 220 + let [loading, setLoading] = useState(false); 221 + 222 + return ( 223 + <Popover 224 + defaultOpen 225 + align="start" 226 + side="bottom" 227 + arrowFill="white" 228 + border="#CCCCCC" 229 + className="sm:w-sm w-[1000px] rounded-lg! !p-0 bg-white! border-[#CCCCCC]!" 230 + trigger={ 231 + <button 232 + type="button" 233 + className="w-8 h-8 rounded-md bg-accent-1 text-accent-2 data-[state=open]:bg-accent-2 data-[state=open]:text-accent-1 flex items-center justify-center transition-colors cursor-pointer " 234 + > 235 + <PaintSmall /> 236 + </button> 237 + } 238 + asChild 239 + > 240 + <BaseThemeProvider 241 + local 242 + {...localPubTheme} 243 + headingFontId={headingFont} 244 + bodyFontId={bodyFont} 245 + hasBackgroundImage={!!image} 246 + pageWidth={pageWidth} 247 + > 248 + <div className="flex flex-col overflow-y-auto max-h-(--radix-popover-content-available-height) py-3"> 249 + {/* Toggle + Save Header */} 250 + 251 + <div className="p-3 pt-0 "> 252 + <ButtonPrimary 253 + fullWidth 254 + type="button" 255 + disabled={loading} 256 + onClick={async () => { 257 + let result = await submitTheme(setLoading); 258 + if (result?.success) { 259 + toaster({ 260 + content: "Theme saved!", 261 + type: "success", 262 + }); 263 + } 264 + }} 265 + > 266 + {loading ? <DotLoader /> : "Save Changes"} 267 + </ButtonPrimary> 268 + </div> 269 + 270 + <div className="px-3 gap-2 flex flex-col"> 271 + <PubThemePickerPanel state={state} /> 272 + </div> 273 + </div> 274 + </BaseThemeProvider> 275 + </Popover> 276 + ); 277 + };
+62
app/lish/[did]/[publication]/theme-settings/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 4 + import { PublicationSWRDataProvider } from "../dashboard/PublicationSWRProvider"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 7 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 8 + import { ThemeSettingsContent } from "./ThemeSettingsContent"; 9 + 10 + export default async function ThemeSettingsPage(props: { 11 + params: Promise<{ publication: string; did: string }>; 12 + }) { 13 + let params = await props.params; 14 + let identity = await getIdentityData(); 15 + if (!identity || !identity.atp_did) 16 + return ( 17 + <NotFoundLayout> 18 + <p>Looks like you&apos;re not logged in.</p> 19 + <p> 20 + If the issue persists please{" "} 21 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 22 + </p> 23 + </NotFoundLayout> 24 + ); 25 + let did = decodeURIComponent(params.did); 26 + if (!did) return <ThemeNotFound />; 27 + let { result: publication_data } = await get_publication_data.handler( 28 + { 29 + did, 30 + publication_name: decodeURIComponent(params.publication), 31 + }, 32 + { supabase: supabaseServerClient }, 33 + ); 34 + let { publication } = publication_data; 35 + const record = normalizePublicationRecord(publication?.record); 36 + 37 + if (!publication || identity.atp_did !== publication.identity_did || !record) 38 + return <ThemeNotFound />; 39 + let uri = new AtUri(publication.uri); 40 + 41 + return ( 42 + <PublicationSWRDataProvider 43 + publication_did={did} 44 + publication_rkey={uri.rkey} 45 + publication_data={publication_data} 46 + > 47 + <ThemeSettingsContent /> 48 + </PublicationSWRDataProvider> 49 + ); 50 + } 51 + 52 + const ThemeNotFound = () => { 53 + return ( 54 + <NotFoundLayout> 55 + <p className="font-bold">Sorry, we can&apos;t find this publication!</p> 56 + <p> 57 + This may be a glitch on our end. If the issue persists please{" "} 58 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 59 + </p> 60 + </NotFoundLayout> 61 + ); 62 + };
+353
app/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts
··· 1 + import type { PubLeafletPagesLinearDocument } from "lexicons/api"; 2 + 3 + export const fakeBlocks: PubLeafletPagesLinearDocument.Block[] = [ 4 + { 5 + $type: "pub.leaflet.pages.linearDocument#block", 6 + block: { 7 + $type: "pub.leaflet.blocks.text", 8 + plaintext: "Welcome to Leaflet, intrepid writer!", 9 + }, 10 + }, 11 + { 12 + $type: "pub.leaflet.pages.linearDocument#block", 13 + block: { 14 + $type: "pub.leaflet.blocks.text", 15 + plaintext: 16 + "This is a place to write, blog, journal, and above all, express oneself. As such we take theming very seriously. Read on to discover how to make your wildest themes come true!", 17 + }, 18 + }, 19 + { 20 + $type: "pub.leaflet.pages.linearDocument#block", 21 + block: { 22 + $type: "pub.leaflet.blocks.text", 23 + plaintext: 'And now, for a horizontal rule (also known as a "divider")!', 24 + }, 25 + }, 26 + { 27 + $type: "pub.leaflet.pages.linearDocument#block", 28 + block: { 29 + $type: "pub.leaflet.blocks.horizontalRule", 30 + }, 31 + }, 32 + { 33 + $type: "pub.leaflet.pages.linearDocument#block", 34 + block: { 35 + $type: "pub.leaflet.blocks.header", 36 + level: 2, 37 + plaintext: "TLDR", 38 + }, 39 + }, 40 + { 41 + $type: "pub.leaflet.pages.linearDocument#block", 42 + block: { 43 + $type: "pub.leaflet.blocks.text", 44 + plaintext: 45 + "We have some great presets available! Go ahead and apply one of those, and mess with the accent color to make it yours. Just keep in mind", 46 + }, 47 + }, 48 + { 49 + $type: "pub.leaflet.pages.linearDocument#block", 50 + block: { 51 + $type: "pub.leaflet.blocks.unorderedList", 52 + children: [ 53 + { 54 + $type: "pub.leaflet.blocks.unorderedList#listItem", 55 + content: { 56 + $type: "pub.leaflet.blocks.text", 57 + plaintext: "Go for a nice bright accent ", 58 + }, 59 + }, 60 + { 61 + $type: "pub.leaflet.blocks.unorderedList#listItem", 62 + content: { 63 + $type: "pub.leaflet.blocks.text", 64 + plaintext: 65 + "Make sure the text on accent is still legible. White or black if you're not sure!", 66 + }, 67 + }, 68 + ], 69 + }, 70 + }, 71 + { 72 + $type: "pub.leaflet.pages.linearDocument#block", 73 + block: { 74 + $type: "pub.leaflet.blocks.horizontalRule", 75 + }, 76 + }, 77 + { 78 + $type: "pub.leaflet.pages.linearDocument#block", 79 + block: { 80 + $type: "pub.leaflet.blocks.header", 81 + level: 2, 82 + plaintext: "Your Text", 83 + }, 84 + }, 85 + { 86 + $type: "pub.leaflet.pages.linearDocument#block", 87 + block: { 88 + $type: "pub.leaflet.blocks.header", 89 + level: 3, 90 + plaintext: "Text", 91 + }, 92 + }, 93 + { 94 + $type: "pub.leaflet.pages.linearDocument#block", 95 + block: { 96 + $type: "pub.leaflet.blocks.text", 97 + plaintext: "This is your default text color. ", 98 + }, 99 + }, 100 + { 101 + $type: "pub.leaflet.pages.linearDocument#block", 102 + block: { 103 + $type: "pub.leaflet.blocks.text", 104 + plaintext: 105 + "We also mix it with your background color to make lighter text or border colors!", 106 + }, 107 + }, 108 + { 109 + $type: "pub.leaflet.pages.linearDocument#block", 110 + block: { 111 + $type: "pub.leaflet.blocks.text", 112 + plaintext: 113 + "No need to think too hard, black or white is good! Just make sure it shows up strong against your background color. ", 114 + }, 115 + }, 116 + { 117 + $type: "pub.leaflet.pages.linearDocument#block", 118 + block: { 119 + $type: "pub.leaflet.blocks.header", 120 + level: 3, 121 + plaintext: "Accent Colors ", 122 + }, 123 + }, 124 + { 125 + $type: "pub.leaflet.pages.linearDocument#block", 126 + block: { 127 + $type: "pub.leaflet.blocks.text", 128 + facets: [ 129 + { 130 + index: { 131 + byteEnd: 32, 132 + byteStart: 20, 133 + }, 134 + features: [ 135 + { 136 + uri: "https://leaflet.pub/about", 137 + $type: "pub.leaflet.richtext.facet#link", 138 + }, 139 + ], 140 + }, 141 + ], 142 + plaintext: 143 + "We use this in your inline links, and in certain block types like...", 144 + }, 145 + }, 146 + { 147 + $type: "pub.leaflet.pages.linearDocument#block", 148 + block: { 149 + url: "https://www.leaflet.pub/about", 150 + text: "Buttons!", 151 + $type: "pub.leaflet.blocks.button", 152 + }, 153 + }, 154 + { 155 + $type: "pub.leaflet.pages.linearDocument#block", 156 + block: { 157 + $type: "pub.leaflet.blocks.text", 158 + plaintext: "Pick something... ", 159 + }, 160 + }, 161 + { 162 + $type: "pub.leaflet.pages.linearDocument#block", 163 + block: { 164 + $type: "pub.leaflet.blocks.unorderedList", 165 + children: [ 166 + { 167 + $type: "pub.leaflet.blocks.unorderedList#listItem", 168 + content: { 169 + $type: "pub.leaflet.blocks.text", 170 + plaintext: "nice and bright", 171 + }, 172 + }, 173 + { 174 + $type: "pub.leaflet.blocks.unorderedList#listItem", 175 + content: { 176 + $type: "pub.leaflet.blocks.text", 177 + plaintext: 178 + "with legible text on accent (white usually works, but sometimes black shows up better) ", 179 + }, 180 + }, 181 + { 182 + $type: "pub.leaflet.blocks.unorderedList#listItem", 183 + content: { 184 + $type: "pub.leaflet.blocks.text", 185 + plaintext: "that shows off your personality!", 186 + }, 187 + }, 188 + ], 189 + }, 190 + }, 191 + { 192 + $type: "pub.leaflet.pages.linearDocument#block", 193 + block: { 194 + $type: "pub.leaflet.blocks.horizontalRule", 195 + }, 196 + }, 197 + { 198 + $type: "pub.leaflet.pages.linearDocument#block", 199 + block: { 200 + $type: "pub.leaflet.blocks.header", 201 + level: 2, 202 + plaintext: "Your Background", 203 + }, 204 + }, 205 + { 206 + $type: "pub.leaflet.pages.linearDocument#block", 207 + block: { 208 + $type: "pub.leaflet.blocks.header", 209 + level: 3, 210 + plaintext: "Background", 211 + }, 212 + }, 213 + { 214 + $type: "pub.leaflet.pages.linearDocument#block", 215 + block: { 216 + $type: "pub.leaflet.blocks.text", 217 + plaintext: "This is your background color. It can also be an image!", 218 + }, 219 + }, 220 + { 221 + $type: "pub.leaflet.pages.linearDocument#block", 222 + block: { 223 + $type: "pub.leaflet.blocks.text", 224 + plaintext: "If you decide to go for a solid background color...", 225 + }, 226 + }, 227 + { 228 + $type: "pub.leaflet.pages.linearDocument#block", 229 + block: { 230 + $type: "pub.leaflet.blocks.unorderedList", 231 + children: [ 232 + { 233 + $type: "pub.leaflet.blocks.unorderedList#listItem", 234 + content: { 235 + $type: "pub.leaflet.blocks.text", 236 + plaintext: "pick one that isn't too vibrant", 237 + }, 238 + }, 239 + { 240 + $type: "pub.leaflet.blocks.unorderedList#listItem", 241 + content: { 242 + $type: "pub.leaflet.blocks.text", 243 + plaintext: "go either dark or light, not in the middle", 244 + }, 245 + }, 246 + { 247 + $type: "pub.leaflet.blocks.unorderedList#listItem", 248 + content: { 249 + $type: "pub.leaflet.blocks.text", 250 + plaintext: 251 + "try a very dark or very light version of your accent color", 252 + }, 253 + }, 254 + ], 255 + }, 256 + }, 257 + { 258 + $type: "pub.leaflet.pages.linearDocument#block", 259 + block: { 260 + $type: "pub.leaflet.blocks.text", 261 + plaintext: 262 + "If you go for an image, it's easy to overwhelm a reader with too much, so pick one that's... ", 263 + }, 264 + }, 265 + { 266 + $type: "pub.leaflet.pages.linearDocument#block", 267 + block: { 268 + $type: "pub.leaflet.blocks.unorderedList", 269 + children: [ 270 + { 271 + $type: "pub.leaflet.blocks.unorderedList#listItem", 272 + content: { 273 + $type: "pub.leaflet.blocks.text", 274 + plaintext: 275 + 'not distracting. ie, not colorful or busy. You want something that could be described as "kinda boring". It\'ll still give a lot of personality once you apply it!', 276 + }, 277 + }, 278 + { 279 + $type: "pub.leaflet.blocks.unorderedList#listItem", 280 + content: { 281 + $type: "pub.leaflet.blocks.text", 282 + plaintext: "Gradients are a classy classic", 283 + }, 284 + }, 285 + ], 286 + }, 287 + }, 288 + { 289 + $type: "pub.leaflet.pages.linearDocument#block", 290 + block: { 291 + $type: "pub.leaflet.blocks.header", 292 + level: 3, 293 + plaintext: "Page or Container", 294 + }, 295 + }, 296 + { 297 + $type: "pub.leaflet.pages.linearDocument#block", 298 + block: { 299 + $type: "pub.leaflet.blocks.text", 300 + plaintext: "You can choose to have a page background or not. ", 301 + }, 302 + }, 303 + { 304 + $type: "pub.leaflet.pages.linearDocument#block", 305 + block: { 306 + $type: "pub.leaflet.blocks.text", 307 + plaintext: 308 + "Page background puts a box around your writing. It's especially useful if you have a background image making your text harder to read. It's also another place to inject a color to give your writing some zuzsh.", 309 + }, 310 + }, 311 + { 312 + $type: "pub.leaflet.pages.linearDocument#block", 313 + block: { 314 + $type: "pub.leaflet.blocks.text", 315 + plaintext: "No page background looks clean and minimal. It's sup to you!", 316 + }, 317 + }, 318 + { 319 + $type: "pub.leaflet.pages.linearDocument#block", 320 + block: { 321 + $type: "pub.leaflet.blocks.horizontalRule", 322 + }, 323 + }, 324 + { 325 + $type: "pub.leaflet.pages.linearDocument#block", 326 + block: { 327 + $type: "pub.leaflet.blocks.header", 328 + level: 2, 329 + plaintext: "Stuck?", 330 + }, 331 + }, 332 + { 333 + $type: "pub.leaflet.pages.linearDocument#block", 334 + block: { 335 + $type: "pub.leaflet.blocks.text", 336 + plaintext: 337 + "If you're really stuck, try one of our preset themes! Change just the accent color to give it a more personal flair. ", 338 + }, 339 + }, 340 + { 341 + $type: "pub.leaflet.pages.linearDocument#block", 342 + block: { 343 + $type: "pub.leaflet.blocks.text", 344 + plaintext: "Good luck!", 345 + }, 346 + }, 347 + ]; 348 + 349 + export const fakePage: PubLeafletPagesLinearDocument.Main = { 350 + $type: "pub.leaflet.pages.linearDocument", 351 + id: "preview-page", 352 + blocks: fakeBlocks, 353 + };
+3 -3
app/lish/createPub/CreatePubForm.tsx
··· 149 149 onChange={(e) => setShowInDiscover(e.target.checked)} 150 150 > 151 151 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 152 - <p className="font-bold italic">Show In Discover</p> 152 + <p className="font-bold italic">Make Public</p> 153 153 <p className="text-sm text-tertiary font-normal"> 154 154 Your posts will appear in{" "} 155 155 <a href="/reader" target="_blank"> 156 156 Leaflet Reader 157 - </a> 158 - . You can change this at any time! 157 + </a>{" "} 158 + and show up in search and tags. You can change this at any time! 159 159 </p> 160 160 </div> 161 161 </Checkbox>
+5 -13
app/lish/createPub/UpdatePubForm.tsx
··· 10 10 import { mutate } from "swr"; 11 11 import { AddTiny } from "components/Icons/AddTiny"; 12 12 import { useToaster } from "components/Toast"; 13 - import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 14 13 import { Toggle } from "components/Toggle"; 15 14 16 15 export const EditPubForm = (props: { ··· 85 84 mutate("publication-data"); 86 85 }} 87 86 > 88 - <PubSettingsHeader 89 - loading={props.loading} 90 - setLoadingAction={props.setLoadingAction} 91 - backToMenuAction={props.backToMenuAction} 92 - > 93 - General Settings 94 - </PubSettingsHeader> 87 + 95 88 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2 overflow-y-auto min-h-0"> 96 89 <div className="flex items-center justify-between gap-2 mt-2 "> 97 90 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> ··· 165 158 onToggle={() => setShowInDiscover(!showInDiscover)} 166 159 > 167 160 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 168 - <p className="font-bold italic">Show In Discover</p> 169 - <p className="text-xs text-tertiary font-normal"> 161 + <p className="font-bold italic">Make Public</p> 162 + <p className="text-sm text-tertiary font-normal"> 170 163 Your posts will appear in{" "} 171 164 <a href="/reader" target="_blank"> 172 165 Leaflet Reader 173 - </a> 174 - . You can change this at any time! 166 + </a>{" "} 167 + and show up in search and tags. You can change this at any time! 175 168 </p> 176 169 </div> 177 170 </Toggle> ··· 179 172 </form> 180 173 ); 181 174 }; 182 -
+1 -1
app/lish/createPub/page.tsx
··· 9 9 return ( 10 10 <div className="createPubPage relative w-full h-full flex items-stretch bg-bg-leaflet p-4"> 11 11 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 12 - <div className="container w-full p-3 justify-items-center text-center"> 12 + <div className="frosted-container w-full p-3 justify-items-center text-center"> 13 13 <LoginForm 14 14 text="Log in to create a publication!" 15 15 noEmail
+4 -2
components/ActionBar/ProfileButton.tsx
··· 9 9 import { SpeedyLink } from "components/SpeedyLink"; 10 10 import { Popover } from "components/Popover"; 11 11 import { Modal } from "components/Modal"; 12 - import { InlineUpgrade } from "app/lish/[did]/[publication]/UpgradeModal"; 12 + import { InlineUpgradeToPro } from "app/lish/[did]/[publication]/UpgradeModal"; 13 13 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 14 14 import { ManageDomains } from "components/Domains/ManageDomains"; 15 15 import { WebSmall } from "components/Icons/WebSmall"; ··· 98 98 {" "} 99 99 <hr className="border-border-light border-dashed" /> 100 100 <div className="py-2"> 101 - <InlineUpgrade /> 101 + <div className="p-2 accent-container"> 102 + <InlineUpgradeToPro compact /> 103 + </div> 102 104 </div> 103 105 </> 104 106 )}
+2 -4
components/Domains/PublicationDomains.tsx
··· 17 17 assignDomainToPublication, 18 18 removeDomainAssignment, 19 19 } from "actions/domains"; 20 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 20 + 21 21 import { AddDomainForm } from "./AddDomainForm"; 22 22 import { DomainSettingsView } from "./DomainSettingsView"; 23 23 import { PinTiny } from "components/Icons/PinTiny"; ··· 44 44 45 45 return ( 46 46 <div> 47 - <PubSettingsHeader backToMenuAction={props.backToMenu}> 48 - Domains 49 - </PubSettingsHeader> 47 + <h4>Domains</h4> 50 48 <div className="flex flex-col gap-1 py-1"> 51 49 <h4 className="">This Publication's Domains</h4> 52 50 <div className="text-xs text-tertiary -mb-1 ">DEFAULT</div>
+30
components/EmptyState.tsx
··· 1 + export function EmptyState({ 2 + title, 3 + description, 4 + className, 5 + container = "frosted", 6 + children, 7 + }: { 8 + title?: string; 9 + description?: string; 10 + className?: string; 11 + container?: "frosted" | "opaque" | "none"; 12 + children?: React.ReactNode; 13 + }) { 14 + const containerClass = 15 + container === "frosted" 16 + ? "frosted-container" 17 + : container === "opaque" 18 + ? "opaque-container" 19 + : ""; 20 + 21 + return ( 22 + <div 23 + className={`w-full text-sm flex flex-col gap-1 italic text-tertiary text-center sm:p-4 p-3 ${containerClass} ${className ?? ""}`} 24 + > 25 + {title && <div className="text-base font-bold">{title}</div>} 26 + {description && <span>{description}</span>} 27 + {children} 28 + </div> 29 + ); 30 + }
+19
components/Icons/PasteTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const PasteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M8.78343 1.30539C9.48078 0.593592 10.6233 0.581819 11.3352 1.27902L14.4504 4.33175C15.2886 5.15308 15.3029 6.49837 14.4817 7.33664L9.31175 12.614C8.71235 13.2257 7.75963 13.28 7.09593 12.7732L6.21214 13.6755C5.97056 13.9219 5.57486 13.9258 5.32835 13.6843L5.15843 13.5173L2.62229 15.1384C2.37716 15.2947 2.05558 15.2608 1.84788 15.0573L1.09886 14.323C0.891251 14.1194 0.851748 13.7997 1.00315 13.5515L2.56956 10.9812L2.39964 10.8152C2.15307 10.5736 2.14929 10.1779 2.39085 9.93136L3.33714 8.96457C2.92145 8.17051 3.04042 7.16777 3.7014 6.49289L8.78343 1.30539ZM2.32835 13.778L2.36839 13.8171L4.24241 12.6198L3.48558 11.8796L2.32835 13.778ZM3.72093 10.3591L5.75706 12.3542L6.19358 11.9079L4.15647 9.91378L3.72093 10.3591ZM10.4612 2.17257C10.2424 1.95827 9.89031 1.96164 9.67601 2.18039L4.59397 7.36691C4.25597 7.71196 4.26193 8.266 4.60667 8.60421L7.81272 11.7448C7.98168 11.9103 8.25367 11.9079 8.41917 11.739L13.5891 6.46164C13.927 6.11657 13.9211 5.56251 13.5764 5.22433L10.4612 2.17257ZM8.07249 4.61203C8.31404 4.3658 8.70981 4.36189 8.95628 4.60324C9.20284 4.84479 9.2066 5.24046 8.96507 5.48703L6.89183 7.60324C6.65032 7.84974 6.25462 7.85438 6.00804 7.613C5.76156 7.37143 5.75771 6.97575 5.99925 6.72921L8.07249 4.61203ZM9.43284 3.22824C9.67693 2.98484 10.0718 2.98449 10.3157 3.22824C10.5597 3.47232 10.5597 3.86893 10.3157 4.113L10.1438 4.28488C9.89974 4.52863 9.504 4.52771 9.25999 4.2839C9.01624 4.03983 9.01609 3.6441 9.25999 3.40011L9.43284 3.22824Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+21
components/Icons/SettingsTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const SettingsTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M9.29339 0.710621C8.52319 0.574818 7.85522 1.07422 7.63679 1.73718C7.59815 1.85444 7.46828 1.97645 7.25941 2.00195C7.22787 2.0058 7.19635 2.0099 7.16486 2.01424C6.95459 2.04326 6.79691 1.95831 6.72935 1.8552C6.34672 1.27127 5.57156 0.961744 4.86275 1.29386C4.47573 1.4752 4.10709 1.68899 3.76034 1.93166C3.11992 2.37986 3.00031 3.20494 3.31462 3.82814C3.37006 3.93806 3.36467 4.11652 3.23453 4.28317C3.21513 4.30802 3.19591 4.33303 3.17688 4.35819C3.04902 4.52728 2.87761 4.57875 2.75677 4.55359C2.07344 4.41128 1.30647 4.74013 1.0396 5.47589C0.896085 5.87156 0.785336 6.28291 0.710626 6.70658C0.574805 7.4768 1.07423 8.14481 1.7372 8.36325C1.85449 8.40189 1.97652 8.53179 2.00201 8.74071C2.00586 8.77227 2.00996 8.80382 2.01431 8.83533C2.04332 9.04561 1.95837 9.20328 1.85525 9.27085C1.27131 9.6535 0.961775 10.4287 1.29392 11.1375C1.47528 11.5245 1.68908 11.8932 1.93177 12.2399C2.37995 12.8803 3.20496 12.9999 3.82811 12.6856C3.938 12.6302 4.11641 12.6356 4.28302 12.7657C4.308 12.7852 4.33314 12.8045 4.35844 12.8236C4.52747 12.9514 4.57891 13.1228 4.55376 13.2435C4.41147 13.9268 4.7403 14.6937 5.476 14.9605C5.87177 15.1041 6.28322 15.2148 6.707 15.2895C7.4771 15.4253 8.14499 14.926 8.3634 14.2631C8.40202 14.1459 8.53184 14.0239 8.74066 13.9984C8.77245 13.9945 8.8042 13.9904 8.83593 13.986C9.04607 13.957 9.20363 14.0419 9.27113 14.1449C9.65372 14.7288 10.4288 15.0383 11.1375 14.7062C11.5246 14.5248 11.8934 14.3109 12.2402 14.0681C12.8805 13.6199 13.0001 12.7951 12.6858 12.172C12.6304 12.0622 12.6358 11.8838 12.7659 11.7173C12.7854 11.6922 12.8048 11.667 12.8241 11.6416C12.9518 11.4726 13.123 11.4212 13.2437 11.4463C13.9269 11.5886 14.6937 11.2598 14.9605 10.5242C15.1041 10.1284 15.2148 9.71681 15.2896 9.29293C15.4253 8.52287 14.926 7.85503 14.2631 7.63664C14.146 7.59804 14.024 7.46823 13.9985 7.25939C13.9946 7.22765 13.9905 7.19593 13.9861 7.16424C13.9571 6.95411 14.042 6.79657 14.145 6.72907C14.7288 6.34649 15.0383 5.57146 14.7062 4.86277C14.5248 4.47562 14.311 4.10688 14.0682 3.76003C13.62 3.11972 12.7951 3.00014 12.172 3.31441C12.0621 3.36981 11.8837 3.36444 11.7172 3.23436C11.6922 3.21487 11.6671 3.19558 11.6418 3.17647C11.4728 3.04869 11.4213 2.87736 11.4465 2.75661C11.5888 2.07337 11.26 1.30651 10.5243 1.03966C10.1286 0.89611 9.71715 0.785339 9.29339 0.710621ZM8.82401 2.12834C8.87493 1.97377 8.99885 1.92797 9.07634 1.94163C9.42822 2.00368 9.76968 2.09562 10.0981 2.21474C10.1715 2.24136 10.2559 2.34245 10.2228 2.50176C10.0817 3.1792 10.4066 3.80969 10.888 4.1736C10.9081 4.18876 10.928 4.20408 10.9478 4.21956C11.4237 4.59115 12.1169 4.74213 12.7349 4.43049C12.8796 4.35748 12.9991 4.41254 13.0441 4.4768C13.246 4.76523 13.4237 5.07165 13.5743 5.39313C13.6073 5.4636 13.5958 5.59451 13.4599 5.68354C12.8813 6.06271 12.6654 6.73809 12.7479 7.33525C12.7513 7.36043 12.7546 7.38564 12.7577 7.41088C12.831 8.011 13.2151 8.60744 13.872 8.82386C14.0264 8.87475 14.0722 8.99856 14.0585 9.07596C13.9965 9.42794 13.9045 9.7695 13.7854 10.098C13.7588 10.1713 13.6578 10.2557 13.4986 10.2226C12.8212 10.0815 12.1908 10.4065 11.8269 10.8878C11.8117 10.9079 11.7963 10.928 11.7807 10.9479C11.4091 11.4237 11.2581 12.117 11.5697 12.7349C11.6427 12.8796 11.5877 12.9991 11.5234 13.044C11.235 13.2459 10.9286 13.4236 10.6071 13.5743C10.5366 13.6073 10.4057 13.5957 10.3167 13.4598C9.9375 12.8812 9.26215 12.6653 8.665 12.7478C8.63978 12.7513 8.61454 12.7545 8.58927 12.7576C7.98906 12.8309 7.39261 13.215 7.17618 13.8719C7.12528 14.0264 7.00143 14.0722 6.924 14.0585C6.5721 13.9965 6.23062 13.9046 5.9022 13.7854C5.82879 13.7588 5.74432 13.6577 5.7775 13.4984C5.91858 12.821 5.59364 12.1905 5.11232 11.8266C5.09223 11.8114 5.07227 11.796 5.05243 11.7805C4.57656 11.4089 3.88319 11.2578 3.26521 11.5695C3.12043 11.6425 3.00086 11.5875 2.95587 11.5232C2.75405 11.2348 2.57641 10.9285 2.42582 10.6071C2.39274 10.5365 2.40437 10.4055 2.54036 10.3164C3.11906 9.93717 3.33498 9.26173 3.25258 8.6645C3.24913 8.63946 3.24587 8.61438 3.24281 8.58928C3.16955 7.98901 2.78533 7.39248 2.12837 7.17603C1.97378 7.12509 1.92797 7.00116 1.94163 6.92366C2.00367 6.57185 2.0956 6.23045 2.21469 5.90211C2.24134 5.82864 2.34251 5.74414 2.50192 5.77733C3.17942 5.91843 3.80997 5.59345 4.17394 5.11209C4.18905 5.09212 4.2043 5.07226 4.21971 5.05253C4.59136 4.57663 4.74239 3.88324 4.4307 3.26524C4.35766 3.12041 4.41274 3.00079 4.47706 2.95578C4.76541 2.75398 5.07174 2.57635 5.39312 2.42577C5.46368 2.3927 5.59472 2.40433 5.68382 2.54031C6.06304 3.11901 6.73849 3.33492 7.33573 3.25251C7.36075 3.24906 7.38581 3.2458 7.41089 3.24274C8.01112 3.16946 8.60757 2.78525 8.82401 2.12834ZM5.72699 7.99998C5.72699 6.73322 6.7311 5.72705 7.94439 5.72705C8.3561 5.72705 8.74037 5.84149 9.07046 6.04146C9.36568 6.22032 9.75 6.12599 9.92886 5.83076C10.1077 5.53554 10.0134 5.15122 9.71816 4.97236C9.19924 4.65798 8.59193 4.47705 7.94439 4.47705C6.01805 4.47705 4.47699 6.06578 4.47699 7.99998C4.47699 9.93419 6.01805 11.5229 7.94439 11.5229C9.21716 11.5229 10.3242 10.8266 10.9257 9.79966C11.1002 9.50182 11.0002 9.11894 10.7024 8.94447C10.4045 8.77 10.0216 8.87001 9.84717 9.16785C9.45648 9.83481 8.74748 10.2729 7.94439 10.2729C6.7311 10.2729 5.72699 9.26674 5.72699 7.99998ZM11.4284 7.27829C11.298 6.95872 10.9331 6.80541 10.6136 6.93587C10.3018 7.06316 10.1483 7.41354 10.2621 7.72732L10.2621 7.73332C10.2621 8.0785 10.542 8.35832 10.8871 8.35832C11.2323 8.35832 11.5121 8.0785 11.5121 7.73332L11.5122 7.72295C11.5123 7.67271 11.5124 7.58581 11.497 7.49896C11.4797 7.4014 11.4454 7.31911 11.4317 7.28615L11.4284 7.27829Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+1 -1
components/Modal.tsx
··· 41 41 ${className}`} 42 42 > 43 43 {title ? ( 44 - <Dialog.Title> 44 + <Dialog.Title asChild> 45 45 <h3 className="pb-1">{title}</h3> 46 46 </Dialog.Title> 47 47 ) : (
+1 -3
components/PageHeader.tsx
··· 21 21 } 22 22 }, []); 23 23 24 - let headerBGColor = !cardBorderHidden 25 - ? "var(--bg-leaflet)" 26 - : "var(--bg-page)"; 24 + let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 27 25 28 26 return ( 29 27 <div
+4 -2
components/PageLayouts/DashboardLayout.tsx
··· 132 132 [name: string]: { 133 133 content: React.ReactNode; 134 134 controls: React.ReactNode; 135 + icon?: React.ReactNode; 135 136 }; 136 137 }, 137 138 >(props: { ··· 186 187 </div> 187 188 </MediaContents> 188 189 <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 + 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-6 sm:pl-8 sm:pr-4 `} 190 191 ref={ref} 191 192 id="home-content" 192 193 > ··· 206 207 <Tab 207 208 key={t} 208 209 name={t} 210 + icon={props.tabs[t].icon} 209 211 selected={t === tab} 210 212 onSelect={() => setTabWithUrl(t)} 211 213 onMouseEnter={() => props.onTabHover?.(t)} ··· 215 217 })} 216 218 </div> 217 219 )} 218 - {props.publication && ( 220 + {props.publication && controls && ( 219 221 <button 220 222 className={`sm:hidden block text-tertiary`} 221 223 onClick={() => {
+1 -1
components/Pages/Page.tsx
··· 116 116 // this div wraps the contents AND the page options. 117 117 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions 118 118 <div 119 - className={`pageWrapper relative shrink-0 ${props.fullPageScroll ? "w-full" : "w-max"}`} 119 + className={`pageWrapper relative shrink-0 h-full ${props.fullPageScroll ? "w-full" : "w-max"}`} 120 120 > 121 121 {/* 122 122 this div is the scrolling container that wraps only the contents div.
+1 -1
components/Pages/PublicationMetadata.tsx
··· 81 81 } 82 82 postTitle={ 83 83 <TextField 84 - className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 84 + className="leading-tight pt-0.5 text-2xl font-bold outline-hidden bg-transparent" 85 85 value={title} 86 86 onChange={async (newTitle) => { 87 87 await rep?.mutate.updatePublicationDraft({
+6 -2
components/Popover/index.tsx
··· 16 16 border?: string; 17 17 className?: string; 18 18 open?: boolean; 19 + defaultOpen?: boolean; 19 20 onOpenChange?: (open: boolean) => void; 20 21 onOpenAutoFocus?: (e: Event) => void; 21 22 asChild?: boolean; 22 23 arrowFill?: string; 23 24 noArrow?: boolean; 25 + onInteractOutside?: (e: Event) => void; 24 26 }) => { 25 - let [open, setOpen] = useState(props.open || false); 27 + let [open, setOpen] = useState(props.open || props.defaultOpen || false); 26 28 useEffect(() => { 27 29 if (props.open !== undefined) setOpen(props.open); 28 30 }, [props.open]); 29 31 return ( 30 32 <RadixPopover.Root 33 + defaultOpen={props.defaultOpen} 31 34 open={props.open} 32 35 onOpenChange={(o) => { 33 36 setOpen(o); ··· 44 47 className={` 45 48 z-20 relative bg-bg-page 46 49 text-primary 47 - flex flex-col overflow-hidden 50 + flex flex-col 48 51 px-3 py-2 49 52 max-w-(--radix-popover-content-available-width) 50 53 max-h-(--radix-popover-content-available-height) ··· 56 59 sideOffset={props.sideOffset ? props.sideOffset : 4} 57 60 collisionPadding={16} 58 61 onOpenAutoFocus={props.onOpenAutoFocus} 62 + onInteractOutside={props.onInteractOutside} 59 63 > 60 64 {props.children} 61 65 {!props.noArrow && (
+4 -2
components/Tab.tsx
··· 2 2 3 3 export const Tab = (props: { 4 4 name: string; 5 + icon?: React.ReactNode; 5 6 selected: boolean; 6 7 onSelect: () => void; 7 8 onMouseEnter?: () => void; ··· 10 11 }) => { 11 12 return ( 12 13 <div 13 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 14 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-secondary"}`} 14 15 onClick={() => props.onSelect()} 15 16 onMouseEnter={props.onMouseEnter} 16 17 onPointerDown={props.onPointerDown} 18 + aria-label={props.icon ? props.name : undefined} 17 19 > 18 - {props.name} 20 + {props.icon ? props.icon : props.name} 19 21 {props.href && <ExternalLinkTiny />} 20 22 </div> 21 23 );
+33 -21
components/ThemeManager/Pickers/ColorPicker.tsx
··· 15 15 import { pickers } from "../ThemeSetter"; 16 16 import { Separator } from "components/Layout"; 17 17 import { onMouseDown } from "src/utils/iosInputMouseDown"; 18 + import { useIsMobile } from "src/hooks/isMobile"; 19 + import { HexKeyboard } from "./HexKeyboard"; 20 + import { NumPad } from "./NumPad"; 18 21 19 22 export let thumbStyle = 20 23 "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"; ··· 33 36 disabled?: boolean; 34 37 children?: React.ReactNode; 35 38 }) => { 39 + let isMobile = useIsMobile(); 40 + 36 41 return ( 37 42 <SpectrumColorPicker value={props.value} onChange={props.setValue}> 38 43 <div className="flex flex-col w-full gap-2"> 39 44 <div className="colorPickerLabel flex gap-2 items-center "> 40 45 <button 46 + type="button" 41 47 disabled={props.disabled} 42 48 className="flex gap-2 items-center disabled:text-tertiary" 43 49 onClick={() => { ··· 63 69 <div>default</div> 64 70 ) : props.disabled ? ( 65 71 <div className="text-tertiary italic">hidden</div> 72 + ) : isMobile ? ( 73 + <HexKeyboard value={props.value} setValue={props.setValue} /> 66 74 ) : ( 67 75 <ColorField className="w-fit gap-1"> 68 76 <Input ··· 88 96 {props.alpha && !props.disabled && ( 89 97 <> 90 98 <Separator classname="my-1" /> 91 - <ColorField 92 - className={`w-[48px] pl-[6px] ${props.disabled ? "opacity-50" : ""}`} 93 - channel="alpha" 94 - > 95 - <Input 96 - disabled={props.disabled} 97 - onMouseDown={onMouseDown} 98 - onFocus={(e) => { 99 - e.currentTarget.setSelectionRange( 100 - 0, 101 - e.currentTarget.value.length - 1, 102 - ); 103 - }} 104 - onKeyDown={(e) => { 105 - if (e.key === "Enter") { 106 - e.currentTarget.blur(); 107 - } else return; 108 - }} 109 - className="w-[72px] bg-transparent outline-hidden " 110 - /> 111 - </ColorField> 99 + {isMobile ? ( 100 + <NumPad value={props.value} setValue={props.setValue} /> 101 + ) : ( 102 + <ColorField 103 + className={`w-[48px] pl-[6px] ${props.disabled ? "opacity-50" : ""}`} 104 + channel="alpha" 105 + > 106 + <Input 107 + disabled={props.disabled} 108 + onMouseDown={onMouseDown} 109 + onFocus={(e) => { 110 + e.currentTarget.setSelectionRange( 111 + 0, 112 + e.currentTarget.value.length - 1, 113 + ); 114 + }} 115 + onKeyDown={(e) => { 116 + if (e.key === "Enter") { 117 + e.currentTarget.blur(); 118 + } else return; 119 + }} 120 + className="w-[72px] bg-transparent outline-hidden " 121 + /> 122 + </ColorField> 123 + )} 112 124 </> 113 125 )} 114 126 </div>
+230
components/ThemeManager/Pickers/HexKeyboard.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useCallback, useRef } from "react"; 4 + import { Color, parseColor } from "react-aria-components"; 5 + import { Popover } from "components/Popover"; 6 + import { GoToArrow } from "components/Icons/GoToArrow"; 7 + import { useSmoker } from "components/Toast"; 8 + import { CopyTiny } from "components/Icons/CopyTiny"; 9 + import { PasteTiny } from "components/Icons/PasteTiny"; 10 + 11 + const HEX_ROWS = [ 12 + ["1", "2", "3", "A", "B", "C"], 13 + ["4", "5", "6", "D", "E", "F"], 14 + ] as const; 15 + 16 + const BOTTOM_ROW = ["7", "8", "9", "0"] as const; 17 + 18 + const HexKey = (props: { 19 + onClick: () => void; 20 + children: React.ReactNode; 21 + className?: string; 22 + disabled?: boolean; 23 + ariaLabel?: string; 24 + }) => { 25 + return ( 26 + <button 27 + onPointerDown={(e) => e.preventDefault()} 28 + onClick={props.onClick} 29 + disabled={props.disabled} 30 + aria-label={props.ariaLabel} 31 + className={` 32 + h-10 w-10 rounded-md 33 + font-bold 34 + flex items-center justify-center 35 + border border-[#CCCCCC] text-[#969696] 36 + disabled:bg-[#DBDBDB] disabled:border-[#DBDBDB] disabled:text-[#CCCCCC] disabled:cursor-not-allowed 37 + ${props.className ?? ""} 38 + `} 39 + > 40 + {props.children} 41 + </button> 42 + ); 43 + }; 44 + 45 + export const HexKeyboard = (props: { 46 + value: Color | undefined; 47 + setValue: (c: Color) => void; 48 + }) => { 49 + let hexString = props.value 50 + ? props.value.toString("hex").toUpperCase() 51 + : "#000000"; 52 + 53 + let [draft, setDraft] = useState(hexString); 54 + let committed = useRef(false); 55 + let copyButtonRef = useRef<HTMLButtonElement>(null); 56 + let smoker = useSmoker(); 57 + 58 + let syncDraftToValue = useCallback(() => { 59 + let hex = props.value 60 + ? props.value.toString("hex").toUpperCase() 61 + : "#000000"; 62 + setDraft(hex); 63 + committed.current = true; 64 + }, [props.value]); 65 + 66 + let handleKey = (key: string) => { 67 + if (committed.current) { 68 + setDraft("#" + key); 69 + committed.current = false; 70 + return; 71 + } 72 + // Max hex length is 7 chars including # 73 + if (draft.length >= 7) return; 74 + setDraft((prev) => prev + key); 75 + }; 76 + 77 + let handleDelete = () => { 78 + committed.current = false; 79 + setDraft((prev) => { 80 + // Don't delete the # prefix 81 + if (prev.length <= 1) return prev; 82 + return prev.slice(0, -1); 83 + }); 84 + }; 85 + 86 + let handleSubmit = () => { 87 + try { 88 + // Pad short values: if user typed #ABC, expand to #AABBCC 89 + let raw = draft.replace("#", ""); 90 + let padded = raw; 91 + if (raw.length === 3) { 92 + padded = raw[0] + raw[0] + raw[1] + raw[1] + raw[2] + raw[2]; 93 + } else if (raw.length < 6) { 94 + padded = raw.padEnd(6, "0"); 95 + } 96 + let color = parseColor("#" + padded); 97 + props.setValue(color); 98 + setDraft("#" + padded.toUpperCase()); 99 + committed.current = true; 100 + } catch { 101 + // Invalid color, reset draft 102 + syncDraftToValue(); 103 + } 104 + }; 105 + 106 + let isValidPartialHex = /^#[0-9A-Fa-f]{0,6}$/.test(draft); 107 + let isSubmittable = 108 + /^#[0-9A-Fa-f]{3}$/.test(draft) || /^#[0-9A-Fa-f]{6}$/.test(draft); 109 + 110 + let handleCopy = () => { 111 + navigator.clipboard.writeText(draft); 112 + let rect = copyButtonRef.current?.getBoundingClientRect(); 113 + smoker({ 114 + position: { 115 + x: rect ? rect.left + rect.width / 2 : 0, 116 + y: rect ? rect.top - 8 : 0, 117 + }, 118 + text: "Copied!", 119 + }); 120 + }; 121 + 122 + let handlePaste = async () => { 123 + try { 124 + let text = await navigator.clipboard.readText(); 125 + let cleaned = text.trim(); 126 + if (!cleaned.startsWith("#")) cleaned = "#" + cleaned; 127 + cleaned = cleaned.toUpperCase(); 128 + if (/^#[0-9A-Fa-f]{0,6}$/.test(cleaned)) { 129 + setDraft(cleaned); 130 + committed.current = false; 131 + } 132 + } catch { 133 + // Clipboard read failed, do nothing 134 + } 135 + }; 136 + 137 + return ( 138 + <Popover 139 + align="center" 140 + className="p-2! w-fit bg-white! text-[#595959] border-[#CCCCCC]!" 141 + arrowFill="white" 142 + border="#CCCCCC" 143 + onOpenChange={(open) => { 144 + if (open) { 145 + syncDraftToValue(); 146 + } 147 + }} 148 + trigger={ 149 + <div className="w-[72px] text-left bg-transparent outline-hidden truncate"> 150 + {hexString} 151 + </div> 152 + } 153 + > 154 + <div className="flex flex-col gap-2"> 155 + {/* Display current draft value */} 156 + <div 157 + className={` 158 + flex items-center 159 + rounded-md border px-2 py-1 text-sm font-mono 160 + text-[#595959]! 161 + ${isValidPartialHex ? "border-[#CCCCCC]" : "border-red-400"} 162 + `} 163 + > 164 + <span className="select-all">{draft}</span> 165 + </div> 166 + 167 + {/* Copy / Paste buttons */} 168 + <div className="grid grid-cols-2 gap-1"> 169 + <button 170 + ref={copyButtonRef} 171 + onPointerDown={(e) => e.preventDefault()} 172 + onClick={handleCopy} 173 + className="h-8 gap-2 rounded-md border border-[#CCCCCC] text-sm text-[#969696] font-bold flex items-center justify-center" 174 + > 175 + <CopyTiny /> Copy 176 + </button> 177 + <button 178 + onPointerDown={(e) => e.preventDefault()} 179 + onClick={handlePaste} 180 + className="h-8 gap-2 rounded-md border border-[#CCCCCC] text-sm text-[#969696] font-bold flex items-center justify-center" 181 + > 182 + <PasteTiny /> 183 + Paste 184 + </button> 185 + </div> 186 + 187 + <hr className="border-[#CCCCCC]" /> 188 + 189 + {/* Hex keyboard grid — 6 columns */} 190 + <div className="grid grid-cols-6 gap-1"> 191 + {/* Row 1: 1 2 3 A B C */} 192 + {HEX_ROWS[0].map((key) => ( 193 + <HexKey key={key} onClick={() => handleKey(key)}> 194 + {key} 195 + </HexKey> 196 + ))} 197 + 198 + {/* Row 2: 4 5 6 D E F */} 199 + {HEX_ROWS[1].map((key) => ( 200 + <HexKey key={key} onClick={() => handleKey(key)}> 201 + {key} 202 + </HexKey> 203 + ))} 204 + 205 + {/* Row 3: 7 8 9 0 [delete] [submit] */} 206 + {BOTTOM_ROW.map((key) => ( 207 + <HexKey key={key} onClick={() => handleKey(key)}> 208 + {key} 209 + </HexKey> 210 + ))} 211 + <HexKey 212 + onClick={handleDelete} 213 + ariaLabel="Delete" 214 + className="bg-accent-1! border-accent-1! text-accent-2!" 215 + > 216 + &#x232B; 217 + </HexKey> 218 + <HexKey 219 + onClick={handleSubmit} 220 + disabled={!isSubmittable && !isValidPartialHex} 221 + ariaLabel="Apply" 222 + className="bg-accent-1! border-accent-1! text-accent-2!" 223 + > 224 + <GoToArrow /> 225 + </HexKey> 226 + </div> 227 + </div> 228 + </Popover> 229 + ); 230 + };
+162
components/ThemeManager/Pickers/NumPad.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useCallback, useRef } from "react"; 4 + import { Color } from "react-aria-components"; 5 + import { Popover } from "components/Popover"; 6 + import { GoToArrow } from "components/Icons/GoToArrow"; 7 + 8 + const NUM_ROWS = [ 9 + ["1", "2", "3"], 10 + ["4", "5", "6"], 11 + ["7", "8", "9"], 12 + ] as const; 13 + 14 + const NumKey = (props: { 15 + onClick: () => void; 16 + children: React.ReactNode; 17 + className?: string; 18 + disabled?: boolean; 19 + ariaLabel?: string; 20 + }) => { 21 + return ( 22 + <button 23 + onPointerDown={(e) => e.preventDefault()} 24 + onClick={props.onClick} 25 + disabled={props.disabled} 26 + aria-label={props.ariaLabel} 27 + className={` 28 + h-10 w-10 rounded-md 29 + font-bold 30 + flex items-center justify-center 31 + border border-[#CCCCCC] text-[#969696] 32 + disabled:bg-[#DBDBDB] disabled:border-[#DBDBDB] disabled:text-[#CCCCCC] disabled:cursor-not-allowed 33 + ${props.className ?? ""} 34 + `} 35 + > 36 + {props.children} 37 + </button> 38 + ); 39 + }; 40 + 41 + export const NumPad = (props: { 42 + value: Color | undefined; 43 + setValue: (c: Color) => void; 44 + }) => { 45 + let alphaPercent = props.value 46 + ? Math.round(props.value.getChannelValue("alpha") * 100) 47 + : 100; 48 + 49 + let displayValue = `${alphaPercent}%`; 50 + 51 + let [draft, setDraft] = useState(String(alphaPercent)); 52 + let committed = useRef(false); 53 + 54 + let syncDraftToValue = useCallback(() => { 55 + let percent = props.value 56 + ? Math.round(props.value.getChannelValue("alpha") * 100) 57 + : 100; 58 + setDraft(String(percent)); 59 + committed.current = true; 60 + }, [props.value]); 61 + 62 + let handleKey = (key: string) => { 63 + if (committed.current) { 64 + committed.current = false; 65 + setDraft(key); 66 + return; 67 + } 68 + setDraft((prev) => { 69 + let next = prev === "0" ? key : prev + key; 70 + // Don't allow more than 3 digits (max 100) 71 + if (next.length > 3) return prev; 72 + let num = parseInt(next, 10); 73 + if (num > 100) return prev; 74 + return next; 75 + }); 76 + }; 77 + 78 + let handleDelete = () => { 79 + committed.current = false; 80 + setDraft((prev) => { 81 + if (prev.length <= 1) return "0"; 82 + return prev.slice(0, -1); 83 + }); 84 + }; 85 + 86 + let handleSubmit = () => { 87 + let num = parseInt(draft, 10); 88 + if (isNaN(num) || num < 0 || num > 100) { 89 + syncDraftToValue(); 90 + return; 91 + } 92 + if (props.value) { 93 + let updated = props.value.withChannelValue("alpha", num / 100); 94 + props.setValue(updated); 95 + } 96 + committed.current = true; 97 + }; 98 + 99 + let isValid = /^\d{1,3}$/.test(draft) && parseInt(draft, 10) <= 100; 100 + 101 + return ( 102 + <Popover 103 + align="center" 104 + className="p-2! w-fit bg-white! text-[#595959] border-[#CCCCCC]!" 105 + arrowFill="white" 106 + border="#CCCCCC" 107 + onOpenChange={(open) => { 108 + if (open) { 109 + syncDraftToValue(); 110 + } 111 + }} 112 + trigger={ 113 + <button className="w-[64px] pl-[6px] text-left bg-transparent outline-hidden truncate"> 114 + {displayValue} 115 + </button> 116 + } 117 + > 118 + <div className="flex flex-col gap-2"> 119 + {/* Display current draft value */} 120 + <div 121 + className={` 122 + flex items-center 123 + rounded-md border px-2 py-1 text-sm font-mono 124 + text-[#595959]! 125 + ${isValid ? "border-[#CCCCCC]" : "border-red-400"} 126 + `} 127 + > 128 + <span className="select-all">{draft}%</span> 129 + </div> 130 + 131 + {/* Numpad grid — 3 columns */} 132 + <div className="grid grid-cols-3 gap-1"> 133 + {NUM_ROWS.map((row) => 134 + row.map((key) => ( 135 + <NumKey key={key} onClick={() => handleKey(key)}> 136 + {key} 137 + </NumKey> 138 + )), 139 + )} 140 + 141 + {/* Bottom row: 0, delete, submit */} 142 + <NumKey onClick={() => handleKey("0")}>0</NumKey> 143 + <NumKey 144 + onClick={handleDelete} 145 + ariaLabel="Delete" 146 + className="bg-accent-1! border-accent-1! text-accent-2!" 147 + > 148 + &#x232B; 149 + </NumKey> 150 + <NumKey 151 + onClick={handleSubmit} 152 + disabled={!isValid} 153 + ariaLabel="Apply" 154 + className="bg-accent-1! border-accent-1! text-accent-2!" 155 + > 156 + <GoToArrow /> 157 + </NumKey> 158 + </div> 159 + </div> 160 + </Popover> 161 + ); 162 + };
+3 -2
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 103 103 alpha={props.hasPageBackground ? true : false} 104 104 /> 105 105 )} 106 - <hr className="border-border-light" /> 106 + <hr className="border-[#CCCCCC] my-0.5" /> 107 107 <div className="flex gap-2 items-center"> 108 108 <Toggle 109 109 toggle={props.hasPageBackground} ··· 145 145 <> 146 146 <div className="bgPickerColorLabel flex gap-2 items-center"> 147 147 <button 148 + type="button" 148 149 disabled={props.disabled} 149 150 onClick={() => { 150 151 if (props.openPicker === props.thisPicker) { ··· 170 171 <div className="italic text-[#8C8C8C]">image</div> 171 172 </button> 172 173 <div className="flex gap-1 text-[#8C8C8C]"> 173 - <button onClick={() => props.setBgImage(null)}> 174 + <button type="button" onClick={() => props.setBgImage(null)}> 174 175 <DeleteSmall /> 175 176 </button> 176 177 <label className="hover:cursor-pointer ">
+2 -2
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
··· 40 40 let open = props.openPicker == props.thisPicker; 41 41 42 42 return ( 43 - <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white"> 43 + <div className="pageWidthSetter flex flex-col gap-2"> 44 44 <button 45 45 type="button" 46 - className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 46 + className="font-bold text-[#595959] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 47 47 onClick={() => { 48 48 if (!open) { 49 49 props.setOpenPicker(props.thisPicker);
+149
components/ThemeManager/PubPickers/PubPresetPicker.tsx
··· 1 + import { useCallback } from "react"; 2 + import { parseColor } from "react-aria-components"; 3 + import { PubThemeEditorState } from "../PubThemeSetter"; 4 + 5 + type PresetTheme = { 6 + name: string; 7 + bgLeaflet: string; 8 + bgPage: string; 9 + showPageBackground: boolean; 10 + primary: string; 11 + accent1: string; 12 + accent2: string; 13 + headingFont: string | undefined; 14 + bodyFont: string | undefined; 15 + pageWidth: number; 16 + }; 17 + 18 + const presetThemes: PresetTheme[] = [ 19 + { 20 + name: "Default", 21 + bgLeaflet: "#FDFCFA", 22 + bgPage: "#FDFCFA", 23 + showPageBackground: false, 24 + primary: "#272727", 25 + accent1: "#57822B", 26 + accent2: "#FFFFFF", 27 + headingFont: undefined, 28 + bodyFont: undefined, 29 + pageWidth: 624, 30 + }, 31 + { 32 + name: "Minimal", 33 + bgLeaflet: "#F0F2F2", 34 + bgPage: "#F0F2F2", 35 + showPageBackground: false, 36 + primary: "#292929", 37 + accent1: "#292929", 38 + accent2: "#F0F2F2", 39 + headingFont: undefined, 40 + bodyFont: "source-sans", 41 + pageWidth: 768, 42 + }, 43 + { 44 + name: "Bookish", 45 + bgLeaflet: "#FFFBEB", 46 + bgPage: "#FFFBEB", 47 + showPageBackground: false, 48 + primary: "#3B3131", 49 + accent1: "#B50000", 50 + accent2: "#F0F2F2", 51 + headingFont: "lora", 52 + bodyFont: "lora", 53 + pageWidth: 768, 54 + }, 55 + { 56 + name: "Dark", 57 + bgLeaflet: "#1A1917", 58 + bgPage: "#1A1917", 59 + showPageBackground: false, 60 + primary: "#FFFFFF", 61 + accent1: "#234D26", 62 + accent2: "#C3D6BC", 63 + headingFont: undefined, 64 + bodyFont: undefined, 65 + pageWidth: 768, 66 + }, 67 + { 68 + name: "Colorful", 69 + bgLeaflet: "#2665C9", 70 + bgPage: "rgba(247, 223, 124, 1)", 71 + showPageBackground: true, 72 + primary: "#363131", 73 + accent1: "#ED3700", 74 + accent2: "#FFFFFF", 75 + headingFont: undefined, 76 + bodyFont: undefined, 77 + pageWidth: 768, 78 + }, 79 + { 80 + name: "Rose", 81 + bgLeaflet: "#FAD2D7", 82 + bgPage: "rgba(252, 246, 245, 0.68)", 83 + showPageBackground: true, 84 + primary: "#613E3E", 85 + accent1: "#74C200", 86 + accent2: "#FFFFFF", 87 + headingFont: "montserrat", 88 + bodyFont: undefined, 89 + pageWidth: 768, 90 + }, 91 + ]; 92 + 93 + export function PresetThemePicker(props: { state: PubThemeEditorState }) { 94 + let { 95 + setTheme, 96 + setImage, 97 + setPageWidth, 98 + setHeadingFont, 99 + setBodyFont, 100 + setShowPageBackground, 101 + } = props.state; 102 + 103 + let applyPreset = useCallback( 104 + (preset: PresetTheme) => { 105 + setTheme(() => ({ 106 + bgLeaflet: parseColor(preset.bgLeaflet), 107 + bgPage: parseColor(preset.bgPage), 108 + primary: parseColor(preset.primary), 109 + accent1: parseColor(preset.accent1), 110 + accent2: parseColor(preset.accent2), 111 + })); 112 + setShowPageBackground(preset.showPageBackground); 113 + setPageWidth(preset.pageWidth); 114 + setHeadingFont(preset.headingFont); 115 + setBodyFont(preset.bodyFont); 116 + setImage(null); 117 + }, 118 + [ 119 + setTheme, 120 + setShowPageBackground, 121 + setPageWidth, 122 + setHeadingFont, 123 + setBodyFont, 124 + setImage, 125 + ], 126 + ); 127 + 128 + return ( 129 + <div className="pubPresetPicker flex flex-col pb-2"> 130 + <div className="text-sm text-[#969696] -mb-0.5">PRESETS</div> 131 + <div className="flex gap-2 items-center px-2 py-2 border border-[#CCCCCC] rounded-md bg-white mb-1"> 132 + {presetThemes.map((preset) => ( 133 + <button 134 + key={preset.name} 135 + type="button" 136 + title={preset.name} 137 + onClick={() => applyPreset(preset)} 138 + className="w-8 h-8 rounded-full border border-[#CCCCCC] shrink-0 overflow-hidden outline-offset-1 outline-transparent hover:outline-[#CCCCCC] outline-2" 139 + style={{ 140 + background: `linear-gradient(135deg, ${preset.bgLeaflet} 50%, ${preset.accent1} 50%)`, 141 + }} 142 + > 143 + <span className="sr-only">{preset.name}</span> 144 + </button> 145 + ))} 146 + </div> 147 + </div> 148 + ); 149 + }
+255 -318
components/ThemeManager/PubThemeSetter.tsx
··· 4 4 } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 5 5 import { useState } from "react"; 6 6 import { pickers, SectionArrow } from "./ThemeSetter"; 7 - import { Color } from "react-aria-components"; 8 7 import { PubLeafletThemeBackgroundImage } from "lexicons/api"; 9 8 import { AtUri } from "@atproto/syntax"; 10 9 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 - import { BaseThemeProvider } from "./ThemeProvider"; 10 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 12 11 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 12 import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 14 13 import { PagePickers } from "./PubPickers/PubTextPickers"; 15 14 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 15 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 16 import { Separator } from "components/Layout"; 18 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 17 + 19 18 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 19 import { useToaster } from "components/Toast"; 21 20 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 21 import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 23 22 import { FontPicker } from "./Pickers/TextPickers"; 23 + import { GoToArrow } from "components/Icons/GoToArrow"; 24 + import { ButtonPrimary } from "components/Buttons"; 25 + import { PresetThemePicker } from "./PubPickers/PubPresetPicker"; 24 26 25 27 export type ImageState = { 26 28 src: string; 27 29 file?: File; 28 30 repeat: number | null; 29 31 }; 30 - export const PubThemeSetter = (props: { 31 - backToMenu: () => void; 32 - loading: boolean; 33 - setLoading: (l: boolean) => void; 34 - }) => { 35 - let [sample, setSample] = useState<"pub" | "post">("pub"); 32 + 33 + export function usePubThemeEditorState() { 36 34 let [openPicker, setOpenPicker] = useState<pickers>("null"); 37 35 let { data, mutate } = usePublicationData(); 38 36 let { publication: pub } = data || {}; ··· 44 42 theme: localPubTheme, 45 43 setTheme, 46 44 changes, 45 + resetChanges, 47 46 } = useLocalPubTheme(record?.theme, showPageBackground); 48 47 let [image, setImage] = useState<ImageState | null>( 49 48 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) ··· 61 60 let [pageWidth, setPageWidth] = useState<number>( 62 61 record?.theme?.pageWidth || 624, 63 62 ); 64 - let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 - let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 63 + let [headingFont, setHeadingFont] = useState<string | undefined>( 64 + record?.theme?.headingFont, 65 + ); 66 + let [bodyFont, setBodyFont] = useState<string | undefined>( 67 + record?.theme?.bodyFont, 68 + ); 66 69 let pubBGImage = image?.src || null; 67 70 let leafletBGRepeat = image?.repeat || null; 68 71 let toaster = useToaster(); 69 72 70 - return ( 71 - <BaseThemeProvider 72 - local 73 - {...localPubTheme} 74 - headingFontId={headingFont} 75 - bodyFontId={bodyFont} 76 - hasBackgroundImage={!!image} 77 - className="min-h-0!" 78 - > 79 - <div className="min-h-0 flex-1 flex flex-col pb-0.5"> 80 - <form 81 - className="flex-shrink-0" 82 - onSubmit={async (e) => { 83 - e.preventDefault(); 84 - if (!pub) return; 85 - props.setLoading(true); 86 - let result = await updatePublicationTheme({ 87 - uri: pub.uri, 88 - theme: { 89 - pageBackground: ColorToRGBA(localPubTheme.bgPage), 90 - showPageBackground: showPageBackground, 91 - backgroundColor: image 92 - ? ColorToRGBA(localPubTheme.bgLeaflet) 93 - : ColorToRGB(localPubTheme.bgLeaflet), 94 - backgroundRepeat: image?.repeat, 95 - backgroundImage: image ? image.file : null, 96 - pageWidth: pageWidth, 97 - primary: ColorToRGB(localPubTheme.primary), 98 - accentBackground: ColorToRGB(localPubTheme.accent1), 99 - accentText: ColorToRGB(localPubTheme.accent2), 100 - headingFont: headingFont, 101 - bodyFont: bodyFont, 102 - }, 103 - }); 73 + let submitTheme = async (setLoading: (l: boolean) => void) => { 74 + if (!pub) return; 75 + setLoading(true); 76 + let result = await updatePublicationTheme({ 77 + uri: pub.uri, 78 + theme: { 79 + pageBackground: ColorToRGBA(localPubTheme.bgPage), 80 + showPageBackground: showPageBackground, 81 + backgroundColor: image 82 + ? ColorToRGBA(localPubTheme.bgLeaflet) 83 + : ColorToRGB(localPubTheme.bgLeaflet), 84 + backgroundRepeat: image?.repeat, 85 + backgroundImage: image ? image.file : null, 86 + pageWidth: pageWidth, 87 + primary: ColorToRGB(localPubTheme.primary), 88 + accentBackground: ColorToRGB(localPubTheme.accent1), 89 + accentText: ColorToRGB(localPubTheme.accent2), 90 + headingFont: headingFont, 91 + bodyFont: bodyFont, 92 + }, 93 + }); 104 94 105 - if (!result.success) { 106 - props.setLoading(false); 107 - if (result.error && isOAuthSessionError(result.error)) { 108 - toaster({ 109 - content: <OAuthErrorMessage error={result.error} />, 110 - type: "error", 111 - }); 112 - } else { 113 - toaster({ 114 - content: "Failed to update theme", 115 - type: "error", 116 - }); 117 - } 118 - return; 119 - } 95 + if (!result.success) { 96 + setLoading(false); 97 + if (result.error && isOAuthSessionError(result.error)) { 98 + toaster({ 99 + content: <OAuthErrorMessage error={result.error} />, 100 + type: "error", 101 + }); 102 + } else { 103 + toaster({ 104 + content: "Failed to update theme", 105 + type: "error", 106 + }); 107 + } 108 + return result; 109 + } 110 + 111 + mutate((pub) => { 112 + if (result.publication && pub?.publication) 113 + return { 114 + ...pub, 115 + publication: { ...pub.publication, ...result.publication }, 116 + }; 117 + return pub; 118 + }, false); 119 + resetChanges(); 120 + setLoading(false); 121 + return result; 122 + }; 123 + 124 + return { 125 + openPicker, 126 + setOpenPicker, 127 + pub, 128 + record, 129 + mutate, 130 + showPageBackground, 131 + setShowPageBackground, 132 + localPubTheme, 133 + setTheme, 134 + changes, 135 + resetChanges, 136 + image, 137 + setImage, 138 + pageWidth, 139 + setPageWidth, 140 + headingFont, 141 + setHeadingFont, 142 + bodyFont, 143 + setBodyFont, 144 + pubBGImage, 145 + leafletBGRepeat, 146 + toaster, 147 + submitTheme, 148 + }; 149 + } 120 150 121 - mutate((pub) => { 122 - if (result.publication && pub?.publication) 123 - return { 124 - ...pub, 125 - publication: { ...pub.publication, ...result.publication }, 126 - }; 127 - return pub; 128 - }, false); 129 - props.setLoading(false); 130 - }} 131 - > 132 - <PubSettingsHeader 133 - loading={props.loading} 134 - setLoadingAction={props.setLoading} 135 - backToMenuAction={props.backToMenu} 136 - > 137 - Theme and Layout 138 - </PubSettingsHeader> 139 - </form> 151 + export type PubThemeEditorState = ReturnType<typeof usePubThemeEditorState>; 140 152 141 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 "> 142 - <PubPageWidthSetter 143 - pageWidth={pageWidth} 144 - setPageWidth={setPageWidth} 145 - thisPicker="page-width" 146 - openPicker={openPicker} 147 - setOpenPicker={setOpenPicker} 148 - /> 149 - <div className="themeBGLeaflet flex flex-col"> 150 - <div 151 - className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 152 - > 153 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 154 - <BackgroundPicker 155 - bgImage={image} 156 - setBgImage={setImage} 157 - backgroundColor={localPubTheme.bgLeaflet} 158 - pageBackground={localPubTheme.bgPage} 159 - setPageBackground={(color) => { 160 - setTheme((t) => ({ ...t, bgPage: color })); 161 - }} 162 - setBackgroundColor={(color) => { 163 - setTheme((t) => ({ ...t, bgLeaflet: color })); 164 - }} 165 - openPicker={openPicker} 166 - setOpenPicker={setOpenPicker} 167 - hasPageBackground={!!showPageBackground} 168 - setHasPageBackground={setShowPageBackground} 169 - /> 170 - </div> 153 + export function PubThemePickerPanel(props: { state: PubThemeEditorState }) { 154 + let { 155 + openPicker, 156 + setOpenPicker, 157 + showPageBackground, 158 + setShowPageBackground, 159 + localPubTheme, 160 + setTheme, 161 + image, 162 + setImage, 163 + pageWidth, 164 + setPageWidth, 165 + headingFont, 166 + setHeadingFont, 167 + bodyFont, 168 + setBodyFont, 169 + pubBGImage, 170 + leafletBGRepeat, 171 + } = props.state; 171 172 172 - <SectionArrow 173 - fill="white" 174 - stroke="#CCCCCC" 175 - className="ml-2 -mt-[1px]" 176 - /> 177 - </div> 173 + return ( 174 + <div className="themeSetterContent flex flex-col w-full"> 175 + <div className="themeBGLeaflet flex flex-col"> 176 + <div 177 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 178 + > 179 + <PresetThemePicker state={props.state} /> 180 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 181 + <PubPageWidthSetter 182 + pageWidth={pageWidth} 183 + setPageWidth={setPageWidth} 184 + thisPicker="page-width" 185 + openPicker={openPicker} 186 + setOpenPicker={setOpenPicker} 187 + /> 188 + <hr className="border-[#CCCCCC] my-0.5" /> 189 + <BackgroundPicker 190 + bgImage={image} 191 + setBgImage={setImage} 192 + backgroundColor={localPubTheme.bgLeaflet} 193 + pageBackground={localPubTheme.bgPage} 194 + setPageBackground={(color) => { 195 + setTheme((t) => ({ ...t, bgPage: color })); 196 + }} 197 + setBackgroundColor={(color) => { 198 + setTheme((t) => ({ ...t, bgLeaflet: color })); 199 + }} 200 + openPicker={openPicker} 201 + setOpenPicker={setOpenPicker} 202 + hasPageBackground={!!showPageBackground} 203 + setHasPageBackground={setShowPageBackground} 204 + /> 178 205 </div> 179 206 180 - <div 181 - style={{ 182 - backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 183 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 184 - backgroundPosition: "center", 185 - backgroundSize: !leafletBGRepeat 186 - ? "cover" 187 - : `calc(${leafletBGRepeat}px / 2 )`, 188 - }} 189 - className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 190 - > 191 - <div className={`flex flex-col gap-3 z-10`}> 192 - <PagePickers 193 - pageBackground={localPubTheme.bgPage} 194 - primary={localPubTheme.primary} 195 - setPageBackground={(color) => { 196 - setTheme((t) => ({ ...t, bgPage: color })); 197 - }} 198 - setPrimary={(color) => { 199 - setTheme((t) => ({ ...t, primary: color })); 200 - }} 201 - openPicker={openPicker} 202 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 203 - hasPageBackground={showPageBackground} 204 - /> 205 - <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 206 - <FontPicker 207 - label="Heading" 208 - value={headingFont} 209 - onChange={setHeadingFont} 210 - /> 211 - <FontPicker 212 - label="Body" 213 - value={bodyFont} 214 - onChange={setBodyFont} 215 - /> 216 - </div> 217 - <PubAccentPickers 218 - accent1={localPubTheme.accent1} 219 - setAccent1={(color) => { 220 - setTheme((t) => ({ ...t, accent1: color })); 221 - }} 222 - accent2={localPubTheme.accent2} 223 - setAccent2={(color) => { 224 - setTheme((t) => ({ ...t, accent2: color })); 225 - }} 226 - openPicker={openPicker} 227 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 228 - /> 229 - </div> 230 - </div> 231 - <div className="flex flex-col mt-4 "> 232 - <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 233 - <div className="text-sm">Preview</div> 234 - <Separator classname="h-4!" />{" "} 235 - <button 236 - className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 237 - onClick={() => setSample("pub")} 238 - > 239 - Pub 240 - </button> 241 - <button 242 - className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 243 - onClick={() => setSample("post")} 244 - > 245 - Post 246 - </button> 247 - </div> 248 - {sample === "pub" ? ( 249 - <SamplePub 250 - pubBGImage={pubBGImage} 251 - pubBGRepeat={leafletBGRepeat} 252 - showPageBackground={showPageBackground} 253 - /> 254 - ) : ( 255 - <SamplePost 256 - pubBGImage={pubBGImage} 257 - pubBGRepeat={leafletBGRepeat} 258 - showPageBackground={showPageBackground} 259 - /> 260 - )} 261 - </div> 207 + <SectionArrow 208 + fill="white" 209 + stroke="#CCCCCC" 210 + className="ml-2 -mt-[1px]" 211 + /> 262 212 </div> 263 213 </div> 264 - </BaseThemeProvider> 265 - ); 266 - }; 267 214 268 - const SamplePub = (props: { 269 - pubBGImage: string | null; 270 - pubBGRepeat: number | null; 271 - showPageBackground: boolean; 272 - }) => { 273 - let { data } = usePublicationData(); 274 - let { publication } = data || {}; 275 - let record = useNormalizedPublicationRecord(); 276 - 277 - return ( 278 - <div 279 - style={{ 280 - backgroundImage: props.pubBGImage 281 - ? `url(${props.pubBGImage})` 282 - : undefined, 283 - backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 284 - backgroundPosition: "center", 285 - backgroundSize: !props.pubBGRepeat 286 - ? "cover" 287 - : `calc(${props.pubBGRepeat}px / 2 )`, 288 - }} 289 - className={`bg-bg-leaflet p-3 pb-0 flex flex-col gap-3 rounded-t-md border border-border border-b-0 h-[148px] overflow-hidden `} 290 - > 291 215 <div 292 - className="pubWrapper sampleContent rounded-t-md border-border pb-4 px-[10px] flex flex-col gap-[14px] w-[250px] mx-auto" 293 216 style={{ 294 - background: props.showPageBackground 295 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 296 - : undefined, 217 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 218 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 219 + backgroundPosition: "center", 220 + backgroundSize: !leafletBGRepeat 221 + ? "cover" 222 + : `calc(${leafletBGRepeat}px / 2 )`, 297 223 }} 224 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 298 225 > 299 - <div className="flex flex-col justify-center text-center pt-2"> 300 - {record?.icon && publication?.uri && ( 301 - <div 302 - style={{ 303 - backgroundRepeat: "no-repeat", 304 - backgroundPosition: "center", 305 - backgroundSize: "cover", 306 - backgroundImage: `url(/api/atproto_images?did=${new AtUri(publication.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`, 307 - }} 308 - className="w-4 h-4 rounded-full place-self-center" 226 + <div className={`flex flex-col gap-3 z-10`}> 227 + <PagePickers 228 + pageBackground={localPubTheme.bgPage} 229 + primary={localPubTheme.primary} 230 + setPageBackground={(color) => { 231 + setTheme((t) => ({ ...t, bgPage: color })); 232 + }} 233 + setPrimary={(color) => { 234 + setTheme((t) => ({ ...t, primary: color })); 235 + }} 236 + openPicker={openPicker} 237 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 238 + hasPageBackground={showPageBackground} 239 + /> 240 + <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 241 + <FontPicker 242 + label="Heading" 243 + value={headingFont} 244 + onChange={setHeadingFont} 309 245 /> 310 - )} 311 - 312 - <div className="text-[11px] font-bold pt-[5px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 313 - {record?.name} 246 + <FontPicker label="Body" value={bodyFont} onChange={setBodyFont} /> 314 247 </div> 315 - <div className="text-[7px] font-normal text-tertiary"> 316 - {record?.description} 317 - </div> 318 - <div className=" flex gap-1 items-center mt-[6px] bg-accent-1 text-accent-2 py-px px-[4px] text-[7px] w-fit font-bold rounded-[2px] mx-auto"> 319 - <div className="h-[7px] w-[7px] rounded-full bg-accent-2" /> 320 - Subscribe with Bluesky 321 - </div> 322 - </div> 323 - 324 - <div className="flex flex-col text-[8px] rounded-md "> 325 - <div className="font-bold" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>A Sample Post</div> 326 - <div className="text-secondary italic text-[6px]"> 327 - This is a sample description about the sample post 328 - </div> 329 - <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 248 + <PubAccentPickers 249 + accent1={localPubTheme.accent1} 250 + setAccent1={(color) => { 251 + setTheme((t) => ({ ...t, accent1: color })); 252 + }} 253 + accent2={localPubTheme.accent2} 254 + setAccent2={(color) => { 255 + setTheme((t) => ({ ...t, accent2: color })); 256 + }} 257 + openPicker={openPicker} 258 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 259 + /> 330 260 </div> 331 261 </div> 332 262 </div> 333 263 ); 334 - }; 264 + } 335 265 336 - const SamplePost = (props: { 337 - pubBGImage: string | null; 338 - pubBGRepeat: number | null; 339 - showPageBackground: boolean; 266 + export const PubThemeSetter = (props: { 267 + backToMenu: () => void; 268 + loading: boolean; 269 + setLoading: (l: boolean) => void; 340 270 }) => { 341 - let { data } = usePublicationData(); 342 - let { publication } = data || {}; 343 - let record = useNormalizedPublicationRecord(); 271 + let [sample, setSample] = useState<"pub" | "post">("pub"); 272 + let state = usePubThemeEditorState(); 273 + let { 274 + localPubTheme, 275 + headingFont, 276 + bodyFont, 277 + image, 278 + pageWidth, 279 + pubBGImage, 280 + leafletBGRepeat, 281 + pub, 282 + record, 283 + showPageBackground, 284 + submitTheme, 285 + } = state; 286 + 344 287 return ( 345 - <div 346 - style={{ 347 - backgroundImage: props.pubBGImage 348 - ? `url(${props.pubBGImage})` 349 - : undefined, 350 - backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat", 351 - backgroundPosition: "center", 352 - backgroundSize: !props.pubBGRepeat 353 - ? "cover" 354 - : `calc(${props.pubBGRepeat}px / 2 )`, 355 - }} 356 - className={`bg-bg-leaflet p-3 max-w-full flex flex-col gap-3 rounded-t-md border border-border border-b-0 pb-0 h-[148px] overflow-hidden`} 357 - > 358 - <div 359 - className="pubWrapper sampleContent rounded-t-md border-border pb-0 px-[6px] flex flex-col w-[250px] mx-auto" 360 - style={{ 361 - background: props.showPageBackground 362 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 363 - : undefined, 364 - }} 288 + <CardBorderHiddenContext.Provider value={!showPageBackground}> 289 + <BaseThemeProvider 290 + local 291 + {...localPubTheme} 292 + headingFontId={headingFont} 293 + bodyFontId={bodyFont} 294 + hasBackgroundImage={!!image} 295 + className="min-h-0!" 365 296 > 366 - <div className="flex flex-col "> 367 - <div className="text-[6px] font-bold pt-[6px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 368 - {record?.name} 369 - </div> 370 - <div className="text-[11px] font-bold text-primary" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}> 371 - A Sample Post 372 - </div> 373 - <div className="text-[7px] font-normal text-secondary italic"> 374 - A short sample description about the sample post 375 - </div> 376 - <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div> 377 - </div> 378 - <div className="text-[6px] pt-[8px] flex flex-col gap-[6px]"> 379 - <div> 380 - Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque 381 - faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi 382 - pretium tellus duis convallis. Tempus leo eu aenean sed diam urna 383 - tempor. 297 + <div className="min-h-0 flex-1 flex flex-col pb-0.5"> 298 + <div className="flex-shrink-0"> 299 + <button type="button" onClick={props.backToMenu}> 300 + <GoToArrow /> 301 + </button> 384 302 </div> 385 303 386 - <div> 387 - Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis 388 - massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit 389 - semper vel class aptent taciti sociosqu. Ad litora torquent per 390 - conubia nostra inceptos himenaeos. 391 - </div> 392 - <div> 393 - Sed et nisi semper, egestas purus a, egestas nulla. Nulla ultricies, 394 - purus non dapibus tincidunt, nunc sem rhoncus sem, vel malesuada 395 - tellus enim sit amet magna. Donec ac justo a ipsum fermentum 396 - vulputate. Etiam sit amet viverra leo. Aenean accumsan consectetur 397 - velit. Vivamus at justo a nisl imperdiet dictum. Donec scelerisque 398 - ex eget turpis scelerisque tincidunt. Proin non convallis nibh, eget 399 - aliquet ex. Curabitur ornare a ipsum in ultrices. 304 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 "> 305 + <PubThemePickerPanel state={state} /> 306 + <div className="flex flex-col mt-4 "> 307 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 308 + <div className="text-sm">Preview</div> 309 + <Separator classname="h-4!" />{" "} 310 + <button 311 + type="button" 312 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 313 + onClick={() => setSample("pub")} 314 + > 315 + Pub 316 + </button> 317 + <button 318 + type="button" 319 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 320 + onClick={() => setSample("post")} 321 + > 322 + Post 323 + </button> 324 + </div> 325 + </div> 326 + <div className="pt-2"> 327 + <ButtonPrimary 328 + fullWidth 329 + disabled={props.loading} 330 + onClick={async () => { 331 + await submitTheme(props.setLoading); 332 + }} 333 + > 334 + {props.loading ? "Saving..." : "Save Theme"} 335 + </ButtonPrimary> 336 + </div> 400 337 </div> 401 338 </div> 402 - </div> 403 - </div> 339 + </BaseThemeProvider> 340 + </CardBorderHiddenContext.Provider> 404 341 ); 405 342 };
+22 -6
components/ThemeManager/PublicationThemeProvider.tsx
··· 69 69 pub_creator: string; 70 70 className?: string; 71 71 children: React.ReactNode; 72 + localBgImage?: string | null; 73 + localBgImageRepeat?: number | null; 72 74 }) { 73 - let backgroundImage = props.theme?.backgroundImage?.image?.ref 74 - ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 75 - : null; 75 + let backgroundImage = 76 + props.localBgImage !== undefined 77 + ? props.localBgImage 78 + : props.theme?.backgroundImage?.image?.ref 79 + ? blobRefToSrc( 80 + props.theme?.backgroundImage?.image?.ref, 81 + props.pub_creator, 82 + ) 83 + : null; 84 + 85 + let backgroundImageRepeat = 86 + props.localBgImageRepeat !== undefined 87 + ? !!props.localBgImageRepeat 88 + : props.theme?.backgroundImage?.repeat; 89 + let backgroundImageSize = 90 + (props.localBgImageRepeat !== undefined 91 + ? props.localBgImageRepeat 92 + : props.theme?.backgroundImage?.width) || 500; 76 93 77 - let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 78 - let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 79 94 return ( 80 95 <div 81 - className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 96 + className={`PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch ${props.className}`} 82 97 style={{ 83 98 backgroundImage: backgroundImage 84 99 ? `url(${backgroundImage})` ··· 184 199 return { 185 200 theme: mergedTheme, 186 201 setTheme, 202 + resetChanges: () => setTheme({}), 187 203 changes: Object.keys(localOverrides).length > 0, 188 204 }; 189 205 };
+1 -1
components/Toast.tsx
··· 105 105 className={`toastAnimationWrapper fixed top-0 bottom-0 right-0 left-0 z-50 h-fit`} 106 106 > 107 107 <div 108 - className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${ 108 + className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center font-bold ${ 109 109 props.toast?.type === "error" 110 110 ? "border-white bg-[#dc143c] text-white border font-bold" 111 111 : props.toast?.type === "success"