tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
add cover image button to images in posts
awarm.space
2 months ago
728c50ca
71b22971
+310
-18
16 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
[leaflet_id]
actions
PublishButton.tsx
publish
PublishPost.tsx
api
atproto_images
route.ts
rpc
[command]
pull.ts
lish
[did]
[publication]
[rkey]
opengraph-image.ts
p
[didOrHandle]
[rkey]
opengraph-image.ts
components
Blocks
ImageBlock.tsx
Icons
ImageCoverImage.tsx
Toolbar
BlockToolbar.tsx
ImageToolbar.tsx
lexicons
api
lexicons.ts
types
pub
leaflet
document.ts
pub
leaflet
document.json
src
document.ts
src
replicache
mutations.ts
+20
actions/publishToPublication.ts
···
57
57
title,
58
58
description,
59
59
tags,
60
60
+
cover_image,
60
61
entitiesToDelete,
61
62
}: {
62
63
root_entity: string;
···
65
66
title?: string;
66
67
description?: string;
67
68
tags?: string[];
69
69
+
cover_image?: string | null;
68
70
entitiesToDelete?: string[];
69
71
}) {
70
72
const oauthClient = await createOauthClient();
···
135
137
theme = await extractThemeFromFacts(facts, root_entity, agent);
136
138
}
137
139
140
140
+
// Upload cover image if provided
141
141
+
let coverImageBlob: BlobRef | undefined;
142
142
+
if (cover_image) {
143
143
+
let scan = scanIndexLocal(facts);
144
144
+
let [imageData] = scan.eav(cover_image, "block/image");
145
145
+
if (imageData) {
146
146
+
let imageResponse = await fetch(imageData.data.src);
147
147
+
if (imageResponse.status === 200) {
148
148
+
let binary = await imageResponse.blob();
149
149
+
let blob = await agent.com.atproto.repo.uploadBlob(binary, {
150
150
+
headers: { "Content-Type": binary.type },
151
151
+
});
152
152
+
coverImageBlob = blob.data.blob;
153
153
+
}
154
154
+
}
155
155
+
}
156
156
+
138
157
let record: PubLeafletDocument.Record = {
139
158
publishedAt: new Date().toISOString(),
140
159
...existingRecord,
···
145
164
title: title || "Untitled",
146
165
description: description || "",
147
166
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
167
167
+
...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded
148
168
pages: pages.map((p) => {
149
169
if (p.type === "canvas") {
150
170
return {
+6
app/[leaflet_id]/actions/PublishButton.tsx
···
72
72
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73
73
const currentTags = Array.isArray(tags) ? tags : [];
74
74
75
75
+
// Get cover image from Replicache state
76
76
+
let coverImage = useSubscribe(rep, (tx) =>
77
77
+
tx.get<string | null>("publication_cover_image"),
78
78
+
);
79
79
+
75
80
return (
76
81
<ActionButton
77
82
primary
···
87
92
title: pub.title,
88
93
description: pub.description,
89
94
tags: currentTags,
95
95
+
cover_image: coverImage,
90
96
});
91
97
setIsLoading(false);
92
98
mutate();
+6
app/[leaflet_id]/publish/PublishPost.tsx
···
74
74
);
75
75
let [localTags, setLocalTags] = useState<string[]>([]);
76
76
77
77
+
// Get cover image from Replicache
78
78
+
let replicacheCoverImage = useSubscribe(rep, (tx) =>
79
79
+
tx.get<string | null>("publication_cover_image"),
80
80
+
);
81
81
+
77
82
// Use Replicache tags only when we have a draft
78
83
const hasDraft = props.hasDraft;
79
84
const currentTags = hasDraft
···
104
109
title: props.title,
105
110
description: props.description,
106
111
tags: currentTags,
112
112
+
cover_image: replicacheCoverImage,
107
113
entitiesToDelete: props.entitiesToDelete,
108
114
});
109
115
if (!doc) return;
+29
-11
app/api/atproto_images/route.ts
···
1
1
import { IdResolver } from "@atproto/identity";
2
2
import { NextRequest, NextResponse } from "next/server";
3
3
+
3
4
let idResolver = new IdResolver();
4
5
5
5
-
export async function GET(req: NextRequest) {
6
6
-
const url = new URL(req.url);
7
7
-
const params = {
8
8
-
did: url.searchParams.get("did") ?? "",
9
9
-
cid: url.searchParams.get("cid") ?? "",
10
10
-
};
11
11
-
if (!params.did || !params.cid)
12
12
-
return new NextResponse(null, { status: 404 });
6
6
+
/**
7
7
+
* Fetches a blob from an AT Protocol PDS given a DID and CID
8
8
+
* Returns the Response object or null if the blob couldn't be fetched
9
9
+
*/
10
10
+
export async function fetchAtprotoBlob(
11
11
+
did: string,
12
12
+
cid: string,
13
13
+
): Promise<Response | null> {
14
14
+
if (!did || !cid) return null;
13
15
14
14
-
let identity = await idResolver.did.resolve(params.did);
16
16
+
let identity = await idResolver.did.resolve(did);
15
17
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
16
16
-
if (!service) return new NextResponse(null, { status: 404 });
18
18
+
if (!service) return null;
19
19
+
17
20
const response = await fetch(
18
18
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`,
21
21
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
19
22
{
20
23
headers: {
21
24
"Accept-Encoding": "gzip, deflate, br, zstd",
22
25
},
23
26
},
24
27
);
28
28
+
29
29
+
if (!response.ok) return null;
30
30
+
31
31
+
return response;
32
32
+
}
33
33
+
34
34
+
export async function GET(req: NextRequest) {
35
35
+
const url = new URL(req.url);
36
36
+
const params = {
37
37
+
did: url.searchParams.get("did") ?? "",
38
38
+
cid: url.searchParams.get("cid") ?? "",
39
39
+
};
40
40
+
41
41
+
const response = await fetchAtprotoBlob(params.did, params.cid);
42
42
+
if (!response) return new NextResponse(null, { status: 404 });
25
43
26
44
// Clone the response to modify headers
27
45
const cachedResponse = new Response(response.body, response);
+6
app/api/rpc/[command]/pull.ts
···
74
74
description: string;
75
75
title: string;
76
76
tags: string[];
77
77
+
cover_image: string | null;
77
78
}[];
78
79
let pub_patch = publication_data?.[0]
79
80
? [
···
91
92
op: "put",
92
93
key: "publication_tags",
93
94
value: publication_data[0].tags || [],
95
95
+
},
96
96
+
{
97
97
+
op: "put",
98
98
+
key: "publication_cover_image",
99
99
+
value: publication_data[0].cover_image || null,
94
100
},
95
101
]
96
102
: [];
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
3
+
import { AtUri } from "@atproto/syntax";
4
4
+
import { ids } from "lexicons/api/lexicons";
5
5
+
import { PubLeafletDocument } from "lexicons/api";
6
6
+
import { jsonToLex } from "@atproto/lexicon";
7
7
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
8
3
3
-
export const runtime = "edge";
4
9
export const revalidate = 60;
5
10
6
11
export default async function OpenGraphImage(props: {
7
12
params: Promise<{ publication: string; did: string; rkey: string }>;
8
13
}) {
9
14
let params = await props.params;
15
15
+
let did = decodeURIComponent(params.did);
16
16
+
17
17
+
// Try to get the document's cover image
18
18
+
let { data: document } = await supabaseServerClient
19
19
+
.from("documents")
20
20
+
.select("data")
21
21
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
22
22
+
.single();
23
23
+
24
24
+
if (document) {
25
25
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
26
26
+
if (docRecord.coverImage) {
27
27
+
try {
28
28
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
29
29
+
let cid =
30
30
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
31
31
+
docRecord.coverImage.ref.toString();
32
32
+
33
33
+
let imageResponse = await fetchAtprotoBlob(did, cid);
34
34
+
if (imageResponse) {
35
35
+
let imageBlob = await imageResponse.blob();
36
36
+
37
37
+
// Return the image with appropriate headers
38
38
+
return new Response(imageBlob, {
39
39
+
headers: {
40
40
+
"Content-Type": imageBlob.type || "image/jpeg",
41
41
+
"Cache-Control": "public, max-age=3600",
42
42
+
},
43
43
+
});
44
44
+
}
45
45
+
} catch (e) {
46
46
+
// Fall through to screenshot if cover image fetch fails
47
47
+
console.error("Failed to fetch cover image:", e);
48
48
+
}
49
49
+
}
50
50
+
}
51
51
+
52
52
+
// Fall back to screenshot
10
53
return getMicroLinkOgImage(
11
54
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
12
55
);
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
3
+
import { AtUri } from "@atproto/syntax";
4
4
+
import { ids } from "lexicons/api/lexicons";
5
5
+
import { PubLeafletDocument } from "lexicons/api";
6
6
+
import { jsonToLex } from "@atproto/lexicon";
7
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
8
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
9
3
3
-
export const runtime = "edge";
4
10
export const revalidate = 60;
5
11
6
12
export default async function OpenGraphImage(props: {
7
13
params: Promise<{ rkey: string; didOrHandle: string }>;
8
14
}) {
9
15
let params = await props.params;
10
10
-
return getMicroLinkOgImage(
11
11
-
`/p/${params.didOrHandle}/${params.rkey}/`,
12
12
-
);
16
16
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
17
17
+
18
18
+
// Resolve handle to DID if needed
19
19
+
let did = didOrHandle;
20
20
+
if (!didOrHandle.startsWith("did:")) {
21
21
+
try {
22
22
+
let resolved = await idResolver.handle.resolve(didOrHandle);
23
23
+
if (resolved) did = resolved;
24
24
+
} catch (e) {
25
25
+
// Fall back to screenshot if handle resolution fails
26
26
+
}
27
27
+
}
28
28
+
29
29
+
if (did) {
30
30
+
// Try to get the document's cover image
31
31
+
let { data: document } = await supabaseServerClient
32
32
+
.from("documents")
33
33
+
.select("data")
34
34
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
35
35
+
.single();
36
36
+
37
37
+
if (document) {
38
38
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
39
39
+
if (docRecord.coverImage) {
40
40
+
try {
41
41
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
42
42
+
let cid =
43
43
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
44
44
+
docRecord.coverImage.ref.toString();
45
45
+
46
46
+
let imageResponse = await fetchAtprotoBlob(did, cid);
47
47
+
if (imageResponse) {
48
48
+
let imageBlob = await imageResponse.blob();
49
49
+
50
50
+
// Return the image with appropriate headers
51
51
+
return new Response(imageBlob, {
52
52
+
headers: {
53
53
+
"Content-Type": imageBlob.type || "image/jpeg",
54
54
+
"Cache-Control": "public, max-age=3600",
55
55
+
},
56
56
+
});
57
57
+
}
58
58
+
} catch (e) {
59
59
+
// Fall through to screenshot if cover image fetch fails
60
60
+
console.error("Failed to fetch cover image:", e);
61
61
+
}
62
62
+
}
63
63
+
}
64
64
+
}
65
65
+
66
66
+
// Fall back to screenshot
67
67
+
return getMicroLinkOgImage(`/p/${params.didOrHandle}/${params.rkey}/`);
13
68
}
+37
components/Blocks/ImageBlock.tsx
···
17
17
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
18
18
import { set } from "colorjs.io/fn";
19
19
import { ImageAltSmall } from "components/Icons/ImageAlt";
20
20
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
21
21
+
import { useSubscribe } from "src/replicache/useSubscribe";
22
22
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
20
23
21
24
export function ImageBlock(props: BlockProps & { preview?: boolean }) {
22
25
let { rep } = useReplicache();
···
172
175
{altText !== undefined && !props.preview ? (
173
176
<ImageAlt entityID={props.value} />
174
177
) : null}
178
178
+
{!props.preview ? <CoverImageButton entityID={props.value} /> : null}
175
179
</div>
176
180
);
177
181
}
···
188
192
altEditorOpen: false,
189
193
setAltEditorOpen: (s: boolean) => {},
190
194
});
195
195
+
196
196
+
const CoverImageButton = (props: { entityID: string }) => {
197
197
+
let { rep } = useReplicache();
198
198
+
let entity_set = useEntitySetContext();
199
199
+
let { data: pubData } = useLeafletPublicationData();
200
200
+
let coverImage = useSubscribe(rep, (tx) =>
201
201
+
tx.get<string | null>("publication_cover_image"),
202
202
+
);
203
203
+
let isFocused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
204
204
+
205
205
+
// Only show if focused, in a publication, has write permissions, and no cover image is set
206
206
+
if (!isFocused || !pubData?.publications || !entity_set.permissions.write || coverImage) return null;
207
207
+
208
208
+
return (
209
209
+
<div className="absolute top-2 left-2">
210
210
+
<button
211
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
212
+
onClick={async (e) => {
213
213
+
e.preventDefault();
214
214
+
e.stopPropagation();
215
215
+
await rep?.mutate.updatePublicationDraft({
216
216
+
cover_image: props.entityID,
217
217
+
});
218
218
+
}}
219
219
+
>
220
220
+
<span className="w-4 h-4 flex items-center justify-center">
221
221
+
<ImageCoverImage />
222
222
+
</span>
223
223
+
Set as Cover
224
224
+
</button>
225
225
+
</div>
226
226
+
);
227
227
+
};
191
228
192
229
const ImageAlt = (props: { entityID: string }) => {
193
230
let { rep } = useReplicache();
+14
components/Icons/ImageCoverImage.tsx
···
1
1
+
export const ImageCoverImage = () => (
2
2
+
<svg
3
3
+
width="24"
4
4
+
height="24"
5
5
+
viewBox="0 0 24 24"
6
6
+
fill="none"
7
7
+
xmlns="http://www.w3.org/2000/svg"
8
8
+
>
9
9
+
<path
10
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
11
+
fill="currentColor"
12
12
+
/>
13
13
+
</svg>
14
14
+
);
+2
-1
components/Toolbar/BlockToolbar.tsx
···
5
5
import { useUIState } from "src/useUIState";
6
6
import { LockBlockButton } from "./LockBlockButton";
7
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
8
-
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
8
8
+
import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar";
9
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
10
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
11
···
44
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
45
<ImageFullBleedButton />
46
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
47
+
<ImageCoverButton />
47
48
{focusedEntityType?.data.value !== "canvas" && (
48
49
<Separator classname="h-6" />
49
50
)}
+37
components/Toolbar/ImageToolbar.tsx
···
4
4
import { useUIState } from "src/useUIState";
5
5
import { Props } from "components/Icons/Props";
6
6
import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt";
7
7
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
8
8
+
import { useSubscribe } from "src/replicache/useSubscribe";
9
9
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
7
10
8
11
export const ImageFullBleedButton = (props: {}) => {
9
12
let { rep } = useReplicache();
···
76
79
) : (
77
80
<ImageRemoveAltSmall />
78
81
)}
82
82
+
</ToolbarButton>
83
83
+
);
84
84
+
};
85
85
+
86
86
+
export const ImageCoverButton = () => {
87
87
+
let { rep } = useReplicache();
88
88
+
let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null;
89
89
+
let hasSrc = useEntity(focusedBlock, "block/image")?.data;
90
90
+
let { data: pubData } = useLeafletPublicationData();
91
91
+
let coverImage = useSubscribe(rep, (tx) =>
92
92
+
tx.get<string | null>("publication_cover_image"),
93
93
+
);
94
94
+
95
95
+
// Only show if in a publication and has an image
96
96
+
if (!pubData?.publications || !hasSrc) return null;
97
97
+
98
98
+
let isCoverImage = coverImage === focusedBlock;
99
99
+
100
100
+
return (
101
101
+
<ToolbarButton
102
102
+
active={isCoverImage}
103
103
+
onClick={async (e) => {
104
104
+
e.preventDefault();
105
105
+
if (rep && focusedBlock) {
106
106
+
await rep.mutate.updatePublicationDraft({
107
107
+
cover_image: isCoverImage ? null : focusedBlock,
108
108
+
});
109
109
+
}
110
110
+
}}
111
111
+
tooltipContent={
112
112
+
<div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div>
113
113
+
}
114
114
+
>
115
115
+
<ImageCoverImage />
79
116
</ToolbarButton>
80
117
);
81
118
};
+5
lexicons/api/lexicons.ts
···
1447
1447
maxLength: 50,
1448
1448
},
1449
1449
},
1450
1450
+
coverImage: {
1451
1451
+
type: 'blob',
1452
1452
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
1453
1453
+
maxSize: 1000000,
1454
1454
+
},
1450
1455
pages: {
1451
1456
type: 'array',
1452
1457
items: {
+1
lexicons/api/types/pub/leaflet/document.ts
···
24
24
author: string
25
25
theme?: PubLeafletPublication.Theme
26
26
tags?: string[]
27
27
+
coverImage?: BlobRef
27
28
pages: (
28
29
| $Typed<PubLeafletPagesLinearDocument.Main>
29
30
| $Typed<PubLeafletPagesCanvas.Main>
+9
lexicons/pub/leaflet/document.json
···
53
53
"maxLength": 50
54
54
}
55
55
},
56
56
+
"coverImage": {
57
57
+
"type": "blob",
58
58
+
"accept": [
59
59
+
"image/png",
60
60
+
"image/jpeg",
61
61
+
"image/webp"
62
62
+
],
63
63
+
"maxSize": 1000000
64
64
+
},
56
65
"pages": {
57
66
"type": "array",
58
67
"items": {
+5
lexicons/src/document.ts
···
24
24
author: { type: "string", format: "at-identifier" },
25
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
26
tags: { type: "array", items: { type: "string", maxLength: 50 } },
27
27
+
coverImage: {
28
28
+
type: "blob",
29
29
+
accept: ["image/png", "image/jpeg", "image/webp"],
30
30
+
maxSize: 1000000,
31
31
+
},
27
32
pages: {
28
33
type: "array",
29
34
items: {
+30
-1
src/replicache/mutations.ts
···
319
319
await supabase.storage
320
320
.from("minilink-user-assets")
321
321
.remove([paths[paths.length - 1]]);
322
322
+
323
323
+
// Clear cover image if this block is the cover image
324
324
+
// First try leaflets_in_publications
325
325
+
const { data: pubResult } = await supabase
326
326
+
.from("leaflets_in_publications")
327
327
+
.update({ cover_image: null })
328
328
+
.eq("leaflet", ctx.permission_token_id)
329
329
+
.eq("cover_image", block.blockEntity)
330
330
+
.select("leaflet");
331
331
+
332
332
+
// If no rows updated, try leaflets_to_documents
333
333
+
if (!pubResult || pubResult.length === 0) {
334
334
+
await supabase
335
335
+
.from("leaflets_to_documents")
336
336
+
.update({ cover_image: null })
337
337
+
.eq("leaflet", ctx.permission_token_id)
338
338
+
.eq("cover_image", block.blockEntity);
339
339
+
}
322
340
}
323
341
});
324
324
-
await ctx.runOnClient(async () => {
342
342
+
await ctx.runOnClient(async ({ tx }) => {
325
343
let cache = await caches.open("minilink-user-assets");
326
344
if (image) {
327
345
await cache.delete(image.data.src + "?local");
346
346
+
347
347
+
// Clear cover image in client state if this block was the cover image
348
348
+
let currentCoverImage = await tx.get("publication_cover_image");
349
349
+
if (currentCoverImage === block.blockEntity) {
350
350
+
await tx.set("publication_cover_image", null);
351
351
+
}
328
352
}
329
353
});
330
354
await ctx.deleteEntity(block.blockEntity);
···
612
636
title?: string;
613
637
description?: string;
614
638
tags?: string[];
639
639
+
cover_image?: string | null;
615
640
}> = async (args, ctx) => {
616
641
await ctx.runOnServer(async (serverCtx) => {
617
642
console.log("updating");
···
619
644
description?: string;
620
645
title?: string;
621
646
tags?: string[];
647
647
+
cover_image?: string | null;
622
648
} = {};
623
649
if (args.description !== undefined) updates.description = args.description;
624
650
if (args.title !== undefined) updates.title = args.title;
625
651
if (args.tags !== undefined) updates.tags = args.tags;
652
652
+
if (args.cover_image !== undefined) updates.cover_image = args.cover_image;
626
653
627
654
if (Object.keys(updates).length > 0) {
628
655
// First try to update leaflets_in_publications (for publications)
···
648
675
if (args.description !== undefined)
649
676
await tx.set("publication_description", args.description);
650
677
if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
678
678
+
if (args.cover_image !== undefined)
679
679
+
await tx.set("publication_cover_image", args.cover_image);
651
680
});
652
681
};
653
682