a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+4224 -1788
actions
app
appview
components
lexicons
src
supabase
-51
actions/createIdentity.ts
··· 1 - import { 2 - entities, 3 - permission_tokens, 4 - permission_token_rights, 5 - entity_sets, 6 - identities, 7 - } from "drizzle/schema"; 8 - import { v7 } from "uuid"; 9 - import { PgTransaction } from "drizzle-orm/pg-core"; 10 - import { NodePgDatabase } from "drizzle-orm/node-postgres"; 11 - import { Json } from "supabase/database.types"; 12 - 13 - export async function createIdentity( 14 - db: NodePgDatabase, 15 - data?: { email?: string; atp_did?: string }, 16 - ) { 17 - return db.transaction(async (tx) => { 18 - // Create a new entity set 19 - let [entity_set] = await tx.insert(entity_sets).values({}).returning(); 20 - // Create a root-entity 21 - let [entity] = await tx 22 - .insert(entities) 23 - // And add it to that permission set 24 - .values({ set: entity_set.id, id: v7() }) 25 - .returning(); 26 - //Create a new permission token 27 - let [permissionToken] = await tx 28 - .insert(permission_tokens) 29 - .values({ root_entity: entity.id }) 30 - .returning(); 31 - //and give it all the permission on that entity set 32 - let [rights] = await tx 33 - .insert(permission_token_rights) 34 - .values({ 35 - token: permissionToken.id, 36 - entity_set: entity_set.id, 37 - read: true, 38 - write: true, 39 - create_token: true, 40 - change_entity_set: true, 41 - }) 42 - .returning(); 43 - let [identity] = await tx 44 - .insert(identities) 45 - .values({ home_page: permissionToken.id, ...data }) 46 - .returning(); 47 - return identity as Omit<typeof identity, "interface_state"> & { 48 - interface_state: Json; 49 - }; 50 - }); 51 - }
+7 -3
actions/emailAuth.ts
··· 6 6 import { email_auth_tokens, identities } from "drizzle/schema"; 7 7 import { and, eq } from "drizzle-orm"; 8 8 import { cookies } from "next/headers"; 9 - import { createIdentity } from "./createIdentity"; 10 9 import { setAuthToken } from "src/auth"; 11 10 import { pool } from "supabase/pool"; 11 + import { supabaseServerClient } from "supabase/serverClient"; 12 12 13 13 async function sendAuthCode(email: string, code: string) { 14 14 if (process.env.NODE_ENV === "development") { ··· 114 114 .from(identities) 115 115 .where(eq(identities.email, token.email)); 116 116 if (!identity) { 117 - let newIdentity = await createIdentity(db, { email: token.email }); 118 - identityID = newIdentity.id; 117 + const { data: newIdentity } = await supabaseServerClient 118 + .from("identities") 119 + .insert({ email: token.email }) 120 + .select() 121 + .single(); 122 + identityID = newIdentity!.id; 119 123 } else { 120 124 identityID = identity.id; 121 125 }
+7 -8
actions/login.ts
··· 4 4 import { 5 5 email_auth_tokens, 6 6 identities, 7 - entity_sets, 8 - entities, 9 - permission_tokens, 10 - permission_token_rights, 11 7 permission_token_on_homepage, 12 8 poll_votes_on_entity, 13 9 } from "drizzle/schema"; 14 10 import { and, eq, isNull } from "drizzle-orm"; 15 11 import { cookies } from "next/headers"; 16 12 import { redirect } from "next/navigation"; 17 - import { v7 } from "uuid"; 18 - import { createIdentity } from "./createIdentity"; 19 13 import { pool } from "supabase/pool"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 20 15 21 16 export async function loginWithEmailToken( 22 17 localLeaflets: { token: { id: string }; added_at: string }[], ··· 77 72 identity = existingIdentityFromCookie; 78 73 } 79 74 } else { 80 - // Create a new identity 81 - identity = await createIdentity(tx, { email: token.email }); 75 + const { data: newIdentity } = await supabaseServerClient 76 + .from("identities") 77 + .insert({ email: token.email }) 78 + .select() 79 + .single(); 80 + identity = newIdentity!; 82 81 } 83 82 } 84 83
+45 -6
actions/publishToPublication.ts
··· 2 2 3 3 import * as Y from "yjs"; 4 4 import * as base64 from "base64-js"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { getIdentityData } from "actions/getIdentityData"; 7 10 import { 8 11 AtpBaseClient, ··· 50 53 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 54 import { v7 } from "uuid"; 52 55 56 + type PublishResult = 57 + | { success: true; rkey: string; record: PubLeafletDocument.Record } 58 + | { success: false; error: OAuthSessionError }; 59 + 53 60 export async function publishToPublication({ 54 61 root_entity, 55 62 publication_uri, ··· 57 64 title, 58 65 description, 59 66 tags, 67 + cover_image, 60 68 entitiesToDelete, 61 69 }: { 62 70 root_entity: string; ··· 65 73 title?: string; 66 74 description?: string; 67 75 tags?: string[]; 76 + cover_image?: string | null; 68 77 entitiesToDelete?: string[]; 69 - }) { 70 - const oauthClient = await createOauthClient(); 78 + }): Promise<PublishResult> { 71 79 let identity = await getIdentityData(); 72 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 80 + if (!identity || !identity.atp_did) { 81 + return { 82 + success: false, 83 + error: { 84 + type: "oauth_session_expired", 85 + message: "Not authenticated", 86 + did: "", 87 + }, 88 + }; 89 + } 73 90 74 - let credentialSession = await oauthClient.restore(identity.atp_did); 91 + const sessionResult = await restoreOAuthSession(identity.atp_did); 92 + if (!sessionResult.ok) { 93 + return { success: false, error: sessionResult.error }; 94 + } 95 + let credentialSession = sessionResult.value; 75 96 let agent = new AtpBaseClient( 76 97 credentialSession.fetchHandler.bind(credentialSession), 77 98 ); ··· 135 156 theme = await extractThemeFromFacts(facts, root_entity, agent); 136 157 } 137 158 159 + // Upload cover image if provided 160 + let coverImageBlob: BlobRef | undefined; 161 + if (cover_image) { 162 + let scan = scanIndexLocal(facts); 163 + let [imageData] = scan.eav(cover_image, "block/image"); 164 + if (imageData) { 165 + let imageResponse = await fetch(imageData.data.src); 166 + if (imageResponse.status === 200) { 167 + let binary = await imageResponse.blob(); 168 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 169 + headers: { "Content-Type": binary.type }, 170 + }); 171 + coverImageBlob = blob.data.blob; 172 + } 173 + } 174 + } 175 + 138 176 let record: PubLeafletDocument.Record = { 139 177 publishedAt: new Date().toISOString(), 140 178 ...existingRecord, ··· 145 183 title: title || "Untitled", 146 184 description: description || "", 147 185 ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 186 + ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 148 187 pages: pages.map((p) => { 149 188 if (p.type === "canvas") { 150 189 return { ··· 217 256 await createMentionNotifications(result.uri, record, credentialSession.did!); 218 257 } 219 258 220 - return { rkey, record: JSON.parse(JSON.stringify(record)) }; 259 + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 221 260 } 222 261 223 262 async function processBlocksToPages(
+1 -1
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 5 5 import { AddTiny } from "components/Icons/AddTiny"; 6 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 - import { Menu, MenuItem } from "components/Layout"; 8 + import { Menu, MenuItem } from "components/Menu"; 9 9 import { useIsMobile } from "src/hooks/isMobile"; 10 10 11 11 export const CreateNewLeafletButton = (props: {}) => {
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 1 1 "use client"; 2 2 3 - import { Menu, MenuItem } from "components/Layout"; 3 + import { Menu, MenuItem } from "components/Menu"; 4 4 import { useState } from "react"; 5 5 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 6 import { useToaster } from "components/Toast";
+4 -1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 177 177 <div className="flex gap-2 w-full"> 178 178 <Avatar src={avatarSrc} displayName={displayName} /> 179 179 <div className="flex flex-col w-full min-w-0 grow"> 180 - <div className="flex flex-row gap-2"> 180 + <div className="flex flex-row gap-2 justify-between"> 181 181 <div className="text-tertiary text-sm truncate"> 182 182 <span className="font-bold text-secondary">{displayName}</span>{" "} 183 183 {isReply ? "replied" : "commented"} on{" "} ··· 191 191 ) : ( 192 192 <span className="italic text-accent-contrast">{postTitle}</span> 193 193 )} 194 + </div> 195 + <div className="text-tertiary text-sm shrink-0"> 196 + {timeAgo(record.createdAt)} 194 197 </div> 195 198 </div> 196 199 {isReply && parentRecord && (
+11 -8
app/(home-pages)/p/[didOrHandle]/layout.tsx
··· 9 9 import { Agent } from "@atproto/api"; 10 10 import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 11 11 import { Metadata } from "next"; 12 + import { cache } from "react"; 13 + 14 + // Cache the profile data call to prevent concurrent OAuth restores 15 + const getCachedProfileData = cache(async (did: string) => { 16 + return get_profile_data.handler( 17 + { didOrHandle: did }, 18 + { supabase: supabaseServerClient }, 19 + ); 20 + }); 12 21 13 22 export async function generateMetadata(props: { 14 23 params: Promise<{ didOrHandle: string }>; ··· 23 32 did = resolved; 24 33 } 25 34 26 - let profileData = await get_profile_data.handler( 27 - { didOrHandle: did }, 28 - { supabase: supabaseServerClient }, 29 - ); 35 + let profileData = await getCachedProfileData(did); 30 36 let { profile } = profileData.result; 31 37 32 38 if (!profile) return { title: "Profile - Leaflet" }; ··· 66 72 } 67 73 did = resolved; 68 74 } 69 - let profileData = await get_profile_data.handler( 70 - { didOrHandle: did }, 71 - { supabase: supabaseServerClient }, 72 - ); 75 + let profileData = await getCachedProfileData(did); 73 76 let { publications, profile } = profileData.result; 74 77 75 78 if (!profile) return null;
+1 -1
app/[leaflet_id]/actions/HelpButton.tsx
··· 161 161 className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 162 style={{ 163 163 backgroundColor: isHovered 164 - ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 164 + ? "rgb(var(--accent-light))" 165 165 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 166 }} 167 167 onMouseEnter={handleMouseEnter}
+45 -7
app/[leaflet_id]/actions/PublishButton.tsx
··· 13 13 import { PublishSmall } from "components/Icons/PublishSmall"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { InputWithLabel } from "components/Input"; 16 - import { Menu, MenuItem } from "components/Layout"; 16 + import { Menu, MenuItem } from "components/Menu"; 17 17 import { 18 18 useLeafletDomains, 19 19 useLeafletPublicationData, ··· 39 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 41 import { AddTiny } from "components/Icons/AddTiny"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 42 43 43 44 export const PublishButton = (props: { entityID: string }) => { 44 45 let { data: pub } = useLeafletPublicationData(); ··· 68 69 let { identity } = useIdentityData(); 69 70 let toaster = useToaster(); 70 71 72 + // Get title and description from Replicache state (same as draft editor) 73 + // This ensures we use the latest edited values, not stale cached data 74 + let replicacheTitle = useSubscribe(rep, (tx) => 75 + tx.get<string>("publication_title"), 76 + ); 77 + let replicacheDescription = useSubscribe(rep, (tx) => 78 + tx.get<string>("publication_description"), 79 + ); 80 + 81 + // Use Replicache state if available, otherwise fall back to pub data 82 + const currentTitle = 83 + typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || ""; 84 + const currentDescription = 85 + typeof replicacheDescription === "string" 86 + ? replicacheDescription 87 + : pub?.description || ""; 88 + 71 89 // Get tags from Replicache state (same as draft editor) 72 90 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 91 const currentTags = Array.isArray(tags) ? tags : []; 74 92 93 + // Get cover image from Replicache state 94 + let coverImage = useSubscribe(rep, (tx) => 95 + tx.get<string | null>("publication_cover_image"), 96 + ); 97 + 75 98 return ( 76 99 <ActionButton 77 100 primary ··· 80 103 onClick={async () => { 81 104 if (!pub) return; 82 105 setIsLoading(true); 83 - let doc = await publishToPublication({ 106 + let result = await publishToPublication({ 84 107 root_entity: rootEntity, 85 108 publication_uri: pub.publications?.uri, 86 109 leaflet_id: permission_token.id, 87 - title: pub.title, 88 - description: pub.description, 110 + title: currentTitle, 111 + description: currentDescription, 89 112 tags: currentTags, 113 + cover_image: coverImage, 90 114 }); 91 115 setIsLoading(false); 92 116 mutate(); 93 117 118 + if (!result.success) { 119 + toaster({ 120 + content: isOAuthSessionError(result.error) ? ( 121 + <OAuthErrorMessage error={result.error} /> 122 + ) : ( 123 + "Failed to publish" 124 + ), 125 + type: "error", 126 + }); 127 + return; 128 + } 129 + 94 130 // Generate URL based on whether it's in a publication or standalone 95 131 let docUrl = pub.publications 96 - ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 97 - : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 132 + ? `${getPublicationURL(pub.publications)}/${result.rkey}` 133 + : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; 98 134 99 135 toaster({ 100 136 content: ( 101 137 <div> 102 138 {pub.doc ? "Updated! " : "Published! "} 103 - <SpeedyLink href={docUrl}>link</SpeedyLink> 139 + <SpeedyLink className="underline" href={docUrl}> 140 + See Published Post 141 + </SpeedyLink> 104 142 </div> 105 143 ), 106 144 type: "success",
+1 -1
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 3 3 import { getShareLink } from "./getShareLink"; 4 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Layout"; 6 + import { Menu, MenuItem } from "components/Menu"; 7 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 8 import useSWR from "swr"; 9 9 import LoginForm from "app/login/LoginForm";
+54 -22
app/[leaflet_id]/publish/PublishPost.tsx
··· 22 22 import { TagSelector } from "../../../components/Tags"; 23 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 24 import { PubIcon } from "components/ActionBar/Publications"; 25 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 25 26 26 27 type Props = { 27 28 title: string; ··· 65 66 let [charCount, setCharCount] = useState(0); 66 67 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 68 let [isLoading, setIsLoading] = useState(false); 69 + let [oauthError, setOauthError] = useState< 70 + import("src/atproto-oauth").OAuthSessionError | null 71 + >(null); 68 72 let params = useParams(); 69 73 let { rep } = useReplicache(); 70 74 ··· 73 77 tx.get<string[]>("publication_tags"), 74 78 ); 75 79 let [localTags, setLocalTags] = useState<string[]>([]); 80 + 81 + // Get cover image from Replicache 82 + let replicacheCoverImage = useSubscribe(rep, (tx) => 83 + tx.get<string | null>("publication_cover_image"), 84 + ); 76 85 77 86 // Use Replicache tags only when we have a draft 78 87 const hasDraft = props.hasDraft; ··· 96 105 async function submit() { 97 106 if (isLoading) return; 98 107 setIsLoading(true); 108 + setOauthError(null); 99 109 await rep?.push(); 100 - let doc = await publishToPublication({ 110 + let result = await publishToPublication({ 101 111 root_entity: props.root_entity, 102 112 publication_uri: props.publication_uri, 103 113 leaflet_id: props.leaflet_id, 104 114 title: props.title, 105 115 description: props.description, 106 116 tags: currentTags, 117 + cover_image: replicacheCoverImage, 107 118 entitiesToDelete: props.entitiesToDelete, 108 119 }); 109 - if (!doc) return; 120 + 121 + if (!result.success) { 122 + setIsLoading(false); 123 + if (isOAuthSessionError(result.error)) { 124 + setOauthError(result.error); 125 + } 126 + return; 127 + } 110 128 111 129 // Generate post URL based on whether it's in a publication or standalone 112 130 let post_url = props.record?.base_path 113 - ? `https://${props.record.base_path}/${doc.rkey}` 114 - : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 131 + ? `https://${props.record.base_path}/${result.rkey}` 132 + : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 115 133 116 134 let [text, facets] = editorStateRef.current 117 135 ? editorStateToFacetedText(editorStateRef.current) 118 136 : []; 119 - if (shareOption === "bluesky") 120 - await publishPostToBsky({ 137 + if (shareOption === "bluesky") { 138 + let bskyResult = await publishPostToBsky({ 121 139 facets: facets || [], 122 140 text: text || "", 123 141 title: props.title, 124 142 url: post_url, 125 143 description: props.description, 126 - document_record: doc.record, 127 - rkey: doc.rkey, 144 + document_record: result.record, 145 + rkey: result.rkey, 128 146 }); 147 + if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) { 148 + setIsLoading(false); 149 + setOauthError(bskyResult.error); 150 + return; 151 + } 152 + } 129 153 setIsLoading(false); 130 154 props.setPublishState({ state: "success", post_url }); 131 155 } ··· 162 186 </div> 163 187 <hr className="border-border mb-2" /> 164 188 165 - <div className="flex justify-between"> 166 - <Link 167 - className="hover:no-underline! font-bold" 168 - href={`/${params.leaflet_id}`} 169 - > 170 - Back 171 - </Link> 172 - <ButtonPrimary 173 - type="submit" 174 - className="place-self-end h-[30px]" 175 - disabled={charCount > 300} 176 - > 177 - {isLoading ? <DotLoader /> : "Publish this Post!"} 178 - </ButtonPrimary> 189 + <div className="flex flex-col gap-2"> 190 + <div className="flex justify-between"> 191 + <Link 192 + className="hover:no-underline! font-bold" 193 + href={`/${params.leaflet_id}`} 194 + > 195 + Back 196 + </Link> 197 + <ButtonPrimary 198 + type="submit" 199 + className="place-self-end h-[30px]" 200 + disabled={charCount > 300} 201 + > 202 + {isLoading ? <DotLoader /> : "Publish this Post!"} 203 + </ButtonPrimary> 204 + </div> 205 + {oauthError && ( 206 + <OAuthErrorMessage 207 + error={oauthError} 208 + className="text-right text-sm text-accent-contrast" 209 + /> 210 + )} 179 211 </div> 180 212 </div> 181 213 </form>
+56 -16
app/[leaflet_id]/publish/publishBskyPost.ts
··· 9 9 import { TID } from "@atproto/common"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 11 import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 - import { createOauthClient } from "src/atproto-oauth"; 12 + import { 13 + restoreOAuthSession, 14 + OAuthSessionError, 15 + } from "src/atproto-oauth"; 13 16 import { supabaseServerClient } from "supabase/serverClient"; 14 17 import { Json } from "supabase/database.types"; 15 18 import { 16 19 getMicroLinkOgImage, 17 20 getWebpageImage, 18 21 } from "src/utils/getMicroLinkOgImage"; 22 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 23 + 24 + type PublishBskyResult = 25 + | { success: true } 26 + | { success: false; error: OAuthSessionError }; 19 27 20 28 export async function publishPostToBsky(args: { 21 29 text: string; ··· 25 33 document_record: PubLeafletDocument.Record; 26 34 rkey: string; 27 35 facets: AppBskyRichtextFacet.Main[]; 28 - }) { 29 - const oauthClient = await createOauthClient(); 36 + }): Promise<PublishBskyResult> { 30 37 let identity = await getIdentityData(); 31 - if (!identity || !identity.atp_did) return null; 38 + if (!identity || !identity.atp_did) { 39 + return { 40 + success: false, 41 + error: { 42 + type: "oauth_session_expired", 43 + message: "Not authenticated", 44 + did: "", 45 + }, 46 + }; 47 + } 32 48 33 - let credentialSession = await oauthClient.restore(identity.atp_did); 49 + const sessionResult = await restoreOAuthSession(identity.atp_did); 50 + if (!sessionResult.ok) { 51 + return { success: false, error: sessionResult.error }; 52 + } 53 + let credentialSession = sessionResult.value; 34 54 let agent = new AtpBaseClient( 35 55 credentialSession.fetchHandler.bind(credentialSession), 36 56 ); 37 - let newPostUrl = args.url; 38 - let preview_image = await getWebpageImage(newPostUrl, { 39 - width: 1400, 40 - height: 733, 41 - noCache: true, 42 - }); 43 57 44 - let binary = await preview_image.blob(); 45 - let resized_preview_image = await sharp(await binary.arrayBuffer()) 58 + // Get image binary - prefer cover image, fall back to screenshot 59 + let imageBinary: Blob | null = null; 60 + 61 + if (args.document_record.coverImage) { 62 + let cid = 63 + (args.document_record.coverImage.ref as unknown as { $link: string })[ 64 + "$link" 65 + ] || args.document_record.coverImage.ref.toString(); 66 + 67 + let coverImageResponse = await fetchAtprotoBlob(identity.atp_did, cid); 68 + if (coverImageResponse) { 69 + imageBinary = await coverImageResponse.blob(); 70 + } 71 + } 72 + 73 + // Fall back to screenshot if no cover image or fetch failed 74 + if (!imageBinary) { 75 + let preview_image = await getWebpageImage(args.url, { 76 + width: 1400, 77 + height: 733, 78 + noCache: true, 79 + }); 80 + imageBinary = await preview_image.blob(); 81 + } 82 + 83 + // Resize and upload 84 + let resizedImage = await sharp(await imageBinary.arrayBuffer()) 46 85 .resize({ 47 86 width: 1200, 87 + height: 630, 48 88 fit: "cover", 49 89 }) 50 90 .webp({ quality: 85 }) 51 91 .toBuffer(); 52 92 53 - let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, { 54 - headers: { "Content-Type": binary.type }, 93 + let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, { 94 + headers: { "Content-Type": "image/webp" }, 55 95 }); 56 96 let bsky = new BskyAgent(credentialSession); 57 97 let post = await bsky.app.bsky.feed.post.create( ··· 90 130 data: record as Json, 91 131 }) 92 132 .eq("uri", result.uri); 93 - return true; 133 + return { success: true }; 94 134 }
+29 -11
app/api/atproto_images/route.ts
··· 1 1 import { IdResolver } from "@atproto/identity"; 2 2 import { NextRequest, NextResponse } from "next/server"; 3 + 3 4 let idResolver = new IdResolver(); 4 5 5 - export async function GET(req: NextRequest) { 6 - const url = new URL(req.url); 7 - const params = { 8 - did: url.searchParams.get("did") ?? "", 9 - cid: url.searchParams.get("cid") ?? "", 10 - }; 11 - if (!params.did || !params.cid) 12 - return new NextResponse(null, { status: 404 }); 6 + /** 7 + * Fetches a blob from an AT Protocol PDS given a DID and CID 8 + * Returns the Response object or null if the blob couldn't be fetched 9 + */ 10 + export async function fetchAtprotoBlob( 11 + did: string, 12 + cid: string, 13 + ): Promise<Response | null> { 14 + if (!did || !cid) return null; 13 15 14 - let identity = await idResolver.did.resolve(params.did); 16 + let identity = await idResolver.did.resolve(did); 15 17 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 16 - if (!service) return new NextResponse(null, { status: 404 }); 18 + if (!service) return null; 19 + 17 20 const response = await fetch( 18 - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`, 21 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 19 22 { 20 23 headers: { 21 24 "Accept-Encoding": "gzip, deflate, br, zstd", 22 25 }, 23 26 }, 24 27 ); 28 + 29 + if (!response.ok) return null; 30 + 31 + return response; 32 + } 33 + 34 + export async function GET(req: NextRequest) { 35 + const url = new URL(req.url); 36 + const params = { 37 + did: url.searchParams.get("did") ?? "", 38 + cid: url.searchParams.get("cid") ?? "", 39 + }; 40 + 41 + const response = await fetchAtprotoBlob(params.did, params.cid); 42 + if (!response) return new NextResponse(null, { status: 404 }); 25 43 26 44 // Clone the response to modify headers 27 45 const cachedResponse = new Response(response.body, response);
+41
app/api/bsky/agent.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { cookies } from "next/headers"; 3 + import { createOauthClient } from "src/atproto-oauth"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function getAuthenticatedAgent(): Promise<Agent | null> { 7 + try { 8 + const cookieStore = await cookies(); 9 + const authToken = 10 + cookieStore.get("auth_token")?.value || 11 + cookieStore.get("external_auth_token")?.value; 12 + 13 + if (!authToken || authToken === "null") return null; 14 + 15 + const { data } = await supabaseServerClient 16 + .from("email_auth_tokens") 17 + .select("identities(atp_did)") 18 + .eq("id", authToken) 19 + .eq("confirmed", true) 20 + .single(); 21 + 22 + const did = data?.identities?.atp_did; 23 + if (!did) return null; 24 + 25 + const oauthClient = await createOauthClient(); 26 + const session = await oauthClient.restore(did); 27 + return new Agent(session); 28 + } catch (error) { 29 + console.error("Failed to get authenticated agent:", error); 30 + return null; 31 + } 32 + } 33 + 34 + export async function getAgent(): Promise<Agent> { 35 + const agent = await getAuthenticatedAgent(); 36 + if (agent) return agent; 37 + 38 + return new Agent({ 39 + service: "https://public.api.bsky.app", 40 + }); 41 + }
+41
app/api/bsky/quotes/route.ts
··· 1 + import { lexToJson } from "@atproto/api"; 2 + import { NextRequest } from "next/server"; 3 + import { getAgent } from "../agent"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + export async function GET(req: NextRequest) { 8 + try { 9 + const searchParams = req.nextUrl.searchParams; 10 + const uri = searchParams.get("uri"); 11 + const cursor = searchParams.get("cursor"); 12 + const limit = searchParams.get("limit"); 13 + 14 + if (!uri) { 15 + return Response.json( 16 + { error: "uri parameter is required" }, 17 + { status: 400 }, 18 + ); 19 + } 20 + 21 + const agent = await getAgent(); 22 + 23 + const response = await agent.app.bsky.feed.getQuotes({ 24 + uri, 25 + limit: limit ? parseInt(limit, 10) : 50, 26 + cursor: cursor || undefined, 27 + }); 28 + 29 + const result = lexToJson(response.data); 30 + 31 + return Response.json(result, { 32 + headers: { 33 + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating 34 + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", 35 + }, 36 + }); 37 + } catch (error) { 38 + console.error("Error fetching Bluesky quotes:", error); 39 + return Response.json({ error: "Failed to fetch quotes" }, { status: 500 }); 40 + } 41 + }
+3 -40
app/api/bsky/thread/route.ts
··· 1 - import { Agent, lexToJson } from "@atproto/api"; 2 - import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 3 - import { cookies } from "next/headers"; 1 + import { lexToJson } from "@atproto/api"; 4 2 import { NextRequest } from "next/server"; 5 - import { createOauthClient } from "src/atproto-oauth"; 6 - import { supabaseServerClient } from "supabase/serverClient"; 3 + import { getAgent } from "../agent"; 7 4 8 5 export const runtime = "nodejs"; 9 6 10 - async function getAuthenticatedAgent(): Promise<Agent | null> { 11 - try { 12 - const cookieStore = await cookies(); 13 - const authToken = 14 - cookieStore.get("auth_token")?.value || 15 - cookieStore.get("external_auth_token")?.value; 16 - 17 - if (!authToken || authToken === "null") return null; 18 - 19 - const { data } = await supabaseServerClient 20 - .from("email_auth_tokens") 21 - .select("identities(atp_did)") 22 - .eq("id", authToken) 23 - .eq("confirmed", true) 24 - .single(); 25 - 26 - const did = data?.identities?.atp_did; 27 - if (!did) return null; 28 - 29 - const oauthClient = await createOauthClient(); 30 - const session = await oauthClient.restore(did); 31 - return new Agent(session); 32 - } catch (error) { 33 - console.error("Failed to get authenticated agent:", error); 34 - return null; 35 - } 36 - } 37 - 38 7 export async function GET(req: NextRequest) { 39 8 try { 40 9 const searchParams = req.nextUrl.searchParams; ··· 49 18 ); 50 19 } 51 20 52 - // Try to use authenticated agent if user is logged in, otherwise fall back to public API 53 - let agent = await getAuthenticatedAgent(); 54 - if (!agent) { 55 - agent = new Agent({ 56 - service: "https://public.api.bsky.app", 57 - }); 58 - } 21 + const agent = await getAgent(); 59 22 60 23 const response = await agent.getPostThread({ 61 24 uri,
+5 -7
app/api/inngest/functions/index_follows.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtpAgent, AtUri } from "@atproto/api"; 3 - import { createIdentity } from "actions/createIdentity"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 3 import { inngest } from "../client"; 6 - import { pool } from "supabase/pool"; 7 4 8 5 export const index_follows = inngest.createFunction( 9 6 { ··· 58 55 .eq("atp_did", event.data.did) 59 56 .single(); 60 57 if (!exists) { 61 - const client = await pool.connect(); 62 - let db = drizzle(client); 63 - let identity = await createIdentity(db, { atp_did: event.data.did }); 64 - client.release(); 58 + const { data: identity } = await supabaseServerClient 59 + .from("identities") 60 + .insert({ atp_did: event.data.did }) 61 + .select() 62 + .single(); 65 63 return identity; 66 64 } 67 65 }),
+8 -9
app/api/oauth/[route]/route.ts
··· 1 - import { createIdentity } from "actions/createIdentity"; 2 1 import { subscribeToPublication } from "app/lish/subscribeToPublication"; 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 2 import { cookies } from "next/headers"; 5 3 import { redirect } from "next/navigation"; 6 4 import { NextRequest, NextResponse } from "next/server"; ··· 13 11 ActionAfterSignIn, 14 12 parseActionFromSearchParam, 15 13 } from "./afterSignInActions"; 16 - import { pool } from "supabase/pool"; 17 14 18 15 type OauthRequestClientState = { 19 16 redirect: string | null; ··· 80 77 81 78 return handleAction(s.action, redirectPath); 82 79 } 83 - const client = await pool.connect(); 84 - const db = drizzle(client); 85 - identity = await createIdentity(db, { atp_did: session.did }); 86 - client.release(); 80 + const { data } = await supabaseServerClient 81 + .from("identities") 82 + .insert({ atp_did: session.did }) 83 + .select() 84 + .single(); 85 + identity = data; 87 86 } 88 87 let { data: token } = await supabaseServerClient 89 88 .from("email_auth_tokens") 90 89 .insert({ 91 - identity: identity.id, 90 + identity: identity!.id, 92 91 confirmed: true, 93 92 confirmation_code: "", 94 93 }) ··· 121 120 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 122 121 if (action?.action === "subscribe") { 123 122 let result = await subscribeToPublication(action.publication); 124 - if (result.hasFeed === false) 123 + if (result.success && result.hasFeed === false) 125 124 url.searchParams.set("showSubscribeSuccess", "true"); 126 125 } 127 126
+6
app/api/rpc/[command]/pull.ts
··· 74 74 description: string; 75 75 title: string; 76 76 tags: string[]; 77 + cover_image: string | null; 77 78 }[]; 78 79 let pub_patch = publication_data?.[0] 79 80 ? [ ··· 91 92 op: "put", 92 93 key: "publication_tags", 93 94 value: publication_data[0].tags || [], 95 + }, 96 + { 97 + op: "put", 98 + key: "publication_cover_image", 99 + value: publication_data[0].cover_image || null, 94 100 }, 95 101 ] 96 102 : [];
+18 -13
app/globals.css
··· 107 107 --highlight-3: 255, 205, 195; 108 108 109 109 --list-marker-width: 36px; 110 - --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); 111 - --page-width-units: min(624px, calc(100vw - 12px)); 110 + --page-width-unitless: min( 111 + var(--page-width-setting), 112 + calc(var(--leaflet-width-unitless) - 12) 113 + ); 114 + --page-width-units: min( 115 + calc(var(--page-width-unitless) * 1px), 116 + calc(100vw - 12px) 117 + ); 112 118 113 119 --gripperSVG: url("/gripperPattern.svg"); 114 120 --gripperSVG2: url("/gripperPattern2.svg"); ··· 125 131 126 132 @media (min-width: 640px) { 127 133 :root { 134 + /*picks between max width and screen width with 64px of padding*/ 128 135 --page-width-unitless: min( 129 - 624, 136 + var(--page-width-setting), 130 137 calc(var(--leaflet-width-unitless) - 128) 131 138 ); 132 - --page-width-units: min(624px, calc(100vw - 128px)); 133 - } 134 - } 135 - 136 - @media (min-width: 1280px) { 137 - :root { 138 - --page-width-unitless: min( 139 - 624, 140 - calc((var(--leaflet-width-unitless) / 2) - 32) 139 + --page-width-units: min( 140 + calc(var(--page-width-unitless) * 1px), 141 + calc(100vw - 128px) 141 142 ); 142 - --page-width-units: min(624px, calc((100vw / 2) - 32px)); 143 143 } 144 144 } 145 145 ··· 270 270 } 271 271 272 272 pre.shiki { 273 + @apply sm:p-3; 273 274 @apply p-2; 274 275 @apply rounded-md; 275 276 @apply overflow-auto; 277 + 278 + @media (min-width: 640px) { 279 + @apply p-3; 280 + } 276 281 } 277 282 278 283 .highlight:has(+ .highlight) {
+20 -3
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 27 27 28 export const SubscribeWithBluesky = (props: { 28 29 pubName: string; ··· 105 106 )} 106 107 107 108 <a 108 - href={`https://${props.base_url}/rss`} 109 + href={`${props.base_url}/rss`} 109 110 className="flex" 110 111 target="_blank" 111 112 aria-label="Subscribe to RSS" ··· 133 134 }) => { 134 135 let { identity } = useIdentityData(); 135 136 let toaster = useToaster(); 137 + let [oauthError, setOauthError] = useState< 138 + import("src/atproto-oauth").OAuthSessionError | null 139 + >(null); 136 140 let [, subscribe, subscribePending] = useActionState(async () => { 141 + setOauthError(null); 137 142 let result = await subscribeToPublication( 138 143 props.pub_uri, 139 144 window.location.href + "?refreshAuth", 140 145 ); 146 + if (!result.success) { 147 + if (isOAuthSessionError(result.error)) { 148 + setOauthError(result.error); 149 + } 150 + return; 151 + } 141 152 if (result.hasFeed === false) { 142 153 props.setSuccessModalOpen(true); 143 154 } ··· 172 183 } 173 184 174 185 return ( 175 - <> 186 + <div className="flex flex-col gap-2 place-self-center"> 176 187 <form 177 188 action={subscribe} 178 189 className="place-self-center flex flex-row gap-1" ··· 187 198 )} 188 199 </ButtonPrimary> 189 200 </form> 190 - </> 201 + {oauthError && ( 202 + <OAuthErrorMessage 203 + error={oauthError} 204 + className="text-center text-sm text-accent-1" 205 + /> 206 + )} 207 + </div> 191 208 ); 192 209 }; 193 210
+20 -197
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 - import { PubLeafletRichtextFacet } from "lexicons/api"; 3 - import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 - import { AtMentionLink } from "components/AtMentionLink"; 5 1 import { ProfilePopover } from "components/ProfilePopover"; 2 + import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore"; 3 + import { ReactNode } from "react"; 6 4 7 - type Facet = PubLeafletRichtextFacet.Main; 8 - export function BaseTextBlock(props: { 9 - plaintext: string; 10 - facets?: Facet[]; 11 - index: number[]; 12 - preview?: boolean; 13 - }) { 14 - let children = []; 15 - let richText = new RichText({ 16 - text: props.plaintext, 17 - facets: props.facets || [], 18 - }); 19 - let counter = 0; 20 - for (const segment of richText.segments()) { 21 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 22 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 23 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 24 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 25 - let isStrikethrough = segment.facet?.find( 26 - PubLeafletRichtextFacet.isStrikethrough, 27 - ); 28 - let isDidMention = segment.facet?.find( 29 - PubLeafletRichtextFacet.isDidMention, 30 - ); 31 - let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 32 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 33 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 34 - let isHighlighted = segment.facet?.find( 35 - PubLeafletRichtextFacet.isHighlight, 36 - ); 37 - let className = ` 38 - ${isCode ? "inline-code" : ""} 39 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 40 - ${isBold ? "font-bold" : ""} 41 - ${isItalic ? "italic" : ""} 42 - ${isUnderline ? "underline" : ""} 43 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 44 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 5 + // Re-export RichText for backwards compatibility 6 + export { RichText }; 45 7 46 - // Split text by newlines and insert <br> tags 47 - const textParts = segment.text.split("\n"); 48 - const renderedText = textParts.flatMap((part, i) => 49 - i < textParts.length - 1 50 - ? [part, <br key={`br-${counter}-${i}`} />] 51 - : [part], 52 - ); 53 - 54 - if (isCode) { 55 - children.push( 56 - <code key={counter} className={className} id={id?.id}> 57 - {renderedText} 58 - </code>, 59 - ); 60 - } else if (isDidMention) { 61 - children.push( 62 - <ProfilePopover 63 - key={counter} 64 - didOrHandle={isDidMention.did} 65 - trigger={<span className="mention">{renderedText}</span>} 66 - />, 67 - ); 68 - } else if (isAtMention) { 69 - children.push( 70 - <AtMentionLink 71 - key={counter} 72 - atURI={isAtMention.atURI} 73 - className={className} 74 - > 75 - {renderedText} 76 - </AtMentionLink>, 77 - ); 78 - } else if (link) { 79 - children.push( 80 - <a 81 - key={counter} 82 - href={link.uri} 83 - className={`text-accent-contrast hover:underline ${className}`} 84 - target="_blank" 85 - > 86 - {renderedText} 87 - </a>, 88 - ); 89 - } else { 90 - children.push( 91 - <span key={counter} className={className} id={id?.id}> 92 - {renderedText} 93 - </span>, 94 - ); 95 - } 96 - 97 - counter++; 98 - } 99 - return <>{children}</>; 8 + function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 9 + return ( 10 + <ProfilePopover 11 + didOrHandle={props.did} 12 + trigger={props.children} 13 + /> 14 + ); 100 15 } 101 16 102 - type RichTextSegment = { 103 - text: string; 104 - facet?: Exclude<Facet["features"], { $type: string }>; 105 - }; 106 - 107 - export class RichText { 108 - unicodeText: UnicodeString; 109 - facets?: Facet[]; 110 - 111 - constructor(props: { text: string; facets: Facet[] }) { 112 - this.unicodeText = new UnicodeString(props.text); 113 - this.facets = props.facets; 114 - if (this.facets) { 115 - this.facets = this.facets 116 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 117 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 118 - } 119 - } 120 - 121 - *segments(): Generator<RichTextSegment, void, void> { 122 - const facets = this.facets || []; 123 - if (!facets.length) { 124 - yield { text: this.unicodeText.utf16 }; 125 - return; 126 - } 127 - 128 - let textCursor = 0; 129 - let facetCursor = 0; 130 - do { 131 - const currFacet = facets[facetCursor]; 132 - if (textCursor < currFacet.index.byteStart) { 133 - yield { 134 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 135 - }; 136 - } else if (textCursor > currFacet.index.byteStart) { 137 - facetCursor++; 138 - continue; 139 - } 140 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 141 - const subtext = this.unicodeText.slice( 142 - currFacet.index.byteStart, 143 - currFacet.index.byteEnd, 144 - ); 145 - if (!subtext.trim()) { 146 - // dont empty string entities 147 - yield { text: subtext }; 148 - } else { 149 - yield { text: subtext, facet: currFacet.features }; 150 - } 151 - } 152 - textCursor = currFacet.index.byteEnd; 153 - facetCursor++; 154 - } while (facetCursor < facets.length); 155 - if (textCursor < this.unicodeText.length) { 156 - yield { 157 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 158 - }; 159 - } 160 - } 161 - } 162 - function addFacet(facets: Facet[], newFacet: Facet, length: number) { 163 - if (facets.length === 0) { 164 - return [newFacet]; 165 - } 166 - 167 - const allFacets = [...facets, newFacet]; 168 - 169 - // Collect all boundary positions 170 - const boundaries = new Set<number>(); 171 - boundaries.add(0); 172 - boundaries.add(length); 173 - 174 - for (const facet of allFacets) { 175 - boundaries.add(facet.index.byteStart); 176 - boundaries.add(facet.index.byteEnd); 177 - } 178 - 179 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 180 - const result: Facet[] = []; 181 - 182 - // Process segments between consecutive boundaries 183 - for (let i = 0; i < sortedBoundaries.length - 1; i++) { 184 - const start = sortedBoundaries[i]; 185 - const end = sortedBoundaries[i + 1]; 186 - 187 - // Find facets that are active at the start position 188 - const activeFacets = allFacets.filter( 189 - (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 190 - ); 191 - 192 - // Only create facet if there are active facets (features present) 193 - if (activeFacets.length > 0) { 194 - const features = activeFacets.flatMap((f) => f.features); 195 - result.push({ 196 - index: { byteStart: start, byteEnd: end }, 197 - features, 198 - }); 199 - } 200 - } 201 - 202 - return result; 17 + export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 18 + return ( 19 + <TextBlockCore 20 + {...props} 21 + renderers={{ 22 + DidMention: DidMentionWithPopover, 23 + }} 24 + /> 25 + ); 203 26 }
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import useSWR from "swr"; 4 + import { PageWrapper } from "components/Pages/Page"; 5 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 + import { DotLoader } from "components/utils/DotLoader"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { openPage } from "./PostPages"; 9 + import { BskyPostContent } from "./BskyPostContent"; 10 + import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 11 + 12 + // Re-export for backwards compatibility 13 + export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; 14 + 15 + type PostView = AppBskyFeedDefs.PostView; 16 + 17 + export function BlueskyQuotesPage(props: { 18 + postUri: string; 19 + pageId: string; 20 + pageOptions?: React.ReactNode; 21 + hasPageBackground: boolean; 22 + }) { 23 + const { postUri, pageId, pageOptions } = props; 24 + const drawer = useDrawerOpen(postUri); 25 + 26 + const { 27 + data: quotesData, 28 + isLoading, 29 + error, 30 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 31 + 32 + return ( 33 + <PageWrapper 34 + pageType="doc" 35 + fullPageScroll={false} 36 + id={`post-page-${pageId}`} 37 + drawerOpen={!!drawer} 38 + pageOptions={pageOptions} 39 + > 40 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 41 + <div className="text-secondary font-bold mb-3 flex items-center gap-2"> 42 + <QuoteTiny /> 43 + Bluesky Quotes 44 + </div> 45 + {isLoading ? ( 46 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 47 + <span>loading quotes</span> 48 + <DotLoader /> 49 + </div> 50 + ) : error ? ( 51 + <div className="text-tertiary italic text-sm text-center py-8"> 52 + Failed to load quotes 53 + </div> 54 + ) : quotesData && quotesData.posts.length > 0 ? ( 55 + <QuotesContent posts={quotesData.posts} postUri={postUri} /> 56 + ) : ( 57 + <div className="text-tertiary italic text-sm text-center py-8"> 58 + No quotes yet 59 + </div> 60 + )} 61 + </div> 62 + </PageWrapper> 63 + ); 64 + } 65 + 66 + function QuotesContent(props: { posts: PostView[]; postUri: string }) { 67 + const { posts, postUri } = props; 68 + 69 + return ( 70 + <div className="flex flex-col gap-0"> 71 + {posts.map((post) => ( 72 + <QuotePost 73 + key={post.uri} 74 + post={post} 75 + quotesUri={postUri} 76 + /> 77 + ))} 78 + </div> 79 + ); 80 + } 81 + 82 + function QuotePost(props: { 83 + post: PostView; 84 + quotesUri: string; 85 + }) { 86 + const { post, quotesUri } = props; 87 + const parent = { type: "quotes" as const, uri: quotesUri }; 88 + 89 + return ( 90 + <div 91 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 92 + onClick={() => openPage(parent, { type: "thread", uri: post.uri })} 93 + > 94 + <BskyPostContent 95 + post={post} 96 + parent={parent} 97 + linksEnabled={true} 98 + showEmbed={true} 99 + showBlueskyLink={true} 100 + onLinkClick={(e) => e.stopPropagation()} 101 + onEmbedClick={(e) => e.stopPropagation()} 102 + /> 103 + </div> 104 + ); 105 + }
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import { 4 + BlueskyEmbed, 5 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 6 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 7 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 + import { CommentTiny } from "components/Icons/CommentTiny"; 9 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 10 + import { Separator } from "components/Layout"; 11 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 + import { OpenPage } from "./PostPages"; 14 + import { ThreadLink, QuotesLink } from "./PostLinks"; 15 + 16 + type PostView = AppBskyFeedDefs.PostView; 17 + 18 + export function BskyPostContent(props: { 19 + post: PostView; 20 + parent?: OpenPage; 21 + linksEnabled?: boolean; 22 + avatarSize?: "sm" | "md"; 23 + showEmbed?: boolean; 24 + showBlueskyLink?: boolean; 25 + onEmbedClick?: (e: React.MouseEvent) => void; 26 + onLinkClick?: (e: React.MouseEvent) => void; 27 + }) { 28 + const { 29 + post, 30 + parent, 31 + linksEnabled = true, 32 + avatarSize = "md", 33 + showEmbed = true, 34 + showBlueskyLink = true, 35 + onEmbedClick, 36 + onLinkClick, 37 + } = props; 38 + 39 + const record = post.record as AppBskyFeedPost.Record; 40 + const postId = post.uri.split("/")[4]; 41 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 + 43 + const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 44 + 45 + return ( 46 + <> 47 + <div className="flex flex-col items-center shrink-0"> 48 + {post.author.avatar ? ( 49 + <img 50 + src={post.author.avatar} 51 + alt={`${post.author.displayName}'s avatar`} 52 + className={`${avatarClass} rounded-full border border-border-light`} 53 + /> 54 + ) : ( 55 + <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 + )} 57 + </div> 58 + 59 + <div className="flex flex-col grow min-w-0"> 60 + <div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}> 61 + <div className="font-bold text-secondary"> 62 + {post.author.displayName} 63 + </div> 64 + <a 65 + className="text-xs text-tertiary hover:underline" 66 + target="_blank" 67 + href={`https://bsky.app/profile/${post.author.handle}`} 68 + onClick={onLinkClick} 69 + > 70 + @{post.author.handle} 71 + </a> 72 + </div> 73 + 74 + <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 75 + <div className="text-sm text-secondary"> 76 + <BlueskyRichText record={record} /> 77 + </div> 78 + {showEmbed && post.embed && ( 79 + <div onClick={onEmbedClick}> 80 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 81 + </div> 82 + )} 83 + </div> 84 + 85 + <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 + <ClientDate date={record.createdAt} /> 87 + <PostCounts 88 + post={post} 89 + parent={parent} 90 + linksEnabled={linksEnabled} 91 + showBlueskyLink={showBlueskyLink} 92 + url={url} 93 + onLinkClick={onLinkClick} 94 + /> 95 + </div> 96 + </div> 97 + </> 98 + ); 99 + } 100 + 101 + function PostCounts(props: { 102 + post: PostView; 103 + parent?: OpenPage; 104 + linksEnabled: boolean; 105 + showBlueskyLink: boolean; 106 + url: string; 107 + onLinkClick?: (e: React.MouseEvent) => void; 108 + }) { 109 + const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 110 + 111 + return ( 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <Separator classname="h-3" /> 116 + {linksEnabled ? ( 117 + <ThreadLink 118 + threadUri={post.uri} 119 + parent={parent} 120 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 + onClick={onLinkClick} 122 + > 123 + {post.replyCount} 124 + <CommentTiny /> 125 + </ThreadLink> 126 + ) : ( 127 + <div className="flex items-center gap-1 text-tertiary text-xs"> 128 + {post.replyCount} 129 + <CommentTiny /> 130 + </div> 131 + )} 132 + </> 133 + )} 134 + {post.quoteCount != null && post.quoteCount > 0 && ( 135 + <> 136 + <Separator classname="h-3" /> 137 + <QuotesLink 138 + postUri={post.uri} 139 + parent={parent} 140 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 141 + onClick={onLinkClick} 142 + > 143 + {post.quoteCount} 144 + <QuoteTiny /> 145 + </QuotesLink> 146 + </> 147 + )} 148 + {showBlueskyLink && ( 149 + <> 150 + <Separator classname="h-3" /> 151 + <a 152 + className="text-tertiary" 153 + target="_blank" 154 + href={url} 155 + onClick={onLinkClick} 156 + > 157 + <BlueskyTiny /> 158 + </a> 159 + </> 160 + )} 161 + </div> 162 + ); 163 + } 164 + 165 + export const ClientDate = (props: { date?: string }) => { 166 + const pageLoaded = useHasPageLoaded(); 167 + const formattedDate = useLocalizedDate( 168 + props.date || new Date().toISOString(), 169 + { 170 + month: "short", 171 + day: "numeric", 172 + year: "numeric", 173 + hour: "numeric", 174 + minute: "numeric", 175 + hour12: true, 176 + }, 177 + ); 178 + 179 + if (!pageLoaded) return null; 180 + 181 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 182 + };
+7 -2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 57 57 <PageWrapper 58 58 pageType="canvas" 59 59 fullPageScroll={fullPageScroll} 60 - id={pageId ? `post-page-${pageId}` : "post-page"} 60 + id={`post-page-${pageId ?? document_uri}`} 61 61 drawerOpen={ 62 62 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 63 63 } ··· 202 202 isSubpage: boolean | undefined; 203 203 data: PostPageData; 204 204 profile: ProfileViewDetailed; 205 - preferences: { showComments?: boolean }; 205 + preferences: { 206 + showComments?: boolean; 207 + showMentions?: boolean; 208 + showPrevNext?: boolean; 209 + }; 206 210 quotesCount: number | undefined; 207 211 commentsCount: number | undefined; 208 212 }) => { ··· 213 217 quotesCount={props.quotesCount || 0} 214 218 commentsCount={props.commentsCount || 0} 215 219 showComments={props.preferences.showComments} 220 + showMentions={props.preferences.showMentions} 216 221 pageId={props.pageId} 217 222 /> 218 223 {!props.isSubpage && (
+21 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 38 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { useToaster } from "components/Toast"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 41 43 import { Mention, MentionAutocomplete } from "components/Mention"; 42 44 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 45 ··· 95 97 } = useInteractionState(props.doc_uri); 96 98 let [loading, setLoading] = useState(false); 97 99 let view = useRef<null | EditorView>(null); 100 + let toaster = useToaster(); 98 101 99 102 // Mention autocomplete state 100 103 const [mentionOpen, setMentionOpen] = useState(false); ··· 161 164 setLoading(true); 162 165 let currentState = view.current.state; 163 166 let [plaintext, facets] = docToFacetedText(currentState.doc); 164 - let comment = await publishComment({ 167 + let result = await publishComment({ 165 168 pageId: props.pageId, 166 169 document: props.doc_uri, 167 170 comment: { ··· 178 181 }, 179 182 }); 180 183 184 + if (!result.success) { 185 + setLoading(false); 186 + toaster({ 187 + content: isOAuthSessionError(result.error) ? ( 188 + <OAuthErrorMessage error={result.error} /> 189 + ) : ( 190 + "Failed to post comment" 191 + ), 192 + type: "error", 193 + }); 194 + return; 195 + } 196 + 181 197 let tr = currentState.tr; 182 198 tr = tr.replaceWith( 183 199 0, ··· 194 210 localComments: [ 195 211 ...s.localComments, 196 212 { 197 - record: comment.record, 198 - uri: comment.uri, 213 + record: result.record, 214 + uri: result.uri, 199 215 bsky_profiles: { 200 - record: comment.profile as Json, 201 - did: new AtUri(comment.uri).host, 216 + record: result.profile as Json, 217 + did: new AtUri(result.uri).host, 202 218 }, 203 219 }, 204 220 ],
+25 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 3 3 import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 12 import { supabaseServerClient } from "supabase/serverClient"; ··· 15 18 } from "src/notifications"; 16 19 import { v7 } from "uuid"; 17 20 21 + type PublishCommentResult = 22 + | { success: true; record: Json; profile: any; uri: string } 23 + | { success: false; error: OAuthSessionError }; 24 + 18 25 export async function publishComment(args: { 19 26 document: string; 20 27 pageId?: string; ··· 24 31 replyTo?: string; 25 32 attachment: PubLeafletComment.Record["attachment"]; 26 33 }; 27 - }) { 28 - const oauthClient = await createOauthClient(); 34 + }): Promise<PublishCommentResult> { 29 35 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 36 + if (!identity || !identity.atp_did) { 37 + return { 38 + success: false, 39 + error: { 40 + type: "oauth_session_expired", 41 + message: "Not authenticated", 42 + did: "", 43 + }, 44 + }; 45 + } 31 46 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 47 + const sessionResult = await restoreOAuthSession(identity.atp_did); 48 + if (!sessionResult.ok) { 49 + return { success: false, error: sessionResult.error }; 50 + } 51 + let credentialSession = sessionResult.value; 33 52 let agent = new AtpBaseClient( 34 53 credentialSession.fetchHandler.bind(credentialSession), 35 54 ); ··· 108 127 } 109 128 110 129 return { 130 + success: true, 111 131 record: data?.[0].record as Json, 112 132 profile: lexToJson(profile.value), 113 133 uri: uri.toString(),
+4 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 51 51 }, []); 52 52 53 53 return ( 54 - <div id={"commentsDrawer"} className="flex flex-col gap-2 relative"> 54 + <div 55 + id={"commentsDrawer"} 56 + className="flex flex-col gap-2 relative text-sm text-secondary" 57 + > 55 58 <div className="w-full flex justify-between text-secondary font-bold"> 56 59 Comments 57 60 <button
+2 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 9 9 import { decodeQuotePosition } from "../quotePosition"; 10 10 11 11 export const InteractionDrawer = (props: { 12 + showPageBackground: boolean | undefined; 12 13 document_uri: string; 13 14 quotesAndMentions: { uri: string; link?: string }[]; 14 15 comments: Comment[]; ··· 38 39 <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 40 <div 40 41 id="interaction-drawer" 41 - className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 42 + className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`} 42 43 > 43 44 {drawer.drawer === "quotes" ? ( 44 45 <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
+68 -44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 108 108 commentsCount: number; 109 109 className?: string; 110 110 showComments?: boolean; 111 + showMentions?: boolean; 111 112 pageId?: string; 112 113 }) => { 113 114 const data = useContext(PostPageContext); ··· 131 132 <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 133 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 134 134 - {props.quotesCount > 0 && ( 135 + {props.quotesCount === 0 || props.showMentions === false ? null : ( 135 136 <button 136 137 className="flex w-fit gap-2 items-center" 137 138 onClick={() => { ··· 168 169 commentsCount: number; 169 170 className?: string; 170 171 showComments?: boolean; 172 + showMentions?: boolean; 171 173 pageId?: string; 172 174 }) => { 173 175 const data = useContext(PostPageContext); ··· 189 191 const tags = (data?.data as any)?.tags as string[] | undefined; 190 192 const tagCount = tags?.length || 0; 191 193 194 + let noInteractions = !props.showComments && !props.showMentions; 195 + 192 196 let subscribed = 193 197 identity?.atp_did && 194 198 publication?.publication_subscriptions && ··· 229 233 <TagList tags={tags} className="mb-3" /> 230 234 </> 231 235 )} 236 + 232 237 <hr className="border-border-light mb-3 " /> 238 + 233 239 <div className="flex gap-2 justify-between"> 234 - <div className="flex gap-2"> 235 - {props.quotesCount > 0 && ( 236 - <button 237 - className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 238 - onClick={() => { 239 - if (!drawerOpen || drawer !== "quotes") 240 - openInteractionDrawer("quotes", document_uri, props.pageId); 241 - else setInteractionState(document_uri, { drawerOpen: false }); 242 - }} 243 - onMouseEnter={handleQuotePrefetch} 244 - onTouchStart={handleQuotePrefetch} 245 - aria-label="Post quotes" 246 - > 247 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 248 - <span 249 - aria-hidden 250 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 251 - </button> 252 - )} 253 - {props.showComments === false ? null : ( 254 - <button 255 - className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 256 - onClick={() => { 257 - if ( 258 - !drawerOpen || 259 - drawer !== "comments" || 260 - pageId !== props.pageId 261 - ) 262 - openInteractionDrawer("comments", document_uri, props.pageId); 263 - else setInteractionState(document_uri, { drawerOpen: false }); 264 - }} 265 - aria-label="Post comments" 266 - > 267 - <CommentTiny aria-hidden />{" "} 268 - {props.commentsCount > 0 ? ( 269 - <span aria-hidden> 270 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 271 - </span> 272 - ) : ( 273 - "Comment" 240 + {noInteractions ? ( 241 + <div /> 242 + ) : ( 243 + <> 244 + <div className="flex gap-2"> 245 + {props.quotesCount === 0 || 246 + props.showMentions === false ? null : ( 247 + <button 248 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 249 + onClick={() => { 250 + if (!drawerOpen || drawer !== "quotes") 251 + openInteractionDrawer( 252 + "quotes", 253 + document_uri, 254 + props.pageId, 255 + ); 256 + else 257 + setInteractionState(document_uri, { drawerOpen: false }); 258 + }} 259 + onMouseEnter={handleQuotePrefetch} 260 + onTouchStart={handleQuotePrefetch} 261 + aria-label="Post quotes" 262 + > 263 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 264 + <span 265 + aria-hidden 266 + >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 267 + </button> 274 268 )} 275 - </button> 276 - )} 277 - </div> 269 + {props.showComments === false ? null : ( 270 + <button 271 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 272 + onClick={() => { 273 + if ( 274 + !drawerOpen || 275 + drawer !== "comments" || 276 + pageId !== props.pageId 277 + ) 278 + openInteractionDrawer( 279 + "comments", 280 + document_uri, 281 + props.pageId, 282 + ); 283 + else 284 + setInteractionState(document_uri, { drawerOpen: false }); 285 + }} 286 + aria-label="Post comments" 287 + > 288 + <CommentTiny aria-hidden />{" "} 289 + {props.commentsCount > 0 ? ( 290 + <span aria-hidden> 291 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 292 + </span> 293 + ) : ( 294 + "Comment" 295 + )} 296 + </button> 297 + )} 298 + </div> 299 + </> 300 + )} 301 + 278 302 <EditButton document={data} /> 279 303 {subscribed && publication && ( 280 304 <ManageSubscription
+30 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 23 23 import useSWR, { mutate } from "swr"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 25 import { CommentTiny } from "components/Icons/CommentTiny"; 26 - import { ThreadLink } from "../ThreadPage"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { ThreadLink, QuotesLink } from "../PostLinks"; 27 28 28 29 // Helper to get SWR key for quotes 29 30 export function getQuotesSWRKey(uris: string[]) { ··· 138 139 profile={pv.author} 139 140 handle={pv.author.handle} 140 141 replyCount={pv.replyCount} 142 + quoteCount={pv.quoteCount} 141 143 /> 142 144 </div> 143 145 ); ··· 161 163 profile={pv.author} 162 164 handle={pv.author.handle} 163 165 replyCount={pv.replyCount} 166 + quoteCount={pv.quoteCount} 164 167 /> 165 168 ); 166 169 })} ··· 180 183 }) => { 181 184 let isMobile = useIsMobile(); 182 185 const data = useContext(PostPageContext); 186 + const document_uri = data?.uri; 183 187 184 188 let record = data?.data as PubLeafletDocument.Record; 185 189 let page: PubLeafletPagesLinearDocument.Main | undefined = ( ··· 211 215 let scrollMargin = isMobile 212 216 ? 16 213 217 : e.currentTarget.getBoundingClientRect().top; 214 - let scrollContainer = window.document.getElementById("post-page"); 218 + let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 219 + let scrollContainer = window.document.getElementById(scrollContainerId); 215 220 let el = window.document.getElementById( 216 221 props.position.start.block.join("."), 217 222 ); ··· 252 257 handle: string; 253 258 profile: ProfileViewBasic; 254 259 replyCount?: number; 260 + quoteCount?: number; 255 261 }) => { 256 262 const handleOpenThread = () => { 257 263 openPage(undefined, { type: "thread", uri: props.uri }); ··· 282 288 </a> 283 289 </div> 284 290 <div className="text-primary">{props.content}</div> 285 - {props.replyCount != null && props.replyCount > 0 && ( 286 - <ThreadLink 287 - threadUri={props.uri} 288 - onClick={(e) => e.stopPropagation()} 289 - className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast" 290 - > 291 - <CommentTiny /> 292 - {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 293 - </ThreadLink> 294 - )} 291 + <div className="flex gap-2 items-center mt-1"> 292 + {props.replyCount != null && props.replyCount > 0 && ( 293 + <ThreadLink 294 + threadUri={props.uri} 295 + onClick={(e) => e.stopPropagation()} 296 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 297 + > 298 + <CommentTiny /> 299 + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 300 + </ThreadLink> 301 + )} 302 + {props.quoteCount != null && props.quoteCount > 0 && ( 303 + <QuotesLink 304 + postUri={props.uri} 305 + onClick={(e) => e.stopPropagation()} 306 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 307 + > 308 + <QuoteTiny /> 309 + {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 310 + </QuotesLink> 311 + )} 312 + </div> 295 313 </div> 296 314 </div> 297 315 );
+8 -3
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 14 14 ExpandedInteractions, 15 15 getCommentCount, 16 16 getQuoteCount, 17 - Interactions, 18 17 } from "./Interactions/Interactions"; 19 18 import { PostContent } from "./PostContent"; 20 19 import { PostHeader } from "./PostHeader/PostHeader"; ··· 25 24 import { decodeQuotePosition } from "./quotePosition"; 26 25 import { PollData } from "./fetchPollData"; 27 26 import { SharedPageProps } from "./PostPages"; 27 + import { PostPrevNextButtons } from "./PostPrevNextButtons"; 28 28 29 29 export function LinearDocumentPage({ 30 30 blocks, ··· 56 56 57 57 const isSubpage = !!pageId; 58 58 59 + console.log("prev/next?: " + preferences.showPrevNext); 60 + 59 61 return ( 60 62 <> 61 63 <PageWrapper 62 64 pageType="doc" 63 65 fullPageScroll={fullPageScroll} 64 - id={pageId ? `post-page-${pageId}` : "post-page"} 66 + id={`post-page-${pageId ?? document_uri}`} 65 67 drawerOpen={ 66 68 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 67 69 } ··· 83 85 did={did} 84 86 prerenderedCodeBlocks={prerenderedCodeBlocks} 85 87 /> 86 - 88 + <PostPrevNextButtons 89 + showPrevNext={preferences.showPrevNext && !isSubpage} 90 + /> 87 91 <ExpandedInteractions 88 92 pageId={pageId} 89 93 showComments={preferences.showComments} 94 + showMentions={preferences.showMentions} 90 95 commentsCount={getCommentCount(document, pageId) || 0} 91 96 quotesCount={getQuoteCount(document, pageId) || 0} 92 97 />
+3 -2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 23 23 export function PostHeader(props: { 24 24 data: PostPageData; 25 25 profile: ProfileViewDetailed; 26 - preferences: { showComments?: boolean }; 26 + preferences: { showComments?: boolean; showMentions?: boolean }; 27 27 }) { 28 28 let { identity } = useIdentityData(); 29 29 let document = props.data; ··· 91 91 </div> 92 92 <Interactions 93 93 showComments={props.preferences.showComments} 94 + showMentions={props.preferences.showMentions} 94 95 quotesCount={getQuoteCount(document) || 0} 95 96 commentsCount={getCommentCount(document) || 0} 96 97 /> ··· 108 109 }) => { 109 110 return ( 110 111 <div 111 - className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 112 + className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 112 113 id="post-header" 113 114 > 114 115 <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import { preload } from "swr"; 4 + import { openPage, OpenPage } from "./PostPages"; 5 + 6 + type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 7 + type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 8 + type BlockedPost = AppBskyFeedDefs.BlockedPost; 9 + type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 10 + 11 + type PostView = AppBskyFeedDefs.PostView; 12 + 13 + export interface QuotesResponse { 14 + uri: string; 15 + cid?: string; 16 + cursor?: string; 17 + posts: PostView[]; 18 + } 19 + 20 + // Thread fetching 21 + export const getThreadKey = (uri: string) => `thread:${uri}`; 22 + 23 + export async function fetchThread(uri: string): Promise<ThreadType> { 24 + const params = new URLSearchParams({ uri }); 25 + const response = await fetch(`/api/bsky/thread?${params.toString()}`); 26 + 27 + if (!response.ok) { 28 + throw new Error("Failed to fetch thread"); 29 + } 30 + 31 + return response.json(); 32 + } 33 + 34 + export const prefetchThread = (uri: string) => { 35 + preload(getThreadKey(uri), () => fetchThread(uri)); 36 + }; 37 + 38 + // Quotes fetching 39 + export const getQuotesKey = (uri: string) => `quotes:${uri}`; 40 + 41 + export async function fetchQuotes(uri: string): Promise<QuotesResponse> { 42 + const params = new URLSearchParams({ uri }); 43 + const response = await fetch(`/api/bsky/quotes?${params.toString()}`); 44 + 45 + if (!response.ok) { 46 + throw new Error("Failed to fetch quotes"); 47 + } 48 + 49 + return response.json(); 50 + } 51 + 52 + export const prefetchQuotes = (uri: string) => { 53 + preload(getQuotesKey(uri), () => fetchQuotes(uri)); 54 + }; 55 + 56 + // Link component for opening thread pages with prefetching 57 + export function ThreadLink(props: { 58 + threadUri: string; 59 + parent?: OpenPage; 60 + children: React.ReactNode; 61 + className?: string; 62 + onClick?: (e: React.MouseEvent) => void; 63 + }) { 64 + const { threadUri, parent, children, className, onClick } = props; 65 + 66 + const handleClick = (e: React.MouseEvent) => { 67 + onClick?.(e); 68 + if (e.defaultPrevented) return; 69 + openPage(parent, { type: "thread", uri: threadUri }); 70 + }; 71 + 72 + const handlePrefetch = () => { 73 + prefetchThread(threadUri); 74 + }; 75 + 76 + return ( 77 + <button 78 + className={className} 79 + onClick={handleClick} 80 + onMouseEnter={handlePrefetch} 81 + onPointerDown={handlePrefetch} 82 + > 83 + {children} 84 + </button> 85 + ); 86 + } 87 + 88 + // Link component for opening quotes pages with prefetching 89 + export function QuotesLink(props: { 90 + postUri: string; 91 + parent?: OpenPage; 92 + children: React.ReactNode; 93 + className?: string; 94 + onClick?: (e: React.MouseEvent) => void; 95 + }) { 96 + const { postUri, parent, children, className, onClick } = props; 97 + 98 + const handleClick = (e: React.MouseEvent) => { 99 + onClick?.(e); 100 + if (e.defaultPrevented) return; 101 + openPage(parent, { type: "quotes", uri: postUri }); 102 + }; 103 + 104 + const handlePrefetch = () => { 105 + prefetchQuotes(postUri); 106 + }; 107 + 108 + return ( 109 + <button 110 + className={className} 111 + onClick={handleClick} 112 + onMouseEnter={handlePrefetch} 113 + onPointerDown={handlePrefetch} 114 + > 115 + {children} 116 + </button> 117 + ); 118 + }
+47 -6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 25 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 26 import { CanvasPage } from "./CanvasPage"; 27 27 import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 + import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 28 29 29 30 // Page types 30 31 export type DocPage = { type: "doc"; id: string }; 31 32 export type ThreadPage = { type: "thread"; uri: string }; 32 - export type OpenPage = DocPage | ThreadPage; 33 + export type QuotesPage = { type: "quotes"; uri: string }; 34 + export type OpenPage = DocPage | ThreadPage | QuotesPage; 33 35 34 36 // Get a stable key for a page 35 37 const getPageKey = (page: OpenPage): string => { 36 38 if (page.type === "doc") return page.id; 39 + if (page.type === "quotes") return `quotes:${page.uri}`; 37 40 return `thread:${page.uri}`; 38 41 }; 39 42 ··· 144 147 document: PostPageData; 145 148 did: string; 146 149 profile: ProfileViewDetailed; 147 - preferences: { showComments?: boolean }; 150 + preferences: { 151 + showComments?: boolean; 152 + showMentions?: boolean; 153 + showPrevNext?: boolean; 154 + }; 148 155 pubRecord?: PubLeafletPublication.Record; 149 156 theme?: PubLeafletPublication.Theme | null; 150 157 prerenderedCodeBlocks?: Map<string, string>; ··· 203 210 did: string; 204 211 prerenderedCodeBlocks?: Map<string, string>; 205 212 bskyPostData: AppBskyFeedDefs.PostView[]; 206 - preferences: { showComments?: boolean }; 213 + preferences: { 214 + showComments?: boolean; 215 + showMentions?: boolean; 216 + showPrevNext?: boolean; 217 + }; 207 218 pollData: PollData[]; 208 219 }) { 209 220 let drawer = useDrawerOpen(document_uri); ··· 258 269 259 270 {drawer && !drawer.pageId && ( 260 271 <InteractionDrawer 272 + showPageBackground={pubRecord?.theme?.showPageBackground} 261 273 document_uri={document.uri} 262 274 comments={ 263 275 pubRecord?.preferences?.showComments === false 264 276 ? [] 265 277 : document.comments_on_documents 266 278 } 267 - quotesAndMentions={quotesAndMentions} 279 + quotesAndMentions={ 280 + pubRecord?.preferences?.showMentions === false 281 + ? [] 282 + : quotesAndMentions 283 + } 268 284 did={did} 269 285 /> 270 286 )} ··· 292 308 ); 293 309 } 294 310 311 + // Handle quotes pages 312 + if (openPage.type === "quotes") { 313 + return ( 314 + <Fragment key={pageKey}> 315 + <SandwichSpacer /> 316 + <BlueskyQuotesPage 317 + postUri={openPage.uri} 318 + pageId={pageKey} 319 + hasPageBackground={hasPageBackground} 320 + pageOptions={ 321 + <PageOptions 322 + onClick={() => closePage(openPage)} 323 + hasPageBackground={hasPageBackground} 324 + /> 325 + } 326 + /> 327 + </Fragment> 328 + ); 329 + } 330 + 295 331 // Handle document pages 296 332 let page = record.pages.find( 297 333 (p) => ··· 324 360 /> 325 361 {drawer && drawer.pageId === page.id && ( 326 362 <InteractionDrawer 363 + showPageBackground={pubRecord?.theme?.showPageBackground} 327 364 pageId={page.id} 328 365 document_uri={document.uri} 329 366 comments={ ··· 331 368 ? [] 332 369 : document.comments_on_documents 333 370 } 334 - quotesAndMentions={quotesAndMentions} 371 + quotesAndMentions={ 372 + pubRecord?.preferences?.showMentions === false 373 + ? [] 374 + : quotesAndMentions 375 + } 335 376 did={did} 336 377 /> 337 378 )} ··· 351 392 return ( 352 393 <div 353 394 className={`pageOptions w-fit z-10 354 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 395 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 355 396 flex sm:flex-col flex-row-reverse gap-1 items-start`} 356 397 > 357 398 <PageOptionButton onClick={props.onClick}>
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 + "use client"; 2 + import { PubLeafletDocument } from "lexicons/api"; 3 + import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { AtUri } from "@atproto/api"; 6 + import { useParams } from "next/navigation"; 7 + import { getPostPageData } from "./getPostPageData"; 8 + import { PostPageContext } from "./PostPageContext"; 9 + import { useContext } from "react"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 + 13 + export const PostPrevNextButtons = (props: { 14 + showPrevNext: boolean | undefined; 15 + }) => { 16 + let postData = useContext(PostPageContext); 17 + let pub = postData?.documents_in_publications[0]?.publications; 18 + 19 + if (!props.showPrevNext || !pub || !postData) return; 20 + 21 + function getPostLink(uri: string) { 22 + return pub && uri 23 + ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 24 + : "leaflet.pub/not-found"; 25 + } 26 + let prevPost = postData?.prevNext?.prev; 27 + let nextPost = postData?.prevNext?.next; 28 + 29 + return ( 30 + <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2"> 31 + {/*<hr className="border-border-light" />*/} 32 + <div className="flex justify-between w-full gap-8 "> 33 + {nextPost ? ( 34 + <SpeedyLink 35 + href={getPostLink(nextPost.uri)} 36 + className="flex gap-1 items-center truncate min-w-0 basis-1/2" 37 + > 38 + <ArrowRightTiny className="rotate-180 shrink-0" /> 39 + <div className="min-w-0 truncate">{nextPost.title}</div> 40 + </SpeedyLink> 41 + ) : ( 42 + <div /> 43 + )} 44 + {prevPost ? ( 45 + <SpeedyLink 46 + href={getPostLink(prevPost.uri)} 47 + className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end" 48 + > 49 + <div className="min-w-0 truncate">{prevPost.title}</div> 50 + <ArrowRightTiny className="shrink-0" /> 51 + </SpeedyLink> 52 + ) : ( 53 + <div /> 54 + )} 55 + </div> 56 + </div> 57 + ); 58 + };
+16 -1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 4 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "./PostLinks"; 7 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 8 10 import { 9 11 BlueskyEmbed, ··· 11 13 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 12 14 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 13 15 import { openPage } from "./PostPages"; 14 - import { ThreadLink } from "./ThreadPage"; 15 16 16 17 export const PubBlueskyPostBlock = (props: { 17 18 post: PostView; ··· 118 119 {post.replyCount} 119 120 <CommentTiny /> 120 121 </ThreadLink> 122 + <Separator classname="h-4" /> 123 + </> 124 + )} 125 + {post.quoteCount != null && post.quoteCount > 0 && ( 126 + <> 127 + <QuotesLink 128 + postUri={post.uri} 129 + parent={parent} 130 + className="flex items-center gap-1 hover:text-accent-contrast" 131 + onClick={(e) => e.stopPropagation()} 132 + > 133 + {post.quoteCount} 134 + <QuoteTiny /> 135 + </QuotesLink> 121 136 <Separator classname="h-4" /> 122 137 </> 123 138 )}
+3 -2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 186 186 <BlueskyLinkTiny className="shrink-0" /> 187 187 Bluesky 188 188 </a> 189 - <Separator classname="h-4" /> 189 + <Separator classname="h-4!" /> 190 190 <button 191 191 id="copy-quote-link" 192 192 className="flex gap-1 items-center hover:font-bold px-1" ··· 211 211 </button> 212 212 {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 213 213 <> 214 - <Separator classname="h-4" /> 214 + <Separator classname="h-4! " /> 215 + 215 216 <button 216 217 className="flex gap-1 items-center hover:font-bold px-1" 217 218 onClick={() => {
+11 -7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 - import { BaseTextBlock } from "./BaseTextBlock"; 15 + import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore"; 16 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 + 19 + function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 20 + return <TextBlockCore {...props} />; 21 + } 18 22 19 23 export function StaticPostContent({ 20 24 blocks, ··· 47 51 case PubLeafletBlocksBlockquote.isMain(b.block): { 48 52 return ( 49 53 <blockquote className={` blockquote `}> 50 - <BaseTextBlock 54 + <StaticBaseTextBlock 51 55 facets={b.block.facets} 52 56 plaintext={b.block.plaintext} 53 57 index={[]} ··· 116 120 case PubLeafletBlocksText.isMain(b.block): 117 121 return ( 118 122 <p> 119 - <BaseTextBlock 123 + <StaticBaseTextBlock 120 124 facets={b.block.facets} 121 125 plaintext={b.block.plaintext} 122 126 index={[]} ··· 127 131 if (b.block.level === 1) 128 132 return ( 129 133 <h1> 130 - <BaseTextBlock {...b.block} index={[]} /> 134 + <StaticBaseTextBlock {...b.block} index={[]} /> 131 135 </h1> 132 136 ); 133 137 if (b.block.level === 2) 134 138 return ( 135 139 <h2> 136 - <BaseTextBlock {...b.block} index={[]} /> 140 + <StaticBaseTextBlock {...b.block} index={[]} /> 137 141 </h2> 138 142 ); 139 143 if (b.block.level === 3) 140 144 return ( 141 145 <h3> 142 - <BaseTextBlock {...b.block} index={[]} /> 146 + <StaticBaseTextBlock {...b.block} index={[]} /> 143 147 </h3> 144 148 ); 145 149 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 146 150 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 147 151 return ( 148 152 <h6> 149 - <BaseTextBlock {...b.block} index={[]} /> 153 + <StaticBaseTextBlock {...b.block} index={[]} /> 150 154 </h6> 151 155 ); 152 156 }
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { AtMentionLink } from "components/AtMentionLink"; 4 + import { ReactNode } from "react"; 5 + 6 + type Facet = PubLeafletRichtextFacet.Main; 7 + 8 + export type FacetRenderers = { 9 + DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 + }; 11 + 12 + export type TextBlockCoreProps = { 13 + plaintext: string; 14 + facets?: Facet[]; 15 + index: number[]; 16 + preview?: boolean; 17 + renderers?: FacetRenderers; 18 + }; 19 + 20 + export function TextBlockCore(props: TextBlockCoreProps) { 21 + let children = []; 22 + let richText = new RichText({ 23 + text: props.plaintext, 24 + facets: props.facets || [], 25 + }); 26 + let counter = 0; 27 + for (const segment of richText.segments()) { 28 + let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 + let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 + let isStrikethrough = segment.facet?.find( 33 + PubLeafletRichtextFacet.isStrikethrough, 34 + ); 35 + let isDidMention = segment.facet?.find( 36 + PubLeafletRichtextFacet.isDidMention, 37 + ); 38 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 + let isHighlighted = segment.facet?.find( 42 + PubLeafletRichtextFacet.isHighlight, 43 + ); 44 + let className = ` 45 + ${isCode ? "inline-code" : ""} 46 + ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 + ${isBold ? "font-bold" : ""} 48 + ${isItalic ? "italic" : ""} 49 + ${isUnderline ? "underline" : ""} 50 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 + 53 + // Split text by newlines and insert <br> tags 54 + const textParts = segment.text.split("\n"); 55 + const renderedText = textParts.flatMap((part, i) => 56 + i < textParts.length - 1 57 + ? [part, <br key={`br-${counter}-${i}`} />] 58 + : [part], 59 + ); 60 + 61 + if (isCode) { 62 + children.push( 63 + <code key={counter} className={className} id={id?.id}> 64 + {renderedText} 65 + </code>, 66 + ); 67 + } else if (isDidMention) { 68 + const DidMentionRenderer = props.renderers?.DidMention; 69 + if (DidMentionRenderer) { 70 + children.push( 71 + <DidMentionRenderer key={counter} did={isDidMention.did}> 72 + <span className="mention">{renderedText}</span> 73 + </DidMentionRenderer>, 74 + ); 75 + } else { 76 + // Default: render as a simple link 77 + children.push( 78 + <a 79 + key={counter} 80 + href={`https://leaflet.pub/p/${isDidMention.did}`} 81 + target="_blank" 82 + className="no-underline" 83 + > 84 + <span className="mention">{renderedText}</span> 85 + </a>, 86 + ); 87 + } 88 + } else if (isAtMention) { 89 + children.push( 90 + <AtMentionLink 91 + key={counter} 92 + atURI={isAtMention.atURI} 93 + className={className} 94 + > 95 + {renderedText} 96 + </AtMentionLink>, 97 + ); 98 + } else if (link) { 99 + children.push( 100 + <a 101 + key={counter} 102 + href={link.uri.trim()} 103 + className={`text-accent-contrast hover:underline ${className}`} 104 + target="_blank" 105 + > 106 + {renderedText} 107 + </a>, 108 + ); 109 + } else { 110 + children.push( 111 + <span key={counter} className={className} id={id?.id}> 112 + {renderedText} 113 + </span>, 114 + ); 115 + } 116 + 117 + counter++; 118 + } 119 + return <>{children}</>; 120 + } 121 + 122 + type RichTextSegment = { 123 + text: string; 124 + facet?: Exclude<Facet["features"], { $type: string }>; 125 + }; 126 + 127 + export class RichText { 128 + unicodeText: UnicodeString; 129 + facets?: Facet[]; 130 + 131 + constructor(props: { text: string; facets: Facet[] }) { 132 + this.unicodeText = new UnicodeString(props.text); 133 + this.facets = props.facets; 134 + if (this.facets) { 135 + this.facets = this.facets 136 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 + } 139 + } 140 + 141 + *segments(): Generator<RichTextSegment, void, void> { 142 + const facets = this.facets || []; 143 + if (!facets.length) { 144 + yield { text: this.unicodeText.utf16 }; 145 + return; 146 + } 147 + 148 + let textCursor = 0; 149 + let facetCursor = 0; 150 + do { 151 + const currFacet = facets[facetCursor]; 152 + if (textCursor < currFacet.index.byteStart) { 153 + yield { 154 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 + }; 156 + } else if (textCursor > currFacet.index.byteStart) { 157 + facetCursor++; 158 + continue; 159 + } 160 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 + const subtext = this.unicodeText.slice( 162 + currFacet.index.byteStart, 163 + currFacet.index.byteEnd, 164 + ); 165 + if (!subtext.trim()) { 166 + // dont empty string entities 167 + yield { text: subtext }; 168 + } else { 169 + yield { text: subtext, facet: currFacet.features }; 170 + } 171 + } 172 + textCursor = currFacet.index.byteEnd; 173 + facetCursor++; 174 + } while (facetCursor < facets.length); 175 + if (textCursor < this.unicodeText.length) { 176 + yield { 177 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 + }; 179 + } 180 + } 181 + }
+110 -224
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import useSWR, { preload } from "swr"; 2 + import { useEffect, useRef } from "react"; 3 + import { AppBskyFeedDefs } from "@atproto/api"; 4 + import useSWR from "swr"; 4 5 import { PageWrapper } from "components/Pages/Page"; 5 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 7 import { DotLoader } from "components/utils/DotLoader"; 8 + import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 + import { openPage } from "./PostPages"; 10 + import { useThreadState } from "src/useThreadState"; 11 + import { BskyPostContent, ClientDate } from "./BskyPostContent"; 7 12 import { 8 - BlueskyEmbed, 9 - PostNotAvailable, 10 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 11 - import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 12 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 13 - import { CommentTiny } from "components/Icons/CommentTiny"; 14 - import { Separator } from "components/Layout"; 15 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 - import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 17 - import { openPage, OpenPage } from "./PostPages"; 18 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 13 + ThreadLink, 14 + getThreadKey, 15 + fetchThread, 16 + prefetchThread, 17 + } from "./PostLinks"; 18 + 19 + // Re-export for backwards compatibility 20 + export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; 19 21 20 22 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 21 23 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 22 24 type BlockedPost = AppBskyFeedDefs.BlockedPost; 23 25 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 24 26 25 - // SWR key for thread data 26 - export const getThreadKey = (uri: string) => `thread:${uri}`; 27 - 28 - // Fetch thread from API route 29 - export async function fetchThread(uri: string): Promise<ThreadType> { 30 - const params = new URLSearchParams({ uri }); 31 - const response = await fetch(`/api/bsky/thread?${params.toString()}`); 32 - 33 - if (!response.ok) { 34 - throw new Error("Failed to fetch thread"); 35 - } 36 - 37 - return response.json(); 38 - } 39 - 40 - // Prefetch thread data 41 - export const prefetchThread = (uri: string) => { 42 - preload(getThreadKey(uri), () => fetchThread(uri)); 43 - }; 44 - 45 - // Link component for opening thread pages with prefetching 46 - export function ThreadLink(props: { 47 - threadUri: string; 48 - parent?: OpenPage; 49 - children: React.ReactNode; 50 - className?: string; 51 - onClick?: (e: React.MouseEvent) => void; 52 - }) { 53 - const { threadUri, parent, children, className, onClick } = props; 54 - 55 - const handleClick = (e: React.MouseEvent) => { 56 - onClick?.(e); 57 - if (e.defaultPrevented) return; 58 - openPage(parent, { type: "thread", uri: threadUri }); 59 - }; 60 - 61 - const handlePrefetch = () => { 62 - prefetchThread(threadUri); 63 - }; 64 - 65 - return ( 66 - <button 67 - className={className} 68 - onClick={handleClick} 69 - onMouseEnter={handlePrefetch} 70 - onPointerDown={handlePrefetch} 71 - > 72 - {children} 73 - </button> 74 - ); 75 - } 76 - 77 27 export function ThreadPage(props: { 78 28 threadUri: string; 79 29 pageId: string; ··· 90 40 } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 91 41 fetchThread(threadUri), 92 42 ); 93 - let cardBorderHidden = useCardBorderHidden(null); 94 43 95 44 return ( 96 45 <PageWrapper ··· 120 69 121 70 function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 122 71 const { thread, threadUri } = props; 72 + const mainPostRef = useRef<HTMLDivElement>(null); 73 + 74 + // Scroll the main post into view when the thread loads 75 + useEffect(() => { 76 + if (mainPostRef.current) { 77 + mainPostRef.current.scrollIntoView({ 78 + behavior: "instant", 79 + block: "start", 80 + }); 81 + } 82 + }, []); 123 83 124 84 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 125 85 return <PostNotAvailable />; ··· 160 120 ))} 161 121 162 122 {/* Main post */} 163 - <ThreadPost 164 - post={thread} 165 - isMainPost={true} 166 - showReplyLine={false} 167 - threadUri={threadUri} 168 - /> 123 + <div ref={mainPostRef}> 124 + <ThreadPost 125 + post={thread} 126 + isMainPost={true} 127 + showReplyLine={false} 128 + threadUri={threadUri} 129 + /> 130 + </div> 169 131 170 132 {/* Replies */} 171 133 {thread.replies && thread.replies.length > 0 && ( ··· 177 139 replies={thread.replies as any[]} 178 140 threadUri={threadUri} 179 141 depth={0} 142 + parentAuthorDid={thread.post.author.did} 180 143 /> 181 144 </div> 182 145 )} ··· 192 155 }) { 193 156 const { post, isMainPost, showReplyLine, threadUri } = props; 194 157 const postView = post.post; 195 - const record = postView.record as AppBskyFeedPost.Record; 196 - 197 - const postId = postView.uri.split("/")[4]; 198 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 158 + const parent = { type: "thread" as const, uri: threadUri }; 199 159 200 160 return ( 201 161 <div className="flex gap-2 relative"> ··· 204 164 <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 205 165 )} 206 166 207 - <div className="flex flex-col items-center shrink-0"> 208 - {postView.author.avatar ? ( 209 - <img 210 - src={postView.author.avatar} 211 - alt={`${postView.author.displayName}'s avatar`} 212 - className="w-10 h-10 rounded-full border border-border-light" 213 - /> 214 - ) : ( 215 - <div className="w-10 h-10 rounded-full border border-border-light bg-border" /> 216 - )} 217 - </div> 218 - 219 - <div 220 - className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`} 221 - > 222 - <div className="flex items-center gap-2 leading-tight"> 223 - <div className="font-bold text-secondary"> 224 - {postView.author.displayName} 225 - </div> 226 - <a 227 - className="text-xs text-tertiary hover:underline" 228 - target="_blank" 229 - href={`https://bsky.app/profile/${postView.author.handle}`} 230 - > 231 - @{postView.author.handle} 232 - </a> 233 - </div> 234 - 235 - <div className="flex flex-col gap-2 mt-1"> 236 - <div className="text-sm text-secondary"> 237 - <BlueskyRichText record={record} /> 238 - </div> 239 - {postView.embed && ( 240 - <BlueskyEmbed embed={postView.embed} postUrl={url} /> 241 - )} 242 - </div> 243 - 244 - <div className="flex gap-2 items-center justify-between mt-2"> 245 - <ClientDate date={record.createdAt} /> 246 - <div className="flex gap-2 items-center"> 247 - {postView.replyCount != null && postView.replyCount > 0 && ( 248 - <> 249 - {isMainPost ? ( 250 - <div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs"> 251 - {postView.replyCount} 252 - <CommentTiny /> 253 - </div> 254 - ) : ( 255 - <ThreadLink 256 - threadUri={postView.uri} 257 - parent={{ type: "thread", uri: threadUri }} 258 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 259 - > 260 - {postView.replyCount} 261 - <CommentTiny /> 262 - </ThreadLink> 263 - )} 264 - <Separator classname="h-4" /> 265 - </> 266 - )} 267 - <a className="text-tertiary" target="_blank" href={url}> 268 - <BlueskyTiny /> 269 - </a> 270 - </div> 271 - </div> 272 - </div> 167 + <BskyPostContent 168 + post={postView} 169 + parent={parent} 170 + linksEnabled={!isMainPost} 171 + showBlueskyLink={true} 172 + showEmbed={true} 173 + /> 273 174 </div> 274 175 ); 275 176 } ··· 278 179 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 279 180 threadUri: string; 280 181 depth: number; 182 + parentAuthorDid?: string; 281 183 }) { 282 - const { replies, threadUri, depth } = props; 184 + const { replies, threadUri, depth, parentAuthorDid } = props; 185 + const collapsedThreads = useThreadState((s) => s.collapsedThreads); 186 + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 187 + 188 + // Sort replies so that replies from the parent author come first 189 + const sortedReplies = parentAuthorDid 190 + ? [...replies].sort((a, b) => { 191 + const aIsAuthor = 192 + AppBskyFeedDefs.isThreadViewPost(a) && 193 + a.post.author.did === parentAuthorDid; 194 + const bIsAuthor = 195 + AppBskyFeedDefs.isThreadViewPost(b) && 196 + b.post.author.did === parentAuthorDid; 197 + if (aIsAuthor && !bIsAuthor) return -1; 198 + if (!aIsAuthor && bIsAuthor) return 1; 199 + return 0; 200 + }) 201 + : replies; 283 202 284 203 return ( 285 204 <div className="flex flex-col gap-0"> 286 - {replies.map((reply, index) => { 205 + {sortedReplies.map((reply, index) => { 287 206 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 288 207 return ( 289 208 <div ··· 311 230 } 312 231 313 232 const hasReplies = reply.replies && reply.replies.length > 0; 233 + const isCollapsed = collapsedThreads.has(reply.post.uri); 234 + const replyCount = reply.replies?.length ?? 0; 314 235 315 236 return ( 316 237 <div key={reply.post.uri} className="flex flex-col"> ··· 321 242 threadUri={threadUri} 322 243 /> 323 244 {hasReplies && depth < 3 && ( 324 - <div className="ml-5 pl-5 border-l border-border-light"> 325 - <Replies 326 - replies={reply.replies as any[]} 327 - threadUri={threadUri} 328 - depth={depth + 1} 329 - /> 245 + <div className="ml-2 flex"> 246 + {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 247 + <button 248 + onClick={(e) => { 249 + e.stopPropagation(); 250 + toggleCollapsed(reply.post.uri); 251 + }} 252 + className="group w-8 flex justify-center cursor-pointer shrink-0" 253 + aria-label={ 254 + isCollapsed ? "Expand replies" : "Collapse replies" 255 + } 256 + > 257 + <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 258 + </button> 259 + {isCollapsed ? ( 260 + <button 261 + onClick={(e) => { 262 + e.stopPropagation(); 263 + toggleCollapsed(reply.post.uri); 264 + }} 265 + className="text-xs text-accent-contrast hover:underline py-1 pl-1" 266 + > 267 + Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 268 + </button> 269 + ) : ( 270 + <div className="grow"> 271 + <Replies 272 + replies={reply.replies as any[]} 273 + threadUri={threadUri} 274 + depth={depth + 1} 275 + parentAuthorDid={reply.post.author.did} 276 + /> 277 + </div> 278 + )} 330 279 </div> 331 280 )} 332 281 {hasReplies && depth >= 3 && ( ··· 351 300 isLast: boolean; 352 301 threadUri: string; 353 302 }) { 354 - const { post, showReplyLine, isLast, threadUri } = props; 303 + const { post, threadUri } = props; 355 304 const postView = post.post; 356 - const record = postView.record as AppBskyFeedPost.Record; 357 - 358 - const postId = postView.uri.split("/")[4]; 359 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 360 - 361 305 const parent = { type: "thread" as const, uri: threadUri }; 362 306 363 307 return ( ··· 365 309 className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 366 310 onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 367 311 > 368 - <div className="flex flex-col items-center shrink-0"> 369 - {postView.author.avatar ? ( 370 - <img 371 - src={postView.author.avatar} 372 - alt={`${postView.author.displayName}'s avatar`} 373 - className="w-8 h-8 rounded-full border border-border-light" 374 - /> 375 - ) : ( 376 - <div className="w-8 h-8 rounded-full border border-border-light bg-border" /> 377 - )} 378 - </div> 379 - 380 - <div className="flex flex-col grow min-w-0"> 381 - <div className="flex items-center gap-2 leading-tight text-sm"> 382 - <div className="font-bold text-secondary"> 383 - {postView.author.displayName} 384 - </div> 385 - <a 386 - className="text-xs text-tertiary hover:underline" 387 - target="_blank" 388 - href={`https://bsky.app/profile/${postView.author.handle}`} 389 - onClick={(e) => e.stopPropagation()} 390 - > 391 - @{postView.author.handle} 392 - </a> 393 - </div> 394 - 395 - <div className="text-sm text-secondary mt-0.5"> 396 - <BlueskyRichText record={record} /> 397 - </div> 398 - 399 - <div className="flex gap-2 items-center mt-1"> 400 - <ClientDate date={record.createdAt} /> 401 - {postView.replyCount != null && postView.replyCount > 0 && ( 402 - <> 403 - <Separator classname="h-3" /> 404 - <ThreadLink 405 - threadUri={postView.uri} 406 - parent={parent} 407 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 408 - onClick={(e) => e.stopPropagation()} 409 - > 410 - {postView.replyCount} 411 - <CommentTiny /> 412 - </ThreadLink> 413 - </> 414 - )} 415 - </div> 416 - </div> 312 + <BskyPostContent 313 + post={postView} 314 + parent={parent} 315 + linksEnabled={true} 316 + avatarSize="sm" 317 + showEmbed={false} 318 + showBlueskyLink={false} 319 + onLinkClick={(e) => e.stopPropagation()} 320 + onEmbedClick={(e) => e.stopPropagation()} 321 + /> 417 322 </div> 418 323 ); 419 324 } 420 - 421 - const ClientDate = (props: { date?: string }) => { 422 - const pageLoaded = useHasPageLoaded(); 423 - const formattedDate = useLocalizedDate( 424 - props.date || new Date().toISOString(), 425 - { 426 - month: "short", 427 - day: "numeric", 428 - year: "numeric", 429 - hour: "numeric", 430 - minute: "numeric", 431 - hour12: true, 432 - }, 433 - ); 434 - 435 - if (!pageLoaded) return null; 436 - 437 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 438 - };
+58 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 10 10 data, 11 11 uri, 12 12 comments_on_documents(*, bsky_profiles(*)), 13 - documents_in_publications(publications(*, publication_subscriptions(*))), 13 + documents_in_publications(publications(*, 14 + documents_in_publications(documents(uri, data)), 15 + publication_subscriptions(*)) 16 + ), 14 17 document_mentions_in_bsky(*), 15 18 leaflets_in_publications(*) 16 19 `, ··· 51 54 ?.record as PubLeafletPublication.Record 52 55 )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 56 57 + // Calculate prev/next documents from the fetched publication documents 58 + let prevNext: 59 + | { 60 + prev?: { uri: string; title: string }; 61 + next?: { uri: string; title: string }; 62 + } 63 + | undefined; 64 + 65 + const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 + ?.publishedAt; 67 + const allDocs = 68 + document.documents_in_publications[0]?.publications 69 + ?.documents_in_publications; 70 + 71 + if (currentPublishedAt && allDocs) { 72 + // Filter and sort documents by publishedAt 73 + const sortedDocs = allDocs 74 + .map((dip) => ({ 75 + uri: dip?.documents?.uri, 76 + title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 + publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 + .publishedAt, 79 + })) 80 + .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 81 + .sort( 82 + (a, b) => 83 + new Date(a.publishedAt!).getTime() - 84 + new Date(b.publishedAt!).getTime(), 85 + ); 86 + 87 + // Find current document index 88 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 89 + 90 + if (currentIndex !== -1) { 91 + prevNext = { 92 + prev: 93 + currentIndex > 0 94 + ? { 95 + uri: sortedDocs[currentIndex - 1].uri || "", 96 + title: sortedDocs[currentIndex - 1].title, 97 + } 98 + : undefined, 99 + next: 100 + currentIndex < sortedDocs.length - 1 101 + ? { 102 + uri: sortedDocs[currentIndex + 1].uri || "", 103 + title: sortedDocs[currentIndex + 1].title, 104 + } 105 + : undefined, 106 + }; 107 + } 108 + } 109 + 54 110 return { 55 111 ...document, 56 112 quotesAndMentions, 57 113 theme, 114 + prevNext, 58 115 }; 59 116 } 60 117
+44 -1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { PubLeafletDocument } from "lexicons/api"; 6 + import { jsonToLex } from "@atproto/lexicon"; 7 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 2 8 3 - export const runtime = "edge"; 4 9 export const revalidate = 60; 5 10 6 11 export default async function OpenGraphImage(props: { 7 12 params: Promise<{ publication: string; did: string; rkey: string }>; 8 13 }) { 9 14 let params = await props.params; 15 + let did = decodeURIComponent(params.did); 16 + 17 + // Try to get the document's cover image 18 + let { data: document } = await supabaseServerClient 19 + .from("documents") 20 + .select("data") 21 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 22 + .single(); 23 + 24 + if (document) { 25 + let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 26 + if (docRecord.coverImage) { 27 + try { 28 + // Get CID from the blob ref (handle both serialized and hydrated forms) 29 + let cid = 30 + (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || 31 + docRecord.coverImage.ref.toString(); 32 + 33 + let imageResponse = await fetchAtprotoBlob(did, cid); 34 + if (imageResponse) { 35 + let imageBlob = await imageResponse.blob(); 36 + 37 + // Return the image with appropriate headers 38 + return new Response(imageBlob, { 39 + headers: { 40 + "Content-Type": imageBlob.type || "image/jpeg", 41 + "Cache-Control": "public, max-age=3600", 42 + }, 43 + }); 44 + } 45 + } catch (e) { 46 + // Fall through to screenshot if cover image fetch fails 47 + console.error("Failed to fetch cover image:", e); 48 + } 49 + } 50 + } 51 + 52 + // Fall back to screenshot 10 53 return getMicroLinkOgImage( 11 54 `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 12 55 );
+12 -4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 1 "use server"; 2 2 3 - import { createOauthClient } from "src/atproto-oauth"; 3 + import { 4 + restoreOAuthSession, 5 + OAuthSessionError, 6 + } from "src/atproto-oauth"; 4 7 import { getIdentityData } from "actions/getIdentityData"; 5 8 import { AtpBaseClient, AtUri } from "@atproto/api"; 6 9 import { PubLeafletPollVote } from "lexicons/api"; ··· 12 15 pollUri: string, 13 16 pollCid: string, 14 17 selectedOption: string, 15 - ): Promise<{ success: boolean; error?: string }> { 18 + ): Promise< 19 + { success: true } | { success: false; error: string | OAuthSessionError } 20 + > { 16 21 try { 17 22 const identity = await getIdentityData(); 18 23 ··· 20 25 return { success: false, error: "Not authenticated" }; 21 26 } 22 27 23 - const oauthClient = await createOauthClient(); 24 - const session = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + const session = sessionResult.value; 25 33 let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 34 27 35 const voteRecord: PubLeafletPollVote.Record = {
+2 -3
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 1 "use client"; 2 2 3 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 - import { PublicationSettingsButton } from "./PublicationSettings"; 4 + import { PublicationSettingsButton } from "./settings/PublicationSettings"; 5 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 - import { Menu } from "components/Layout"; 8 - import { MenuItem } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 9 8 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 9 import { usePublicationData } from "./PublicationSWRProvider"; 11 10 import { useSmoker } from "components/Toast";
-132
app/lish/[did]/[publication]/dashboard/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 - 16 - export function PublicationSettingsButton(props: { publication: string }) { 17 - let isMobile = useIsMobile(); 18 - let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 19 - let [loading, setLoading] = useState(false); 20 - 21 - return ( 22 - <Popover 23 - asChild 24 - onOpenChange={() => setState("menu")} 25 - side={isMobile ? "top" : "right"} 26 - align={isMobile ? "center" : "start"} 27 - className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 28 - arrowFill={theme.colors["border-light"]} 29 - trigger={ 30 - <ActionButton 31 - id="pub-settings-button" 32 - icon=<SettingsSmall /> 33 - label="Settings" 34 - /> 35 - } 36 - > 37 - {state === "general" ? ( 38 - <EditPubForm 39 - backToMenuAction={() => setState("menu")} 40 - loading={loading} 41 - setLoadingAction={setLoading} 42 - /> 43 - ) : state === "theme" ? ( 44 - <PubThemeSetter 45 - backToMenu={() => setState("menu")} 46 - loading={loading} 47 - setLoading={setLoading} 48 - /> 49 - ) : ( 50 - <PubSettingsMenu 51 - state={state} 52 - setState={setState} 53 - loading={loading} 54 - setLoading={setLoading} 55 - /> 56 - )} 57 - </Popover> 58 - ); 59 - } 60 - 61 - const PubSettingsMenu = (props: { 62 - state: "menu" | "general" | "theme"; 63 - setState: (s: typeof props.state) => void; 64 - loading: boolean; 65 - setLoading: (l: boolean) => void; 66 - }) => { 67 - let menuItemClassName = 68 - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 69 - 70 - return ( 71 - <div className="flex flex-col gap-0.5"> 72 - <PubSettingsHeader 73 - loading={props.loading} 74 - setLoadingAction={props.setLoading} 75 - state={"menu"} 76 - /> 77 - <button 78 - className={menuItemClassName} 79 - type="button" 80 - onClick={() => { 81 - props.setState("general"); 82 - }} 83 - > 84 - Publication Settings 85 - <ArrowRightTiny /> 86 - </button> 87 - <button 88 - className={menuItemClassName} 89 - type="button" 90 - onClick={() => props.setState("theme")} 91 - > 92 - Publication Theme 93 - <ArrowRightTiny /> 94 - </button> 95 - </div> 96 - ); 97 - }; 98 - 99 - export const PubSettingsHeader = (props: { 100 - state: "menu" | "general" | "theme"; 101 - backToMenuAction?: () => void; 102 - loading: boolean; 103 - setLoadingAction: (l: boolean) => void; 104 - }) => { 105 - return ( 106 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 107 - {props.state === "menu" 108 - ? "Settings" 109 - : props.state === "general" 110 - ? "General" 111 - : props.state === "theme" 112 - ? "Publication Theme" 113 - : ""} 114 - {props.state !== "menu" && ( 115 - <div className="flex gap-2"> 116 - <button 117 - type="button" 118 - onClick={() => { 119 - props.backToMenuAction && props.backToMenuAction(); 120 - }} 121 - > 122 - <GoBackSmall className="text-accent-contrast" /> 123 - </button> 124 - 125 - <ButtonPrimary compact type="submit"> 126 - {props.loading ? <DotLoader /> : "Update"} 127 - </ButtonPrimary> 128 - </div> 129 - )} 130 - </div> 131 - ); 132 - };
+2 -1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 4 4 import { ButtonPrimary } from "components/Buttons"; 5 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 6 import { useSmoker } from "components/Toast"; 7 - import { Menu, MenuItem, Separator } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 8 + import { Separator } from "components/Layout"; 8 9 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 10 import { Checkbox } from "components/Checkbox"; 10 11 import { useEffect, useState } from "react";
+2 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 7 7 import { Fragment, useState } from "react"; 8 8 import { useParams } from "next/navigation"; 9 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Layout"; 10 + import { Menu, MenuItem } from "components/Menu"; 11 11 import { deletePost } from "./deletePost"; 12 12 import { ButtonPrimary } from "components/Buttons"; 13 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; ··· 140 140 commentsCount={comments} 141 141 tags={tags} 142 142 showComments={pubRecord?.preferences?.showComments} 143 + showMentions={pubRecord?.preferences?.showMentions} 143 144 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 145 /> 145 146 </div>
+50 -13
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 2 2 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { AtUri } from "@atproto/syntax"; 7 10 import { supabaseServerClient } from "supabase/serverClient"; 8 11 import { revalidatePath } from "next/cache"; 9 12 10 - export async function deletePost(document_uri: string) { 13 + export async function deletePost( 14 + document_uri: string 15 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 11 16 let identity = await getIdentityData(); 12 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 17 + if (!identity || !identity.atp_did) { 18 + return { 19 + success: false, 20 + error: { 21 + type: "oauth_session_expired", 22 + message: "Not authenticated", 23 + did: "", 24 + }, 25 + }; 26 + } 13 27 14 - const oauthClient = await createOauthClient(); 15 - let credentialSession = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + let credentialSession = sessionResult.value; 16 33 let agent = new AtpBaseClient( 17 34 credentialSession.fetchHandler.bind(credentialSession), 18 35 ); 19 36 let uri = new AtUri(document_uri); 20 - if (uri.host !== identity.atp_did) return; 37 + if (uri.host !== identity.atp_did) { 38 + return { success: true }; 39 + } 21 40 22 41 await Promise.all([ 23 42 agent.pub.leaflet.document.delete({ ··· 31 50 .eq("doc", document_uri), 32 51 ]); 33 52 34 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 53 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 54 + return { success: true }; 35 55 } 36 56 37 - export async function unpublishPost(document_uri: string) { 57 + export async function unpublishPost( 58 + document_uri: string 59 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 38 60 let identity = await getIdentityData(); 39 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 61 + if (!identity || !identity.atp_did) { 62 + return { 63 + success: false, 64 + error: { 65 + type: "oauth_session_expired", 66 + message: "Not authenticated", 67 + did: "", 68 + }, 69 + }; 70 + } 40 71 41 - const oauthClient = await createOauthClient(); 42 - let credentialSession = await oauthClient.restore(identity.atp_did); 72 + const sessionResult = await restoreOAuthSession(identity.atp_did); 73 + if (!sessionResult.ok) { 74 + return { success: false, error: sessionResult.error }; 75 + } 76 + let credentialSession = sessionResult.value; 43 77 let agent = new AtpBaseClient( 44 78 credentialSession.fetchHandler.bind(credentialSession), 45 79 ); 46 80 let uri = new AtUri(document_uri); 47 - if (uri.host !== identity.atp_did) return; 81 + if (uri.host !== identity.atp_did) { 82 + return { success: true }; 83 + } 48 84 49 85 await Promise.all([ 50 86 agent.pub.leaflet.document.delete({ ··· 53 89 }), 54 90 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 55 91 ]); 56 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 92 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 93 + return { success: true }; 57 94 }
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 + import { PubLeafletPublication } from "lexicons/api"; 2 + import { usePublicationData } from "../PublicationSWRProvider"; 3 + import { PubSettingsHeader } from "./PublicationSettings"; 4 + import { useState } from "react"; 5 + import { Toggle } from "components/Toggle"; 6 + import { updatePublication } from "app/lish/createPub/updatePublication"; 7 + import { useToaster } from "components/Toast"; 8 + import { mutate } from "swr"; 9 + 10 + export const PostOptions = (props: { 11 + backToMenu: () => void; 12 + loading: boolean; 13 + setLoading: (l: boolean) => void; 14 + }) => { 15 + let { data } = usePublicationData(); 16 + 17 + let { publication: pubData } = data || {}; 18 + let record = pubData?.record as PubLeafletPublication.Record; 19 + 20 + let [showComments, setShowComments] = useState( 21 + record?.preferences?.showComments === undefined 22 + ? true 23 + : record.preferences.showComments, 24 + ); 25 + let [showMentions, setShowMentions] = useState( 26 + record?.preferences?.showMentions === undefined 27 + ? true 28 + : record.preferences.showMentions, 29 + ); 30 + let [showPrevNext, setShowPrevNext] = useState( 31 + record?.preferences?.showPrevNext === undefined 32 + ? true 33 + : record.preferences.showPrevNext, 34 + ); 35 + 36 + let toast = useToaster(); 37 + return ( 38 + <form 39 + onSubmit={async (e) => { 40 + if (!pubData) return; 41 + e.preventDefault(); 42 + props.setLoading(true); 43 + let data = await updatePublication({ 44 + name: record.name, 45 + uri: pubData.uri, 46 + preferences: { 47 + showInDiscover: 48 + record?.preferences?.showInDiscover === undefined 49 + ? true 50 + : record.preferences.showInDiscover, 51 + showComments: showComments, 52 + showMentions: showMentions, 53 + showPrevNext: showPrevNext, 54 + }, 55 + }); 56 + toast({ type: "success", content: <strong>Posts Updated!</strong> }); 57 + console.log(record.preferences?.showPrevNext); 58 + props.setLoading(false); 59 + mutate("publication-data"); 60 + }} 61 + className="text-primary flex flex-col" 62 + > 63 + <PubSettingsHeader 64 + loading={props.loading} 65 + setLoadingAction={props.setLoading} 66 + backToMenuAction={props.backToMenu} 67 + state={"post-options"} 68 + > 69 + Post Options 70 + </PubSettingsHeader> 71 + <h4 className="mb-1">Layout</h4> 72 + <Toggle 73 + toggle={showPrevNext} 74 + onToggle={() => { 75 + setShowPrevNext(!showPrevNext); 76 + }} 77 + > 78 + <div className="font-bold">Show Prev/Next Buttons</div> 79 + </Toggle> 80 + <hr className="my-2 border-border-light" /> 81 + <h4 className="mb-1">Interactions</h4> 82 + <div className="flex flex-col gap-2"> 83 + <Toggle 84 + toggle={showComments} 85 + onToggle={() => { 86 + setShowComments(!showComments); 87 + }} 88 + > 89 + <div className="font-bold">Show Comments</div> 90 + </Toggle> 91 + 92 + <Toggle 93 + toggle={showMentions} 94 + onToggle={() => { 95 + setShowMentions(!showMentions); 96 + }} 97 + > 98 + <div className="flex flex-col justify-start"> 99 + <div className="font-bold">Show Mentions</div> 100 + <div className="text-tertiary text-sm leading-tight"> 101 + Display a list of posts on Bluesky that mention your post 102 + </div> 103 + </div> 104 + </Toggle> 105 + </div> 106 + </form> 107 + ); 108 + };
+146
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 + 17 + type menuState = "menu" | "general" | "theme" | "post-options"; 18 + 19 + export function PublicationSettingsButton(props: { publication: string }) { 20 + let isMobile = useIsMobile(); 21 + let [state, setState] = useState<menuState>("menu"); 22 + let [loading, setLoading] = useState(false); 23 + 24 + return ( 25 + <Popover 26 + asChild 27 + onOpenChange={() => setState("menu")} 28 + side={isMobile ? "top" : "right"} 29 + align={isMobile ? "center" : "start"} 30 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 31 + arrowFill={theme.colors["border-light"]} 32 + trigger={ 33 + <ActionButton 34 + id="pub-settings-button" 35 + icon=<SettingsSmall /> 36 + label="Settings" 37 + /> 38 + } 39 + > 40 + {state === "general" ? ( 41 + <EditPubForm 42 + backToMenuAction={() => setState("menu")} 43 + loading={loading} 44 + setLoadingAction={setLoading} 45 + /> 46 + ) : state === "theme" ? ( 47 + <PubThemeSetter 48 + backToMenu={() => setState("menu")} 49 + loading={loading} 50 + setLoading={setLoading} 51 + /> 52 + ) : state === "post-options" ? ( 53 + <PostOptions 54 + backToMenu={() => setState("menu")} 55 + loading={loading} 56 + setLoading={setLoading} 57 + /> 58 + ) : ( 59 + <PubSettingsMenu 60 + state={state} 61 + setState={setState} 62 + loading={loading} 63 + setLoading={setLoading} 64 + /> 65 + )} 66 + </Popover> 67 + ); 68 + } 69 + 70 + const PubSettingsMenu = (props: { 71 + state: menuState; 72 + setState: (s: menuState) => void; 73 + loading: boolean; 74 + setLoading: (l: boolean) => void; 75 + }) => { 76 + let menuItemClassName = 77 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 78 + 79 + return ( 80 + <div className="flex flex-col gap-0.5"> 81 + <PubSettingsHeader 82 + loading={props.loading} 83 + setLoadingAction={props.setLoading} 84 + state={"menu"} 85 + > 86 + Settings 87 + </PubSettingsHeader> 88 + <button 89 + className={menuItemClassName} 90 + type="button" 91 + onClick={() => { 92 + props.setState("general"); 93 + }} 94 + > 95 + General Settings 96 + <ArrowRightTiny /> 97 + </button> 98 + <button 99 + className={menuItemClassName} 100 + type="button" 101 + onClick={() => props.setState("theme")} 102 + > 103 + Theme and Layout 104 + <ArrowRightTiny /> 105 + </button> 106 + <button 107 + className={menuItemClassName} 108 + type="button" 109 + onClick={() => props.setState("post-options")} 110 + > 111 + Post Options 112 + <ArrowRightTiny /> 113 + </button> 114 + </div> 115 + ); 116 + }; 117 + 118 + export const PubSettingsHeader = (props: { 119 + state: menuState; 120 + backToMenuAction?: () => void; 121 + loading: boolean; 122 + setLoadingAction: (l: boolean) => void; 123 + children: React.ReactNode; 124 + }) => { 125 + return ( 126 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 127 + {props.children} 128 + {props.state !== "menu" && ( 129 + <div className="flex gap-2"> 130 + <button 131 + type="button" 132 + onClick={() => { 133 + props.backToMenuAction && props.backToMenuAction(); 134 + }} 135 + > 136 + <GoBackSmall className="text-accent-contrast" /> 137 + </button> 138 + 139 + <ButtonPrimary compact type="submit"> 140 + {props.loading ? <DotLoader /> : "Update"} 141 + </ButtonPrimary> 142 + </div> 143 + )} 144 + </div> 145 + ); 146 + };
+1
app/lish/[did]/[publication]/page.tsx
··· 167 167 tags={tags} 168 168 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 169 showComments={record?.preferences?.showComments} 170 + showMentions={record?.preferences?.showMentions} 170 171 /> 171 172 </div> 172 173 </div>
+22 -6
app/lish/addFeed.tsx
··· 2 2 3 3 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 const leafletFeedURI = 7 10 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 8 11 9 - export async function addFeed() { 10 - const oauthClient = await createOauthClient(); 12 + export async function addFeed(): Promise< 13 + { success: true } | { success: false; error: OAuthSessionError } 14 + > { 11 15 let identity = await getIdentityData(); 12 16 if (!identity || !identity.atp_did) { 13 - throw new Error("Invalid identity data"); 17 + return { 18 + success: false, 19 + error: { 20 + type: "oauth_session_expired", 21 + message: "Not authenticated", 22 + did: "", 23 + }, 24 + }; 14 25 } 15 26 16 - let credentialSession = await oauthClient.restore(identity.atp_did); 27 + const sessionResult = await restoreOAuthSession(identity.atp_did); 28 + if (!sessionResult.ok) { 29 + return { success: false, error: sessionResult.error }; 30 + } 31 + let credentialSession = sessionResult.value; 17 32 let bsky = new BskyAgent(credentialSession); 18 33 let prefs = await bsky.app.bsky.actor.getPreferences(); 19 34 let savedFeeds = prefs.data.preferences.find( ··· 23 38 let hasFeed = !!savedFeeds.items.find( 24 39 (feed) => feed.value === leafletFeedURI, 25 40 ); 26 - if (hasFeed) return; 41 + if (hasFeed) return { success: true }; 27 42 28 43 await bsky.addSavedFeeds([ 29 44 { ··· 32 47 type: "feed", 33 48 }, 34 49 ]); 50 + return { success: true }; 35 51 }
+42 -13
app/lish/createPub/CreatePubForm.tsx
··· 13 13 import { string } from "zod"; 14 14 import { DotLoader } from "components/utils/DotLoader"; 15 15 import { Checkbox } from "components/Checkbox"; 16 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 16 17 17 18 type DomainState = 18 19 | { status: "empty" } ··· 32 33 let [domainState, setDomainState] = useState<DomainState>({ 33 34 status: "empty", 34 35 }); 36 + let [oauthError, setOauthError] = useState< 37 + import("src/atproto-oauth").OAuthSessionError | null 38 + >(null); 35 39 let fileInputRef = useRef<HTMLInputElement>(null); 36 40 37 41 let router = useRouter(); ··· 43 47 e.preventDefault(); 44 48 if (!subdomainValidator.safeParse(domainValue).success) return; 45 49 setFormState("loading"); 46 - let data = await createPublication({ 50 + setOauthError(null); 51 + let result = await createPublication({ 47 52 name: nameValue, 48 53 description: descriptionValue, 49 54 iconFile: logoFile, 50 55 subdomain: domainValue, 51 - preferences: { showInDiscover, showComments: true }, 56 + preferences: { 57 + showInDiscover, 58 + showComments: true, 59 + showMentions: true, 60 + showPrevNext: false, 61 + }, 52 62 }); 63 + 64 + if (!result.success) { 65 + setFormState("normal"); 66 + if (result.error && isOAuthSessionError(result.error)) { 67 + setOauthError(result.error); 68 + } 69 + return; 70 + } 71 + 53 72 // Show a spinner while this is happening! Maybe a progress bar? 54 73 setTimeout(() => { 55 74 setFormState("normal"); 56 - if (data?.publication) 57 - router.push(`${getBasePublicationURL(data.publication)}/dashboard`); 75 + if (result.publication) 76 + router.push( 77 + `${getBasePublicationURL(result.publication)}/dashboard`, 78 + ); 58 79 }, 500); 59 80 }} 60 81 > ··· 139 160 </Checkbox> 140 161 <hr className="border-border-light" /> 141 162 142 - <div className="flex w-full justify-end"> 143 - <ButtonPrimary 144 - type="submit" 145 - disabled={ 146 - !nameValue || !domainValue || domainState.status !== "valid" 147 - } 148 - > 149 - {formState === "loading" ? <DotLoader /> : "Create Publication!"} 150 - </ButtonPrimary> 163 + <div className="flex flex-col gap-2"> 164 + <div className="flex w-full justify-end"> 165 + <ButtonPrimary 166 + type="submit" 167 + disabled={ 168 + !nameValue || !domainValue || domainState.status !== "valid" 169 + } 170 + > 171 + {formState === "loading" ? <DotLoader /> : "Create Publication!"} 172 + </ButtonPrimary> 173 + </div> 174 + {oauthError && ( 175 + <OAuthErrorMessage 176 + error={oauthError} 177 + className="text-right text-sm text-accent-1" 178 + /> 179 + )} 151 180 </div> 152 181 </form> 153 182 );
+23 -16
app/lish/createPub/UpdatePubForm.tsx
··· 20 20 import Link from "next/link"; 21 21 import { Checkbox } from "components/Checkbox"; 22 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 - import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; 23 + import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 + import { Toggle } from "components/Toggle"; 24 25 25 26 export const EditPubForm = (props: { 26 27 backToMenuAction: () => void; ··· 43 44 ? true 44 45 : record.preferences.showComments, 45 46 ); 47 + let showMentions = 48 + record?.preferences?.showMentions === undefined 49 + ? true 50 + : record.preferences.showMentions; 51 + let showPrevNext = 52 + record?.preferences?.showPrevNext === undefined 53 + ? true 54 + : record.preferences.showPrevNext; 55 + 46 56 let [descriptionValue, setDescriptionValue] = useState( 47 57 record?.description || "", 48 58 ); ··· 74 84 preferences: { 75 85 showInDiscover: showInDiscover, 76 86 showComments: showComments, 87 + showMentions: showMentions, 88 + showPrevNext: showPrevNext, 77 89 }, 78 90 }); 79 91 toast({ type: "success", content: "Updated!" }); ··· 86 98 setLoadingAction={props.setLoadingAction} 87 99 backToMenuAction={props.backToMenuAction} 88 100 state={"theme"} 89 - /> 101 + > 102 + General Settings 103 + </PubSettingsHeader> 90 104 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 91 - <div className="flex items-center justify-between gap-2 "> 105 + <div className="flex items-center justify-between gap-2 mt-2 "> 92 106 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 93 107 Logo <span className="font-normal">(optional)</span> 94 108 </p> ··· 158 172 <CustomDomainForm /> 159 173 <hr className="border-border-light" /> 160 174 161 - <Checkbox 162 - checked={showInDiscover} 163 - onChange={(e) => setShowInDiscover(e.target.checked)} 175 + <Toggle 176 + toggle={showInDiscover} 177 + onToggle={() => setShowInDiscover(!showInDiscover)} 164 178 > 165 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 179 + <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 166 180 <p className="font-bold"> 167 181 Show In{" "} 168 182 <a href="/discover" target="_blank"> ··· 177 191 page. You can change this at any time! 178 192 </p> 179 193 </div> 180 - </Checkbox> 194 + </Toggle> 181 195 182 - <Checkbox 183 - checked={showComments} 184 - onChange={(e) => setShowComments(e.target.checked)} 185 - > 186 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 187 - <p className="font-bold">Show comments on posts</p> 188 - </div> 189 - </Checkbox> 196 + 190 197 </div> 191 198 </form> 192 199 );
+24 -5
app/lish/createPub/createPublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 3 import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 - import { createOauthClient } from "src/atproto-oauth"; 4 + import { 5 + restoreOAuthSession, 6 + OAuthSessionError, 7 + } from "src/atproto-oauth"; 5 8 import { getIdentityData } from "actions/getIdentityData"; 6 9 import { supabaseServerClient } from "supabase/serverClient"; 7 10 import { Un$Typed } from "@atproto/api"; ··· 18 21 .min(3) 19 22 .max(63) 20 23 .regex(/^[a-z0-9-]+$/); 24 + type CreatePublicationResult = 25 + | { success: true; publication: any } 26 + | { success: false; error?: OAuthSessionError }; 27 + 21 28 export async function createPublication({ 22 29 name, 23 30 description, ··· 30 37 iconFile: File | null; 31 38 subdomain: string; 32 39 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 33 - }) { 40 + }): Promise<CreatePublicationResult> { 34 41 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 35 42 if (!isSubdomainValid.success) { 36 43 return { success: false }; 37 44 } 38 - const oauthClient = await createOauthClient(); 39 45 let identity = await getIdentityData(); 40 - if (!identity || !identity.atp_did) return; 46 + if (!identity || !identity.atp_did) { 47 + return { 48 + success: false, 49 + error: { 50 + type: "oauth_session_expired", 51 + message: "Not authenticated", 52 + did: "", 53 + }, 54 + }; 55 + } 41 56 42 57 let domain = `${subdomain}.leaflet.pub`; 43 58 44 - let credentialSession = await oauthClient.restore(identity.atp_did); 59 + const sessionResult = await restoreOAuthSession(identity.atp_did); 60 + if (!sessionResult.ok) { 61 + return { success: false, error: sessionResult.error }; 62 + } 63 + let credentialSession = sessionResult.value; 45 64 let agent = new AtpBaseClient( 46 65 credentialSession.fetchHandler.bind(credentialSession), 47 66 );
+66 -18
app/lish/createPub/updatePublication.ts
··· 5 5 PubLeafletPublication, 6 6 PubLeafletThemeColor, 7 7 } from "lexicons/api"; 8 - import { createOauthClient } from "src/atproto-oauth"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 9 import { getIdentityData } from "actions/getIdentityData"; 10 10 import { supabaseServerClient } from "supabase/serverClient"; 11 11 import { Json } from "supabase/database.types"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { $Typed } from "@atproto/api"; 14 + 15 + type UpdatePublicationResult = 16 + | { success: true; publication: any } 17 + | { success: false; error?: OAuthSessionError }; 14 18 15 19 export async function updatePublication({ 16 20 uri, ··· 21 25 }: { 22 26 uri: string; 23 27 name: string; 24 - description: string; 25 - iconFile: File | null; 28 + description?: string; 29 + iconFile?: File | null; 26 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 27 - }) { 28 - const oauthClient = await createOauthClient(); 31 + }): Promise<UpdatePublicationResult> { 29 32 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) return; 33 + if (!identity || !identity.atp_did) { 34 + return { 35 + success: false, 36 + error: { 37 + type: "oauth_session_expired", 38 + message: "Not authenticated", 39 + did: "", 40 + }, 41 + }; 42 + } 31 43 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 44 + const sessionResult = await restoreOAuthSession(identity.atp_did); 45 + if (!sessionResult.ok) { 46 + return { success: false, error: sessionResult.error }; 47 + } 48 + let credentialSession = sessionResult.value; 33 49 let agent = new AtpBaseClient( 34 50 credentialSession.fetchHandler.bind(credentialSession), 35 51 ); ··· 38 54 .select("*") 39 55 .eq("uri", uri) 40 56 .single(); 41 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 57 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 58 + return { success: false }; 59 + } 42 60 let aturi = new AtUri(existingPub.uri); 43 61 44 62 let record: PubLeafletPublication.Record = { ··· 94 112 }: { 95 113 uri: string; 96 114 base_path: string; 97 - }) { 98 - const oauthClient = await createOauthClient(); 115 + }): Promise<UpdatePublicationResult> { 99 116 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 117 + if (!identity || !identity.atp_did) { 118 + return { 119 + success: false, 120 + error: { 121 + type: "oauth_session_expired", 122 + message: "Not authenticated", 123 + did: "", 124 + }, 125 + }; 126 + } 101 127 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 128 + const sessionResult = await restoreOAuthSession(identity.atp_did); 129 + if (!sessionResult.ok) { 130 + return { success: false, error: sessionResult.error }; 131 + } 132 + let credentialSession = sessionResult.value; 103 133 let agent = new AtpBaseClient( 104 134 credentialSession.fetchHandler.bind(credentialSession), 105 135 ); ··· 108 138 .select("*") 109 139 .eq("uri", uri) 110 140 .single(); 111 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 141 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 142 + return { success: false }; 143 + } 112 144 let aturi = new AtUri(existingPub.uri); 113 145 114 146 let record: PubLeafletPublication.Record = { ··· 149 181 backgroundImage?: File | null; 150 182 backgroundRepeat?: number | null; 151 183 backgroundColor: Color; 184 + pageWidth?: number; 152 185 primary: Color; 153 186 pageBackground: Color; 154 187 showPageBackground: boolean; 155 188 accentBackground: Color; 156 189 accentText: Color; 157 190 }; 158 - }) { 159 - const oauthClient = await createOauthClient(); 191 + }): Promise<UpdatePublicationResult> { 160 192 let identity = await getIdentityData(); 161 - if (!identity || !identity.atp_did) return; 193 + if (!identity || !identity.atp_did) { 194 + return { 195 + success: false, 196 + error: { 197 + type: "oauth_session_expired", 198 + message: "Not authenticated", 199 + did: "", 200 + }, 201 + }; 202 + } 162 203 163 - let credentialSession = await oauthClient.restore(identity.atp_did); 204 + const sessionResult = await restoreOAuthSession(identity.atp_did); 205 + if (!sessionResult.ok) { 206 + return { success: false, error: sessionResult.error }; 207 + } 208 + let credentialSession = sessionResult.value; 164 209 let agent = new AtpBaseClient( 165 210 credentialSession.fetchHandler.bind(credentialSession), 166 211 ); ··· 169 214 .select("*") 170 215 .eq("uri", uri) 171 216 .single(); 172 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 217 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 218 + return { success: false }; 219 + } 173 220 let aturi = new AtUri(existingPub.uri); 174 221 175 222 let oldRecord = existingPub.record as PubLeafletPublication.Record; ··· 197 244 ...theme.backgroundColor, 198 245 } 199 246 : undefined, 247 + pageWidth: theme.pageWidth, 200 248 primary: { 201 249 ...theme.primary, 202 250 },
+40 -9
app/lish/subscribeToPublication.ts
··· 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { supabaseServerClient } from "supabase/serverClient"; 9 12 import { revalidatePath } from "next/cache"; ··· 21 24 let leafletFeedURI = 22 25 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 23 26 let idResolver = new IdResolver(); 27 + 28 + type SubscribeResult = 29 + | { success: true; hasFeed: boolean } 30 + | { success: false; error: OAuthSessionError }; 31 + 24 32 export async function subscribeToPublication( 25 33 publication: string, 26 34 redirectRoute?: string, 27 - ) { 28 - const oauthClient = await createOauthClient(); 35 + ): Promise<SubscribeResult | never> { 29 36 let identity = await getIdentityData(); 30 37 if (!identity || !identity.atp_did) { 31 38 return redirect( ··· 33 40 ); 34 41 } 35 42 36 - let credentialSession = await oauthClient.restore(identity.atp_did); 43 + const sessionResult = await restoreOAuthSession(identity.atp_did); 44 + if (!sessionResult.ok) { 45 + return { success: false, error: sessionResult.error }; 46 + } 47 + let credentialSession = sessionResult.value; 37 48 let agent = new AtpBaseClient( 38 49 credentialSession.fetchHandler.bind(credentialSession), 39 50 ); ··· 90 101 ) as AppBskyActorDefs.SavedFeedsPrefV2; 91 102 revalidatePath("/lish/[did]/[publication]", "layout"); 92 103 return { 104 + success: true, 93 105 hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 94 106 }; 95 107 } 96 108 97 - export async function unsubscribeToPublication(publication: string) { 98 - const oauthClient = await createOauthClient(); 109 + type UnsubscribeResult = 110 + | { success: true } 111 + | { success: false; error: OAuthSessionError }; 112 + 113 + export async function unsubscribeToPublication( 114 + publication: string 115 + ): Promise<UnsubscribeResult> { 99 116 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 117 + if (!identity || !identity.atp_did) { 118 + return { 119 + success: false, 120 + error: { 121 + type: "oauth_session_expired", 122 + message: "Not authenticated", 123 + did: "", 124 + }, 125 + }; 126 + } 101 127 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 128 + const sessionResult = await restoreOAuthSession(identity.atp_did); 129 + if (!sessionResult.ok) { 130 + return { success: false, error: sessionResult.error }; 131 + } 132 + let credentialSession = sessionResult.value; 103 133 let agent = new AtpBaseClient( 104 134 credentialSession.fetchHandler.bind(credentialSession), 105 135 ); ··· 109 139 .eq("identity", identity.atp_did) 110 140 .eq("publication", publication) 111 141 .single(); 112 - if (!existingSubscription) return; 142 + if (!existingSubscription) return { success: true }; 113 143 await agent.pub.leaflet.graph.subscription.delete({ 114 144 repo: credentialSession.did!, 115 145 rkey: new AtUri(existingSubscription.uri).rkey, ··· 120 150 .eq("identity", identity.atp_did) 121 151 .eq("publication", publication); 122 152 revalidatePath("/lish/[did]/[publication]", "layout"); 153 + return { success: true }; 123 154 }
+59 -4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { PubLeafletDocument } from "lexicons/api"; 6 + import { jsonToLex } from "@atproto/lexicon"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 2 9 3 - export const runtime = "edge"; 4 10 export const revalidate = 60; 5 11 6 12 export default async function OpenGraphImage(props: { 7 13 params: Promise<{ rkey: string; didOrHandle: string }>; 8 14 }) { 9 15 let params = await props.params; 10 - return getMicroLinkOgImage( 11 - `/p/${params.didOrHandle}/${params.rkey}/`, 12 - ); 16 + let didOrHandle = decodeURIComponent(params.didOrHandle); 17 + 18 + // Resolve handle to DID if needed 19 + let did = didOrHandle; 20 + if (!didOrHandle.startsWith("did:")) { 21 + try { 22 + let resolved = await idResolver.handle.resolve(didOrHandle); 23 + if (resolved) did = resolved; 24 + } catch (e) { 25 + // Fall back to screenshot if handle resolution fails 26 + } 27 + } 28 + 29 + if (did) { 30 + // Try to get the document's cover image 31 + let { data: document } = await supabaseServerClient 32 + .from("documents") 33 + .select("data") 34 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 35 + .single(); 36 + 37 + if (document) { 38 + let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 39 + if (docRecord.coverImage) { 40 + try { 41 + // Get CID from the blob ref (handle both serialized and hydrated forms) 42 + let cid = 43 + (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || 44 + docRecord.coverImage.ref.toString(); 45 + 46 + let imageResponse = await fetchAtprotoBlob(did, cid); 47 + if (imageResponse) { 48 + let imageBlob = await imageResponse.blob(); 49 + 50 + // Return the image with appropriate headers 51 + return new Response(imageBlob, { 52 + headers: { 53 + "Content-Type": imageBlob.type || "image/jpeg", 54 + "Cache-Control": "public, max-age=3600", 55 + }, 56 + }); 57 + } 58 + } catch (e) { 59 + // Fall through to screenshot if cover image fetch fails 60 + console.error("Failed to fetch cover image:", e); 61 + } 62 + } 63 + } 64 + } 65 + 66 + // Fall back to screenshot 67 + return getMicroLinkOgImage(`/p/${params.didOrHandle}/${params.rkey}/`); 13 68 }
+8 -32
appview/index.ts
··· 20 20 } from "@atproto/api"; 21 21 import { AtUri } from "@atproto/syntax"; 22 22 import { writeFile, readFile } from "fs/promises"; 23 - import { createIdentity } from "actions/createIdentity"; 24 - import { drizzle } from "drizzle-orm/node-postgres"; 25 23 import { inngest } from "app/api/inngest/client"; 26 - import { Client } from "pg"; 27 24 28 25 const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 29 26 ··· 135 132 if (evt.event === "create" || evt.event === "update") { 136 133 let record = PubLeafletPublication.validateRecord(evt.record); 137 134 if (!record.success) return; 138 - let { error } = await supabase.from("publications").upsert({ 135 + await supabase 136 + .from("identities") 137 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 138 + await supabase.from("publications").upsert({ 139 139 uri: evt.uri.toString(), 140 140 identity_did: evt.did, 141 141 name: record.value.name, 142 142 record: record.value as Json, 143 143 }); 144 - 145 - if (error && error.code === "23503") { 146 - console.log("creating identity"); 147 - let client = new Client({ connectionString: process.env.DB_URL }); 148 - let db = drizzle(client); 149 - await createIdentity(db, { atp_did: evt.did }); 150 - client.end(); 151 - await supabase.from("publications").upsert({ 152 - uri: evt.uri.toString(), 153 - identity_did: evt.did, 154 - name: record.value.name, 155 - record: record.value as Json, 156 - }); 157 - } 158 144 } 159 145 if (evt.event === "delete") { 160 146 await supabase ··· 222 208 if (evt.event === "create" || evt.event === "update") { 223 209 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 224 210 if (!record.success) return; 225 - let { error } = await supabase.from("publication_subscriptions").upsert({ 211 + await supabase 212 + .from("identities") 213 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 214 + await supabase.from("publication_subscriptions").upsert({ 226 215 uri: evt.uri.toString(), 227 216 identity: evt.did, 228 217 publication: record.value.publication, 229 218 record: record.value as Json, 230 219 }); 231 - if (error && error.code === "23503") { 232 - console.log("creating identity"); 233 - let client = new Client({ connectionString: process.env.DB_URL }); 234 - let db = drizzle(client); 235 - await createIdentity(db, { atp_did: evt.did }); 236 - client.end(); 237 - await supabase.from("publication_subscriptions").upsert({ 238 - uri: evt.uri.toString(), 239 - identity: evt.did, 240 - publication: record.value.publication, 241 - record: record.value as Json, 242 - }); 243 - } 244 220 } 245 221 if (evt.event === "delete") { 246 222 await supabase
+26
components/Blocks/Block.tsx
··· 383 383 ); 384 384 }; 385 385 386 + export const BlockLayout = (props: { 387 + isSelected?: boolean; 388 + children: React.ReactNode; 389 + className?: string; 390 + hasBackground?: "accent" | "page"; 391 + borderOnHover?: boolean; 392 + }) => { 393 + return ( 394 + <div 395 + className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 396 + ${props.isSelected ? "block-border-selected " : "block-border"} 397 + ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 398 + style={{ 399 + backgroundColor: 400 + props.hasBackground === "accent" 401 + ? "var(--accent-light)" 402 + : props.hasBackground === "page" 403 + ? "rgb(var(--bg-page))" 404 + : "transparent", 405 + }} 406 + > 407 + {props.children} 408 + </div> 409 + ); 410 + }; 411 + 386 412 export const ListMarker = ( 387 413 props: Block & { 388 414 previousBlock?: Block | null;
+7 -5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 148 148 } 149 149 return ( 150 150 <div 151 - className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 151 + className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 152 152 > 153 - <div className="bskyAuthor w-full flex items-center gap-1"> 153 + <div className="bskyAuthor w-full flex items-center "> 154 154 {record.author.avatar && ( 155 155 <img 156 156 src={record.author?.avatar} 157 157 alt={`${record.author?.displayName}'s avatar`} 158 - className="shink-0 w-6 h-6 rounded-full border border-border-light" 158 + className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 159 /> 160 160 )} 161 - <div className=" font-bold text-secondary"> 161 + <div className=" font-bold text-secondary mr-1"> 162 162 {record.author?.displayName} 163 163 </div> 164 164 <a ··· 171 171 </div> 172 172 173 173 <div className="flex flex-col gap-2 "> 174 - {text && <pre className="whitespace-pre-wrap">{text}</pre>} 174 + {text && ( 175 + <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 + )} 175 177 {record.embeds !== undefined 176 178 ? record.embeds.map((embed, index) => ( 177 179 <BlueskyEmbed embed={embed} key={index} />
+8 -11
components/Blocks/BlueskyPostBlock/index.tsx
··· 2 2 import { useEffect, useState } from "react"; 3 3 import { useEntity } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { BlockProps } from "../Block"; 5 + import { BlockProps, BlockLayout } from "../Block"; 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; ··· 56 56 AppBskyFeedDefs.isBlockedAuthor(post) || 57 57 AppBskyFeedDefs.isNotFoundPost(post): 58 58 return ( 59 - <div 60 - className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`} 61 - > 59 + <BlockLayout isSelected={!!isSelected} className="w-full"> 62 60 <PostNotAvailable /> 63 - </div> 61 + </BlockLayout> 64 62 ); 65 63 66 64 case AppBskyFeedDefs.isThreadViewPost(post): ··· 81 79 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 82 80 83 81 return ( 84 - <div 85 - className={` 86 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 87 - ${isSelected ? "block-border-selected " : "block-border"} 88 - `} 82 + <BlockLayout 83 + isSelected={!!isSelected} 84 + hasBackground="page" 85 + className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 89 86 > 90 87 {post.post.author && record && ( 91 88 <> ··· 149 146 </a> 150 147 </div> 151 148 </div> 152 - </div> 149 + </BlockLayout> 153 150 ); 154 151 } 155 152 };
+103 -103
components/Blocks/ButtonBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 ··· 106 106 }; 107 107 108 108 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full"> 109 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 110 <ButtonPrimary className="mx-auto"> 111 111 {text !== "" ? text : "Button"} 112 112 </ButtonPrimary> 113 - 114 - <form 115 - className={` 116 - buttonBlockSettingsBorder 117 - w-full bg-bg-page 118 - text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0 119 - flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 120 - ${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"} 121 - `} 122 - onSubmit={(e) => { 123 - e.preventDefault(); 124 - let rect = document 125 - .getElementById("button-block-settings") 126 - ?.getBoundingClientRect(); 127 - if (!textValue) { 128 - smoker({ 129 - error: true, 130 - text: "missing button text!", 131 - position: { 132 - y: rect ? rect.top : 0, 133 - x: rect ? rect.left + 12 : 0, 134 - }, 135 - }); 136 - return; 137 - } 138 - if (!urlValue) { 139 - smoker({ 140 - error: true, 141 - text: "missing url!", 142 - position: { 143 - y: rect ? rect.top : 0, 144 - x: rect ? rect.left + 12 : 0, 145 - }, 146 - }); 147 - return; 148 - } 149 - if (!isUrl(urlValue)) { 150 - smoker({ 151 - error: true, 152 - text: "invalid url!", 153 - position: { 154 - y: rect ? rect.top : 0, 155 - x: rect ? rect.left + 12 : 0, 156 - }, 157 - }); 158 - return; 159 - } 160 - submit(); 161 - }} 113 + <BlockLayout 114 + isSelected={!!isSelected} 115 + borderOnHover 116 + hasBackground="accent" 117 + className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!" 162 118 > 163 - <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 164 - <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 165 - <BlockButtonSmall 166 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 167 - /> 168 - <Separator /> 169 - <Input 170 - type="text" 171 - autoFocus 172 - className="w-full grow border-none outline-hidden bg-transparent" 173 - placeholder="button text" 174 - value={textValue} 175 - disabled={isLocked} 176 - onChange={(e) => setTextValue(e.target.value)} 177 - onKeyDown={(e) => { 178 - if ( 179 - e.key === "Backspace" && 180 - !e.currentTarget.value && 181 - urlValue !== "" 182 - ) 183 - e.preventDefault(); 184 - }} 185 - /> 186 - </div> 187 - <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 188 - <LinkSmall 189 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 190 - /> 191 - <Separator /> 192 - <Input 193 - type="text" 194 - id="button-block-url-input" 195 - className="w-full grow border-none outline-hidden bg-transparent" 196 - placeholder="www.example.com" 197 - value={urlValue} 198 - disabled={isLocked} 199 - onChange={(e) => setUrlValue(e.target.value)} 200 - onKeyDown={(e) => { 201 - if (e.key === "Backspace" && !e.currentTarget.value) 202 - e.preventDefault(); 203 - }} 204 - /> 119 + <form 120 + className={`w-full`} 121 + onSubmit={(e) => { 122 + e.preventDefault(); 123 + let rect = document 124 + .getElementById("button-block-settings") 125 + ?.getBoundingClientRect(); 126 + if (!textValue) { 127 + smoker({ 128 + error: true, 129 + text: "missing button text!", 130 + position: { 131 + y: rect ? rect.top : 0, 132 + x: rect ? rect.left + 12 : 0, 133 + }, 134 + }); 135 + return; 136 + } 137 + if (!urlValue) { 138 + smoker({ 139 + error: true, 140 + text: "missing url!", 141 + position: { 142 + y: rect ? rect.top : 0, 143 + x: rect ? rect.left + 12 : 0, 144 + }, 145 + }); 146 + return; 147 + } 148 + if (!isUrl(urlValue)) { 149 + smoker({ 150 + error: true, 151 + text: "invalid url!", 152 + position: { 153 + y: rect ? rect.top : 0, 154 + x: rect ? rect.left + 12 : 0, 155 + }, 156 + }); 157 + return; 158 + } 159 + submit(); 160 + }} 161 + > 162 + <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 163 + <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 164 + <BlockButtonSmall 165 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 166 + /> 167 + <Separator /> 168 + <Input 169 + type="text" 170 + autoFocus 171 + className="w-full grow border-none outline-hidden bg-transparent" 172 + placeholder="button text" 173 + value={textValue} 174 + disabled={isLocked} 175 + onChange={(e) => setTextValue(e.target.value)} 176 + onKeyDown={(e) => { 177 + if ( 178 + e.key === "Backspace" && 179 + !e.currentTarget.value && 180 + urlValue !== "" 181 + ) 182 + e.preventDefault(); 183 + }} 184 + /> 185 + </div> 186 + <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 187 + <LinkSmall 188 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 189 + /> 190 + <Separator /> 191 + <Input 192 + type="text" 193 + id="button-block-url-input" 194 + className="w-full grow border-none outline-hidden bg-transparent" 195 + placeholder="www.example.com" 196 + value={urlValue} 197 + disabled={isLocked} 198 + onChange={(e) => setUrlValue(e.target.value)} 199 + onKeyDown={(e) => { 200 + if (e.key === "Backspace" && !e.currentTarget.value) 201 + e.preventDefault(); 202 + }} 203 + /> 204 + </div> 205 + <button 206 + id="button-block-settings" 207 + type="submit" 208 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 + > 210 + <div className="sm:hidden block">Save</div> 211 + <CheckTiny /> 212 + </button> 205 213 </div> 206 - <button 207 - id="button-block-settings" 208 - type="submit" 209 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 210 - > 211 - <div className="sm:hidden block">Save</div> 212 - <CheckTiny /> 213 - </button> 214 - </div> 215 - </form> 214 + </form> 215 + </BlockLayout> 216 216 </div> 217 217 ); 218 218 };
+17 -6
components/Blocks/CodeBlock.tsx
··· 6 6 } from "shiki"; 7 7 import { useEntity, useReplicache } from "src/replicache"; 8 8 import "katex/dist/katex.min.css"; 9 - import { BlockProps } from "./Block"; 9 + import { BlockLayout, BlockProps } from "./Block"; 10 10 import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 11 import { useUIState } from "src/useUIState"; 12 12 import { BaseTextareaBlock } from "./BaseTextareaBlock"; ··· 119 119 </select> 120 120 </div> 121 121 )} 122 - <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 122 + 123 + <BlockLayout 124 + isSelected={focusedBlock} 125 + hasBackground="accent" 126 + borderOnHover 127 + className="p-0! min-h-[48px]" 128 + > 123 129 {focusedBlock && permissions.write ? ( 124 130 <BaseTextareaBlock 131 + placeholder="write some codeโ€ฆ" 125 132 data-editable-block 126 133 data-entityid={props.entityID} 127 134 id={elementId.block(props.entityID).input} ··· 131 138 spellCheck={false} 132 139 autoCapitalize="none" 133 140 autoCorrect="off" 134 - className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 141 + className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3" 135 142 value={content?.data.value} 136 143 onChange={async (e) => { 137 144 // Update the entity with the new value ··· 146 153 <pre 147 154 onClick={onClick} 148 155 onMouseDown={(e) => e.stopPropagation()} 149 - className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full" 156 + className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full" 150 157 > 151 - {content?.data.value} 158 + {content?.data.value === "" || content?.data.value === undefined ? ( 159 + <div className="text-tertiary italic">write some codeโ€ฆ</div> 160 + ) : ( 161 + content?.data.value 162 + )} 152 163 </pre> 153 164 ) : ( 154 165 <div ··· 159 170 dangerouslySetInnerHTML={{ __html: html || "" }} 160 171 /> 161 172 )} 162 - </div> 173 + </BlockLayout> 163 174 </div> 164 175 ); 165 176 }
+5 -5
components/Blocks/DateTimeBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockProps, BlockLayout } from "./Block"; 3 3 import { ChevronProps, DayPicker } from "react-day-picker"; 4 4 import { Popover } from "components/Popover"; 5 5 import { useEffect, useMemo, useState } from "react"; ··· 121 121 disabled={isLocked || !permissions.write} 122 122 className="w-64 z-10 px-2!" 123 123 trigger={ 124 - <div 125 - className={`flex flex-row gap-2 group/date w-64 z-1 126 - ${isSelected ? "block-border-selected border-transparent!" : "border border-transparent"} 124 + <BlockLayout 125 + isSelected={!!isSelected} 126 + className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent! 127 127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"} 128 128 `} 129 129 > ··· 163 163 </div> 164 164 )} 165 165 </FadeIn> 166 - </div> 166 + </BlockLayout> 167 167 } 168 168 > 169 169 <div className="flex flex-col gap-3 ">
+13 -16
components/Blocks/EmbedBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 import { Separator } from "components/Layout"; ··· 84 84 <div 85 85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`} 86 86 > 87 - {/* 88 - the iframe! 89 - can also add 'allow' and 'referrerpolicy' attributes later if needed 90 - */} 91 - <iframe 92 - className={` 93 - flex flex-col relative w-full overflow-hidden group/embedBlock 94 - ${isSelected ? "block-border-selected " : "block-border"} 95 - `} 96 - width="100%" 97 - height={height + (heightHandle.dragDelta?.y || 0)} 98 - src={url?.data.value} 99 - allow="fullscreen" 100 - loading="lazy" 101 - ></iframe> 87 + <BlockLayout 88 + isSelected={!!isSelected} 89 + className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" 90 + > 91 + <iframe 92 + width="100%" 93 + height={height + (heightHandle.dragDelta?.y || 0)} 94 + src={url?.data.value} 95 + allow="fullscreen" 96 + loading="lazy" 97 + ></iframe> 98 + </BlockLayout> 102 99 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast"> 103 100 <a 104 101 href={url?.data.value}
+43 -42
components/Blocks/ExternalLinkBlock.tsx
··· 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import { addLinkBlock } from "src/utils/addLinkBlock"; 7 - import { BlockProps } from "./Block"; 7 + import { BlockProps, BlockLayout } from "./Block"; 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; ··· 64 64 } 65 65 66 66 return ( 67 - <a 68 - href={url?.data.value} 69 - target="_blank" 70 - className={` 71 - externalLinkBlock flex relative group/linkBlock 72 - h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 73 - hover:border-accent-contrast shadow-sm 74 - ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"} 75 - 76 - `} 67 + <BlockLayout 68 + isSelected={!!isSelected} 69 + hasBackground="page" 70 + borderOnHover 71 + className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!" 77 72 > 78 - <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 - <div className="flex flex-col w-full min-w-0 h-full grow "> 80 - <div 81 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 - style={{ 83 - overflow: "hidden", 84 - textOverflow: "ellipsis", 85 - wordBreak: "break-all", 86 - }} 87 - > 88 - {title?.data.value} 89 - </div> 73 + <a 74 + href={url?.data.value} 75 + target="_blank" 76 + className="flex w-full h-full text-primary hover:no-underline no-underline" 77 + > 78 + <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 + <div className="flex flex-col w-full min-w-0 h-full grow "> 80 + <div 81 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 + style={{ 83 + overflow: "hidden", 84 + textOverflow: "ellipsis", 85 + wordBreak: "break-all", 86 + }} 87 + > 88 + {title?.data.value} 89 + </div> 90 90 91 - <div 92 - className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 - > 94 - {description?.data.value} 95 - </div> 96 - <div 97 - style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 - className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 - > 100 - {url?.data.value} 91 + <div 92 + className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 + > 94 + {description?.data.value} 95 + </div> 96 + <div 97 + style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 + className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 + > 100 + {url?.data.value} 101 + </div> 101 102 </div> 102 103 </div> 103 - </div> 104 104 105 - <div 106 - className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 - style={{ 108 - backgroundImage: `url(${previewImage?.data.src})`, 109 - backgroundPosition: "center", 110 - }} 111 - /> 112 - </a> 105 + <div 106 + className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 + style={{ 108 + backgroundImage: `url(${previewImage?.data.src})`, 109 + backgroundPosition: "center", 110 + }} 111 + /> 112 + </a> 113 + </BlockLayout> 113 114 ); 114 115 }; 115 116
+68 -24
components/Blocks/ImageBlock.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { BlockProps } from "./Block"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import Image from "next/image"; 7 7 import { v7 } from "uuid"; ··· 17 17 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18 18 import { set } from "colorjs.io/fn"; 19 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 + import { useSubscribe } from "src/replicache/useSubscribe"; 22 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 20 23 21 24 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 22 25 let { rep } = useReplicache(); ··· 61 64 factID: v7(), 62 65 permission_set: entity_set.set, 63 66 type: "text", 64 - position: generateKeyBetween( 65 - props.position, 66 - props.nextPosition, 67 - ), 67 + position: generateKeyBetween(props.position, props.nextPosition), 68 68 newEntityID: entity, 69 69 }); 70 70 } ··· 82 82 if (!image) { 83 83 if (!entity_set.permissions.write) return null; 84 84 return ( 85 - <div className="grow w-full"> 85 + <BlockLayout 86 + hasBackground="accent" 87 + isSelected={!!isSelected && !isLocked} 88 + borderOnHover 89 + className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 + > 86 91 <label 87 92 className={` 88 - group/image-block 89 - w-full h-[104px] hover:cursor-pointer p-2 90 - text-tertiary hover:text-accent-contrast hover:font-bold 93 + 94 + w-full h-full hover:cursor-pointer 91 95 flex flex-col items-center justify-center 92 - hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 93 - ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 94 96 ${props.pageType === "canvas" && "bg-bg-page"}`} 95 97 onMouseDown={(e) => e.preventDefault()} 96 98 onDragOver={(e) => { ··· 104 106 const files = e.dataTransfer.files; 105 107 if (files && files.length > 0) { 106 108 const file = files[0]; 107 - if (file.type.startsWith('image/')) { 109 + if (file.type.startsWith("image/")) { 108 110 await handleImageUpload(file); 109 111 } 110 112 } ··· 128 130 }} 129 131 /> 130 132 </label> 131 - </div> 133 + </BlockLayout> 132 134 ); 133 135 } 134 136 135 - let className = isFullBleed 137 + let imageClassName = isFullBleed 136 138 ? "" 137 139 : isSelected 138 140 ? "block-border-selected border-transparent! " ··· 140 142 141 143 let isLocalUpload = localImages.get(image.data.src); 142 144 145 + let blockClassName = ` 146 + relative group/image border-transparent! p-0! w-fit! 147 + ${isFullBleed && "-mx-3 sm:-mx-4"} 148 + ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 + ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 + `; 151 + 143 152 return ( 144 - <div 145 - className={`relative group/image 146 - ${className} 147 - ${isFullBleed && "-mx-3 sm:-mx-4"} 148 - ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 - ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 150 - > 151 - {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 153 + <BlockLayout isSelected={!!isSelected} className={blockClassName}> 152 154 {isLocalUpload || image.data.local ? ( 153 155 <img 154 156 loading="lazy" ··· 166 168 } 167 169 height={image?.data.height} 168 170 width={image?.data.width} 169 - className={className} 171 + className={imageClassName} 170 172 /> 171 173 )} 172 174 {altText !== undefined && !props.preview ? ( 173 175 <ImageAlt entityID={props.value} /> 174 176 ) : null} 175 - </div> 177 + {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 178 + </BlockLayout> 176 179 ); 177 180 } 178 181 ··· 188 191 altEditorOpen: false, 189 192 setAltEditorOpen: (s: boolean) => {}, 190 193 }); 194 + 195 + const CoverImageButton = (props: { entityID: string }) => { 196 + let { rep } = useReplicache(); 197 + let entity_set = useEntitySetContext(); 198 + let { data: pubData } = useLeafletPublicationData(); 199 + let coverImage = useSubscribe(rep, (tx) => 200 + tx.get<string | null>("publication_cover_image"), 201 + ); 202 + let isFocused = useUIState( 203 + (s) => s.focusedEntity?.entityID === props.entityID, 204 + ); 205 + 206 + // Only show if focused, in a publication, has write permissions, and no cover image is set 207 + if ( 208 + !isFocused || 209 + !pubData?.publications || 210 + !entity_set.permissions.write || 211 + coverImage 212 + ) 213 + return null; 214 + 215 + return ( 216 + <div className="absolute top-2 left-2"> 217 + <button 218 + className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 219 + onClick={async (e) => { 220 + e.preventDefault(); 221 + e.stopPropagation(); 222 + await rep?.mutate.updatePublicationDraft({ 223 + cover_image: props.entityID, 224 + }); 225 + }} 226 + > 227 + <span className="w-4 h-4 flex items-center justify-center"> 228 + <ImageCoverImage /> 229 + </span> 230 + Set as Cover 231 + </button> 232 + </div> 233 + ); 234 + }; 191 235 192 236 const ImageAlt = (props: { entityID: string }) => { 193 237 let { rep } = useReplicache();
+80 -94
components/Blocks/MailboxBlock.tsx
··· 1 1 import { ButtonPrimary } from "components/Buttons"; 2 2 import { Popover } from "components/Popover"; 3 - import { Menu, MenuItem, Separator } from "components/Layout"; 3 + import { MenuItem } from "components/Menu"; 4 + import { Separator } from "components/Layout"; 4 5 import { useUIState } from "src/useUIState"; 5 6 import { useState } from "react"; 6 7 import { useSmoker, useToaster } from "components/Toast"; 7 - import { BlockProps } from "./Block"; 8 + import { BlockProps, BlockLayout } from "./Block"; 8 9 import { useEntity, useReplicache } from "src/replicache"; 9 10 import { useEntitySetContext } from "components/EntitySetProvider"; 10 11 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; ··· 45 46 46 47 return ( 47 48 <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 48 - <div 49 - className={`flex flex-col gap-2 items-center justify-center w-full 50 - ${isSelected ? "block-border-selected " : "block-border"} `} 51 - style={{ 52 - backgroundColor: 53 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 54 - }} 49 + <BlockLayout 50 + isSelected={!!isSelected} 51 + hasBackground={"accent"} 52 + className="flex gap-2 items-center justify-center" 55 53 > 56 - <div className="flex gap-2 p-4"> 57 - <ButtonPrimary 58 - onClick={async () => { 59 - let entity; 60 - if (draft) { 61 - entity = draft.data.value; 62 - } else { 63 - entity = v7(); 64 - await rep?.mutate.createDraft({ 65 - mailboxEntity: props.entityID, 66 - permission_set: entity_set.set, 67 - newEntity: entity, 68 - firstBlockEntity: v7(), 69 - firstBlockFactID: v7(), 70 - }); 71 - } 72 - useUIState.getState().openPage(props.parent, entity); 73 - if (rep) focusPage(entity, rep, "focusFirstBlock"); 74 - return; 75 - }} 76 - > 77 - {draft ? "Edit Draft" : "Write a Post"} 78 - </ButtonPrimary> 79 - <MailboxInfo /> 80 - </div> 81 - </div> 54 + <ButtonPrimary 55 + onClick={async () => { 56 + let entity; 57 + if (draft) { 58 + entity = draft.data.value; 59 + } else { 60 + entity = v7(); 61 + await rep?.mutate.createDraft({ 62 + mailboxEntity: props.entityID, 63 + permission_set: entity_set.set, 64 + newEntity: entity, 65 + firstBlockEntity: v7(), 66 + firstBlockFactID: v7(), 67 + }); 68 + } 69 + useUIState.getState().openPage(props.parent, entity); 70 + if (rep) focusPage(entity, rep, "focusFirstBlock"); 71 + return; 72 + }} 73 + > 74 + {draft ? "Edit Draft" : "Write a Post"} 75 + </ButtonPrimary> 76 + <MailboxInfo /> 77 + </BlockLayout> 82 78 <div className="flex gap-3 items-center justify-between"> 83 79 { 84 80 <> ··· 134 130 let { rep } = useReplicache(); 135 131 return ( 136 132 <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}> 137 - <div 138 - className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 139 - isSelected 140 - ? "border-border outline-border" 141 - : "border-border-light outline-transparent" 142 - }`} 143 - style={{ 144 - backgroundColor: 145 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 146 - }} 133 + <BlockLayout 134 + isSelected={!!isSelected} 135 + hasBackground={"accent"} 136 + className="`h-full flex flex-col gap-2 items-center justify-center" 147 137 > 148 - <div className="flex flex-col w-full gap-2 p-4"> 149 - {!isSubscribed?.confirmed ? ( 150 - <> 151 - <SubscribeForm 152 - entityID={props.entityID} 153 - role={"reader"} 154 - parent={props.parent} 155 - /> 156 - </> 157 - ) : ( 158 - <div className="flex flex-col gap-2 items-center place-self-center"> 159 - <div className=" font-bold text-secondary "> 160 - You&apos;re Subscribed! 161 - </div> 162 - <div className="flex flex-col gap-1 items-center place-self-center"> 163 - {archive ? ( 164 - <ButtonPrimary 165 - onMouseDown={(e) => { 166 - e.preventDefault(); 167 - if (rep) { 168 - useUIState 169 - .getState() 170 - .openPage(props.parent, archive.data.value); 171 - focusPage(archive.data.value, rep); 172 - } 173 - }} 174 - > 175 - See All Posts 176 - </ButtonPrimary> 177 - ) : ( 178 - <div className="text-tertiary"> 179 - Nothing has been posted yet 180 - </div> 181 - )} 182 - <button 183 - className="text-accent-contrast hover:underline text-sm" 184 - onClick={(e) => { 185 - let rect = e.currentTarget.getBoundingClientRect(); 186 - unsubscribe(isSubscribed); 187 - smoke({ 188 - text: "unsubscribed!", 189 - position: { x: rect.left, y: rect.top - 8 }, 190 - }); 138 + {!isSubscribed?.confirmed ? ( 139 + <> 140 + <SubscribeForm 141 + entityID={props.entityID} 142 + role={"reader"} 143 + parent={props.parent} 144 + /> 145 + </> 146 + ) : ( 147 + <div className="flex flex-col gap-2 items-center place-self-center"> 148 + <div className=" font-bold text-secondary "> 149 + You&apos;re Subscribed! 150 + </div> 151 + <div className="flex flex-col gap-1 items-center place-self-center"> 152 + {archive ? ( 153 + <ButtonPrimary 154 + onMouseDown={(e) => { 155 + e.preventDefault(); 156 + if (rep) { 157 + useUIState 158 + .getState() 159 + .openPage(props.parent, archive.data.value); 160 + focusPage(archive.data.value, rep); 161 + } 191 162 }} 192 163 > 193 - unsubscribe 194 - </button> 195 - </div> 164 + See All Posts 165 + </ButtonPrimary> 166 + ) : ( 167 + <div className="text-tertiary">Nothing has been posted yet</div> 168 + )} 169 + <button 170 + className="text-accent-contrast hover:underline text-sm" 171 + onClick={(e) => { 172 + let rect = e.currentTarget.getBoundingClientRect(); 173 + unsubscribe(isSubscribed); 174 + smoke({ 175 + text: "unsubscribed!", 176 + position: { x: rect.left, y: rect.top - 8 }, 177 + }); 178 + }} 179 + > 180 + unsubscribe 181 + </button> 196 182 </div> 197 - )} 198 - </div> 199 - </div> 183 + </div> 184 + )} 185 + </BlockLayout> 200 186 </div> 201 187 ); 202 188 };
+33 -23
components/Blocks/MathBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import "katex/dist/katex.min.css"; 3 - import { BlockProps } from "./Block"; 3 + import { BlockLayout, BlockProps } from "./Block"; 4 4 import Katex from "katex"; 5 5 import { useMemo } from "react"; 6 6 import { useUIState } from "src/useUIState"; ··· 32 32 } 33 33 }, [content?.data.value]); 34 34 return focusedBlock ? ( 35 - <BaseTextareaBlock 36 - id={elementId.block(props.entityID).input} 37 - block={props} 38 - spellCheck={false} 39 - autoCapitalize="none" 40 - autoCorrect="off" 41 - className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 42 - placeholder="write some Tex here..." 43 - value={content?.data.value} 44 - onChange={async (e) => { 45 - // Update the entity with the new value 46 - await rep?.mutate.assertFact({ 47 - attribute: "block/math", 48 - entity: props.entityID, 49 - data: { type: "string", value: e.target.value }, 50 - }); 51 - }} 52 - /> 35 + <BlockLayout 36 + isSelected={focusedBlock} 37 + hasBackground="accent" 38 + className="min-h-[48px]" 39 + > 40 + <BaseTextareaBlock 41 + id={elementId.block(props.entityID).input} 42 + block={props} 43 + spellCheck={false} 44 + autoCapitalize="none" 45 + autoCorrect="off" 46 + className="h-full w-full whitespace-nowrap overflow-auto!" 47 + placeholder="write some Tex here..." 48 + value={content?.data.value} 49 + onChange={async (e) => { 50 + // Update the entity with the new value 51 + await rep?.mutate.assertFact({ 52 + attribute: "block/math", 53 + entity: props.entityID, 54 + data: { type: "string", value: e.target.value }, 55 + }); 56 + }} 57 + /> 58 + </BlockLayout> 53 59 ) : html && content?.data.value ? ( 54 60 <div 55 - className="text-lg min-h-[66px] w-full border border-transparent" 61 + className="text-lg min-h-[48px] w-full border border-transparent" 56 62 dangerouslySetInnerHTML={{ __html: html }} 57 63 /> 58 64 ) : ( 59 - <div className="text-tertiary italic rounded-md p-2 w-full min-h-16"> 60 - write some Tex here... 61 - </div> 65 + <BlockLayout 66 + isSelected={focusedBlock} 67 + hasBackground="accent" 68 + className="min-h-[48px]" 69 + > 70 + <div className="text-tertiary italic w-full ">write some Tex here...</div> 71 + </BlockLayout> 62 72 ); 63 73 }
+26 -22
components/Blocks/PageLinkBlock.tsx
··· 1 1 "use client"; 2 - import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 2 + import { BlockProps, ListMarker, Block, BlockLayout } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 5 import { focusPage } from "src/utils/focusPage"; ··· 29 29 30 30 return ( 31 31 <CardThemeProvider entityID={page?.data.value}> 32 - <div 33 - className={`w-full cursor-pointer 32 + <BlockLayout 33 + hasBackground="page" 34 + isSelected={!!isSelected} 35 + className={`cursor-pointer 34 36 pageLinkBlockWrapper relative group/pageLinkBlock 35 - bg-bg-page shadow-sm 36 - flex overflow-clip 37 - ${isSelected ? "block-border-selected " : "block-border"} 38 - ${isOpen && "border-tertiary!"} 37 + flex overflow-clip p-0! 38 + ${isOpen && "border-accent-contrast! outline-accent-contrast!"} 39 39 `} 40 - onClick={(e) => { 41 - if (!page) return; 42 - if (e.isDefaultPrevented()) return; 43 - if (e.shiftKey) return; 44 - e.preventDefault(); 45 - e.stopPropagation(); 46 - useUIState.getState().openPage(props.parent, page.data.value); 47 - if (rep) focusPage(page.data.value, rep); 48 - }} 49 40 > 50 - {type === "canvas" && page ? ( 51 - <CanvasLinkBlock entityID={page?.data.value} /> 52 - ) : ( 53 - <DocLinkBlock {...props} /> 54 - )} 55 - </div> 41 + <div 42 + className="w-full h-full" 43 + onClick={(e) => { 44 + if (!page) return; 45 + if (e.isDefaultPrevented()) return; 46 + if (e.shiftKey) return; 47 + e.preventDefault(); 48 + e.stopPropagation(); 49 + useUIState.getState().openPage(props.parent, page.data.value); 50 + if (rep) focusPage(page.data.value, rep); 51 + }} 52 + > 53 + {type === "canvas" && page ? ( 54 + <CanvasLinkBlock entityID={page?.data.value} /> 55 + ) : ( 56 + <DocLinkBlock {...props} /> 57 + )} 58 + </div> 59 + </BlockLayout> 56 60 </CardThemeProvider> 57 61 ); 58 62 }
+7 -10
components/Blocks/PollBlock/index.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "../Block"; 2 + import { BlockProps, BlockLayout } from "../Block"; 3 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 4 import { useCallback, useEffect, useState } from "react"; 5 5 import { Input } from "components/Input"; ··· 61 61 let totalVotes = votes.length; 62 62 63 63 return ( 64 - <div 65 - className={`poll flex flex-col gap-2 p-3 w-full 66 - ${isSelected ? "block-border-selected " : "block-border"}`} 67 - style={{ 68 - backgroundColor: 69 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 70 - }} 64 + <BlockLayout 65 + isSelected={!!isSelected} 66 + hasBackground={"accent"} 67 + className="poll flex flex-col gap-2 w-full" 71 68 > 72 69 {pollState === "editing" ? ( 73 70 <EditPoll ··· 95 92 hasVoted={!!hasVoted} 96 93 /> 97 94 )} 98 - </div> 95 + </BlockLayout> 99 96 ); 100 97 }; 101 98 ··· 486 483 }) => { 487 484 return ( 488 485 <button 489 - className="text-sm text-accent-contrast sm:hover:underline" 486 + className="text-sm text-accent-contrast " 490 487 onClick={() => { 491 488 props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 489 }}
+6 -9
components/Blocks/PublicationPollBlock.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockLayout, BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 4 import { AsyncValueInput } from "components/Input"; 5 5 import { focusElement } from "src/utils/focusElement"; ··· 53 53 }, [publicationData, props.entityID]); 54 54 55 55 return ( 56 - <div 57 - className={`poll flex flex-col gap-2 p-3 w-full 58 - ${isSelected ? "block-border-selected " : "block-border"}`} 59 - style={{ 60 - backgroundColor: 61 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 62 - }} 56 + <BlockLayout 57 + className="poll flex flex-col gap-2" 58 + hasBackground={"accent"} 59 + isSelected={!!isSelected} 63 60 > 64 61 <EditPollForPublication 65 62 entityID={props.entityID} 66 63 isPublished={isPublished} 67 64 /> 68 - </div> 65 + </BlockLayout> 69 66 ); 70 67 }; 71 68
+6 -8
components/Blocks/RSVPBlock/index.tsx
··· 1 1 "use client"; 2 2 import { Database } from "supabase/database.types"; 3 - import { BlockProps } from "components/Blocks/Block"; 3 + import { BlockProps, BlockLayout } from "components/Blocks/Block"; 4 4 import { useState } from "react"; 5 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 6 import { useRSVPData } from "components/PageSWRDataProvider"; ··· 29 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 30 ); 31 31 return ( 32 - <div 33 - className={`rsvp relative flex flex-col gap-1 border p-3 w-full rounded-lg place-items-center justify-center ${isSelected ? "block-border-selected " : "block-border"}`} 34 - style={{ 35 - backgroundColor: 36 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 37 - }} 32 + <BlockLayout 33 + isSelected={!!isSelected} 34 + hasBackground={"accent"} 35 + className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 38 36 > 39 37 <RSVPForm entityID={props.entityID} /> 40 - </div> 38 + </BlockLayout> 41 39 ); 42 40 } 43 41
+11 -5
components/Buttons.tsx
··· 38 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 39 bg-accent-1 disabled:bg-border-light 40 40 border border-accent-1 rounded-md disabled:border-border-light 41 - outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 41 + outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 43 flex gap-2 items-center justify-center shrink-0 44 44 ${className} ··· 77 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 78 bg-bg-page disabled:bg-border-light 79 79 border border-accent-contrast rounded-md 80 - outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 82 flex gap-2 items-center justify-center shrink-0 83 83 ${props.className} ··· 116 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 117 bg-transparent hover:bg-[var(--accent-light)] 118 118 border border-transparent rounded-md hover:border-[var(--accent-light)] 119 - outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 119 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 120 text-base font-bold text-accent-contrast disabled:text-border 121 121 flex gap-2 items-center justify-center shrink-0 122 122 ${props.className} ··· 165 165 side={props.side ? props.side : undefined} 166 166 sideOffset={6} 167 167 alignOffset={12} 168 - className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 168 + className="z-10 rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 169 + style={{ 170 + backgroundColor: 171 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)", 172 + }} 169 173 > 170 174 {props.tooltipContent} 171 175 <RadixTooltip.Arrow ··· 175 179 viewBox="0 0 16 8" 176 180 > 177 181 <PopoverArrow 178 - arrowFill={theme.colors["border"]} 182 + arrowFill={ 183 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)" 184 + } 179 185 arrowStroke="transparent" 180 186 /> 181 187 </RadixTooltip.Arrow>
+6 -3
components/Canvas.tsx
··· 170 170 171 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 172 let showComments = pubRecord.preferences?.showComments; 173 + let showMentions = pubRecord.preferences?.showMentions; 173 174 174 175 return ( 175 176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> ··· 178 179 <CommentTiny className="text-border" /> โ€” 179 180 </div> 180 181 )} 181 - <div className="flex gap-1 text-tertiary items-center"> 182 - <QuoteTiny className="text-border" /> โ€” 183 - </div> 182 + {showComments && ( 183 + <div className="flex gap-1 text-tertiary items-center"> 184 + <QuoteTiny className="text-border" /> โ€” 185 + </div> 186 + )} 184 187 185 188 {!props.isSubpage && ( 186 189 <>
+14
components/Icons/ImageCoverImage.tsx
··· 1 + export const ImageCoverImage = () => ( 2 + <svg 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + xmlns="http://www.w3.org/2000/svg" 8 + > 9 + <path 10 + d="M20.1631 2.56445C21.8887 2.56481 23.2881 3.96378 23.2881 5.68945V18.3105C23.288 20.0361 21.8886 21.4362 20.1631 21.4365H3.83789C2.11225 21.4365 0.713286 20.0371 0.712891 18.3115V5.68945C0.712891 3.96356 2.112 2.56445 3.83789 2.56445H20.1631ZM1.96289 18.3115C1.96329 19.3467 2.8026 20.1865 3.83789 20.1865H20.1631C21.1982 20.1862 22.038 19.3457 22.0381 18.3105V15.8066H1.96289V18.3115ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5936 15.2383 18.0078C15.2383 18.422 14.9025 18.7578 14.4883 18.7578H3.81543C3.40138 18.7576 3.06543 18.4219 3.06543 18.0078C3.06546 17.5937 3.4014 17.258 3.81543 17.2578H14.4883ZM19.9775 10.9688C19.5515 11.5175 18.8232 11.7343 18.166 11.5088L16.3213 10.876C16.2238 10.8425 16.1167 10.8506 16.0254 10.8984L15.0215 11.4238C14.4872 11.7037 13.8413 11.6645 13.3447 11.3223L12.6826 10.8652L11.3467 12.2539L11.6924 12.4844C11.979 12.6758 12.0572 13.0635 11.8662 13.3506C11.6751 13.6377 11.2873 13.7151 11 13.5244L10.0312 12.8799L8.81152 12.0654L8.03027 12.8691C7.5506 13.3622 6.78589 13.4381 6.21875 13.0488C6.17033 13.0156 6.10738 13.0112 6.05469 13.0371L4.79883 13.6572C4.25797 13.9241 3.61321 13.8697 3.125 13.5156L2.26172 12.8887L1.96289 13.1572V14.5566H22.0381V10.1299L21.1738 9.42383L19.9775 10.9688ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0361L3.85938 12.5039C3.97199 12.5854 4.12044 12.5977 4.24512 12.5361L5.50098 11.917C5.95929 11.6908 6.50439 11.7294 6.92578 12.0186C6.99106 12.0633 7.07957 12.0548 7.13477 11.998L7.75488 11.3604L5.58984 9.91504L4.71094 10.7012ZM3.83789 3.81445C2.80236 3.81445 1.96289 4.65392 1.96289 5.68945V11.4805L4.8291 8.91895C5.18774 8.59891 5.70727 8.54436 6.12207 8.77344L6.20312 8.82324L10.2891 11.5498L16.3809 5.22754L16.46 5.15234C16.8692 4.80225 17.4773 4.78945 17.9023 5.13672L22.0381 8.51562V5.68945C22.0381 4.65414 21.1983 3.81481 20.1631 3.81445H3.83789ZM13.5625 9.95312L14.0547 10.293C14.1692 10.3717 14.3182 10.3809 14.4414 10.3164L15.4453 9.79102C15.841 9.58378 16.3051 9.54827 16.7275 9.69336L18.5723 10.3271C18.7238 10.3788 18.8921 10.3286 18.9902 10.2021L20.2061 8.63281L17.2002 6.17676L13.5625 9.95312ZM8.86328 4.8291C9.84255 4.82937 10.6366 5.62324 10.6367 6.60254C10.6365 7.58178 9.8425 8.37571 8.86328 8.37598C7.88394 8.37585 7.09004 7.58186 7.08984 6.60254C7.08997 5.62315 7.88389 4.82923 8.86328 4.8291ZM8.86328 5.8291C8.43618 5.82923 8.08997 6.17544 8.08984 6.60254C8.09004 7.02958 8.43622 7.37585 8.86328 7.37598C9.29022 7.37571 9.63652 7.02949 9.63672 6.60254C9.63659 6.17552 9.29026 5.82937 8.86328 5.8291Z" 11 + fill="currentColor" 12 + /> 13 + </svg> 14 + );
+4 -2
components/InteractionsPreview.tsx
··· 14 14 tags?: string[]; 15 15 postUrl: string; 16 16 showComments: boolean | undefined; 17 + showMentions: boolean | undefined; 18 + 17 19 share?: boolean; 18 20 }) => { 19 21 let smoker = useSmoker(); 20 22 let interactionsAvailable = 21 - props.quotesCount > 0 || 23 + (props.quotesCount > 0 && props.showMentions !== false) || 22 24 (props.showComments !== false && props.commentsCount > 0); 23 25 24 26 const tagsCount = props.tags?.length || 0; ··· 36 38 </> 37 39 )} 38 40 39 - {props.quotesCount === 0 ? null : ( 41 + {props.showMentions === false || props.quotesCount === 0 ? null : ( 40 42 <SpeedyLink 41 43 aria-label="Post quotes" 42 44 href={`${props.postUrl}?interactionDrawer=quotes`}
-94
components/Layout.tsx
··· 1 - "use client"; 2 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 - import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 - import { useState } from "react"; 8 - 9 1 export const Separator = (props: { classname?: string }) => { 10 2 return <div className={`h-full border-r border-border ${props.classname}`} />; 11 - }; 12 - 13 - export const Menu = (props: { 14 - open?: boolean; 15 - trigger: React.ReactNode; 16 - children: React.ReactNode; 17 - align?: "start" | "end" | "center" | undefined; 18 - alignOffset?: number; 19 - side?: "top" | "bottom" | "right" | "left" | undefined; 20 - background?: string; 21 - border?: string; 22 - className?: string; 23 - onOpenChange?: (o: boolean) => void; 24 - asChild?: boolean; 25 - }) => { 26 - let [open, setOpen] = useState(props.open || false); 27 - return ( 28 - <DropdownMenu.Root 29 - onOpenChange={(o) => { 30 - setOpen(o); 31 - props.onOpenChange?.(o); 32 - }} 33 - open={props.open} 34 - > 35 - <PopoverOpenContext value={open}> 36 - <DropdownMenu.Trigger asChild={props.asChild}> 37 - {props.trigger} 38 - </DropdownMenu.Trigger> 39 - <DropdownMenu.Portal> 40 - <NestedCardThemeProvider> 41 - <DropdownMenu.Content 42 - side={props.side ? props.side : "bottom"} 43 - align={props.align ? props.align : "center"} 44 - alignOffset={props.alignOffset ? props.alignOffset : undefined} 45 - sideOffset={4} 46 - collisionPadding={16} 47 - className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 48 - > 49 - {props.children} 50 - <DropdownMenu.Arrow 51 - asChild 52 - width={16} 53 - height={8} 54 - viewBox="0 0 16 8" 55 - > 56 - <PopoverArrow 57 - arrowFill={ 58 - props.background 59 - ? props.background 60 - : theme.colors["bg-page"] 61 - } 62 - arrowStroke={ 63 - props.border ? props.border : theme.colors["border"] 64 - } 65 - /> 66 - </DropdownMenu.Arrow> 67 - </DropdownMenu.Content> 68 - </NestedCardThemeProvider> 69 - </DropdownMenu.Portal> 70 - </PopoverOpenContext> 71 - </DropdownMenu.Root> 72 - ); 73 - }; 74 - 75 - export const MenuItem = (props: { 76 - children?: React.ReactNode; 77 - className?: string; 78 - onSelect: (e: Event) => void; 79 - id?: string; 80 - }) => { 81 - return ( 82 - <DropdownMenu.Item 83 - id={props.id} 84 - onSelect={(event) => { 85 - props.onSelect(event); 86 - }} 87 - className={` 88 - menuItem 89 - z-10 py-1! px-2! 90 - flex gap-2 91 - ${props.className} 92 - `} 93 - > 94 - {props.children} 95 - </DropdownMenu.Item> 96 - ); 97 3 }; 98 4 99 5 export const ShortcutKey = (props: { children: React.ReactNode }) => {
+97
components/Menu.tsx
··· 1 + "use client"; 2 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 + import { theme } from "tailwind.config"; 4 + import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 + import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 + import { useState } from "react"; 8 + 9 + export const Menu = (props: { 10 + open?: boolean; 11 + trigger: React.ReactNode; 12 + children: React.ReactNode; 13 + align?: "start" | "end" | "center" | undefined; 14 + alignOffset?: number; 15 + side?: "top" | "bottom" | "right" | "left" | undefined; 16 + background?: string; 17 + border?: string; 18 + className?: string; 19 + onOpenChange?: (o: boolean) => void; 20 + asChild?: boolean; 21 + }) => { 22 + let [open, setOpen] = useState(props.open || false); 23 + 24 + return ( 25 + <DropdownMenu.Root 26 + onOpenChange={(o) => { 27 + setOpen(o); 28 + props.onOpenChange?.(o); 29 + }} 30 + open={props.open} 31 + > 32 + <PopoverOpenContext value={open}> 33 + <DropdownMenu.Trigger asChild={props.asChild}> 34 + {props.trigger} 35 + </DropdownMenu.Trigger> 36 + <DropdownMenu.Portal> 37 + <NestedCardThemeProvider> 38 + <DropdownMenu.Content 39 + side={props.side ? props.side : "bottom"} 40 + align={props.align ? props.align : "center"} 41 + alignOffset={props.alignOffset ? props.alignOffset : undefined} 42 + sideOffset={4} 43 + collisionPadding={16} 44 + className={` 45 + dropdownMenu z-20 p-1 46 + flex flex-col gap-0.5 47 + bg-bg-page 48 + border border-border rounded-md shadow-md 49 + ${props.className}`} 50 + > 51 + {props.children} 52 + <DropdownMenu.Arrow 53 + asChild 54 + width={16} 55 + height={8} 56 + viewBox="0 0 16 8" 57 + > 58 + <PopoverArrow 59 + arrowFill={ 60 + props.background 61 + ? props.background 62 + : theme.colors["bg-page"] 63 + } 64 + arrowStroke={ 65 + props.border ? props.border : theme.colors["border"] 66 + } 67 + /> 68 + </DropdownMenu.Arrow> 69 + </DropdownMenu.Content> 70 + </NestedCardThemeProvider> 71 + </DropdownMenu.Portal> 72 + </PopoverOpenContext> 73 + </DropdownMenu.Root> 74 + ); 75 + }; 76 + 77 + export const MenuItem = (props: { 78 + children?: React.ReactNode; 79 + className?: string; 80 + onSelect: (e: Event) => void; 81 + id?: string; 82 + }) => { 83 + return ( 84 + <DropdownMenu.Item 85 + id={props.id} 86 + onSelect={(event) => { 87 + props.onSelect(event); 88 + }} 89 + className={` 90 + menuItem 91 + ${props.className} 92 + `} 93 + > 94 + {props.children} 95 + </DropdownMenu.Item> 96 + ); 97 + };
+33
components/OAuthError.tsx
··· 1 + "use client"; 2 + 3 + import { OAuthSessionError } from "src/atproto-oauth"; 4 + 5 + export function OAuthErrorMessage({ 6 + error, 7 + className, 8 + }: { 9 + error: OAuthSessionError; 10 + className?: string; 11 + }) { 12 + const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(window.location.href)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`; 13 + 14 + return ( 15 + <div className={className}> 16 + <span>Your session has expired or is invalid. </span> 17 + <a href={signInUrl} className="underline font-bold whitespace-nowrap"> 18 + Sign in again 19 + </a> 20 + </div> 21 + ); 22 + } 23 + 24 + export function isOAuthSessionError( 25 + error: unknown, 26 + ): error is OAuthSessionError { 27 + return ( 28 + typeof error === "object" && 29 + error !== null && 30 + "type" in error && 31 + (error as OAuthSessionError).type === "oauth_session_expired" 32 + ); 33 + }
+1 -1
components/PageLayouts/DashboardLayout.tsx
··· 180 180 </div> 181 181 </MediaContents> 182 182 <div 183 - className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `} 183 + className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `} 184 184 ref={ref} 185 185 id="home-content" 186 186 >
+2 -2
components/Pages/PageOptions.tsx
··· 7 7 import { useReplicache } from "src/replicache"; 8 8 9 9 import { Media } from "../Media"; 10 - import { MenuItem, Menu } from "../Layout"; 10 + import { MenuItem, Menu } from "../Menu"; 11 11 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 12 import { PageShareMenu } from "./PageShareMenu"; 13 13 import { useUndoState } from "src/undoManager"; ··· 61 61 <div 62 62 className={`pageOptions w-fit z-10 63 63 ${props.isFocused ? "block" : "sm:hidden block"} 64 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 64 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 65 65 flex sm:flex-col flex-row-reverse gap-1 items-start`} 66 66 > 67 67 {!props.first && (
+5 -3
components/Pages/PublicationMetadata.tsx
··· 121 121 <Separator classname="h-4!" /> 122 122 </> 123 123 )} 124 - <div className="flex gap-1 items-center"> 125 - <QuoteTiny />โ€” 126 - </div> 124 + {pubRecord?.preferences?.showMentions && ( 125 + <div className="flex gap-1 items-center"> 126 + <QuoteTiny />โ€” 127 + </div> 128 + )} 127 129 {pubRecord?.preferences?.showComments && ( 128 130 <div className="flex gap-1 items-center"> 129 131 <CommentTiny />โ€”
+13 -9
components/PostListing.tsx
··· 22 22 23 23 let postRecord = props.documents.data as PubLeafletDocument.Record; 24 24 let postUri = new AtUri(props.documents.uri); 25 + let uri = props.publication ? props.publication?.uri : props.documents.uri; 25 26 26 - let theme = usePubTheme(pubRecord?.theme || postRecord?.theme); 27 + // For standalone documents (no publication), pass isStandalone to get correct defaults 28 + let isStandalone = !pubRecord; 29 + let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 30 + let themeRecord = pubRecord?.theme || postRecord?.theme; 27 31 let backgroundImage = 28 - pubRecord?.theme?.backgroundImage?.image?.ref && props.publication 29 - ? blobRefToSrc( 30 - pubRecord.theme.backgroundImage.image.ref, 31 - new AtUri(props.publication.uri).host, 32 - ) 32 + themeRecord?.backgroundImage?.image?.ref && uri 33 + ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) 33 34 : null; 34 35 35 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 36 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 36 + let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; 37 + let backgroundImageSize = themeRecord?.backgroundImage?.width || 500; 37 38 38 - let showPageBackground = pubRecord?.theme?.showPageBackground; 39 + let showPageBackground = pubRecord 40 + ? pubRecord?.theme?.showPageBackground 41 + : postRecord.theme?.showPageBackground ?? true; 39 42 40 43 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 41 44 let comments = ··· 94 97 commentsCount={comments} 95 98 tags={tags} 96 99 showComments={pubRecord?.preferences?.showComments} 100 + showMentions={pubRecord?.preferences?.showMentions} 97 101 share 98 102 /> 99 103 </div>
+4 -5
components/ThemeManager/PageThemeSetter.tsx
··· 3 3 import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter"; 4 4 5 5 import { 6 - PageBackgroundPicker, 6 + SubpageBackgroundPicker, 7 7 PageThemePickers, 8 8 } from "./Pickers/PageThemePickers"; 9 9 import { useMemo, useState } from "react"; ··· 54 54 className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 55 55 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 56 56 > 57 - <PageBackgroundPicker 57 + <SubpageBackgroundPicker 58 58 entityID={props.entityID} 59 59 openPicker={openPicker} 60 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 61 - setValue={set("theme/card-background")} 60 + setOpenPicker={setOpenPicker} 62 61 /> 63 62 </div> 64 63 ··· 147 146 <div 148 147 className={ 149 148 pageBorderHidden 150 - ? "py-2 px-0 border border-transparent" 149 + ? "relative py-2 px-0 border border-transparent" 151 150 : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 152 151 } 153 152 style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
··· 21 21 22 22 export const ColorPicker = (props: { 23 23 label?: string; 24 + helpText?: string; 24 25 value: Color | undefined; 25 26 alpha?: boolean; 26 27 image?: boolean; ··· 116 117 <div className="w-full flex flex-col gap-2 px-1 pb-2"> 117 118 { 118 119 <> 120 + {props.helpText && ( 121 + <div className="text-sm leading-tight text-tertiary pl-7 -mt-2.5"> 122 + {props.helpText} 123 + </div> 124 + )} 119 125 <ColorArea 120 126 className="w-full h-[128px] rounded-md" 121 127 colorSpace="hsb"
+4 -4
components/ThemeManager/Pickers/ImagePicker.tsx
··· 73 73 }); 74 74 }} 75 75 > 76 - <div className="flex flex-col gap-2 w-full"> 76 + <div className="flex flex-col w-full"> 77 77 <div className="flex gap-2"> 78 78 <div 79 79 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 122 122 }} 123 123 > 124 124 <Slider.Track 125 - className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 125 + className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 126 126 ></Slider.Track> 127 127 <Slider.Thumb 128 128 className={` 129 129 flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 130 - ${repeat ? "bg-[#595959]" : " bg-[#C3C3C3] "} 131 - ${repeat && "shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"} `} 130 + ${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "} 131 + `} 132 132 aria-label="Volume" 133 133 /> 134 134 </Slider.Root>
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - ColorPicker as SpectrumColorPicker, 5 - parseColor, 6 - Color, 7 - ColorArea, 8 - ColorThumb, 9 - ColorSlider, 10 - Input, 11 - ColorField, 12 - SliderTrack, 13 - ColorSwatch, 14 - } from "react-aria-components"; 15 - import { pickers, setColorAttribute } from "../ThemeSetter"; 16 - import { thumbStyle } from "./ColorPicker"; 17 - import { ImageInput, ImageSettings } from "./ImagePicker"; 18 - import { useEntity, useReplicache } from "src/replicache"; 19 - import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 - import { Separator } from "components/Layout"; 21 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 24 - 25 - export const LeafletBGPicker = (props: { 26 - entityID: string; 27 - openPicker: pickers; 28 - thisPicker: pickers; 29 - setOpenPicker: (thisPicker: pickers) => void; 30 - closePicker: () => void; 31 - setValue: (c: Color) => void; 32 - }) => { 33 - let bgImage = useEntity(props.entityID, "theme/background-image"); 34 - let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 35 - let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 36 - let open = props.openPicker == props.thisPicker; 37 - let { rep } = useReplicache(); 38 - 39 - return ( 40 - <> 41 - <div className="bgPickerLabel flex justify-between place-items-center "> 42 - <div className="bgPickerColorLabel flex gap-2 items-center"> 43 - <button 44 - onClick={() => { 45 - if (props.openPicker === props.thisPicker) { 46 - props.setOpenPicker("null"); 47 - } else { 48 - props.setOpenPicker(props.thisPicker); 49 - } 50 - }} 51 - className="flex gap-2 items-center" 52 - > 53 - <ColorSwatch 54 - color={bgColor} 55 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 56 - style={{ 57 - backgroundImage: bgImage?.data.src 58 - ? `url(${bgImage.data.src})` 59 - : undefined, 60 - backgroundSize: "cover", 61 - }} 62 - /> 63 - <strong className={` "text-[#595959]`}>{"Background"}</strong> 64 - </button> 65 - 66 - <div className="flex"> 67 - {bgImage ? ( 68 - <div className={`"text-[#969696]`}>Image</div> 69 - ) : ( 70 - <> 71 - <ColorField className="w-fit gap-1" value={bgColor}> 72 - <Input 73 - onMouseDown={onMouseDown} 74 - onFocus={(e) => { 75 - e.currentTarget.setSelectionRange( 76 - 1, 77 - e.currentTarget.value.length, 78 - ); 79 - }} 80 - onPaste={(e) => { 81 - console.log(e); 82 - }} 83 - onKeyDown={(e) => { 84 - if (e.key === "Enter") { 85 - e.currentTarget.blur(); 86 - } else return; 87 - }} 88 - onBlur={(e) => { 89 - props.setValue(parseColor(e.currentTarget.value)); 90 - }} 91 - className={`w-[72px] bg-transparent outline-nonetext-[#595959]`} 92 - /> 93 - </ColorField> 94 - </> 95 - )} 96 - </div> 97 - </div> 98 - <div className="flex gap-1 justify-end grow text-[#969696]"> 99 - {bgImage && ( 100 - <button 101 - onClick={() => { 102 - if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 103 - if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 104 - }} 105 - > 106 - <DeleteSmall /> 107 - </button> 108 - )} 109 - <label> 110 - <BlockImageSmall /> 111 - <div className="hidden"> 112 - <ImageInput 113 - {...props} 114 - onChange={() => { 115 - props.setOpenPicker(props.thisPicker); 116 - }} 117 - /> 118 - </div> 119 - </label> 120 - </div> 121 - </div> 122 - {open && ( 123 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 124 - <SpectrumColorPicker 125 - value={bgColor} 126 - onChange={setColorAttribute( 127 - rep, 128 - props.entityID, 129 - )("theme/page-background")} 130 - > 131 - {bgImage ? ( 132 - <ImageSettings 133 - entityID={props.entityID} 134 - setValue={props.setValue} 135 - /> 136 - ) : ( 137 - <> 138 - <ColorArea 139 - className="w-full h-[128px] rounded-md" 140 - colorSpace="hsb" 141 - xChannel="saturation" 142 - yChannel="brightness" 143 - > 144 - <ColorThumb className={thumbStyle} /> 145 - </ColorArea> 146 - <ColorSlider 147 - colorSpace="hsb" 148 - className="w-full " 149 - channel="hue" 150 - > 151 - <SliderTrack className="h-2 w-full rounded-md"> 152 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 153 - </SliderTrack> 154 - </ColorSlider> 155 - </> 156 - )} 157 - </SpectrumColorPicker> 158 - </div> 159 - )} 160 - </> 161 - ); 162 - };
+353 -43
components/ThemeManager/Pickers/PageThemePickers.tsx
··· 51 51 <hr className="border-border-light w-full" /> 52 52 </> 53 53 )} 54 - <PageTextPicker 54 + <TextPickers 55 55 value={primaryValue} 56 56 setValue={set("theme/primary")} 57 57 openPicker={props.openPicker} ··· 61 61 ); 62 62 }; 63 63 64 - export const PageBackgroundPicker = (props: { 64 + // Page background picker for subpages - shows Page/Containers color with optional background image 65 + export const SubpageBackgroundPicker = (props: { 65 66 entityID: string; 66 - setValue: (c: Color) => void; 67 67 openPicker: pickers; 68 68 setOpenPicker: (p: pickers) => void; 69 - home?: boolean; 70 69 }) => { 70 + let { rep, rootEntity } = useReplicache(); 71 + let set = useMemo(() => { 72 + return setColorAttribute(rep, props.entityID); 73 + }, [rep, props.entityID]); 74 + 71 75 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 72 76 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 73 - let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 77 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 78 + let entityPageBorderHidden = useEntity( 79 + props.entityID, 80 + "theme/card-border-hidden", 81 + ); 82 + let pageBorderHidden = 83 + (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 84 + let hasPageBackground = !pageBorderHidden; 85 + 86 + // Label is "Page" when page background is visible, "Containers" when hidden 87 + let label = hasPageBackground ? "Page" : "Containers"; 88 + 89 + // If root page border is hidden, only show color picker (no image support) 90 + if (!hasPageBackground) { 91 + return ( 92 + <ColorPicker 93 + label={label} 94 + helpText={"Affects menus, tooltips and some block backgrounds"} 95 + value={pageValue} 96 + setValue={set("theme/card-background")} 97 + thisPicker="page" 98 + openPicker={props.openPicker} 99 + setOpenPicker={props.setOpenPicker} 100 + closePicker={() => props.setOpenPicker("null")} 101 + alpha 102 + /> 103 + ); 104 + } 74 105 75 106 return ( 76 107 <> 77 - {pageBGImage && pageBGImage !== null && ( 78 - <PageBackgroundImagePicker 79 - disabled={pageBorderHidden?.data.value} 108 + {pageBGImage && ( 109 + <SubpageBackgroundImagePicker 80 110 entityID={props.entityID} 81 - thisPicker={"page-background-image"} 82 111 openPicker={props.openPicker} 83 112 setOpenPicker={props.setOpenPicker} 84 - closePicker={() => props.setOpenPicker("null")} 85 - setValue={props.setValue} 86 - home={props.home} 113 + setValue={set("theme/card-background")} 87 114 /> 88 115 )} 89 116 <div className="relative"> 90 - <PageBackgroundColorPicker 91 - label={pageBorderHidden?.data.value ? "Menus" : "Page"} 117 + <ColorPicker 118 + label={label} 92 119 value={pageValue} 93 - setValue={props.setValue} 94 - thisPicker={"page"} 120 + setValue={set("theme/card-background")} 121 + thisPicker="page" 95 122 openPicker={props.openPicker} 96 123 setOpenPicker={props.setOpenPicker} 124 + closePicker={() => props.setOpenPicker("null")} 97 125 alpha 98 126 /> 99 - {(pageBGImage === null || 100 - (!pageBGImage && !pageBorderHidden?.data.value && !props.home)) && ( 101 - <label 102 - className={` 103 - hover:cursor-pointer text-[#969696] shrink-0 104 - absolute top-0 right-0 105 - `} 106 - > 127 + {!pageBGImage && ( 128 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 107 129 <BlockImageSmall /> 108 130 <div className="hidden"> 109 131 <ImageInput ··· 119 141 ); 120 142 }; 121 143 144 + const SubpageBackgroundImagePicker = (props: { 145 + entityID: string; 146 + openPicker: pickers; 147 + setOpenPicker: (p: pickers) => void; 148 + setValue: (c: Color) => void; 149 + }) => { 150 + let { rep } = useReplicache(); 151 + let bgImage = useEntity(props.entityID, "theme/card-background-image"); 152 + let bgRepeat = useEntity( 153 + props.entityID, 154 + "theme/card-background-image-repeat", 155 + ); 156 + let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 157 + let bgAlpha = 158 + useEntity(props.entityID, "theme/card-background-image-opacity")?.data 159 + .value || 1; 160 + let alphaColor = useMemo(() => { 161 + return parseColor(`rgba(0,0,0,${bgAlpha})`); 162 + }, [bgAlpha]); 163 + let open = props.openPicker === "page-background-image"; 164 + 165 + return ( 166 + <> 167 + <div className="bgPickerColorLabel flex gap-2 items-center"> 168 + <button 169 + onClick={() => { 170 + props.setOpenPicker(open ? "null" : "page-background-image"); 171 + }} 172 + className="flex gap-2 items-center grow" 173 + > 174 + <ColorSwatch 175 + color={bgColor} 176 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 177 + style={{ 178 + backgroundImage: bgImage?.data.src 179 + ? `url(${bgImage.data.src})` 180 + : undefined, 181 + backgroundPosition: "center", 182 + backgroundSize: "cover", 183 + }} 184 + /> 185 + <strong className="text-[#595959]">Page</strong> 186 + <div className="italic text-[#8C8C8C]">image</div> 187 + </button> 188 + 189 + <SpectrumColorPicker 190 + value={alphaColor} 191 + onChange={(c) => { 192 + let alpha = c.getChannelValue("alpha"); 193 + rep?.mutate.assertFact({ 194 + entity: props.entityID, 195 + attribute: "theme/card-background-image-opacity", 196 + data: { type: "number", value: alpha }, 197 + }); 198 + }} 199 + > 200 + <Separator classname="h-4! my-1 border-[#C3C3C3]!" /> 201 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 202 + <Input 203 + onMouseDown={onMouseDown} 204 + onFocus={(e) => { 205 + e.currentTarget.setSelectionRange( 206 + 0, 207 + e.currentTarget.value.length - 1, 208 + ); 209 + }} 210 + onKeyDown={(e) => { 211 + if (e.key === "Enter") { 212 + e.currentTarget.blur(); 213 + } else return; 214 + }} 215 + className="w-[48px] bg-transparent outline-hidden" 216 + /> 217 + </ColorField> 218 + </SpectrumColorPicker> 219 + 220 + <div className="flex gap-1 text-[#8C8C8C]"> 221 + <button 222 + onClick={() => { 223 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 224 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 225 + }} 226 + > 227 + <DeleteSmall /> 228 + </button> 229 + <label className="hover:cursor-pointer"> 230 + <BlockImageSmall /> 231 + <div className="hidden"> 232 + <ImageInput 233 + entityID={props.entityID} 234 + onChange={() => props.setOpenPicker("page-background-image")} 235 + card 236 + /> 237 + </div> 238 + </label> 239 + </div> 240 + </div> 241 + {open && ( 242 + <div className="pageImagePicker flex flex-col gap-2"> 243 + <ImageSettings 244 + entityID={props.entityID} 245 + card 246 + setValue={props.setValue} 247 + /> 248 + <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2"> 249 + <hr className="border-[#DBDBDB]" /> 250 + <SpectrumColorPicker 251 + value={alphaColor} 252 + onChange={(c) => { 253 + let alpha = c.getChannelValue("alpha"); 254 + rep?.mutate.assertFact({ 255 + entity: props.entityID, 256 + attribute: "theme/card-background-image-opacity", 257 + data: { type: "number", value: alpha }, 258 + }); 259 + }} 260 + > 261 + <ColorSlider 262 + colorSpace="hsb" 263 + className="w-full mt-1 rounded-full" 264 + style={{ 265 + backgroundImage: `url(/transparent-bg.png)`, 266 + backgroundRepeat: "repeat", 267 + backgroundSize: "8px", 268 + }} 269 + channel="alpha" 270 + > 271 + <SliderTrack className="h-2 w-full rounded-md"> 272 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 273 + </SliderTrack> 274 + </ColorSlider> 275 + </SpectrumColorPicker> 276 + </div> 277 + </div> 278 + )} 279 + </> 280 + ); 281 + }; 282 + 283 + // Unified background picker for leaflets - matches structure of BackgroundPicker for publications 284 + export const LeafletBackgroundPicker = (props: { 285 + entityID: string; 286 + openPicker: pickers; 287 + setOpenPicker: (p: pickers) => void; 288 + }) => { 289 + let { rep } = useReplicache(); 290 + let set = useMemo(() => { 291 + return setColorAttribute(rep, props.entityID); 292 + }, [rep, props.entityID]); 293 + 294 + let leafletBgValue = useColorAttribute( 295 + props.entityID, 296 + "theme/page-background", 297 + ); 298 + let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 299 + let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 300 + let leafletBGRepeat = useEntity( 301 + props.entityID, 302 + "theme/background-image-repeat", 303 + ); 304 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 305 + let hasPageBackground = !pageBorderHidden?.data.value; 306 + 307 + // When page background is hidden and no background image, only show the Background picker 308 + let showPagePicker = hasPageBackground || !!leafletBGImage; 309 + 310 + return ( 311 + <> 312 + {/* Background color/image picker */} 313 + {leafletBGImage ? ( 314 + <LeafletBackgroundImagePicker 315 + entityID={props.entityID} 316 + openPicker={props.openPicker} 317 + setOpenPicker={props.setOpenPicker} 318 + /> 319 + ) : ( 320 + <div className="relative"> 321 + <ColorPicker 322 + label="Background" 323 + value={leafletBgValue} 324 + setValue={set("theme/page-background")} 325 + thisPicker="leaflet" 326 + openPicker={props.openPicker} 327 + setOpenPicker={props.setOpenPicker} 328 + closePicker={() => props.setOpenPicker("null")} 329 + /> 330 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 331 + <BlockImageSmall /> 332 + <div className="hidden"> 333 + <ImageInput 334 + entityID={props.entityID} 335 + onChange={() => props.setOpenPicker("leaflet")} 336 + /> 337 + </div> 338 + </label> 339 + </div> 340 + )} 341 + 342 + {/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */} 343 + {showPagePicker && ( 344 + <ColorPicker 345 + label={hasPageBackground ? "Page" : "Containers"} 346 + helpText={ 347 + hasPageBackground 348 + ? undefined 349 + : "Affects menus, tooltips and some block backgrounds" 350 + } 351 + value={pageValue} 352 + setValue={set("theme/card-background")} 353 + thisPicker="page" 354 + openPicker={props.openPicker} 355 + setOpenPicker={props.setOpenPicker} 356 + closePicker={() => props.setOpenPicker("null")} 357 + alpha 358 + /> 359 + )} 360 + 361 + <hr className="border-[#CCCCCC]" /> 362 + 363 + {/* Page Background toggle */} 364 + <PageBorderHider 365 + entityID={props.entityID} 366 + openPicker={props.openPicker} 367 + setOpenPicker={props.setOpenPicker} 368 + /> 369 + </> 370 + ); 371 + }; 372 + 373 + const LeafletBackgroundImagePicker = (props: { 374 + entityID: string; 375 + openPicker: pickers; 376 + setOpenPicker: (p: pickers) => void; 377 + }) => { 378 + let { rep } = useReplicache(); 379 + let bgImage = useEntity(props.entityID, "theme/background-image"); 380 + let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 381 + let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 382 + let open = props.openPicker === "leaflet"; 383 + 384 + return ( 385 + <> 386 + <div className="bgPickerColorLabel flex gap-2 items-center"> 387 + <button 388 + onClick={() => { 389 + props.setOpenPicker(open ? "null" : "leaflet"); 390 + }} 391 + className="flex gap-2 items-center grow" 392 + > 393 + <ColorSwatch 394 + color={bgColor} 395 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 396 + style={{ 397 + backgroundImage: bgImage?.data.src 398 + ? `url(${bgImage.data.src})` 399 + : undefined, 400 + backgroundPosition: "center", 401 + backgroundSize: "cover", 402 + }} 403 + /> 404 + <strong className="text-[#595959]">Background</strong> 405 + <div className="italic text-[#8C8C8C]">image</div> 406 + </button> 407 + <div className="flex gap-1 text-[#8C8C8C]"> 408 + <button 409 + onClick={() => { 410 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 411 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 412 + }} 413 + > 414 + <DeleteSmall /> 415 + </button> 416 + <label className="hover:cursor-pointer"> 417 + <BlockImageSmall /> 418 + <div className="hidden"> 419 + <ImageInput 420 + entityID={props.entityID} 421 + onChange={() => props.setOpenPicker("leaflet")} 422 + /> 423 + </div> 424 + </label> 425 + </div> 426 + </div> 427 + {open && ( 428 + <div className="pageImagePicker flex flex-col gap-2"> 429 + <ImageSettings entityID={props.entityID} setValue={() => {}} /> 430 + </div> 431 + )} 432 + </> 433 + ); 434 + }; 435 + 122 436 export const PageBackgroundColorPicker = (props: { 123 437 disabled?: boolean; 124 438 label: string; ··· 128 442 setValue: (c: Color) => void; 129 443 value: Color; 130 444 alpha?: boolean; 445 + helpText?: string; 131 446 }) => { 132 447 return ( 133 448 <ColorPicker 134 449 disabled={props.disabled} 135 450 label={props.label} 451 + helpText={props.helpText} 136 452 value={props.value} 137 453 setValue={props.setValue} 138 454 thisPicker={"page"} ··· 347 663 ); 348 664 }; 349 665 350 - export const PageTextPicker = (props: { 666 + export const TextPickers = (props: { 351 667 openPicker: pickers; 352 668 setOpenPicker: (thisPicker: pickers) => void; 353 669 value: Color; ··· 394 710 395 711 return ( 396 712 <> 397 - <div className="flex gap-2 items-center"> 398 - <Toggle 399 - toggleOn={!pageBorderHidden} 400 - setToggleOn={() => { 401 - handleToggle(); 402 - }} 403 - disabledColor1="#8C8C8C" 404 - disabledColor2="#DBDBDB" 405 - /> 406 - <button 407 - className="flex gap-2 items-center" 408 - onClick={() => { 409 - handleToggle(); 410 - }} 411 - > 713 + <Toggle 714 + toggle={!pageBorderHidden} 715 + onToggle={() => { 716 + handleToggle(); 717 + }} 718 + disabledColor1="#8C8C8C" 719 + disabledColor2="#DBDBDB" 720 + > 721 + <div className="flex gap-2"> 412 722 <div className="font-bold">Page Background</div> 413 723 <div className="italic text-[#8C8C8C]"> 414 - {pageBorderHidden ? "hidden" : ""} 724 + {pageBorderHidden ? "none" : ""} 415 725 </div> 416 - </button> 417 - </div> 726 + </div> 727 + </Toggle> 418 728 </> 419 729 ); 420 730 };
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
··· 1 + import * as Slider from "@radix-ui/react-slider"; 2 + import { Input } from "components/Input"; 3 + import { Radio } from "components/Checkbox"; 4 + import { useEntity, useReplicache } from "src/replicache"; 5 + import { pickers } from "../ThemeSetter"; 6 + import { useState, useEffect } from "react"; 7 + 8 + export const PageWidthSetter = (props: { 9 + entityID: string; 10 + openPicker: pickers; 11 + thisPicker: pickers; 12 + setOpenPicker: (thisPicker: pickers) => void; 13 + closePicker: () => void; 14 + }) => { 15 + let { rep } = useReplicache(); 16 + 17 + let defaultPreset = 624; 18 + let widePreset = 768; 19 + let pageWidth = useEntity(props.entityID, "theme/page-width")?.data.value; 20 + let currentValue = pageWidth || defaultPreset; 21 + let [interimValue, setInterimValue] = useState<number>(currentValue); 22 + let [selectedPreset, setSelectedPreset] = useState< 23 + "default" | "wide" | "custom" 24 + >( 25 + currentValue === defaultPreset 26 + ? "default" 27 + : currentValue === widePreset 28 + ? "wide" 29 + : "custom", 30 + ); 31 + let min = 320; 32 + let max = 1200; 33 + 34 + let open = props.openPicker == props.thisPicker; 35 + 36 + // Update interim value when current value changes 37 + useEffect(() => { 38 + setInterimValue(currentValue); 39 + }, [currentValue]); 40 + 41 + const setPageWidth = (value: number) => { 42 + rep?.mutate.assertFact({ 43 + entity: props.entityID, 44 + attribute: "theme/page-width", 45 + data: { 46 + type: "number", 47 + value: value, 48 + }, 49 + }); 50 + }; 51 + 52 + return ( 53 + <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md"> 54 + <div className="flex flex-col gap-2"> 55 + <div className="flex gap-2 items-center"> 56 + <button 57 + className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 items-start text-left" 58 + onClick={() => { 59 + if (props.openPicker === props.thisPicker) { 60 + props.setOpenPicker("null"); 61 + } else { 62 + props.setOpenPicker(props.thisPicker); 63 + } 64 + }} 65 + > 66 + Max Page Width 67 + <span className="flex font-normal text-[#969696]"> 68 + {currentValue}px 69 + </span> 70 + </button> 71 + </div> 72 + {open && ( 73 + <div className="flex flex-col gap-1 px-3"> 74 + <label htmlFor="default" className="w-full"> 75 + <Radio 76 + radioCheckedClassName="text-[#595959]!" 77 + radioEmptyClassName="text-[#969696]!" 78 + type="radio" 79 + id="default" 80 + name="page-width-options" 81 + value="default" 82 + checked={selectedPreset === "default"} 83 + onChange={(e) => { 84 + if (!e.currentTarget.checked) return; 85 + setSelectedPreset("default"); 86 + setPageWidth(defaultPreset); 87 + }} 88 + > 89 + <div 90 + className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 91 + > 92 + default (624px) 93 + </div> 94 + </Radio> 95 + </label> 96 + <label htmlFor="wide" className="w-full"> 97 + <Radio 98 + radioCheckedClassName="text-[#595959]!" 99 + radioEmptyClassName="text-[#969696]!" 100 + type="radio" 101 + id="wide" 102 + name="page-width-options" 103 + value="wide" 104 + checked={selectedPreset === "wide"} 105 + onChange={(e) => { 106 + if (!e.currentTarget.checked) return; 107 + setSelectedPreset("wide"); 108 + setPageWidth(widePreset); 109 + }} 110 + > 111 + <div 112 + className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 113 + > 114 + wide (756px) 115 + </div> 116 + </Radio> 117 + </label> 118 + <label htmlFor="custom" className="pb-3 w-full"> 119 + <Radio 120 + type="radio" 121 + id="custom" 122 + name="page-width-options" 123 + value="custom" 124 + radioCheckedClassName="text-[#595959]!" 125 + radioEmptyClassName="text-[#969696]!" 126 + checked={selectedPreset === "custom"} 127 + onChange={(e) => { 128 + if (!e.currentTarget.checked) return; 129 + setSelectedPreset("custom"); 130 + if (selectedPreset !== "custom") { 131 + setPageWidth(currentValue); 132 + setInterimValue(currentValue); 133 + } 134 + }} 135 + > 136 + <div className="flex flex-col w-full"> 137 + <div className="flex gap-2"> 138 + <div 139 + className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`} 140 + > 141 + custom 142 + </div> 143 + <div 144 + className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`} 145 + > 146 + <Input 147 + type="number" 148 + className="w-10 text-right appearance-none bg-transparent" 149 + max={max} 150 + min={min} 151 + value={interimValue} 152 + onChange={(e) => { 153 + setInterimValue(parseInt(e.currentTarget.value)); 154 + }} 155 + onKeyDown={(e) => { 156 + if (e.key === "Enter" || e.key === "Escape") { 157 + e.preventDefault(); 158 + let clampedValue = interimValue; 159 + if (!isNaN(interimValue)) { 160 + clampedValue = Math.max( 161 + min, 162 + Math.min(max, interimValue), 163 + ); 164 + setInterimValue(clampedValue); 165 + } 166 + setPageWidth(clampedValue); 167 + } 168 + }} 169 + onBlur={() => { 170 + let clampedValue = interimValue; 171 + if (!isNaN(interimValue)) { 172 + clampedValue = Math.max( 173 + min, 174 + Math.min(max, interimValue), 175 + ); 176 + setInterimValue(clampedValue); 177 + } 178 + setPageWidth(clampedValue); 179 + }} 180 + /> 181 + px 182 + </div> 183 + </div> 184 + <Slider.Root 185 + className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`} 186 + value={[interimValue]} 187 + max={max} 188 + min={min} 189 + step={16} 190 + onValueChange={(value) => { 191 + setInterimValue(value[0]); 192 + }} 193 + onValueCommit={(value) => { 194 + setPageWidth(value[0]); 195 + }} 196 + > 197 + <Slider.Track 198 + className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 199 + /> 200 + <Slider.Thumb 201 + className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 202 + ${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"} 203 + `} 204 + aria-label="Max Page Width" 205 + /> 206 + </Slider.Root> 207 + </div> 208 + </Radio> 209 + </label> 210 + </div> 211 + )} 212 + </div> 213 + </div> 214 + ); 215 + };
+30 -24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 24 24 hasPageBackground: boolean; 25 25 setHasPageBackground: (s: boolean) => void; 26 26 }) => { 27 + // When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker 28 + let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage; 29 + 27 30 return ( 28 31 <> 29 32 {props.bgImage && props.bgImage !== null ? ( ··· 83 86 )} 84 87 </div> 85 88 )} 86 - <PageBackgroundColorPicker 87 - label={"Containers"} 88 - value={props.pageBackground} 89 - setValue={props.setPageBackground} 90 - thisPicker={"page"} 91 - openPicker={props.openPicker} 92 - setOpenPicker={props.setOpenPicker} 93 - alpha={props.hasPageBackground ? true : false} 94 - /> 89 + {!showLeafletBgPicker && ( 90 + // When there's a background image and page background hidden, label should say "Containers" 91 + <PageBackgroundColorPicker 92 + label={props.hasPageBackground ? "Page" : "Containers"} 93 + helpText={ 94 + props.hasPageBackground 95 + ? undefined 96 + : "Affects menus, tooltips and some block backgrounds" 97 + } 98 + value={props.pageBackground} 99 + setValue={props.setPageBackground} 100 + thisPicker={"page"} 101 + openPicker={props.openPicker} 102 + setOpenPicker={props.setOpenPicker} 103 + alpha={props.hasPageBackground ? true : false} 104 + /> 105 + )} 95 106 <hr className="border-border-light" /> 96 107 <div className="flex gap-2 items-center"> 97 108 <Toggle 98 - toggleOn={props.hasPageBackground} 99 - setToggleOn={() => { 109 + toggle={props.hasPageBackground} 110 + onToggle={() => { 100 111 props.setHasPageBackground(!props.hasPageBackground); 101 112 props.hasPageBackground && 102 113 props.openPicker === "page" && ··· 104 115 }} 105 116 disabledColor1="#8C8C8C" 106 117 disabledColor2="#DBDBDB" 107 - /> 108 - <button 109 - className="flex gap-2 items-center" 110 - onClick={() => { 111 - props.setHasPageBackground(!props.hasPageBackground); 112 - props.hasPageBackground && props.setOpenPicker("null"); 113 - }} 114 118 > 115 - <div className="font-bold">Page Background</div> 116 - <div className="italic text-[#8C8C8C]"> 117 - {props.hasPageBackground ? "" : "hidden"} 119 + <div className="flex gap-2"> 120 + <div className="font-bold">Page Background</div> 121 + <div className="italic text-[#8C8C8C]"> 122 + {props.hasPageBackground ? "" : "none"} 123 + </div> 118 124 </div> 119 - </button> 125 + </Toggle> 120 126 </div> 121 127 </> 122 128 ); ··· 250 256 props.setBgImage({ ...props.bgImage, repeat: 500 }); 251 257 }} 252 258 > 253 - <div className="flex flex-col gap-2 w-full"> 259 + <div className="flex flex-col w-full"> 254 260 <div className="flex gap-2"> 255 261 <div 256 262 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 289 295 }} 290 296 > 291 297 <Slider.Track 292 - className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 298 + className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 293 299 ></Slider.Track> 294 300 <Slider.Thumb 295 301 className={`
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
··· 1 + import * as Slider from "@radix-ui/react-slider"; 2 + import { Input } from "components/Input"; 3 + import { Radio } from "components/Checkbox"; 4 + import { useState, useEffect } from "react"; 5 + import { pickers } from "../ThemeSetter"; 6 + 7 + export const PubPageWidthSetter = (props: { 8 + pageWidth: number | undefined; 9 + setPageWidth: (value: number) => void; 10 + thisPicker: pickers; 11 + openPicker: pickers; 12 + setOpenPicker: (p: pickers) => void; 13 + }) => { 14 + let defaultPreset = 624; 15 + let widePreset = 768; 16 + 17 + let currentValue = props.pageWidth || defaultPreset; 18 + let [interimValue, setInterimValue] = useState<number>(currentValue); 19 + let [selectedPreset, setSelectedPreset] = useState< 20 + "default" | "wide" | "custom" 21 + >( 22 + currentValue === defaultPreset 23 + ? "default" 24 + : currentValue === widePreset 25 + ? "wide" 26 + : "custom", 27 + ); 28 + let min = 320; 29 + let max = 1200; 30 + 31 + // Update interim value when current value changes 32 + useEffect(() => { 33 + setInterimValue(currentValue); 34 + }, [currentValue]); 35 + 36 + const setPageWidth = (value: number) => { 37 + props.setPageWidth(value); 38 + }; 39 + 40 + let open = props.openPicker == props.thisPicker; 41 + 42 + return ( 43 + <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white"> 44 + <button 45 + type="button" 46 + className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 47 + onClick={() => { 48 + if (!open) { 49 + props.setOpenPicker(props.thisPicker); 50 + } else { 51 + props.setOpenPicker("null"); 52 + } 53 + }} 54 + > 55 + Max Page Width 56 + <div className="flex font-normal text-[#969696]">{currentValue}px</div> 57 + </button> 58 + 59 + {open && ( 60 + <div className="flex flex-col gap-1 px-3"> 61 + <label htmlFor="pub-default" className="w-full"> 62 + <Radio 63 + radioCheckedClassName="text-[#595959]!" 64 + radioEmptyClassName="text-[#969696]!" 65 + type="radio" 66 + id="pub-default" 67 + name="pub-page-width-options" 68 + value="default" 69 + checked={selectedPreset === "default"} 70 + onChange={(e) => { 71 + if (!e.currentTarget.checked) return; 72 + setSelectedPreset("default"); 73 + setPageWidth(defaultPreset); 74 + }} 75 + > 76 + <div 77 + className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 78 + > 79 + default (624px) 80 + </div> 81 + </Radio> 82 + </label> 83 + <label htmlFor="pub-wide" className="w-full"> 84 + <Radio 85 + radioCheckedClassName="text-[#595959]!" 86 + radioEmptyClassName="text-[#969696]!" 87 + type="radio" 88 + id="pub-wide" 89 + name="pub-page-width-options" 90 + value="wide" 91 + checked={selectedPreset === "wide"} 92 + onChange={(e) => { 93 + if (!e.currentTarget.checked) return; 94 + setSelectedPreset("wide"); 95 + setPageWidth(widePreset); 96 + }} 97 + > 98 + <div 99 + className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 100 + > 101 + wide (756px) 102 + </div> 103 + </Radio> 104 + </label> 105 + <label htmlFor="pub-custom" className="pb-3 w-full"> 106 + <Radio 107 + type="radio" 108 + id="pub-custom" 109 + name="pub-page-width-options" 110 + value="custom" 111 + radioCheckedClassName="text-[#595959]!" 112 + radioEmptyClassName="text-[#969696]!" 113 + checked={selectedPreset === "custom"} 114 + onChange={(e) => { 115 + if (!e.currentTarget.checked) return; 116 + setSelectedPreset("custom"); 117 + if (selectedPreset !== "custom") { 118 + setPageWidth(currentValue); 119 + setInterimValue(currentValue); 120 + } 121 + }} 122 + > 123 + <div className="flex flex-col w-full"> 124 + <div className="flex gap-2"> 125 + <div 126 + className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`} 127 + > 128 + custom 129 + </div> 130 + <div 131 + className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`} 132 + > 133 + <Input 134 + type="number" 135 + className="w-10 text-right appearance-none bg-transparent" 136 + max={max} 137 + min={min} 138 + value={interimValue} 139 + onChange={(e) => { 140 + setInterimValue(parseInt(e.currentTarget.value)); 141 + }} 142 + onKeyDown={(e) => { 143 + if (e.key === "Enter" || e.key === "Escape") { 144 + e.preventDefault(); 145 + let clampedValue = interimValue; 146 + if (!isNaN(interimValue)) { 147 + clampedValue = Math.max( 148 + min, 149 + Math.min(max, interimValue), 150 + ); 151 + setInterimValue(clampedValue); 152 + } 153 + setPageWidth(clampedValue); 154 + } 155 + }} 156 + onBlur={() => { 157 + let clampedValue = interimValue; 158 + if (!isNaN(interimValue)) { 159 + clampedValue = Math.max( 160 + min, 161 + Math.min(max, interimValue), 162 + ); 163 + setInterimValue(clampedValue); 164 + } 165 + setPageWidth(clampedValue); 166 + }} 167 + /> 168 + px 169 + </div> 170 + </div> 171 + <Slider.Root 172 + className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`} 173 + value={[interimValue]} 174 + max={max} 175 + min={min} 176 + step={16} 177 + onValueChange={(value) => { 178 + setInterimValue(value[0]); 179 + }} 180 + onValueCommit={(value) => { 181 + setPageWidth(value[0]); 182 + }} 183 + > 184 + <Slider.Track 185 + className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 186 + /> 187 + <Slider.Thumb 188 + className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 189 + ${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"} 190 + `} 191 + aria-label="Max Page Width" 192 + /> 193 + </Slider.Root> 194 + </div> 195 + </Radio> 196 + </label> 197 + </div> 198 + )} 199 + </div> 200 + ); 201 + };
+2 -2
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 1 1 import { pickers } from "../ThemeSetter"; 2 - import { PageTextPicker } from "../Pickers/PageThemePickers"; 2 + import { TextPickers } from "../Pickers/PageThemePickers"; 3 3 import { Color } from "react-aria-components"; 4 4 5 5 export const PagePickers = (props: { ··· 20 20 : "transparent", 21 21 }} 22 22 > 23 - <PageTextPicker 23 + <TextPickers 24 24 value={props.primary} 25 25 setValue={props.setPrimary} 26 26 openPicker={props.openPicker}
+41 -8
components/ThemeManager/PubThemeSetter.tsx
··· 15 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 17 import { Separator } from "components/Layout"; 18 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 18 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 19 19 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 + import { useToaster } from "components/Toast"; 21 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 + import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 20 23 21 24 export type ImageState = { 22 25 src: string; ··· 54 57 } 55 58 : null, 56 59 ); 57 - 60 + let [pageWidth, setPageWidth] = useState<number>( 61 + record?.theme?.pageWidth || 624, 62 + ); 58 63 let pubBGImage = image?.src || null; 59 64 let leafletBGRepeat = image?.repeat || null; 65 + let toaster = useToaster(); 60 66 61 67 return ( 62 - <BaseThemeProvider local {...localPubTheme}> 68 + <BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}> 63 69 <form 64 70 onSubmit={async (e) => { 65 71 e.preventDefault(); ··· 75 81 : ColorToRGB(localPubTheme.bgLeaflet), 76 82 backgroundRepeat: image?.repeat, 77 83 backgroundImage: image ? image.file : null, 84 + pageWidth: pageWidth, 78 85 primary: ColorToRGB(localPubTheme.primary), 79 86 accentBackground: ColorToRGB(localPubTheme.accent1), 80 87 accentText: ColorToRGB(localPubTheme.accent2), 81 88 }, 82 89 }); 90 + 91 + if (!result.success) { 92 + props.setLoading(false); 93 + if (result.error && isOAuthSessionError(result.error)) { 94 + toaster({ 95 + content: <OAuthErrorMessage error={result.error} />, 96 + type: "error", 97 + }); 98 + } else { 99 + toaster({ 100 + content: "Failed to update theme", 101 + type: "error", 102 + }); 103 + } 104 + return; 105 + } 106 + 83 107 mutate((pub) => { 84 - if (result?.publication && pub?.publication) 108 + if (result.publication && pub?.publication) 85 109 return { 86 110 ...pub, 87 111 publication: { ...pub.publication, ...result.publication }, ··· 96 120 setLoadingAction={props.setLoading} 97 121 backToMenuAction={props.backToMenu} 98 122 state={"theme"} 99 - /> 123 + > 124 + Theme and Layout 125 + </PubSettingsHeader> 100 126 </form> 101 127 102 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 "> 103 - <div className="themeBGLeaflet flex"> 128 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 mt-2 "> 129 + <PubPageWidthSetter 130 + pageWidth={pageWidth} 131 + setPageWidth={setPageWidth} 132 + thisPicker="page-width" 133 + openPicker={openPicker} 134 + setOpenPicker={setOpenPicker} 135 + /> 136 + <div className="themeBGLeaflet flex flex-col"> 104 137 <div 105 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 138 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 106 139 > 107 140 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 108 141 <BackgroundPicker
+11 -3
components/ThemeManager/PublicationThemeProvider.tsx
··· 102 102 pub_creator: string; 103 103 isStandalone?: boolean; 104 104 }) { 105 - let colors = usePubTheme(props.theme, props.isStandalone); 106 - let cardBorderHidden = !colors.showPageBackground; 105 + let theme = usePubTheme(props.theme, props.isStandalone); 106 + let cardBorderHidden = !theme.showPageBackground; 107 + let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref; 108 + 107 109 return ( 108 110 <CardBorderHiddenContext.Provider value={cardBorderHidden}> 109 - <BaseThemeProvider local={props.local} {...colors}> 111 + <BaseThemeProvider 112 + local={props.local} 113 + {...theme} 114 + hasBackgroundImage={hasBackgroundImage} 115 + > 110 116 {props.children} 111 117 </BaseThemeProvider> 112 118 </CardBorderHiddenContext.Provider> ··· 127 133 bgPage = bgLeaflet; 128 134 } 129 135 let showPageBackground = theme?.showPageBackground; 136 + let pageWidth = theme?.pageWidth; 130 137 131 138 let primary = useColor(theme, "primary"); 132 139 ··· 147 154 highlight2, 148 155 highlight3, 149 156 showPageBackground, 157 + pageWidth, 150 158 }; 151 159 }; 152 160
+25 -1
components/ThemeManager/ThemeProvider.tsx
··· 65 65 "theme/card-border-hidden", 66 66 )?.data.value; 67 67 let showPageBackground = !cardBorderHiddenValue; 68 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 69 + let hasBackgroundImage = !!backgroundImage; 68 70 let primary = useColorAttribute(props.entityID, "theme/primary"); 69 71 70 72 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 73 75 74 76 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 75 77 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 78 + 79 + let pageWidth = useEntity(props.entityID, "theme/page-width"); 76 80 77 81 return ( 78 82 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 87 91 accent1={accent1} 88 92 accent2={accent2} 89 93 showPageBackground={showPageBackground} 94 + pageWidth={pageWidth?.data.value} 95 + hasBackgroundImage={hasBackgroundImage} 90 96 > 91 97 {props.children} 92 98 </BaseThemeProvider> ··· 98 104 export const BaseThemeProvider = ({ 99 105 local, 100 106 bgLeaflet, 101 - bgPage, 107 + bgPage: bgPageProp, 102 108 primary, 103 109 accent1, 104 110 accent2, ··· 106 112 highlight2, 107 113 highlight3, 108 114 showPageBackground, 115 + pageWidth, 116 + hasBackgroundImage, 109 117 children, 110 118 }: { 111 119 local?: boolean; 112 120 showPageBackground?: boolean; 121 + hasBackgroundImage?: boolean; 113 122 bgLeaflet: AriaColor; 114 123 bgPage: AriaColor; 115 124 primary: AriaColor; ··· 118 127 highlight1?: string; 119 128 highlight2: AriaColor; 120 129 highlight3: AriaColor; 130 + pageWidth?: number; 121 131 children: React.ReactNode; 122 132 }) => { 133 + // When showPageBackground is false and there's no background image, 134 + // pageBg should inherit from leafletBg 135 + const bgPage = 136 + !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 123 137 // set accent contrast to the accent color that has the highest contrast with the page background 124 138 let accentContrast; 125 139 ··· 196 210 "--accent-1-is-contrast", 197 211 accentContrast === accent1 ? "1" : "0", 198 212 ); 213 + 214 + // Set page width CSS variable 215 + el?.style.setProperty( 216 + "--page-width-setting", 217 + (pageWidth || 624).toString(), 218 + ); 199 219 }, [ 200 220 local, 201 221 bgLeaflet, ··· 207 227 accent1, 208 228 accent2, 209 229 accentContrast, 230 + pageWidth, 210 231 ]); 211 232 return ( 212 233 <div ··· 226 247 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 227 248 "--highlight-2": colorToString(highlight2, "rgb"), 228 249 "--highlight-3": colorToString(highlight3, "rgb"), 250 + "--page-width-setting": pageWidth || 624, 251 + "--page-width-unitless": pageWidth || 624, 252 + "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 229 253 } as CSSProperties 230 254 } 231 255 >
+21 -35
components/ThemeManager/ThemeSetter.tsx
··· 1 1 "use client"; 2 2 import { Popover } from "components/Popover"; 3 - import { theme } from "../../tailwind.config"; 4 3 5 4 import { Color } from "react-aria-components"; 6 5 7 - import { LeafletBGPicker } from "./Pickers/LeafletBGPicker"; 8 6 import { 9 - PageBackgroundPicker, 10 - PageBorderHider, 7 + LeafletBackgroundPicker, 11 8 PageThemePickers, 12 9 } from "./Pickers/PageThemePickers"; 10 + import { PageWidthSetter } from "./Pickers/PageWidthSetter"; 13 11 import { useMemo, useState } from "react"; 14 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 15 13 import { Replicache } from "replicache"; ··· 35 33 | "highlight-1" 36 34 | "highlight-2" 37 35 | "highlight-3" 38 - | "page-background-image"; 36 + | "page-background-image" 37 + | "page-width"; 39 38 40 39 export function setColorAttribute( 41 40 rep: Replicache<ReplicacheMutators> | null, ··· 75 74 return ( 76 75 <> 77 76 <Popover 78 - className="w-80 bg-white" 77 + className="w-80 bg-white py-3!" 79 78 arrowFill="#FFFFFF" 80 79 asChild 81 80 side={isMobile ? "top" : "right"} ··· 114 113 if (pub?.publications) return null; 115 114 return ( 116 115 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 116 + {!props.home && ( 117 + <PageWidthSetter 118 + entityID={props.entityID} 119 + thisPicker={"page-width"} 120 + openPicker={openPicker} 121 + setOpenPicker={setOpenPicker} 122 + closePicker={() => setOpenPicker("null")} 123 + /> 124 + )} 117 125 <div className="themeBGLeaflet flex"> 118 126 <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 127 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 120 - <LeafletBGPicker 121 - entityID={props.entityID} 122 - thisPicker={"leaflet"} 123 - openPicker={openPicker} 124 - setOpenPicker={setOpenPicker} 125 - closePicker={() => setOpenPicker("null")} 126 - setValue={set("theme/page-background")} 127 - /> 128 - <PageBackgroundPicker 129 - entityID={props.entityID} 130 - setValue={set("theme/card-background")} 131 - openPicker={openPicker} 132 - setOpenPicker={setOpenPicker} 133 - home={props.home} 134 - /> 135 - <hr className=" border-[#CCCCCC]" /> 136 - <PageBorderHider 128 + <LeafletBackgroundPicker 137 129 entityID={props.entityID} 138 130 openPicker={openPicker} 139 131 setOpenPicker={setOpenPicker} ··· 173 165 setOpenPicker={(pickers) => setOpenPicker(pickers)} 174 166 /> 175 167 <SectionArrow 176 - fill={theme.colors["accent-2"]} 177 - stroke={theme.colors["accent-1"]} 168 + fill="rgb(var(--accent-2))" 169 + stroke="rgb(var(--accent-1))" 178 170 className="ml-2" 179 171 /> 180 172 </div> ··· 209 201 return ( 210 202 <div className="flex gap-2 items-start mt-0.5"> 211 203 <Toggle 212 - toggleOn={!!checked?.data.value} 213 - setToggleOn={() => { 204 + toggle={!!checked?.data.value} 205 + onToggle={() => { 214 206 handleToggle(); 215 207 }} 216 208 disabledColor1="#8C8C8C" 217 209 disabledColor2="#DBDBDB" 218 - /> 219 - <button 220 - className="flex gap-2 items-center -mt-0.5" 221 - onClick={() => { 222 - handleToggle(); 223 - }} 224 210 > 225 - <div className="flex flex-col gap-0 items-start"> 211 + <div className="flex flex-col gap-0 items-start "> 226 212 <div className="font-bold">Show Leaflet Watermark</div> 227 213 <div className="text-sm text-[#969696]">Help us spread the word!</div> 228 214 </div> 229 - </button> 215 + </Toggle> 230 216 </div> 231 217 ); 232 218 }
+32 -20
components/Toggle.tsx
··· 1 1 import { theme } from "tailwind.config"; 2 2 3 3 export const Toggle = (props: { 4 - toggleOn: boolean; 5 - setToggleOn: (s: boolean) => void; 4 + toggle: boolean; 5 + onToggle: () => void; 6 6 disabledColor1?: string; 7 7 disabledColor2?: string; 8 + children: React.ReactNode; 8 9 }) => { 9 10 return ( 10 11 <button 11 - className="toggle selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border" 12 - style={{ 13 - border: props.toggleOn 14 - ? "1px solid " + theme.colors["accent-2"] 15 - : "1px solid " + props.disabledColor2 || theme.colors["border-light"], 16 - justifyContent: props.toggleOn ? "flex-end" : "flex-start", 17 - background: props.toggleOn 18 - ? theme.colors["accent-1"] 19 - : props.disabledColor1 || theme.colors["tertiary"], 12 + type="button" 13 + className="toggle flex gap-2 items-start justify-start text-left" 14 + onClick={() => { 15 + props.onToggle(); 20 16 }} 21 - onClick={() => props.setToggleOn(!props.toggleOn)} 22 17 > 23 - <div 24 - className="h-[14px] w-[10px] m-0.5 rounded-[2px]" 25 - style={{ 26 - background: props.toggleOn 27 - ? theme.colors["accent-2"] 28 - : props.disabledColor2 || theme.colors["border-light"], 29 - }} 30 - /> 18 + <div className="h-6 flex place-items-center"> 19 + <div 20 + className="selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border" 21 + style={{ 22 + border: props.toggle 23 + ? "1px solid " + theme.colors["accent-2"] 24 + : "1px solid " + props.disabledColor2 || 25 + theme.colors["border-light"], 26 + justifyContent: props.toggle ? "flex-end" : "flex-start", 27 + background: props.toggle 28 + ? theme.colors["accent-1"] 29 + : props.disabledColor1 || theme.colors["tertiary"], 30 + }} 31 + > 32 + <div 33 + className="h-[14px] w-[10px] m-0.5 rounded-[2px]" 34 + style={{ 35 + background: props.toggle 36 + ? theme.colors["accent-2"] 37 + : props.disabledColor2 || theme.colors["border-light"], 38 + }} 39 + /> 40 + </div> 41 + </div> 42 + {props.children} 31 43 </button> 32 44 ); 33 45 };
+2 -1
components/Toolbar/BlockToolbar.tsx
··· 5 5 import { useUIState } from "src/useUIState"; 6 6 import { LockBlockButton } from "./LockBlockButton"; 7 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 8 + import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar"; 9 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 10 import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 11 ··· 44 44 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 45 45 <ImageFullBleedButton /> 46 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 47 + <ImageCoverButton /> 47 48 {focusedEntityType?.data.value !== "canvas" && ( 48 49 <Separator classname="h-6" /> 49 50 )}
+37
components/Toolbar/ImageToolbar.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { Props } from "components/Icons/Props"; 6 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { useSubscribe } from "src/replicache/useSubscribe"; 9 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 7 10 8 11 export const ImageFullBleedButton = (props: {}) => { 9 12 let { rep } = useReplicache(); ··· 76 79 ) : ( 77 80 <ImageRemoveAltSmall /> 78 81 )} 82 + </ToolbarButton> 83 + ); 84 + }; 85 + 86 + export const ImageCoverButton = () => { 87 + let { rep } = useReplicache(); 88 + let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 89 + let hasSrc = useEntity(focusedBlock, "block/image")?.data; 90 + let { data: pubData } = useLeafletPublicationData(); 91 + let coverImage = useSubscribe(rep, (tx) => 92 + tx.get<string | null>("publication_cover_image"), 93 + ); 94 + 95 + // Only show if in a publication and has an image 96 + if (!pubData?.publications || !hasSrc) return null; 97 + 98 + let isCoverImage = coverImage === focusedBlock; 99 + 100 + return ( 101 + <ToolbarButton 102 + active={isCoverImage} 103 + onClick={async (e) => { 104 + e.preventDefault(); 105 + if (rep && focusedBlock) { 106 + await rep.mutate.updatePublicationDraft({ 107 + cover_image: isCoverImage ? null : focusedBlock, 108 + }); 109 + } 110 + }} 111 + tooltipContent={ 112 + <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 113 + } 114 + > 115 + <ImageCoverImage /> 79 116 </ToolbarButton> 80 117 ); 81 118 };
+18
lexicons/api/lexicons.ts
··· 1447 1447 maxLength: 50, 1448 1448 }, 1449 1449 }, 1450 + coverImage: { 1451 + type: 'blob', 1452 + accept: ['image/png', 'image/jpeg', 'image/webp'], 1453 + maxSize: 1000000, 1454 + }, 1450 1455 pages: { 1451 1456 type: 'array', 1452 1457 items: { ··· 1801 1806 type: 'boolean', 1802 1807 default: true, 1803 1808 }, 1809 + showMentions: { 1810 + type: 'boolean', 1811 + default: true, 1812 + }, 1813 + showPrevNext: { 1814 + type: 'boolean', 1815 + default: true, 1816 + }, 1804 1817 }, 1805 1818 }, 1806 1819 theme: { ··· 1816 1829 backgroundImage: { 1817 1830 type: 'ref', 1818 1831 ref: 'lex:pub.leaflet.theme.backgroundImage', 1832 + }, 1833 + pageWidth: { 1834 + type: 'integer', 1835 + minimum: 0, 1836 + maximum: 1600, 1819 1837 }, 1820 1838 primary: { 1821 1839 type: 'union',
+1
lexicons/api/types/pub/leaflet/document.ts
··· 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 26 tags?: string[] 27 + coverImage?: BlobRef 27 28 pages: ( 28 29 | $Typed<PubLeafletPagesLinearDocument.Main> 29 30 | $Typed<PubLeafletPagesCanvas.Main>
+3
lexicons/api/types/pub/leaflet/publication.ts
··· 37 37 $type?: 'pub.leaflet.publication#preferences' 38 38 showInDiscover: boolean 39 39 showComments: boolean 40 + showMentions: boolean 41 + showPrevNext: boolean 40 42 } 41 43 42 44 const hashPreferences = 'preferences' ··· 56 58 | $Typed<PubLeafletThemeColor.Rgb> 57 59 | { $type: string } 58 60 backgroundImage?: PubLeafletThemeBackgroundImage.Main 61 + pageWidth?: number 59 62 primary?: 60 63 | $Typed<PubLeafletThemeColor.Rgba> 61 64 | $Typed<PubLeafletThemeColor.Rgb>
+2
lexicons/build.ts
··· 9 9 import * as path from "path"; 10 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 11 import { PubLeafletComment } from "./src/comment"; 12 + import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 12 13 13 14 const outdir = path.join("lexicons", "pub", "leaflet"); 14 15 ··· 21 22 PubLeafletDocument, 22 23 PubLeafletComment, 23 24 PubLeafletRichTextFacet, 25 + PubLeafletAuthFullPermissions, 24 26 PageLexicons.PubLeafletPagesLinearDocument, 25 27 PageLexicons.PubLeafletPagesCanvasDocument, 26 28 ...ThemeLexicons,
+44
lexicons/fix-extensions.ts
··· 1 + import * as fs from "fs"; 2 + import * as path from "path"; 3 + 4 + /** 5 + * Recursively processes all files in a directory and removes .js extensions from imports 6 + */ 7 + function fixExtensionsInDirectory(dir: string): void { 8 + const entries = fs.readdirSync(dir, { withFileTypes: true }); 9 + 10 + for (const entry of entries) { 11 + const fullPath = path.join(dir, entry.name); 12 + 13 + if (entry.isDirectory()) { 14 + fixExtensionsInDirectory(fullPath); 15 + } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { 16 + fixExtensionsInFile(fullPath); 17 + } 18 + } 19 + } 20 + 21 + /** 22 + * Removes .js extensions from import/export statements in a file 23 + */ 24 + function fixExtensionsInFile(filePath: string): void { 25 + const content = fs.readFileSync(filePath, "utf-8"); 26 + const fixedContent = content.replace(/\.js'/g, "'"); 27 + 28 + if (content !== fixedContent) { 29 + fs.writeFileSync(filePath, fixedContent, "utf-8"); 30 + console.log(`Fixed: ${filePath}`); 31 + } 32 + } 33 + 34 + // Get the directory to process from command line arguments 35 + const targetDir = process.argv[2] || "./lexicons/api"; 36 + 37 + if (!fs.existsSync(targetDir)) { 38 + console.error(`Directory not found: ${targetDir}`); 39 + process.exit(1); 40 + } 41 + 42 + console.log(`Fixing extensions in: ${targetDir}`); 43 + fixExtensionsInDirectory(targetDir); 44 + console.log("Done!");
+30
lexicons/pub/leaflet/authFullPermissions.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.authFullPermissions", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Full Leaflet Permissions", 8 + "detail": "Manage creating and updating leaflet documents and publications and all interactions on them.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "repo", 13 + "action": [ 14 + "create", 15 + "update", 16 + "delete" 17 + ], 18 + "collection": [ 19 + "pub.leaflet.document", 20 + "pub.leaflet.publication", 21 + "pub.leaflet.comment", 22 + "pub.leaflet.poll.definition", 23 + "pub.leaflet.poll.vote", 24 + "pub.leaflet.graph.subscription" 25 + ] 26 + } 27 + ] 28 + } 29 + } 30 + }
+9
lexicons/pub/leaflet/document.json
··· 53 53 "maxLength": 50 54 54 } 55 55 }, 56 + "coverImage": { 57 + "type": "blob", 58 + "accept": [ 59 + "image/png", 60 + "image/jpeg", 61 + "image/webp" 62 + ], 63 + "maxSize": 1000000 64 + }, 56 65 "pages": { 57 66 "type": "array", 58 67 "items": {
+13
lexicons/pub/leaflet/publication.json
··· 51 51 "showComments": { 52 52 "type": "boolean", 53 53 "default": true 54 + }, 55 + "showMentions": { 56 + "type": "boolean", 57 + "default": true 58 + }, 59 + "showPrevNext": { 60 + "type": "boolean", 61 + "default": true 54 62 } 55 63 } 56 64 }, ··· 67 75 "backgroundImage": { 68 76 "type": "ref", 69 77 "ref": "pub.leaflet.theme.backgroundImage" 78 + }, 79 + "pageWidth": { 80 + "type": "integer", 81 + "minimum": 0, 82 + "maximum": 1600 70 83 }, 71 84 "primary": { 72 85 "type": "union",
+36
lexicons/src/authFullPermissions.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletDocument } from "./document"; 3 + import { 4 + PubLeafletPublication, 5 + PubLeafletPublicationSubscription, 6 + } from "./publication"; 7 + import { PubLeafletComment } from "./comment"; 8 + import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; 9 + 10 + export const PubLeafletAuthFullPermissions: LexiconDoc = { 11 + lexicon: 1, 12 + id: "pub.leaflet.authFullPermissions", 13 + defs: { 14 + main: { 15 + type: "permission-set", 16 + title: "Full Leaflet Permissions", 17 + detail: 18 + "Manage creating and updating leaflet documents and publications and all interactions on them.", 19 + permissions: [ 20 + { 21 + type: "permission", 22 + resource: "repo", 23 + action: ["create", "update", "delete"], 24 + collection: [ 25 + PubLeafletDocument.id, 26 + PubLeafletPublication.id, 27 + PubLeafletComment.id, 28 + PubLeafletPollDefinition.id, 29 + PubLeafletPollVote.id, 30 + PubLeafletPublicationSubscription.id, 31 + ], 32 + }, 33 + ], 34 + }, 35 + }, 36 + };
+5
lexicons/src/document.ts
··· 24 24 author: { type: "string", format: "at-identifier" }, 25 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 26 tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 + coverImage: { 28 + type: "blob", 29 + accept: ["image/png", "image/jpeg", "image/webp"], 30 + maxSize: 1000000, 31 + }, 27 32 pages: { 28 33 type: "array", 29 34 items: {
+7
lexicons/src/publication.ts
··· 27 27 properties: { 28 28 showInDiscover: { type: "boolean", default: true }, 29 29 showComments: { type: "boolean", default: true }, 30 + showMentions: { type: "boolean", default: true }, 31 + showPrevNext: { type: "boolean", default: false }, 30 32 }, 31 33 }, 32 34 theme: { ··· 36 38 backgroundImage: { 37 39 type: "ref", 38 40 ref: PubLeafletThemeBackgroundImage.id, 41 + }, 42 + pageWidth: { 43 + type: "integer", 44 + minimum: 0, 45 + maximum: 1600, 39 46 }, 40 47 primary: ColorUnion, 41 48 pageBackground: ColorUnion,
+1 -1
package.json
··· 7 7 "dev": "TZ=UTC next dev --turbo", 8 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", 10 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 11 11 "wrangler-dev": "wrangler dev", 12 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
+27
src/atproto-oauth.ts
··· 3 3 NodeSavedSession, 4 4 NodeSavedState, 5 5 RuntimeLock, 6 + OAuthSession, 6 7 } from "@atproto/oauth-client-node"; 7 8 import { JoseKey } from "@atproto/jwk-jose"; 8 9 import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; ··· 10 11 11 12 import Client from "ioredis"; 12 13 import Redlock from "redlock"; 14 + import { Result, Ok, Err } from "./result"; 13 15 export async function createOauthClient() { 14 16 let keyset = 15 17 process.env.NODE_ENV === "production" ··· 90 92 .eq("key", key); 91 93 }, 92 94 }; 95 + 96 + export type OAuthSessionError = { 97 + type: "oauth_session_expired"; 98 + message: string; 99 + did: string; 100 + }; 101 + 102 + export async function restoreOAuthSession( 103 + did: string 104 + ): Promise<Result<OAuthSession, OAuthSessionError>> { 105 + try { 106 + const oauthClient = await createOauthClient(); 107 + const session = await oauthClient.restore(did); 108 + return Ok(session); 109 + } catch (error) { 110 + return Err({ 111 + type: "oauth_session_expired", 112 + message: 113 + error instanceof Error 114 + ? error.message 115 + : "OAuth session expired or invalid", 116 + did, 117 + }); 118 + } 119 + }
+4
src/replicache/attributes.ts
··· 191 191 type: "boolean", 192 192 cardinality: "one", 193 193 }, 194 + "theme/page-width": { 195 + type: "number", 196 + cardinality: "one", 197 + }, 194 198 "theme/page-background": { 195 199 type: "color", 196 200 cardinality: "one",
+30 -1
src/replicache/mutations.ts
··· 319 319 await supabase.storage 320 320 .from("minilink-user-assets") 321 321 .remove([paths[paths.length - 1]]); 322 + 323 + // Clear cover image if this block is the cover image 324 + // First try leaflets_in_publications 325 + const { data: pubResult } = await supabase 326 + .from("leaflets_in_publications") 327 + .update({ cover_image: null }) 328 + .eq("leaflet", ctx.permission_token_id) 329 + .eq("cover_image", block.blockEntity) 330 + .select("leaflet"); 331 + 332 + // If no rows updated, try leaflets_to_documents 333 + if (!pubResult || pubResult.length === 0) { 334 + await supabase 335 + .from("leaflets_to_documents") 336 + .update({ cover_image: null }) 337 + .eq("leaflet", ctx.permission_token_id) 338 + .eq("cover_image", block.blockEntity); 339 + } 322 340 } 323 341 }); 324 - await ctx.runOnClient(async () => { 342 + await ctx.runOnClient(async ({ tx }) => { 325 343 let cache = await caches.open("minilink-user-assets"); 326 344 if (image) { 327 345 await cache.delete(image.data.src + "?local"); 346 + 347 + // Clear cover image in client state if this block was the cover image 348 + let currentCoverImage = await tx.get("publication_cover_image"); 349 + if (currentCoverImage === block.blockEntity) { 350 + await tx.set("publication_cover_image", null); 351 + } 328 352 } 329 353 }); 330 354 await ctx.deleteEntity(block.blockEntity); ··· 612 636 title?: string; 613 637 description?: string; 614 638 tags?: string[]; 639 + cover_image?: string | null; 615 640 }> = async (args, ctx) => { 616 641 await ctx.runOnServer(async (serverCtx) => { 617 642 console.log("updating"); ··· 619 644 description?: string; 620 645 title?: string; 621 646 tags?: string[]; 647 + cover_image?: string | null; 622 648 } = {}; 623 649 if (args.description !== undefined) updates.description = args.description; 624 650 if (args.title !== undefined) updates.title = args.title; 625 651 if (args.tags !== undefined) updates.tags = args.tags; 652 + if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 626 653 627 654 if (Object.keys(updates).length > 0) { 628 655 // First try to update leaflets_in_publications (for publications) ··· 648 675 if (args.description !== undefined) 649 676 await tx.set("publication_description", args.description); 650 677 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 678 + if (args.cover_image !== undefined) 679 + await tx.set("publication_cover_image", args.cover_image); 651 680 }); 652 681 }; 653 682
+8
src/result.ts
··· 1 + // Result type - a discriminated union for handling success/error cases 2 + export type Result<T, E> = 3 + | { ok: true; value: T } 4 + | { ok: false; error: E }; 5 + 6 + // Constructors 7 + export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value }); 8 + export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
+28
src/useThreadState.ts
··· 1 + import { create } from "zustand"; 2 + import { combine } from "zustand/middleware"; 3 + 4 + export const useThreadState = create( 5 + combine( 6 + { 7 + // Set of collapsed thread URIs 8 + collapsedThreads: new Set<string>(), 9 + }, 10 + (set) => ({ 11 + toggleCollapsed: (uri: string) => { 12 + set((state) => { 13 + const newCollapsed = new Set(state.collapsedThreads); 14 + if (newCollapsed.has(uri)) { 15 + newCollapsed.delete(uri); 16 + } else { 17 + newCollapsed.add(uri); 18 + } 19 + return { collapsedThreads: newCollapsed }; 20 + }); 21 + }, 22 + isCollapsed: (uri: string) => { 23 + // This is a selector helper, but we'll use the state directly 24 + return false; 25 + }, 26 + }), 27 + ), 28 + );
+11 -1
supabase/database.types.ts
··· 556 556 atp_did?: string | null 557 557 created_at?: string 558 558 email?: string | null 559 - home_page: string 559 + home_page?: string 560 560 id?: string 561 561 interface_state?: Json | null 562 562 } ··· 581 581 leaflets_in_publications: { 582 582 Row: { 583 583 archived: boolean | null 584 + cover_image: string | null 584 585 description: string 585 586 doc: string | null 586 587 leaflet: string ··· 589 590 } 590 591 Insert: { 591 592 archived?: boolean | null 593 + cover_image?: string | null 592 594 description?: string 593 595 doc?: string | null 594 596 leaflet: string ··· 597 599 } 598 600 Update: { 599 601 archived?: boolean | null 602 + cover_image?: string | null 600 603 description?: string 601 604 doc?: string | null 602 605 leaflet?: string ··· 629 632 } 630 633 leaflets_to_documents: { 631 634 Row: { 635 + cover_image: string | null 632 636 created_at: string 633 637 description: string 634 638 document: string ··· 636 640 title: string 637 641 } 638 642 Insert: { 643 + cover_image?: string | null 639 644 created_at?: string 640 645 description?: string 641 646 document: string ··· 643 648 title?: string 644 649 } 645 650 Update: { 651 + cover_image?: string | null 646 652 created_at?: string 647 653 description?: string 648 654 document?: string ··· 1112 1118 [_ in never]: never 1113 1119 } 1114 1120 Functions: { 1121 + create_identity_homepage: { 1122 + Args: Record<PropertyKey, never> 1123 + Returns: string 1124 + } 1115 1125 get_facts: { 1116 1126 Args: { 1117 1127 root: string
+2
supabase/migrations/20251223000000_add_cover_image_column.sql
··· 1 + alter table "public"."leaflets_in_publications" add column "cover_image" text; 2 + alter table "public"."leaflets_to_documents" add column "cover_image" text;
+34
supabase/migrations/20260106183631_add_homepage_default_to_identities.sql
··· 1 + -- Function to create homepage infrastructure for new identities 2 + -- Replicates the logic from createIdentity TypeScript function 3 + -- Returns the permission token ID to be used as home_page 4 + CREATE OR REPLACE FUNCTION create_identity_homepage() 5 + RETURNS uuid AS $$ 6 + DECLARE 7 + new_entity_set_id uuid; 8 + new_entity_id uuid; 9 + new_permission_token_id uuid; 10 + BEGIN 11 + -- Create a new entity set 12 + INSERT INTO entity_sets DEFAULT VALUES 13 + RETURNING id INTO new_entity_set_id; 14 + 15 + -- Create a root entity and add it to that entity set 16 + new_entity_id := gen_random_uuid(); 17 + INSERT INTO entities (id, set) 18 + VALUES (new_entity_id, new_entity_set_id); 19 + 20 + -- Create a new permission token 21 + INSERT INTO permission_tokens (root_entity) 22 + VALUES (new_entity_id) 23 + RETURNING id INTO new_permission_token_id; 24 + 25 + -- Give the token full permissions on that entity set 26 + INSERT INTO permission_token_rights (token, entity_set, read, write, create_token, change_entity_set) 27 + VALUES (new_permission_token_id, new_entity_set_id, true, true, true, true); 28 + 29 + RETURN new_permission_token_id; 30 + END; 31 + $$ LANGUAGE plpgsql; 32 + 33 + -- Set the function as the default value for home_page column 34 + ALTER TABLE identities ALTER COLUMN home_page SET DEFAULT create_identity_homepage();
+161
supabase/migrations/20260106190000_add_site_standard_tables.sql
··· 1 + -- site_standard_publications table (modeled off publications) 2 + create table "public"."site_standard_publications" ( 3 + "uri" text not null, 4 + "data" jsonb not null, 5 + "indexed_at" timestamp with time zone not null default now(), 6 + "identity_did" text not null 7 + ); 8 + alter table "public"."site_standard_publications" enable row level security; 9 + 10 + -- site_standard_documents table (modeled off documents) 11 + create table "public"."site_standard_documents" ( 12 + "uri" text not null, 13 + "data" jsonb not null, 14 + "indexed_at" timestamp with time zone not null default now(), 15 + "identity_did" text not null 16 + ); 17 + alter table "public"."site_standard_documents" enable row level security; 18 + 19 + -- site_standard_documents_in_publications relation table (modeled off documents_in_publications) 20 + create table "public"."site_standard_documents_in_publications" ( 21 + "publication" text not null, 22 + "document" text not null, 23 + "indexed_at" timestamp with time zone not null default now() 24 + ); 25 + alter table "public"."site_standard_documents_in_publications" enable row level security; 26 + 27 + -- Primary key indexes 28 + CREATE UNIQUE INDEX site_standard_publications_pkey ON public.site_standard_publications USING btree (uri); 29 + CREATE UNIQUE INDEX site_standard_documents_pkey ON public.site_standard_documents USING btree (uri); 30 + CREATE UNIQUE INDEX site_standard_documents_in_publications_pkey ON public.site_standard_documents_in_publications USING btree (publication, document); 31 + 32 + -- Add primary key constraints 33 + alter table "public"."site_standard_publications" add constraint "site_standard_publications_pkey" PRIMARY KEY using index "site_standard_publications_pkey"; 34 + alter table "public"."site_standard_documents" add constraint "site_standard_documents_pkey" PRIMARY KEY using index "site_standard_documents_pkey"; 35 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_pkey" PRIMARY KEY using index "site_standard_documents_in_publications_pkey"; 36 + 37 + -- Foreign key constraints for identity relations 38 + alter table "public"."site_standard_publications" add constraint "site_standard_publications_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 39 + alter table "public"."site_standard_publications" validate constraint "site_standard_publications_identity_did_fkey"; 40 + alter table "public"."site_standard_documents" add constraint "site_standard_documents_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 41 + alter table "public"."site_standard_documents" validate constraint "site_standard_documents_identity_did_fkey"; 42 + 43 + -- Foreign key constraints for relation table 44 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_document_fkey" FOREIGN KEY (document) REFERENCES site_standard_documents(uri) ON DELETE CASCADE not valid; 45 + alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_document_fkey"; 46 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid; 47 + alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_publication_fkey"; 48 + 49 + -- Grants for site_standard_publications 50 + grant delete on table "public"."site_standard_publications" to "anon"; 51 + grant insert on table "public"."site_standard_publications" to "anon"; 52 + grant references on table "public"."site_standard_publications" to "anon"; 53 + grant select on table "public"."site_standard_publications" to "anon"; 54 + grant trigger on table "public"."site_standard_publications" to "anon"; 55 + grant truncate on table "public"."site_standard_publications" to "anon"; 56 + grant update on table "public"."site_standard_publications" to "anon"; 57 + grant delete on table "public"."site_standard_publications" to "authenticated"; 58 + grant insert on table "public"."site_standard_publications" to "authenticated"; 59 + grant references on table "public"."site_standard_publications" to "authenticated"; 60 + grant select on table "public"."site_standard_publications" to "authenticated"; 61 + grant trigger on table "public"."site_standard_publications" to "authenticated"; 62 + grant truncate on table "public"."site_standard_publications" to "authenticated"; 63 + grant update on table "public"."site_standard_publications" to "authenticated"; 64 + grant delete on table "public"."site_standard_publications" to "service_role"; 65 + grant insert on table "public"."site_standard_publications" to "service_role"; 66 + grant references on table "public"."site_standard_publications" to "service_role"; 67 + grant select on table "public"."site_standard_publications" to "service_role"; 68 + grant trigger on table "public"."site_standard_publications" to "service_role"; 69 + grant truncate on table "public"."site_standard_publications" to "service_role"; 70 + grant update on table "public"."site_standard_publications" to "service_role"; 71 + 72 + -- Grants for site_standard_documents 73 + grant delete on table "public"."site_standard_documents" to "anon"; 74 + grant insert on table "public"."site_standard_documents" to "anon"; 75 + grant references on table "public"."site_standard_documents" to "anon"; 76 + grant select on table "public"."site_standard_documents" to "anon"; 77 + grant trigger on table "public"."site_standard_documents" to "anon"; 78 + grant truncate on table "public"."site_standard_documents" to "anon"; 79 + grant update on table "public"."site_standard_documents" to "anon"; 80 + grant delete on table "public"."site_standard_documents" to "authenticated"; 81 + grant insert on table "public"."site_standard_documents" to "authenticated"; 82 + grant references on table "public"."site_standard_documents" to "authenticated"; 83 + grant select on table "public"."site_standard_documents" to "authenticated"; 84 + grant trigger on table "public"."site_standard_documents" to "authenticated"; 85 + grant truncate on table "public"."site_standard_documents" to "authenticated"; 86 + grant update on table "public"."site_standard_documents" to "authenticated"; 87 + grant delete on table "public"."site_standard_documents" to "service_role"; 88 + grant insert on table "public"."site_standard_documents" to "service_role"; 89 + grant references on table "public"."site_standard_documents" to "service_role"; 90 + grant select on table "public"."site_standard_documents" to "service_role"; 91 + grant trigger on table "public"."site_standard_documents" to "service_role"; 92 + grant truncate on table "public"."site_standard_documents" to "service_role"; 93 + grant update on table "public"."site_standard_documents" to "service_role"; 94 + 95 + -- Grants for site_standard_documents_in_publications 96 + grant delete on table "public"."site_standard_documents_in_publications" to "anon"; 97 + grant insert on table "public"."site_standard_documents_in_publications" to "anon"; 98 + grant references on table "public"."site_standard_documents_in_publications" to "anon"; 99 + grant select on table "public"."site_standard_documents_in_publications" to "anon"; 100 + grant trigger on table "public"."site_standard_documents_in_publications" to "anon"; 101 + grant truncate on table "public"."site_standard_documents_in_publications" to "anon"; 102 + grant update on table "public"."site_standard_documents_in_publications" to "anon"; 103 + grant delete on table "public"."site_standard_documents_in_publications" to "authenticated"; 104 + grant insert on table "public"."site_standard_documents_in_publications" to "authenticated"; 105 + grant references on table "public"."site_standard_documents_in_publications" to "authenticated"; 106 + grant select on table "public"."site_standard_documents_in_publications" to "authenticated"; 107 + grant trigger on table "public"."site_standard_documents_in_publications" to "authenticated"; 108 + grant truncate on table "public"."site_standard_documents_in_publications" to "authenticated"; 109 + grant update on table "public"."site_standard_documents_in_publications" to "authenticated"; 110 + grant delete on table "public"."site_standard_documents_in_publications" to "service_role"; 111 + grant insert on table "public"."site_standard_documents_in_publications" to "service_role"; 112 + grant references on table "public"."site_standard_documents_in_publications" to "service_role"; 113 + grant select on table "public"."site_standard_documents_in_publications" to "service_role"; 114 + grant trigger on table "public"."site_standard_documents_in_publications" to "service_role"; 115 + grant truncate on table "public"."site_standard_documents_in_publications" to "service_role"; 116 + grant update on table "public"."site_standard_documents_in_publications" to "service_role"; 117 + 118 + -- site_standard_subscriptions table (modeled off publication_subscriptions) 119 + create table "public"."site_standard_subscriptions" ( 120 + "publication" text not null, 121 + "identity" text not null, 122 + "created_at" timestamp with time zone not null default now(), 123 + "record" jsonb not null, 124 + "uri" text not null 125 + ); 126 + alter table "public"."site_standard_subscriptions" enable row level security; 127 + 128 + -- Primary key and unique indexes 129 + CREATE UNIQUE INDEX site_standard_subscriptions_pkey ON public.site_standard_subscriptions USING btree (publication, identity); 130 + CREATE UNIQUE INDEX site_standard_subscriptions_uri_key ON public.site_standard_subscriptions USING btree (uri); 131 + 132 + -- Add constraints 133 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_pkey" PRIMARY KEY using index "site_standard_subscriptions_pkey"; 134 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_uri_key" UNIQUE using index "site_standard_subscriptions_uri_key"; 135 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid; 136 + alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_publication_fkey"; 137 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 138 + alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_identity_fkey"; 139 + 140 + -- Grants for site_standard_subscriptions 141 + grant delete on table "public"."site_standard_subscriptions" to "anon"; 142 + grant insert on table "public"."site_standard_subscriptions" to "anon"; 143 + grant references on table "public"."site_standard_subscriptions" to "anon"; 144 + grant select on table "public"."site_standard_subscriptions" to "anon"; 145 + grant trigger on table "public"."site_standard_subscriptions" to "anon"; 146 + grant truncate on table "public"."site_standard_subscriptions" to "anon"; 147 + grant update on table "public"."site_standard_subscriptions" to "anon"; 148 + grant delete on table "public"."site_standard_subscriptions" to "authenticated"; 149 + grant insert on table "public"."site_standard_subscriptions" to "authenticated"; 150 + grant references on table "public"."site_standard_subscriptions" to "authenticated"; 151 + grant select on table "public"."site_standard_subscriptions" to "authenticated"; 152 + grant trigger on table "public"."site_standard_subscriptions" to "authenticated"; 153 + grant truncate on table "public"."site_standard_subscriptions" to "authenticated"; 154 + grant update on table "public"."site_standard_subscriptions" to "authenticated"; 155 + grant delete on table "public"."site_standard_subscriptions" to "service_role"; 156 + grant insert on table "public"."site_standard_subscriptions" to "service_role"; 157 + grant references on table "public"."site_standard_subscriptions" to "service_role"; 158 + grant select on table "public"."site_standard_subscriptions" to "service_role"; 159 + grant trigger on table "public"."site_standard_subscriptions" to "service_role"; 160 + grant truncate on table "public"."site_standard_subscriptions" to "service_role"; 161 + grant update on table "public"."site_standard_subscriptions" to "service_role";