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
28
pulls
pipelines
render standalone published docs at /p/
awarm.space
3 months ago
49e6bca4
c8d2446b
+385
-225
14 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
[leaflet_id]
actions
PublishButton.tsx
publish
PublishPost.tsx
page.tsx
lish
[did]
[publication]
[rkey]
CanvasPage.tsx
DocumentPageRenderer.tsx
LinearDocumentPage.tsx
PostHeader
PostHeader.tsx
PostPages.tsx
page.tsx
p
[didOrHandle]
[rkey]
l-quote
[quote]
opengraph-image.ts
page.tsx
page.tsx
components
Pages
PublicationMetadata.tsx
+20
actions/publishToPublication.ts
···
52
52
leaflet_id,
53
53
title,
54
54
description,
55
55
+
entitiesToDelete,
55
56
}: {
56
57
root_entity: string;
57
58
publication_uri?: string;
58
59
leaflet_id: string;
59
60
title?: string;
60
61
description?: string;
62
62
+
entitiesToDelete?: string[];
61
63
}) {
62
64
const oauthClient = await createOauthClient();
63
65
let identity = await getIdentityData();
···
179
181
.eq("leaflet", leaflet_id)
180
182
.eq("publication", publication_uri),
181
183
]);
184
184
+
185
185
+
// Heuristic: Remove title entities if this is the first time publishing
186
186
+
// (when coming from a standalone leaflet with entitiesToDelete passed in)
187
187
+
if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
188
188
+
await supabaseServerClient
189
189
+
.from("entities")
190
190
+
.delete()
191
191
+
.in("id", entitiesToDelete);
192
192
+
}
182
193
} else {
183
194
// Publishing standalone - update leaflets_to_documents
184
195
await supabaseServerClient.from("leaflets_to_documents").upsert({
···
187
198
title: title || "Untitled",
188
199
description: description || "",
189
200
});
201
201
+
202
202
+
// Heuristic: Remove title entities if this is the first time publishing standalone
203
203
+
// (when entitiesToDelete is provided and there's no existing document)
204
204
+
if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
205
205
+
await supabaseServerClient
206
206
+
.from("entities")
207
207
+
.delete()
208
208
+
.in("id", entitiesToDelete);
209
209
+
}
190
210
}
191
211
192
212
return { rkey, record: JSON.parse(JSON.stringify(record)) };
+15
-8
app/[leaflet_id]/actions/PublishButton.tsx
···
190
190
// For looseleaf, navigate without publication_uri
191
191
if (selectedPub === "looseleaf") {
192
192
router.push(
193
193
-
`${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`,
193
193
+
`${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
194
194
);
195
195
} else {
196
196
router.push(
197
197
-
`${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`,
197
197
+
`${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
198
198
);
199
199
}
200
200
}}
···
362
362
363
363
let useTitle = (entityID: string) => {
364
364
let rootPage = useEntity(entityID, "root/page")[0].data.value;
365
365
-
let firstBlock = useBlocks(rootPage)[0]?.value;
365
365
+
let blocks = useBlocks(rootPage);
366
366
+
let firstBlock = blocks[0];
366
367
367
367
-
let firstBlockText = useEntity(firstBlock, "block/text")?.data.value;
368
368
+
let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value;
368
369
369
370
const leafletTitle = useMemo(() => {
370
371
if (!firstBlockText) return "Untitled";
···
375
376
return YJSFragmentToString(nodes[0]) || "Untitled";
376
377
}, [firstBlockText]);
377
378
378
378
-
let secondBlock = useBlocks(rootPage)[1];
379
379
-
let secondBlockTextValue = useEntity(secondBlock.value, "block/text")?.data
379
379
+
let secondBlock = blocks[1];
380
380
+
let secondBlockTextValue = useEntity(secondBlock?.value, "block/text")?.data
380
381
.value;
381
382
const secondBlockText = useMemo(() => {
382
383
if (!secondBlockTextValue) return "";
···
388
389
}, [firstBlockText]);
389
390
390
391
let entitiesToDelete = useMemo(() => {
391
391
-
let etod = [firstBlock];
392
392
-
if (secondBlockText.trim() === "" && secondBlock.type === "text")
392
392
+
let etod: string[] = [];
393
393
+
// Only delete first block if it's a heading type
394
394
+
if (firstBlock?.type === "heading") {
395
395
+
etod.push(firstBlock.value);
396
396
+
}
397
397
+
// Delete second block if it's empty text
398
398
+
if (secondBlockText.trim() === "" && secondBlock?.type === "text") {
393
399
etod.push(secondBlock.value);
400
400
+
}
394
401
return etod;
395
402
}, [firstBlock, secondBlockText, secondBlock]);
396
403
+2
app/[leaflet_id]/publish/PublishPost.tsx
···
30
30
publication_uri?: string;
31
31
record?: PubLeafletPublication.Record;
32
32
posts_in_pub?: number;
33
33
+
entitiesToDelete?: string[];
33
34
};
34
35
35
36
export function PublishPost(props: Props) {
···
74
75
leaflet_id: props.leaflet_id,
75
76
title: props.title,
76
77
description: props.description,
78
78
+
entitiesToDelete: props.entitiesToDelete,
77
79
});
78
80
if (!doc) return;
79
81
+15
app/[leaflet_id]/publish/page.tsx
···
17
17
publication_uri: string;
18
18
title: string;
19
19
description: string;
20
20
+
entitiesToDelete: string;
20
21
}>;
21
22
};
22
23
export default async function PublishLeafletPage(props: Props) {
···
85
86
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
86
87
let profile = await agent.getProfile({ actor: identity.atp_did });
87
88
89
89
+
// Parse entitiesToDelete from URL params
90
90
+
let searchParams = await props.searchParams;
91
91
+
let entitiesToDelete: string[] = [];
92
92
+
try {
93
93
+
if (searchParams.entitiesToDelete) {
94
94
+
entitiesToDelete = JSON.parse(
95
95
+
decodeURIComponent(searchParams.entitiesToDelete),
96
96
+
);
97
97
+
}
98
98
+
} catch (e) {
99
99
+
// If parsing fails, just use empty array
100
100
+
}
101
101
+
88
102
return (
89
103
<ReplicacheProvider
90
104
rootEntity={rootEntity}
···
101
115
publication_uri={publication?.uri}
102
116
record={publication?.record as PubLeafletPublication.Record | undefined}
103
117
posts_in_pub={publication?.documents_in_publications[0]?.count}
118
118
+
entitiesToDelete={entitiesToDelete}
104
119
/>
105
120
</ReplicacheProvider>
106
121
);
+4
-2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
29
29
profile,
30
30
preferences,
31
31
pubRecord,
32
32
+
theme,
32
33
prerenderedCodeBlocks,
33
34
bskyPostData,
34
35
pollData,
···
42
43
document: PostPageData;
43
44
blocks: PubLeafletPagesCanvas.Block[];
44
45
profile: ProfileViewDetailed;
45
45
-
pubRecord: PubLeafletPublication.Record;
46
46
+
pubRecord?: PubLeafletPublication.Record;
47
47
+
theme?: PubLeafletPublication.Theme | null;
46
48
did: string;
47
49
prerenderedCodeBlocks?: Map<string, string>;
48
50
bskyPostData: AppBskyFeedDefs.PostView[];
···
55
57
}) {
56
58
if (!document) return null;
57
59
58
58
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
60
60
+
let hasPageBackground = !!theme?.showPageBackground;
59
61
let isSubpage = !!pageId;
60
62
let drawer = useDrawerOpen(document_uri);
61
63
+139
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
···
1
1
+
import { AtpAgent } from "@atproto/api";
2
2
+
import { AtUri } from "@atproto/syntax";
3
3
+
import { ids } from "lexicons/api/lexicons";
4
4
+
import {
5
5
+
PubLeafletBlocksBskyPost,
6
6
+
PubLeafletDocument,
7
7
+
PubLeafletPagesLinearDocument,
8
8
+
PubLeafletPublication,
9
9
+
} from "lexicons/api";
10
10
+
import { QuoteHandler } from "./QuoteHandler";
11
11
+
import {
12
12
+
PublicationBackgroundProvider,
13
13
+
PublicationThemeProvider,
14
14
+
} from "components/ThemeManager/PublicationThemeProvider";
15
15
+
import { getPostPageData } from "./getPostPageData";
16
16
+
import { PostPageContextProvider } from "./PostPageContext";
17
17
+
import { PostPages } from "./PostPages";
18
18
+
import { extractCodeBlocks } from "./extractCodeBlocks";
19
19
+
import { LeafletLayout } from "components/LeafletLayout";
20
20
+
import { fetchPollData } from "./fetchPollData";
21
21
+
22
22
+
export async function DocumentPageRenderer({ did, rkey }: { did: string; rkey: string }) {
23
23
+
let agent = new AtpAgent({
24
24
+
service: "https://public.api.bsky.app",
25
25
+
fetch: (...args) =>
26
26
+
fetch(args[0], {
27
27
+
...args[1],
28
28
+
next: { revalidate: 3600 },
29
29
+
}),
30
30
+
});
31
31
+
32
32
+
let [document, profile] = await Promise.all([
33
33
+
getPostPageData(
34
34
+
AtUri.make(did, ids.PubLeafletDocument, rkey).toString(),
35
35
+
),
36
36
+
agent.getProfile({ actor: did }),
37
37
+
]);
38
38
+
39
39
+
if (!document?.data)
40
40
+
return (
41
41
+
<div className="bg-bg-leaflet h-full p-3 text-center relative">
42
42
+
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full">
43
43
+
<div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 ">
44
44
+
<h3>Sorry, post not found!</h3>
45
45
+
<p>
46
46
+
This may be a glitch on our end. If the issue persists please{" "}
47
47
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
48
48
+
</p>
49
49
+
</div>
50
50
+
</div>
51
51
+
</div>
52
52
+
);
53
53
+
54
54
+
let record = document.data as PubLeafletDocument.Record;
55
55
+
let bskyPosts =
56
56
+
record.pages.flatMap((p) => {
57
57
+
let page = p as PubLeafletPagesLinearDocument.Main;
58
58
+
return page.blocks?.filter(
59
59
+
(b) => b.block.$type === ids.PubLeafletBlocksBskyPost,
60
60
+
);
61
61
+
}) || [];
62
62
+
63
63
+
// Batch bsky posts into groups of 25 and fetch in parallel
64
64
+
let bskyPostBatches = [];
65
65
+
for (let i = 0; i < bskyPosts.length; i += 25) {
66
66
+
bskyPostBatches.push(bskyPosts.slice(i, i + 25));
67
67
+
}
68
68
+
69
69
+
let bskyPostResponses = await Promise.all(
70
70
+
bskyPostBatches.map((batch) =>
71
71
+
agent.getPosts(
72
72
+
{
73
73
+
uris: batch.map((p) => {
74
74
+
let block = p?.block as PubLeafletBlocksBskyPost.Main;
75
75
+
return block.postRef.uri;
76
76
+
}),
77
77
+
},
78
78
+
{ headers: {} },
79
79
+
),
80
80
+
),
81
81
+
);
82
82
+
83
83
+
let bskyPostData =
84
84
+
bskyPostResponses.length > 0
85
85
+
? bskyPostResponses.flatMap((response) => response.data.posts)
86
86
+
: [];
87
87
+
88
88
+
// Extract poll blocks and fetch vote data
89
89
+
let pollBlocks = record.pages.flatMap((p) => {
90
90
+
let page = p as PubLeafletPagesLinearDocument.Main;
91
91
+
return (
92
92
+
page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) ||
93
93
+
[]
94
94
+
);
95
95
+
});
96
96
+
let pollData = await fetchPollData(
97
97
+
pollBlocks.map((b) => (b.block as any).pollRef.uri),
98
98
+
);
99
99
+
100
100
+
// Get theme from publication or document (for standalone docs)
101
101
+
let pubRecord = document.documents_in_publications[0]?.publications
102
102
+
?.record as PubLeafletPublication.Record | undefined;
103
103
+
let theme = pubRecord?.theme || record.theme || null;
104
104
+
let pub_creator = document.documents_in_publications[0]?.publications
105
105
+
?.identity_did || did;
106
106
+
107
107
+
let firstPage = record.pages[0];
108
108
+
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
109
109
+
if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
110
110
+
blocks = firstPage.blocks || [];
111
111
+
}
112
112
+
113
113
+
let prerenderedCodeBlocks = await extractCodeBlocks(blocks);
114
114
+
115
115
+
return (
116
116
+
<PostPageContextProvider value={document}>
117
117
+
<PublicationThemeProvider theme={theme} pub_creator={pub_creator}>
118
118
+
<PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}>
119
119
+
<LeafletLayout>
120
120
+
<PostPages
121
121
+
document_uri={document.uri}
122
122
+
preferences={pubRecord?.preferences || {}}
123
123
+
pubRecord={pubRecord}
124
124
+
profile={JSON.parse(JSON.stringify(profile.data))}
125
125
+
document={document}
126
126
+
bskyPostData={bskyPostData}
127
127
+
did={did}
128
128
+
blocks={blocks}
129
129
+
prerenderedCodeBlocks={prerenderedCodeBlocks}
130
130
+
pollData={pollData}
131
131
+
/>
132
132
+
</LeafletLayout>
133
133
+
134
134
+
<QuoteHandler />
135
135
+
</PublicationBackgroundProvider>
136
136
+
</PublicationThemeProvider>
137
137
+
</PostPageContextProvider>
138
138
+
);
139
139
+
}
+23
-20
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
31
31
profile,
32
32
preferences,
33
33
pubRecord,
34
34
+
theme,
34
35
prerenderedCodeBlocks,
35
36
bskyPostData,
36
37
document_uri,
···
43
44
document: PostPageData;
44
45
blocks: PubLeafletPagesLinearDocument.Block[];
45
46
profile?: ProfileViewDetailed;
46
46
-
pubRecord: PubLeafletPublication.Record;
47
47
+
pubRecord?: PubLeafletPublication.Record;
48
48
+
theme?: PubLeafletPublication.Theme | null;
47
49
did: string;
48
50
prerenderedCodeBlocks?: Map<string, string>;
49
51
bskyPostData: AppBskyFeedDefs.PostView[];
···
56
58
let { identity } = useIdentityData();
57
59
let drawer = useDrawerOpen(document_uri);
58
60
59
59
-
if (!document || !document.documents_in_publications[0].publications)
60
60
-
return null;
61
61
+
if (!document) return null;
61
62
62
62
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
63
63
+
let hasPageBackground = !!theme?.showPageBackground;
63
64
let record = document.data as PubLeafletDocument.Record;
64
65
65
66
const isSubpage = !!pageId;
···
114
115
<EditTiny /> Edit Post
115
116
</a>
116
117
) : (
117
117
-
<SubscribeWithBluesky
118
118
-
isPost
119
119
-
base_url={getPublicationURL(
120
120
-
document.documents_in_publications[0].publications,
121
121
-
)}
122
122
-
pub_uri={
123
123
-
document.documents_in_publications[0].publications.uri
124
124
-
}
125
125
-
subscribers={
126
126
-
document.documents_in_publications[0].publications
127
127
-
.publication_subscriptions
128
128
-
}
129
129
-
pubName={
130
130
-
document.documents_in_publications[0].publications.name
131
131
-
}
132
132
-
/>
118
118
+
document.documents_in_publications[0]?.publications && (
119
119
+
<SubscribeWithBluesky
120
120
+
isPost
121
121
+
base_url={getPublicationURL(
122
122
+
document.documents_in_publications[0].publications,
123
123
+
)}
124
124
+
pub_uri={
125
125
+
document.documents_in_publications[0].publications.uri
126
126
+
}
127
127
+
subscribers={
128
128
+
document.documents_in_publications[0].publications
129
129
+
.publication_subscriptions
130
130
+
}
131
131
+
pubName={
132
132
+
document.documents_in_publications[0].publications.name
133
133
+
}
134
134
+
/>
135
135
+
)
133
136
)}
134
137
</div>
135
138
</>
+14
-21
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
27
27
28
28
let record = document?.data as PubLeafletDocument.Record;
29
29
let profile = props.profile;
30
30
-
let pub = props.data?.documents_in_publications[0].publications;
31
31
-
let pubRecord = pub?.record as PubLeafletPublication.Record;
30
30
+
let pub = props.data?.documents_in_publications[0]?.publications;
32
31
33
32
const formattedDate = useLocalizedDate(
34
33
record.publishedAt || new Date().toISOString(),
···
36
35
year: "numeric",
37
36
month: "long",
38
37
day: "2-digit",
39
39
-
}
38
38
+
},
40
39
);
41
40
42
42
-
if (!document?.data || !document.documents_in_publications[0].publications)
43
43
-
return;
41
41
+
if (!document?.data) return;
44
42
return (
45
43
<div
46
44
className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2"
···
48
46
>
49
47
<div className="pubHeader flex flex-col pb-5">
50
48
<div className="flex justify-between w-full">
51
51
-
<SpeedyLink
52
52
-
className="font-bold hover:no-underline text-accent-contrast"
53
53
-
href={
54
54
-
document &&
55
55
-
getPublicationURL(
56
56
-
document.documents_in_publications[0].publications,
57
57
-
)
58
58
-
}
59
59
-
>
60
60
-
{pub?.name}
61
61
-
</SpeedyLink>
49
49
+
{pub && (
50
50
+
<SpeedyLink
51
51
+
className="font-bold hover:no-underline text-accent-contrast"
52
52
+
href={document && getPublicationURL(pub)}
53
53
+
>
54
54
+
{pub?.name}
55
55
+
</SpeedyLink>
56
56
+
)}
62
57
{identity &&
63
63
-
identity.atp_did ===
64
64
-
document.documents_in_publications[0]?.publications
65
65
-
.identity_did &&
58
58
+
pub &&
59
59
+
identity.atp_did === pub.identity_did &&
66
60
document.leaflets_in_publications[0] && (
67
61
<a
68
62
className=" rounded-full flex place-items-center"
···
90
84
) : null}
91
85
{record.publishedAt ? (
92
86
<>
93
93
-
|
94
94
-
<p>{formattedDate}</p>
87
87
+
|<p>{formattedDate}</p>
95
88
</>
96
89
) : null}
97
90
|{" "}
+12
-6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
114
114
document: PostPageData;
115
115
blocks: PubLeafletPagesLinearDocument.Block[];
116
116
profile: ProfileViewDetailed;
117
117
-
pubRecord: PubLeafletPublication.Record;
117
117
+
pubRecord?: PubLeafletPublication.Record;
118
118
did: string;
119
119
prerenderedCodeBlocks?: Map<string, string>;
120
120
bskyPostData: AppBskyFeedDefs.PostView[];
···
124
124
let drawer = useDrawerOpen(document_uri);
125
125
useInitializeOpenPages();
126
126
let pages = useOpenPages();
127
127
-
if (!document || !document.documents_in_publications[0].publications)
128
128
-
return null;
127
127
+
if (!document) return null;
129
128
130
130
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
131
129
let record = document.data as PubLeafletDocument.Record;
130
130
+
131
131
+
// Get theme from publication or document (for standalone docs)
132
132
+
let theme = pubRecord?.theme || record.theme || null;
133
133
+
let hasPageBackground = !!theme?.showPageBackground;
134
134
+
132
135
let quotesAndMentions = document.quotesAndMentions;
133
136
134
137
let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0;
···
144
147
pollData={pollData}
145
148
preferences={preferences}
146
149
pubRecord={pubRecord}
150
150
+
theme={theme}
147
151
prerenderedCodeBlocks={prerenderedCodeBlocks}
148
152
bskyPostData={bskyPostData}
149
153
document_uri={document_uri}
···
153
157
<InteractionDrawer
154
158
document_uri={document.uri}
155
159
comments={
156
156
-
pubRecord.preferences?.showComments === false
160
160
+
pubRecord?.preferences?.showComments === false
157
161
? []
158
162
: document.comments_on_documents
159
163
}
···
190
194
preferences={preferences}
191
195
profile={profile}
192
196
pubRecord={pubRecord}
197
197
+
theme={theme}
193
198
prerenderedCodeBlocks={prerenderedCodeBlocks}
194
199
pollData={pollData}
195
200
bskyPostData={bskyPostData}
···
211
216
did={did}
212
217
preferences={preferences}
213
218
pubRecord={pubRecord}
219
219
+
theme={theme}
214
220
pollData={pollData}
215
221
prerenderedCodeBlocks={prerenderedCodeBlocks}
216
222
bskyPostData={bskyPostData}
···
229
235
pageId={page.id}
230
236
document_uri={document.uri}
231
237
comments={
232
232
-
pubRecord.preferences?.showComments === false
238
238
+
pubRecord?.preferences?.showComments === false
233
239
? []
234
240
: document.comments_on_documents
235
241
}
+6
-156
app/lish/[did]/[publication]/[rkey]/page.tsx
···
1
1
import { supabaseServerClient } from "supabase/serverClient";
2
2
import { AtUri } from "@atproto/syntax";
3
3
import { ids } from "lexicons/api/lexicons";
4
4
-
import {
5
5
-
PubLeafletBlocksBskyPost,
6
6
-
PubLeafletDocument,
7
7
-
PubLeafletPagesLinearDocument,
8
8
-
PubLeafletPublication,
9
9
-
} from "lexicons/api";
4
4
+
import { PubLeafletDocument } from "lexicons/api";
10
5
import { Metadata } from "next";
11
11
-
import { AtpAgent } from "@atproto/api";
12
12
-
import { QuoteHandler } from "./QuoteHandler";
13
13
-
import { InteractionDrawer } from "./Interactions/InteractionDrawer";
14
14
-
import {
15
15
-
PublicationBackgroundProvider,
16
16
-
PublicationThemeProvider,
17
17
-
} from "components/ThemeManager/PublicationThemeProvider";
18
18
-
import { getPostPageData } from "./getPostPageData";
19
19
-
import { PostPageContextProvider } from "./PostPageContext";
20
20
-
import { PostPages } from "./PostPages";
21
21
-
import { extractCodeBlocks } from "./extractCodeBlocks";
22
22
-
import { LeafletLayout } from "components/LeafletLayout";
23
23
-
import { fetchPollData } from "./fetchPollData";
6
6
+
import { DocumentPageRenderer } from "./DocumentPageRenderer";
24
7
25
8
export async function generateMetadata(props: {
26
9
params: Promise<{ publication: string; did: string; rkey: string }>;
···
57
40
export default async function Post(props: {
58
41
params: Promise<{ publication: string; did: string; rkey: string }>;
59
42
}) {
60
60
-
let did = decodeURIComponent((await props.params).did);
43
43
+
let params = await props.params;
44
44
+
let did = decodeURIComponent(params.did);
45
45
+
61
46
if (!did)
62
47
return (
63
48
<div className="p-4 text-lg text-center flex flex-col gap-4">
···
68
53
</p>
69
54
</div>
70
55
);
71
71
-
let agent = new AtpAgent({
72
72
-
service: "https://public.api.bsky.app",
73
73
-
fetch: (...args) =>
74
74
-
fetch(args[0], {
75
75
-
...args[1],
76
76
-
next: { revalidate: 3600 },
77
77
-
}),
78
78
-
});
79
79
-
let [document, profile] = await Promise.all([
80
80
-
getPostPageData(
81
81
-
AtUri.make(
82
82
-
did,
83
83
-
ids.PubLeafletDocument,
84
84
-
(await props.params).rkey,
85
85
-
).toString(),
86
86
-
),
87
87
-
agent.getProfile({ actor: did }),
88
88
-
]);
89
89
-
if (!document?.data || !document.documents_in_publications[0].publications)
90
90
-
return (
91
91
-
<div className="bg-bg-leaflet h-full p-3 text-center relative">
92
92
-
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full">
93
93
-
<div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 ">
94
94
-
<h3>Sorry, post not found!</h3>
95
95
-
<p>
96
96
-
This may be a glitch on our end. If the issue persists please{" "}
97
97
-
<a href="mailto:contact@leaflet.pub">send us a note</a>.
98
98
-
</p>
99
99
-
</div>
100
100
-
</div>
101
101
-
</div>
102
102
-
);
103
103
-
let record = document.data as PubLeafletDocument.Record;
104
104
-
let bskyPosts =
105
105
-
record.pages.flatMap((p) => {
106
106
-
let page = p as PubLeafletPagesLinearDocument.Main;
107
107
-
return page.blocks?.filter(
108
108
-
(b) => b.block.$type === ids.PubLeafletBlocksBskyPost,
109
109
-
);
110
110
-
}) || [];
111
56
112
112
-
// Batch bsky posts into groups of 25 and fetch in parallel
113
113
-
let bskyPostBatches = [];
114
114
-
for (let i = 0; i < bskyPosts.length; i += 25) {
115
115
-
bskyPostBatches.push(bskyPosts.slice(i, i + 25));
116
116
-
}
117
117
-
118
118
-
let bskyPostResponses = await Promise.all(
119
119
-
bskyPostBatches.map((batch) =>
120
120
-
agent.getPosts(
121
121
-
{
122
122
-
uris: batch.map((p) => {
123
123
-
let block = p?.block as PubLeafletBlocksBskyPost.Main;
124
124
-
return block.postRef.uri;
125
125
-
}),
126
126
-
},
127
127
-
{ headers: {} },
128
128
-
),
129
129
-
),
130
130
-
);
131
131
-
132
132
-
let bskyPostData =
133
133
-
bskyPostResponses.length > 0
134
134
-
? bskyPostResponses.flatMap((response) => response.data.posts)
135
135
-
: [];
136
136
-
137
137
-
// Extract poll blocks and fetch vote data
138
138
-
let pollBlocks = record.pages.flatMap((p) => {
139
139
-
let page = p as PubLeafletPagesLinearDocument.Main;
140
140
-
return (
141
141
-
page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) ||
142
142
-
[]
143
143
-
);
144
144
-
});
145
145
-
let pollData = await fetchPollData(
146
146
-
pollBlocks.map((b) => (b.block as any).pollRef.uri),
147
147
-
);
148
148
-
149
149
-
let pubRecord = document.documents_in_publications[0]?.publications
150
150
-
.record as PubLeafletPublication.Record;
151
151
-
152
152
-
let firstPage = record.pages[0];
153
153
-
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
154
154
-
if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
155
155
-
blocks = firstPage.blocks || [];
156
156
-
}
157
157
-
158
158
-
let prerenderedCodeBlocks = await extractCodeBlocks(blocks);
159
159
-
160
160
-
return (
161
161
-
<PostPageContextProvider value={document}>
162
162
-
<PublicationThemeProvider
163
163
-
theme={pubRecord?.theme || record.theme || null}
164
164
-
pub_creator={
165
165
-
document.documents_in_publications[0].publications.identity_did
166
166
-
}
167
167
-
>
168
168
-
<PublicationBackgroundProvider
169
169
-
theme={pubRecord?.theme || record.theme || null}
170
170
-
pub_creator={
171
171
-
document.documents_in_publications[0].publications.identity_did
172
172
-
}
173
173
-
>
174
174
-
{/*
175
175
-
TODO: SCROLL PAGE TO FIT DRAWER
176
176
-
If the drawer fits without scrolling, dont scroll
177
177
-
If both drawer and page fit if you scrolled it, scroll it all into the center
178
178
-
If the drawer and pafe doesn't all fit, scroll to drawer
179
179
-
180
180
-
TODO: SROLL BAR
181
181
-
If there is no drawer && there is no page bg, scroll the entire page
182
182
-
If there is either a drawer open OR a page background, scroll just the post content
183
183
-
184
184
-
TODO: HIGHLIGHTING BORKED
185
185
-
on chrome, if you scroll backward, things stop working
186
186
-
seems like if you use an older browser, sel direction is not a thing yet
187
187
-
*/}
188
188
-
<LeafletLayout>
189
189
-
<PostPages
190
190
-
document_uri={document.uri}
191
191
-
preferences={pubRecord.preferences || {}}
192
192
-
pubRecord={pubRecord}
193
193
-
profile={JSON.parse(JSON.stringify(profile.data))}
194
194
-
document={document}
195
195
-
bskyPostData={bskyPostData}
196
196
-
did={did}
197
197
-
blocks={blocks}
198
198
-
prerenderedCodeBlocks={prerenderedCodeBlocks}
199
199
-
pollData={pollData}
200
200
-
/>
201
201
-
</LeafletLayout>
202
202
-
203
203
-
<QuoteHandler />
204
204
-
</PublicationBackgroundProvider>
205
205
-
</PublicationThemeProvider>
206
206
-
</PostPageContextProvider>
207
207
-
);
57
57
+
return <DocumentPageRenderer did={did} rkey={params.rkey} />;
208
58
}
+19
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts
···
1
1
+
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
2
+
import { decodeQuotePosition } from "app/lish/[did]/[publication]/[rkey]/quotePosition";
3
3
+
4
4
+
export const runtime = "edge";
5
5
+
export const revalidate = 60;
6
6
+
7
7
+
export default async function OpenGraphImage(props: {
8
8
+
params: { didOrHandle: string; rkey: string; quote: string };
9
9
+
}) {
10
10
+
let quotePosition = decodeQuotePosition(props.params.quote);
11
11
+
return getMicroLinkOgImage(
12
12
+
`/p/${decodeURIComponent(props.params.didOrHandle)}/${props.params.rkey}/l-quote/${props.params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`,
13
13
+
{
14
14
+
width: 620,
15
15
+
height: 324,
16
16
+
deviceScaleFactor: 2,
17
17
+
},
18
18
+
);
19
19
+
}
+8
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx
···
1
1
+
import PostPage from "app/p/[didOrHandle]/[rkey]/page";
2
2
+
3
3
+
export { generateMetadata } from "app/p/[didOrHandle]/[rkey]/page";
4
4
+
export default async function Post(props: {
5
5
+
params: Promise<{ didOrHandle: string; rkey: string }>;
6
6
+
}) {
7
7
+
return <PostPage {...props} />;
8
8
+
}
+90
app/p/[didOrHandle]/[rkey]/page.tsx
···
1
1
+
import { supabaseServerClient } from "supabase/serverClient";
2
2
+
import { AtUri } from "@atproto/syntax";
3
3
+
import { ids } from "lexicons/api/lexicons";
4
4
+
import { PubLeafletDocument } from "lexicons/api";
5
5
+
import { Metadata } from "next";
6
6
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
7
+
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
8
+
9
9
+
export async function generateMetadata(props: {
10
10
+
params: Promise<{ didOrHandle: string; rkey: string }>;
11
11
+
}): Promise<Metadata> {
12
12
+
let params = await props.params;
13
13
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
14
14
+
15
15
+
// Resolve handle to DID if necessary
16
16
+
let did = didOrHandle;
17
17
+
if (!didOrHandle.startsWith("did:")) {
18
18
+
try {
19
19
+
let resolved = await idResolver.handle.resolve(didOrHandle);
20
20
+
if (resolved) did = resolved;
21
21
+
} catch (e) {
22
22
+
return { title: "404" };
23
23
+
}
24
24
+
}
25
25
+
26
26
+
let { data: document } = await supabaseServerClient
27
27
+
.from("documents")
28
28
+
.select("*, documents_in_publications(publications(*))")
29
29
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey))
30
30
+
.single();
31
31
+
32
32
+
if (!document) return { title: "404" };
33
33
+
34
34
+
let docRecord = document.data as PubLeafletDocument.Record;
35
35
+
36
36
+
// For documents in publications, include publication name
37
37
+
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
38
+
39
39
+
return {
40
40
+
icons: {
41
41
+
other: {
42
42
+
rel: "alternate",
43
43
+
url: document.uri,
44
44
+
},
45
45
+
},
46
46
+
title: publicationName
47
47
+
? `${docRecord.title} - ${publicationName}`
48
48
+
: docRecord.title,
49
49
+
description: docRecord?.description || "",
50
50
+
};
51
51
+
}
52
52
+
53
53
+
export default async function StandaloneDocumentPage(props: {
54
54
+
params: Promise<{ didOrHandle: string; rkey: string }>;
55
55
+
}) {
56
56
+
let params = await props.params;
57
57
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
58
58
+
59
59
+
// Resolve handle to DID if necessary
60
60
+
let did = didOrHandle;
61
61
+
if (!didOrHandle.startsWith("did:")) {
62
62
+
try {
63
63
+
let resolved = await idResolver.handle.resolve(didOrHandle);
64
64
+
if (!resolved) {
65
65
+
return (
66
66
+
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
67
+
<p>Sorry, can't resolve handle.</p>
68
68
+
<p>
69
69
+
This may be a glitch on our end. If the issue persists please{" "}
70
70
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
71
+
</p>
72
72
+
</div>
73
73
+
);
74
74
+
}
75
75
+
did = resolved;
76
76
+
} catch (e) {
77
77
+
return (
78
78
+
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
79
+
<p>Sorry, can't resolve handle.</p>
80
80
+
<p>
81
81
+
This may be a glitch on our end. If the issue persists please{" "}
82
82
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
83
+
</p>
84
84
+
</div>
85
85
+
);
86
86
+
}
87
87
+
}
88
88
+
89
89
+
return <DocumentPageRenderer did={did} rkey={params.rkey} />;
90
90
+
}
+18
-12
components/Pages/PublicationMetadata.tsx
···
25
25
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
26
26
let publishedAt = record?.publishedAt;
27
27
28
28
-
if (!pub || !pub.publications) return null;
28
28
+
if (!pub) return null;
29
29
30
30
if (typeof title !== "string") {
31
31
title = pub?.title || "";
···
36
36
return (
37
37
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
38
38
<div className="flex gap-2">
39
39
-
<Link
40
40
-
href={
41
41
-
identity?.atp_did === pub.publications?.identity_did
42
42
-
? `${getBasePublicationURL(pub.publications)}/dashboard`
43
43
-
: getPublicationURL(pub.publications)
44
44
-
}
45
45
-
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
46
46
-
>
47
47
-
{pub.publications?.name}
48
48
-
</Link>
39
39
+
{pub.publications && (
40
40
+
<Link
41
41
+
href={
42
42
+
identity?.atp_did === pub.publications?.identity_did
43
43
+
? `${getBasePublicationURL(pub.publications)}/dashboard`
44
44
+
: getPublicationURL(pub.publications)
45
45
+
}
46
46
+
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
47
47
+
>
48
48
+
{pub.publications?.name}
49
49
+
</Link>
50
50
+
)}
49
51
<div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md ">
50
52
Editor
51
53
</div>
···
81
83
<Link
82
84
target="_blank"
83
85
className="text-sm"
84
84
-
href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`}
86
86
+
href={
87
87
+
pub.publications
88
88
+
? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
89
89
+
: `/p/${identity?.atp_did}/${new AtUri(pub.doc).rkey}`
90
90
+
}
85
91
>
86
92
View Post
87
93
</Link>