a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+6457 -2914
actions
app
(home-pages)
[leaflet_id]
api
atproto_images
bsky
quotes
thread
inngest
functions
oauth
[route]
rpc
lish
p
[didOrHandle]
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 import { email_auth_tokens, identities } from "drizzle/schema"; 7 import { and, eq } from "drizzle-orm"; 8 import { cookies } from "next/headers"; 9 - import { createIdentity } from "./createIdentity"; 10 import { setAuthToken } from "src/auth"; 11 import { pool } from "supabase/pool"; 12 13 async function sendAuthCode(email: string, code: string) { 14 if (process.env.NODE_ENV === "development") { ··· 114 .from(identities) 115 .where(eq(identities.email, token.email)); 116 if (!identity) { 117 - let newIdentity = await createIdentity(db, { email: token.email }); 118 - identityID = newIdentity.id; 119 } else { 120 identityID = identity.id; 121 }
··· 6 import { email_auth_tokens, identities } from "drizzle/schema"; 7 import { and, eq } from "drizzle-orm"; 8 import { cookies } from "next/headers"; 9 import { setAuthToken } from "src/auth"; 10 import { pool } from "supabase/pool"; 11 + import { supabaseServerClient } from "supabase/serverClient"; 12 13 async function sendAuthCode(email: string, code: string) { 14 if (process.env.NODE_ENV === "development") { ··· 114 .from(identities) 115 .where(eq(identities.email, token.email)); 116 if (!identity) { 117 + const { data: newIdentity } = await supabaseServerClient 118 + .from("identities") 119 + .insert({ email: token.email }) 120 + .select() 121 + .single(); 122 + identityID = newIdentity!.id; 123 } else { 124 identityID = identity.id; 125 }
+7 -8
actions/login.ts
··· 4 import { 5 email_auth_tokens, 6 identities, 7 - entity_sets, 8 - entities, 9 - permission_tokens, 10 - permission_token_rights, 11 permission_token_on_homepage, 12 poll_votes_on_entity, 13 } from "drizzle/schema"; 14 import { and, eq, isNull } from "drizzle-orm"; 15 import { cookies } from "next/headers"; 16 import { redirect } from "next/navigation"; 17 - import { v7 } from "uuid"; 18 - import { createIdentity } from "./createIdentity"; 19 import { pool } from "supabase/pool"; 20 21 export async function loginWithEmailToken( 22 localLeaflets: { token: { id: string }; added_at: string }[], ··· 77 identity = existingIdentityFromCookie; 78 } 79 } else { 80 - // Create a new identity 81 - identity = await createIdentity(tx, { email: token.email }); 82 } 83 } 84
··· 4 import { 5 email_auth_tokens, 6 identities, 7 permission_token_on_homepage, 8 poll_votes_on_entity, 9 } from "drizzle/schema"; 10 import { and, eq, isNull } from "drizzle-orm"; 11 import { cookies } from "next/headers"; 12 import { redirect } from "next/navigation"; 13 import { pool } from "supabase/pool"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 15 16 export async function loginWithEmailToken( 17 localLeaflets: { token: { id: string }; added_at: string }[], ··· 72 identity = existingIdentityFromCookie; 73 } 74 } else { 75 + const { data: newIdentity } = await supabaseServerClient 76 + .from("identities") 77 + .insert({ email: token.email }) 78 + .select() 79 + .single(); 80 + identity = newIdentity!; 81 } 82 } 83
+45 -6
actions/publishToPublication.ts
··· 2 3 import * as Y from "yjs"; 4 import * as base64 from "base64-js"; 5 - import { createOauthClient } from "src/atproto-oauth"; 6 import { getIdentityData } from "actions/getIdentityData"; 7 import { 8 AtpBaseClient, ··· 50 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 import { v7 } from "uuid"; 52 53 export async function publishToPublication({ 54 root_entity, 55 publication_uri, ··· 57 title, 58 description, 59 tags, 60 entitiesToDelete, 61 }: { 62 root_entity: string; ··· 65 title?: string; 66 description?: string; 67 tags?: string[]; 68 entitiesToDelete?: string[]; 69 - }) { 70 - const oauthClient = await createOauthClient(); 71 let identity = await getIdentityData(); 72 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 73 74 - let credentialSession = await oauthClient.restore(identity.atp_did); 75 let agent = new AtpBaseClient( 76 credentialSession.fetchHandler.bind(credentialSession), 77 ); ··· 135 theme = await extractThemeFromFacts(facts, root_entity, agent); 136 } 137 138 let record: PubLeafletDocument.Record = { 139 publishedAt: new Date().toISOString(), 140 ...existingRecord, ··· 145 title: title || "Untitled", 146 description: description || "", 147 ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 pages: pages.map((p) => { 149 if (p.type === "canvas") { 150 return { ··· 217 await createMentionNotifications(result.uri, record, credentialSession.did!); 218 } 219 220 - return { rkey, record: JSON.parse(JSON.stringify(record)) }; 221 } 222 223 async function processBlocksToPages(
··· 2 3 import * as Y from "yjs"; 4 import * as base64 from "base64-js"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { 11 AtpBaseClient, ··· 53 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 54 import { v7 } from "uuid"; 55 56 + type PublishResult = 57 + | { success: true; rkey: string; record: PubLeafletDocument.Record } 58 + | { success: false; error: OAuthSessionError }; 59 + 60 export async function publishToPublication({ 61 root_entity, 62 publication_uri, ··· 64 title, 65 description, 66 tags, 67 + cover_image, 68 entitiesToDelete, 69 }: { 70 root_entity: string; ··· 73 title?: string; 74 description?: string; 75 tags?: string[]; 76 + cover_image?: string | null; 77 entitiesToDelete?: string[]; 78 + }): Promise<PublishResult> { 79 let identity = await getIdentityData(); 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 + } 90 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; 96 let agent = new AtpBaseClient( 97 credentialSession.fetchHandler.bind(credentialSession), 98 ); ··· 156 theme = await extractThemeFromFacts(facts, root_entity, agent); 157 } 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 + 176 let record: PubLeafletDocument.Record = { 177 publishedAt: new Date().toISOString(), 178 ...existingRecord, ··· 183 title: title || "Untitled", 184 description: description || "", 185 ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 186 + ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 187 pages: pages.map((p) => { 188 if (p.type === "canvas") { 189 return { ··· 256 await createMentionNotifications(result.uri, record, credentialSession.did!); 257 } 258 259 + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 260 } 261 262 async function processBlocksToPages(
+1 -3
app/(home-pages)/discover/page.tsx
··· 17 return ( 18 <DashboardLayout 19 id="discover" 20 - cardBorderHidden={false} 21 currentPage="discover" 22 defaultTab="default" 23 actions={null} ··· 32 } 33 34 const DiscoverContent = async (props: { order: string }) => { 35 - const orderValue = 36 - props.order === "popular" ? "popular" : "recentlyUpdated"; 37 let { publications, nextCursor } = await getPublications(orderValue); 38 39 return (
··· 17 return ( 18 <DashboardLayout 19 id="discover" 20 currentPage="discover" 21 defaultTab="default" 22 actions={null} ··· 31 } 32 33 const DiscoverContent = async (props: { order: string }) => { 34 + const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 35 let { publications, nextCursor } = await getPublications(orderValue); 36 37 return (
+1 -1
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 5 import { AddTiny } from "components/Icons/AddTiny"; 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 - import { Menu, MenuItem } from "components/Layout"; 9 import { useIsMobile } from "src/hooks/isMobile"; 10 11 export const CreateNewLeafletButton = (props: {}) => {
··· 5 import { AddTiny } from "components/Icons/AddTiny"; 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 + import { Menu, MenuItem } from "components/Menu"; 9 import { useIsMobile } from "src/hooks/isMobile"; 10 11 export const CreateNewLeafletButton = (props: {}) => {
-8
app/(home-pages)/home/HomeLayout.tsx
··· 20 useDashboardState, 21 } from "components/PageLayouts/DashboardLayout"; 22 import { Actions } from "./Actions/Actions"; 23 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 25 import { useState } from "react"; 26 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 56 props.entityID, 57 "theme/background-image", 58 ); 59 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 60 61 let [searchValue, setSearchValue] = useState(""); 62 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 81 return ( 82 <DashboardLayout 83 id="home" 84 - cardBorderHidden={cardBorderHidden} 85 currentPage="home" 86 defaultTab="home" 87 actions={<Actions />} ··· 101 <HomeLeafletList 102 titles={props.titles} 103 initialFacts={props.initialFacts} 104 - cardBorderHidden={cardBorderHidden} 105 searchValue={debouncedSearchValue} 106 /> 107 ), ··· 117 [root_entity: string]: Fact<Attribute>[]; 118 }; 119 searchValue: string; 120 - cardBorderHidden: boolean; 121 }) { 122 let { identity } = useIdentityData(); 123 let { data: initialFacts } = useSWR( ··· 171 searchValue={props.searchValue} 172 leaflets={leaflets} 173 titles={initialFacts?.titles || {}} 174 - cardBorderHidden={props.cardBorderHidden} 175 initialFacts={initialFacts?.facts || {}} 176 showPreview 177 /> ··· 192 [root_entity: string]: Fact<Attribute>[]; 193 }; 194 searchValue: string; 195 - cardBorderHidden: boolean; 196 showPreview?: boolean; 197 }) { 198 let { identity } = useIdentityData(); ··· 238 loggedIn={!!identity} 239 display={display} 240 added_at={added_at} 241 - cardBorderHidden={props.cardBorderHidden} 242 index={index} 243 showPreview={props.showPreview} 244 isHidden={
··· 20 useDashboardState, 21 } from "components/PageLayouts/DashboardLayout"; 22 import { Actions } from "./Actions/Actions"; 23 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 24 import { useState } from "react"; 25 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 55 props.entityID, 56 "theme/background-image", 57 ); 58 59 let [searchValue, setSearchValue] = useState(""); 60 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 79 return ( 80 <DashboardLayout 81 id="home" 82 currentPage="home" 83 defaultTab="home" 84 actions={<Actions />} ··· 98 <HomeLeafletList 99 titles={props.titles} 100 initialFacts={props.initialFacts} 101 searchValue={debouncedSearchValue} 102 /> 103 ), ··· 113 [root_entity: string]: Fact<Attribute>[]; 114 }; 115 searchValue: string; 116 }) { 117 let { identity } = useIdentityData(); 118 let { data: initialFacts } = useSWR( ··· 166 searchValue={props.searchValue} 167 leaflets={leaflets} 168 titles={initialFacts?.titles || {}} 169 initialFacts={initialFacts?.facts || {}} 170 showPreview 171 /> ··· 186 [root_entity: string]: Fact<Attribute>[]; 187 }; 188 searchValue: string; 189 showPreview?: boolean; 190 }) { 191 let { identity } = useIdentityData(); ··· 231 loggedIn={!!identity} 232 display={display} 233 added_at={added_at} 234 index={index} 235 showPreview={props.showPreview} 236 isHidden={
+6 -5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 4 import { useState, useRef, useEffect } from "react"; 5 import { SpeedyLink } from "components/SpeedyLink"; 6 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 8 export const LeafletListItem = (props: { 9 archived?: boolean | null; 10 loggedIn: boolean; 11 display: "list" | "grid"; 12 - cardBorderHidden: boolean; 13 added_at: string; 14 title?: string; 15 index: number; 16 isHidden: boolean; 17 showPreview?: boolean; 18 }) => { 19 const pubStatus = useLeafletPublicationStatus(); 20 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 21 let previewRef = useRef<HTMLDivElement | null>(null); ··· 47 ref={previewRef} 48 className={`relative flex gap-3 w-full 49 ${props.isHidden ? "hidden" : "flex"} 50 - ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 style={{ 52 - backgroundColor: props.cardBorderHidden 53 ? "transparent" 54 : "rgba(var(--bg-page), var(--bg-page-alpha))", 55 }} ··· 67 loggedIn={props.loggedIn} 68 /> 69 </div> 70 - {props.cardBorderHidden && ( 71 <hr 72 className="last:hidden border-border-light" 73 style={{ ··· 87 ${props.isHidden ? "hidden" : "flex"} 88 `} 89 style={{ 90 - backgroundColor: props.cardBorderHidden 91 ? "transparent" 92 : "rgba(var(--bg-page), var(--bg-page-alpha))", 93 }}
··· 4 import { useState, useRef, useEffect } from "react"; 5 import { SpeedyLink } from "components/SpeedyLink"; 6 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 9 export const LeafletListItem = (props: { 10 archived?: boolean | null; 11 loggedIn: boolean; 12 display: "list" | "grid"; 13 added_at: string; 14 title?: string; 15 index: number; 16 isHidden: boolean; 17 showPreview?: boolean; 18 }) => { 19 + const cardBorderHidden = useCardBorderHidden(); 20 const pubStatus = useLeafletPublicationStatus(); 21 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 22 let previewRef = useRef<HTMLDivElement | null>(null); ··· 48 ref={previewRef} 49 className={`relative flex gap-3 w-full 50 ${props.isHidden ? "hidden" : "flex"} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 52 style={{ 53 + backgroundColor: cardBorderHidden 54 ? "transparent" 55 : "rgba(var(--bg-page), var(--bg-page-alpha))", 56 }} ··· 68 loggedIn={props.loggedIn} 69 /> 70 </div> 71 + {cardBorderHidden && ( 72 <hr 73 className="last:hidden border-border-light" 74 style={{ ··· 88 ${props.isHidden ? "hidden" : "flex"} 89 `} 90 style={{ 91 + backgroundColor: cardBorderHidden 92 ? "transparent" 93 : "rgba(var(--bg-page), var(--bg-page-alpha))", 94 }}
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 1 "use client"; 2 3 - import { Menu, MenuItem } from "components/Layout"; 4 import { useState } from "react"; 5 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 import { useToaster } from "components/Toast";
··· 1 "use client"; 2 3 + import { Menu, MenuItem } from "components/Menu"; 4 import { useState } from "react"; 5 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 import { useToaster } from "components/Toast";
+18 -7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
··· 18 const firstPage = useEntity(root, "root/page")[0]; 19 const page = firstPage?.data.value || root; 20 21 - const cardBorderHidden = useCardBorderHidden(root); 22 const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 23 const rootBackgroundRepeat = useEntity( 24 root, ··· 49 50 const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`; 51 52 - return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass }; 53 } 54 55 export const LeafletListPreview = (props: { isVisible: boolean }) => { 56 - const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } = 57 - useLeafletPreviewData(); 58 59 return ( 60 <Tooltip 61 - open={true} 62 - delayDuration={0} 63 side="right" 64 trigger={ 65 - <div className="w-12 h-full py-1"> 66 <div className="rounded-md h-full overflow-hidden"> 67 <ThemeProvider local entityID={root} className=""> 68 <ThemeBackgroundProvider entityID={root}>
··· 18 const firstPage = useEntity(root, "root/page")[0]; 19 const page = firstPage?.data.value || root; 20 21 + const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data 22 + .value; 23 const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 24 const rootBackgroundRepeat = useEntity( 25 root, ··· 50 51 const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`; 52 53 + return { 54 + root, 55 + page, 56 + cardBorderHidden, 57 + contentWrapperStyle, 58 + contentWrapperClass, 59 + }; 60 } 61 62 export const LeafletListPreview = (props: { isVisible: boolean }) => { 63 + const { 64 + root, 65 + page, 66 + cardBorderHidden, 67 + contentWrapperStyle, 68 + contentWrapperClass, 69 + } = useLeafletPreviewData(); 70 71 return ( 72 <Tooltip 73 side="right" 74 + asChild 75 trigger={ 76 + <div className="w-12 h-full py-1 z-10"> 77 <div className="rounded-md h-full overflow-hidden"> 78 <ThemeProvider local entityID={root} className=""> 79 <ThemeBackgroundProvider entityID={root}>
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 "use client"; 2 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 import { useState } from "react"; 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 import { Fact, PermissionToken } from "src/replicache"; ··· 30 [searchValue], 31 ); 32 33 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 34 return ( 35 <DashboardLayout 36 id="looseleafs" 37 - cardBorderHidden={cardBorderHidden} 38 currentPage="looseleafs" 39 defaultTab="home" 40 actions={<Actions />} ··· 45 <LooseleafList 46 titles={props.titles} 47 initialFacts={props.initialFacts} 48 - cardBorderHidden={cardBorderHidden} 49 searchValue={debouncedSearchValue} 50 /> 51 ), ··· 61 [root_entity: string]: Fact<Attribute>[]; 62 }; 63 searchValue: string; 64 - cardBorderHidden: boolean; 65 }) => { 66 let { identity } = useIdentityData(); 67 let { data: initialFacts } = useSWR( ··· 108 searchValue={props.searchValue} 109 leaflets={leaflets} 110 titles={initialFacts?.titles || {}} 111 - cardBorderHidden={props.cardBorderHidden} 112 initialFacts={initialFacts?.facts || {}} 113 showPreview 114 />
··· 1 "use client"; 2 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 import { useState } from "react"; 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 import { Fact, PermissionToken } from "src/replicache"; ··· 29 [searchValue], 30 ); 31 32 return ( 33 <DashboardLayout 34 id="looseleafs" 35 currentPage="looseleafs" 36 defaultTab="home" 37 actions={<Actions />} ··· 42 <LooseleafList 43 titles={props.titles} 44 initialFacts={props.initialFacts} 45 searchValue={debouncedSearchValue} 46 /> 47 ), ··· 57 [root_entity: string]: Fact<Attribute>[]; 58 }; 59 searchValue: string; 60 }) => { 61 let { identity } = useIdentityData(); 62 let { data: initialFacts } = useSWR( ··· 103 searchValue={props.searchValue} 104 leaflets={leaflets} 105 titles={initialFacts?.titles || {}} 106 initialFacts={initialFacts?.facts || {}} 107 showPreview 108 />
-1
app/(home-pages)/notifications/page.tsx
··· 10 return ( 11 <DashboardLayout 12 id="discover" 13 - cardBorderHidden={true} 14 currentPage="notifications" 15 defaultTab="default" 16 actions={null}
··· 10 return ( 11 <DashboardLayout 12 id="discover" 13 currentPage="notifications" 14 defaultTab="default" 15 actions={null}
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { PostListing } from "components/PostListing"; 4 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "./getProfilePosts"; 6 + import { getProfilePosts } from "./getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + 10 + export const ProfilePostsContent = (props: { 11 + did: string; 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, did, cursor]) => getProfilePosts(did, cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) { 67 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + } 69 + 70 + return ( 71 + <div className="flex flex-col gap-3 text-left relative"> 72 + {allPosts.map((post) => ( 73 + <PostListing key={post.documents.uri} {...post} /> 74 + ))} 75 + {/* Trigger element for loading more posts */} 76 + <div 77 + ref={loadMoreRef} 78 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 79 + aria-hidden="true" 80 + /> 81 + {isValidating && ( 82 + <div className="text-center text-tertiary py-4"> 83 + Loading more posts... 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import type { ProfileData } from "./layout"; 6 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 + import { PubIcon } from "components/ActionBar/Publications"; 9 + import { Json } from "supabase/database.types"; 10 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 11 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 + import { SpeedyLink } from "components/SpeedyLink"; 13 + import { ReactNode } from "react"; 14 + import * as linkify from "linkifyjs"; 15 + 16 + export const ProfileHeader = (props: { 17 + profile: ProfileViewDetailed; 18 + publications: { record: Json; uri: string }[]; 19 + popover?: boolean; 20 + }) => { 21 + let profileRecord = props.profile; 22 + const profileUrl = `/p/${props.profile.handle}`; 23 + 24 + const avatarElement = ( 25 + <Avatar 26 + src={profileRecord.avatar} 27 + displayName={profileRecord.displayName} 28 + className="mx-auto mt-3 sm:mt-4" 29 + giant 30 + /> 31 + ); 32 + 33 + const displayNameElement = ( 34 + <h3 className=" px-3 sm:px-4 pt-2 leading-tight"> 35 + {profileRecord.displayName 36 + ? profileRecord.displayName 37 + : `@${props.profile.handle}`} 38 + </h3> 39 + ); 40 + 41 + const handleElement = profileRecord.displayName && ( 42 + <div 43 + className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`} 44 + > 45 + @{props.profile.handle} 46 + </div> 47 + ); 48 + 49 + return ( 50 + <div 51 + className={`flex flex-col relative ${props.popover && "text-sm"}`} 52 + id="profile-header" 53 + > 54 + <ProfileLinks handle={props.profile.handle || ""} /> 55 + <div className="flex flex-col"> 56 + <div className="flex flex-col group"> 57 + {props.popover ? ( 58 + <SpeedyLink className={"hover:no-underline!"} href={profileUrl}> 59 + {avatarElement} 60 + </SpeedyLink> 61 + ) : ( 62 + avatarElement 63 + )} 64 + {props.popover ? ( 65 + <SpeedyLink 66 + className={" text-primary group-hover:underline"} 67 + href={profileUrl} 68 + > 69 + {displayNameElement} 70 + </SpeedyLink> 71 + ) : ( 72 + displayNameElement 73 + )} 74 + {props.popover && handleElement ? ( 75 + <SpeedyLink className={"group-hover:underline"} href={profileUrl}> 76 + {handleElement} 77 + </SpeedyLink> 78 + ) : ( 79 + handleElement 80 + )} 81 + </div> 82 + <pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap"> 83 + {profileRecord.description 84 + ? parseDescription(profileRecord.description) 85 + : null} 86 + </pre> 87 + <div className=" w-full overflow-x-scroll py-3 mb-3 "> 88 + <div 89 + className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 90 + > 91 + {props.publications.map((p) => ( 92 + <PublicationCard 93 + key={p.uri} 94 + record={p.record as PubLeafletPublication.Record} 95 + uri={p.uri} 96 + /> 97 + ))} 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + ); 103 + }; 104 + 105 + const ProfileLinks = (props: { handle: string }) => { 106 + return ( 107 + <div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2"> 108 + <a 109 + className="text-tertiary hover:text-accent-contrast hover:no-underline!" 110 + href={`https://bsky.app/profile/${props.handle}`} 111 + > 112 + <BlueskyTiny /> 113 + </a> 114 + </div> 115 + ); 116 + }; 117 + const PublicationCard = (props: { 118 + record: PubLeafletPublication.Record; 119 + uri: string; 120 + }) => { 121 + const { record, uri } = props; 122 + const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 123 + 124 + return ( 125 + <a 126 + href={`https://${record.base_path}`} 127 + className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 128 + style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 129 + > 130 + <div 131 + className="rounded-md p-2 flex flex-row gap-2" 132 + style={{ 133 + backgroundColor: record.theme?.showPageBackground 134 + ? `rgb(${colorToString(bgPage, "rgb")})` 135 + : undefined, 136 + }} 137 + > 138 + <PubIcon record={record} uri={uri} /> 139 + <h4 140 + className="truncate min-w-0" 141 + style={{ 142 + color: `rgb(${colorToString(primary, "rgb")})`, 143 + }} 144 + > 145 + {record.name} 146 + </h4> 147 + </div> 148 + </a> 149 + ); 150 + }; 151 + 152 + function parseDescription(description: string): ReactNode[] { 153 + // Find all mentions using regex 154 + const mentionRegex = /@\S+/g; 155 + const mentions: { start: number; end: number; value: string }[] = []; 156 + let mentionMatch; 157 + while ((mentionMatch = mentionRegex.exec(description)) !== null) { 158 + mentions.push({ 159 + start: mentionMatch.index, 160 + end: mentionMatch.index + mentionMatch[0].length, 161 + value: mentionMatch[0], 162 + }); 163 + } 164 + 165 + // Find all URLs using linkifyjs 166 + const links = linkify.find(description).filter((link) => link.type === "url"); 167 + 168 + // Filter out URLs that overlap with mentions (mentions take priority) 169 + const nonOverlappingLinks = links.filter((link) => { 170 + return !mentions.some( 171 + (mention) => 172 + (link.start >= mention.start && link.start < mention.end) || 173 + (link.end > mention.start && link.end <= mention.end) || 174 + (link.start <= mention.start && link.end >= mention.end), 175 + ); 176 + }); 177 + 178 + // Combine into a single sorted list 179 + const allMatches: Array<{ 180 + start: number; 181 + end: number; 182 + value: string; 183 + href: string; 184 + type: "url" | "mention"; 185 + }> = [ 186 + ...nonOverlappingLinks.map((link) => ({ 187 + start: link.start, 188 + end: link.end, 189 + value: link.value, 190 + href: link.href, 191 + type: "url" as const, 192 + })), 193 + ...mentions.map((mention) => ({ 194 + start: mention.start, 195 + end: mention.end, 196 + value: mention.value, 197 + href: `/p/${mention.value.slice(1)}`, 198 + type: "mention" as const, 199 + })), 200 + ].sort((a, b) => a.start - b.start); 201 + 202 + const parts: ReactNode[] = []; 203 + let lastIndex = 0; 204 + let key = 0; 205 + 206 + for (const match of allMatches) { 207 + // Add text before this match 208 + if (match.start > lastIndex) { 209 + parts.push(description.slice(lastIndex, match.start)); 210 + } 211 + 212 + if (match.type === "mention") { 213 + parts.push( 214 + <SpeedyLink key={key++} href={match.href}> 215 + {match.value} 216 + </SpeedyLink>, 217 + ); 218 + } else { 219 + // It's a URL 220 + const urlWithoutProtocol = match.value 221 + .replace(/^https?:\/\//, "") 222 + .replace(/\/+$/, ""); 223 + const displayText = 224 + urlWithoutProtocol.length > 50 225 + ? urlWithoutProtocol.slice(0, 50) + "โ€ฆ" 226 + : urlWithoutProtocol; 227 + parts.push( 228 + <a key={key++} href={match.href} target="_blank" rel="noopener noreferrer"> 229 + {displayText} 230 + </a>, 231 + ); 232 + } 233 + 234 + lastIndex = match.end; 235 + } 236 + 237 + // Add remaining text after last match 238 + if (lastIndex < description.length) { 239 + parts.push(description.slice(lastIndex)); 240 + } 241 + 242 + return parts; 243 + }
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
···
··· 1 + "use client"; 2 + 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + 5 + export function ProfileLayout(props: { children: React.ReactNode }) { 6 + let cardBorderHidden = useCardBorderHidden(); 7 + return ( 8 + <div 9 + id="profile-content" 10 + className={` 11 + ${ 12 + cardBorderHidden 13 + ? "" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 15 + } 16 + max-w-prose mx-auto w-full 17 + flex flex-col 18 + text-center 19 + `} 20 + > 21 + {props.children} 22 + </div> 23 + ); 24 + }
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
···
··· 1 + "use client"; 2 + 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + import { useState, useEffect } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + 8 + export type ProfileTabType = "posts" | "comments" | "subscriptions"; 9 + 10 + export const ProfileTabs = (props: { didOrHandle: string }) => { 11 + const cardBorderHidden = useCardBorderHidden(); 12 + const segment = useSelectedLayoutSegment(); 13 + const currentTab = (segment || "posts") as ProfileTabType; 14 + const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0); 15 + const [headerHeight, setHeaderHeight] = useState(0); 16 + useEffect(() => { 17 + let headerHeight = 18 + document.getElementById("profile-header")?.clientHeight || 0; 19 + setHeaderHeight(headerHeight); 20 + 21 + const profileContent = cardBorderHidden 22 + ? document.getElementById("home-content") 23 + : document.getElementById("profile-content"); 24 + const handleScroll = () => { 25 + if (profileContent) { 26 + setScrollPosWithinTabContent( 27 + profileContent.scrollTop - headerHeight > 0 28 + ? profileContent.scrollTop - headerHeight 29 + : 0, 30 + ); 31 + } 32 + }; 33 + 34 + if (profileContent) { 35 + profileContent.addEventListener("scroll", handleScroll); 36 + return () => profileContent.removeEventListener("scroll", handleScroll); 37 + } 38 + }, []); 39 + 40 + const baseUrl = `/p/${props.didOrHandle}`; 41 + const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 + 43 + return ( 44 + <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3"> 45 + <div 46 + style={ 47 + scrollPosWithinTabContent < 20 48 + ? { 49 + paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 50 + paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 51 + } 52 + : { paddingLeft: "12px", paddingRight: "12px" } 53 + } 54 + > 55 + <div 56 + className={` 57 + border rounded-lg 58 + ${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"} 59 + py-1 60 + w-full `} 61 + style={ 62 + scrollPosWithinTabContent < 20 63 + ? { 64 + backgroundColor: !cardBorderHidden 65 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 66 + : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 + paddingLeft: !cardBorderHidden 68 + ? "4px" 69 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 70 + paddingRight: !cardBorderHidden 71 + ? "4px" 72 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 73 + } 74 + : { 75 + backgroundColor: `rgb(${bgColor})`, 76 + paddingLeft: "4px", 77 + paddingRight: "4px", 78 + } 79 + } 80 + > 81 + <div className="flex gap-2 justify-between"> 82 + <div className="flex gap-2"> 83 + <TabLink 84 + href={baseUrl} 85 + name="Posts" 86 + selected={currentTab === "posts"} 87 + /> 88 + <TabLink 89 + href={`${baseUrl}/comments`} 90 + name="Comments" 91 + selected={currentTab === "comments"} 92 + /> 93 + </div> 94 + <TabLink 95 + href={`${baseUrl}/subscriptions`} 96 + name="Subscriptions" 97 + selected={currentTab === "subscriptions"} 98 + /> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + ); 104 + }; 105 + 106 + const TabLink = (props: { href: string; name: string; selected: boolean }) => { 107 + return ( 108 + <SpeedyLink 109 + href={props.href} 110 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 + props.selected 112 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 + : "text-tertiary" 114 + }`} 115 + > 116 + {props.name} 117 + </SpeedyLink> 118 + ); 119 + };
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useMemo } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 + import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { 12 + getProfileComments, 13 + type ProfileComment, 14 + type Cursor, 15 + } from "./getProfileComments"; 16 + import { timeAgo } from "src/utils/timeAgo"; 17 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 18 + 19 + export const ProfileCommentsContent = (props: { 20 + did: string; 21 + comments: ProfileComment[]; 22 + nextCursor: Cursor | null; 23 + }) => { 24 + const getKey = ( 25 + pageIndex: number, 26 + previousPageData: { 27 + comments: ProfileComment[]; 28 + nextCursor: Cursor | null; 29 + } | null, 30 + ) => { 31 + // Reached the end 32 + if (previousPageData && !previousPageData.nextCursor) return null; 33 + 34 + // First page, we don't have previousPageData 35 + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; 36 + 37 + // Add the cursor to the key 38 + return [ 39 + "profile-comments", 40 + props.did, 41 + previousPageData?.nextCursor, 42 + ] as const; 43 + }; 44 + 45 + const { data, size, setSize, isValidating } = useSWRInfinite( 46 + getKey, 47 + ([_, did, cursor]) => getProfileComments(did, cursor), 48 + { 49 + fallbackData: [ 50 + { comments: props.comments, nextCursor: props.nextCursor }, 51 + ], 52 + revalidateFirstPage: false, 53 + }, 54 + ); 55 + 56 + const loadMoreRef = useRef<HTMLDivElement>(null); 57 + 58 + // Set up intersection observer to load more when trigger element is visible 59 + useEffect(() => { 60 + const observer = new IntersectionObserver( 61 + (entries) => { 62 + if (entries[0].isIntersecting && !isValidating) { 63 + const hasMore = data && data[data.length - 1]?.nextCursor; 64 + if (hasMore) { 65 + setSize(size + 1); 66 + } 67 + } 68 + }, 69 + { threshold: 0.1 }, 70 + ); 71 + 72 + if (loadMoreRef.current) { 73 + observer.observe(loadMoreRef.current); 74 + } 75 + 76 + return () => observer.disconnect(); 77 + }, [data, size, setSize, isValidating]); 78 + 79 + const allComments = data ? data.flatMap((page) => page.comments) : []; 80 + 81 + if (allComments.length === 0 && !isValidating) { 82 + return ( 83 + <div className="text-tertiary text-center py-4">No comments yet</div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div className="flex flex-col gap-2 text-left relative"> 89 + {allComments.map((comment) => ( 90 + <CommentItem key={comment.uri} comment={comment} /> 91 + ))} 92 + {/* Trigger element for loading more comments */} 93 + <div 94 + ref={loadMoreRef} 95 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 + aria-hidden="true" 97 + /> 98 + {isValidating && ( 99 + <div className="text-center text-tertiary py-4"> 100 + Loading more comments... 101 + </div> 102 + )} 103 + </div> 104 + ); 105 + }; 106 + 107 + const CommentItem = ({ comment }: { comment: ProfileComment }) => { 108 + const record = comment.record as PubLeafletComment.Record; 109 + const profile = comment.bsky_profiles?.record as 110 + | AppBskyActorProfile.Record 111 + | undefined; 112 + const displayName = 113 + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 114 + 115 + // Get commenter DID from comment URI 116 + const commenterDid = new AtUri(comment.uri).host; 117 + 118 + const isReply = !!record.reply; 119 + 120 + // Get document title 121 + const docData = comment.document?.data as 122 + | PubLeafletDocument.Record 123 + | undefined; 124 + const postTitle = docData?.title || "Untitled"; 125 + 126 + // Get parent comment info for replies 127 + const parentRecord = comment.parentComment?.record as 128 + | PubLeafletComment.Record 129 + | undefined; 130 + const parentProfile = comment.parentComment?.bsky_profiles?.record as 131 + | AppBskyActorProfile.Record 132 + | undefined; 133 + const parentDisplayName = 134 + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 135 + 136 + // Build direct link to the comment 137 + const commentLink = useMemo(() => { 138 + if (!comment.document) return null; 139 + const docUri = new AtUri(comment.document.uri); 140 + 141 + // Get base URL using getPublicationURL if publication exists, otherwise build path 142 + let baseUrl: string; 143 + if (comment.publication) { 144 + baseUrl = getPublicationURL(comment.publication); 145 + const pubUri = new AtUri(comment.publication.uri); 146 + // If getPublicationURL returns a relative path, append the document rkey 147 + if (baseUrl.startsWith("/")) { 148 + baseUrl = `${baseUrl}/${docUri.rkey}`; 149 + } else { 150 + // For custom domains, append the document rkey 151 + baseUrl = `${baseUrl}/${docUri.rkey}`; 152 + } 153 + } else { 154 + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; 155 + } 156 + 157 + // Build query parameters 158 + const params = new URLSearchParams(); 159 + params.set("interactionDrawer", "comments"); 160 + if (record.onPage) { 161 + params.set("page", record.onPage); 162 + } 163 + 164 + // Use comment URI as hash for direct reference 165 + const commentId = encodeURIComponent(comment.uri); 166 + 167 + return `${baseUrl}?${params.toString()}#${commentId}`; 168 + }, [comment.document, comment.publication, comment.uri, record.onPage]); 169 + 170 + // Get avatar source 171 + const avatarSrc = profile?.avatar?.ref 172 + ? blobRefToSrc(profile.avatar.ref, commenterDid) 173 + : undefined; 174 + 175 + return ( 176 + <div id={comment.uri} className="w-full flex flex-col text-left mb-8"> 177 + <div className="flex gap-2 w-full"> 178 + <Avatar src={avatarSrc} displayName={displayName} /> 179 + <div className="flex flex-col w-full min-w-0 grow"> 180 + <div className="flex flex-row gap-2 justify-between"> 181 + <div className="text-tertiary text-sm truncate"> 182 + <span className="font-bold text-secondary">{displayName}</span>{" "} 183 + {isReply ? "replied" : "commented"} on{" "} 184 + {commentLink ? ( 185 + <a 186 + href={commentLink} 187 + className="italic text-accent-contrast hover:underline" 188 + > 189 + {postTitle} 190 + </a> 191 + ) : ( 192 + <span className="italic text-accent-contrast">{postTitle}</span> 193 + )} 194 + </div> 195 + <div className="text-tertiary text-sm shrink-0"> 196 + {timeAgo(record.createdAt)} 197 + </div> 198 + </div> 199 + {isReply && parentRecord && ( 200 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 201 + <ReplyTiny className="shrink-0 scale-75" /> 202 + {parentDisplayName && ( 203 + <div className="font-bold shrink-0">{parentDisplayName}</div> 204 + )} 205 + <div className="grow truncate">{parentRecord.plaintext}</div> 206 + </div> 207 + )} 208 + <pre 209 + style={{ wordBreak: "break-word" }} 210 + className="whitespace-pre-wrap text-secondary" 211 + > 212 + <BaseTextBlock 213 + index={[]} 214 + plaintext={record.plaintext} 215 + facets={record.facets} 216 + /> 217 + </pre> 218 + </div> 219 + </div> 220 + </div> 221 + ); 222 + };
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { PubLeafletComment } from "lexicons/api"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export type ProfileComment = { 13 + uri: string; 14 + record: Json; 15 + indexed_at: string; 16 + bsky_profiles: { record: Json; handle: string | null } | null; 17 + document: { 18 + uri: string; 19 + data: Json; 20 + } | null; 21 + publication: { 22 + uri: string; 23 + record: Json; 24 + } | null; 25 + // For replies, include the parent comment info 26 + parentComment: { 27 + uri: string; 28 + record: Json; 29 + bsky_profiles: { record: Json; handle: string | null } | null; 30 + } | null; 31 + }; 32 + 33 + export async function getProfileComments( 34 + did: string, 35 + cursor?: Cursor | null, 36 + ): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { 37 + const limit = 20; 38 + 39 + let query = supabaseServerClient 40 + .from("comments_on_documents") 41 + .select( 42 + `*, 43 + bsky_profiles(record, handle), 44 + documents(uri, data, documents_in_publications(publications(*)))`, 45 + ) 46 + .eq("profile", did) 47 + .order("indexed_at", { ascending: false }) 48 + .order("uri", { ascending: false }) 49 + .limit(limit); 50 + 51 + if (cursor) { 52 + query = query.or( 53 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 54 + ); 55 + } 56 + 57 + const { data: rawComments } = await query; 58 + 59 + if (!rawComments || rawComments.length === 0) { 60 + return { comments: [], nextCursor: null }; 61 + } 62 + 63 + // Collect parent comment URIs for replies 64 + const parentUris = rawComments 65 + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 + .filter((uri): uri is string => !!uri); 67 + 68 + // Fetch parent comments if there are any replies 69 + let parentCommentsMap = new Map< 70 + string, 71 + { 72 + uri: string; 73 + record: Json; 74 + bsky_profiles: { record: Json; handle: string | null } | null; 75 + } 76 + >(); 77 + 78 + if (parentUris.length > 0) { 79 + const { data: parentComments } = await supabaseServerClient 80 + .from("comments_on_documents") 81 + .select(`uri, record, bsky_profiles(record, handle)`) 82 + .in("uri", parentUris); 83 + 84 + if (parentComments) { 85 + for (const pc of parentComments) { 86 + parentCommentsMap.set(pc.uri, { 87 + uri: pc.uri, 88 + record: pc.record, 89 + bsky_profiles: pc.bsky_profiles, 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Transform to ProfileComment format 96 + const comments: ProfileComment[] = rawComments.map((comment) => { 97 + const record = comment.record as PubLeafletComment.Record; 98 + const doc = comment.documents; 99 + const pub = doc?.documents_in_publications?.[0]?.publications; 100 + 101 + return { 102 + uri: comment.uri, 103 + record: comment.record, 104 + indexed_at: comment.indexed_at, 105 + bsky_profiles: comment.bsky_profiles, 106 + document: doc 107 + ? { 108 + uri: doc.uri, 109 + data: doc.data, 110 + } 111 + : null, 112 + publication: pub 113 + ? { 114 + uri: pub.uri, 115 + record: pub.record, 116 + } 117 + : null, 118 + parentComment: record.reply?.parent 119 + ? parentCommentsMap.get(record.reply.parent) || null 120 + : null, 121 + }; 122 + }); 123 + 124 + const nextCursor = 125 + comments.length === limit 126 + ? { 127 + indexed_at: comments[comments.length - 1].indexed_at, 128 + uri: comments[comments.length - 1].uri, 129 + } 130 + : null; 131 + 132 + return { comments, nextCursor }; 133 + }
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfileComments } from "./getProfileComments"; 3 + import { ProfileCommentsContent } from "./CommentsContent"; 4 + 5 + export default async function ProfileCommentsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { comments, nextCursor } = await getProfileComments(did); 20 + 21 + return ( 22 + <ProfileCommentsContent 23 + did={did} 24 + comments={comments} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export async function getProfilePosts( 13 + did: string, 14 + cursor?: Cursor | null, 15 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 16 + const limit = 20; 17 + 18 + let query = supabaseServerClient 19 + .from("documents") 20 + .select( 21 + `*, 22 + comments_on_documents(count), 23 + document_mentions_in_bsky(count), 24 + documents_in_publications(publications(*))`, 25 + ) 26 + .like("uri", `at://${did}/%`) 27 + .order("indexed_at", { ascending: false }) 28 + .order("uri", { ascending: false }) 29 + .limit(limit); 30 + 31 + if (cursor) { 32 + query = query.or( 33 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 34 + ); 35 + } 36 + 37 + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 38 + query, 39 + supabaseServerClient 40 + .from("publications") 41 + .select("*") 42 + .eq("identity_did", did), 43 + supabaseServerClient 44 + .from("bsky_profiles") 45 + .select("handle") 46 + .eq("did", did) 47 + .single(), 48 + ]); 49 + 50 + // Build a map of publications for quick lookup 51 + let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 + for (let pub of pubs || []) { 53 + pubMap.set(pub.uri, pub); 54 + } 55 + 56 + // Transform data to Post[] format 57 + let handle = profile?.handle ? `@${profile.handle}` : null; 58 + let posts: Post[] = []; 59 + 60 + for (let doc of docs || []) { 61 + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 + 64 + let post: Post = { 65 + author: handle, 66 + documents: { 67 + data: doc.data, 68 + uri: doc.uri, 69 + indexed_at: doc.indexed_at, 70 + comments_on_documents: doc.comments_on_documents, 71 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 72 + }, 73 + }; 74 + 75 + if (pub) { 76 + post.publication = { 77 + href: getPublicationURL(pub), 78 + pubRecord: pub.record, 79 + uri: pub.uri, 80 + }; 81 + } 82 + 83 + posts.push(post); 84 + } 85 + 86 + const nextCursor = 87 + posts.length === limit 88 + ? { 89 + indexed_at: posts[posts.length - 1].documents.indexed_at, 90 + uri: posts[posts.length - 1].documents.uri, 91 + } 92 + : null; 93 + 94 + return { posts, nextCursor }; 95 + }
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { ProfileHeader } from "./ProfileHeader"; 6 + import { ProfileTabs } from "./ProfileTabs"; 7 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 8 + import { ProfileLayout } from "./ProfileLayout"; 9 + import { Agent } from "@atproto/api"; 10 + import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 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 + }); 21 + 22 + export async function generateMetadata(props: { 23 + params: Promise<{ didOrHandle: string }>; 24 + }): Promise<Metadata> { 25 + let params = await props.params; 26 + let didOrHandle = decodeURIComponent(params.didOrHandle); 27 + 28 + let did = didOrHandle; 29 + if (!didOrHandle.startsWith("did:")) { 30 + let resolved = await idResolver.handle.resolve(didOrHandle); 31 + if (!resolved) return { title: "Profile - Leaflet" }; 32 + did = resolved; 33 + } 34 + 35 + let profileData = await getCachedProfileData(did); 36 + let { profile } = profileData.result; 37 + 38 + if (!profile) return { title: "Profile - Leaflet" }; 39 + 40 + const displayName = profile.displayName; 41 + const handle = profile.handle; 42 + 43 + const title = displayName 44 + ? `${displayName} (@${handle}) - Leaflet` 45 + : `@${handle} - Leaflet`; 46 + 47 + return { title }; 48 + } 49 + 50 + export default async function ProfilePageLayout(props: { 51 + params: Promise<{ didOrHandle: string }>; 52 + children: React.ReactNode; 53 + }) { 54 + let params = await props.params; 55 + let didOrHandle = decodeURIComponent(params.didOrHandle); 56 + 57 + // Resolve handle to DID if necessary 58 + let did = didOrHandle; 59 + 60 + if (!didOrHandle.startsWith("did:")) { 61 + let resolved = await idResolver.handle.resolve(didOrHandle); 62 + if (!resolved) { 63 + return ( 64 + <NotFoundLayout> 65 + <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 66 + <p> 67 + This may be a glitch on our end. If the issue persists please{" "} 68 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 69 + </p> 70 + </NotFoundLayout> 71 + ); 72 + } 73 + did = resolved; 74 + } 75 + let profileData = await getCachedProfileData(did); 76 + let { publications, profile } = profileData.result; 77 + 78 + if (!profile) return null; 79 + 80 + return ( 81 + <DashboardLayout 82 + id="profile" 83 + defaultTab="default" 84 + currentPage="profile" 85 + actions={null} 86 + tabs={{ 87 + default: { 88 + controls: null, 89 + content: ( 90 + <ProfileLayout> 91 + <ProfileHeader 92 + profile={profile} 93 + publications={publications || []} 94 + /> 95 + <ProfileTabs didOrHandle={params.didOrHandle} /> 96 + <div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 97 + {props.children} 98 + </div> 99 + </ProfileLayout> 100 + ), 101 + }, 102 + }} 103 + /> 104 + ); 105 + } 106 + 107 + export type ProfileData = { 108 + did: string; 109 + handle: string | null; 110 + indexed_at: string; 111 + record: Json; 112 + };
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfilePosts } from "./getProfilePosts"; 3 + import { ProfilePostsContent } from "./PostsContent"; 4 + 5 + export default async function ProfilePostsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { posts, nextCursor } = await getProfilePosts(did); 20 + 21 + return ( 22 + <ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} /> 23 + ); 24 + }
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 6 + import { 7 + getSubscriptions, 8 + type PublicationSubscription, 9 + } from "app/(home-pages)/reader/getSubscriptions"; 10 + import { Cursor } from "app/(home-pages)/reader/getReaderFeed"; 11 + 12 + export const ProfileSubscriptionsContent = (props: { 13 + did: string; 14 + subscriptions: PublicationSubscription[]; 15 + nextCursor: Cursor | null; 16 + }) => { 17 + const getKey = ( 18 + pageIndex: number, 19 + previousPageData: { 20 + subscriptions: PublicationSubscription[]; 21 + nextCursor: Cursor | null; 22 + } | null, 23 + ) => { 24 + // Reached the end 25 + if (previousPageData && !previousPageData.nextCursor) return null; 26 + 27 + // First page, we don't have previousPageData 28 + if (pageIndex === 0) 29 + return ["profile-subscriptions", props.did, null] as const; 30 + 31 + // Add the cursor to the key 32 + return [ 33 + "profile-subscriptions", 34 + props.did, 35 + previousPageData?.nextCursor, 36 + ] as const; 37 + }; 38 + 39 + const { data, size, setSize, isValidating } = useSWRInfinite( 40 + getKey, 41 + ([_, did, cursor]) => getSubscriptions(did, cursor), 42 + { 43 + fallbackData: [ 44 + { subscriptions: props.subscriptions, nextCursor: props.nextCursor }, 45 + ], 46 + revalidateFirstPage: false, 47 + }, 48 + ); 49 + 50 + const loadMoreRef = useRef<HTMLDivElement>(null); 51 + 52 + // Set up intersection observer to load more when trigger element is visible 53 + useEffect(() => { 54 + const observer = new IntersectionObserver( 55 + (entries) => { 56 + if (entries[0].isIntersecting && !isValidating) { 57 + const hasMore = data && data[data.length - 1]?.nextCursor; 58 + if (hasMore) { 59 + setSize(size + 1); 60 + } 61 + } 62 + }, 63 + { threshold: 0.1 }, 64 + ); 65 + 66 + if (loadMoreRef.current) { 67 + observer.observe(loadMoreRef.current); 68 + } 69 + 70 + return () => observer.disconnect(); 71 + }, [data, size, setSize, isValidating]); 72 + 73 + const allSubscriptions = data 74 + ? data.flatMap((page) => page.subscriptions) 75 + : []; 76 + 77 + if (allSubscriptions.length === 0 && !isValidating) { 78 + return ( 79 + <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 + ); 81 + } 82 + 83 + return ( 84 + <div className="relative"> 85 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 86 + {allSubscriptions.map((sub) => ( 87 + <PubListing key={sub.uri} {...sub} /> 88 + ))} 89 + </div> 90 + {/* Trigger element for loading more subscriptions */} 91 + <div 92 + ref={loadMoreRef} 93 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 94 + aria-hidden="true" 95 + /> 96 + {isValidating && ( 97 + <div className="text-center text-tertiary py-4"> 98 + Loading more subscriptions... 99 + </div> 100 + )} 101 + </div> 102 + ); 103 + };
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions"; 3 + import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 + 5 + export default async function ProfileSubscriptionsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + const params = await props.params; 9 + const didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + const resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { subscriptions, nextCursor } = await getSubscriptions(did); 20 + 21 + return ( 22 + <ProfileSubscriptionsContent 23 + did={did} 24 + subscriptions={subscriptions} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+1 -1
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 32 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 getKey, 35 - ([_, cursor]) => getSubscriptions(cursor), 36 { 37 fallbackData: [ 38 { subscriptions: props.publications, nextCursor: props.nextCursor },
··· 32 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 getKey, 35 + ([_, cursor]) => getSubscriptions(null, cursor), 36 { 37 fallbackData: [ 38 { subscriptions: props.publications, nextCursor: props.nextCursor },
+1 -1
app/(home-pages)/reader/getReaderFeed.ts
··· 83 84 export type Post = { 85 author: string | null; 86 - publication: { 87 href: string; 88 pubRecord: Json; 89 uri: string;
··· 83 84 export type Post = { 85 author: string | null; 86 + publication?: { 87 href: string; 88 pubRecord: Json; 89 uri: string;
+13 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 8 import { idResolver } from "./idResolver"; 9 import { Cursor } from "./getReaderFeed"; 10 11 - export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 nextCursor: null | Cursor; 13 subscriptions: PublicationSubscription[]; 14 }> { 15 - let auth_res = await getIdentityData(); 16 - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 let query = supabaseServerClient 18 .from("publication_subscriptions") 19 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 25 }) 26 .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 .limit(25) 28 - .eq("identity", auth_res.atp_did); 29 30 if (cursor) { 31 query = query.or(
··· 8 import { idResolver } from "./idResolver"; 9 import { Cursor } from "./getReaderFeed"; 10 11 + export async function getSubscriptions( 12 + did?: string | null, 13 + cursor?: Cursor | null, 14 + ): Promise<{ 15 nextCursor: null | Cursor; 16 subscriptions: PublicationSubscription[]; 17 }> { 18 + // If no DID provided, use logged-in user's DID 19 + let identity = did; 20 + if (!identity) { 21 + const auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 23 + identity = auth_res.atp_did; 24 + } 25 + 26 let query = supabaseServerClient 27 .from("publication_subscriptions") 28 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 34 }) 35 .limit(1, { referencedTable: "publications.documents_in_publications" }) 36 .limit(25) 37 + .eq("identity", identity); 38 39 if (cursor) { 40 query = query.or(
-1
app/(home-pages)/reader/page.tsx
··· 12 return ( 13 <DashboardLayout 14 id="reader" 15 - cardBorderHidden={false} 16 currentPage="reader" 17 defaultTab="Read" 18 actions={null}
··· 12 return ( 13 <DashboardLayout 14 id="reader" 15 currentPage="reader" 16 defaultTab="Read" 17 actions={null}
+9 -1
app/(home-pages)/tag/[tag]/page.tsx
··· 3 import { PostListing } from "components/PostListing"; 4 import { getDocumentsByTag } from "./getDocumentsByTag"; 5 import { TagTiny } from "components/Icons/TagTiny"; 6 7 export default async function TagPage(props: { 8 params: Promise<{ tag: string }>; ··· 14 return ( 15 <DashboardLayout 16 id="tag" 17 - cardBorderHidden={false} 18 currentPage="tag" 19 defaultTab="default" 20 actions={null}
··· 3 import { PostListing } from "components/PostListing"; 4 import { getDocumentsByTag } from "./getDocumentsByTag"; 5 import { TagTiny } from "components/Icons/TagTiny"; 6 + import { Metadata } from "next"; 7 + 8 + export async function generateMetadata(props: { 9 + params: Promise<{ tag: string }>; 10 + }): Promise<Metadata> { 11 + const params = await props.params; 12 + const decodedTag = decodeURIComponent(params.tag); 13 + return { title: `${decodedTag} - Leaflet` }; 14 + } 15 16 export default async function TagPage(props: { 17 params: Promise<{ tag: string }>; ··· 23 return ( 24 <DashboardLayout 25 id="tag" 26 currentPage="tag" 27 defaultTab="default" 28 actions={null}
+1 -1
app/[leaflet_id]/actions/HelpButton.tsx
··· 161 className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 style={{ 163 backgroundColor: isHovered 164 - ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 165 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 }} 167 onMouseEnter={handleMouseEnter}
··· 161 className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 style={{ 163 backgroundColor: isHovered 164 + ? "rgb(var(--accent-light))" 165 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 }} 167 onMouseEnter={handleMouseEnter}
+42 -6
app/[leaflet_id]/actions/PublishButton.tsx
··· 13 import { PublishSmall } from "components/Icons/PublishSmall"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 import { InputWithLabel } from "components/Input"; 16 - import { Menu, MenuItem } from "components/Layout"; 17 import { 18 useLeafletDomains, 19 useLeafletPublicationData, ··· 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 import { AddTiny } from "components/Icons/AddTiny"; 42 43 export const PublishButton = (props: { entityID: string }) => { 44 let { data: pub } = useLeafletPublicationData(); ··· 68 let { identity } = useIdentityData(); 69 let toaster = useToaster(); 70 71 // Get tags from Replicache state (same as draft editor) 72 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 const currentTags = Array.isArray(tags) ? tags : []; 74 75 return ( 76 <ActionButton 77 primary ··· 80 onClick={async () => { 81 if (!pub) return; 82 setIsLoading(true); 83 - let doc = await publishToPublication({ 84 root_entity: rootEntity, 85 publication_uri: pub.publications?.uri, 86 leaflet_id: permission_token.id, 87 - title: pub.title, 88 - description: pub.description, 89 tags: currentTags, 90 }); 91 setIsLoading(false); 92 mutate(); 93 94 // Generate URL based on whether it's in a publication or standalone 95 let docUrl = pub.publications 96 - ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 97 - : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 98 99 toaster({ 100 content: (
··· 13 import { PublishSmall } from "components/Icons/PublishSmall"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 import { InputWithLabel } from "components/Input"; 16 + import { Menu, MenuItem } from "components/Menu"; 17 import { 18 useLeafletDomains, 19 useLeafletPublicationData, ··· 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 import { AddTiny } from "components/Icons/AddTiny"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 44 export const PublishButton = (props: { entityID: string }) => { 45 let { data: pub } = useLeafletPublicationData(); ··· 69 let { identity } = useIdentityData(); 70 let toaster = useToaster(); 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 + 89 // Get tags from Replicache state (same as draft editor) 90 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 91 const currentTags = Array.isArray(tags) ? tags : []; 92 93 + // Get cover image from Replicache state 94 + let coverImage = useSubscribe(rep, (tx) => 95 + tx.get<string | null>("publication_cover_image"), 96 + ); 97 + 98 return ( 99 <ActionButton 100 primary ··· 103 onClick={async () => { 104 if (!pub) return; 105 setIsLoading(true); 106 + let result = await publishToPublication({ 107 root_entity: rootEntity, 108 publication_uri: pub.publications?.uri, 109 leaflet_id: permission_token.id, 110 + title: currentTitle, 111 + description: currentDescription, 112 tags: currentTags, 113 + cover_image: coverImage, 114 }); 115 setIsLoading(false); 116 mutate(); 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 + 130 // Generate URL based on whether it's in a publication or standalone 131 let docUrl = pub.publications 132 + ? `${getPublicationURL(pub.publications)}/${result.rkey}` 133 + : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; 134 135 toaster({ 136 content: (
+1 -1
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 3 import { getShareLink } from "./getShareLink"; 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Layout"; 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 import useSWR from "swr"; 9 import LoginForm from "app/login/LoginForm";
··· 3 import { getShareLink } from "./getShareLink"; 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 import { useSmoker } from "components/Toast"; 6 + import { Menu, MenuItem } from "components/Menu"; 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 import useSWR from "swr"; 9 import LoginForm from "app/login/LoginForm";
+54 -22
app/[leaflet_id]/publish/PublishPost.tsx
··· 22 import { TagSelector } from "../../../components/Tags"; 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 25 26 type Props = { 27 title: string; ··· 65 let [charCount, setCharCount] = useState(0); 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 let [isLoading, setIsLoading] = useState(false); 68 let params = useParams(); 69 let { rep } = useReplicache(); 70 ··· 73 tx.get<string[]>("publication_tags"), 74 ); 75 let [localTags, setLocalTags] = useState<string[]>([]); 76 77 // Use Replicache tags only when we have a draft 78 const hasDraft = props.hasDraft; ··· 96 async function submit() { 97 if (isLoading) return; 98 setIsLoading(true); 99 await rep?.push(); 100 - let doc = await publishToPublication({ 101 root_entity: props.root_entity, 102 publication_uri: props.publication_uri, 103 leaflet_id: props.leaflet_id, 104 title: props.title, 105 description: props.description, 106 tags: currentTags, 107 entitiesToDelete: props.entitiesToDelete, 108 }); 109 - if (!doc) return; 110 111 // Generate post URL based on whether it's in a publication or standalone 112 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}`; 115 116 let [text, facets] = editorStateRef.current 117 ? editorStateToFacetedText(editorStateRef.current) 118 : []; 119 - if (shareOption === "bluesky") 120 - await publishPostToBsky({ 121 facets: facets || [], 122 text: text || "", 123 title: props.title, 124 url: post_url, 125 description: props.description, 126 - document_record: doc.record, 127 - rkey: doc.rkey, 128 }); 129 setIsLoading(false); 130 props.setPublishState({ state: "success", post_url }); 131 } ··· 162 </div> 163 <hr className="border-border mb-2" /> 164 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> 179 </div> 180 </div> 181 </form>
··· 22 import { TagSelector } from "../../../components/Tags"; 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 25 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 27 type Props = { 28 title: string; ··· 66 let [charCount, setCharCount] = useState(0); 67 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 68 let [isLoading, setIsLoading] = useState(false); 69 + let [oauthError, setOauthError] = useState< 70 + import("src/atproto-oauth").OAuthSessionError | null 71 + >(null); 72 let params = useParams(); 73 let { rep } = useReplicache(); 74 ··· 77 tx.get<string[]>("publication_tags"), 78 ); 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 + ); 85 86 // Use Replicache tags only when we have a draft 87 const hasDraft = props.hasDraft; ··· 105 async function submit() { 106 if (isLoading) return; 107 setIsLoading(true); 108 + setOauthError(null); 109 await rep?.push(); 110 + let result = await publishToPublication({ 111 root_entity: props.root_entity, 112 publication_uri: props.publication_uri, 113 leaflet_id: props.leaflet_id, 114 title: props.title, 115 description: props.description, 116 tags: currentTags, 117 + cover_image: replicacheCoverImage, 118 entitiesToDelete: props.entitiesToDelete, 119 }); 120 + 121 + if (!result.success) { 122 + setIsLoading(false); 123 + if (isOAuthSessionError(result.error)) { 124 + setOauthError(result.error); 125 + } 126 + return; 127 + } 128 129 // Generate post URL based on whether it's in a publication or standalone 130 let post_url = props.record?.base_path 131 + ? `https://${props.record.base_path}/${result.rkey}` 132 + : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 133 134 let [text, facets] = editorStateRef.current 135 ? editorStateToFacetedText(editorStateRef.current) 136 : []; 137 + if (shareOption === "bluesky") { 138 + let bskyResult = await publishPostToBsky({ 139 facets: facets || [], 140 text: text || "", 141 title: props.title, 142 url: post_url, 143 description: props.description, 144 + document_record: result.record, 145 + rkey: result.rkey, 146 }); 147 + if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) { 148 + setIsLoading(false); 149 + setOauthError(bskyResult.error); 150 + return; 151 + } 152 + } 153 setIsLoading(false); 154 props.setPublishState({ state: "success", post_url }); 155 } ··· 186 </div> 187 <hr className="border-border mb-2" /> 188 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 + )} 211 </div> 212 </div> 213 </form>
+56 -16
app/[leaflet_id]/publish/publishBskyPost.ts
··· 9 import { TID } from "@atproto/common"; 10 import { getIdentityData } from "actions/getIdentityData"; 11 import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 - import { createOauthClient } from "src/atproto-oauth"; 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { Json } from "supabase/database.types"; 15 import { 16 getMicroLinkOgImage, 17 getWebpageImage, 18 } from "src/utils/getMicroLinkOgImage"; 19 20 export async function publishPostToBsky(args: { 21 text: string; ··· 25 document_record: PubLeafletDocument.Record; 26 rkey: string; 27 facets: AppBskyRichtextFacet.Main[]; 28 - }) { 29 - const oauthClient = await createOauthClient(); 30 let identity = await getIdentityData(); 31 - if (!identity || !identity.atp_did) return null; 32 33 - let credentialSession = await oauthClient.restore(identity.atp_did); 34 let agent = new AtpBaseClient( 35 credentialSession.fetchHandler.bind(credentialSession), 36 ); 37 - let newPostUrl = args.url; 38 - let preview_image = await getWebpageImage(newPostUrl, { 39 - width: 1400, 40 - height: 733, 41 - noCache: true, 42 - }); 43 44 - let binary = await preview_image.blob(); 45 - let resized_preview_image = await sharp(await binary.arrayBuffer()) 46 .resize({ 47 width: 1200, 48 fit: "cover", 49 }) 50 .webp({ quality: 85 }) 51 .toBuffer(); 52 53 - let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, { 54 - headers: { "Content-Type": binary.type }, 55 }); 56 let bsky = new BskyAgent(credentialSession); 57 let post = await bsky.app.bsky.feed.post.create( ··· 90 data: record as Json, 91 }) 92 .eq("uri", result.uri); 93 - return true; 94 }
··· 9 import { TID } from "@atproto/common"; 10 import { getIdentityData } from "actions/getIdentityData"; 11 import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 + import { 13 + restoreOAuthSession, 14 + OAuthSessionError, 15 + } from "src/atproto-oauth"; 16 import { supabaseServerClient } from "supabase/serverClient"; 17 import { Json } from "supabase/database.types"; 18 import { 19 getMicroLinkOgImage, 20 getWebpageImage, 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 }; 27 28 export async function publishPostToBsky(args: { 29 text: string; ··· 33 document_record: PubLeafletDocument.Record; 34 rkey: string; 35 facets: AppBskyRichtextFacet.Main[]; 36 + }): Promise<PublishBskyResult> { 37 let identity = await getIdentityData(); 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 + } 48 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; 54 let agent = new AtpBaseClient( 55 credentialSession.fetchHandler.bind(credentialSession), 56 ); 57 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()) 85 .resize({ 86 width: 1200, 87 + height: 630, 88 fit: "cover", 89 }) 90 .webp({ quality: 85 }) 91 .toBuffer(); 92 93 + let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, { 94 + headers: { "Content-Type": "image/webp" }, 95 }); 96 let bsky = new BskyAgent(credentialSession); 97 let post = await bsky.app.bsky.feed.post.create( ··· 130 data: record as Json, 131 }) 132 .eq("uri", result.uri); 133 + return { success: true }; 134 }
+29 -11
app/api/atproto_images/route.ts
··· 1 import { IdResolver } from "@atproto/identity"; 2 import { NextRequest, NextResponse } from "next/server"; 3 let idResolver = new IdResolver(); 4 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 }); 13 14 - let identity = await idResolver.did.resolve(params.did); 15 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 16 - if (!service) return new NextResponse(null, { status: 404 }); 17 const response = await fetch( 18 - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`, 19 { 20 headers: { 21 "Accept-Encoding": "gzip, deflate, br, zstd", 22 }, 23 }, 24 ); 25 26 // Clone the response to modify headers 27 const cachedResponse = new Response(response.body, response);
··· 1 import { IdResolver } from "@atproto/identity"; 2 import { NextRequest, NextResponse } from "next/server"; 3 + 4 let idResolver = new IdResolver(); 5 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; 15 16 + let identity = await idResolver.did.resolve(did); 17 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 18 + if (!service) return null; 19 + 20 const response = await fetch( 21 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 22 { 23 headers: { 24 "Accept-Encoding": "gzip, deflate, br, zstd", 25 }, 26 }, 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 }); 43 44 // Clone the response to modify headers 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"; 4 import { NextRequest } from "next/server"; 5 - import { createOauthClient } from "src/atproto-oauth"; 6 - import { supabaseServerClient } from "supabase/serverClient"; 7 8 export const runtime = "nodejs"; 9 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 export async function GET(req: NextRequest) { 39 try { 40 const searchParams = req.nextUrl.searchParams; ··· 49 ); 50 } 51 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 - } 59 60 const response = await agent.getPostThread({ 61 uri,
··· 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; ··· 18 ); 19 } 20 21 + const agent = await getAgent(); 22 23 const response = await agent.getPostThread({ 24 uri,
+5 -7
app/api/inngest/functions/index_follows.ts
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtpAgent, AtUri } from "@atproto/api"; 3 - import { createIdentity } from "actions/createIdentity"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 import { inngest } from "../client"; 6 - import { pool } from "supabase/pool"; 7 8 export const index_follows = inngest.createFunction( 9 { ··· 58 .eq("atp_did", event.data.did) 59 .single(); 60 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(); 65 return identity; 66 } 67 }),
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtpAgent, AtUri } from "@atproto/api"; 3 import { inngest } from "../client"; 4 5 export const index_follows = inngest.createFunction( 6 { ··· 55 .eq("atp_did", event.data.did) 56 .single(); 57 if (!exists) { 58 + const { data: identity } = await supabaseServerClient 59 + .from("identities") 60 + .insert({ atp_did: event.data.did }) 61 + .select() 62 + .single(); 63 return identity; 64 } 65 }),
+8 -9
app/api/oauth/[route]/route.ts
··· 1 - import { createIdentity } from "actions/createIdentity"; 2 import { subscribeToPublication } from "app/lish/subscribeToPublication"; 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 import { cookies } from "next/headers"; 5 import { redirect } from "next/navigation"; 6 import { NextRequest, NextResponse } from "next/server"; ··· 13 ActionAfterSignIn, 14 parseActionFromSearchParam, 15 } from "./afterSignInActions"; 16 - import { pool } from "supabase/pool"; 17 18 type OauthRequestClientState = { 19 redirect: string | null; ··· 80 81 return handleAction(s.action, redirectPath); 82 } 83 - const client = await pool.connect(); 84 - const db = drizzle(client); 85 - identity = await createIdentity(db, { atp_did: session.did }); 86 - client.release(); 87 } 88 let { data: token } = await supabaseServerClient 89 .from("email_auth_tokens") 90 .insert({ 91 - identity: identity.id, 92 confirmed: true, 93 confirmation_code: "", 94 }) ··· 121 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 122 if (action?.action === "subscribe") { 123 let result = await subscribeToPublication(action.publication); 124 - if (result.hasFeed === false) 125 url.searchParams.set("showSubscribeSuccess", "true"); 126 } 127
··· 1 import { subscribeToPublication } from "app/lish/subscribeToPublication"; 2 import { cookies } from "next/headers"; 3 import { redirect } from "next/navigation"; 4 import { NextRequest, NextResponse } from "next/server"; ··· 11 ActionAfterSignIn, 12 parseActionFromSearchParam, 13 } from "./afterSignInActions"; 14 15 type OauthRequestClientState = { 16 redirect: string | null; ··· 77 78 return handleAction(s.action, redirectPath); 79 } 80 + const { data } = await supabaseServerClient 81 + .from("identities") 82 + .insert({ atp_did: session.did }) 83 + .select() 84 + .single(); 85 + identity = data; 86 } 87 let { data: token } = await supabaseServerClient 88 .from("email_auth_tokens") 89 .insert({ 90 + identity: identity!.id, 91 confirmed: true, 92 confirmation_code: "", 93 }) ··· 120 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 121 if (action?.action === "subscribe") { 122 let result = await subscribeToPublication(action.publication); 123 + if (result.success && result.hasFeed === false) 124 url.searchParams.set("showSubscribeSuccess", "true"); 125 } 126
+69
app/api/rpc/[command]/get_profile_data.ts
···
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Agent } from "@atproto/api"; 7 + import { getIdentityData } from "actions/getIdentityData"; 8 + import { createOauthClient } from "src/atproto-oauth"; 9 + 10 + export type GetProfileDataReturnType = Awaited< 11 + ReturnType<(typeof get_profile_data)["handler"]> 12 + >; 13 + 14 + export const get_profile_data = makeRoute({ 15 + route: "get_profile_data", 16 + input: z.object({ 17 + didOrHandle: z.string(), 18 + }), 19 + handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => { 20 + // Resolve handle to DID if necessary 21 + let did = didOrHandle; 22 + 23 + if (!didOrHandle.startsWith("did:")) { 24 + const resolved = await idResolver.handle.resolve(didOrHandle); 25 + if (!resolved) { 26 + throw new Error("Could not resolve handle to DID"); 27 + } 28 + did = resolved; 29 + } 30 + let agent; 31 + let authed_identity = await getIdentityData(); 32 + if (authed_identity?.atp_did) { 33 + try { 34 + const oauthClient = await createOauthClient(); 35 + let credentialSession = await oauthClient.restore( 36 + authed_identity.atp_did, 37 + ); 38 + agent = new Agent(credentialSession); 39 + } catch (e) { 40 + agent = new Agent({ 41 + service: "https://public.api.bsky.app", 42 + }); 43 + } 44 + } else { 45 + agent = new Agent({ 46 + service: "https://public.api.bsky.app", 47 + }); 48 + } 49 + 50 + let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); 51 + 52 + let publicationsReq = supabase 53 + .from("publications") 54 + .select("*") 55 + .eq("identity_did", did); 56 + 57 + let [{ data: profile }, { data: publications }] = await Promise.all([ 58 + profileReq, 59 + publicationsReq, 60 + ]); 61 + 62 + return { 63 + result: { 64 + profile, 65 + publications: publications || [], 66 + }, 67 + }; 68 + }, 69 + });
+6
app/api/rpc/[command]/pull.ts
··· 74 description: string; 75 title: string; 76 tags: string[]; 77 }[]; 78 let pub_patch = publication_data?.[0] 79 ? [ ··· 91 op: "put", 92 key: "publication_tags", 93 value: publication_data[0].tags || [], 94 }, 95 ] 96 : [];
··· 74 description: string; 75 title: string; 76 tags: string[]; 77 + cover_image: string | null; 78 }[]; 79 let pub_patch = publication_data?.[0] 80 ? [ ··· 92 op: "put", 93 key: "publication_tags", 94 value: publication_data[0].tags || [], 95 + }, 96 + { 97 + op: "put", 98 + key: "publication_cover_image", 99 + value: publication_data[0].cover_image || null, 100 }, 101 ] 102 : [];
+2
app/api/rpc/[command]/route.ts
··· 13 import { get_publication_data } from "./get_publication_data"; 14 import { search_publication_names } from "./search_publication_names"; 15 import { search_publication_documents } from "./search_publication_documents"; 16 17 let supabase = createClient<Database>( 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 39 get_publication_data, 40 search_publication_names, 41 search_publication_documents, 42 ]; 43 export async function POST( 44 req: Request,
··· 13 import { get_publication_data } from "./get_publication_data"; 14 import { search_publication_names } from "./search_publication_names"; 15 import { search_publication_documents } from "./search_publication_documents"; 16 + import { get_profile_data } from "./get_profile_data"; 17 18 let supabase = createClient<Database>( 19 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 40 get_publication_data, 41 search_publication_names, 42 search_publication_documents, 43 + get_profile_data, 44 ]; 45 export async function POST( 46 req: Request,
+40 -17
app/globals.css
··· 107 --highlight-3: 255, 205, 195; 108 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)); 112 113 --gripperSVG: url("/gripperPattern.svg"); 114 --gripperSVG2: url("/gripperPattern2.svg"); ··· 125 126 @media (min-width: 640px) { 127 :root { 128 --page-width-unitless: min( 129 - 624, 130 calc(var(--leaflet-width-unitless) - 128) 131 ); 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) 141 ); 142 - --page-width-units: min(624px, calc((100vw / 2) - 32px)); 143 } 144 } 145 ··· 270 } 271 272 pre.shiki { 273 @apply p-2; 274 @apply rounded-md; 275 @apply overflow-auto; 276 } 277 278 .highlight:has(+ .highlight) { ··· 296 @apply py-[1.5px]; 297 } 298 299 - /* Underline mention nodes when selected in ProseMirror */ 300 .ProseMirror .atMention.ProseMirror-selectednode, 301 .ProseMirror .didMention.ProseMirror-selectednode { 302 - text-decoration: underline; 303 } 304 305 - .ProseMirror:focus-within .selection-highlight { 306 - background-color: transparent; 307 } 308 309 .multiselected:focus-within .selection-highlight {
··· 107 --highlight-3: 255, 205, 195; 108 109 --list-marker-width: 36px; 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 + ); 118 119 --gripperSVG: url("/gripperPattern.svg"); 120 --gripperSVG2: url("/gripperPattern2.svg"); ··· 131 132 @media (min-width: 640px) { 133 :root { 134 + /*picks between max width and screen width with 64px of padding*/ 135 --page-width-unitless: min( 136 + var(--page-width-setting), 137 calc(var(--leaflet-width-unitless) - 128) 138 ); 139 + --page-width-units: min( 140 + calc(var(--page-width-unitless) * 1px), 141 + calc(100vw - 128px) 142 ); 143 } 144 } 145 ··· 270 } 271 272 pre.shiki { 273 + @apply sm:p-3; 274 @apply p-2; 275 @apply rounded-md; 276 @apply overflow-auto; 277 + 278 + @media (min-width: 640px) { 279 + @apply p-3; 280 + } 281 } 282 283 .highlight:has(+ .highlight) { ··· 301 @apply py-[1.5px]; 302 } 303 304 + .ProseMirror:focus-within .selection-highlight { 305 + background-color: transparent; 306 + } 307 + 308 .ProseMirror .atMention.ProseMirror-selectednode, 309 .ProseMirror .didMention.ProseMirror-selectednode { 310 + @apply text-accent-contrast; 311 + @apply px-0.5; 312 + @apply -mx-[3px]; /* extra px to account for the border*/ 313 + @apply -my-px; /*to account for the border*/ 314 + @apply rounded-[4px]; 315 + @apply box-decoration-clone; 316 + background-color: rgba(var(--accent-contrast), 0.2); 317 + border: 1px solid rgba(var(--accent-contrast), 1); 318 } 319 320 + .mention { 321 + @apply cursor-pointer; 322 + @apply text-accent-contrast; 323 + @apply px-0.5; 324 + @apply -mx-[3px]; 325 + @apply -my-px; /*to account for the border*/ 326 + @apply rounded-[4px]; 327 + @apply box-decoration-clone; 328 + background-color: rgba(var(--accent-contrast), 0.2); 329 + border: 1px solid transparent; 330 } 331 332 .multiselected:focus-within .selection-highlight {
+19 -2
app/lish/Subscribe.tsx
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 export const SubscribeWithBluesky = (props: { 28 pubName: string; ··· 133 }) => { 134 let { identity } = useIdentityData(); 135 let toaster = useToaster(); 136 let [, subscribe, subscribePending] = useActionState(async () => { 137 let result = await subscribeToPublication( 138 props.pub_uri, 139 window.location.href + "?refreshAuth", 140 ); 141 if (result.hasFeed === false) { 142 props.setSuccessModalOpen(true); 143 } ··· 172 } 173 174 return ( 175 - <> 176 <form 177 action={subscribe} 178 className="place-self-center flex flex-row gap-1" ··· 187 )} 188 </ButtonPrimary> 189 </form> 190 - </> 191 ); 192 }; 193
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 27 28 export const SubscribeWithBluesky = (props: { 29 pubName: string; ··· 134 }) => { 135 let { identity } = useIdentityData(); 136 let toaster = useToaster(); 137 + let [oauthError, setOauthError] = useState< 138 + import("src/atproto-oauth").OAuthSessionError | null 139 + >(null); 140 let [, subscribe, subscribePending] = useActionState(async () => { 141 + setOauthError(null); 142 let result = await subscribeToPublication( 143 props.pub_uri, 144 window.location.href + "?refreshAuth", 145 ); 146 + if (!result.success) { 147 + if (isOAuthSessionError(result.error)) { 148 + setOauthError(result.error); 149 + } 150 + return; 151 + } 152 if (result.hasFeed === false) { 153 props.setSuccessModalOpen(true); 154 } ··· 183 } 184 185 return ( 186 + <div className="flex flex-col gap-2 place-self-center"> 187 <form 188 action={subscribe} 189 className="place-self-center flex flex-row gap-1" ··· 198 )} 199 </ButtonPrimary> 200 </form> 201 + {oauthError && ( 202 + <OAuthErrorMessage 203 + error={oauthError} 204 + className="text-center text-sm text-accent-1" 205 + /> 206 + )} 207 + </div> 208 ); 209 }; 210
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
···
··· 1 + "use client"; 2 + import { ProfilePopover } from "components/ProfilePopover"; 3 + 4 + export const PublicationAuthor = (props: { 5 + did: string; 6 + displayName?: string; 7 + handle: string; 8 + }) => { 9 + return ( 10 + <p className="italic text-tertiary sm:text-base text-sm"> 11 + <ProfilePopover 12 + didOrHandle={props.did} 13 + trigger={ 14 + <span className="hover:underline"> 15 + <strong>by {props.displayName}</strong> @{props.handle} 16 + </span> 17 + } 18 + /> 19 + </p> 20 + ); 21 + };
+21 -201
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 - 6 - type Facet = PubLeafletRichtextFacet.Main; 7 - export function BaseTextBlock(props: { 8 - plaintext: string; 9 - facets?: Facet[]; 10 - index: number[]; 11 - preview?: boolean; 12 - }) { 13 - let children = []; 14 - let richText = new RichText({ 15 - text: props.plaintext, 16 - facets: props.facets || [], 17 - }); 18 - let counter = 0; 19 - for (const segment of richText.segments()) { 20 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 21 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 22 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 23 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 24 - let isStrikethrough = segment.facet?.find( 25 - PubLeafletRichtextFacet.isStrikethrough, 26 - ); 27 - let isDidMention = segment.facet?.find( 28 - PubLeafletRichtextFacet.isDidMention, 29 - ); 30 - let isAtMention = segment.facet?.find( 31 - PubLeafletRichtextFacet.isAtMention, 32 - ); 33 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 34 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 35 - let isHighlighted = segment.facet?.find( 36 - PubLeafletRichtextFacet.isHighlight, 37 - ); 38 - let className = ` 39 - ${isCode ? "inline-code" : ""} 40 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 41 - ${isBold ? "font-bold" : ""} 42 - ${isItalic ? "italic" : ""} 43 - ${isUnderline ? "underline" : ""} 44 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 45 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 46 - 47 - // Split text by newlines and insert <br> tags 48 - const textParts = segment.text.split('\n'); 49 - const renderedText = textParts.flatMap((part, i) => 50 - i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part] 51 - ); 52 - 53 - if (isCode) { 54 - children.push( 55 - <code key={counter} className={className} id={id?.id}> 56 - {renderedText} 57 - </code>, 58 - ); 59 - } else if (isDidMention) { 60 - children.push( 61 - <a 62 - key={counter} 63 - href={didToBlueskyUrl(isDidMention.did)} 64 - className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 - target="_blank" 66 - rel="noopener noreferrer" 67 - > 68 - {renderedText} 69 - </a>, 70 - ); 71 - } else if (isAtMention) { 72 - children.push( 73 - <AtMentionLink 74 - key={counter} 75 - atURI={isAtMention.atURI} 76 - className={className} 77 - > 78 - {renderedText} 79 - </AtMentionLink>, 80 - ); 81 - } else if (link) { 82 - children.push( 83 - <a 84 - key={counter} 85 - href={link.uri} 86 - className={`text-accent-contrast hover:underline ${className}`} 87 - target="_blank" 88 - > 89 - {renderedText} 90 - </a>, 91 - ); 92 - } else { 93 - children.push( 94 - <span key={counter} className={className} id={id?.id}> 95 - {renderedText} 96 - </span>, 97 - ); 98 - } 99 - 100 - counter++; 101 - } 102 - return <>{children}</>; 103 - } 104 - 105 - type RichTextSegment = { 106 - text: string; 107 - facet?: Exclude<Facet["features"], { $type: string }>; 108 - }; 109 - 110 - export class RichText { 111 - unicodeText: UnicodeString; 112 - facets?: Facet[]; 113 - 114 - constructor(props: { text: string; facets: Facet[] }) { 115 - this.unicodeText = new UnicodeString(props.text); 116 - this.facets = props.facets; 117 - if (this.facets) { 118 - this.facets = this.facets 119 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 120 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 121 - } 122 - } 123 124 - *segments(): Generator<RichTextSegment, void, void> { 125 - const facets = this.facets || []; 126 - if (!facets.length) { 127 - yield { text: this.unicodeText.utf16 }; 128 - return; 129 - } 130 131 - let textCursor = 0; 132 - let facetCursor = 0; 133 - do { 134 - const currFacet = facets[facetCursor]; 135 - if (textCursor < currFacet.index.byteStart) { 136 - yield { 137 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 138 - }; 139 - } else if (textCursor > currFacet.index.byteStart) { 140 - facetCursor++; 141 - continue; 142 - } 143 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 144 - const subtext = this.unicodeText.slice( 145 - currFacet.index.byteStart, 146 - currFacet.index.byteEnd, 147 - ); 148 - if (!subtext.trim()) { 149 - // dont empty string entities 150 - yield { text: subtext }; 151 - } else { 152 - yield { text: subtext, facet: currFacet.features }; 153 - } 154 - } 155 - textCursor = currFacet.index.byteEnd; 156 - facetCursor++; 157 - } while (facetCursor < facets.length); 158 - if (textCursor < this.unicodeText.length) { 159 - yield { 160 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 161 - }; 162 - } 163 - } 164 } 165 - function addFacet(facets: Facet[], newFacet: Facet, length: number) { 166 - if (facets.length === 0) { 167 - return [newFacet]; 168 - } 169 170 - const allFacets = [...facets, newFacet]; 171 - 172 - // Collect all boundary positions 173 - const boundaries = new Set<number>(); 174 - boundaries.add(0); 175 - boundaries.add(length); 176 - 177 - for (const facet of allFacets) { 178 - boundaries.add(facet.index.byteStart); 179 - boundaries.add(facet.index.byteEnd); 180 - } 181 - 182 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 183 - const result: Facet[] = []; 184 - 185 - // Process segments between consecutive boundaries 186 - for (let i = 0; i < sortedBoundaries.length - 1; i++) { 187 - const start = sortedBoundaries[i]; 188 - const end = sortedBoundaries[i + 1]; 189 - 190 - // Find facets that are active at the start position 191 - const activeFacets = allFacets.filter( 192 - (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 193 - ); 194 - 195 - // Only create facet if there are active facets (features present) 196 - if (activeFacets.length > 0) { 197 - const features = activeFacets.flatMap((f) => f.features); 198 - result.push({ 199 - index: { byteStart: start, byteEnd: end }, 200 - features, 201 - }); 202 - } 203 - } 204 - 205 - return result; 206 }
··· 1 + import { ProfilePopover } from "components/ProfilePopover"; 2 + import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore"; 3 + import { ReactNode } from "react"; 4 5 + // Re-export RichText for backwards compatibility 6 + export { RichText }; 7 8 + function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 9 + return ( 10 + <ProfilePopover 11 + didOrHandle={props.did} 12 + trigger={props.children} 13 + /> 14 + ); 15 } 16 17 + export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 18 + return ( 19 + <TextBlockCore 20 + {...props} 21 + renderers={{ 22 + DidMention: DidMentionWithPopover, 23 + }} 24 + /> 25 + ); 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 + };
+1 -2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 57 <PageWrapper 58 pageType="canvas" 59 fullPageScroll={fullPageScroll} 60 - cardBorderHidden={!hasPageBackground} 61 - id={pageId ? `post-page-${pageId}` : "post-page"} 62 drawerOpen={ 63 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 64 }
··· 57 <PageWrapper 58 pageType="canvas" 59 fullPageScroll={fullPageScroll} 60 + id={`post-page-${pageId ?? document_uri}`} 61 drawerOpen={ 62 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 63 }
+24 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 import { betterIsUrl } from "src/utils/isURL"; 41 import { Mention, MentionAutocomplete } from "components/Mention"; 42 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 ··· 95 } = useInteractionState(props.doc_uri); 96 let [loading, setLoading] = useState(false); 97 let view = useRef<null | EditorView>(null); 98 99 // Mention autocomplete state 100 const [mentionOpen, setMentionOpen] = useState(false); ··· 161 setLoading(true); 162 let currentState = view.current.state; 163 let [plaintext, facets] = docToFacetedText(currentState.doc); 164 - let comment = await publishComment({ 165 pageId: props.pageId, 166 document: props.doc_uri, 167 comment: { ··· 178 }, 179 }); 180 181 let tr = currentState.tr; 182 tr = tr.replaceWith( 183 0, ··· 194 localComments: [ 195 ...s.localComments, 196 { 197 - record: comment.record, 198 - uri: comment.uri, 199 - bsky_profiles: { record: comment.profile as Json }, 200 }, 201 ], 202 }));
··· 1 + import { AtUri, UnicodeString } from "@atproto/api"; 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { useToaster } from "components/Toast"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 import { Mention, MentionAutocomplete } from "components/Mention"; 44 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 45 ··· 97 } = useInteractionState(props.doc_uri); 98 let [loading, setLoading] = useState(false); 99 let view = useRef<null | EditorView>(null); 100 + let toaster = useToaster(); 101 102 // Mention autocomplete state 103 const [mentionOpen, setMentionOpen] = useState(false); ··· 164 setLoading(true); 165 let currentState = view.current.state; 166 let [plaintext, facets] = docToFacetedText(currentState.doc); 167 + let result = await publishComment({ 168 pageId: props.pageId, 169 document: props.doc_uri, 170 comment: { ··· 181 }, 182 }); 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 + 197 let tr = currentState.tr; 198 tr = tr.replaceWith( 199 0, ··· 210 localComments: [ 211 ...s.localComments, 212 { 213 + record: result.record, 214 + uri: result.uri, 215 + bsky_profiles: { 216 + record: result.profile as Json, 217 + did: new AtUri(result.uri).host, 218 + }, 219 }, 220 ], 221 }));
+25 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 3 import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 - import { createOauthClient } from "src/atproto-oauth"; 7 import { TID } from "@atproto/common"; 8 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 import { supabaseServerClient } from "supabase/serverClient"; ··· 15 } from "src/notifications"; 16 import { v7 } from "uuid"; 17 18 export async function publishComment(args: { 19 document: string; 20 pageId?: string; ··· 24 replyTo?: string; 25 attachment: PubLeafletComment.Record["attachment"]; 26 }; 27 - }) { 28 - const oauthClient = await createOauthClient(); 29 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 31 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 33 let agent = new AtpBaseClient( 34 credentialSession.fetchHandler.bind(credentialSession), 35 ); ··· 108 } 109 110 return { 111 record: data?.[0].record as Json, 112 profile: lexToJson(profile.value), 113 uri: uri.toString(),
··· 3 import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 10 import { TID } from "@atproto/common"; 11 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 12 import { supabaseServerClient } from "supabase/serverClient"; ··· 18 } from "src/notifications"; 19 import { v7 } from "uuid"; 20 21 + type PublishCommentResult = 22 + | { success: true; record: Json; profile: any; uri: string } 23 + | { success: false; error: OAuthSessionError }; 24 + 25 export async function publishComment(args: { 26 document: string; 27 pageId?: string; ··· 31 replyTo?: string; 32 attachment: PubLeafletComment.Record["attachment"]; 33 }; 34 + }): Promise<PublishCommentResult> { 35 let identity = await getIdentityData(); 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 + } 46 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; 52 let agent = new AtpBaseClient( 53 credentialSession.fetchHandler.bind(credentialSession), 54 ); ··· 127 } 128 129 return { 130 + success: true, 131 record: data?.[0].record as Json, 132 profile: lexToJson(profile.value), 133 uri: uri.toString(),
+15 -99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 export type Comment = { 23 record: Json; 24 uri: string; 25 - bsky_profiles: { record: Json } | null; 26 }; 27 export function Comments(props: { 28 document_uri: string; ··· 109 document: string; 110 comment: Comment; 111 comments: Comment[]; 112 - profile?: AppBskyActorProfile.Record; 113 record: PubLeafletComment.Record; 114 pageId?: string; 115 }) => { 116 return ( 117 - <div className="comment"> 118 <div className="flex gap-2"> 119 - {props.profile && ( 120 - <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 121 )} 122 - <DatePopover date={props.record.createdAt} /> 123 </div> 124 {props.record.attachment && 125 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 291 </Popover> 292 ); 293 }; 294 - 295 - const ProfilePopover = (props: { 296 - profile: AppBskyActorProfile.Record; 297 - comment: string; 298 - }) => { 299 - let commenterId = new AtUri(props.comment).host; 300 - 301 - return ( 302 - <> 303 - <a 304 - className="font-bold text-tertiary text-sm hover:underline" 305 - href={`https://bsky.app/profile/${commenterId}`} 306 - > 307 - {props.profile.displayName} 308 - </a> 309 - {/*<Media mobile={false}> 310 - <Popover 311 - align="start" 312 - trigger={ 313 - <div 314 - onMouseOver={() => { 315 - setHovering(true); 316 - hoverTimeout.current = window.setTimeout(() => { 317 - setLoadProfile(true); 318 - }, 500); 319 - }} 320 - onMouseOut={() => { 321 - setHovering(false); 322 - clearTimeout(hoverTimeout.current); 323 - }} 324 - className="font-bold text-tertiary text-sm hover:underline" 325 - > 326 - {props.profile.displayName} 327 - </div> 328 - } 329 - className="max-w-sm" 330 - > 331 - {profile && ( 332 - <> 333 - <div className="profilePopover text-sm flex gap-2"> 334 - <div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" /> 335 - <div className="flex flex-col"> 336 - <div className="flex justify-between"> 337 - <div className="profileHeader flex gap-2 items-center"> 338 - <div className="font-bold">celine</div> 339 - <a className="text-tertiary" href="/"> 340 - @{profile.handle} 341 - </a> 342 - </div> 343 - </div> 344 - 345 - <div className="profileBio text-secondary "> 346 - {profile.description} 347 - </div> 348 - <div className="flex flex-row gap-2 items-center pt-2 font-bold"> 349 - {!profile.viewer?.following ? ( 350 - <div className="text-tertiary bg-border-light rounded-md px-1 py-0"> 351 - Following 352 - </div> 353 - ) : ( 354 - <ButtonPrimary compact className="text-sm"> 355 - Follow <BlueskyTiny /> 356 - </ButtonPrimary> 357 - )} 358 - {profile.viewer?.followedBy && ( 359 - <div className="text-tertiary">Follows You</div> 360 - )} 361 - </div> 362 - </div> 363 - </div> 364 - 365 - <hr className="my-2 border-border-light" /> 366 - <div className="flex gap-2 leading-tight items-center text-tertiary text-sm"> 367 - <div className="flex flex-col w-6 justify-center"> 368 - {profile.viewer?.knownFollowers?.followers.map((follower) => { 369 - return ( 370 - <div 371 - className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page" 372 - key={follower.did} 373 - /> 374 - ); 375 - })} 376 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 377 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 378 - </div> 379 - </div> 380 - </> 381 - )} 382 - </Popover> 383 - </Media>*/} 384 - </> 385 - ); 386 - };
··· 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 22 23 export type Comment = { 24 record: Json; 25 uri: string; 26 + bsky_profiles: { record: Json; did: string } | null; 27 }; 28 export function Comments(props: { 29 document_uri: string; ··· 110 document: string; 111 comment: Comment; 112 comments: Comment[]; 113 + profile: AppBskyActorProfile.Record; 114 record: PubLeafletComment.Record; 115 pageId?: string; 116 }) => { 117 + const did = props.comment.bsky_profiles?.did; 118 + 119 return ( 120 + <div id={props.comment.uri} className="comment"> 121 <div className="flex gap-2"> 122 + {did && ( 123 + <ProfilePopover 124 + didOrHandle={did} 125 + trigger={ 126 + <div className="text-sm text-tertiary font-bold hover:underline"> 127 + {props.profile.displayName} 128 + </div> 129 + } 130 + /> 131 )} 132 </div> 133 {props.record.attachment && 134 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 300 </Popover> 301 ); 302 };
+4 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 58 export const useDrawerOpen = (uri: string) => { 59 let params = useSearchParams(); 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 return null; 64 drawer = 65 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 - return { drawer, pageId }; 67 };
··· 58 export const useDrawerOpen = (uri: string) => { 59 let params = useSearchParams(); 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 + let pageParam = params.get("page"); 62 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 63 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 64 return null; 65 drawer = 66 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 67 + // Use pageId from state, or fall back to page search param 68 + const resolvedPageId = pageId ?? pageParam ?? undefined; 69 + return { drawer, pageId: resolvedPageId }; 70 };
+1 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 247 <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 248 <span 249 aria-hidden 250 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 251 </button> 252 )} 253 {props.showComments === false ? null : (
··· 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 : (
+30 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 23 import useSWR, { mutate } from "swr"; 24 import { DotLoader } from "components/utils/DotLoader"; 25 import { CommentTiny } from "components/Icons/CommentTiny"; 26 - import { ThreadLink } from "../ThreadPage"; 27 28 // Helper to get SWR key for quotes 29 export function getQuotesSWRKey(uris: string[]) { ··· 138 profile={pv.author} 139 handle={pv.author.handle} 140 replyCount={pv.replyCount} 141 /> 142 </div> 143 ); ··· 161 profile={pv.author} 162 handle={pv.author.handle} 163 replyCount={pv.replyCount} 164 /> 165 ); 166 })} ··· 180 }) => { 181 let isMobile = useIsMobile(); 182 const data = useContext(PostPageContext); 183 184 let record = data?.data as PubLeafletDocument.Record; 185 let page: PubLeafletPagesLinearDocument.Main | undefined = ( ··· 211 let scrollMargin = isMobile 212 ? 16 213 : e.currentTarget.getBoundingClientRect().top; 214 - let scrollContainer = window.document.getElementById("post-page"); 215 let el = window.document.getElementById( 216 props.position.start.block.join("."), 217 ); ··· 252 handle: string; 253 profile: ProfileViewBasic; 254 replyCount?: number; 255 }) => { 256 const handleOpenThread = () => { 257 openPage(undefined, { type: "thread", uri: props.uri }); ··· 282 </a> 283 </div> 284 <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 - )} 295 </div> 296 </div> 297 );
··· 23 import useSWR, { mutate } from "swr"; 24 import { DotLoader } from "components/utils/DotLoader"; 25 import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { ThreadLink, QuotesLink } from "../PostLinks"; 28 29 // Helper to get SWR key for quotes 30 export function getQuotesSWRKey(uris: string[]) { ··· 139 profile={pv.author} 140 handle={pv.author.handle} 141 replyCount={pv.replyCount} 142 + quoteCount={pv.quoteCount} 143 /> 144 </div> 145 ); ··· 163 profile={pv.author} 164 handle={pv.author.handle} 165 replyCount={pv.replyCount} 166 + quoteCount={pv.quoteCount} 167 /> 168 ); 169 })} ··· 183 }) => { 184 let isMobile = useIsMobile(); 185 const data = useContext(PostPageContext); 186 + const document_uri = data?.uri; 187 188 let record = data?.data as PubLeafletDocument.Record; 189 let page: PubLeafletPagesLinearDocument.Main | undefined = ( ··· 215 let scrollMargin = isMobile 216 ? 16 217 : e.currentTarget.getBoundingClientRect().top; 218 + let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 219 + let scrollContainer = window.document.getElementById(scrollContainerId); 220 let el = window.document.getElementById( 221 props.position.start.block.join("."), 222 ); ··· 257 handle: string; 258 profile: ProfileViewBasic; 259 replyCount?: number; 260 + quoteCount?: number; 261 }) => { 262 const handleOpenThread = () => { 263 openPage(undefined, { type: "thread", uri: props.uri }); ··· 288 </a> 289 </div> 290 <div className="text-primary">{props.content}</div> 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> 313 </div> 314 </div> 315 );
+1 -2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 61 <PageWrapper 62 pageType="doc" 63 fullPageScroll={fullPageScroll} 64 - cardBorderHidden={!hasPageBackground} 65 - id={pageId ? `post-page-${pageId}` : "post-page"} 66 drawerOpen={ 67 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 68 }
··· 61 <PageWrapper 62 pageType="doc" 63 fullPageScroll={fullPageScroll} 64 + id={`post-page-${pageId ?? document_uri}`} 65 drawerOpen={ 66 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 67 }
+12 -11
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 import { Separator } from "components/Layout"; 21 22 export function PostHeader(props: { 23 data: PostPageData; ··· 72 <> 73 <div className="flex flex-row gap-2 items-center"> 74 {profile ? ( 75 - <> 76 - <a 77 - className="text-tertiary" 78 - href={`https://bsky.app/profile/${profile.handle}`} 79 - > 80 - {profile.displayName || profile.handle} 81 - </a> 82 - </> 83 ) : null} 84 {record.publishedAt ? ( 85 <> ··· 107 }) => { 108 return ( 109 <div 110 - className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 id="post-header" 112 > 113 <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> ··· 119 {props.postTitle ? props.postTitle : "Untitled"} 120 </h2> 121 {props.postDescription ? ( 122 - <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 {props.postDescription} 124 - </p> 125 ) : null} 126 <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 {props.postInfo}
··· 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 import { Separator } from "components/Layout"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 22 23 export function PostHeader(props: { 24 data: PostPageData; ··· 73 <> 74 <div className="flex flex-row gap-2 items-center"> 75 {profile ? ( 76 + <ProfilePopover 77 + didOrHandle={profile.did} 78 + trigger={ 79 + <span className="text-tertiary hover:underline"> 80 + {profile.displayName || profile.handle} 81 + </span> 82 + } 83 + /> 84 ) : null} 85 {record.publishedAt ? ( 86 <> ··· 108 }) => { 109 return ( 110 <div 111 + className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 112 id="post-header" 113 > 114 <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> ··· 120 {props.postTitle ? props.postTitle : "Untitled"} 121 </h2> 122 {props.postDescription ? ( 123 + <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 124 {props.postDescription} 125 + </div> 126 ) : null} 127 <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 128 {props.postInfo}
+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 + }
+27 -8
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 import { CanvasPage } from "./CanvasPage"; 27 import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 29 // Page types 30 export type DocPage = { type: "doc"; id: string }; 31 export type ThreadPage = { type: "thread"; uri: string }; 32 - export type OpenPage = DocPage | ThreadPage; 33 34 // Get a stable key for a page 35 const getPageKey = (page: OpenPage): string => { 36 if (page.type === "doc") return page.id; 37 return `thread:${page.uri}`; 38 }; 39 ··· 81 }); 82 return; 83 } 84 - 85 // Then check for quote param 86 if (quote) { 87 const decodedQuote = decodeQuotePosition(quote as string); ··· 96 // Mark as initialized even if no pageId found 97 usePostPageUIState.setState({ initialized: true }); 98 } 99 - }, [quote]); 100 }; 101 102 export const openPage = ( ··· 293 ); 294 } 295 296 // Handle document pages 297 let page = record.pages.find( 298 (p) => ··· 352 return ( 353 <div 354 className={`pageOptions w-fit z-10 355 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 356 flex sm:flex-col flex-row-reverse gap-1 items-start`} 357 > 358 - <PageOptionButton 359 - cardBorderHidden={!props.hasPageBackground} 360 - onClick={props.onClick} 361 - > 362 <CloseTiny /> 363 </PageOptionButton> 364 </div>
··· 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 import { CanvasPage } from "./CanvasPage"; 27 import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 + import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 29 30 // Page types 31 export type DocPage = { type: "doc"; id: string }; 32 export type ThreadPage = { type: "thread"; uri: string }; 33 + export type QuotesPage = { type: "quotes"; uri: string }; 34 + export type OpenPage = DocPage | ThreadPage | QuotesPage; 35 36 // Get a stable key for a page 37 const getPageKey = (page: OpenPage): string => { 38 if (page.type === "doc") return page.id; 39 + if (page.type === "quotes") return `quotes:${page.uri}`; 40 return `thread:${page.uri}`; 41 }; 42 ··· 84 }); 85 return; 86 } 87 // Then check for quote param 88 if (quote) { 89 const decodedQuote = decodeQuotePosition(quote as string); ··· 98 // Mark as initialized even if no pageId found 99 usePostPageUIState.setState({ initialized: true }); 100 } 101 + }, [quote, pageParam]); 102 }; 103 104 export const openPage = ( ··· 295 ); 296 } 297 298 + // Handle quotes pages 299 + if (openPage.type === "quotes") { 300 + return ( 301 + <Fragment key={pageKey}> 302 + <SandwichSpacer /> 303 + <BlueskyQuotesPage 304 + postUri={openPage.uri} 305 + pageId={pageKey} 306 + hasPageBackground={hasPageBackground} 307 + pageOptions={ 308 + <PageOptions 309 + onClick={() => closePage(openPage)} 310 + hasPageBackground={hasPageBackground} 311 + /> 312 + } 313 + /> 314 + </Fragment> 315 + ); 316 + } 317 + 318 // Handle document pages 319 let page = record.pages.find( 320 (p) => ··· 374 return ( 375 <div 376 className={`pageOptions w-fit z-10 377 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 378 flex sm:flex-col flex-row-reverse gap-1 items-start`} 379 > 380 + <PageOptionButton onClick={props.onClick}> 381 <CloseTiny /> 382 </PageOptionButton> 383 </div>
+16 -1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 8 import { 9 BlueskyEmbed, ··· 11 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 12 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 13 import { openPage } from "./PostPages"; 14 - import { ThreadLink } from "./ThreadPage"; 15 16 export const PubBlueskyPostBlock = (props: { 17 post: PostView; ··· 118 {post.replyCount} 119 <CommentTiny /> 120 </ThreadLink> 121 <Separator classname="h-4" /> 122 </> 123 )}
··· 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "./PostLinks"; 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 import { 11 BlueskyEmbed, ··· 13 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 import { openPage } from "./PostPages"; 16 17 export const PubBlueskyPostBlock = (props: { 18 post: PostView; ··· 119 {post.replyCount} 120 <CommentTiny /> 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> 136 <Separator classname="h-4" /> 137 </> 138 )}
+11 -7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 PubLeafletPagesLinearDocument, 13 } from "lexicons/api"; 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 - import { BaseTextBlock } from "./BaseTextBlock"; 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 19 export function StaticPostContent({ 20 blocks, ··· 47 case PubLeafletBlocksBlockquote.isMain(b.block): { 48 return ( 49 <blockquote className={` blockquote `}> 50 - <BaseTextBlock 51 facets={b.block.facets} 52 plaintext={b.block.plaintext} 53 index={[]} ··· 116 case PubLeafletBlocksText.isMain(b.block): 117 return ( 118 <p> 119 - <BaseTextBlock 120 facets={b.block.facets} 121 plaintext={b.block.plaintext} 122 index={[]} ··· 127 if (b.block.level === 1) 128 return ( 129 <h1> 130 - <BaseTextBlock {...b.block} index={[]} /> 131 </h1> 132 ); 133 if (b.block.level === 2) 134 return ( 135 <h2> 136 - <BaseTextBlock {...b.block} index={[]} /> 137 </h2> 138 ); 139 if (b.block.level === 3) 140 return ( 141 <h3> 142 - <BaseTextBlock {...b.block} index={[]} /> 143 </h3> 144 ); 145 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 146 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 147 return ( 148 <h6> 149 - <BaseTextBlock {...b.block} index={[]} /> 150 </h6> 151 ); 152 }
··· 12 PubLeafletPagesLinearDocument, 13 } from "lexicons/api"; 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore"; 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 + 19 + function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 20 + return <TextBlockCore {...props} />; 21 + } 22 23 export function StaticPostContent({ 24 blocks, ··· 51 case PubLeafletBlocksBlockquote.isMain(b.block): { 52 return ( 53 <blockquote className={` blockquote `}> 54 + <StaticBaseTextBlock 55 facets={b.block.facets} 56 plaintext={b.block.plaintext} 57 index={[]} ··· 120 case PubLeafletBlocksText.isMain(b.block): 121 return ( 122 <p> 123 + <StaticBaseTextBlock 124 facets={b.block.facets} 125 plaintext={b.block.plaintext} 126 index={[]} ··· 131 if (b.block.level === 1) 132 return ( 133 <h1> 134 + <StaticBaseTextBlock {...b.block} index={[]} /> 135 </h1> 136 ); 137 if (b.block.level === 2) 138 return ( 139 <h2> 140 + <StaticBaseTextBlock {...b.block} index={[]} /> 141 </h2> 142 ); 143 if (b.block.level === 3) 144 return ( 145 <h3> 146 + <StaticBaseTextBlock {...b.block} index={[]} /> 147 </h3> 148 ); 149 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 150 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 151 return ( 152 <h6> 153 + <StaticBaseTextBlock {...b.block} index={[]} /> 154 </h6> 155 ); 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 -225
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 "use client"; 2 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import useSWR, { preload } from "swr"; 4 import { PageWrapper } from "components/Pages/Page"; 5 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 import { DotLoader } from "components/utils/DotLoader"; 7 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"; 19 20 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 21 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 22 type BlockedPost = AppBskyFeedDefs.BlockedPost; 23 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 24 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 export function ThreadPage(props: { 78 threadUri: string; 79 pageId: string; ··· 90 } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 91 fetchThread(threadUri), 92 ); 93 - let cardBorderHidden = useCardBorderHidden(null); 94 95 return ( 96 <PageWrapper 97 - cardBorderHidden={!!cardBorderHidden} 98 pageType="doc" 99 fullPageScroll={false} 100 id={`post-page-${pageId}`} ··· 121 122 function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 123 const { thread, threadUri } = props; 124 125 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 126 return <PostNotAvailable />; ··· 161 ))} 162 163 {/* Main post */} 164 - <ThreadPost 165 - post={thread} 166 - isMainPost={true} 167 - showReplyLine={false} 168 - threadUri={threadUri} 169 - /> 170 171 {/* Replies */} 172 {thread.replies && thread.replies.length > 0 && ( ··· 178 replies={thread.replies as any[]} 179 threadUri={threadUri} 180 depth={0} 181 /> 182 </div> 183 )} ··· 193 }) { 194 const { post, isMainPost, showReplyLine, threadUri } = props; 195 const postView = post.post; 196 - const record = postView.record as AppBskyFeedPost.Record; 197 - 198 - const postId = postView.uri.split("/")[4]; 199 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 200 201 return ( 202 <div className="flex gap-2 relative"> ··· 205 <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 206 )} 207 208 - <div className="flex flex-col items-center shrink-0"> 209 - {postView.author.avatar ? ( 210 - <img 211 - src={postView.author.avatar} 212 - alt={`${postView.author.displayName}'s avatar`} 213 - className="w-10 h-10 rounded-full border border-border-light" 214 - /> 215 - ) : ( 216 - <div className="w-10 h-10 rounded-full border border-border-light bg-border" /> 217 - )} 218 - </div> 219 - 220 - <div 221 - className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`} 222 - > 223 - <div className="flex items-center gap-2 leading-tight"> 224 - <div className="font-bold text-secondary"> 225 - {postView.author.displayName} 226 - </div> 227 - <a 228 - className="text-xs text-tertiary hover:underline" 229 - target="_blank" 230 - href={`https://bsky.app/profile/${postView.author.handle}`} 231 - > 232 - @{postView.author.handle} 233 - </a> 234 - </div> 235 - 236 - <div className="flex flex-col gap-2 mt-1"> 237 - <div className="text-sm text-secondary"> 238 - <BlueskyRichText record={record} /> 239 - </div> 240 - {postView.embed && ( 241 - <BlueskyEmbed embed={postView.embed} postUrl={url} /> 242 - )} 243 - </div> 244 - 245 - <div className="flex gap-2 items-center justify-between mt-2"> 246 - <ClientDate date={record.createdAt} /> 247 - <div className="flex gap-2 items-center"> 248 - {postView.replyCount != null && postView.replyCount > 0 && ( 249 - <> 250 - {isMainPost ? ( 251 - <div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs"> 252 - {postView.replyCount} 253 - <CommentTiny /> 254 - </div> 255 - ) : ( 256 - <ThreadLink 257 - threadUri={postView.uri} 258 - parent={{ type: "thread", uri: threadUri }} 259 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 260 - > 261 - {postView.replyCount} 262 - <CommentTiny /> 263 - </ThreadLink> 264 - )} 265 - <Separator classname="h-4" /> 266 - </> 267 - )} 268 - <a className="text-tertiary" target="_blank" href={url}> 269 - <BlueskyTiny /> 270 - </a> 271 - </div> 272 - </div> 273 - </div> 274 </div> 275 ); 276 } ··· 279 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 280 threadUri: string; 281 depth: number; 282 }) { 283 - const { replies, threadUri, depth } = props; 284 285 return ( 286 <div className="flex flex-col gap-0"> 287 - {replies.map((reply, index) => { 288 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 289 return ( 290 <div ··· 312 } 313 314 const hasReplies = reply.replies && reply.replies.length > 0; 315 316 return ( 317 <div key={reply.post.uri} className="flex flex-col"> ··· 322 threadUri={threadUri} 323 /> 324 {hasReplies && depth < 3 && ( 325 - <div className="ml-5 pl-5 border-l border-border-light"> 326 - <Replies 327 - replies={reply.replies as any[]} 328 - threadUri={threadUri} 329 - depth={depth + 1} 330 - /> 331 </div> 332 )} 333 {hasReplies && depth >= 3 && ( ··· 352 isLast: boolean; 353 threadUri: string; 354 }) { 355 - const { post, showReplyLine, isLast, threadUri } = props; 356 const postView = post.post; 357 - const record = postView.record as AppBskyFeedPost.Record; 358 - 359 - const postId = postView.uri.split("/")[4]; 360 - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; 361 - 362 const parent = { type: "thread" as const, uri: threadUri }; 363 364 return ( ··· 366 className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 367 onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 368 > 369 - <div className="flex flex-col items-center shrink-0"> 370 - {postView.author.avatar ? ( 371 - <img 372 - src={postView.author.avatar} 373 - alt={`${postView.author.displayName}'s avatar`} 374 - className="w-8 h-8 rounded-full border border-border-light" 375 - /> 376 - ) : ( 377 - <div className="w-8 h-8 rounded-full border border-border-light bg-border" /> 378 - )} 379 - </div> 380 - 381 - <div className="flex flex-col grow min-w-0"> 382 - <div className="flex items-center gap-2 leading-tight text-sm"> 383 - <div className="font-bold text-secondary"> 384 - {postView.author.displayName} 385 - </div> 386 - <a 387 - className="text-xs text-tertiary hover:underline" 388 - target="_blank" 389 - href={`https://bsky.app/profile/${postView.author.handle}`} 390 - onClick={(e) => e.stopPropagation()} 391 - > 392 - @{postView.author.handle} 393 - </a> 394 - </div> 395 - 396 - <div className="text-sm text-secondary mt-0.5"> 397 - <BlueskyRichText record={record} /> 398 - </div> 399 - 400 - <div className="flex gap-2 items-center mt-1"> 401 - <ClientDate date={record.createdAt} /> 402 - {postView.replyCount != null && postView.replyCount > 0 && ( 403 - <> 404 - <Separator classname="h-3" /> 405 - <ThreadLink 406 - threadUri={postView.uri} 407 - parent={parent} 408 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 409 - onClick={(e) => e.stopPropagation()} 410 - > 411 - {postView.replyCount} 412 - <CommentTiny /> 413 - </ThreadLink> 414 - </> 415 - )} 416 - </div> 417 - </div> 418 </div> 419 ); 420 } 421 - 422 - const ClientDate = (props: { date?: string }) => { 423 - const pageLoaded = useHasPageLoaded(); 424 - const formattedDate = useLocalizedDate( 425 - props.date || new Date().toISOString(), 426 - { 427 - month: "short", 428 - day: "numeric", 429 - year: "numeric", 430 - hour: "numeric", 431 - minute: "numeric", 432 - hour12: true, 433 - }, 434 - ); 435 - 436 - if (!pageLoaded) return null; 437 - 438 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 439 - };
··· 1 "use client"; 2 + import { useEffect, useRef } from "react"; 3 + import { AppBskyFeedDefs } from "@atproto/api"; 4 + import useSWR from "swr"; 5 import { PageWrapper } from "components/Pages/Page"; 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 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"; 12 import { 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 }; 21 22 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 23 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 24 type BlockedPost = AppBskyFeedDefs.BlockedPost; 25 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 26 27 export function ThreadPage(props: { 28 threadUri: string; 29 pageId: string; ··· 40 } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 41 fetchThread(threadUri), 42 ); 43 44 return ( 45 <PageWrapper 46 pageType="doc" 47 fullPageScroll={false} 48 id={`post-page-${pageId}`} ··· 69 70 function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 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 + }, []); 83 84 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 85 return <PostNotAvailable />; ··· 120 ))} 121 122 {/* Main post */} 123 + <div ref={mainPostRef}> 124 + <ThreadPost 125 + post={thread} 126 + isMainPost={true} 127 + showReplyLine={false} 128 + threadUri={threadUri} 129 + /> 130 + </div> 131 132 {/* Replies */} 133 {thread.replies && thread.replies.length > 0 && ( ··· 139 replies={thread.replies as any[]} 140 threadUri={threadUri} 141 depth={0} 142 + parentAuthorDid={thread.post.author.did} 143 /> 144 </div> 145 )} ··· 155 }) { 156 const { post, isMainPost, showReplyLine, threadUri } = props; 157 const postView = post.post; 158 + const parent = { type: "thread" as const, uri: threadUri }; 159 160 return ( 161 <div className="flex gap-2 relative"> ··· 164 <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 )} 166 167 + <BskyPostContent 168 + post={postView} 169 + parent={parent} 170 + linksEnabled={!isMainPost} 171 + showBlueskyLink={true} 172 + showEmbed={true} 173 + /> 174 </div> 175 ); 176 } ··· 179 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 180 threadUri: string; 181 depth: number; 182 + parentAuthorDid?: string; 183 }) { 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; 202 203 return ( 204 <div className="flex flex-col gap-0"> 205 + {sortedReplies.map((reply, index) => { 206 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 207 return ( 208 <div ··· 230 } 231 232 const hasReplies = reply.replies && reply.replies.length > 0; 233 + const isCollapsed = collapsedThreads.has(reply.post.uri); 234 + const replyCount = reply.replies?.length ?? 0; 235 236 return ( 237 <div key={reply.post.uri} className="flex flex-col"> ··· 242 threadUri={threadUri} 243 /> 244 {hasReplies && depth < 3 && ( 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 + )} 279 </div> 280 )} 281 {hasReplies && depth >= 3 && ( ··· 300 isLast: boolean; 301 threadUri: string; 302 }) { 303 + const { post, threadUri } = props; 304 const postView = post.post; 305 const parent = { type: "thread" as const, uri: threadUri }; 306 307 return ( ··· 309 className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 310 onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 311 > 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 + /> 322 </div> 323 ); 324 }
+44 -1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 3 - export const runtime = "edge"; 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 params: Promise<{ publication: string; did: string; rkey: string }>; 8 }) { 9 let params = await props.params; 10 return getMicroLinkOgImage( 11 `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 12 );
··· 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"; 8 9 export const revalidate = 60; 10 11 export default async function OpenGraphImage(props: { 12 params: Promise<{ publication: string; did: string; rkey: string }>; 13 }) { 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 53 return getMicroLinkOgImage( 54 `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 55 );
+12 -4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 "use server"; 2 3 - import { createOauthClient } from "src/atproto-oauth"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 import { AtpBaseClient, AtUri } from "@atproto/api"; 6 import { PubLeafletPollVote } from "lexicons/api"; ··· 12 pollUri: string, 13 pollCid: string, 14 selectedOption: string, 15 - ): Promise<{ success: boolean; error?: string }> { 16 try { 17 const identity = await getIdentityData(); 18 ··· 20 return { success: false, error: "Not authenticated" }; 21 } 22 23 - const oauthClient = await createOauthClient(); 24 - const session = await oauthClient.restore(identity.atp_did); 25 let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 27 const voteRecord: PubLeafletPollVote.Record = {
··· 1 "use server"; 2 3 + import { 4 + restoreOAuthSession, 5 + OAuthSessionError, 6 + } from "src/atproto-oauth"; 7 import { getIdentityData } from "actions/getIdentityData"; 8 import { AtpBaseClient, AtUri } from "@atproto/api"; 9 import { PubLeafletPollVote } from "lexicons/api"; ··· 15 pollUri: string, 16 pollCid: string, 17 selectedOption: string, 18 + ): Promise< 19 + { success: true } | { success: false; error: string | OAuthSessionError } 20 + > { 21 try { 22 const identity = await getIdentityData(); 23 ··· 25 return { success: false, error: "Not authenticated" }; 26 } 27 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; 33 let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 34 35 const voteRecord: PubLeafletPollVote.Record = {
+2 -3
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 "use client"; 2 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 - import { PublicationSettingsButton } from "./PublicationSettings"; 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 - import { Menu } from "components/Layout"; 8 - import { MenuItem } from "components/Layout"; 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 import { usePublicationData } from "./PublicationSWRProvider"; 11 import { useSmoker } from "components/Toast";
··· 1 "use client"; 2 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 + import { PublicationSettingsButton } from "./settings/PublicationSettings"; 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 + import { Menu, MenuItem } from "components/Menu"; 8 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 9 import { usePublicationData } from "./PublicationSWRProvider"; 10 import { useSmoker } from "components/Toast";
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 23 searchValue={props.searchValue} 24 showPreview={false} 25 defaultDisplay="list" 26 - cardBorderHidden={!props.showPageBackground} 27 leaflets={leaflets_in_publications 28 .filter((l) => !l.documents) 29 .filter((l) => !l.archived)
··· 23 searchValue={props.searchValue} 24 showPreview={false} 25 defaultDisplay="list" 26 leaflets={leaflets_in_publications 27 .filter((l) => !l.documents) 28 .filter((l) => !l.archived)
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 return ( 40 <DashboardLayout 41 id={publication.uri} 42 - cardBorderHidden={!!record.theme?.showPageBackground} 43 defaultTab="Drafts" 44 tabs={{ 45 Drafts: {
··· 39 return ( 40 <DashboardLayout 41 id={publication.uri} 42 defaultTab="Drafts" 43 tabs={{ 44 Drafts: {
-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 import { ButtonPrimary } from "components/Buttons"; 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 import { useSmoker } from "components/Toast"; 7 - import { Menu, MenuItem, Separator } from "components/Layout"; 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 import { Checkbox } from "components/Checkbox"; 10 import { useEffect, useState } from "react";
··· 4 import { ButtonPrimary } from "components/Buttons"; 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 import { useSmoker } from "components/Toast"; 7 + import { Menu, MenuItem } from "components/Menu"; 8 + import { Separator } from "components/Layout"; 9 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 10 import { Checkbox } from "components/Checkbox"; 11 import { useEffect, useState } from "react";
+1 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 7 import { Fragment, useState } from "react"; 8 import { useParams } from "next/navigation"; 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Layout"; 11 import { deletePost } from "./deletePost"; 12 import { ButtonPrimary } from "components/Buttons"; 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
··· 7 import { Fragment, useState } from "react"; 8 import { useParams } from "next/navigation"; 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 + import { Menu, MenuItem } from "components/Menu"; 11 import { deletePost } from "./deletePost"; 12 import { ButtonPrimary } from "components/Buttons"; 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
+50 -13
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 2 3 import { AtpBaseClient } from "lexicons/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 6 import { AtUri } from "@atproto/syntax"; 7 import { supabaseServerClient } from "supabase/serverClient"; 8 import { revalidatePath } from "next/cache"; 9 10 - export async function deletePost(document_uri: string) { 11 let identity = await getIdentityData(); 12 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 13 14 - const oauthClient = await createOauthClient(); 15 - let credentialSession = await oauthClient.restore(identity.atp_did); 16 let agent = new AtpBaseClient( 17 credentialSession.fetchHandler.bind(credentialSession), 18 ); 19 let uri = new AtUri(document_uri); 20 - if (uri.host !== identity.atp_did) return; 21 22 await Promise.all([ 23 agent.pub.leaflet.document.delete({ ··· 31 .eq("doc", document_uri), 32 ]); 33 34 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 35 } 36 37 - export async function unpublishPost(document_uri: string) { 38 let identity = await getIdentityData(); 39 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 40 41 - const oauthClient = await createOauthClient(); 42 - let credentialSession = await oauthClient.restore(identity.atp_did); 43 let agent = new AtpBaseClient( 44 credentialSession.fetchHandler.bind(credentialSession), 45 ); 46 let uri = new AtUri(document_uri); 47 - if (uri.host !== identity.atp_did) return; 48 49 await Promise.all([ 50 agent.pub.leaflet.document.delete({ ··· 53 }), 54 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 55 ]); 56 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 57 }
··· 2 3 import { AtpBaseClient } from "lexicons/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 9 import { AtUri } from "@atproto/syntax"; 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { revalidatePath } from "next/cache"; 12 13 + export async function deletePost( 14 + document_uri: string 15 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 16 let identity = await getIdentityData(); 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 + } 27 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; 33 let agent = new AtpBaseClient( 34 credentialSession.fetchHandler.bind(credentialSession), 35 ); 36 let uri = new AtUri(document_uri); 37 + if (uri.host !== identity.atp_did) { 38 + return { success: true }; 39 + } 40 41 await Promise.all([ 42 agent.pub.leaflet.document.delete({ ··· 50 .eq("doc", document_uri), 51 ]); 52 53 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 54 + return { success: true }; 55 } 56 57 + export async function unpublishPost( 58 + document_uri: string 59 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 60 let identity = await getIdentityData(); 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 + } 71 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; 77 let agent = new AtpBaseClient( 78 credentialSession.fetchHandler.bind(credentialSession), 79 ); 80 let uri = new AtUri(document_uri); 81 + if (uri.host !== identity.atp_did) { 82 + return { success: true }; 83 + } 84 85 await Promise.all([ 86 agent.pub.leaflet.document.delete({ ··· 89 }), 90 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 91 ]); 92 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 93 + return { success: true }; 94 }
+102
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(true); 26 + let [showPrevNext, setShowPrevNext] = useState(true); 27 + 28 + let toast = useToaster(); 29 + return ( 30 + <form 31 + onSubmit={async (e) => { 32 + // if (!pubData) return; 33 + // e.preventDefault(); 34 + // props.setLoading(true); 35 + // let data = await updatePublication({ 36 + // uri: pubData.uri, 37 + // name: nameValue, 38 + // description: descriptionValue, 39 + // iconFile: iconFile, 40 + // preferences: { 41 + // showInDiscover: showInDiscover, 42 + // showComments: showComments, 43 + // }, 44 + // }); 45 + // toast({ type: "success", content: "Posts Updated!" }); 46 + // props.setLoading(false); 47 + // mutate("publication-data"); 48 + }} 49 + className="text-primary flex flex-col" 50 + > 51 + <PubSettingsHeader 52 + loading={props.loading} 53 + setLoadingAction={props.setLoading} 54 + backToMenuAction={props.backToMenu} 55 + state={"post-options"} 56 + > 57 + Post Options 58 + </PubSettingsHeader> 59 + <h4 className="mb-1">Layout</h4> 60 + {/*<div>Max Post Width</div>*/} 61 + <Toggle 62 + toggle={showPrevNext} 63 + onToggle={() => { 64 + setShowPrevNext(!showPrevNext); 65 + }} 66 + > 67 + <div className="flex flex-col justify-start"> 68 + <div className="font-bold">Show Prev/Next Buttons</div> 69 + <div className="text-tertiary text-sm leading-tight"> 70 + Show buttons that navigate to the previous and next posts 71 + </div> 72 + </div> 73 + </Toggle> 74 + <hr className="my-2 border-border-light" /> 75 + <h4 className="mb-1">Interactions</h4> 76 + <div className="flex flex-col gap-2"> 77 + <Toggle 78 + toggle={showComments} 79 + onToggle={() => { 80 + setShowComments(!showComments); 81 + }} 82 + > 83 + <div className="font-bold">Show Comments</div> 84 + </Toggle> 85 + 86 + <Toggle 87 + toggle={showMentions} 88 + onToggle={() => { 89 + setShowMentions(!showMentions); 90 + }} 91 + > 92 + <div className="flex flex-col justify-start"> 93 + <div className="font-bold">Show Mentions</div> 94 + <div className="text-tertiary text-sm leading-tight"> 95 + Display a list of posts on Bluesky that mention your post 96 + </div> 97 + </div> 98 + </Toggle> 99 + </div> 100 + </form> 101 + ); 102 + };
+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 + };
+7 -10
app/lish/[did]/[publication]/page.tsx
··· 17 import { InteractionPreview } from "components/InteractionsPreview"; 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 21 export default async function Publication(props: { 22 params: Promise<{ publication: string; did: string }>; ··· 91 {record?.description}{" "} 92 </p> 93 {profile && ( 94 - <p className="italic text-tertiary sm:text-base text-sm"> 95 - <strong className="">by {profile.displayName}</strong>{" "} 96 - <a 97 - className="text-tertiary" 98 - href={`https://bsky.app/profile/${profile.handle}`} 99 - > 100 - @{profile.handle} 101 - </a> 102 - </p> 103 )} 104 <div className="sm:pt-4 pt-4"> 105 <SubscribeWithBluesky ··· 168 quotesCount={quotes} 169 commentsCount={comments} 170 tags={tags} 171 - postUrl="" 172 showComments={record?.preferences?.showComments} 173 /> 174 </div>
··· 17 import { InteractionPreview } from "components/InteractionsPreview"; 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 + import { PublicationAuthor } from "./PublicationAuthor"; 21 22 export default async function Publication(props: { 23 params: Promise<{ publication: string; did: string }>; ··· 92 {record?.description}{" "} 93 </p> 94 {profile && ( 95 + <PublicationAuthor 96 + did={profile.did} 97 + displayName={profile.displayName} 98 + handle={profile.handle} 99 + /> 100 )} 101 <div className="sm:pt-4 pt-4"> 102 <SubscribeWithBluesky ··· 165 quotesCount={quotes} 166 commentsCount={comments} 167 tags={tags} 168 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 showComments={record?.preferences?.showComments} 170 /> 171 </div>
+22 -6
app/lish/addFeed.tsx
··· 2 3 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 6 const leafletFeedURI = 7 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 8 9 - export async function addFeed() { 10 - const oauthClient = await createOauthClient(); 11 let identity = await getIdentityData(); 12 if (!identity || !identity.atp_did) { 13 - throw new Error("Invalid identity data"); 14 } 15 16 - let credentialSession = await oauthClient.restore(identity.atp_did); 17 let bsky = new BskyAgent(credentialSession); 18 let prefs = await bsky.app.bsky.actor.getPreferences(); 19 let savedFeeds = prefs.data.preferences.find( ··· 23 let hasFeed = !!savedFeeds.items.find( 24 (feed) => feed.value === leafletFeedURI, 25 ); 26 - if (hasFeed) return; 27 28 await bsky.addSavedFeeds([ 29 { ··· 32 type: "feed", 33 }, 34 ]); 35 }
··· 2 3 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 import { getIdentityData } from "actions/getIdentityData"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 9 const leafletFeedURI = 10 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 11 12 + export async function addFeed(): Promise< 13 + { success: true } | { success: false; error: OAuthSessionError } 14 + > { 15 let identity = await getIdentityData(); 16 if (!identity || !identity.atp_did) { 17 + return { 18 + success: false, 19 + error: { 20 + type: "oauth_session_expired", 21 + message: "Not authenticated", 22 + did: "", 23 + }, 24 + }; 25 } 26 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; 32 let bsky = new BskyAgent(credentialSession); 33 let prefs = await bsky.app.bsky.actor.getPreferences(); 34 let savedFeeds = prefs.data.preferences.find( ··· 38 let hasFeed = !!savedFeeds.items.find( 39 (feed) => feed.value === leafletFeedURI, 40 ); 41 + if (hasFeed) return { success: true }; 42 43 await bsky.addSavedFeeds([ 44 { ··· 47 type: "feed", 48 }, 49 ]); 50 + return { success: true }; 51 }
+34 -12
app/lish/createPub/CreatePubForm.tsx
··· 13 import { string } from "zod"; 14 import { DotLoader } from "components/utils/DotLoader"; 15 import { Checkbox } from "components/Checkbox"; 16 17 type DomainState = 18 | { status: "empty" } ··· 32 let [domainState, setDomainState] = useState<DomainState>({ 33 status: "empty", 34 }); 35 let fileInputRef = useRef<HTMLInputElement>(null); 36 37 let router = useRouter(); ··· 43 e.preventDefault(); 44 if (!subdomainValidator.safeParse(domainValue).success) return; 45 setFormState("loading"); 46 - let data = await createPublication({ 47 name: nameValue, 48 description: descriptionValue, 49 iconFile: logoFile, 50 subdomain: domainValue, 51 preferences: { showInDiscover, showComments: true }, 52 }); 53 // Show a spinner while this is happening! Maybe a progress bar? 54 setTimeout(() => { 55 setFormState("normal"); 56 - if (data?.publication) 57 - router.push(`${getBasePublicationURL(data.publication)}/dashboard`); 58 }, 500); 59 }} 60 > ··· 139 </Checkbox> 140 <hr className="border-border-light" /> 141 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> 151 </div> 152 </form> 153 );
··· 13 import { string } from "zod"; 14 import { DotLoader } from "components/utils/DotLoader"; 15 import { Checkbox } from "components/Checkbox"; 16 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 17 18 type DomainState = 19 | { status: "empty" } ··· 33 let [domainState, setDomainState] = useState<DomainState>({ 34 status: "empty", 35 }); 36 + let [oauthError, setOauthError] = useState< 37 + import("src/atproto-oauth").OAuthSessionError | null 38 + >(null); 39 let fileInputRef = useRef<HTMLInputElement>(null); 40 41 let router = useRouter(); ··· 47 e.preventDefault(); 48 if (!subdomainValidator.safeParse(domainValue).success) return; 49 setFormState("loading"); 50 + setOauthError(null); 51 + let result = await createPublication({ 52 name: nameValue, 53 description: descriptionValue, 54 iconFile: logoFile, 55 subdomain: domainValue, 56 preferences: { showInDiscover, showComments: true }, 57 }); 58 + 59 + if (!result.success) { 60 + setFormState("normal"); 61 + if (result.error && isOAuthSessionError(result.error)) { 62 + setOauthError(result.error); 63 + } 64 + return; 65 + } 66 + 67 // Show a spinner while this is happening! Maybe a progress bar? 68 setTimeout(() => { 69 setFormState("normal"); 70 + if (result.publication) 71 + router.push(`${getBasePublicationURL(result.publication)}/dashboard`); 72 }, 500); 73 }} 74 > ··· 153 </Checkbox> 154 <hr className="border-border-light" /> 155 156 + <div className="flex flex-col gap-2"> 157 + <div className="flex w-full justify-end"> 158 + <ButtonPrimary 159 + type="submit" 160 + disabled={ 161 + !nameValue || !domainValue || domainState.status !== "valid" 162 + } 163 + > 164 + {formState === "loading" ? <DotLoader /> : "Create Publication!"} 165 + </ButtonPrimary> 166 + </div> 167 + {oauthError && ( 168 + <OAuthErrorMessage 169 + error={oauthError} 170 + className="text-right text-sm text-accent-1" 171 + /> 172 + )} 173 </div> 174 </form> 175 );
+4 -2
app/lish/createPub/UpdatePubForm.tsx
··· 20 import Link from "next/link"; 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 - import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; 24 25 export const EditPubForm = (props: { 26 backToMenuAction: () => void; ··· 86 setLoadingAction={props.setLoadingAction} 87 backToMenuAction={props.backToMenuAction} 88 state={"theme"} 89 - /> 90 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 91 <div className="flex items-center justify-between gap-2 "> 92 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
··· 20 import Link from "next/link"; 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 + import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 25 export const EditPubForm = (props: { 26 backToMenuAction: () => void; ··· 86 setLoadingAction={props.setLoadingAction} 87 backToMenuAction={props.backToMenuAction} 88 state={"theme"} 89 + > 90 + General Settings 91 + </PubSettingsHeader> 92 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 93 <div className="flex items-center justify-between gap-2 "> 94 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
+24 -5
app/lish/createPub/createPublication.ts
··· 1 "use server"; 2 import { TID } from "@atproto/common"; 3 import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 - import { createOauthClient } from "src/atproto-oauth"; 5 import { getIdentityData } from "actions/getIdentityData"; 6 import { supabaseServerClient } from "supabase/serverClient"; 7 import { Un$Typed } from "@atproto/api"; ··· 18 .min(3) 19 .max(63) 20 .regex(/^[a-z0-9-]+$/); 21 export async function createPublication({ 22 name, 23 description, ··· 30 iconFile: File | null; 31 subdomain: string; 32 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 33 - }) { 34 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 35 if (!isSubdomainValid.success) { 36 return { success: false }; 37 } 38 - const oauthClient = await createOauthClient(); 39 let identity = await getIdentityData(); 40 - if (!identity || !identity.atp_did) return; 41 42 let domain = `${subdomain}.leaflet.pub`; 43 44 - let credentialSession = await oauthClient.restore(identity.atp_did); 45 let agent = new AtpBaseClient( 46 credentialSession.fetchHandler.bind(credentialSession), 47 );
··· 1 "use server"; 2 import { TID } from "@atproto/common"; 3 import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 + import { 5 + restoreOAuthSession, 6 + OAuthSessionError, 7 + } from "src/atproto-oauth"; 8 import { getIdentityData } from "actions/getIdentityData"; 9 import { supabaseServerClient } from "supabase/serverClient"; 10 import { Un$Typed } from "@atproto/api"; ··· 21 .min(3) 22 .max(63) 23 .regex(/^[a-z0-9-]+$/); 24 + type CreatePublicationResult = 25 + | { success: true; publication: any } 26 + | { success: false; error?: OAuthSessionError }; 27 + 28 export async function createPublication({ 29 name, 30 description, ··· 37 iconFile: File | null; 38 subdomain: string; 39 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 40 + }): Promise<CreatePublicationResult> { 41 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 42 if (!isSubdomainValid.success) { 43 return { success: false }; 44 } 45 let identity = await getIdentityData(); 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 + } 56 57 let domain = `${subdomain}.leaflet.pub`; 58 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; 64 let agent = new AtpBaseClient( 65 credentialSession.fetchHandler.bind(credentialSession), 66 );
+64 -16
app/lish/createPub/updatePublication.ts
··· 5 PubLeafletPublication, 6 PubLeafletThemeColor, 7 } from "lexicons/api"; 8 - import { createOauthClient } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; 12 import { AtUri } from "@atproto/syntax"; 13 import { $Typed } from "@atproto/api"; 14 15 export async function updatePublication({ 16 uri, ··· 24 description: string; 25 iconFile: File | null; 26 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 27 - }) { 28 - const oauthClient = await createOauthClient(); 29 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) return; 31 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 33 let agent = new AtpBaseClient( 34 credentialSession.fetchHandler.bind(credentialSession), 35 ); ··· 38 .select("*") 39 .eq("uri", uri) 40 .single(); 41 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 42 let aturi = new AtUri(existingPub.uri); 43 44 let record: PubLeafletPublication.Record = { ··· 94 }: { 95 uri: string; 96 base_path: string; 97 - }) { 98 - const oauthClient = await createOauthClient(); 99 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 101 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 103 let agent = new AtpBaseClient( 104 credentialSession.fetchHandler.bind(credentialSession), 105 ); ··· 108 .select("*") 109 .eq("uri", uri) 110 .single(); 111 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 112 let aturi = new AtUri(existingPub.uri); 113 114 let record: PubLeafletPublication.Record = { ··· 149 backgroundImage?: File | null; 150 backgroundRepeat?: number | null; 151 backgroundColor: Color; 152 primary: Color; 153 pageBackground: Color; 154 showPageBackground: boolean; 155 accentBackground: Color; 156 accentText: Color; 157 }; 158 - }) { 159 - const oauthClient = await createOauthClient(); 160 let identity = await getIdentityData(); 161 - if (!identity || !identity.atp_did) return; 162 163 - let credentialSession = await oauthClient.restore(identity.atp_did); 164 let agent = new AtpBaseClient( 165 credentialSession.fetchHandler.bind(credentialSession), 166 ); ··· 169 .select("*") 170 .eq("uri", uri) 171 .single(); 172 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 173 let aturi = new AtUri(existingPub.uri); 174 175 let oldRecord = existingPub.record as PubLeafletPublication.Record; ··· 197 ...theme.backgroundColor, 198 } 199 : undefined, 200 primary: { 201 ...theme.primary, 202 },
··· 5 PubLeafletPublication, 6 PubLeafletThemeColor, 7 } from "lexicons/api"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; 12 import { AtUri } from "@atproto/syntax"; 13 import { $Typed } from "@atproto/api"; 14 + 15 + type UpdatePublicationResult = 16 + | { success: true; publication: any } 17 + | { success: false; error?: OAuthSessionError }; 18 19 export async function updatePublication({ 20 uri, ··· 28 description: string; 29 iconFile: File | null; 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 + }): Promise<UpdatePublicationResult> { 32 let identity = await getIdentityData(); 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 + } 43 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; 49 let agent = new AtpBaseClient( 50 credentialSession.fetchHandler.bind(credentialSession), 51 ); ··· 54 .select("*") 55 .eq("uri", uri) 56 .single(); 57 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 58 + return { success: false }; 59 + } 60 let aturi = new AtUri(existingPub.uri); 61 62 let record: PubLeafletPublication.Record = { ··· 112 }: { 113 uri: string; 114 base_path: string; 115 + }): Promise<UpdatePublicationResult> { 116 let identity = await getIdentityData(); 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 + } 127 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; 133 let agent = new AtpBaseClient( 134 credentialSession.fetchHandler.bind(credentialSession), 135 ); ··· 138 .select("*") 139 .eq("uri", uri) 140 .single(); 141 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 142 + return { success: false }; 143 + } 144 let aturi = new AtUri(existingPub.uri); 145 146 let record: PubLeafletPublication.Record = { ··· 181 backgroundImage?: File | null; 182 backgroundRepeat?: number | null; 183 backgroundColor: Color; 184 + pageWidth?: number; 185 primary: Color; 186 pageBackground: Color; 187 showPageBackground: boolean; 188 accentBackground: Color; 189 accentText: Color; 190 }; 191 + }): Promise<UpdatePublicationResult> { 192 let identity = await getIdentityData(); 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 + } 203 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; 209 let agent = new AtpBaseClient( 210 credentialSession.fetchHandler.bind(credentialSession), 211 ); ··· 214 .select("*") 215 .eq("uri", uri) 216 .single(); 217 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 218 + return { success: false }; 219 + } 220 let aturi = new AtUri(existingPub.uri); 221 222 let oldRecord = existingPub.record as PubLeafletPublication.Record; ··· 244 ...theme.backgroundColor, 245 } 246 : undefined, 247 + pageWidth: theme.pageWidth, 248 primary: { 249 ...theme.primary, 250 },
+40 -9
app/lish/subscribeToPublication.ts
··· 3 import { AtpBaseClient } from "lexicons/api"; 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 import { getIdentityData } from "actions/getIdentityData"; 6 - import { createOauthClient } from "src/atproto-oauth"; 7 import { TID } from "@atproto/common"; 8 import { supabaseServerClient } from "supabase/serverClient"; 9 import { revalidatePath } from "next/cache"; ··· 21 let leafletFeedURI = 22 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 23 let idResolver = new IdResolver(); 24 export async function subscribeToPublication( 25 publication: string, 26 redirectRoute?: string, 27 - ) { 28 - const oauthClient = await createOauthClient(); 29 let identity = await getIdentityData(); 30 if (!identity || !identity.atp_did) { 31 return redirect( ··· 33 ); 34 } 35 36 - let credentialSession = await oauthClient.restore(identity.atp_did); 37 let agent = new AtpBaseClient( 38 credentialSession.fetchHandler.bind(credentialSession), 39 ); ··· 90 ) as AppBskyActorDefs.SavedFeedsPrefV2; 91 revalidatePath("/lish/[did]/[publication]", "layout"); 92 return { 93 hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 94 }; 95 } 96 97 - export async function unsubscribeToPublication(publication: string) { 98 - const oauthClient = await createOauthClient(); 99 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 101 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 103 let agent = new AtpBaseClient( 104 credentialSession.fetchHandler.bind(credentialSession), 105 ); ··· 109 .eq("identity", identity.atp_did) 110 .eq("publication", publication) 111 .single(); 112 - if (!existingSubscription) return; 113 await agent.pub.leaflet.graph.subscription.delete({ 114 repo: credentialSession.did!, 115 rkey: new AtUri(existingSubscription.uri).rkey, ··· 120 .eq("identity", identity.atp_did) 121 .eq("publication", publication); 122 revalidatePath("/lish/[did]/[publication]", "layout"); 123 }
··· 3 import { AtpBaseClient } from "lexicons/api"; 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 import { getIdentityData } from "actions/getIdentityData"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 10 import { TID } from "@atproto/common"; 11 import { supabaseServerClient } from "supabase/serverClient"; 12 import { revalidatePath } from "next/cache"; ··· 24 let leafletFeedURI = 25 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 26 let idResolver = new IdResolver(); 27 + 28 + type SubscribeResult = 29 + | { success: true; hasFeed: boolean } 30 + | { success: false; error: OAuthSessionError }; 31 + 32 export async function subscribeToPublication( 33 publication: string, 34 redirectRoute?: string, 35 + ): Promise<SubscribeResult | never> { 36 let identity = await getIdentityData(); 37 if (!identity || !identity.atp_did) { 38 return redirect( ··· 40 ); 41 } 42 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; 48 let agent = new AtpBaseClient( 49 credentialSession.fetchHandler.bind(credentialSession), 50 ); ··· 101 ) as AppBskyActorDefs.SavedFeedsPrefV2; 102 revalidatePath("/lish/[did]/[publication]", "layout"); 103 return { 104 + success: true, 105 hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 106 }; 107 } 108 109 + type UnsubscribeResult = 110 + | { success: true } 111 + | { success: false; error: OAuthSessionError }; 112 + 113 + export async function unsubscribeToPublication( 114 + publication: string 115 + ): Promise<UnsubscribeResult> { 116 let identity = await getIdentityData(); 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 + } 127 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; 133 let agent = new AtpBaseClient( 134 credentialSession.fetchHandler.bind(credentialSession), 135 ); ··· 139 .eq("identity", identity.atp_did) 140 .eq("publication", publication) 141 .single(); 142 + if (!existingSubscription) return { success: true }; 143 await agent.pub.leaflet.graph.subscription.delete({ 144 repo: credentialSession.did!, 145 rkey: new AtUri(existingSubscription.uri).rkey, ··· 150 .eq("identity", identity.atp_did) 151 .eq("publication", publication); 152 revalidatePath("/lish/[did]/[publication]", "layout"); 153 + return { success: true }; 154 }
+59 -4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 3 - export const runtime = "edge"; 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 params: Promise<{ rkey: string; didOrHandle: string }>; 8 }) { 9 let params = await props.params; 10 - return getMicroLinkOgImage( 11 - `/p/${params.didOrHandle}/${params.rkey}/`, 12 - ); 13 }
··· 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"; 9 10 export const revalidate = 60; 11 12 export default async function OpenGraphImage(props: { 13 params: Promise<{ rkey: string; didOrHandle: string }>; 14 }) { 15 let params = await props.params; 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}/`); 68 }
+9 -7
app/p/[didOrHandle]/[rkey]/page.tsx
··· 5 import { Metadata } from "next"; 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 9 export async function generateMetadata(props: { 10 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 34 let docRecord = document.data as PubLeafletDocument.Record; 35 36 // For documents in publications, include publication name 37 - let publicationName = document.documents_in_publications[0]?.publications?.name; 38 39 return { 40 icons: { ··· 63 let resolved = await idResolver.handle.resolve(didOrHandle); 64 if (!resolved) { 65 return ( 66 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 67 - <p>Sorry, can&apos;t resolve handle.</p> 68 <p> 69 This may be a glitch on our end. If the issue persists please{" "} 70 <a href="mailto:contact@leaflet.pub">send us a note</a>. 71 </p> 72 - </div> 73 ); 74 } 75 did = resolved; 76 } catch (e) { 77 return ( 78 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 79 - <p>Sorry, can&apos;t resolve handle.</p> 80 <p> 81 This may be a glitch on our end. If the issue persists please{" "} 82 <a href="mailto:contact@leaflet.pub">send us a note</a>. 83 </p> 84 - </div> 85 ); 86 } 87 }
··· 5 import { Metadata } from "next"; 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 9 10 export async function generateMetadata(props: { 11 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 35 let docRecord = document.data as PubLeafletDocument.Record; 36 37 // For documents in publications, include publication name 38 + let publicationName = 39 + document.documents_in_publications[0]?.publications?.name; 40 41 return { 42 icons: { ··· 65 let resolved = await idResolver.handle.resolve(didOrHandle); 66 if (!resolved) { 67 return ( 68 + <NotFoundLayout> 69 + <p className="font-bold">Sorry, we can't find this handle!</p> 70 <p> 71 This may be a glitch on our end. If the issue persists please{" "} 72 <a href="mailto:contact@leaflet.pub">send us a note</a>. 73 </p> 74 + </NotFoundLayout> 75 ); 76 } 77 did = resolved; 78 } catch (e) { 79 return ( 80 + <NotFoundLayout> 81 + <p className="font-bold">Sorry, we can't find this leaflet!</p> 82 <p> 83 This may be a glitch on our end. If the issue persists please{" "} 84 <a href="mailto:contact@leaflet.pub">send us a note</a>. 85 </p> 86 + </NotFoundLayout> 87 ); 88 } 89 }
+8 -32
appview/index.ts
··· 20 } from "@atproto/api"; 21 import { AtUri } from "@atproto/syntax"; 22 import { writeFile, readFile } from "fs/promises"; 23 - import { createIdentity } from "actions/createIdentity"; 24 - import { drizzle } from "drizzle-orm/node-postgres"; 25 import { inngest } from "app/api/inngest/client"; 26 - import { Client } from "pg"; 27 28 const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 29 ··· 135 if (evt.event === "create" || evt.event === "update") { 136 let record = PubLeafletPublication.validateRecord(evt.record); 137 if (!record.success) return; 138 - let { error } = await supabase.from("publications").upsert({ 139 uri: evt.uri.toString(), 140 identity_did: evt.did, 141 name: record.value.name, 142 record: record.value as Json, 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 } 159 if (evt.event === "delete") { 160 await supabase ··· 222 if (evt.event === "create" || evt.event === "update") { 223 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 224 if (!record.success) return; 225 - let { error } = await supabase.from("publication_subscriptions").upsert({ 226 uri: evt.uri.toString(), 227 identity: evt.did, 228 publication: record.value.publication, 229 record: record.value as Json, 230 }); 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 } 245 if (evt.event === "delete") { 246 await supabase
··· 20 } from "@atproto/api"; 21 import { AtUri } from "@atproto/syntax"; 22 import { writeFile, readFile } from "fs/promises"; 23 import { inngest } from "app/api/inngest/client"; 24 25 const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 26 ··· 132 if (evt.event === "create" || evt.event === "update") { 133 let record = PubLeafletPublication.validateRecord(evt.record); 134 if (!record.success) return; 135 + await supabase 136 + .from("identities") 137 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 138 + await supabase.from("publications").upsert({ 139 uri: evt.uri.toString(), 140 identity_did: evt.did, 141 name: record.value.name, 142 record: record.value as Json, 143 }); 144 } 145 if (evt.event === "delete") { 146 await supabase ··· 208 if (evt.event === "create" || evt.event === "update") { 209 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 210 if (!record.success) return; 211 + await supabase 212 + .from("identities") 213 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 214 + await supabase.from("publication_subscriptions").upsert({ 215 uri: evt.uri.toString(), 216 identity: evt.did, 217 publication: record.value.publication, 218 record: record.value as Json, 219 }); 220 } 221 if (evt.event === "delete") { 222 await supabase
+2 -1
components/ActionBar/Navigation.tsx
··· 25 | "discover" 26 | "notifications" 27 | "looseleafs" 28 - | "tag"; 29 30 export const DesktopNavigation = (props: { 31 currentPage: navPages;
··· 25 | "discover" 26 | "notifications" 27 | "looseleafs" 28 + | "tag" 29 + | "profile"; 30 31 export const DesktopNavigation = (props: { 32 currentPage: navPages;
+1 -1
components/ActionBar/Publications.tsx
··· 193 194 return props.record.icon ? ( 195 <div 196 - className={`${iconSizeClassName} ${props.className} relative overflow-hidden`} 197 > 198 <img 199 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
··· 193 194 return props.record.icon ? ( 195 <div 196 + className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`} 197 > 198 <img 199 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+2 -2
components/AtMentionLink.tsx
··· 24 isPublication || isDocument ? ( 25 <img 26 src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 - className="inline-block w-5 h-5 rounded-full mr-1 align-text-top" 28 alt="" 29 width="20" 30 height="20" ··· 37 href={atUriToUrl(atURI)} 38 target="_blank" 39 rel="noopener noreferrer" 40 - className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 > 42 {icon} 43 {children}
··· 24 isPublication || isDocument ? ( 25 <img 26 src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 + className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top" 28 alt="" 29 width="20" 30 height="20" ··· 37 href={atUriToUrl(atURI)} 38 target="_blank" 39 rel="noopener noreferrer" 40 + className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 > 42 {icon} 43 {children}
+4 -1
components/Avatar.tsx
··· 3 export const Avatar = (props: { 4 src: string | undefined; 5 displayName: string | undefined; 6 tiny?: boolean; 7 }) => { 8 if (props.src) 9 return ( 10 <img 11 - className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 12 src={props.src} 13 alt={ 14 props.displayName
··· 3 export const Avatar = (props: { 4 src: string | undefined; 5 displayName: string | undefined; 6 + className?: string; 7 tiny?: boolean; 8 + large?: boolean; 9 + giant?: boolean; 10 }) => { 11 if (props.src) 12 return ( 13 <img 14 + className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 15 src={props.src} 16 alt={ 17 props.displayName
+26
components/Blocks/Block.tsx
··· 383 ); 384 }; 385 386 export const ListMarker = ( 387 props: Block & { 388 previousBlock?: Block | null;
··· 383 ); 384 }; 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 + 412 export const ListMarker = ( 413 props: Block & { 414 previousBlock?: Block | null;
+7 -5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 148 } 149 return ( 150 <div 151 - className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 152 > 153 - <div className="bskyAuthor w-full flex items-center gap-1"> 154 {record.author.avatar && ( 155 <img 156 src={record.author?.avatar} 157 alt={`${record.author?.displayName}'s avatar`} 158 - className="shink-0 w-6 h-6 rounded-full border border-border-light" 159 /> 160 )} 161 - <div className=" font-bold text-secondary"> 162 {record.author?.displayName} 163 </div> 164 <a ··· 171 </div> 172 173 <div className="flex flex-col gap-2 "> 174 - {text && <pre className="whitespace-pre-wrap">{text}</pre>} 175 {record.embeds !== undefined 176 ? record.embeds.map((embed, index) => ( 177 <BlueskyEmbed embed={embed} key={index} />
··· 148 } 149 return ( 150 <div 151 + className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 152 > 153 + <div className="bskyAuthor w-full flex items-center "> 154 {record.author.avatar && ( 155 <img 156 src={record.author?.avatar} 157 alt={`${record.author?.displayName}'s avatar`} 158 + className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 /> 160 )} 161 + <div className=" font-bold text-secondary mr-1"> 162 {record.author?.displayName} 163 </div> 164 <a ··· 171 </div> 172 173 <div className="flex flex-col gap-2 "> 174 + {text && ( 175 + <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 + )} 177 {record.embeds !== undefined 178 ? record.embeds.map((embed, index) => ( 179 <BlueskyEmbed embed={embed} key={index} />
+8 -11
components/Blocks/BlueskyPostBlock/index.tsx
··· 2 import { useEffect, useState } from "react"; 3 import { useEntity } from "src/replicache"; 4 import { useUIState } from "src/useUIState"; 5 - import { BlockProps } from "../Block"; 6 import { elementId } from "src/utils/elementId"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; ··· 56 AppBskyFeedDefs.isBlockedAuthor(post) || 57 AppBskyFeedDefs.isNotFoundPost(post): 58 return ( 59 - <div 60 - className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`} 61 - > 62 <PostNotAvailable /> 63 - </div> 64 ); 65 66 case AppBskyFeedDefs.isThreadViewPost(post): ··· 81 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 82 83 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 - `} 89 > 90 {post.post.author && record && ( 91 <> ··· 149 </a> 150 </div> 151 </div> 152 - </div> 153 ); 154 } 155 };
··· 2 import { useEffect, useState } from "react"; 3 import { useEntity } from "src/replicache"; 4 import { useUIState } from "src/useUIState"; 5 + import { BlockProps, BlockLayout } from "../Block"; 6 import { elementId } from "src/utils/elementId"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; ··· 56 AppBskyFeedDefs.isBlockedAuthor(post) || 57 AppBskyFeedDefs.isNotFoundPost(post): 58 return ( 59 + <BlockLayout isSelected={!!isSelected} className="w-full"> 60 <PostNotAvailable /> 61 + </BlockLayout> 62 ); 63 64 case AppBskyFeedDefs.isThreadViewPost(post): ··· 79 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 80 81 return ( 82 + <BlockLayout 83 + isSelected={!!isSelected} 84 + hasBackground="page" 85 + className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 86 > 87 {post.post.author && record && ( 88 <> ··· 146 </a> 147 </div> 148 </div> 149 + </BlockLayout> 150 ); 151 } 152 };
+103 -103
components/Blocks/ButtonBlock.tsx
··· 3 import { useCallback, useEffect, useState } from "react"; 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 7 import { v7 } from "uuid"; 8 import { useSmoker } from "components/Toast"; 9 ··· 106 }; 107 108 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full"> 110 <ButtonPrimary className="mx-auto"> 111 {text !== "" ? text : "Button"} 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 - }} 162 > 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 - /> 205 </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> 216 </div> 217 ); 218 };
··· 3 import { useCallback, useEffect, useState } from "react"; 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 import { v7 } from "uuid"; 8 import { useSmoker } from "components/Toast"; 9 ··· 106 }; 107 108 return ( 109 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 <ButtonPrimary className="mx-auto"> 111 {text !== "" ? text : "Button"} 112 </ButtonPrimary> 113 + <BlockLayout 114 + isSelected={!!isSelected} 115 + borderOnHover 116 + hasBackground="accent" 117 + className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!" 118 > 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> 213 </div> 214 + </form> 215 + </BlockLayout> 216 </div> 217 ); 218 };
+17 -6
components/Blocks/CodeBlock.tsx
··· 6 } from "shiki"; 7 import { useEntity, useReplicache } from "src/replicache"; 8 import "katex/dist/katex.min.css"; 9 - import { BlockProps } from "./Block"; 10 import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 import { useUIState } from "src/useUIState"; 12 import { BaseTextareaBlock } from "./BaseTextareaBlock"; ··· 119 </select> 120 </div> 121 )} 122 - <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 123 {focusedBlock && permissions.write ? ( 124 <BaseTextareaBlock 125 data-editable-block 126 data-entityid={props.entityID} 127 id={elementId.block(props.entityID).input} ··· 131 spellCheck={false} 132 autoCapitalize="none" 133 autoCorrect="off" 134 - className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 135 value={content?.data.value} 136 onChange={async (e) => { 137 // Update the entity with the new value ··· 146 <pre 147 onClick={onClick} 148 onMouseDown={(e) => e.stopPropagation()} 149 - className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full" 150 > 151 - {content?.data.value} 152 </pre> 153 ) : ( 154 <div ··· 159 dangerouslySetInnerHTML={{ __html: html || "" }} 160 /> 161 )} 162 - </div> 163 </div> 164 ); 165 }
··· 6 } from "shiki"; 7 import { useEntity, useReplicache } from "src/replicache"; 8 import "katex/dist/katex.min.css"; 9 + import { BlockLayout, BlockProps } from "./Block"; 10 import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 import { useUIState } from "src/useUIState"; 12 import { BaseTextareaBlock } from "./BaseTextareaBlock"; ··· 119 </select> 120 </div> 121 )} 122 + 123 + <BlockLayout 124 + isSelected={focusedBlock} 125 + hasBackground="accent" 126 + borderOnHover 127 + className="p-0! min-h-[48px]" 128 + > 129 {focusedBlock && permissions.write ? ( 130 <BaseTextareaBlock 131 + placeholder="write some codeโ€ฆ" 132 data-editable-block 133 data-entityid={props.entityID} 134 id={elementId.block(props.entityID).input} ··· 138 spellCheck={false} 139 autoCapitalize="none" 140 autoCorrect="off" 141 + className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3" 142 value={content?.data.value} 143 onChange={async (e) => { 144 // Update the entity with the new value ··· 153 <pre 154 onClick={onClick} 155 onMouseDown={(e) => e.stopPropagation()} 156 + className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full" 157 > 158 + {content?.data.value === "" || content?.data.value === undefined ? ( 159 + <div className="text-tertiary italic">write some codeโ€ฆ</div> 160 + ) : ( 161 + content?.data.value 162 + )} 163 </pre> 164 ) : ( 165 <div ··· 170 dangerouslySetInnerHTML={{ __html: html || "" }} 171 /> 172 )} 173 + </BlockLayout> 174 </div> 175 ); 176 }
+5 -5
components/Blocks/DateTimeBlock.tsx
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { BlockProps } from "./Block"; 3 import { ChevronProps, DayPicker } from "react-day-picker"; 4 import { Popover } from "components/Popover"; 5 import { useEffect, useMemo, useState } from "react"; ··· 121 disabled={isLocked || !permissions.write} 122 className="w-64 z-10 px-2!" 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"} 127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"} 128 `} 129 > ··· 163 </div> 164 )} 165 </FadeIn> 166 - </div> 167 } 168 > 169 <div className="flex flex-col gap-3 ">
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 + import { BlockProps, BlockLayout } from "./Block"; 3 import { ChevronProps, DayPicker } from "react-day-picker"; 4 import { Popover } from "components/Popover"; 5 import { useEffect, useMemo, useState } from "react"; ··· 121 disabled={isLocked || !permissions.write} 122 className="w-64 z-10 px-2!" 123 trigger={ 124 + <BlockLayout 125 + isSelected={!!isSelected} 126 + className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent! 127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"} 128 `} 129 > ··· 163 </div> 164 )} 165 </FadeIn> 166 + </BlockLayout> 167 } 168 > 169 <div className="flex flex-col gap-3 ">
+13 -16
components/Blocks/EmbedBlock.tsx
··· 3 import { useCallback, useEffect, useState } from "react"; 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 7 import { v7 } from "uuid"; 8 import { useSmoker } from "components/Toast"; 9 import { Separator } from "components/Layout"; ··· 84 <div 85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`} 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> 102 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast"> 103 <a 104 href={url?.data.value}
··· 3 import { useCallback, useEffect, useState } from "react"; 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 import { v7 } from "uuid"; 8 import { useSmoker } from "components/Toast"; 9 import { Separator } from "components/Layout"; ··· 84 <div 85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`} 86 > 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> 99 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast"> 100 <a 101 href={url?.data.value}
+43 -42
components/Blocks/ExternalLinkBlock.tsx
··· 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 import { addLinkBlock } from "src/utils/addLinkBlock"; 7 - import { BlockProps } from "./Block"; 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; ··· 64 } 65 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 - `} 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 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> 102 </div> 103 - </div> 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> 113 ); 114 }; 115
··· 4 import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 import { addLinkBlock } from "src/utils/addLinkBlock"; 7 + import { BlockProps, BlockLayout } from "./Block"; 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; ··· 64 } 65 66 return ( 67 + <BlockLayout 68 + isSelected={!!isSelected} 69 + hasBackground="page" 70 + borderOnHover 71 + className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!" 72 > 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 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> 102 </div> 103 </div> 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> 113 + </BlockLayout> 114 ); 115 }; 116
+68 -24
components/Blocks/ImageBlock.tsx
··· 1 "use client"; 2 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { BlockProps } from "./Block"; 5 import { useUIState } from "src/useUIState"; 6 import Image from "next/image"; 7 import { v7 } from "uuid"; ··· 17 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18 import { set } from "colorjs.io/fn"; 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 21 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 22 let { rep } = useReplicache(); ··· 61 factID: v7(), 62 permission_set: entity_set.set, 63 type: "text", 64 - position: generateKeyBetween( 65 - props.position, 66 - props.nextPosition, 67 - ), 68 newEntityID: entity, 69 }); 70 } ··· 82 if (!image) { 83 if (!entity_set.permissions.write) return null; 84 return ( 85 - <div className="grow w-full"> 86 <label 87 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 91 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 ${props.pageType === "canvas" && "bg-bg-page"}`} 95 onMouseDown={(e) => e.preventDefault()} 96 onDragOver={(e) => { ··· 104 const files = e.dataTransfer.files; 105 if (files && files.length > 0) { 106 const file = files[0]; 107 - if (file.type.startsWith('image/')) { 108 await handleImageUpload(file); 109 } 110 } ··· 128 }} 129 /> 130 </label> 131 - </div> 132 ); 133 } 134 135 - let className = isFullBleed 136 ? "" 137 : isSelected 138 ? "block-border-selected border-transparent! " ··· 140 141 let isLocalUpload = localImages.get(image.data.src); 142 143 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} 152 {isLocalUpload || image.data.local ? ( 153 <img 154 loading="lazy" ··· 166 } 167 height={image?.data.height} 168 width={image?.data.width} 169 - className={className} 170 /> 171 )} 172 {altText !== undefined && !props.preview ? ( 173 <ImageAlt entityID={props.value} /> 174 ) : null} 175 - </div> 176 ); 177 } 178 ··· 188 altEditorOpen: false, 189 setAltEditorOpen: (s: boolean) => {}, 190 }); 191 192 const ImageAlt = (props: { entityID: string }) => { 193 let { rep } = useReplicache();
··· 1 "use client"; 2 3 import { useEntity, useReplicache } from "src/replicache"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 import { useUIState } from "src/useUIState"; 6 import Image from "next/image"; 7 import { v7 } from "uuid"; ··· 17 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18 import { set } from "colorjs.io/fn"; 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"; 23 24 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 let { rep } = useReplicache(); ··· 64 factID: v7(), 65 permission_set: entity_set.set, 66 type: "text", 67 + position: generateKeyBetween(props.position, props.nextPosition), 68 newEntityID: entity, 69 }); 70 } ··· 82 if (!image) { 83 if (!entity_set.permissions.write) return null; 84 return ( 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 + > 91 <label 92 className={` 93 + 94 + w-full h-full hover:cursor-pointer 95 flex flex-col items-center justify-center 96 ${props.pageType === "canvas" && "bg-bg-page"}`} 97 onMouseDown={(e) => e.preventDefault()} 98 onDragOver={(e) => { ··· 106 const files = e.dataTransfer.files; 107 if (files && files.length > 0) { 108 const file = files[0]; 109 + if (file.type.startsWith("image/")) { 110 await handleImageUpload(file); 111 } 112 } ··· 130 }} 131 /> 132 </label> 133 + </BlockLayout> 134 ); 135 } 136 137 + let imageClassName = isFullBleed 138 ? "" 139 : isSelected 140 ? "block-border-selected border-transparent! " ··· 142 143 let isLocalUpload = localImages.get(image.data.src); 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 + 152 return ( 153 + <BlockLayout isSelected={!!isSelected} className={blockClassName}> 154 {isLocalUpload || image.data.local ? ( 155 <img 156 loading="lazy" ··· 168 } 169 height={image?.data.height} 170 width={image?.data.width} 171 + className={imageClassName} 172 /> 173 )} 174 {altText !== undefined && !props.preview ? ( 175 <ImageAlt entityID={props.value} /> 176 ) : null} 177 + {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 178 + </BlockLayout> 179 ); 180 } 181 ··· 191 altEditorOpen: false, 192 setAltEditorOpen: (s: boolean) => {}, 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 + }; 235 236 const ImageAlt = (props: { entityID: string }) => { 237 let { rep } = useReplicache();
+80 -94
components/Blocks/MailboxBlock.tsx
··· 1 import { ButtonPrimary } from "components/Buttons"; 2 import { Popover } from "components/Popover"; 3 - import { Menu, MenuItem, Separator } from "components/Layout"; 4 import { useUIState } from "src/useUIState"; 5 import { useState } from "react"; 6 import { useSmoker, useToaster } from "components/Toast"; 7 - import { BlockProps } from "./Block"; 8 import { useEntity, useReplicache } from "src/replicache"; 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; ··· 45 46 return ( 47 <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 - }} 55 > 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> 82 <div className="flex gap-3 items-center justify-between"> 83 { 84 <> ··· 134 let { rep } = useReplicache(); 135 return ( 136 <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 - }} 147 > 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 - }); 191 }} 192 > 193 - unsubscribe 194 - </button> 195 - </div> 196 </div> 197 - )} 198 - </div> 199 - </div> 200 </div> 201 ); 202 };
··· 1 import { ButtonPrimary } from "components/Buttons"; 2 import { Popover } from "components/Popover"; 3 + import { MenuItem } from "components/Menu"; 4 + import { Separator } from "components/Layout"; 5 import { useUIState } from "src/useUIState"; 6 import { useState } from "react"; 7 import { useSmoker, useToaster } from "components/Toast"; 8 + import { BlockProps, BlockLayout } from "./Block"; 9 import { useEntity, useReplicache } from "src/replicache"; 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; ··· 46 47 return ( 48 <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 49 + <BlockLayout 50 + isSelected={!!isSelected} 51 + hasBackground={"accent"} 52 + className="flex gap-2 items-center justify-center" 53 > 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> 78 <div className="flex gap-3 items-center justify-between"> 79 { 80 <> ··· 130 let { rep } = useReplicache(); 131 return ( 132 <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}> 133 + <BlockLayout 134 + isSelected={!!isSelected} 135 + hasBackground={"accent"} 136 + className="`h-full flex flex-col gap-2 items-center justify-center" 137 > 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 + } 162 }} 163 > 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> 182 </div> 183 + </div> 184 + )} 185 + </BlockLayout> 186 </div> 187 ); 188 };
+33 -23
components/Blocks/MathBlock.tsx
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 import "katex/dist/katex.min.css"; 3 - import { BlockProps } from "./Block"; 4 import Katex from "katex"; 5 import { useMemo } from "react"; 6 import { useUIState } from "src/useUIState"; ··· 32 } 33 }, [content?.data.value]); 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 - /> 53 ) : html && content?.data.value ? ( 54 <div 55 - className="text-lg min-h-[66px] w-full border border-transparent" 56 dangerouslySetInnerHTML={{ __html: html }} 57 /> 58 ) : ( 59 - <div className="text-tertiary italic rounded-md p-2 w-full min-h-16"> 60 - write some Tex here... 61 - </div> 62 ); 63 }
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 import "katex/dist/katex.min.css"; 3 + import { BlockLayout, BlockProps } from "./Block"; 4 import Katex from "katex"; 5 import { useMemo } from "react"; 6 import { useUIState } from "src/useUIState"; ··· 32 } 33 }, [content?.data.value]); 34 return focusedBlock ? ( 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> 59 ) : html && content?.data.value ? ( 60 <div 61 + className="text-lg min-h-[48px] w-full border border-transparent" 62 dangerouslySetInnerHTML={{ __html: html }} 63 /> 64 ) : ( 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> 72 ); 73 }
+26 -22
components/Blocks/PageLinkBlock.tsx
··· 1 "use client"; 2 - import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 import { focusPage } from "src/utils/focusPage"; ··· 29 30 return ( 31 <CardThemeProvider entityID={page?.data.value}> 32 - <div 33 - className={`w-full cursor-pointer 34 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!"} 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 > 50 - {type === "canvas" && page ? ( 51 - <CanvasLinkBlock entityID={page?.data.value} /> 52 - ) : ( 53 - <DocLinkBlock {...props} /> 54 - )} 55 - </div> 56 </CardThemeProvider> 57 ); 58 }
··· 1 "use client"; 2 + import { BlockProps, ListMarker, Block, BlockLayout } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 import { focusPage } from "src/utils/focusPage"; ··· 29 30 return ( 31 <CardThemeProvider entityID={page?.data.value}> 32 + <BlockLayout 33 + hasBackground="page" 34 + isSelected={!!isSelected} 35 + className={`cursor-pointer 36 pageLinkBlockWrapper relative group/pageLinkBlock 37 + flex overflow-clip p-0! 38 + ${isOpen && "border-accent-contrast! outline-accent-contrast!"} 39 `} 40 > 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> 60 </CardThemeProvider> 61 ); 62 }
+7 -10
components/Blocks/PollBlock/index.tsx
··· 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "../Block"; 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 import { useCallback, useEffect, useState } from "react"; 5 import { Input } from "components/Input"; ··· 61 let totalVotes = votes.length; 62 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 - }} 71 > 72 {pollState === "editing" ? ( 73 <EditPoll ··· 95 hasVoted={!!hasVoted} 96 /> 97 )} 98 - </div> 99 ); 100 }; 101 ··· 486 }) => { 487 return ( 488 <button 489 - className="text-sm text-accent-contrast sm:hover:underline" 490 onClick={() => { 491 props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 }}
··· 1 import { useUIState } from "src/useUIState"; 2 + import { BlockProps, BlockLayout } from "../Block"; 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 import { useCallback, useEffect, useState } from "react"; 5 import { Input } from "components/Input"; ··· 61 let totalVotes = votes.length; 62 63 return ( 64 + <BlockLayout 65 + isSelected={!!isSelected} 66 + hasBackground={"accent"} 67 + className="poll flex flex-col gap-2 w-full" 68 > 69 {pollState === "editing" ? ( 70 <EditPoll ··· 92 hasVoted={!!hasVoted} 93 /> 94 )} 95 + </BlockLayout> 96 ); 97 }; 98 ··· 483 }) => { 484 return ( 485 <button 486 + className="text-sm text-accent-contrast " 487 onClick={() => { 488 props.setPollState(props.pollState === "voting" ? "results" : "voting"); 489 }}
+6 -9
components/Blocks/PublicationPollBlock.tsx
··· 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 3 import { useMemo } from "react"; 4 import { AsyncValueInput } from "components/Input"; 5 import { focusElement } from "src/utils/focusElement"; ··· 53 }, [publicationData, props.entityID]); 54 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 - }} 63 > 64 <EditPollForPublication 65 entityID={props.entityID} 66 isPublished={isPublished} 67 /> 68 - </div> 69 ); 70 }; 71
··· 1 import { useUIState } from "src/useUIState"; 2 + import { BlockLayout, BlockProps } from "./Block"; 3 import { useMemo } from "react"; 4 import { AsyncValueInput } from "components/Input"; 5 import { focusElement } from "src/utils/focusElement"; ··· 53 }, [publicationData, props.entityID]); 54 55 return ( 56 + <BlockLayout 57 + className="poll flex flex-col gap-2" 58 + hasBackground={"accent"} 59 + isSelected={!!isSelected} 60 > 61 <EditPollForPublication 62 entityID={props.entityID} 63 isPublished={isPublished} 64 /> 65 + </BlockLayout> 66 ); 67 }; 68
+6 -8
components/Blocks/RSVPBlock/index.tsx
··· 1 "use client"; 2 import { Database } from "supabase/database.types"; 3 - import { BlockProps } from "components/Blocks/Block"; 4 import { useState } from "react"; 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 import { useRSVPData } from "components/PageSWRDataProvider"; ··· 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 ); 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 - }} 38 > 39 <RSVPForm entityID={props.entityID} /> 40 - </div> 41 ); 42 } 43
··· 1 "use client"; 2 import { Database } from "supabase/database.types"; 3 + import { BlockProps, BlockLayout } from "components/Blocks/Block"; 4 import { useState } from "react"; 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 import { useRSVPData } from "components/PageSWRDataProvider"; ··· 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 ); 31 return ( 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" 36 > 37 <RSVPForm entityID={props.entityID} /> 38 + </BlockLayout> 39 ); 40 } 41
+14 -5
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 6 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 import { AtMentionLink } from "components/AtMentionLink"; 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 11 export function RenderYJSFragment({ ··· 63 ); 64 } 65 66 - if (node.constructor === XmlElement && node.nodeName === "hard_break") { 67 return <br key={index} />; 68 } 69 70 // Handle didMention inline nodes 71 - if (node.constructor === XmlElement && node.nodeName === "didMention") { 72 const did = node.getAttribute("did") || ""; 73 const text = node.getAttribute("text") || ""; 74 return ( ··· 77 target="_blank" 78 rel="noopener noreferrer" 79 key={index} 80 - className="text-accent-contrast hover:underline cursor-pointer" 81 > 82 {text} 83 </a> ··· 85 } 86 87 // Handle atMention inline nodes 88 - if (node.constructor === XmlElement && node.nodeName === "atMention") { 89 const atURI = node.getAttribute("atURI") || ""; 90 const text = node.getAttribute("text") || ""; 91 return ( ··· 161 162 return props; 163 } 164 -
··· 6 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 import { AtMentionLink } from "components/AtMentionLink"; 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 + import { ProfilePopover } from "components/ProfilePopover"; 10 11 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 12 export function RenderYJSFragment({ ··· 64 ); 65 } 66 67 + if ( 68 + node.constructor === XmlElement && 69 + node.nodeName === "hard_break" 70 + ) { 71 return <br key={index} />; 72 } 73 74 // Handle didMention inline nodes 75 + if ( 76 + node.constructor === XmlElement && 77 + node.nodeName === "didMention" 78 + ) { 79 const did = node.getAttribute("did") || ""; 80 const text = node.getAttribute("text") || ""; 81 return ( ··· 84 target="_blank" 85 rel="noopener noreferrer" 86 key={index} 87 + className="mention" 88 > 89 {text} 90 </a> ··· 92 } 93 94 // Handle atMention inline nodes 95 + if ( 96 + node.constructor === XmlElement && 97 + node.nodeName === "atMention" 98 + ) { 99 const atURI = node.getAttribute("atURI") || ""; 100 const text = node.getAttribute("text") || ""; 101 return ( ··· 171 172 return props; 173 }
+4 -3
components/Blocks/TextBlock/schema.ts
··· 147 toDOM(node) { 148 // NOTE: This rendering should match the AtMentionLink component in 149 // components/AtMentionLink.tsx. If you update one, update the other. 150 - let className = "atMention text-accent-contrast"; 151 let aturi = new AtUri(node.attrs.atURI); 152 if (aturi.collection === "pub.leaflet.publication") 153 className += " font-bold"; ··· 168 "img", 169 { 170 src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 - class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", 172 alt: "", 173 width: "16", 174 height: "16", ··· 214 return [ 215 "span", 216 { 217 - class: "didMention text-accent-contrast", 218 "data-did": node.attrs.did, 219 }, 220 node.attrs.text,
··· 147 toDOM(node) { 148 // NOTE: This rendering should match the AtMentionLink component in 149 // components/AtMentionLink.tsx. If you update one, update the other. 150 + let className = "atMention mention"; 151 let aturi = new AtUri(node.attrs.atURI); 152 if (aturi.collection === "pub.leaflet.publication") 153 className += " font-bold"; ··· 168 "img", 169 { 170 src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 + class: 172 + "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", 173 alt: "", 174 width: "16", 175 height: "16", ··· 215 return [ 216 "span", 217 { 218 + class: "didMention mention", 219 "data-did": node.attrs.did, 220 }, 221 node.attrs.text,
+11 -5
components/Buttons.tsx
··· 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 bg-accent-1 disabled:bg-border-light 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 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 flex gap-2 items-center justify-center shrink-0 44 ${className} ··· 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 bg-bg-page disabled:bg-border-light 79 border border-accent-contrast rounded-md 80 - outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 flex gap-2 items-center justify-center shrink-0 83 ${props.className} ··· 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 bg-transparent hover:bg-[var(--accent-light)] 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 120 text-base font-bold text-accent-contrast disabled:text-border 121 flex gap-2 items-center justify-center shrink-0 122 ${props.className} ··· 165 side={props.side ? props.side : undefined} 166 sideOffset={6} 167 alignOffset={12} 168 - className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 169 > 170 {props.tooltipContent} 171 <RadixTooltip.Arrow ··· 175 viewBox="0 0 16 8" 176 > 177 <PopoverArrow 178 - arrowFill={theme.colors["border"]} 179 arrowStroke="transparent" 180 /> 181 </RadixTooltip.Arrow>
··· 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 bg-accent-1 disabled:bg-border-light 40 border border-accent-1 rounded-md disabled:border-border-light 41 + outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 flex gap-2 items-center justify-center shrink-0 44 ${className} ··· 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 bg-bg-page disabled:bg-border-light 79 border border-accent-contrast rounded-md 80 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 flex gap-2 items-center justify-center shrink-0 83 ${props.className} ··· 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 bg-transparent hover:bg-[var(--accent-light)] 118 border border-transparent rounded-md hover:border-[var(--accent-light)] 119 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 text-base font-bold text-accent-contrast disabled:text-border 121 flex gap-2 items-center justify-center shrink-0 122 ${props.className} ··· 165 side={props.side ? props.side : undefined} 166 sideOffset={6} 167 alignOffset={12} 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 + }} 173 > 174 {props.tooltipContent} 175 <RadixTooltip.Arrow ··· 179 viewBox="0 0 16 8" 180 > 181 <PopoverArrow 182 + arrowFill={ 183 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)" 184 + } 185 arrowStroke="transparent" 186 /> 187 </RadixTooltip.Arrow>
+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 + );
+1
components/Icons/ReplyTiny.tsx
··· 8 viewBox="0 0 16 16" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 > 12 <path 13 fillRule="evenodd"
··· 8 viewBox="0 0 16 16" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 > 13 <path 14 fillRule="evenodd"
+3 -3
components/InteractionsPreview.tsx
··· 40 <SpeedyLink 41 aria-label="Post quotes" 42 href={`${props.postUrl}?interactionDrawer=quotes`} 43 - className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 44 > 45 <QuoteTiny /> {props.quotesCount} 46 </SpeedyLink> ··· 49 <SpeedyLink 50 aria-label="Post comments" 51 href={`${props.postUrl}?interactionDrawer=comments`} 52 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 > 54 <CommentTiny /> {props.commentsCount} 55 </SpeedyLink> ··· 93 <Popover 94 className="p-2! max-w-xs" 95 trigger={ 96 - <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 97 <TagTiny /> {props.tags.length} 98 </div> 99 }
··· 40 <SpeedyLink 41 aria-label="Post quotes" 42 href={`${props.postUrl}?interactionDrawer=quotes`} 43 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 44 > 45 <QuoteTiny /> {props.quotesCount} 46 </SpeedyLink> ··· 49 <SpeedyLink 50 aria-label="Post comments" 51 href={`${props.postUrl}?interactionDrawer=comments`} 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 > 54 <CommentTiny /> {props.commentsCount} 55 </SpeedyLink> ··· 93 <Popover 94 className="p-2! max-w-xs" 95 trigger={ 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast"> 97 <TagTiny /> {props.tags.length} 98 </div> 99 }
-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 export const Separator = (props: { classname?: string }) => { 10 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 }; 98 99 export const ShortcutKey = (props: { children: React.ReactNode }) => {
··· 1 export const Separator = (props: { classname?: string }) => { 2 return <div className={`h-full border-r border-border ${props.classname}`} />; 3 }; 4 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 + }
+7 -8
components/PageHeader.tsx
··· 1 "use client"; 2 import { useState, useEffect } from "react"; 3 4 - export const Header = (props: { 5 - children: React.ReactNode; 6 - cardBorderHidden: boolean; 7 - }) => { 8 let [scrollPos, setScrollPos] = useState(0); 9 10 useEffect(() => { ··· 22 } 23 }, []); 24 25 - let headerBGColor = props.cardBorderHidden 26 ? "var(--bg-leaflet)" 27 : "var(--bg-page)"; 28 ··· 54 style={ 55 scrollPos < 20 56 ? { 57 - backgroundColor: props.cardBorderHidden 58 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 - paddingLeft: props.cardBorderHidden 61 ? "4px" 62 : `calc(${scrollPos / 20}*4px)`, 63 - paddingRight: props.cardBorderHidden 64 ? "8px" 65 : `calc(${scrollPos / 20}*8px)`, 66 }
··· 1 "use client"; 2 import { useState, useEffect } from "react"; 3 + import { useCardBorderHidden } from "./Pages/useCardBorderHidden"; 4 5 + export const Header = (props: { children: React.ReactNode }) => { 6 + let cardBorderHidden = useCardBorderHidden(); 7 let [scrollPos, setScrollPos] = useState(0); 8 9 useEffect(() => { ··· 21 } 22 }, []); 23 24 + let headerBGColor = !cardBorderHidden 25 ? "var(--bg-leaflet)" 26 : "var(--bg-page)"; 27 ··· 53 style={ 54 scrollPos < 20 55 ? { 56 + backgroundColor: !cardBorderHidden 57 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 58 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 59 + paddingLeft: !cardBorderHidden 60 ? "4px" 61 : `calc(${scrollPos / 20}*4px)`, 62 + paddingRight: !cardBorderHidden 63 ? "8px" 64 : `calc(${scrollPos / 20}*8px)`, 65 }
+4 -24
components/PageLayouts/DashboardLayout.tsx
··· 25 import Link from "next/link"; 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 29 export type DashboardState = { 30 display?: "grid" | "list"; ··· 133 }, 134 >(props: { 135 id: string; 136 - cardBorderHidden: boolean; 137 tabs: T; 138 defaultTab: keyof T; 139 currentPage: navPages; ··· 180 </div> 181 </MediaContents> 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 `} 184 ref={ref} 185 id="home-content" 186 > 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 <> 189 - <Header cardBorderHidden={props.cardBorderHidden}> 190 {headerState === "default" ? ( 191 <> 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 ); 356 }; 357 358 - function Tab(props: { 359 - name: string; 360 - selected: boolean; 361 - onSelect: () => void; 362 - href?: string; 363 - }) { 364 - return ( 365 - <div 366 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 367 - onClick={() => props.onSelect()} 368 - > 369 - {props.name} 370 - {props.href && <ExternalLinkTiny />} 371 - </div> 372 - ); 373 - } 374 - 375 - const FilterOptions = (props: { 376 - hasPubs: boolean; 377 - hasArchived: boolean; 378 - }) => { 379 let { filter } = useDashboardState(); 380 let setState = useSetDashboardState(); 381 let filterCount = Object.values(filter).filter(Boolean).length;
··· 25 import Link from "next/link"; 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 + import { Tab } from "components/Tab"; 29 30 export type DashboardState = { 31 display?: "grid" | "list"; ··· 134 }, 135 >(props: { 136 id: string; 137 tabs: T; 138 defaultTab: keyof T; 139 currentPage: navPages; ··· 180 </div> 181 </MediaContents> 182 <div 183 + className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `} 184 ref={ref} 185 id="home-content" 186 > 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 <> 189 + <Header> 190 {headerState === "default" ? ( 191 <> 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 ); 356 }; 357 358 + const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 359 let { filter } = useDashboardState(); 360 let setState = useSetDashboardState(); 361 let filterCount = Object.values(filter).filter(Boolean).length;
+3 -5
components/Pages/Page.tsx
··· 34 return focusedPageID === props.entityID; 35 }); 36 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 - let cardBorderHidden = useCardBorderHidden(props.entityID); 38 39 let drawerOpen = useDrawerOpen(props.entityID); 40 return ( ··· 49 }} 50 id={elementId.page(props.entityID).container} 51 drawerOpen={!!drawerOpen} 52 - cardBorderHidden={!!cardBorderHidden} 53 isFocused={isFocused} 54 fullPageScroll={props.fullPageScroll} 55 pageType={pageType} ··· 77 id: string; 78 children: React.ReactNode; 79 pageOptions?: React.ReactNode; 80 - cardBorderHidden: boolean; 81 fullPageScroll: boolean; 82 isFocused?: boolean; 83 onClickAction?: (e: React.MouseEvent) => void; 84 pageType: "canvas" | "doc"; 85 drawerOpen: boolean | undefined; 86 }) => { 87 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 88 return ( 89 // this div wraps the contents AND the page options. ··· 106 shrink-0 snap-center 107 overflow-y-scroll 108 ${ 109 - !props.cardBorderHidden && 110 `h-full border 111 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 112 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 113 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 114 } 115 - ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 116 ${props.fullPageScroll && "max-w-full "} 117 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 118 ${
··· 34 return focusedPageID === props.entityID; 35 }); 36 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 38 let drawerOpen = useDrawerOpen(props.entityID); 39 return ( ··· 48 }} 49 id={elementId.page(props.entityID).container} 50 drawerOpen={!!drawerOpen} 51 isFocused={isFocused} 52 fullPageScroll={props.fullPageScroll} 53 pageType={pageType} ··· 75 id: string; 76 children: React.ReactNode; 77 pageOptions?: React.ReactNode; 78 fullPageScroll: boolean; 79 isFocused?: boolean; 80 onClickAction?: (e: React.MouseEvent) => void; 81 pageType: "canvas" | "doc"; 82 drawerOpen: boolean | undefined; 83 }) => { 84 + const cardBorderHidden = useCardBorderHidden(); 85 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 86 return ( 87 // this div wraps the contents AND the page options. ··· 104 shrink-0 snap-center 105 overflow-y-scroll 106 ${ 107 + !cardBorderHidden && 108 `h-full border 109 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 110 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 111 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 112 } 113 + ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 114 ${props.fullPageScroll && "max-w-full "} 115 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 116 ${
+9 -31
components/Pages/PageOptions.tsx
··· 7 import { useReplicache } from "src/replicache"; 8 9 import { Media } from "../Media"; 10 - import { MenuItem, Menu } from "../Layout"; 11 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 import { PageShareMenu } from "./PageShareMenu"; 13 import { useUndoState } from "src/undoManager"; ··· 21 export const PageOptionButton = ({ 22 children, 23 secondary, 24 - cardBorderHidden, 25 className, 26 disabled, 27 ...props 28 }: { 29 children: React.ReactNode; 30 secondary?: boolean; 31 - cardBorderHidden: boolean | undefined; 32 className?: string; 33 disabled?: boolean; 34 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 35 return ( 36 <button 37 className={` ··· 58 first: boolean | undefined; 59 isFocused: boolean; 60 }) => { 61 - let cardBorderHidden = useCardBorderHidden(props.entityID); 62 - 63 return ( 64 <div 65 className={`pageOptions w-fit z-10 66 ${props.isFocused ? "block" : "sm:hidden block"} 67 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 68 flex sm:flex-col flex-row-reverse gap-1 items-start`} 69 > 70 {!props.first && ( 71 <PageOptionButton 72 - cardBorderHidden={cardBorderHidden} 73 secondary 74 onClick={() => { 75 useUIState.getState().closePage(props.entityID); ··· 78 <CloseTiny /> 79 </PageOptionButton> 80 )} 81 - <OptionsMenu 82 - entityID={props.entityID} 83 - first={!!props.first} 84 - cardBorderHidden={cardBorderHidden} 85 - /> 86 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 87 </div> 88 ); 89 }; 90 91 - export const UndoButtons = (props: { 92 - cardBorderHidden: boolean | undefined; 93 - }) => { 94 let undoState = useUndoState(); 95 let { undoManager } = useReplicache(); 96 return ( 97 <Media mobile> 98 {undoState.canUndo && ( 99 <div className="gap-1 flex sm:flex-col"> 100 - <PageOptionButton 101 - secondary 102 - cardBorderHidden={props.cardBorderHidden} 103 - onClick={() => undoManager.undo()} 104 - > 105 <UndoTiny /> 106 </PageOptionButton> 107 108 <PageOptionButton 109 secondary 110 - cardBorderHidden={props.cardBorderHidden} 111 onClick={() => undoManager.undo()} 112 disabled={!undoState.canRedo} 113 > ··· 119 ); 120 }; 121 122 - export const OptionsMenu = (props: { 123 - entityID: string; 124 - first: boolean; 125 - cardBorderHidden: boolean | undefined; 126 - }) => { 127 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 let { permissions } = useEntitySetContext(); 129 if (!permissions.write) return null; ··· 138 if (!open) setState("normal"); 139 }} 140 trigger={ 141 - <PageOptionButton 142 - cardBorderHidden={props.cardBorderHidden} 143 - className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 - > 145 <MoreOptionsTiny className="sm:rotate-90" /> 146 </PageOptionButton> 147 }
··· 7 import { useReplicache } from "src/replicache"; 8 9 import { Media } from "../Media"; 10 + import { MenuItem, Menu } from "../Menu"; 11 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 import { PageShareMenu } from "./PageShareMenu"; 13 import { useUndoState } from "src/undoManager"; ··· 21 export const PageOptionButton = ({ 22 children, 23 secondary, 24 className, 25 disabled, 26 ...props 27 }: { 28 children: React.ReactNode; 29 secondary?: boolean; 30 className?: string; 31 disabled?: boolean; 32 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 33 + const cardBorderHidden = useCardBorderHidden(); 34 return ( 35 <button 36 className={` ··· 57 first: boolean | undefined; 58 isFocused: boolean; 59 }) => { 60 return ( 61 <div 62 className={`pageOptions w-fit z-10 63 ${props.isFocused ? "block" : "sm:hidden block"} 64 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 65 flex sm:flex-col flex-row-reverse gap-1 items-start`} 66 > 67 {!props.first && ( 68 <PageOptionButton 69 secondary 70 onClick={() => { 71 useUIState.getState().closePage(props.entityID); ··· 74 <CloseTiny /> 75 </PageOptionButton> 76 )} 77 + <OptionsMenu entityID={props.entityID} first={!!props.first} /> 78 + <UndoButtons /> 79 </div> 80 ); 81 }; 82 83 + export const UndoButtons = () => { 84 let undoState = useUndoState(); 85 let { undoManager } = useReplicache(); 86 return ( 87 <Media mobile> 88 {undoState.canUndo && ( 89 <div className="gap-1 flex sm:flex-col"> 90 + <PageOptionButton secondary onClick={() => undoManager.undo()}> 91 <UndoTiny /> 92 </PageOptionButton> 93 94 <PageOptionButton 95 secondary 96 onClick={() => undoManager.undo()} 97 disabled={!undoState.canRedo} 98 > ··· 104 ); 105 }; 106 107 + export const OptionsMenu = (props: { entityID: string; first: boolean }) => { 108 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 109 let { permissions } = useEntitySetContext(); 110 if (!permissions.write) return null; ··· 119 if (!open) setState("normal"); 120 }} 121 trigger={ 122 + <PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8"> 123 <MoreOptionsTiny className="sm:rotate-90" /> 124 </PageOptionButton> 125 }
+3 -18
components/Pages/useCardBorderHidden.ts
··· 1 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 - import { PubLeafletPublication } from "lexicons/api"; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 5 - export function useCardBorderHidden(entityID: string | null) { 6 - let { rootEntity } = useReplicache(); 7 - let { data: pub } = useLeafletPublicationData(); 8 - let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 9 - 10 - let cardBorderHidden = 11 - useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 12 - if (!cardBorderHidden && !rootCardBorderHidden) { 13 - if (pub?.publications?.record) { 14 - let record = pub.publications.record as PubLeafletPublication.Record; 15 - return !record.theme?.showPageBackground; 16 - } 17 - return false; 18 - } 19 - return (cardBorderHidden || rootCardBorderHidden)?.data.value; 20 }
··· 1 + import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider"; 2 3 + export function useCardBorderHidden(entityID?: string | null) { 4 + return useCardBorderHiddenContext(); 5 }
+43 -31
components/PostListing.tsx
··· 13 14 import Link from "next/link"; 15 import { InteractionPreview } from "./InteractionsPreview"; 16 17 export const PostListing = (props: Post) => { 18 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 19 20 let postRecord = props.documents.data as PubLeafletDocument.Record; 21 let postUri = new AtUri(props.documents.uri); 22 23 - let theme = usePubTheme(pubRecord.theme); 24 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 25 - ? blobRefToSrc( 26 - pubRecord?.theme?.backgroundImage?.image?.ref, 27 - new AtUri(props.publication.uri).host, 28 - ) 29 - : null; 30 31 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 32 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 33 34 - let showPageBackground = pubRecord.theme?.showPageBackground; 35 36 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 37 let comments = 38 - pubRecord.preferences?.showComments === false 39 ? 0 40 : props.documents.comments_on_documents?.[0]?.count || 0; 41 let tags = (postRecord?.tags as string[] | undefined) || []; 42 43 return ( 44 <BaseThemeProvider {...theme} local> 45 <div 46 style={{ 47 - backgroundImage: `url(${backgroundImage})`, 48 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 49 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 50 }} ··· 55 hover:outline-accent-contrast hover:border-accent-contrast 56 `} 57 > 58 - <Link 59 - className="h-full w-full absolute top-0 left-0" 60 - href={`${props.publication.href}/${postUri.rkey}`} 61 - /> 62 <div 63 className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 64 style={{ ··· 71 72 <p className="text-secondary italic">{postRecord.description}</p> 73 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 74 - <PubInfo 75 - href={props.publication.href} 76 - pubRecord={pubRecord} 77 - uri={props.publication.uri} 78 - /> 79 <div className="flex flex-row justify-between gap-2 items-center w-full"> 80 <PostInfo publishedAt={postRecord.publishedAt} /> 81 <InteractionPreview 82 - postUrl={`${props.publication.href}/${postUri.rkey}`} 83 quotesCount={quotes} 84 commentsCount={comments} 85 tags={tags} 86 - showComments={pubRecord.preferences?.showComments} 87 share 88 /> 89 </div> ··· 114 }; 115 116 const PostInfo = (props: { publishedAt: string | undefined }) => { 117 return ( 118 <div className="flex gap-2 items-center shrink-0 self-start"> 119 {props.publishedAt && ( 120 <> 121 - <div className="shrink-0"> 122 - {new Date(props.publishedAt).toLocaleDateString("en-US", { 123 - year: "numeric", 124 - month: "short", 125 - day: "numeric", 126 - })} 127 - </div> 128 </> 129 )} 130 </div>
··· 13 14 import Link from "next/link"; 15 import { InteractionPreview } from "./InteractionsPreview"; 16 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 17 18 export const PostListing = (props: Post) => { 19 + let pubRecord = props.publication?.pubRecord as 20 + | PubLeafletPublication.Record 21 + | undefined; 22 23 let postRecord = props.documents.data as PubLeafletDocument.Record; 24 let postUri = new AtUri(props.documents.uri); 25 + let uri = props.publication ? props.publication?.uri : props.documents.uri; 26 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; 31 + let backgroundImage = 32 + themeRecord?.backgroundImage?.image?.ref && uri 33 + ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) 34 + : null; 35 36 + let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; 37 + let backgroundImageSize = themeRecord?.backgroundImage?.width || 500; 38 39 + let showPageBackground = pubRecord 40 + ? pubRecord?.theme?.showPageBackground 41 + : postRecord.theme?.showPageBackground ?? true; 42 43 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 44 let comments = 45 + pubRecord?.preferences?.showComments === false 46 ? 0 47 : props.documents.comments_on_documents?.[0]?.count || 0; 48 let tags = (postRecord?.tags as string[] | undefined) || []; 49 50 + // For standalone posts, link directly to the document 51 + let postHref = props.publication 52 + ? `${props.publication.href}/${postUri.rkey}` 53 + : `/p/${postUri.host}/${postUri.rkey}`; 54 + 55 return ( 56 <BaseThemeProvider {...theme} local> 57 <div 58 style={{ 59 + backgroundImage: backgroundImage 60 + ? `url(${backgroundImage})` 61 + : undefined, 62 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 63 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 64 }} ··· 69 hover:outline-accent-contrast hover:border-accent-contrast 70 `} 71 > 72 + <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 73 <div 74 className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 75 style={{ ··· 82 83 <p className="text-secondary italic">{postRecord.description}</p> 84 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 85 + {props.publication && pubRecord && ( 86 + <PubInfo 87 + href={props.publication.href} 88 + pubRecord={pubRecord} 89 + uri={props.publication.uri} 90 + /> 91 + )} 92 <div className="flex flex-row justify-between gap-2 items-center w-full"> 93 <PostInfo publishedAt={postRecord.publishedAt} /> 94 <InteractionPreview 95 + postUrl={postHref} 96 quotesCount={quotes} 97 commentsCount={comments} 98 tags={tags} 99 + showComments={pubRecord?.preferences?.showComments} 100 share 101 /> 102 </div> ··· 127 }; 128 129 const PostInfo = (props: { publishedAt: string | undefined }) => { 130 + let localizedDate = useLocalizedDate(props.publishedAt || "", { 131 + year: "numeric", 132 + month: "short", 133 + day: "numeric", 134 + }); 135 return ( 136 <div className="flex gap-2 items-center shrink-0 self-start"> 137 {props.publishedAt && ( 138 <> 139 + <div className="shrink-0">{localizedDate}</div> 140 </> 141 )} 142 </div>
+98
components/ProfilePopover.tsx
···
··· 1 + "use client"; 2 + import { Popover } from "./Popover"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { useRef, useState } from "react"; 6 + import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader"; 7 + import { SpeedyLink } from "./SpeedyLink"; 8 + import { Tooltip } from "./Tooltip"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + 11 + export const ProfilePopover = (props: { 12 + trigger: React.ReactNode; 13 + didOrHandle: string; 14 + }) => { 15 + const [isOpen, setIsOpen] = useState(false); 16 + let [isHovered, setIsHovered] = useState(false); 17 + const hoverTimeout = useRef<null | number>(null); 18 + 19 + const { data, isLoading } = useSWR( 20 + isHovered ? ["profile-data", props.didOrHandle] : null, 21 + async () => { 22 + const response = await callRPC("get_profile_data", { 23 + didOrHandle: props.didOrHandle, 24 + }); 25 + return response.result; 26 + }, 27 + ); 28 + 29 + return ( 30 + <Tooltip 31 + className="max-w-sm p-0! text-center" 32 + asChild 33 + trigger={ 34 + <a 35 + className="no-underline" 36 + href={`https://leaflet.pub/p/${props.didOrHandle}`} 37 + target="_blank" 38 + onPointerEnter={(e) => { 39 + if (hoverTimeout.current) { 40 + window.clearTimeout(hoverTimeout.current); 41 + } 42 + hoverTimeout.current = window.setTimeout(async () => { 43 + setIsHovered(true); 44 + }, 150); 45 + }} 46 + onPointerLeave={() => { 47 + if (isHovered) return; 48 + if (hoverTimeout.current) { 49 + window.clearTimeout(hoverTimeout.current); 50 + hoverTimeout.current = null; 51 + } 52 + setIsHovered(false); 53 + }} 54 + > 55 + {props.trigger} 56 + </a> 57 + } 58 + onOpenChange={setIsOpen} 59 + > 60 + {isLoading ? ( 61 + <div className="text-secondary p-4">Loading...</div> 62 + ) : data ? ( 63 + <div> 64 + <ProfileHeader 65 + profile={data.profile} 66 + publications={data.publications} 67 + popover 68 + /> 69 + <KnownFollowers viewer={data.profile.viewer} did={data.profile.did} /> 70 + </div> 71 + ) : ( 72 + <div className="text-secondary py-2 px-4">Profile not found</div> 73 + )} 74 + </Tooltip> 75 + ); 76 + }; 77 + 78 + let KnownFollowers = (props: { 79 + viewer: ProfileViewDetailed["viewer"]; 80 + did: string; 81 + }) => { 82 + if (!props.viewer?.knownFollowers) return null; 83 + let count = props.viewer.knownFollowers.count; 84 + return ( 85 + <> 86 + <hr className="border-border" /> 87 + Followed by{" "} 88 + <a 89 + className="hover:underline" 90 + href={`https://bsky.social/profile/${props.did}/known-followers`} 91 + target="_blank" 92 + > 93 + {props.viewer?.knownFollowers?.followers[0]?.displayName}{" "} 94 + {count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""} 95 + </a> 96 + </> 97 + ); 98 + };
+18
components/Tab.tsx
···
··· 1 + import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 2 + 3 + export const Tab = (props: { 4 + name: string; 5 + selected: boolean; 6 + onSelect: () => void; 7 + href?: string; 8 + }) => { 9 + return ( 10 + <div 11 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 + onClick={() => props.onSelect()} 13 + > 14 + {props.name} 15 + {props.href && <ExternalLinkTiny />} 16 + </div> 17 + ); 18 + };
+4 -5
components/ThemeManager/PageThemeSetter.tsx
··· 3 import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter"; 4 5 import { 6 - PageBackgroundPicker, 7 PageThemePickers, 8 } from "./Pickers/PageThemePickers"; 9 import { useMemo, useState } from "react"; ··· 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 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 56 > 57 - <PageBackgroundPicker 58 entityID={props.entityID} 59 openPicker={openPicker} 60 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 61 - setValue={set("theme/card-background")} 62 /> 63 </div> 64 ··· 147 <div 148 className={ 149 pageBorderHidden 150 - ? "py-2 px-0 border border-transparent" 151 : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 152 } 153 style={
··· 3 import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter"; 4 5 import { 6 + SubpageBackgroundPicker, 7 PageThemePickers, 8 } from "./Pickers/PageThemePickers"; 9 import { useMemo, useState } from "react"; ··· 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 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 56 > 57 + <SubpageBackgroundPicker 58 entityID={props.entityID} 59 openPicker={openPicker} 60 + setOpenPicker={setOpenPicker} 61 /> 62 </div> 63 ··· 146 <div 147 className={ 148 pageBorderHidden 149 + ? "relative py-2 px-0 border border-transparent" 150 : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 151 } 152 style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
··· 21 22 export const ColorPicker = (props: { 23 label?: string; 24 value: Color | undefined; 25 alpha?: boolean; 26 image?: boolean; ··· 116 <div className="w-full flex flex-col gap-2 px-1 pb-2"> 117 { 118 <> 119 <ColorArea 120 className="w-full h-[128px] rounded-md" 121 colorSpace="hsb"
··· 21 22 export const ColorPicker = (props: { 23 label?: string; 24 + helpText?: string; 25 value: Color | undefined; 26 alpha?: boolean; 27 image?: boolean; ··· 117 <div className="w-full flex flex-col gap-2 px-1 pb-2"> 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 + )} 125 <ColorArea 126 className="w-full h-[128px] rounded-md" 127 colorSpace="hsb"
+4 -4
components/ThemeManager/Pickers/ImagePicker.tsx
··· 73 }); 74 }} 75 > 76 - <div className="flex flex-col gap-2 w-full"> 77 <div className="flex gap-2"> 78 <div 79 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 122 }} 123 > 124 <Slider.Track 125 - className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 126 ></Slider.Track> 127 <Slider.Thumb 128 className={` 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]"} `} 132 aria-label="Volume" 133 /> 134 </Slider.Root>
··· 73 }); 74 }} 75 > 76 + <div className="flex flex-col w-full"> 77 <div className="flex gap-2"> 78 <div 79 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 122 }} 123 > 124 <Slider.Track 125 + className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 126 ></Slider.Track> 127 <Slider.Thumb 128 className={` 129 flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 130 + ${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "} 131 + `} 132 aria-label="Volume" 133 /> 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 <hr className="border-border-light w-full" /> 52 </> 53 )} 54 - <PageTextPicker 55 value={primaryValue} 56 setValue={set("theme/primary")} 57 openPicker={props.openPicker} ··· 61 ); 62 }; 63 64 - export const PageBackgroundPicker = (props: { 65 entityID: string; 66 - setValue: (c: Color) => void; 67 openPicker: pickers; 68 setOpenPicker: (p: pickers) => void; 69 - home?: boolean; 70 }) => { 71 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 72 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 73 - let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 74 75 return ( 76 <> 77 - {pageBGImage && pageBGImage !== null && ( 78 - <PageBackgroundImagePicker 79 - disabled={pageBorderHidden?.data.value} 80 entityID={props.entityID} 81 - thisPicker={"page-background-image"} 82 openPicker={props.openPicker} 83 setOpenPicker={props.setOpenPicker} 84 - closePicker={() => props.setOpenPicker("null")} 85 - setValue={props.setValue} 86 - home={props.home} 87 /> 88 )} 89 <div className="relative"> 90 - <PageBackgroundColorPicker 91 - label={pageBorderHidden?.data.value ? "Menus" : "Page"} 92 value={pageValue} 93 - setValue={props.setValue} 94 - thisPicker={"page"} 95 openPicker={props.openPicker} 96 setOpenPicker={props.setOpenPicker} 97 alpha 98 /> 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 - > 107 <BlockImageSmall /> 108 <div className="hidden"> 109 <ImageInput ··· 119 ); 120 }; 121 122 export const PageBackgroundColorPicker = (props: { 123 disabled?: boolean; 124 label: string; ··· 128 setValue: (c: Color) => void; 129 value: Color; 130 alpha?: boolean; 131 }) => { 132 return ( 133 <ColorPicker 134 disabled={props.disabled} 135 label={props.label} 136 value={props.value} 137 setValue={props.setValue} 138 thisPicker={"page"} ··· 347 ); 348 }; 349 350 - export const PageTextPicker = (props: { 351 openPicker: pickers; 352 setOpenPicker: (thisPicker: pickers) => void; 353 value: Color; ··· 394 395 return ( 396 <> 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 - > 412 <div className="font-bold">Page Background</div> 413 <div className="italic text-[#8C8C8C]"> 414 - {pageBorderHidden ? "hidden" : ""} 415 </div> 416 - </button> 417 - </div> 418 </> 419 ); 420 };
··· 51 <hr className="border-border-light w-full" /> 52 </> 53 )} 54 + <TextPickers 55 value={primaryValue} 56 setValue={set("theme/primary")} 57 openPicker={props.openPicker} ··· 61 ); 62 }; 63 64 + // Page background picker for subpages - shows Page/Containers color with optional background image 65 + export const SubpageBackgroundPicker = (props: { 66 entityID: string; 67 openPicker: pickers; 68 setOpenPicker: (p: pickers) => void; 69 }) => { 70 + let { rep, rootEntity } = useReplicache(); 71 + let set = useMemo(() => { 72 + return setColorAttribute(rep, props.entityID); 73 + }, [rep, props.entityID]); 74 + 75 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 76 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 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 + } 105 106 return ( 107 <> 108 + {pageBGImage && ( 109 + <SubpageBackgroundImagePicker 110 entityID={props.entityID} 111 openPicker={props.openPicker} 112 setOpenPicker={props.setOpenPicker} 113 + setValue={set("theme/card-background")} 114 /> 115 )} 116 <div className="relative"> 117 + <ColorPicker 118 + label={label} 119 value={pageValue} 120 + setValue={set("theme/card-background")} 121 + thisPicker="page" 122 openPicker={props.openPicker} 123 setOpenPicker={props.setOpenPicker} 124 + closePicker={() => props.setOpenPicker("null")} 125 alpha 126 /> 127 + {!pageBGImage && ( 128 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 129 <BlockImageSmall /> 130 <div className="hidden"> 131 <ImageInput ··· 141 ); 142 }; 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 + 436 export const PageBackgroundColorPicker = (props: { 437 disabled?: boolean; 438 label: string; ··· 442 setValue: (c: Color) => void; 443 value: Color; 444 alpha?: boolean; 445 + helpText?: string; 446 }) => { 447 return ( 448 <ColorPicker 449 disabled={props.disabled} 450 label={props.label} 451 + helpText={props.helpText} 452 value={props.value} 453 setValue={props.setValue} 454 thisPicker={"page"} ··· 663 ); 664 }; 665 666 + export const TextPickers = (props: { 667 openPicker: pickers; 668 setOpenPicker: (thisPicker: pickers) => void; 669 value: Color; ··· 710 711 return ( 712 <> 713 + <Toggle 714 + toggle={!pageBorderHidden} 715 + onToggle={() => { 716 + handleToggle(); 717 + }} 718 + disabledColor1="#8C8C8C" 719 + disabledColor2="#DBDBDB" 720 + > 721 + <div className="flex gap-2"> 722 <div className="font-bold">Page Background</div> 723 <div className="italic text-[#8C8C8C]"> 724 + {pageBorderHidden ? "none" : ""} 725 </div> 726 + </div> 727 + </Toggle> 728 </> 729 ); 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 hasPageBackground: boolean; 25 setHasPageBackground: (s: boolean) => void; 26 }) => { 27 return ( 28 <> 29 {props.bgImage && props.bgImage !== null ? ( ··· 83 )} 84 </div> 85 )} 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 - /> 95 <hr className="border-border-light" /> 96 <div className="flex gap-2 items-center"> 97 <Toggle 98 - toggleOn={props.hasPageBackground} 99 - setToggleOn={() => { 100 props.setHasPageBackground(!props.hasPageBackground); 101 props.hasPageBackground && 102 props.openPicker === "page" && ··· 104 }} 105 disabledColor1="#8C8C8C" 106 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 > 115 - <div className="font-bold">Page Background</div> 116 - <div className="italic text-[#8C8C8C]"> 117 - {props.hasPageBackground ? "" : "hidden"} 118 </div> 119 - </button> 120 </div> 121 </> 122 ); ··· 250 props.setBgImage({ ...props.bgImage, repeat: 500 }); 251 }} 252 > 253 - <div className="flex flex-col gap-2 w-full"> 254 <div className="flex gap-2"> 255 <div 256 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 289 }} 290 > 291 <Slider.Track 292 - className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 293 ></Slider.Track> 294 <Slider.Thumb 295 className={`
··· 24 hasPageBackground: boolean; 25 setHasPageBackground: (s: boolean) => void; 26 }) => { 27 + // When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker 28 + let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage; 29 + 30 return ( 31 <> 32 {props.bgImage && props.bgImage !== null ? ( ··· 86 )} 87 </div> 88 )} 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 + )} 106 <hr className="border-border-light" /> 107 <div className="flex gap-2 items-center"> 108 <Toggle 109 + toggle={props.hasPageBackground} 110 + onToggle={() => { 111 props.setHasPageBackground(!props.hasPageBackground); 112 props.hasPageBackground && 113 props.openPicker === "page" && ··· 115 }} 116 disabledColor1="#8C8C8C" 117 disabledColor2="#DBDBDB" 118 > 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> 124 </div> 125 + </Toggle> 126 </div> 127 </> 128 ); ··· 256 props.setBgImage({ ...props.bgImage, repeat: 500 }); 257 }} 258 > 259 + <div className="flex flex-col w-full"> 260 <div className="flex gap-2"> 261 <div 262 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 295 }} 296 > 297 <Slider.Track 298 + className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 299 ></Slider.Track> 300 <Slider.Thumb 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 import { pickers } from "../ThemeSetter"; 2 - import { PageTextPicker } from "../Pickers/PageThemePickers"; 3 import { Color } from "react-aria-components"; 4 5 export const PagePickers = (props: { ··· 20 : "transparent", 21 }} 22 > 23 - <PageTextPicker 24 value={props.primary} 25 setValue={props.setPrimary} 26 openPicker={props.openPicker}
··· 1 import { pickers } from "../ThemeSetter"; 2 + import { TextPickers } from "../Pickers/PageThemePickers"; 3 import { Color } from "react-aria-components"; 4 5 export const PagePickers = (props: { ··· 20 : "transparent", 21 }} 22 > 23 + <TextPickers 24 value={props.primary} 25 setValue={props.setPrimary} 26 openPicker={props.openPicker}
+41 -8
components/ThemeManager/PubThemeSetter.tsx
··· 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 import { Separator } from "components/Layout"; 18 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 21 export type ImageState = { 22 src: string; ··· 54 } 55 : null, 56 ); 57 - 58 let pubBGImage = image?.src || null; 59 let leafletBGRepeat = image?.repeat || null; 60 61 return ( 62 - <BaseThemeProvider local {...localPubTheme}> 63 <form 64 onSubmit={async (e) => { 65 e.preventDefault(); ··· 75 : ColorToRGB(localPubTheme.bgLeaflet), 76 backgroundRepeat: image?.repeat, 77 backgroundImage: image ? image.file : null, 78 primary: ColorToRGB(localPubTheme.primary), 79 accentBackground: ColorToRGB(localPubTheme.accent1), 80 accentText: ColorToRGB(localPubTheme.accent2), 81 }, 82 }); 83 mutate((pub) => { 84 - if (result?.publication && pub?.publication) 85 return { 86 ...pub, 87 publication: { ...pub.publication, ...result.publication }, ··· 96 setLoadingAction={props.setLoading} 97 backToMenuAction={props.backToMenu} 98 state={"theme"} 99 - /> 100 </form> 101 102 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 "> 103 - <div className="themeBGLeaflet flex"> 104 <div 105 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 106 > 107 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 108 <BackgroundPicker
··· 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 import { Separator } from "components/Layout"; 18 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 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"; 23 24 export type ImageState = { 25 src: string; ··· 57 } 58 : null, 59 ); 60 + let [pageWidth, setPageWidth] = useState<number>( 61 + record?.theme?.pageWidth || 624, 62 + ); 63 let pubBGImage = image?.src || null; 64 let leafletBGRepeat = image?.repeat || null; 65 + let toaster = useToaster(); 66 67 return ( 68 + <BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}> 69 <form 70 onSubmit={async (e) => { 71 e.preventDefault(); ··· 81 : ColorToRGB(localPubTheme.bgLeaflet), 82 backgroundRepeat: image?.repeat, 83 backgroundImage: image ? image.file : null, 84 + pageWidth: pageWidth, 85 primary: ColorToRGB(localPubTheme.primary), 86 accentBackground: ColorToRGB(localPubTheme.accent1), 87 accentText: ColorToRGB(localPubTheme.accent2), 88 }, 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 + 107 mutate((pub) => { 108 + if (result.publication && pub?.publication) 109 return { 110 ...pub, 111 publication: { ...pub.publication, ...result.publication }, ··· 120 setLoadingAction={props.setLoading} 121 backToMenuAction={props.backToMenu} 122 state={"theme"} 123 + > 124 + Theme and Layout 125 + </PubSettingsHeader> 126 </form> 127 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"> 137 <div 138 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 139 > 140 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 141 <BackgroundPicker
+23 -12
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./themeUtils"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 - import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 102 pub_creator: string; 103 isStandalone?: boolean; 104 }) { 105 - let colors = usePubTheme(props.theme, props.isStandalone); 106 return ( 107 - <BaseThemeProvider local={props.local} {...colors}> 108 - {props.children} 109 - </BaseThemeProvider> 110 ); 111 } 112 ··· 124 bgPage = bgLeaflet; 125 } 126 let showPageBackground = theme?.showPageBackground; 127 128 let primary = useColor(theme, "primary"); 129 ··· 144 highlight2, 145 highlight3, 146 showPageBackground, 147 }; 148 }; 149 ··· 163 let newAccentContrast; 164 let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => { 165 return ( 166 - getColorContrast( 167 colorToString(b, "rgb"), 168 colorToString( 169 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 170 "rgb", 171 ), 172 ) - 173 - getColorContrast( 174 colorToString(a, "rgb"), 175 colorToString( 176 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, ··· 180 ); 181 }); 182 if ( 183 - getColorContrast( 184 colorToString(sortedAccents[0], "rgb"), 185 colorToString(newTheme.primary, "rgb"), 186 - ) < 30 && 187 - getColorContrast( 188 colorToString(sortedAccents[1], "rgb"), 189 colorToString( 190 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 191 "rgb", 192 ), 193 - ) > 12 194 ) { 195 newAccentContrast = sortedAccents[1]; 196 } else newAccentContrast = sortedAccents[0];
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 + import { getColorDifference } from "./themeUtils"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 102 pub_creator: string; 103 isStandalone?: boolean; 104 }) { 105 + let theme = usePubTheme(props.theme, props.isStandalone); 106 + let cardBorderHidden = !theme.showPageBackground; 107 + let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref; 108 + 109 return ( 110 + <CardBorderHiddenContext.Provider value={cardBorderHidden}> 111 + <BaseThemeProvider 112 + local={props.local} 113 + {...theme} 114 + hasBackgroundImage={hasBackgroundImage} 115 + > 116 + {props.children} 117 + </BaseThemeProvider> 118 + </CardBorderHiddenContext.Provider> 119 ); 120 } 121 ··· 133 bgPage = bgLeaflet; 134 } 135 let showPageBackground = theme?.showPageBackground; 136 + let pageWidth = theme?.pageWidth; 137 138 let primary = useColor(theme, "primary"); 139 ··· 154 highlight2, 155 highlight3, 156 showPageBackground, 157 + pageWidth, 158 }; 159 }; 160 ··· 174 let newAccentContrast; 175 let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => { 176 return ( 177 + getColorDifference( 178 colorToString(b, "rgb"), 179 colorToString( 180 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 181 "rgb", 182 ), 183 ) - 184 + getColorDifference( 185 colorToString(a, "rgb"), 186 colorToString( 187 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, ··· 191 ); 192 }); 193 if ( 194 + getColorDifference( 195 colorToString(sortedAccents[0], "rgb"), 196 colorToString(newTheme.primary, "rgb"), 197 + ) < 0.15 && 198 + getColorDifference( 199 colorToString(sortedAccents[1], "rgb"), 200 colorToString( 201 showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 202 "rgb", 203 ), 204 + ) > 0.08 205 ) { 206 newAccentContrast = sortedAccents[1]; 207 } else newAccentContrast = sortedAccents[0];
+60 -32
components/ThemeManager/ThemeProvider.tsx
··· 1 "use client"; 2 3 - import { 4 - createContext, 5 - CSSProperties, 6 - useContext, 7 - useEffect, 8 - } from "react"; 9 import { 10 colorToString, 11 useColorAttribute, ··· 20 PublicationThemeProvider, 21 } from "./PublicationThemeProvider"; 22 import { PubLeafletPublication } from "lexicons/api"; 23 - import { getColorContrast } from "./themeUtils"; 24 25 // define a function to set an Aria Color to a CSS Variable in RGB 26 function setCSSVariableToColor( ··· 58 }) { 59 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 60 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 61 - let showPageBackground = !useEntity( 62 props.entityID, 63 "theme/card-border-hidden", 64 )?.data.value; 65 let primary = useColorAttribute(props.entityID, "theme/primary"); 66 67 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 70 71 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 72 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 73 74 return ( 75 - <BaseThemeProvider 76 - local={props.local} 77 - bgLeaflet={bgLeaflet} 78 - bgPage={bgPage} 79 - primary={primary} 80 - highlight2={highlight2} 81 - highlight3={highlight3} 82 - highlight1={highlight1?.data.value} 83 - accent1={accent1} 84 - accent2={accent2} 85 - showPageBackground={showPageBackground} 86 - > 87 - {props.children} 88 - </BaseThemeProvider> 89 ); 90 } 91 ··· 93 export const BaseThemeProvider = ({ 94 local, 95 bgLeaflet, 96 - bgPage, 97 primary, 98 accent1, 99 accent2, ··· 101 highlight2, 102 highlight3, 103 showPageBackground, 104 children, 105 }: { 106 local?: boolean; 107 showPageBackground?: boolean; 108 bgLeaflet: AriaColor; 109 bgPage: AriaColor; 110 primary: AriaColor; ··· 113 highlight1?: string; 114 highlight2: AriaColor; 115 highlight3: AriaColor; 116 children: React.ReactNode; 117 }) => { 118 // set accent contrast to the accent color that has the highest contrast with the page background 119 let accentContrast; 120 121 //sorting the accents by contrast on background 122 let sortedAccents = [accent1, accent2].sort((a, b) => { 123 return ( 124 - getColorContrast( 125 colorToString(b, "rgb"), 126 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 127 ) - 128 - getColorContrast( 129 colorToString(a, "rgb"), 130 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 131 ) ··· 137 // then use the not contrasty option 138 139 if ( 140 - getColorContrast( 141 colorToString(sortedAccents[0], "rgb"), 142 colorToString(primary, "rgb"), 143 - ) < 30 && 144 - getColorContrast( 145 colorToString(sortedAccents[1], "rgb"), 146 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 147 - ) > 12 148 ) { 149 accentContrast = sortedAccents[1]; 150 } else accentContrast = sortedAccents[0]; ··· 191 "--accent-1-is-contrast", 192 accentContrast === accent1 ? "1" : "0", 193 ); 194 }, [ 195 local, 196 bgLeaflet, ··· 202 accent1, 203 accent2, 204 accentContrast, 205 ]); 206 return ( 207 <div ··· 221 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 222 "--highlight-2": colorToString(highlight2, "rgb"), 223 "--highlight-3": colorToString(highlight3, "rgb"), 224 } as CSSProperties 225 } 226 > ··· 257 bgPage && accent1 && accent2 258 ? [accent1, accent2].sort((a, b) => { 259 return ( 260 - getColorContrast( 261 colorToString(b, "rgb"), 262 colorToString(bgPage, "rgb"), 263 ) - 264 - getColorContrast( 265 colorToString(a, "rgb"), 266 colorToString(bgPage, "rgb"), 267 ) ··· 337 </div> 338 ); 339 }; 340 -
··· 1 "use client"; 2 3 + import { createContext, CSSProperties, useContext, useEffect } from "react"; 4 + 5 + // Context for cardBorderHidden 6 + export const CardBorderHiddenContext = createContext<boolean>(false); 7 + 8 + export function useCardBorderHiddenContext() { 9 + return useContext(CardBorderHiddenContext); 10 + } 11 import { 12 colorToString, 13 useColorAttribute, ··· 22 PublicationThemeProvider, 23 } from "./PublicationThemeProvider"; 24 import { PubLeafletPublication } from "lexicons/api"; 25 + import { getColorDifference } from "./themeUtils"; 26 27 // define a function to set an Aria Color to a CSS Variable in RGB 28 function setCSSVariableToColor( ··· 60 }) { 61 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 62 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 63 + let cardBorderHiddenValue = useEntity( 64 props.entityID, 65 "theme/card-border-hidden", 66 )?.data.value; 67 + let showPageBackground = !cardBorderHiddenValue; 68 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 69 + let hasBackgroundImage = !!backgroundImage; 70 let primary = useColorAttribute(props.entityID, "theme/primary"); 71 72 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 75 76 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 77 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 78 + 79 + let pageWidth = useEntity(props.entityID, "theme/page-width"); 80 81 return ( 82 + <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 83 + <BaseThemeProvider 84 + local={props.local} 85 + bgLeaflet={bgLeaflet} 86 + bgPage={bgPage} 87 + primary={primary} 88 + highlight2={highlight2} 89 + highlight3={highlight3} 90 + highlight1={highlight1?.data.value} 91 + accent1={accent1} 92 + accent2={accent2} 93 + showPageBackground={showPageBackground} 94 + pageWidth={pageWidth?.data.value} 95 + hasBackgroundImage={hasBackgroundImage} 96 + > 97 + {props.children} 98 + </BaseThemeProvider> 99 + </CardBorderHiddenContext.Provider> 100 ); 101 } 102 ··· 104 export const BaseThemeProvider = ({ 105 local, 106 bgLeaflet, 107 + bgPage: bgPageProp, 108 primary, 109 accent1, 110 accent2, ··· 112 highlight2, 113 highlight3, 114 showPageBackground, 115 + pageWidth, 116 + hasBackgroundImage, 117 children, 118 }: { 119 local?: boolean; 120 showPageBackground?: boolean; 121 + hasBackgroundImage?: boolean; 122 bgLeaflet: AriaColor; 123 bgPage: AriaColor; 124 primary: AriaColor; ··· 127 highlight1?: string; 128 highlight2: AriaColor; 129 highlight3: AriaColor; 130 + pageWidth?: number; 131 children: React.ReactNode; 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; 137 // set accent contrast to the accent color that has the highest contrast with the page background 138 let accentContrast; 139 140 //sorting the accents by contrast on background 141 let sortedAccents = [accent1, accent2].sort((a, b) => { 142 return ( 143 + getColorDifference( 144 colorToString(b, "rgb"), 145 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 146 ) - 147 + getColorDifference( 148 colorToString(a, "rgb"), 149 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 150 ) ··· 156 // then use the not contrasty option 157 158 if ( 159 + getColorDifference( 160 colorToString(sortedAccents[0], "rgb"), 161 colorToString(primary, "rgb"), 162 + ) < 0.15 && 163 + getColorDifference( 164 colorToString(sortedAccents[1], "rgb"), 165 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 166 + ) > 0.08 167 ) { 168 accentContrast = sortedAccents[1]; 169 } else accentContrast = sortedAccents[0]; ··· 210 "--accent-1-is-contrast", 211 accentContrast === accent1 ? "1" : "0", 212 ); 213 + 214 + // Set page width CSS variable 215 + el?.style.setProperty( 216 + "--page-width-setting", 217 + (pageWidth || 624).toString(), 218 + ); 219 }, [ 220 local, 221 bgLeaflet, ··· 227 accent1, 228 accent2, 229 accentContrast, 230 + pageWidth, 231 ]); 232 return ( 233 <div ··· 247 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 248 "--highlight-2": colorToString(highlight2, "rgb"), 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))`, 253 } as CSSProperties 254 } 255 > ··· 286 bgPage && accent1 && accent2 287 ? [accent1, accent2].sort((a, b) => { 288 return ( 289 + getColorDifference( 290 colorToString(b, "rgb"), 291 colorToString(bgPage, "rgb"), 292 ) - 293 + getColorDifference( 294 colorToString(a, "rgb"), 295 colorToString(bgPage, "rgb"), 296 ) ··· 366 </div> 367 ); 368 };
+21 -35
components/ThemeManager/ThemeSetter.tsx
··· 1 "use client"; 2 import { Popover } from "components/Popover"; 3 - import { theme } from "../../tailwind.config"; 4 5 import { Color } from "react-aria-components"; 6 7 - import { LeafletBGPicker } from "./Pickers/LeafletBGPicker"; 8 import { 9 - PageBackgroundPicker, 10 - PageBorderHider, 11 PageThemePickers, 12 } from "./Pickers/PageThemePickers"; 13 import { useMemo, useState } from "react"; 14 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 15 import { Replicache } from "replicache"; ··· 35 | "highlight-1" 36 | "highlight-2" 37 | "highlight-3" 38 - | "page-background-image"; 39 40 export function setColorAttribute( 41 rep: Replicache<ReplicacheMutators> | null, ··· 75 return ( 76 <> 77 <Popover 78 - className="w-80 bg-white" 79 arrowFill="#FFFFFF" 80 asChild 81 side={isMobile ? "top" : "right"} ··· 114 if (pub?.publications) return null; 115 return ( 116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 <div className="themeBGLeaflet flex"> 118 <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 <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 137 entityID={props.entityID} 138 openPicker={openPicker} 139 setOpenPicker={setOpenPicker} ··· 173 setOpenPicker={(pickers) => setOpenPicker(pickers)} 174 /> 175 <SectionArrow 176 - fill={theme.colors["accent-2"]} 177 - stroke={theme.colors["accent-1"]} 178 className="ml-2" 179 /> 180 </div> ··· 209 return ( 210 <div className="flex gap-2 items-start mt-0.5"> 211 <Toggle 212 - toggleOn={!!checked?.data.value} 213 - setToggleOn={() => { 214 handleToggle(); 215 }} 216 disabledColor1="#8C8C8C" 217 disabledColor2="#DBDBDB" 218 - /> 219 - <button 220 - className="flex gap-2 items-center -mt-0.5" 221 - onClick={() => { 222 - handleToggle(); 223 - }} 224 > 225 - <div className="flex flex-col gap-0 items-start"> 226 <div className="font-bold">Show Leaflet Watermark</div> 227 <div className="text-sm text-[#969696]">Help us spread the word!</div> 228 </div> 229 - </button> 230 </div> 231 ); 232 }
··· 1 "use client"; 2 import { Popover } from "components/Popover"; 3 4 import { Color } from "react-aria-components"; 5 6 import { 7 + LeafletBackgroundPicker, 8 PageThemePickers, 9 } from "./Pickers/PageThemePickers"; 10 + import { PageWidthSetter } from "./Pickers/PageWidthSetter"; 11 import { useMemo, useState } from "react"; 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { Replicache } from "replicache"; ··· 33 | "highlight-1" 34 | "highlight-2" 35 | "highlight-3" 36 + | "page-background-image" 37 + | "page-width"; 38 39 export function setColorAttribute( 40 rep: Replicache<ReplicacheMutators> | null, ··· 74 return ( 75 <> 76 <Popover 77 + className="w-80 bg-white py-3!" 78 arrowFill="#FFFFFF" 79 asChild 80 side={isMobile ? "top" : "right"} ··· 113 if (pub?.publications) return null; 114 return ( 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 + )} 125 <div className="themeBGLeaflet flex"> 126 <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 127 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 128 + <LeafletBackgroundPicker 129 entityID={props.entityID} 130 openPicker={openPicker} 131 setOpenPicker={setOpenPicker} ··· 165 setOpenPicker={(pickers) => setOpenPicker(pickers)} 166 /> 167 <SectionArrow 168 + fill="rgb(var(--accent-2))" 169 + stroke="rgb(var(--accent-1))" 170 className="ml-2" 171 /> 172 </div> ··· 201 return ( 202 <div className="flex gap-2 items-start mt-0.5"> 203 <Toggle 204 + toggle={!!checked?.data.value} 205 + onToggle={() => { 206 handleToggle(); 207 }} 208 disabledColor1="#8C8C8C" 209 disabledColor2="#DBDBDB" 210 > 211 + <div className="flex flex-col gap-0 items-start "> 212 <div className="font-bold">Show Leaflet Watermark</div> 213 <div className="text-sm text-[#969696]">Help us spread the word!</div> 214 </div> 215 + </Toggle> 216 </div> 217 ); 218 }
+4 -3
components/ThemeManager/themeUtils.ts
··· 1 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 3 // define the color defaults for everything 4 export const ThemeDefaults = { ··· 17 }; 18 19 // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 - export function getColorContrast(color1: string, color2: string) { 21 ColorSpace.register(sRGB); 22 23 let parsedColor1 = parse(`rgb(${color1})`); 24 let parsedColor2 = parse(`rgb(${color2})`); 25 26 - return contrastLstar(parsedColor1, parsedColor2); 27 }
··· 1 + import { parse, ColorSpace, sRGB, distance, OKLab } from "colorjs.io/fn"; 2 3 // define the color defaults for everything 4 export const ThemeDefaults = { ··· 17 }; 18 19 // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorDifference(color1: string, color2: string) { 21 ColorSpace.register(sRGB); 22 + ColorSpace.register(OKLab); 23 24 let parsedColor1 = parse(`rgb(${color1})`); 25 let parsedColor2 = parse(`rgb(${color2})`); 26 27 + return distance(parsedColor1, parsedColor2, "oklab"); 28 }
+32 -20
components/Toggle.tsx
··· 1 import { theme } from "tailwind.config"; 2 3 export const Toggle = (props: { 4 - toggleOn: boolean; 5 - setToggleOn: (s: boolean) => void; 6 disabledColor1?: string; 7 disabledColor2?: string; 8 }) => { 9 return ( 10 <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"], 20 }} 21 - onClick={() => props.setToggleOn(!props.toggleOn)} 22 > 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 - /> 31 </button> 32 ); 33 };
··· 1 import { theme } from "tailwind.config"; 2 3 export const Toggle = (props: { 4 + toggle: boolean; 5 + onToggle: () => void; 6 disabledColor1?: string; 7 disabledColor2?: string; 8 + children: React.ReactNode; 9 }) => { 10 return ( 11 <button 12 + type="button" 13 + className="toggle flex gap-2 items-start justify-start text-left" 14 + onClick={() => { 15 + props.onToggle(); 16 }} 17 > 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} 43 </button> 44 ); 45 };
+2 -1
components/Toolbar/BlockToolbar.tsx
··· 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 ··· 44 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 45 <ImageFullBleedButton /> 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 47 {focusedEntityType?.data.value !== "canvas" && ( 48 <Separator classname="h-6" /> 49 )}
··· 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 + import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 ··· 44 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 45 <ImageFullBleedButton /> 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 47 + <ImageCoverButton /> 48 {focusedEntityType?.data.value !== "canvas" && ( 49 <Separator classname="h-6" /> 50 )}
+37
components/Toolbar/ImageToolbar.tsx
··· 4 import { useUIState } from "src/useUIState"; 5 import { Props } from "components/Icons/Props"; 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 8 export const ImageFullBleedButton = (props: {}) => { 9 let { rep } = useReplicache(); ··· 76 ) : ( 77 <ImageRemoveAltSmall /> 78 )} 79 </ToolbarButton> 80 ); 81 };
··· 4 import { useUIState } from "src/useUIState"; 5 import { Props } from "components/Icons/Props"; 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"; 10 11 export const ImageFullBleedButton = (props: {}) => { 12 let { rep } = useReplicache(); ··· 79 ) : ( 80 <ImageRemoveAltSmall /> 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 /> 116 </ToolbarButton> 117 ); 118 };
+1 -1
components/Tooltip.tsx
··· 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 } 28 > 29 - <RadixTooltip.Root> 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 {props.trigger} 32 </RadixTooltip.Trigger>
··· 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 } 28 > 29 + <RadixTooltip.Root onOpenChange={props.onOpenChange}> 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 {props.trigger} 32 </RadixTooltip.Trigger>
+834 -824
lexicons/api/lexicons.ts
··· 6 Lexicons, 7 ValidationError, 8 type ValidationResult, 9 - } from '@atproto/lexicon' 10 - import { type $Typed, is$typed, maybe$typed } from './util' 11 12 export const schemaDict = { 13 AppBskyActorProfile: { 14 lexicon: 1, 15 - id: 'app.bsky.actor.profile', 16 defs: { 17 main: { 18 - type: 'record', 19 - description: 'A declaration of a Bluesky account profile.', 20 - key: 'literal:self', 21 record: { 22 - type: 'object', 23 properties: { 24 displayName: { 25 - type: 'string', 26 maxGraphemes: 64, 27 maxLength: 640, 28 }, 29 description: { 30 - type: 'string', 31 - description: 'Free-form profile description text.', 32 maxGraphemes: 256, 33 maxLength: 2560, 34 }, 35 avatar: { 36 - type: 'blob', 37 description: 38 "Small image to be displayed next to posts from account. AKA, 'profile picture'", 39 - accept: ['image/png', 'image/jpeg'], 40 maxSize: 1000000, 41 }, 42 banner: { 43 - type: 'blob', 44 description: 45 - 'Larger horizontal image to display behind profile view.', 46 - accept: ['image/png', 'image/jpeg'], 47 maxSize: 1000000, 48 }, 49 labels: { 50 - type: 'union', 51 description: 52 - 'Self-label values, specific to the Bluesky application, on the overall account.', 53 - refs: ['lex:com.atproto.label.defs#selfLabels'], 54 }, 55 joinedViaStarterPack: { 56 - type: 'ref', 57 - ref: 'lex:com.atproto.repo.strongRef', 58 }, 59 pinnedPost: { 60 - type: 'ref', 61 - ref: 'lex:com.atproto.repo.strongRef', 62 }, 63 createdAt: { 64 - type: 'string', 65 - format: 'datetime', 66 }, 67 }, 68 }, ··· 71 }, 72 ComAtprotoLabelDefs: { 73 lexicon: 1, 74 - id: 'com.atproto.label.defs', 75 defs: { 76 label: { 77 - type: 'object', 78 description: 79 - 'Metadata tag on an atproto resource (eg, repo or record).', 80 - required: ['src', 'uri', 'val', 'cts'], 81 properties: { 82 ver: { 83 - type: 'integer', 84 - description: 'The AT Protocol version of the label object.', 85 }, 86 src: { 87 - type: 'string', 88 - format: 'did', 89 - description: 'DID of the actor who created this label.', 90 }, 91 uri: { 92 - type: 'string', 93 - format: 'uri', 94 description: 95 - 'AT URI of the record, repository (account), or other resource that this label applies to.', 96 }, 97 cid: { 98 - type: 'string', 99 - format: 'cid', 100 description: 101 "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 102 }, 103 val: { 104 - type: 'string', 105 maxLength: 128, 106 description: 107 - 'The short string name of the value or type of this label.', 108 }, 109 neg: { 110 - type: 'boolean', 111 description: 112 - 'If true, this is a negation label, overwriting a previous label.', 113 }, 114 cts: { 115 - type: 'string', 116 - format: 'datetime', 117 - description: 'Timestamp when this label was created.', 118 }, 119 exp: { 120 - type: 'string', 121 - format: 'datetime', 122 description: 123 - 'Timestamp at which this label expires (no longer applies).', 124 }, 125 sig: { 126 - type: 'bytes', 127 - description: 'Signature of dag-cbor encoded label.', 128 }, 129 }, 130 }, 131 selfLabels: { 132 - type: 'object', 133 description: 134 - 'Metadata tags on an atproto record, published by the author within the record.', 135 - required: ['values'], 136 properties: { 137 values: { 138 - type: 'array', 139 items: { 140 - type: 'ref', 141 - ref: 'lex:com.atproto.label.defs#selfLabel', 142 }, 143 maxLength: 10, 144 }, 145 }, 146 }, 147 selfLabel: { 148 - type: 'object', 149 description: 150 - 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', 151 - required: ['val'], 152 properties: { 153 val: { 154 - type: 'string', 155 maxLength: 128, 156 description: 157 - 'The short string name of the value or type of this label.', 158 }, 159 }, 160 }, 161 labelValueDefinition: { 162 - type: 'object', 163 description: 164 - 'Declares a label value and its expected interpretations and behaviors.', 165 - required: ['identifier', 'severity', 'blurs', 'locales'], 166 properties: { 167 identifier: { 168 - type: 'string', 169 description: 170 "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 171 maxLength: 100, 172 maxGraphemes: 100, 173 }, 174 severity: { 175 - type: 'string', 176 description: 177 "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 178 - knownValues: ['inform', 'alert', 'none'], 179 }, 180 blurs: { 181 - type: 'string', 182 description: 183 "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 184 - knownValues: ['content', 'media', 'none'], 185 }, 186 defaultSetting: { 187 - type: 'string', 188 - description: 'The default setting for this label.', 189 - knownValues: ['ignore', 'warn', 'hide'], 190 - default: 'warn', 191 }, 192 adultOnly: { 193 - type: 'boolean', 194 description: 195 - 'Does the user need to have adult content enabled in order to configure this label?', 196 }, 197 locales: { 198 - type: 'array', 199 items: { 200 - type: 'ref', 201 - ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', 202 }, 203 }, 204 }, 205 }, 206 labelValueDefinitionStrings: { 207 - type: 'object', 208 description: 209 - 'Strings which describe the label in the UI, localized into a specific language.', 210 - required: ['lang', 'name', 'description'], 211 properties: { 212 lang: { 213 - type: 'string', 214 description: 215 - 'The code of the language these strings are written in.', 216 - format: 'language', 217 }, 218 name: { 219 - type: 'string', 220 - description: 'A short human-readable name for the label.', 221 maxGraphemes: 64, 222 maxLength: 640, 223 }, 224 description: { 225 - type: 'string', 226 description: 227 - 'A longer description of what the label means and why it might be applied.', 228 maxGraphemes: 10000, 229 maxLength: 100000, 230 }, 231 }, 232 }, 233 labelValue: { 234 - type: 'string', 235 knownValues: [ 236 - '!hide', 237 - '!no-promote', 238 - '!warn', 239 - '!no-unauthenticated', 240 - 'dmca-violation', 241 - 'doxxing', 242 - 'porn', 243 - 'sexual', 244 - 'nudity', 245 - 'nsfl', 246 - 'gore', 247 ], 248 }, 249 }, 250 }, 251 ComAtprotoRepoApplyWrites: { 252 lexicon: 1, 253 - id: 'com.atproto.repo.applyWrites', 254 defs: { 255 main: { 256 - type: 'procedure', 257 description: 258 - 'Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.', 259 input: { 260 - encoding: 'application/json', 261 schema: { 262 - type: 'object', 263 - required: ['repo', 'writes'], 264 properties: { 265 repo: { 266 - type: 'string', 267 - format: 'at-identifier', 268 description: 269 - 'The handle or DID of the repo (aka, current account).', 270 }, 271 validate: { 272 - type: 'boolean', 273 description: 274 "Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.", 275 }, 276 writes: { 277 - type: 'array', 278 items: { 279 - type: 'union', 280 refs: [ 281 - 'lex:com.atproto.repo.applyWrites#create', 282 - 'lex:com.atproto.repo.applyWrites#update', 283 - 'lex:com.atproto.repo.applyWrites#delete', 284 ], 285 closed: true, 286 }, 287 }, 288 swapCommit: { 289 - type: 'string', 290 description: 291 - 'If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.', 292 - format: 'cid', 293 }, 294 }, 295 }, 296 }, 297 output: { 298 - encoding: 'application/json', 299 schema: { 300 - type: 'object', 301 required: [], 302 properties: { 303 commit: { 304 - type: 'ref', 305 - ref: 'lex:com.atproto.repo.defs#commitMeta', 306 }, 307 results: { 308 - type: 'array', 309 items: { 310 - type: 'union', 311 refs: [ 312 - 'lex:com.atproto.repo.applyWrites#createResult', 313 - 'lex:com.atproto.repo.applyWrites#updateResult', 314 - 'lex:com.atproto.repo.applyWrites#deleteResult', 315 ], 316 closed: true, 317 }, ··· 321 }, 322 errors: [ 323 { 324 - name: 'InvalidSwap', 325 description: 326 "Indicates that the 'swapCommit' parameter did not match current commit.", 327 }, 328 ], 329 }, 330 create: { 331 - type: 'object', 332 - description: 'Operation which creates a new record.', 333 - required: ['collection', 'value'], 334 properties: { 335 collection: { 336 - type: 'string', 337 - format: 'nsid', 338 }, 339 rkey: { 340 - type: 'string', 341 maxLength: 512, 342 - format: 'record-key', 343 description: 344 - 'NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.', 345 }, 346 value: { 347 - type: 'unknown', 348 }, 349 }, 350 }, 351 update: { 352 - type: 'object', 353 - description: 'Operation which updates an existing record.', 354 - required: ['collection', 'rkey', 'value'], 355 properties: { 356 collection: { 357 - type: 'string', 358 - format: 'nsid', 359 }, 360 rkey: { 361 - type: 'string', 362 - format: 'record-key', 363 }, 364 value: { 365 - type: 'unknown', 366 }, 367 }, 368 }, 369 delete: { 370 - type: 'object', 371 - description: 'Operation which deletes an existing record.', 372 - required: ['collection', 'rkey'], 373 properties: { 374 collection: { 375 - type: 'string', 376 - format: 'nsid', 377 }, 378 rkey: { 379 - type: 'string', 380 - format: 'record-key', 381 }, 382 }, 383 }, 384 createResult: { 385 - type: 'object', 386 - required: ['uri', 'cid'], 387 properties: { 388 uri: { 389 - type: 'string', 390 - format: 'at-uri', 391 }, 392 cid: { 393 - type: 'string', 394 - format: 'cid', 395 }, 396 validationStatus: { 397 - type: 'string', 398 - knownValues: ['valid', 'unknown'], 399 }, 400 }, 401 }, 402 updateResult: { 403 - type: 'object', 404 - required: ['uri', 'cid'], 405 properties: { 406 uri: { 407 - type: 'string', 408 - format: 'at-uri', 409 }, 410 cid: { 411 - type: 'string', 412 - format: 'cid', 413 }, 414 validationStatus: { 415 - type: 'string', 416 - knownValues: ['valid', 'unknown'], 417 }, 418 }, 419 }, 420 deleteResult: { 421 - type: 'object', 422 required: [], 423 properties: {}, 424 }, ··· 426 }, 427 ComAtprotoRepoCreateRecord: { 428 lexicon: 1, 429 - id: 'com.atproto.repo.createRecord', 430 defs: { 431 main: { 432 - type: 'procedure', 433 description: 434 - 'Create a single new repository record. Requires auth, implemented by PDS.', 435 input: { 436 - encoding: 'application/json', 437 schema: { 438 - type: 'object', 439 - required: ['repo', 'collection', 'record'], 440 properties: { 441 repo: { 442 - type: 'string', 443 - format: 'at-identifier', 444 description: 445 - 'The handle or DID of the repo (aka, current account).', 446 }, 447 collection: { 448 - type: 'string', 449 - format: 'nsid', 450 - description: 'The NSID of the record collection.', 451 }, 452 rkey: { 453 - type: 'string', 454 - format: 'record-key', 455 - description: 'The Record Key.', 456 maxLength: 512, 457 }, 458 validate: { 459 - type: 'boolean', 460 description: 461 "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 462 }, 463 record: { 464 - type: 'unknown', 465 - description: 'The record itself. Must contain a $type field.', 466 }, 467 swapCommit: { 468 - type: 'string', 469 - format: 'cid', 470 description: 471 - 'Compare and swap with the previous commit by CID.', 472 }, 473 }, 474 }, 475 }, 476 output: { 477 - encoding: 'application/json', 478 schema: { 479 - type: 'object', 480 - required: ['uri', 'cid'], 481 properties: { 482 uri: { 483 - type: 'string', 484 - format: 'at-uri', 485 }, 486 cid: { 487 - type: 'string', 488 - format: 'cid', 489 }, 490 commit: { 491 - type: 'ref', 492 - ref: 'lex:com.atproto.repo.defs#commitMeta', 493 }, 494 validationStatus: { 495 - type: 'string', 496 - knownValues: ['valid', 'unknown'], 497 }, 498 }, 499 }, 500 }, 501 errors: [ 502 { 503 - name: 'InvalidSwap', 504 description: 505 "Indicates that 'swapCommit' didn't match current repo commit.", 506 }, ··· 510 }, 511 ComAtprotoRepoDefs: { 512 lexicon: 1, 513 - id: 'com.atproto.repo.defs', 514 defs: { 515 commitMeta: { 516 - type: 'object', 517 - required: ['cid', 'rev'], 518 properties: { 519 cid: { 520 - type: 'string', 521 - format: 'cid', 522 }, 523 rev: { 524 - type: 'string', 525 - format: 'tid', 526 }, 527 }, 528 }, ··· 530 }, 531 ComAtprotoRepoDeleteRecord: { 532 lexicon: 1, 533 - id: 'com.atproto.repo.deleteRecord', 534 defs: { 535 main: { 536 - type: 'procedure', 537 description: 538 "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", 539 input: { 540 - encoding: 'application/json', 541 schema: { 542 - type: 'object', 543 - required: ['repo', 'collection', 'rkey'], 544 properties: { 545 repo: { 546 - type: 'string', 547 - format: 'at-identifier', 548 description: 549 - 'The handle or DID of the repo (aka, current account).', 550 }, 551 collection: { 552 - type: 'string', 553 - format: 'nsid', 554 - description: 'The NSID of the record collection.', 555 }, 556 rkey: { 557 - type: 'string', 558 - format: 'record-key', 559 - description: 'The Record Key.', 560 }, 561 swapRecord: { 562 - type: 'string', 563 - format: 'cid', 564 description: 565 - 'Compare and swap with the previous record by CID.', 566 }, 567 swapCommit: { 568 - type: 'string', 569 - format: 'cid', 570 description: 571 - 'Compare and swap with the previous commit by CID.', 572 }, 573 }, 574 }, 575 }, 576 output: { 577 - encoding: 'application/json', 578 schema: { 579 - type: 'object', 580 properties: { 581 commit: { 582 - type: 'ref', 583 - ref: 'lex:com.atproto.repo.defs#commitMeta', 584 }, 585 }, 586 }, 587 }, 588 errors: [ 589 { 590 - name: 'InvalidSwap', 591 }, 592 ], 593 }, ··· 595 }, 596 ComAtprotoRepoDescribeRepo: { 597 lexicon: 1, 598 - id: 'com.atproto.repo.describeRepo', 599 defs: { 600 main: { 601 - type: 'query', 602 description: 603 - 'Get information about an account and repository, including the list of collections. Does not require auth.', 604 parameters: { 605 - type: 'params', 606 - required: ['repo'], 607 properties: { 608 repo: { 609 - type: 'string', 610 - format: 'at-identifier', 611 - description: 'The handle or DID of the repo.', 612 }, 613 }, 614 }, 615 output: { 616 - encoding: 'application/json', 617 schema: { 618 - type: 'object', 619 required: [ 620 - 'handle', 621 - 'did', 622 - 'didDoc', 623 - 'collections', 624 - 'handleIsCorrect', 625 ], 626 properties: { 627 handle: { 628 - type: 'string', 629 - format: 'handle', 630 }, 631 did: { 632 - type: 'string', 633 - format: 'did', 634 }, 635 didDoc: { 636 - type: 'unknown', 637 - description: 'The complete DID document for this account.', 638 }, 639 collections: { 640 - type: 'array', 641 description: 642 - 'List of all the collections (NSIDs) for which this repo contains at least one record.', 643 items: { 644 - type: 'string', 645 - format: 'nsid', 646 }, 647 }, 648 handleIsCorrect: { 649 - type: 'boolean', 650 description: 651 - 'Indicates if handle is currently valid (resolves bi-directionally)', 652 }, 653 }, 654 }, ··· 658 }, 659 ComAtprotoRepoGetRecord: { 660 lexicon: 1, 661 - id: 'com.atproto.repo.getRecord', 662 defs: { 663 main: { 664 - type: 'query', 665 description: 666 - 'Get a single record from a repository. Does not require auth.', 667 parameters: { 668 - type: 'params', 669 - required: ['repo', 'collection', 'rkey'], 670 properties: { 671 repo: { 672 - type: 'string', 673 - format: 'at-identifier', 674 - description: 'The handle or DID of the repo.', 675 }, 676 collection: { 677 - type: 'string', 678 - format: 'nsid', 679 - description: 'The NSID of the record collection.', 680 }, 681 rkey: { 682 - type: 'string', 683 - description: 'The Record Key.', 684 - format: 'record-key', 685 }, 686 cid: { 687 - type: 'string', 688 - format: 'cid', 689 description: 690 - 'The CID of the version of the record. If not specified, then return the most recent version.', 691 }, 692 }, 693 }, 694 output: { 695 - encoding: 'application/json', 696 schema: { 697 - type: 'object', 698 - required: ['uri', 'value'], 699 properties: { 700 uri: { 701 - type: 'string', 702 - format: 'at-uri', 703 }, 704 cid: { 705 - type: 'string', 706 - format: 'cid', 707 }, 708 value: { 709 - type: 'unknown', 710 }, 711 }, 712 }, 713 }, 714 errors: [ 715 { 716 - name: 'RecordNotFound', 717 }, 718 ], 719 }, ··· 721 }, 722 ComAtprotoRepoImportRepo: { 723 lexicon: 1, 724 - id: 'com.atproto.repo.importRepo', 725 defs: { 726 main: { 727 - type: 'procedure', 728 description: 729 - 'Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.', 730 input: { 731 - encoding: 'application/vnd.ipld.car', 732 }, 733 }, 734 }, 735 }, 736 ComAtprotoRepoListMissingBlobs: { 737 lexicon: 1, 738 - id: 'com.atproto.repo.listMissingBlobs', 739 defs: { 740 main: { 741 - type: 'query', 742 description: 743 - 'Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.', 744 parameters: { 745 - type: 'params', 746 properties: { 747 limit: { 748 - type: 'integer', 749 minimum: 1, 750 maximum: 1000, 751 default: 500, 752 }, 753 cursor: { 754 - type: 'string', 755 }, 756 }, 757 }, 758 output: { 759 - encoding: 'application/json', 760 schema: { 761 - type: 'object', 762 - required: ['blobs'], 763 properties: { 764 cursor: { 765 - type: 'string', 766 }, 767 blobs: { 768 - type: 'array', 769 items: { 770 - type: 'ref', 771 - ref: 'lex:com.atproto.repo.listMissingBlobs#recordBlob', 772 }, 773 }, 774 }, ··· 776 }, 777 }, 778 recordBlob: { 779 - type: 'object', 780 - required: ['cid', 'recordUri'], 781 properties: { 782 cid: { 783 - type: 'string', 784 - format: 'cid', 785 }, 786 recordUri: { 787 - type: 'string', 788 - format: 'at-uri', 789 }, 790 }, 791 }, ··· 793 }, 794 ComAtprotoRepoListRecords: { 795 lexicon: 1, 796 - id: 'com.atproto.repo.listRecords', 797 defs: { 798 main: { 799 - type: 'query', 800 description: 801 - 'List a range of records in a repository, matching a specific collection. Does not require auth.', 802 parameters: { 803 - type: 'params', 804 - required: ['repo', 'collection'], 805 properties: { 806 repo: { 807 - type: 'string', 808 - format: 'at-identifier', 809 - description: 'The handle or DID of the repo.', 810 }, 811 collection: { 812 - type: 'string', 813 - format: 'nsid', 814 - description: 'The NSID of the record type.', 815 }, 816 limit: { 817 - type: 'integer', 818 minimum: 1, 819 maximum: 100, 820 default: 50, 821 - description: 'The number of records to return.', 822 }, 823 cursor: { 824 - type: 'string', 825 }, 826 rkeyStart: { 827 - type: 'string', 828 description: 829 - 'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)', 830 }, 831 rkeyEnd: { 832 - type: 'string', 833 description: 834 - 'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)', 835 }, 836 reverse: { 837 - type: 'boolean', 838 - description: 'Flag to reverse the order of the returned records.', 839 }, 840 }, 841 }, 842 output: { 843 - encoding: 'application/json', 844 schema: { 845 - type: 'object', 846 - required: ['records'], 847 properties: { 848 cursor: { 849 - type: 'string', 850 }, 851 records: { 852 - type: 'array', 853 items: { 854 - type: 'ref', 855 - ref: 'lex:com.atproto.repo.listRecords#record', 856 }, 857 }, 858 }, ··· 860 }, 861 }, 862 record: { 863 - type: 'object', 864 - required: ['uri', 'cid', 'value'], 865 properties: { 866 uri: { 867 - type: 'string', 868 - format: 'at-uri', 869 }, 870 cid: { 871 - type: 'string', 872 - format: 'cid', 873 }, 874 value: { 875 - type: 'unknown', 876 }, 877 }, 878 }, ··· 880 }, 881 ComAtprotoRepoPutRecord: { 882 lexicon: 1, 883 - id: 'com.atproto.repo.putRecord', 884 defs: { 885 main: { 886 - type: 'procedure', 887 description: 888 - 'Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.', 889 input: { 890 - encoding: 'application/json', 891 schema: { 892 - type: 'object', 893 - required: ['repo', 'collection', 'rkey', 'record'], 894 - nullable: ['swapRecord'], 895 properties: { 896 repo: { 897 - type: 'string', 898 - format: 'at-identifier', 899 description: 900 - 'The handle or DID of the repo (aka, current account).', 901 }, 902 collection: { 903 - type: 'string', 904 - format: 'nsid', 905 - description: 'The NSID of the record collection.', 906 }, 907 rkey: { 908 - type: 'string', 909 - format: 'record-key', 910 - description: 'The Record Key.', 911 maxLength: 512, 912 }, 913 validate: { 914 - type: 'boolean', 915 description: 916 "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 917 }, 918 record: { 919 - type: 'unknown', 920 - description: 'The record to write.', 921 }, 922 swapRecord: { 923 - type: 'string', 924 - format: 'cid', 925 description: 926 - 'Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation', 927 }, 928 swapCommit: { 929 - type: 'string', 930 - format: 'cid', 931 description: 932 - 'Compare and swap with the previous commit by CID.', 933 }, 934 }, 935 }, 936 }, 937 output: { 938 - encoding: 'application/json', 939 schema: { 940 - type: 'object', 941 - required: ['uri', 'cid'], 942 properties: { 943 uri: { 944 - type: 'string', 945 - format: 'at-uri', 946 }, 947 cid: { 948 - type: 'string', 949 - format: 'cid', 950 }, 951 commit: { 952 - type: 'ref', 953 - ref: 'lex:com.atproto.repo.defs#commitMeta', 954 }, 955 validationStatus: { 956 - type: 'string', 957 - knownValues: ['valid', 'unknown'], 958 }, 959 }, 960 }, 961 }, 962 errors: [ 963 { 964 - name: 'InvalidSwap', 965 }, 966 ], 967 }, ··· 969 }, 970 ComAtprotoRepoStrongRef: { 971 lexicon: 1, 972 - id: 'com.atproto.repo.strongRef', 973 - description: 'A URI with a content-hash fingerprint.', 974 defs: { 975 main: { 976 - type: 'object', 977 - required: ['uri', 'cid'], 978 properties: { 979 uri: { 980 - type: 'string', 981 - format: 'at-uri', 982 }, 983 cid: { 984 - type: 'string', 985 - format: 'cid', 986 }, 987 }, 988 }, ··· 990 }, 991 ComAtprotoRepoUploadBlob: { 992 lexicon: 1, 993 - id: 'com.atproto.repo.uploadBlob', 994 defs: { 995 main: { 996 - type: 'procedure', 997 description: 998 - 'Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.', 999 input: { 1000 - encoding: '*/*', 1001 }, 1002 output: { 1003 - encoding: 'application/json', 1004 schema: { 1005 - type: 'object', 1006 - required: ['blob'], 1007 properties: { 1008 blob: { 1009 - type: 'blob', 1010 }, 1011 }, 1012 }, ··· 1016 }, 1017 PubLeafletBlocksBlockquote: { 1018 lexicon: 1, 1019 - id: 'pub.leaflet.blocks.blockquote', 1020 defs: { 1021 main: { 1022 - type: 'object', 1023 - required: ['plaintext'], 1024 properties: { 1025 plaintext: { 1026 - type: 'string', 1027 }, 1028 facets: { 1029 - type: 'array', 1030 items: { 1031 - type: 'ref', 1032 - ref: 'lex:pub.leaflet.richtext.facet', 1033 }, 1034 }, 1035 }, ··· 1038 }, 1039 PubLeafletBlocksBskyPost: { 1040 lexicon: 1, 1041 - id: 'pub.leaflet.blocks.bskyPost', 1042 defs: { 1043 main: { 1044 - type: 'object', 1045 - required: ['postRef'], 1046 properties: { 1047 postRef: { 1048 - type: 'ref', 1049 - ref: 'lex:com.atproto.repo.strongRef', 1050 }, 1051 }, 1052 }, ··· 1054 }, 1055 PubLeafletBlocksButton: { 1056 lexicon: 1, 1057 - id: 'pub.leaflet.blocks.button', 1058 defs: { 1059 main: { 1060 - type: 'object', 1061 - required: ['text', 'url'], 1062 properties: { 1063 text: { 1064 - type: 'string', 1065 }, 1066 url: { 1067 - type: 'string', 1068 - format: 'uri', 1069 }, 1070 }, 1071 }, ··· 1073 }, 1074 PubLeafletBlocksCode: { 1075 lexicon: 1, 1076 - id: 'pub.leaflet.blocks.code', 1077 defs: { 1078 main: { 1079 - type: 'object', 1080 - required: ['plaintext'], 1081 properties: { 1082 plaintext: { 1083 - type: 'string', 1084 }, 1085 language: { 1086 - type: 'string', 1087 }, 1088 syntaxHighlightingTheme: { 1089 - type: 'string', 1090 }, 1091 }, 1092 }, ··· 1094 }, 1095 PubLeafletBlocksHeader: { 1096 lexicon: 1, 1097 - id: 'pub.leaflet.blocks.header', 1098 defs: { 1099 main: { 1100 - type: 'object', 1101 - required: ['plaintext'], 1102 properties: { 1103 level: { 1104 - type: 'integer', 1105 minimum: 1, 1106 maximum: 6, 1107 }, 1108 plaintext: { 1109 - type: 'string', 1110 }, 1111 facets: { 1112 - type: 'array', 1113 items: { 1114 - type: 'ref', 1115 - ref: 'lex:pub.leaflet.richtext.facet', 1116 }, 1117 }, 1118 }, ··· 1121 }, 1122 PubLeafletBlocksHorizontalRule: { 1123 lexicon: 1, 1124 - id: 'pub.leaflet.blocks.horizontalRule', 1125 defs: { 1126 main: { 1127 - type: 'object', 1128 required: [], 1129 properties: {}, 1130 }, ··· 1132 }, 1133 PubLeafletBlocksIframe: { 1134 lexicon: 1, 1135 - id: 'pub.leaflet.blocks.iframe', 1136 defs: { 1137 main: { 1138 - type: 'object', 1139 - required: ['url'], 1140 properties: { 1141 url: { 1142 - type: 'string', 1143 - format: 'uri', 1144 }, 1145 height: { 1146 - type: 'integer', 1147 minimum: 16, 1148 maximum: 1600, 1149 }, ··· 1153 }, 1154 PubLeafletBlocksImage: { 1155 lexicon: 1, 1156 - id: 'pub.leaflet.blocks.image', 1157 defs: { 1158 main: { 1159 - type: 'object', 1160 - required: ['image', 'aspectRatio'], 1161 properties: { 1162 image: { 1163 - type: 'blob', 1164 - accept: ['image/*'], 1165 maxSize: 1000000, 1166 }, 1167 alt: { 1168 - type: 'string', 1169 description: 1170 - 'Alt text description of the image, for accessibility.', 1171 }, 1172 aspectRatio: { 1173 - type: 'ref', 1174 - ref: 'lex:pub.leaflet.blocks.image#aspectRatio', 1175 }, 1176 }, 1177 }, 1178 aspectRatio: { 1179 - type: 'object', 1180 - required: ['width', 'height'], 1181 properties: { 1182 width: { 1183 - type: 'integer', 1184 }, 1185 height: { 1186 - type: 'integer', 1187 }, 1188 }, 1189 }, ··· 1191 }, 1192 PubLeafletBlocksMath: { 1193 lexicon: 1, 1194 - id: 'pub.leaflet.blocks.math', 1195 defs: { 1196 main: { 1197 - type: 'object', 1198 - required: ['tex'], 1199 properties: { 1200 tex: { 1201 - type: 'string', 1202 }, 1203 }, 1204 }, ··· 1206 }, 1207 PubLeafletBlocksPage: { 1208 lexicon: 1, 1209 - id: 'pub.leaflet.blocks.page', 1210 defs: { 1211 main: { 1212 - type: 'object', 1213 - required: ['id'], 1214 properties: { 1215 id: { 1216 - type: 'string', 1217 }, 1218 }, 1219 }, ··· 1221 }, 1222 PubLeafletBlocksPoll: { 1223 lexicon: 1, 1224 - id: 'pub.leaflet.blocks.poll', 1225 defs: { 1226 main: { 1227 - type: 'object', 1228 - required: ['pollRef'], 1229 properties: { 1230 pollRef: { 1231 - type: 'ref', 1232 - ref: 'lex:com.atproto.repo.strongRef', 1233 }, 1234 }, 1235 }, ··· 1237 }, 1238 PubLeafletBlocksText: { 1239 lexicon: 1, 1240 - id: 'pub.leaflet.blocks.text', 1241 defs: { 1242 main: { 1243 - type: 'object', 1244 - required: ['plaintext'], 1245 properties: { 1246 plaintext: { 1247 - type: 'string', 1248 }, 1249 facets: { 1250 - type: 'array', 1251 items: { 1252 - type: 'ref', 1253 - ref: 'lex:pub.leaflet.richtext.facet', 1254 }, 1255 }, 1256 }, ··· 1259 }, 1260 PubLeafletBlocksUnorderedList: { 1261 lexicon: 1, 1262 - id: 'pub.leaflet.blocks.unorderedList', 1263 defs: { 1264 main: { 1265 - type: 'object', 1266 - required: ['children'], 1267 properties: { 1268 children: { 1269 - type: 'array', 1270 items: { 1271 - type: 'ref', 1272 - ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 1273 }, 1274 }, 1275 }, 1276 }, 1277 listItem: { 1278 - type: 'object', 1279 - required: ['content'], 1280 properties: { 1281 content: { 1282 - type: 'union', 1283 refs: [ 1284 - 'lex:pub.leaflet.blocks.text', 1285 - 'lex:pub.leaflet.blocks.header', 1286 - 'lex:pub.leaflet.blocks.image', 1287 ], 1288 }, 1289 children: { 1290 - type: 'array', 1291 items: { 1292 - type: 'ref', 1293 - ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 1294 }, 1295 }, 1296 }, ··· 1299 }, 1300 PubLeafletBlocksWebsite: { 1301 lexicon: 1, 1302 - id: 'pub.leaflet.blocks.website', 1303 defs: { 1304 main: { 1305 - type: 'object', 1306 - required: ['src'], 1307 properties: { 1308 previewImage: { 1309 - type: 'blob', 1310 - accept: ['image/*'], 1311 maxSize: 1000000, 1312 }, 1313 title: { 1314 - type: 'string', 1315 }, 1316 description: { 1317 - type: 'string', 1318 }, 1319 src: { 1320 - type: 'string', 1321 - format: 'uri', 1322 }, 1323 }, 1324 }, ··· 1326 }, 1327 PubLeafletComment: { 1328 lexicon: 1, 1329 - id: 'pub.leaflet.comment', 1330 revision: 1, 1331 - description: 'A lexicon for comments on documents', 1332 defs: { 1333 main: { 1334 - type: 'record', 1335 - key: 'tid', 1336 - description: 'Record containing a comment', 1337 record: { 1338 - type: 'object', 1339 - required: ['subject', 'plaintext', 'createdAt'], 1340 properties: { 1341 subject: { 1342 - type: 'string', 1343 - format: 'at-uri', 1344 }, 1345 createdAt: { 1346 - type: 'string', 1347 - format: 'datetime', 1348 }, 1349 reply: { 1350 - type: 'ref', 1351 - ref: 'lex:pub.leaflet.comment#replyRef', 1352 }, 1353 plaintext: { 1354 - type: 'string', 1355 }, 1356 facets: { 1357 - type: 'array', 1358 items: { 1359 - type: 'ref', 1360 - ref: 'lex:pub.leaflet.richtext.facet', 1361 }, 1362 }, 1363 onPage: { 1364 - type: 'string', 1365 }, 1366 attachment: { 1367 - type: 'union', 1368 - refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], 1369 }, 1370 }, 1371 }, 1372 }, 1373 linearDocumentQuote: { 1374 - type: 'object', 1375 - required: ['document', 'quote'], 1376 properties: { 1377 document: { 1378 - type: 'string', 1379 - format: 'at-uri', 1380 }, 1381 quote: { 1382 - type: 'ref', 1383 - ref: 'lex:pub.leaflet.pages.linearDocument#quote', 1384 }, 1385 }, 1386 }, 1387 replyRef: { 1388 - type: 'object', 1389 - required: ['parent'], 1390 properties: { 1391 parent: { 1392 - type: 'string', 1393 - format: 'at-uri', 1394 }, 1395 }, 1396 }, ··· 1398 }, 1399 PubLeafletDocument: { 1400 lexicon: 1, 1401 - id: 'pub.leaflet.document', 1402 revision: 1, 1403 - description: 'A lexicon for long form rich media documents', 1404 defs: { 1405 main: { 1406 - type: 'record', 1407 - key: 'tid', 1408 - description: 'Record containing a document', 1409 record: { 1410 - type: 'object', 1411 - required: ['pages', 'author', 'title'], 1412 properties: { 1413 title: { 1414 - type: 'string', 1415 maxLength: 1280, 1416 maxGraphemes: 128, 1417 }, 1418 postRef: { 1419 - type: 'ref', 1420 - ref: 'lex:com.atproto.repo.strongRef', 1421 }, 1422 description: { 1423 - type: 'string', 1424 maxLength: 3000, 1425 maxGraphemes: 300, 1426 }, 1427 publishedAt: { 1428 - type: 'string', 1429 - format: 'datetime', 1430 }, 1431 publication: { 1432 - type: 'string', 1433 - format: 'at-uri', 1434 }, 1435 author: { 1436 - type: 'string', 1437 - format: 'at-identifier', 1438 }, 1439 theme: { 1440 - type: 'ref', 1441 - ref: 'lex:pub.leaflet.publication#theme', 1442 }, 1443 tags: { 1444 - type: 'array', 1445 items: { 1446 - type: 'string', 1447 maxLength: 50, 1448 }, 1449 }, 1450 pages: { 1451 - type: 'array', 1452 items: { 1453 - type: 'union', 1454 refs: [ 1455 - 'lex:pub.leaflet.pages.linearDocument', 1456 - 'lex:pub.leaflet.pages.canvas', 1457 ], 1458 }, 1459 }, ··· 1464 }, 1465 PubLeafletGraphSubscription: { 1466 lexicon: 1, 1467 - id: 'pub.leaflet.graph.subscription', 1468 defs: { 1469 main: { 1470 - type: 'record', 1471 - key: 'tid', 1472 - description: 'Record declaring a subscription to a publication', 1473 record: { 1474 - type: 'object', 1475 - required: ['publication'], 1476 properties: { 1477 publication: { 1478 - type: 'string', 1479 - format: 'at-uri', 1480 }, 1481 }, 1482 }, ··· 1485 }, 1486 PubLeafletPagesCanvas: { 1487 lexicon: 1, 1488 - id: 'pub.leaflet.pages.canvas', 1489 defs: { 1490 main: { 1491 - type: 'object', 1492 - required: ['blocks'], 1493 properties: { 1494 id: { 1495 - type: 'string', 1496 }, 1497 blocks: { 1498 - type: 'array', 1499 items: { 1500 - type: 'ref', 1501 - ref: 'lex:pub.leaflet.pages.canvas#block', 1502 }, 1503 }, 1504 }, 1505 }, 1506 block: { 1507 - type: 'object', 1508 - required: ['block', 'x', 'y', 'width'], 1509 properties: { 1510 block: { 1511 - type: 'union', 1512 refs: [ 1513 - 'lex:pub.leaflet.blocks.iframe', 1514 - 'lex:pub.leaflet.blocks.text', 1515 - 'lex:pub.leaflet.blocks.blockquote', 1516 - 'lex:pub.leaflet.blocks.header', 1517 - 'lex:pub.leaflet.blocks.image', 1518 - 'lex:pub.leaflet.blocks.unorderedList', 1519 - 'lex:pub.leaflet.blocks.website', 1520 - 'lex:pub.leaflet.blocks.math', 1521 - 'lex:pub.leaflet.blocks.code', 1522 - 'lex:pub.leaflet.blocks.horizontalRule', 1523 - 'lex:pub.leaflet.blocks.bskyPost', 1524 - 'lex:pub.leaflet.blocks.page', 1525 - 'lex:pub.leaflet.blocks.poll', 1526 - 'lex:pub.leaflet.blocks.button', 1527 ], 1528 }, 1529 x: { 1530 - type: 'integer', 1531 }, 1532 y: { 1533 - type: 'integer', 1534 }, 1535 width: { 1536 - type: 'integer', 1537 }, 1538 height: { 1539 - type: 'integer', 1540 }, 1541 rotation: { 1542 - type: 'integer', 1543 - description: 'The rotation of the block in degrees', 1544 }, 1545 }, 1546 }, 1547 textAlignLeft: { 1548 - type: 'token', 1549 }, 1550 textAlignCenter: { 1551 - type: 'token', 1552 }, 1553 textAlignRight: { 1554 - type: 'token', 1555 }, 1556 quote: { 1557 - type: 'object', 1558 - required: ['start', 'end'], 1559 properties: { 1560 start: { 1561 - type: 'ref', 1562 - ref: 'lex:pub.leaflet.pages.canvas#position', 1563 }, 1564 end: { 1565 - type: 'ref', 1566 - ref: 'lex:pub.leaflet.pages.canvas#position', 1567 }, 1568 }, 1569 }, 1570 position: { 1571 - type: 'object', 1572 - required: ['block', 'offset'], 1573 properties: { 1574 block: { 1575 - type: 'array', 1576 items: { 1577 - type: 'integer', 1578 }, 1579 }, 1580 offset: { 1581 - type: 'integer', 1582 }, 1583 }, 1584 }, ··· 1586 }, 1587 PubLeafletPagesLinearDocument: { 1588 lexicon: 1, 1589 - id: 'pub.leaflet.pages.linearDocument', 1590 defs: { 1591 main: { 1592 - type: 'object', 1593 - required: ['blocks'], 1594 properties: { 1595 id: { 1596 - type: 'string', 1597 }, 1598 blocks: { 1599 - type: 'array', 1600 items: { 1601 - type: 'ref', 1602 - ref: 'lex:pub.leaflet.pages.linearDocument#block', 1603 }, 1604 }, 1605 }, 1606 }, 1607 block: { 1608 - type: 'object', 1609 - required: ['block'], 1610 properties: { 1611 block: { 1612 - type: 'union', 1613 refs: [ 1614 - 'lex:pub.leaflet.blocks.iframe', 1615 - 'lex:pub.leaflet.blocks.text', 1616 - 'lex:pub.leaflet.blocks.blockquote', 1617 - 'lex:pub.leaflet.blocks.header', 1618 - 'lex:pub.leaflet.blocks.image', 1619 - 'lex:pub.leaflet.blocks.unorderedList', 1620 - 'lex:pub.leaflet.blocks.website', 1621 - 'lex:pub.leaflet.blocks.math', 1622 - 'lex:pub.leaflet.blocks.code', 1623 - 'lex:pub.leaflet.blocks.horizontalRule', 1624 - 'lex:pub.leaflet.blocks.bskyPost', 1625 - 'lex:pub.leaflet.blocks.page', 1626 - 'lex:pub.leaflet.blocks.poll', 1627 - 'lex:pub.leaflet.blocks.button', 1628 ], 1629 }, 1630 alignment: { 1631 - type: 'string', 1632 knownValues: [ 1633 - 'lex:pub.leaflet.pages.linearDocument#textAlignLeft', 1634 - 'lex:pub.leaflet.pages.linearDocument#textAlignCenter', 1635 - 'lex:pub.leaflet.pages.linearDocument#textAlignRight', 1636 - 'lex:pub.leaflet.pages.linearDocument#textAlignJustify', 1637 ], 1638 }, 1639 }, 1640 }, 1641 textAlignLeft: { 1642 - type: 'token', 1643 }, 1644 textAlignCenter: { 1645 - type: 'token', 1646 }, 1647 textAlignRight: { 1648 - type: 'token', 1649 }, 1650 textAlignJustify: { 1651 - type: 'token', 1652 }, 1653 quote: { 1654 - type: 'object', 1655 - required: ['start', 'end'], 1656 properties: { 1657 start: { 1658 - type: 'ref', 1659 - ref: 'lex:pub.leaflet.pages.linearDocument#position', 1660 }, 1661 end: { 1662 - type: 'ref', 1663 - ref: 'lex:pub.leaflet.pages.linearDocument#position', 1664 }, 1665 }, 1666 }, 1667 position: { 1668 - type: 'object', 1669 - required: ['block', 'offset'], 1670 properties: { 1671 block: { 1672 - type: 'array', 1673 items: { 1674 - type: 'integer', 1675 }, 1676 }, 1677 offset: { 1678 - type: 'integer', 1679 }, 1680 }, 1681 }, ··· 1683 }, 1684 PubLeafletPollDefinition: { 1685 lexicon: 1, 1686 - id: 'pub.leaflet.poll.definition', 1687 defs: { 1688 main: { 1689 - type: 'record', 1690 - key: 'tid', 1691 - description: 'Record declaring a poll', 1692 record: { 1693 - type: 'object', 1694 - required: ['name', 'options'], 1695 properties: { 1696 name: { 1697 - type: 'string', 1698 maxLength: 500, 1699 maxGraphemes: 100, 1700 }, 1701 options: { 1702 - type: 'array', 1703 items: { 1704 - type: 'ref', 1705 - ref: 'lex:pub.leaflet.poll.definition#option', 1706 }, 1707 }, 1708 endDate: { 1709 - type: 'string', 1710 - format: 'datetime', 1711 }, 1712 }, 1713 }, 1714 }, 1715 option: { 1716 - type: 'object', 1717 properties: { 1718 text: { 1719 - type: 'string', 1720 maxLength: 500, 1721 maxGraphemes: 50, 1722 }, ··· 1726 }, 1727 PubLeafletPollVote: { 1728 lexicon: 1, 1729 - id: 'pub.leaflet.poll.vote', 1730 defs: { 1731 main: { 1732 - type: 'record', 1733 - key: 'tid', 1734 - description: 'Record declaring a vote on a poll', 1735 record: { 1736 - type: 'object', 1737 - required: ['poll', 'option'], 1738 properties: { 1739 poll: { 1740 - type: 'ref', 1741 - ref: 'lex:com.atproto.repo.strongRef', 1742 }, 1743 option: { 1744 - type: 'array', 1745 items: { 1746 - type: 'string', 1747 }, 1748 }, 1749 }, ··· 1753 }, 1754 PubLeafletPublication: { 1755 lexicon: 1, 1756 - id: 'pub.leaflet.publication', 1757 defs: { 1758 main: { 1759 - type: 'record', 1760 - key: 'tid', 1761 - description: 'Record declaring a publication', 1762 record: { 1763 - type: 'object', 1764 - required: ['name'], 1765 properties: { 1766 name: { 1767 - type: 'string', 1768 maxLength: 2000, 1769 }, 1770 base_path: { 1771 - type: 'string', 1772 }, 1773 description: { 1774 - type: 'string', 1775 maxLength: 2000, 1776 }, 1777 icon: { 1778 - type: 'blob', 1779 - accept: ['image/*'], 1780 maxSize: 1000000, 1781 }, 1782 theme: { 1783 - type: 'ref', 1784 - ref: 'lex:pub.leaflet.publication#theme', 1785 }, 1786 preferences: { 1787 - type: 'ref', 1788 - ref: 'lex:pub.leaflet.publication#preferences', 1789 }, 1790 }, 1791 }, 1792 }, 1793 preferences: { 1794 - type: 'object', 1795 properties: { 1796 showInDiscover: { 1797 - type: 'boolean', 1798 default: true, 1799 }, 1800 showComments: { 1801 - type: 'boolean', 1802 default: true, 1803 }, 1804 }, 1805 }, 1806 theme: { 1807 - type: 'object', 1808 properties: { 1809 backgroundColor: { 1810 - type: 'union', 1811 refs: [ 1812 - 'lex:pub.leaflet.theme.color#rgba', 1813 - 'lex:pub.leaflet.theme.color#rgb', 1814 ], 1815 }, 1816 backgroundImage: { 1817 - type: 'ref', 1818 - ref: 'lex:pub.leaflet.theme.backgroundImage', 1819 }, 1820 primary: { 1821 - type: 'union', 1822 refs: [ 1823 - 'lex:pub.leaflet.theme.color#rgba', 1824 - 'lex:pub.leaflet.theme.color#rgb', 1825 ], 1826 }, 1827 pageBackground: { 1828 - type: 'union', 1829 refs: [ 1830 - 'lex:pub.leaflet.theme.color#rgba', 1831 - 'lex:pub.leaflet.theme.color#rgb', 1832 ], 1833 }, 1834 showPageBackground: { 1835 - type: 'boolean', 1836 default: false, 1837 }, 1838 accentBackground: { 1839 - type: 'union', 1840 refs: [ 1841 - 'lex:pub.leaflet.theme.color#rgba', 1842 - 'lex:pub.leaflet.theme.color#rgb', 1843 ], 1844 }, 1845 accentText: { 1846 - type: 'union', 1847 refs: [ 1848 - 'lex:pub.leaflet.theme.color#rgba', 1849 - 'lex:pub.leaflet.theme.color#rgb', 1850 ], 1851 }, 1852 }, ··· 1855 }, 1856 PubLeafletRichtextFacet: { 1857 lexicon: 1, 1858 - id: 'pub.leaflet.richtext.facet', 1859 defs: { 1860 main: { 1861 - type: 'object', 1862 - description: 'Annotation of a sub-string within rich text.', 1863 - required: ['index', 'features'], 1864 properties: { 1865 index: { 1866 - type: 'ref', 1867 - ref: 'lex:pub.leaflet.richtext.facet#byteSlice', 1868 }, 1869 features: { 1870 - type: 'array', 1871 items: { 1872 - type: 'union', 1873 refs: [ 1874 - 'lex:pub.leaflet.richtext.facet#link', 1875 - 'lex:pub.leaflet.richtext.facet#didMention', 1876 - 'lex:pub.leaflet.richtext.facet#atMention', 1877 - 'lex:pub.leaflet.richtext.facet#code', 1878 - 'lex:pub.leaflet.richtext.facet#highlight', 1879 - 'lex:pub.leaflet.richtext.facet#underline', 1880 - 'lex:pub.leaflet.richtext.facet#strikethrough', 1881 - 'lex:pub.leaflet.richtext.facet#id', 1882 - 'lex:pub.leaflet.richtext.facet#bold', 1883 - 'lex:pub.leaflet.richtext.facet#italic', 1884 ], 1885 }, 1886 }, 1887 }, 1888 }, 1889 byteSlice: { 1890 - type: 'object', 1891 description: 1892 - 'Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.', 1893 - required: ['byteStart', 'byteEnd'], 1894 properties: { 1895 byteStart: { 1896 - type: 'integer', 1897 minimum: 0, 1898 }, 1899 byteEnd: { 1900 - type: 'integer', 1901 minimum: 0, 1902 }, 1903 }, 1904 }, 1905 link: { 1906 - type: 'object', 1907 description: 1908 - 'Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.', 1909 - required: ['uri'], 1910 properties: { 1911 uri: { 1912 - type: 'string', 1913 }, 1914 }, 1915 }, 1916 didMention: { 1917 - type: 'object', 1918 - description: 'Facet feature for mentioning a did.', 1919 - required: ['did'], 1920 properties: { 1921 did: { 1922 - type: 'string', 1923 - format: 'did', 1924 }, 1925 }, 1926 }, 1927 atMention: { 1928 - type: 'object', 1929 - description: 'Facet feature for mentioning an AT URI.', 1930 - required: ['atURI'], 1931 properties: { 1932 atURI: { 1933 - type: 'string', 1934 - format: 'uri', 1935 }, 1936 }, 1937 }, 1938 code: { 1939 - type: 'object', 1940 - description: 'Facet feature for inline code.', 1941 required: [], 1942 properties: {}, 1943 }, 1944 highlight: { 1945 - type: 'object', 1946 - description: 'Facet feature for highlighted text.', 1947 required: [], 1948 properties: {}, 1949 }, 1950 underline: { 1951 - type: 'object', 1952 - description: 'Facet feature for underline markup', 1953 required: [], 1954 properties: {}, 1955 }, 1956 strikethrough: { 1957 - type: 'object', 1958 - description: 'Facet feature for strikethrough markup', 1959 required: [], 1960 properties: {}, 1961 }, 1962 id: { 1963 - type: 'object', 1964 description: 1965 - 'Facet feature for an identifier. Used for linking to a segment', 1966 required: [], 1967 properties: { 1968 id: { 1969 - type: 'string', 1970 }, 1971 }, 1972 }, 1973 bold: { 1974 - type: 'object', 1975 - description: 'Facet feature for bold text', 1976 required: [], 1977 properties: {}, 1978 }, 1979 italic: { 1980 - type: 'object', 1981 - description: 'Facet feature for italic text', 1982 required: [], 1983 properties: {}, 1984 }, ··· 1986 }, 1987 PubLeafletThemeBackgroundImage: { 1988 lexicon: 1, 1989 - id: 'pub.leaflet.theme.backgroundImage', 1990 defs: { 1991 main: { 1992 - type: 'object', 1993 - required: ['image'], 1994 properties: { 1995 image: { 1996 - type: 'blob', 1997 - accept: ['image/*'], 1998 maxSize: 1000000, 1999 }, 2000 width: { 2001 - type: 'integer', 2002 }, 2003 repeat: { 2004 - type: 'boolean', 2005 }, 2006 }, 2007 }, ··· 2009 }, 2010 PubLeafletThemeColor: { 2011 lexicon: 1, 2012 - id: 'pub.leaflet.theme.color', 2013 defs: { 2014 rgba: { 2015 - type: 'object', 2016 - required: ['r', 'g', 'b', 'a'], 2017 properties: { 2018 r: { 2019 - type: 'integer', 2020 maximum: 255, 2021 minimum: 0, 2022 }, 2023 g: { 2024 - type: 'integer', 2025 maximum: 255, 2026 minimum: 0, 2027 }, 2028 b: { 2029 - type: 'integer', 2030 maximum: 255, 2031 minimum: 0, 2032 }, 2033 a: { 2034 - type: 'integer', 2035 maximum: 100, 2036 minimum: 0, 2037 }, 2038 }, 2039 }, 2040 rgb: { 2041 - type: 'object', 2042 - required: ['r', 'g', 'b'], 2043 properties: { 2044 r: { 2045 - type: 'integer', 2046 maximum: 255, 2047 minimum: 0, 2048 }, 2049 g: { 2050 - type: 'integer', 2051 maximum: 255, 2052 minimum: 0, 2053 }, 2054 b: { 2055 - type: 'integer', 2056 maximum: 255, 2057 minimum: 0, 2058 }, ··· 2060 }, 2061 }, 2062 }, 2063 - } as const satisfies Record<string, LexiconDoc> 2064 - export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 2065 - export const lexicons: Lexicons = new Lexicons(schemas) 2066 2067 export function validate<T extends { $type: string }>( 2068 v: unknown, 2069 id: string, 2070 hash: string, 2071 requiredType: true, 2072 - ): ValidationResult<T> 2073 export function validate<T extends { $type?: string }>( 2074 v: unknown, 2075 id: string, 2076 hash: string, 2077 requiredType?: false, 2078 - ): ValidationResult<T> 2079 export function validate( 2080 v: unknown, 2081 id: string, ··· 2087 : { 2088 success: false, 2089 error: new ValidationError( 2090 - `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 2091 ), 2092 - } 2093 } 2094 2095 export const ids = { 2096 - AppBskyActorProfile: 'app.bsky.actor.profile', 2097 - ComAtprotoLabelDefs: 'com.atproto.label.defs', 2098 - ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites', 2099 - ComAtprotoRepoCreateRecord: 'com.atproto.repo.createRecord', 2100 - ComAtprotoRepoDefs: 'com.atproto.repo.defs', 2101 - ComAtprotoRepoDeleteRecord: 'com.atproto.repo.deleteRecord', 2102 - ComAtprotoRepoDescribeRepo: 'com.atproto.repo.describeRepo', 2103 - ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord', 2104 - ComAtprotoRepoImportRepo: 'com.atproto.repo.importRepo', 2105 - ComAtprotoRepoListMissingBlobs: 'com.atproto.repo.listMissingBlobs', 2106 - ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords', 2107 - ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', 2108 - ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 2109 - ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', 2110 - PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote', 2111 - PubLeafletBlocksBskyPost: 'pub.leaflet.blocks.bskyPost', 2112 - PubLeafletBlocksButton: 'pub.leaflet.blocks.button', 2113 - PubLeafletBlocksCode: 'pub.leaflet.blocks.code', 2114 - PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 2115 - PubLeafletBlocksHorizontalRule: 'pub.leaflet.blocks.horizontalRule', 2116 - PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 2117 - PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 2118 - PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 2119 - PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2120 - PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2121 - PubLeafletBlocksText: 'pub.leaflet.blocks.text', 2122 - PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 2123 - PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 2124 - PubLeafletComment: 'pub.leaflet.comment', 2125 - PubLeafletDocument: 'pub.leaflet.document', 2126 - PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2127 - PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 2128 - PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2129 - PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 2130 - PubLeafletPollVote: 'pub.leaflet.poll.vote', 2131 - PubLeafletPublication: 'pub.leaflet.publication', 2132 - PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 2133 - PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 2134 - PubLeafletThemeColor: 'pub.leaflet.theme.color', 2135 - } as const
··· 6 Lexicons, 7 ValidationError, 8 type ValidationResult, 9 + } from "@atproto/lexicon"; 10 + import { type $Typed, is$typed, maybe$typed } from "./util"; 11 12 export const schemaDict = { 13 AppBskyActorProfile: { 14 lexicon: 1, 15 + id: "app.bsky.actor.profile", 16 defs: { 17 main: { 18 + type: "record", 19 + description: "A declaration of a Bluesky account profile.", 20 + key: "literal:self", 21 record: { 22 + type: "object", 23 properties: { 24 displayName: { 25 + type: "string", 26 maxGraphemes: 64, 27 maxLength: 640, 28 }, 29 description: { 30 + type: "string", 31 + description: "Free-form profile description text.", 32 maxGraphemes: 256, 33 maxLength: 2560, 34 }, 35 avatar: { 36 + type: "blob", 37 description: 38 "Small image to be displayed next to posts from account. AKA, 'profile picture'", 39 + accept: ["image/png", "image/jpeg"], 40 maxSize: 1000000, 41 }, 42 banner: { 43 + type: "blob", 44 description: 45 + "Larger horizontal image to display behind profile view.", 46 + accept: ["image/png", "image/jpeg"], 47 maxSize: 1000000, 48 }, 49 labels: { 50 + type: "union", 51 description: 52 + "Self-label values, specific to the Bluesky application, on the overall account.", 53 + refs: ["lex:com.atproto.label.defs#selfLabels"], 54 }, 55 joinedViaStarterPack: { 56 + type: "ref", 57 + ref: "lex:com.atproto.repo.strongRef", 58 }, 59 pinnedPost: { 60 + type: "ref", 61 + ref: "lex:com.atproto.repo.strongRef", 62 }, 63 createdAt: { 64 + type: "string", 65 + format: "datetime", 66 }, 67 }, 68 }, ··· 71 }, 72 ComAtprotoLabelDefs: { 73 lexicon: 1, 74 + id: "com.atproto.label.defs", 75 defs: { 76 label: { 77 + type: "object", 78 description: 79 + "Metadata tag on an atproto resource (eg, repo or record).", 80 + required: ["src", "uri", "val", "cts"], 81 properties: { 82 ver: { 83 + type: "integer", 84 + description: "The AT Protocol version of the label object.", 85 }, 86 src: { 87 + type: "string", 88 + format: "did", 89 + description: "DID of the actor who created this label.", 90 }, 91 uri: { 92 + type: "string", 93 + format: "uri", 94 description: 95 + "AT URI of the record, repository (account), or other resource that this label applies to.", 96 }, 97 cid: { 98 + type: "string", 99 + format: "cid", 100 description: 101 "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 102 }, 103 val: { 104 + type: "string", 105 maxLength: 128, 106 description: 107 + "The short string name of the value or type of this label.", 108 }, 109 neg: { 110 + type: "boolean", 111 description: 112 + "If true, this is a negation label, overwriting a previous label.", 113 }, 114 cts: { 115 + type: "string", 116 + format: "datetime", 117 + description: "Timestamp when this label was created.", 118 }, 119 exp: { 120 + type: "string", 121 + format: "datetime", 122 description: 123 + "Timestamp at which this label expires (no longer applies).", 124 }, 125 sig: { 126 + type: "bytes", 127 + description: "Signature of dag-cbor encoded label.", 128 }, 129 }, 130 }, 131 selfLabels: { 132 + type: "object", 133 description: 134 + "Metadata tags on an atproto record, published by the author within the record.", 135 + required: ["values"], 136 properties: { 137 values: { 138 + type: "array", 139 items: { 140 + type: "ref", 141 + ref: "lex:com.atproto.label.defs#selfLabel", 142 }, 143 maxLength: 10, 144 }, 145 }, 146 }, 147 selfLabel: { 148 + type: "object", 149 description: 150 + "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 151 + required: ["val"], 152 properties: { 153 val: { 154 + type: "string", 155 maxLength: 128, 156 description: 157 + "The short string name of the value or type of this label.", 158 }, 159 }, 160 }, 161 labelValueDefinition: { 162 + type: "object", 163 description: 164 + "Declares a label value and its expected interpretations and behaviors.", 165 + required: ["identifier", "severity", "blurs", "locales"], 166 properties: { 167 identifier: { 168 + type: "string", 169 description: 170 "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 171 maxLength: 100, 172 maxGraphemes: 100, 173 }, 174 severity: { 175 + type: "string", 176 description: 177 "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 178 + knownValues: ["inform", "alert", "none"], 179 }, 180 blurs: { 181 + type: "string", 182 description: 183 "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 184 + knownValues: ["content", "media", "none"], 185 }, 186 defaultSetting: { 187 + type: "string", 188 + description: "The default setting for this label.", 189 + knownValues: ["ignore", "warn", "hide"], 190 + default: "warn", 191 }, 192 adultOnly: { 193 + type: "boolean", 194 description: 195 + "Does the user need to have adult content enabled in order to configure this label?", 196 }, 197 locales: { 198 + type: "array", 199 items: { 200 + type: "ref", 201 + ref: "lex:com.atproto.label.defs#labelValueDefinitionStrings", 202 }, 203 }, 204 }, 205 }, 206 labelValueDefinitionStrings: { 207 + type: "object", 208 description: 209 + "Strings which describe the label in the UI, localized into a specific language.", 210 + required: ["lang", "name", "description"], 211 properties: { 212 lang: { 213 + type: "string", 214 description: 215 + "The code of the language these strings are written in.", 216 + format: "language", 217 }, 218 name: { 219 + type: "string", 220 + description: "A short human-readable name for the label.", 221 maxGraphemes: 64, 222 maxLength: 640, 223 }, 224 description: { 225 + type: "string", 226 description: 227 + "A longer description of what the label means and why it might be applied.", 228 maxGraphemes: 10000, 229 maxLength: 100000, 230 }, 231 }, 232 }, 233 labelValue: { 234 + type: "string", 235 knownValues: [ 236 + "!hide", 237 + "!no-promote", 238 + "!warn", 239 + "!no-unauthenticated", 240 + "dmca-violation", 241 + "doxxing", 242 + "porn", 243 + "sexual", 244 + "nudity", 245 + "nsfl", 246 + "gore", 247 ], 248 }, 249 }, 250 }, 251 ComAtprotoRepoApplyWrites: { 252 lexicon: 1, 253 + id: "com.atproto.repo.applyWrites", 254 defs: { 255 main: { 256 + type: "procedure", 257 description: 258 + "Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.", 259 input: { 260 + encoding: "application/json", 261 schema: { 262 + type: "object", 263 + required: ["repo", "writes"], 264 properties: { 265 repo: { 266 + type: "string", 267 + format: "at-identifier", 268 description: 269 + "The handle or DID of the repo (aka, current account).", 270 }, 271 validate: { 272 + type: "boolean", 273 description: 274 "Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.", 275 }, 276 writes: { 277 + type: "array", 278 items: { 279 + type: "union", 280 refs: [ 281 + "lex:com.atproto.repo.applyWrites#create", 282 + "lex:com.atproto.repo.applyWrites#update", 283 + "lex:com.atproto.repo.applyWrites#delete", 284 ], 285 closed: true, 286 }, 287 }, 288 swapCommit: { 289 + type: "string", 290 description: 291 + "If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.", 292 + format: "cid", 293 }, 294 }, 295 }, 296 }, 297 output: { 298 + encoding: "application/json", 299 schema: { 300 + type: "object", 301 required: [], 302 properties: { 303 commit: { 304 + type: "ref", 305 + ref: "lex:com.atproto.repo.defs#commitMeta", 306 }, 307 results: { 308 + type: "array", 309 items: { 310 + type: "union", 311 refs: [ 312 + "lex:com.atproto.repo.applyWrites#createResult", 313 + "lex:com.atproto.repo.applyWrites#updateResult", 314 + "lex:com.atproto.repo.applyWrites#deleteResult", 315 ], 316 closed: true, 317 }, ··· 321 }, 322 errors: [ 323 { 324 + name: "InvalidSwap", 325 description: 326 "Indicates that the 'swapCommit' parameter did not match current commit.", 327 }, 328 ], 329 }, 330 create: { 331 + type: "object", 332 + description: "Operation which creates a new record.", 333 + required: ["collection", "value"], 334 properties: { 335 collection: { 336 + type: "string", 337 + format: "nsid", 338 }, 339 rkey: { 340 + type: "string", 341 maxLength: 512, 342 + format: "record-key", 343 description: 344 + "NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.", 345 }, 346 value: { 347 + type: "unknown", 348 }, 349 }, 350 }, 351 update: { 352 + type: "object", 353 + description: "Operation which updates an existing record.", 354 + required: ["collection", "rkey", "value"], 355 properties: { 356 collection: { 357 + type: "string", 358 + format: "nsid", 359 }, 360 rkey: { 361 + type: "string", 362 + format: "record-key", 363 }, 364 value: { 365 + type: "unknown", 366 }, 367 }, 368 }, 369 delete: { 370 + type: "object", 371 + description: "Operation which deletes an existing record.", 372 + required: ["collection", "rkey"], 373 properties: { 374 collection: { 375 + type: "string", 376 + format: "nsid", 377 }, 378 rkey: { 379 + type: "string", 380 + format: "record-key", 381 }, 382 }, 383 }, 384 createResult: { 385 + type: "object", 386 + required: ["uri", "cid"], 387 properties: { 388 uri: { 389 + type: "string", 390 + format: "at-uri", 391 }, 392 cid: { 393 + type: "string", 394 + format: "cid", 395 }, 396 validationStatus: { 397 + type: "string", 398 + knownValues: ["valid", "unknown"], 399 }, 400 }, 401 }, 402 updateResult: { 403 + type: "object", 404 + required: ["uri", "cid"], 405 properties: { 406 uri: { 407 + type: "string", 408 + format: "at-uri", 409 }, 410 cid: { 411 + type: "string", 412 + format: "cid", 413 }, 414 validationStatus: { 415 + type: "string", 416 + knownValues: ["valid", "unknown"], 417 }, 418 }, 419 }, 420 deleteResult: { 421 + type: "object", 422 required: [], 423 properties: {}, 424 }, ··· 426 }, 427 ComAtprotoRepoCreateRecord: { 428 lexicon: 1, 429 + id: "com.atproto.repo.createRecord", 430 defs: { 431 main: { 432 + type: "procedure", 433 description: 434 + "Create a single new repository record. Requires auth, implemented by PDS.", 435 input: { 436 + encoding: "application/json", 437 schema: { 438 + type: "object", 439 + required: ["repo", "collection", "record"], 440 properties: { 441 repo: { 442 + type: "string", 443 + format: "at-identifier", 444 description: 445 + "The handle or DID of the repo (aka, current account).", 446 }, 447 collection: { 448 + type: "string", 449 + format: "nsid", 450 + description: "The NSID of the record collection.", 451 }, 452 rkey: { 453 + type: "string", 454 + format: "record-key", 455 + description: "The Record Key.", 456 maxLength: 512, 457 }, 458 validate: { 459 + type: "boolean", 460 description: 461 "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 462 }, 463 record: { 464 + type: "unknown", 465 + description: "The record itself. Must contain a $type field.", 466 }, 467 swapCommit: { 468 + type: "string", 469 + format: "cid", 470 description: 471 + "Compare and swap with the previous commit by CID.", 472 }, 473 }, 474 }, 475 }, 476 output: { 477 + encoding: "application/json", 478 schema: { 479 + type: "object", 480 + required: ["uri", "cid"], 481 properties: { 482 uri: { 483 + type: "string", 484 + format: "at-uri", 485 }, 486 cid: { 487 + type: "string", 488 + format: "cid", 489 }, 490 commit: { 491 + type: "ref", 492 + ref: "lex:com.atproto.repo.defs#commitMeta", 493 }, 494 validationStatus: { 495 + type: "string", 496 + knownValues: ["valid", "unknown"], 497 }, 498 }, 499 }, 500 }, 501 errors: [ 502 { 503 + name: "InvalidSwap", 504 description: 505 "Indicates that 'swapCommit' didn't match current repo commit.", 506 }, ··· 510 }, 511 ComAtprotoRepoDefs: { 512 lexicon: 1, 513 + id: "com.atproto.repo.defs", 514 defs: { 515 commitMeta: { 516 + type: "object", 517 + required: ["cid", "rev"], 518 properties: { 519 cid: { 520 + type: "string", 521 + format: "cid", 522 }, 523 rev: { 524 + type: "string", 525 + format: "tid", 526 }, 527 }, 528 }, ··· 530 }, 531 ComAtprotoRepoDeleteRecord: { 532 lexicon: 1, 533 + id: "com.atproto.repo.deleteRecord", 534 defs: { 535 main: { 536 + type: "procedure", 537 description: 538 "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", 539 input: { 540 + encoding: "application/json", 541 schema: { 542 + type: "object", 543 + required: ["repo", "collection", "rkey"], 544 properties: { 545 repo: { 546 + type: "string", 547 + format: "at-identifier", 548 description: 549 + "The handle or DID of the repo (aka, current account).", 550 }, 551 collection: { 552 + type: "string", 553 + format: "nsid", 554 + description: "The NSID of the record collection.", 555 }, 556 rkey: { 557 + type: "string", 558 + format: "record-key", 559 + description: "The Record Key.", 560 }, 561 swapRecord: { 562 + type: "string", 563 + format: "cid", 564 description: 565 + "Compare and swap with the previous record by CID.", 566 }, 567 swapCommit: { 568 + type: "string", 569 + format: "cid", 570 description: 571 + "Compare and swap with the previous commit by CID.", 572 }, 573 }, 574 }, 575 }, 576 output: { 577 + encoding: "application/json", 578 schema: { 579 + type: "object", 580 properties: { 581 commit: { 582 + type: "ref", 583 + ref: "lex:com.atproto.repo.defs#commitMeta", 584 }, 585 }, 586 }, 587 }, 588 errors: [ 589 { 590 + name: "InvalidSwap", 591 }, 592 ], 593 }, ··· 595 }, 596 ComAtprotoRepoDescribeRepo: { 597 lexicon: 1, 598 + id: "com.atproto.repo.describeRepo", 599 defs: { 600 main: { 601 + type: "query", 602 description: 603 + "Get information about an account and repository, including the list of collections. Does not require auth.", 604 parameters: { 605 + type: "params", 606 + required: ["repo"], 607 properties: { 608 repo: { 609 + type: "string", 610 + format: "at-identifier", 611 + description: "The handle or DID of the repo.", 612 }, 613 }, 614 }, 615 output: { 616 + encoding: "application/json", 617 schema: { 618 + type: "object", 619 required: [ 620 + "handle", 621 + "did", 622 + "didDoc", 623 + "collections", 624 + "handleIsCorrect", 625 ], 626 properties: { 627 handle: { 628 + type: "string", 629 + format: "handle", 630 }, 631 did: { 632 + type: "string", 633 + format: "did", 634 }, 635 didDoc: { 636 + type: "unknown", 637 + description: "The complete DID document for this account.", 638 }, 639 collections: { 640 + type: "array", 641 description: 642 + "List of all the collections (NSIDs) for which this repo contains at least one record.", 643 items: { 644 + type: "string", 645 + format: "nsid", 646 }, 647 }, 648 handleIsCorrect: { 649 + type: "boolean", 650 description: 651 + "Indicates if handle is currently valid (resolves bi-directionally)", 652 }, 653 }, 654 }, ··· 658 }, 659 ComAtprotoRepoGetRecord: { 660 lexicon: 1, 661 + id: "com.atproto.repo.getRecord", 662 defs: { 663 main: { 664 + type: "query", 665 description: 666 + "Get a single record from a repository. Does not require auth.", 667 parameters: { 668 + type: "params", 669 + required: ["repo", "collection", "rkey"], 670 properties: { 671 repo: { 672 + type: "string", 673 + format: "at-identifier", 674 + description: "The handle or DID of the repo.", 675 }, 676 collection: { 677 + type: "string", 678 + format: "nsid", 679 + description: "The NSID of the record collection.", 680 }, 681 rkey: { 682 + type: "string", 683 + description: "The Record Key.", 684 + format: "record-key", 685 }, 686 cid: { 687 + type: "string", 688 + format: "cid", 689 description: 690 + "The CID of the version of the record. If not specified, then return the most recent version.", 691 }, 692 }, 693 }, 694 output: { 695 + encoding: "application/json", 696 schema: { 697 + type: "object", 698 + required: ["uri", "value"], 699 properties: { 700 uri: { 701 + type: "string", 702 + format: "at-uri", 703 }, 704 cid: { 705 + type: "string", 706 + format: "cid", 707 }, 708 value: { 709 + type: "unknown", 710 }, 711 }, 712 }, 713 }, 714 errors: [ 715 { 716 + name: "RecordNotFound", 717 }, 718 ], 719 }, ··· 721 }, 722 ComAtprotoRepoImportRepo: { 723 lexicon: 1, 724 + id: "com.atproto.repo.importRepo", 725 defs: { 726 main: { 727 + type: "procedure", 728 description: 729 + "Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.", 730 input: { 731 + encoding: "application/vnd.ipld.car", 732 }, 733 }, 734 }, 735 }, 736 ComAtprotoRepoListMissingBlobs: { 737 lexicon: 1, 738 + id: "com.atproto.repo.listMissingBlobs", 739 defs: { 740 main: { 741 + type: "query", 742 description: 743 + "Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.", 744 parameters: { 745 + type: "params", 746 properties: { 747 limit: { 748 + type: "integer", 749 minimum: 1, 750 maximum: 1000, 751 default: 500, 752 }, 753 cursor: { 754 + type: "string", 755 }, 756 }, 757 }, 758 output: { 759 + encoding: "application/json", 760 schema: { 761 + type: "object", 762 + required: ["blobs"], 763 properties: { 764 cursor: { 765 + type: "string", 766 }, 767 blobs: { 768 + type: "array", 769 items: { 770 + type: "ref", 771 + ref: "lex:com.atproto.repo.listMissingBlobs#recordBlob", 772 }, 773 }, 774 }, ··· 776 }, 777 }, 778 recordBlob: { 779 + type: "object", 780 + required: ["cid", "recordUri"], 781 properties: { 782 cid: { 783 + type: "string", 784 + format: "cid", 785 }, 786 recordUri: { 787 + type: "string", 788 + format: "at-uri", 789 }, 790 }, 791 }, ··· 793 }, 794 ComAtprotoRepoListRecords: { 795 lexicon: 1, 796 + id: "com.atproto.repo.listRecords", 797 defs: { 798 main: { 799 + type: "query", 800 description: 801 + "List a range of records in a repository, matching a specific collection. Does not require auth.", 802 parameters: { 803 + type: "params", 804 + required: ["repo", "collection"], 805 properties: { 806 repo: { 807 + type: "string", 808 + format: "at-identifier", 809 + description: "The handle or DID of the repo.", 810 }, 811 collection: { 812 + type: "string", 813 + format: "nsid", 814 + description: "The NSID of the record type.", 815 }, 816 limit: { 817 + type: "integer", 818 minimum: 1, 819 maximum: 100, 820 default: 50, 821 + description: "The number of records to return.", 822 }, 823 cursor: { 824 + type: "string", 825 }, 826 rkeyStart: { 827 + type: "string", 828 description: 829 + "DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)", 830 }, 831 rkeyEnd: { 832 + type: "string", 833 description: 834 + "DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)", 835 }, 836 reverse: { 837 + type: "boolean", 838 + description: "Flag to reverse the order of the returned records.", 839 }, 840 }, 841 }, 842 output: { 843 + encoding: "application/json", 844 schema: { 845 + type: "object", 846 + required: ["records"], 847 properties: { 848 cursor: { 849 + type: "string", 850 }, 851 records: { 852 + type: "array", 853 items: { 854 + type: "ref", 855 + ref: "lex:com.atproto.repo.listRecords#record", 856 }, 857 }, 858 }, ··· 860 }, 861 }, 862 record: { 863 + type: "object", 864 + required: ["uri", "cid", "value"], 865 properties: { 866 uri: { 867 + type: "string", 868 + format: "at-uri", 869 }, 870 cid: { 871 + type: "string", 872 + format: "cid", 873 }, 874 value: { 875 + type: "unknown", 876 }, 877 }, 878 }, ··· 880 }, 881 ComAtprotoRepoPutRecord: { 882 lexicon: 1, 883 + id: "com.atproto.repo.putRecord", 884 defs: { 885 main: { 886 + type: "procedure", 887 description: 888 + "Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.", 889 input: { 890 + encoding: "application/json", 891 schema: { 892 + type: "object", 893 + required: ["repo", "collection", "rkey", "record"], 894 + nullable: ["swapRecord"], 895 properties: { 896 repo: { 897 + type: "string", 898 + format: "at-identifier", 899 description: 900 + "The handle or DID of the repo (aka, current account).", 901 }, 902 collection: { 903 + type: "string", 904 + format: "nsid", 905 + description: "The NSID of the record collection.", 906 }, 907 rkey: { 908 + type: "string", 909 + format: "record-key", 910 + description: "The Record Key.", 911 maxLength: 512, 912 }, 913 validate: { 914 + type: "boolean", 915 description: 916 "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 917 }, 918 record: { 919 + type: "unknown", 920 + description: "The record to write.", 921 }, 922 swapRecord: { 923 + type: "string", 924 + format: "cid", 925 description: 926 + "Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation", 927 }, 928 swapCommit: { 929 + type: "string", 930 + format: "cid", 931 description: 932 + "Compare and swap with the previous commit by CID.", 933 }, 934 }, 935 }, 936 }, 937 output: { 938 + encoding: "application/json", 939 schema: { 940 + type: "object", 941 + required: ["uri", "cid"], 942 properties: { 943 uri: { 944 + type: "string", 945 + format: "at-uri", 946 }, 947 cid: { 948 + type: "string", 949 + format: "cid", 950 }, 951 commit: { 952 + type: "ref", 953 + ref: "lex:com.atproto.repo.defs#commitMeta", 954 }, 955 validationStatus: { 956 + type: "string", 957 + knownValues: ["valid", "unknown"], 958 }, 959 }, 960 }, 961 }, 962 errors: [ 963 { 964 + name: "InvalidSwap", 965 }, 966 ], 967 }, ··· 969 }, 970 ComAtprotoRepoStrongRef: { 971 lexicon: 1, 972 + id: "com.atproto.repo.strongRef", 973 + description: "A URI with a content-hash fingerprint.", 974 defs: { 975 main: { 976 + type: "object", 977 + required: ["uri", "cid"], 978 properties: { 979 uri: { 980 + type: "string", 981 + format: "at-uri", 982 }, 983 cid: { 984 + type: "string", 985 + format: "cid", 986 }, 987 }, 988 }, ··· 990 }, 991 ComAtprotoRepoUploadBlob: { 992 lexicon: 1, 993 + id: "com.atproto.repo.uploadBlob", 994 defs: { 995 main: { 996 + type: "procedure", 997 description: 998 + "Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.", 999 input: { 1000 + encoding: "*/*", 1001 }, 1002 output: { 1003 + encoding: "application/json", 1004 schema: { 1005 + type: "object", 1006 + required: ["blob"], 1007 properties: { 1008 blob: { 1009 + type: "blob", 1010 }, 1011 }, 1012 }, ··· 1016 }, 1017 PubLeafletBlocksBlockquote: { 1018 lexicon: 1, 1019 + id: "pub.leaflet.blocks.blockquote", 1020 defs: { 1021 main: { 1022 + type: "object", 1023 + required: ["plaintext"], 1024 properties: { 1025 plaintext: { 1026 + type: "string", 1027 }, 1028 facets: { 1029 + type: "array", 1030 items: { 1031 + type: "ref", 1032 + ref: "lex:pub.leaflet.richtext.facet", 1033 }, 1034 }, 1035 }, ··· 1038 }, 1039 PubLeafletBlocksBskyPost: { 1040 lexicon: 1, 1041 + id: "pub.leaflet.blocks.bskyPost", 1042 defs: { 1043 main: { 1044 + type: "object", 1045 + required: ["postRef"], 1046 properties: { 1047 postRef: { 1048 + type: "ref", 1049 + ref: "lex:com.atproto.repo.strongRef", 1050 }, 1051 }, 1052 }, ··· 1054 }, 1055 PubLeafletBlocksButton: { 1056 lexicon: 1, 1057 + id: "pub.leaflet.blocks.button", 1058 defs: { 1059 main: { 1060 + type: "object", 1061 + required: ["text", "url"], 1062 properties: { 1063 text: { 1064 + type: "string", 1065 }, 1066 url: { 1067 + type: "string", 1068 + format: "uri", 1069 }, 1070 }, 1071 }, ··· 1073 }, 1074 PubLeafletBlocksCode: { 1075 lexicon: 1, 1076 + id: "pub.leaflet.blocks.code", 1077 defs: { 1078 main: { 1079 + type: "object", 1080 + required: ["plaintext"], 1081 properties: { 1082 plaintext: { 1083 + type: "string", 1084 }, 1085 language: { 1086 + type: "string", 1087 }, 1088 syntaxHighlightingTheme: { 1089 + type: "string", 1090 }, 1091 }, 1092 }, ··· 1094 }, 1095 PubLeafletBlocksHeader: { 1096 lexicon: 1, 1097 + id: "pub.leaflet.blocks.header", 1098 defs: { 1099 main: { 1100 + type: "object", 1101 + required: ["plaintext"], 1102 properties: { 1103 level: { 1104 + type: "integer", 1105 minimum: 1, 1106 maximum: 6, 1107 }, 1108 plaintext: { 1109 + type: "string", 1110 }, 1111 facets: { 1112 + type: "array", 1113 items: { 1114 + type: "ref", 1115 + ref: "lex:pub.leaflet.richtext.facet", 1116 }, 1117 }, 1118 }, ··· 1121 }, 1122 PubLeafletBlocksHorizontalRule: { 1123 lexicon: 1, 1124 + id: "pub.leaflet.blocks.horizontalRule", 1125 defs: { 1126 main: { 1127 + type: "object", 1128 required: [], 1129 properties: {}, 1130 }, ··· 1132 }, 1133 PubLeafletBlocksIframe: { 1134 lexicon: 1, 1135 + id: "pub.leaflet.blocks.iframe", 1136 defs: { 1137 main: { 1138 + type: "object", 1139 + required: ["url"], 1140 properties: { 1141 url: { 1142 + type: "string", 1143 + format: "uri", 1144 }, 1145 height: { 1146 + type: "integer", 1147 minimum: 16, 1148 maximum: 1600, 1149 }, ··· 1153 }, 1154 PubLeafletBlocksImage: { 1155 lexicon: 1, 1156 + id: "pub.leaflet.blocks.image", 1157 defs: { 1158 main: { 1159 + type: "object", 1160 + required: ["image", "aspectRatio"], 1161 properties: { 1162 image: { 1163 + type: "blob", 1164 + accept: ["image/*"], 1165 maxSize: 1000000, 1166 }, 1167 alt: { 1168 + type: "string", 1169 description: 1170 + "Alt text description of the image, for accessibility.", 1171 }, 1172 aspectRatio: { 1173 + type: "ref", 1174 + ref: "lex:pub.leaflet.blocks.image#aspectRatio", 1175 }, 1176 }, 1177 }, 1178 aspectRatio: { 1179 + type: "object", 1180 + required: ["width", "height"], 1181 properties: { 1182 width: { 1183 + type: "integer", 1184 }, 1185 height: { 1186 + type: "integer", 1187 }, 1188 }, 1189 }, ··· 1191 }, 1192 PubLeafletBlocksMath: { 1193 lexicon: 1, 1194 + id: "pub.leaflet.blocks.math", 1195 defs: { 1196 main: { 1197 + type: "object", 1198 + required: ["tex"], 1199 properties: { 1200 tex: { 1201 + type: "string", 1202 }, 1203 }, 1204 }, ··· 1206 }, 1207 PubLeafletBlocksPage: { 1208 lexicon: 1, 1209 + id: "pub.leaflet.blocks.page", 1210 defs: { 1211 main: { 1212 + type: "object", 1213 + required: ["id"], 1214 properties: { 1215 id: { 1216 + type: "string", 1217 }, 1218 }, 1219 }, ··· 1221 }, 1222 PubLeafletBlocksPoll: { 1223 lexicon: 1, 1224 + id: "pub.leaflet.blocks.poll", 1225 defs: { 1226 main: { 1227 + type: "object", 1228 + required: ["pollRef"], 1229 properties: { 1230 pollRef: { 1231 + type: "ref", 1232 + ref: "lex:com.atproto.repo.strongRef", 1233 }, 1234 }, 1235 }, ··· 1237 }, 1238 PubLeafletBlocksText: { 1239 lexicon: 1, 1240 + id: "pub.leaflet.blocks.text", 1241 defs: { 1242 main: { 1243 + type: "object", 1244 + required: ["plaintext"], 1245 properties: { 1246 plaintext: { 1247 + type: "string", 1248 }, 1249 facets: { 1250 + type: "array", 1251 items: { 1252 + type: "ref", 1253 + ref: "lex:pub.leaflet.richtext.facet", 1254 }, 1255 }, 1256 }, ··· 1259 }, 1260 PubLeafletBlocksUnorderedList: { 1261 lexicon: 1, 1262 + id: "pub.leaflet.blocks.unorderedList", 1263 defs: { 1264 main: { 1265 + type: "object", 1266 + required: ["children"], 1267 properties: { 1268 children: { 1269 + type: "array", 1270 items: { 1271 + type: "ref", 1272 + ref: "lex:pub.leaflet.blocks.unorderedList#listItem", 1273 }, 1274 }, 1275 }, 1276 }, 1277 listItem: { 1278 + type: "object", 1279 + required: ["content"], 1280 properties: { 1281 content: { 1282 + type: "union", 1283 refs: [ 1284 + "lex:pub.leaflet.blocks.text", 1285 + "lex:pub.leaflet.blocks.header", 1286 + "lex:pub.leaflet.blocks.image", 1287 ], 1288 }, 1289 children: { 1290 + type: "array", 1291 items: { 1292 + type: "ref", 1293 + ref: "lex:pub.leaflet.blocks.unorderedList#listItem", 1294 }, 1295 }, 1296 }, ··· 1299 }, 1300 PubLeafletBlocksWebsite: { 1301 lexicon: 1, 1302 + id: "pub.leaflet.blocks.website", 1303 defs: { 1304 main: { 1305 + type: "object", 1306 + required: ["src"], 1307 properties: { 1308 previewImage: { 1309 + type: "blob", 1310 + accept: ["image/*"], 1311 maxSize: 1000000, 1312 }, 1313 title: { 1314 + type: "string", 1315 }, 1316 description: { 1317 + type: "string", 1318 }, 1319 src: { 1320 + type: "string", 1321 + format: "uri", 1322 }, 1323 }, 1324 }, ··· 1326 }, 1327 PubLeafletComment: { 1328 lexicon: 1, 1329 + id: "pub.leaflet.comment", 1330 revision: 1, 1331 + description: "A lexicon for comments on documents", 1332 defs: { 1333 main: { 1334 + type: "record", 1335 + key: "tid", 1336 + description: "Record containing a comment", 1337 record: { 1338 + type: "object", 1339 + required: ["subject", "plaintext", "createdAt"], 1340 properties: { 1341 subject: { 1342 + type: "string", 1343 + format: "at-uri", 1344 }, 1345 createdAt: { 1346 + type: "string", 1347 + format: "datetime", 1348 }, 1349 reply: { 1350 + type: "ref", 1351 + ref: "lex:pub.leaflet.comment#replyRef", 1352 }, 1353 plaintext: { 1354 + type: "string", 1355 }, 1356 facets: { 1357 + type: "array", 1358 items: { 1359 + type: "ref", 1360 + ref: "lex:pub.leaflet.richtext.facet", 1361 }, 1362 }, 1363 onPage: { 1364 + type: "string", 1365 }, 1366 attachment: { 1367 + type: "union", 1368 + refs: ["lex:pub.leaflet.comment#linearDocumentQuote"], 1369 }, 1370 }, 1371 }, 1372 }, 1373 linearDocumentQuote: { 1374 + type: "object", 1375 + required: ["document", "quote"], 1376 properties: { 1377 document: { 1378 + type: "string", 1379 + format: "at-uri", 1380 }, 1381 quote: { 1382 + type: "ref", 1383 + ref: "lex:pub.leaflet.pages.linearDocument#quote", 1384 }, 1385 }, 1386 }, 1387 replyRef: { 1388 + type: "object", 1389 + required: ["parent"], 1390 properties: { 1391 parent: { 1392 + type: "string", 1393 + format: "at-uri", 1394 }, 1395 }, 1396 }, ··· 1398 }, 1399 PubLeafletDocument: { 1400 lexicon: 1, 1401 + id: "pub.leaflet.document", 1402 revision: 1, 1403 + description: "A lexicon for long form rich media documents", 1404 defs: { 1405 main: { 1406 + type: "record", 1407 + key: "tid", 1408 + description: "Record containing a document", 1409 record: { 1410 + type: "object", 1411 + required: ["pages", "author", "title"], 1412 properties: { 1413 title: { 1414 + type: "string", 1415 maxLength: 1280, 1416 maxGraphemes: 128, 1417 }, 1418 postRef: { 1419 + type: "ref", 1420 + ref: "lex:com.atproto.repo.strongRef", 1421 }, 1422 description: { 1423 + type: "string", 1424 maxLength: 3000, 1425 maxGraphemes: 300, 1426 }, 1427 publishedAt: { 1428 + type: "string", 1429 + format: "datetime", 1430 }, 1431 publication: { 1432 + type: "string", 1433 + format: "at-uri", 1434 }, 1435 author: { 1436 + type: "string", 1437 + format: "at-identifier", 1438 }, 1439 theme: { 1440 + type: "ref", 1441 + ref: "lex:pub.leaflet.publication#theme", 1442 }, 1443 tags: { 1444 + type: "array", 1445 items: { 1446 + type: "string", 1447 maxLength: 50, 1448 }, 1449 }, 1450 + coverImage: { 1451 + type: "blob", 1452 + accept: ["image/png", "image/jpeg", "image/webp"], 1453 + maxSize: 1000000, 1454 + }, 1455 pages: { 1456 + type: "array", 1457 items: { 1458 + type: "union", 1459 refs: [ 1460 + "lex:pub.leaflet.pages.linearDocument", 1461 + "lex:pub.leaflet.pages.canvas", 1462 ], 1463 }, 1464 }, ··· 1469 }, 1470 PubLeafletGraphSubscription: { 1471 lexicon: 1, 1472 + id: "pub.leaflet.graph.subscription", 1473 defs: { 1474 main: { 1475 + type: "record", 1476 + key: "tid", 1477 + description: "Record declaring a subscription to a publication", 1478 record: { 1479 + type: "object", 1480 + required: ["publication"], 1481 properties: { 1482 publication: { 1483 + type: "string", 1484 + format: "at-uri", 1485 }, 1486 }, 1487 }, ··· 1490 }, 1491 PubLeafletPagesCanvas: { 1492 lexicon: 1, 1493 + id: "pub.leaflet.pages.canvas", 1494 defs: { 1495 main: { 1496 + type: "object", 1497 + required: ["blocks"], 1498 properties: { 1499 id: { 1500 + type: "string", 1501 }, 1502 blocks: { 1503 + type: "array", 1504 items: { 1505 + type: "ref", 1506 + ref: "lex:pub.leaflet.pages.canvas#block", 1507 }, 1508 }, 1509 }, 1510 }, 1511 block: { 1512 + type: "object", 1513 + required: ["block", "x", "y", "width"], 1514 properties: { 1515 block: { 1516 + type: "union", 1517 refs: [ 1518 + "lex:pub.leaflet.blocks.iframe", 1519 + "lex:pub.leaflet.blocks.text", 1520 + "lex:pub.leaflet.blocks.blockquote", 1521 + "lex:pub.leaflet.blocks.header", 1522 + "lex:pub.leaflet.blocks.image", 1523 + "lex:pub.leaflet.blocks.unorderedList", 1524 + "lex:pub.leaflet.blocks.website", 1525 + "lex:pub.leaflet.blocks.math", 1526 + "lex:pub.leaflet.blocks.code", 1527 + "lex:pub.leaflet.blocks.horizontalRule", 1528 + "lex:pub.leaflet.blocks.bskyPost", 1529 + "lex:pub.leaflet.blocks.page", 1530 + "lex:pub.leaflet.blocks.poll", 1531 + "lex:pub.leaflet.blocks.button", 1532 ], 1533 }, 1534 x: { 1535 + type: "integer", 1536 }, 1537 y: { 1538 + type: "integer", 1539 }, 1540 width: { 1541 + type: "integer", 1542 }, 1543 height: { 1544 + type: "integer", 1545 }, 1546 rotation: { 1547 + type: "integer", 1548 + description: "The rotation of the block in degrees", 1549 }, 1550 }, 1551 }, 1552 textAlignLeft: { 1553 + type: "token", 1554 }, 1555 textAlignCenter: { 1556 + type: "token", 1557 }, 1558 textAlignRight: { 1559 + type: "token", 1560 }, 1561 quote: { 1562 + type: "object", 1563 + required: ["start", "end"], 1564 properties: { 1565 start: { 1566 + type: "ref", 1567 + ref: "lex:pub.leaflet.pages.canvas#position", 1568 }, 1569 end: { 1570 + type: "ref", 1571 + ref: "lex:pub.leaflet.pages.canvas#position", 1572 }, 1573 }, 1574 }, 1575 position: { 1576 + type: "object", 1577 + required: ["block", "offset"], 1578 properties: { 1579 block: { 1580 + type: "array", 1581 items: { 1582 + type: "integer", 1583 }, 1584 }, 1585 offset: { 1586 + type: "integer", 1587 }, 1588 }, 1589 }, ··· 1591 }, 1592 PubLeafletPagesLinearDocument: { 1593 lexicon: 1, 1594 + id: "pub.leaflet.pages.linearDocument", 1595 defs: { 1596 main: { 1597 + type: "object", 1598 + required: ["blocks"], 1599 properties: { 1600 id: { 1601 + type: "string", 1602 }, 1603 blocks: { 1604 + type: "array", 1605 items: { 1606 + type: "ref", 1607 + ref: "lex:pub.leaflet.pages.linearDocument#block", 1608 }, 1609 }, 1610 }, 1611 }, 1612 block: { 1613 + type: "object", 1614 + required: ["block"], 1615 properties: { 1616 block: { 1617 + type: "union", 1618 refs: [ 1619 + "lex:pub.leaflet.blocks.iframe", 1620 + "lex:pub.leaflet.blocks.text", 1621 + "lex:pub.leaflet.blocks.blockquote", 1622 + "lex:pub.leaflet.blocks.header", 1623 + "lex:pub.leaflet.blocks.image", 1624 + "lex:pub.leaflet.blocks.unorderedList", 1625 + "lex:pub.leaflet.blocks.website", 1626 + "lex:pub.leaflet.blocks.math", 1627 + "lex:pub.leaflet.blocks.code", 1628 + "lex:pub.leaflet.blocks.horizontalRule", 1629 + "lex:pub.leaflet.blocks.bskyPost", 1630 + "lex:pub.leaflet.blocks.page", 1631 + "lex:pub.leaflet.blocks.poll", 1632 + "lex:pub.leaflet.blocks.button", 1633 ], 1634 }, 1635 alignment: { 1636 + type: "string", 1637 knownValues: [ 1638 + "lex:pub.leaflet.pages.linearDocument#textAlignLeft", 1639 + "lex:pub.leaflet.pages.linearDocument#textAlignCenter", 1640 + "lex:pub.leaflet.pages.linearDocument#textAlignRight", 1641 + "lex:pub.leaflet.pages.linearDocument#textAlignJustify", 1642 ], 1643 }, 1644 }, 1645 }, 1646 textAlignLeft: { 1647 + type: "token", 1648 }, 1649 textAlignCenter: { 1650 + type: "token", 1651 }, 1652 textAlignRight: { 1653 + type: "token", 1654 }, 1655 textAlignJustify: { 1656 + type: "token", 1657 }, 1658 quote: { 1659 + type: "object", 1660 + required: ["start", "end"], 1661 properties: { 1662 start: { 1663 + type: "ref", 1664 + ref: "lex:pub.leaflet.pages.linearDocument#position", 1665 }, 1666 end: { 1667 + type: "ref", 1668 + ref: "lex:pub.leaflet.pages.linearDocument#position", 1669 }, 1670 }, 1671 }, 1672 position: { 1673 + type: "object", 1674 + required: ["block", "offset"], 1675 properties: { 1676 block: { 1677 + type: "array", 1678 items: { 1679 + type: "integer", 1680 }, 1681 }, 1682 offset: { 1683 + type: "integer", 1684 }, 1685 }, 1686 }, ··· 1688 }, 1689 PubLeafletPollDefinition: { 1690 lexicon: 1, 1691 + id: "pub.leaflet.poll.definition", 1692 defs: { 1693 main: { 1694 + type: "record", 1695 + key: "tid", 1696 + description: "Record declaring a poll", 1697 record: { 1698 + type: "object", 1699 + required: ["name", "options"], 1700 properties: { 1701 name: { 1702 + type: "string", 1703 maxLength: 500, 1704 maxGraphemes: 100, 1705 }, 1706 options: { 1707 + type: "array", 1708 items: { 1709 + type: "ref", 1710 + ref: "lex:pub.leaflet.poll.definition#option", 1711 }, 1712 }, 1713 endDate: { 1714 + type: "string", 1715 + format: "datetime", 1716 }, 1717 }, 1718 }, 1719 }, 1720 option: { 1721 + type: "object", 1722 properties: { 1723 text: { 1724 + type: "string", 1725 maxLength: 500, 1726 maxGraphemes: 50, 1727 }, ··· 1731 }, 1732 PubLeafletPollVote: { 1733 lexicon: 1, 1734 + id: "pub.leaflet.poll.vote", 1735 defs: { 1736 main: { 1737 + type: "record", 1738 + key: "tid", 1739 + description: "Record declaring a vote on a poll", 1740 record: { 1741 + type: "object", 1742 + required: ["poll", "option"], 1743 properties: { 1744 poll: { 1745 + type: "ref", 1746 + ref: "lex:com.atproto.repo.strongRef", 1747 }, 1748 option: { 1749 + type: "array", 1750 items: { 1751 + type: "string", 1752 }, 1753 }, 1754 }, ··· 1758 }, 1759 PubLeafletPublication: { 1760 lexicon: 1, 1761 + id: "pub.leaflet.publication", 1762 defs: { 1763 main: { 1764 + type: "record", 1765 + key: "tid", 1766 + description: "Record declaring a publication", 1767 record: { 1768 + type: "object", 1769 + required: ["name"], 1770 properties: { 1771 name: { 1772 + type: "string", 1773 maxLength: 2000, 1774 }, 1775 base_path: { 1776 + type: "string", 1777 }, 1778 description: { 1779 + type: "string", 1780 maxLength: 2000, 1781 }, 1782 icon: { 1783 + type: "blob", 1784 + accept: ["image/*"], 1785 maxSize: 1000000, 1786 }, 1787 theme: { 1788 + type: "ref", 1789 + ref: "lex:pub.leaflet.publication#theme", 1790 }, 1791 preferences: { 1792 + type: "ref", 1793 + ref: "lex:pub.leaflet.publication#preferences", 1794 }, 1795 }, 1796 }, 1797 }, 1798 preferences: { 1799 + type: "object", 1800 properties: { 1801 showInDiscover: { 1802 + type: "boolean", 1803 default: true, 1804 }, 1805 showComments: { 1806 + type: "boolean", 1807 default: true, 1808 }, 1809 }, 1810 }, 1811 theme: { 1812 + type: "object", 1813 properties: { 1814 backgroundColor: { 1815 + type: "union", 1816 refs: [ 1817 + "lex:pub.leaflet.theme.color#rgba", 1818 + "lex:pub.leaflet.theme.color#rgb", 1819 ], 1820 }, 1821 backgroundImage: { 1822 + type: "ref", 1823 + ref: "lex:pub.leaflet.theme.backgroundImage", 1824 + }, 1825 + pageWidth: { 1826 + type: "integer", 1827 + minimum: 320, 1828 + maximum: 1200, 1829 }, 1830 primary: { 1831 + type: "union", 1832 refs: [ 1833 + "lex:pub.leaflet.theme.color#rgba", 1834 + "lex:pub.leaflet.theme.color#rgb", 1835 ], 1836 }, 1837 pageBackground: { 1838 + type: "union", 1839 refs: [ 1840 + "lex:pub.leaflet.theme.color#rgba", 1841 + "lex:pub.leaflet.theme.color#rgb", 1842 ], 1843 }, 1844 showPageBackground: { 1845 + type: "boolean", 1846 default: false, 1847 }, 1848 accentBackground: { 1849 + type: "union", 1850 refs: [ 1851 + "lex:pub.leaflet.theme.color#rgba", 1852 + "lex:pub.leaflet.theme.color#rgb", 1853 ], 1854 }, 1855 accentText: { 1856 + type: "union", 1857 refs: [ 1858 + "lex:pub.leaflet.theme.color#rgba", 1859 + "lex:pub.leaflet.theme.color#rgb", 1860 ], 1861 }, 1862 }, ··· 1865 }, 1866 PubLeafletRichtextFacet: { 1867 lexicon: 1, 1868 + id: "pub.leaflet.richtext.facet", 1869 defs: { 1870 main: { 1871 + type: "object", 1872 + description: "Annotation of a sub-string within rich text.", 1873 + required: ["index", "features"], 1874 properties: { 1875 index: { 1876 + type: "ref", 1877 + ref: "lex:pub.leaflet.richtext.facet#byteSlice", 1878 }, 1879 features: { 1880 + type: "array", 1881 items: { 1882 + type: "union", 1883 refs: [ 1884 + "lex:pub.leaflet.richtext.facet#link", 1885 + "lex:pub.leaflet.richtext.facet#didMention", 1886 + "lex:pub.leaflet.richtext.facet#atMention", 1887 + "lex:pub.leaflet.richtext.facet#code", 1888 + "lex:pub.leaflet.richtext.facet#highlight", 1889 + "lex:pub.leaflet.richtext.facet#underline", 1890 + "lex:pub.leaflet.richtext.facet#strikethrough", 1891 + "lex:pub.leaflet.richtext.facet#id", 1892 + "lex:pub.leaflet.richtext.facet#bold", 1893 + "lex:pub.leaflet.richtext.facet#italic", 1894 ], 1895 }, 1896 }, 1897 }, 1898 }, 1899 byteSlice: { 1900 + type: "object", 1901 description: 1902 + "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", 1903 + required: ["byteStart", "byteEnd"], 1904 properties: { 1905 byteStart: { 1906 + type: "integer", 1907 minimum: 0, 1908 }, 1909 byteEnd: { 1910 + type: "integer", 1911 minimum: 0, 1912 }, 1913 }, 1914 }, 1915 link: { 1916 + type: "object", 1917 description: 1918 + "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 1919 + required: ["uri"], 1920 properties: { 1921 uri: { 1922 + type: "string", 1923 }, 1924 }, 1925 }, 1926 didMention: { 1927 + type: "object", 1928 + description: "Facet feature for mentioning a did.", 1929 + required: ["did"], 1930 properties: { 1931 did: { 1932 + type: "string", 1933 + format: "did", 1934 }, 1935 }, 1936 }, 1937 atMention: { 1938 + type: "object", 1939 + description: "Facet feature for mentioning an AT URI.", 1940 + required: ["atURI"], 1941 properties: { 1942 atURI: { 1943 + type: "string", 1944 + format: "uri", 1945 }, 1946 }, 1947 }, 1948 code: { 1949 + type: "object", 1950 + description: "Facet feature for inline code.", 1951 required: [], 1952 properties: {}, 1953 }, 1954 highlight: { 1955 + type: "object", 1956 + description: "Facet feature for highlighted text.", 1957 required: [], 1958 properties: {}, 1959 }, 1960 underline: { 1961 + type: "object", 1962 + description: "Facet feature for underline markup", 1963 required: [], 1964 properties: {}, 1965 }, 1966 strikethrough: { 1967 + type: "object", 1968 + description: "Facet feature for strikethrough markup", 1969 required: [], 1970 properties: {}, 1971 }, 1972 id: { 1973 + type: "object", 1974 description: 1975 + "Facet feature for an identifier. Used for linking to a segment", 1976 required: [], 1977 properties: { 1978 id: { 1979 + type: "string", 1980 }, 1981 }, 1982 }, 1983 bold: { 1984 + type: "object", 1985 + description: "Facet feature for bold text", 1986 required: [], 1987 properties: {}, 1988 }, 1989 italic: { 1990 + type: "object", 1991 + description: "Facet feature for italic text", 1992 required: [], 1993 properties: {}, 1994 }, ··· 1996 }, 1997 PubLeafletThemeBackgroundImage: { 1998 lexicon: 1, 1999 + id: "pub.leaflet.theme.backgroundImage", 2000 defs: { 2001 main: { 2002 + type: "object", 2003 + required: ["image"], 2004 properties: { 2005 image: { 2006 + type: "blob", 2007 + accept: ["image/*"], 2008 maxSize: 1000000, 2009 }, 2010 width: { 2011 + type: "integer", 2012 }, 2013 repeat: { 2014 + type: "boolean", 2015 }, 2016 }, 2017 }, ··· 2019 }, 2020 PubLeafletThemeColor: { 2021 lexicon: 1, 2022 + id: "pub.leaflet.theme.color", 2023 defs: { 2024 rgba: { 2025 + type: "object", 2026 + required: ["r", "g", "b", "a"], 2027 properties: { 2028 r: { 2029 + type: "integer", 2030 maximum: 255, 2031 minimum: 0, 2032 }, 2033 g: { 2034 + type: "integer", 2035 maximum: 255, 2036 minimum: 0, 2037 }, 2038 b: { 2039 + type: "integer", 2040 maximum: 255, 2041 minimum: 0, 2042 }, 2043 a: { 2044 + type: "integer", 2045 maximum: 100, 2046 minimum: 0, 2047 }, 2048 }, 2049 }, 2050 rgb: { 2051 + type: "object", 2052 + required: ["r", "g", "b"], 2053 properties: { 2054 r: { 2055 + type: "integer", 2056 maximum: 255, 2057 minimum: 0, 2058 }, 2059 g: { 2060 + type: "integer", 2061 maximum: 255, 2062 minimum: 0, 2063 }, 2064 b: { 2065 + type: "integer", 2066 maximum: 255, 2067 minimum: 0, 2068 }, ··· 2070 }, 2071 }, 2072 }, 2073 + } as const satisfies Record<string, LexiconDoc>; 2074 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]; 2075 + export const lexicons: Lexicons = new Lexicons(schemas); 2076 2077 export function validate<T extends { $type: string }>( 2078 v: unknown, 2079 id: string, 2080 hash: string, 2081 requiredType: true, 2082 + ): ValidationResult<T>; 2083 export function validate<T extends { $type?: string }>( 2084 v: unknown, 2085 id: string, 2086 hash: string, 2087 requiredType?: false, 2088 + ): ValidationResult<T>; 2089 export function validate( 2090 v: unknown, 2091 id: string, ··· 2097 : { 2098 success: false, 2099 error: new ValidationError( 2100 + `Must be an object with "${hash === "main" ? id : `${id}#${hash}`}" $type property`, 2101 ), 2102 + }; 2103 } 2104 2105 export const ids = { 2106 + AppBskyActorProfile: "app.bsky.actor.profile", 2107 + ComAtprotoLabelDefs: "com.atproto.label.defs", 2108 + ComAtprotoRepoApplyWrites: "com.atproto.repo.applyWrites", 2109 + ComAtprotoRepoCreateRecord: "com.atproto.repo.createRecord", 2110 + ComAtprotoRepoDefs: "com.atproto.repo.defs", 2111 + ComAtprotoRepoDeleteRecord: "com.atproto.repo.deleteRecord", 2112 + ComAtprotoRepoDescribeRepo: "com.atproto.repo.describeRepo", 2113 + ComAtprotoRepoGetRecord: "com.atproto.repo.getRecord", 2114 + ComAtprotoRepoImportRepo: "com.atproto.repo.importRepo", 2115 + ComAtprotoRepoListMissingBlobs: "com.atproto.repo.listMissingBlobs", 2116 + ComAtprotoRepoListRecords: "com.atproto.repo.listRecords", 2117 + ComAtprotoRepoPutRecord: "com.atproto.repo.putRecord", 2118 + ComAtprotoRepoStrongRef: "com.atproto.repo.strongRef", 2119 + ComAtprotoRepoUploadBlob: "com.atproto.repo.uploadBlob", 2120 + PubLeafletBlocksBlockquote: "pub.leaflet.blocks.blockquote", 2121 + PubLeafletBlocksBskyPost: "pub.leaflet.blocks.bskyPost", 2122 + PubLeafletBlocksButton: "pub.leaflet.blocks.button", 2123 + PubLeafletBlocksCode: "pub.leaflet.blocks.code", 2124 + PubLeafletBlocksHeader: "pub.leaflet.blocks.header", 2125 + PubLeafletBlocksHorizontalRule: "pub.leaflet.blocks.horizontalRule", 2126 + PubLeafletBlocksIframe: "pub.leaflet.blocks.iframe", 2127 + PubLeafletBlocksImage: "pub.leaflet.blocks.image", 2128 + PubLeafletBlocksMath: "pub.leaflet.blocks.math", 2129 + PubLeafletBlocksPage: "pub.leaflet.blocks.page", 2130 + PubLeafletBlocksPoll: "pub.leaflet.blocks.poll", 2131 + PubLeafletBlocksText: "pub.leaflet.blocks.text", 2132 + PubLeafletBlocksUnorderedList: "pub.leaflet.blocks.unorderedList", 2133 + PubLeafletBlocksWebsite: "pub.leaflet.blocks.website", 2134 + PubLeafletComment: "pub.leaflet.comment", 2135 + PubLeafletDocument: "pub.leaflet.document", 2136 + PubLeafletGraphSubscription: "pub.leaflet.graph.subscription", 2137 + PubLeafletPagesCanvas: "pub.leaflet.pages.canvas", 2138 + PubLeafletPagesLinearDocument: "pub.leaflet.pages.linearDocument", 2139 + PubLeafletPollDefinition: "pub.leaflet.poll.definition", 2140 + PubLeafletPollVote: "pub.leaflet.poll.vote", 2141 + PubLeafletPublication: "pub.leaflet.publication", 2142 + PubLeafletRichtextFacet: "pub.leaflet.richtext.facet", 2143 + PubLeafletThemeBackgroundImage: "pub.leaflet.theme.backgroundImage", 2144 + PubLeafletThemeColor: "pub.leaflet.theme.color", 2145 + } as const;
+1
lexicons/api/types/pub/leaflet/document.ts
··· 24 author: string 25 theme?: PubLeafletPublication.Theme 26 tags?: string[] 27 pages: ( 28 | $Typed<PubLeafletPagesLinearDocument.Main> 29 | $Typed<PubLeafletPagesCanvas.Main>
··· 24 author: string 25 theme?: PubLeafletPublication.Theme 26 tags?: string[] 27 + coverImage?: BlobRef 28 pages: ( 29 | $Typed<PubLeafletPagesLinearDocument.Main> 30 | $Typed<PubLeafletPagesCanvas.Main>
+41 -36
lexicons/api/types/pub/leaflet/publication.ts
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../lexicons' 7 - import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - import type * as PubLeafletThemeColor from './theme/color' 9 - import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage' 10 11 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'pub.leaflet.publication' 14 15 export interface Record { 16 - $type: 'pub.leaflet.publication' 17 - name: string 18 - base_path?: string 19 - description?: string 20 - icon?: BlobRef 21 - theme?: Theme 22 - preferences?: Preferences 23 - [k: string]: unknown 24 } 25 26 - const hashRecord = 'main' 27 28 export function isRecord<V>(v: V) { 29 - return is$typed(v, id, hashRecord) 30 } 31 32 export function validateRecord<V>(v: V) { 33 - return validate<Record & V>(v, id, hashRecord, true) 34 } 35 36 export interface Preferences { 37 - $type?: 'pub.leaflet.publication#preferences' 38 - showInDiscover: boolean 39 - showComments: boolean 40 } 41 42 - const hashPreferences = 'preferences' 43 44 export function isPreferences<V>(v: V) { 45 - return is$typed(v, id, hashPreferences) 46 } 47 48 export function validatePreferences<V>(v: V) { 49 - return validate<Preferences & V>(v, id, hashPreferences) 50 } 51 52 export interface Theme { 53 - $type?: 'pub.leaflet.publication#theme' 54 backgroundColor?: 55 | $Typed<PubLeafletThemeColor.Rgba> 56 | $Typed<PubLeafletThemeColor.Rgb> 57 - | { $type: string } 58 - backgroundImage?: PubLeafletThemeBackgroundImage.Main 59 primary?: 60 | $Typed<PubLeafletThemeColor.Rgba> 61 | $Typed<PubLeafletThemeColor.Rgb> 62 - | { $type: string } 63 pageBackground?: 64 | $Typed<PubLeafletThemeColor.Rgba> 65 | $Typed<PubLeafletThemeColor.Rgb> 66 - | { $type: string } 67 - showPageBackground: boolean 68 accentBackground?: 69 | $Typed<PubLeafletThemeColor.Rgba> 70 | $Typed<PubLeafletThemeColor.Rgb> 71 - | { $type: string } 72 accentText?: 73 | $Typed<PubLeafletThemeColor.Rgba> 74 | $Typed<PubLeafletThemeColor.Rgb> 75 - | { $type: string } 76 } 77 78 - const hashTheme = 'theme' 79 80 export function isTheme<V>(v: V) { 81 - return is$typed(v, id, hashTheme) 82 } 83 84 export function validateTheme<V>(v: V) { 85 - return validate<Theme & V>(v, id, hashTheme) 86 }
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 + import { type ValidationResult, BlobRef } from "@atproto/lexicon"; 5 + import { CID } from "multiformats/cid"; 6 + import { validate as _validate } from "../../../lexicons"; 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from "../../../util"; 12 + import type * as PubLeafletThemeColor from "./theme/color"; 13 + import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage"; 14 15 const is$typed = _is$typed, 16 + validate = _validate; 17 + const id = "pub.leaflet.publication"; 18 19 export interface Record { 20 + $type: "pub.leaflet.publication"; 21 + name: string; 22 + base_path?: string; 23 + description?: string; 24 + icon?: BlobRef; 25 + theme?: Theme; 26 + preferences?: Preferences; 27 + [k: string]: unknown; 28 } 29 30 + const hashRecord = "main"; 31 32 export function isRecord<V>(v: V) { 33 + return is$typed(v, id, hashRecord); 34 } 35 36 export function validateRecord<V>(v: V) { 37 + return validate<Record & V>(v, id, hashRecord, true); 38 } 39 40 export interface Preferences { 41 + $type?: "pub.leaflet.publication#preferences"; 42 + showInDiscover: boolean; 43 + showComments: boolean; 44 } 45 46 + const hashPreferences = "preferences"; 47 48 export function isPreferences<V>(v: V) { 49 + return is$typed(v, id, hashPreferences); 50 } 51 52 export function validatePreferences<V>(v: V) { 53 + return validate<Preferences & V>(v, id, hashPreferences); 54 } 55 56 export interface Theme { 57 + $type?: "pub.leaflet.publication#theme"; 58 backgroundColor?: 59 | $Typed<PubLeafletThemeColor.Rgba> 60 | $Typed<PubLeafletThemeColor.Rgb> 61 + | { $type: string }; 62 + backgroundImage?: PubLeafletThemeBackgroundImage.Main; 63 + pageWidth?: number; 64 primary?: 65 | $Typed<PubLeafletThemeColor.Rgba> 66 | $Typed<PubLeafletThemeColor.Rgb> 67 + | { $type: string }; 68 pageBackground?: 69 | $Typed<PubLeafletThemeColor.Rgba> 70 | $Typed<PubLeafletThemeColor.Rgb> 71 + | { $type: string }; 72 + showPageBackground: boolean; 73 accentBackground?: 74 | $Typed<PubLeafletThemeColor.Rgba> 75 | $Typed<PubLeafletThemeColor.Rgb> 76 + | { $type: string }; 77 accentText?: 78 | $Typed<PubLeafletThemeColor.Rgba> 79 | $Typed<PubLeafletThemeColor.Rgb> 80 + | { $type: string }; 81 } 82 83 + const hashTheme = "theme"; 84 85 export function isTheme<V>(v: V) { 86 + return is$typed(v, id, hashTheme); 87 } 88 89 export function validateTheme<V>(v: V) { 90 + return validate<Theme & V>(v, id, hashTheme); 91 }
+2
lexicons/build.ts
··· 9 import * as path from "path"; 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 import { PubLeafletComment } from "./src/comment"; 12 13 const outdir = path.join("lexicons", "pub", "leaflet"); 14 ··· 21 PubLeafletDocument, 22 PubLeafletComment, 23 PubLeafletRichTextFacet, 24 PageLexicons.PubLeafletPagesLinearDocument, 25 PageLexicons.PubLeafletPagesCanvasDocument, 26 ...ThemeLexicons,
··· 9 import * as path from "path"; 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 import { PubLeafletComment } from "./src/comment"; 12 + import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 13 14 const outdir = path.join("lexicons", "pub", "leaflet"); 15 ··· 22 PubLeafletDocument, 23 PubLeafletComment, 24 PubLeafletRichTextFacet, 25 + PubLeafletAuthFullPermissions, 26 PageLexicons.PubLeafletPagesLinearDocument, 27 PageLexicons.PubLeafletPagesCanvasDocument, 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 "maxLength": 50 54 } 55 }, 56 "pages": { 57 "type": "array", 58 "items": {
··· 53 "maxLength": 50 54 } 55 }, 56 + "coverImage": { 57 + "type": "blob", 58 + "accept": [ 59 + "image/png", 60 + "image/jpeg", 61 + "image/webp" 62 + ], 63 + "maxSize": 1000000 64 + }, 65 "pages": { 66 "type": "array", 67 "items": {
+8 -7
lexicons/pub/leaflet/publication.json
··· 8 "description": "Record declaring a publication", 9 "record": { 10 "type": "object", 11 - "required": [ 12 - "name" 13 - ], 14 "properties": { 15 "name": { 16 "type": "string", ··· 25 }, 26 "icon": { 27 "type": "blob", 28 - "accept": [ 29 - "image/*" 30 - ], 31 "maxSize": 1000000 32 }, 33 "theme": { ··· 68 "type": "ref", 69 "ref": "pub.leaflet.theme.backgroundImage" 70 }, 71 "primary": { 72 "type": "union", 73 "refs": [ ··· 103 } 104 } 105 } 106 - }
··· 8 "description": "Record declaring a publication", 9 "record": { 10 "type": "object", 11 + "required": ["name"], 12 "properties": { 13 "name": { 14 "type": "string", ··· 23 }, 24 "icon": { 25 "type": "blob", 26 + "accept": ["image/*"], 27 "maxSize": 1000000 28 }, 29 "theme": { ··· 64 "type": "ref", 65 "ref": "pub.leaflet.theme.backgroundImage" 66 }, 67 + "pageWidth": { 68 + "type": "integer", 69 + "minimum": 0, 70 + "maximum": 1600 71 + }, 72 "primary": { 73 "type": "union", 74 "refs": [ ··· 104 } 105 } 106 } 107 + }
+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 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 pages: { 28 type: "array", 29 items: {
··· 24 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 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 + }, 32 pages: { 33 type: "array", 34 items: {
+5
lexicons/src/publication.ts
··· 37 type: "ref", 38 ref: PubLeafletThemeBackgroundImage.id, 39 }, 40 primary: ColorUnion, 41 pageBackground: ColorUnion, 42 showPageBackground: { type: "boolean", default: false },
··· 37 type: "ref", 38 ref: PubLeafletThemeBackgroundImage.id, 39 }, 40 + pageWidth: { 41 + type: "integer", 42 + minimum: 0, 43 + maximum: 1600, 44 + }, 45 primary: ColorUnion, 46 pageBackground: ColorUnion, 47 showPageBackground: { type: "boolean", default: false },
+1 -1
package.json
··· 7 "dev": "TZ=UTC next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 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' {} \\;", 11 "wrangler-dev": "wrangler dev", 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
··· 7 "dev": "TZ=UTC next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 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/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 "wrangler-dev": "wrangler dev", 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
+27
src/atproto-oauth.ts
··· 3 NodeSavedSession, 4 NodeSavedState, 5 RuntimeLock, 6 } from "@atproto/oauth-client-node"; 7 import { JoseKey } from "@atproto/jwk-jose"; 8 import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; ··· 10 11 import Client from "ioredis"; 12 import Redlock from "redlock"; 13 export async function createOauthClient() { 14 let keyset = 15 process.env.NODE_ENV === "production" ··· 90 .eq("key", key); 91 }, 92 };
··· 3 NodeSavedSession, 4 NodeSavedState, 5 RuntimeLock, 6 + OAuthSession, 7 } from "@atproto/oauth-client-node"; 8 import { JoseKey } from "@atproto/jwk-jose"; 9 import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; ··· 11 12 import Client from "ioredis"; 13 import Redlock from "redlock"; 14 + import { Result, Ok, Err } from "./result"; 15 export async function createOauthClient() { 16 let keyset = 17 process.env.NODE_ENV === "production" ··· 92 .eq("key", key); 93 }, 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 + }
-2
src/hooks/useLocalizedDate.ts
··· 31 ? timezone || "UTC" 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 34 - console.log("tz", effectiveTimezone); 35 - 36 // Apply timezone if available 37 if (effectiveTimezone) { 38 dateTime = dateTime.setZone(effectiveTimezone);
··· 31 ? timezone || "UTC" 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 34 // Apply timezone if available 35 if (effectiveTimezone) { 36 dateTime = dateTime.setZone(effectiveTimezone);
+4
src/replicache/attributes.ts
··· 191 type: "boolean", 192 cardinality: "one", 193 }, 194 "theme/page-background": { 195 type: "color", 196 cardinality: "one",
··· 191 type: "boolean", 192 cardinality: "one", 193 }, 194 + "theme/page-width": { 195 + type: "number", 196 + cardinality: "one", 197 + }, 198 "theme/page-background": { 199 type: "color", 200 cardinality: "one",
+30 -1
src/replicache/mutations.ts
··· 319 await supabase.storage 320 .from("minilink-user-assets") 321 .remove([paths[paths.length - 1]]); 322 } 323 }); 324 - await ctx.runOnClient(async () => { 325 let cache = await caches.open("minilink-user-assets"); 326 if (image) { 327 await cache.delete(image.data.src + "?local"); 328 } 329 }); 330 await ctx.deleteEntity(block.blockEntity); ··· 612 title?: string; 613 description?: string; 614 tags?: string[]; 615 }> = async (args, ctx) => { 616 await ctx.runOnServer(async (serverCtx) => { 617 console.log("updating"); ··· 619 description?: string; 620 title?: string; 621 tags?: string[]; 622 } = {}; 623 if (args.description !== undefined) updates.description = args.description; 624 if (args.title !== undefined) updates.title = args.title; 625 if (args.tags !== undefined) updates.tags = args.tags; 626 627 if (Object.keys(updates).length > 0) { 628 // First try to update leaflets_in_publications (for publications) ··· 648 if (args.description !== undefined) 649 await tx.set("publication_description", args.description); 650 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 651 }); 652 }; 653
··· 319 await supabase.storage 320 .from("minilink-user-assets") 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 + } 340 } 341 }); 342 + await ctx.runOnClient(async ({ tx }) => { 343 let cache = await caches.open("minilink-user-assets"); 344 if (image) { 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 + } 352 } 353 }); 354 await ctx.deleteEntity(block.blockEntity); ··· 636 title?: string; 637 description?: string; 638 tags?: string[]; 639 + cover_image?: string | null; 640 }> = async (args, ctx) => { 641 await ctx.runOnServer(async (serverCtx) => { 642 console.log("updating"); ··· 644 description?: string; 645 title?: string; 646 tags?: string[]; 647 + cover_image?: string | null; 648 } = {}; 649 if (args.description !== undefined) updates.description = args.description; 650 if (args.title !== undefined) updates.title = args.title; 651 if (args.tags !== undefined) updates.tags = args.tags; 652 + if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 653 654 if (Object.keys(updates).length > 0) { 655 // First try to update leaflets_in_publications (for publications) ··· 675 if (args.description !== undefined) 676 await tx.set("publication_description", args.description); 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); 680 }); 681 }; 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 atp_did?: string | null 557 created_at?: string 558 email?: string | null 559 - home_page: string 560 id?: string 561 interface_state?: Json | null 562 } ··· 581 leaflets_in_publications: { 582 Row: { 583 archived: boolean | null 584 description: string 585 doc: string | null 586 leaflet: string ··· 589 } 590 Insert: { 591 archived?: boolean | null 592 description?: string 593 doc?: string | null 594 leaflet: string ··· 597 } 598 Update: { 599 archived?: boolean | null 600 description?: string 601 doc?: string | null 602 leaflet?: string ··· 629 } 630 leaflets_to_documents: { 631 Row: { 632 created_at: string 633 description: string 634 document: string ··· 636 title: string 637 } 638 Insert: { 639 created_at?: string 640 description?: string 641 document: string ··· 643 title?: string 644 } 645 Update: { 646 created_at?: string 647 description?: string 648 document?: string ··· 1112 [_ in never]: never 1113 } 1114 Functions: { 1115 get_facts: { 1116 Args: { 1117 root: string
··· 556 atp_did?: string | null 557 created_at?: string 558 email?: string | null 559 + home_page?: string 560 id?: string 561 interface_state?: Json | null 562 } ··· 581 leaflets_in_publications: { 582 Row: { 583 archived: boolean | null 584 + cover_image: string | null 585 description: string 586 doc: string | null 587 leaflet: string ··· 590 } 591 Insert: { 592 archived?: boolean | null 593 + cover_image?: string | null 594 description?: string 595 doc?: string | null 596 leaflet: string ··· 599 } 600 Update: { 601 archived?: boolean | null 602 + cover_image?: string | null 603 description?: string 604 doc?: string | null 605 leaflet?: string ··· 632 } 633 leaflets_to_documents: { 634 Row: { 635 + cover_image: string | null 636 created_at: string 637 description: string 638 document: string ··· 640 title: string 641 } 642 Insert: { 643 + cover_image?: string | null 644 created_at?: string 645 description?: string 646 document: string ··· 648 title?: string 649 } 650 Update: { 651 + cover_image?: string | null 652 created_at?: string 653 description?: string 654 document?: string ··· 1118 [_ in never]: never 1119 } 1120 Functions: { 1121 + create_identity_homepage: { 1122 + Args: Record<PropertyKey, never> 1123 + Returns: string 1124 + } 1125 get_facts: { 1126 Args: { 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";