a tool for shared writing and social publishing

add cover image button to images in posts

+310 -18
+20
actions/publishToPublication.ts
··· 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(); ··· 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 {
··· 57 title, 58 description, 59 tags, 60 + cover_image, 61 entitiesToDelete, 62 }: { 63 root_entity: string; ··· 66 title?: string; 67 description?: string; 68 tags?: string[]; 69 + cover_image?: string | null; 70 entitiesToDelete?: string[]; 71 }) { 72 const oauthClient = await createOauthClient(); ··· 137 theme = await extractThemeFromFacts(facts, root_entity, agent); 138 } 139 140 + // Upload cover image if provided 141 + let coverImageBlob: BlobRef | undefined; 142 + if (cover_image) { 143 + let scan = scanIndexLocal(facts); 144 + let [imageData] = scan.eav(cover_image, "block/image"); 145 + if (imageData) { 146 + let imageResponse = await fetch(imageData.data.src); 147 + if (imageResponse.status === 200) { 148 + let binary = await imageResponse.blob(); 149 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 150 + headers: { "Content-Type": binary.type }, 151 + }); 152 + coverImageBlob = blob.data.blob; 153 + } 154 + } 155 + } 156 + 157 let record: PubLeafletDocument.Record = { 158 publishedAt: new Date().toISOString(), 159 ...existingRecord, ··· 164 title: title || "Untitled", 165 description: description || "", 166 ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 167 + ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 168 pages: pages.map((p) => { 169 if (p.type === "canvas") { 170 return {
+6
app/[leaflet_id]/actions/PublishButton.tsx
··· 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 ··· 87 title: pub.title, 88 description: pub.description, 89 tags: currentTags, 90 }); 91 setIsLoading(false); 92 mutate();
··· 72 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 const currentTags = Array.isArray(tags) ? tags : []; 74 75 + // Get cover image from Replicache state 76 + let coverImage = useSubscribe(rep, (tx) => 77 + tx.get<string | null>("publication_cover_image"), 78 + ); 79 + 80 return ( 81 <ActionButton 82 primary ··· 92 title: pub.title, 93 description: pub.description, 94 tags: currentTags, 95 + cover_image: coverImage, 96 }); 97 setIsLoading(false); 98 mutate();
+6
app/[leaflet_id]/publish/PublishPost.tsx
··· 74 ); 75 let [localTags, setLocalTags] = useState<string[]>([]); 76 77 // Use Replicache tags only when we have a draft 78 const hasDraft = props.hasDraft; 79 const currentTags = hasDraft ··· 104 title: props.title, 105 description: props.description, 106 tags: currentTags, 107 entitiesToDelete: props.entitiesToDelete, 108 }); 109 if (!doc) return;
··· 74 ); 75 let [localTags, setLocalTags] = useState<string[]>([]); 76 77 + // Get cover image from Replicache 78 + let replicacheCoverImage = useSubscribe(rep, (tx) => 79 + tx.get<string | null>("publication_cover_image"), 80 + ); 81 + 82 // Use Replicache tags only when we have a draft 83 const hasDraft = props.hasDraft; 84 const currentTags = hasDraft ··· 109 title: props.title, 110 description: props.description, 111 tags: currentTags, 112 + cover_image: replicacheCoverImage, 113 entitiesToDelete: props.entitiesToDelete, 114 }); 115 if (!doc) return;
+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);
+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 : [];
+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 );
+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 }
+37
components/Blocks/ImageBlock.tsx
··· 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(); ··· 172 {altText !== undefined && !props.preview ? ( 173 <ImageAlt entityID={props.value} /> 174 ) : null} 175 </div> 176 ); 177 } ··· 188 altEditorOpen: false, 189 setAltEditorOpen: (s: boolean) => {}, 190 }); 191 192 const ImageAlt = (props: { entityID: string }) => { 193 let { rep } = useReplicache();
··· 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(); ··· 175 {altText !== undefined && !props.preview ? ( 176 <ImageAlt entityID={props.value} /> 177 ) : null} 178 + {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 179 </div> 180 ); 181 } ··· 192 altEditorOpen: false, 193 setAltEditorOpen: (s: boolean) => {}, 194 }); 195 + 196 + const CoverImageButton = (props: { entityID: string }) => { 197 + let { rep } = useReplicache(); 198 + let entity_set = useEntitySetContext(); 199 + let { data: pubData } = useLeafletPublicationData(); 200 + let coverImage = useSubscribe(rep, (tx) => 201 + tx.get<string | null>("publication_cover_image"), 202 + ); 203 + let isFocused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 204 + 205 + // Only show if focused, in a publication, has write permissions, and no cover image is set 206 + if (!isFocused || !pubData?.publications || !entity_set.permissions.write || coverImage) return null; 207 + 208 + return ( 209 + <div className="absolute top-2 left-2"> 210 + <button 211 + 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" 212 + onClick={async (e) => { 213 + e.preventDefault(); 214 + e.stopPropagation(); 215 + await rep?.mutate.updatePublicationDraft({ 216 + cover_image: props.entityID, 217 + }); 218 + }} 219 + > 220 + <span className="w-4 h-4 flex items-center justify-center"> 221 + <ImageCoverImage /> 222 + </span> 223 + Set as Cover 224 + </button> 225 + </div> 226 + ); 227 + }; 228 229 const ImageAlt = (props: { entityID: string }) => { 230 let { rep } = useReplicache();
+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 + );
+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 };
+5
lexicons/api/lexicons.ts
··· 1447 maxLength: 50, 1448 }, 1449 }, 1450 pages: { 1451 type: 'array', 1452 items: {
··· 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: {
+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>
+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": {
+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: {
+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