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
support publishing standalone leaflets
awarm.space
3 months ago
8fe7157b
db553602
+206
-74
7 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
(home-pages)
home
HomeLayout.tsx
[leaflet_id]
actions
PublishButton.tsx
publish
PublishPost.tsx
page.tsx
api
rpc
[command]
get_leaflet_data.ts
components
PageSWRDataProvider.tsx
+58
-24
actions/publishToPublication.ts
···
53
53
description,
54
54
}: {
55
55
root_entity: string;
56
56
-
publication_uri: string;
56
56
+
publication_uri?: string;
57
57
leaflet_id: string;
58
58
title?: string;
59
59
description?: string;
···
66
66
let agent = new AtpBaseClient(
67
67
credentialSession.fetchHandler.bind(credentialSession),
68
68
);
69
69
-
let { data: draft } = await supabaseServerClient
70
70
-
.from("leaflets_in_publications")
71
71
-
.select("*, publications(*), documents(*)")
72
72
-
.eq("publication", publication_uri)
73
73
-
.eq("leaflet", leaflet_id)
74
74
-
.single();
75
75
-
if (!draft || identity.atp_did !== draft?.publications?.identity_did)
76
76
-
throw new Error("No draft or not publisher");
69
69
+
70
70
+
// Check if we're publishing to a publication or standalone
71
71
+
let draft: any = null;
72
72
+
let existingDocUri: string | null = null;
73
73
+
74
74
+
if (publication_uri) {
75
75
+
// Publishing to a publication - use leaflets_in_publications
76
76
+
let { data } = await supabaseServerClient
77
77
+
.from("leaflets_in_publications")
78
78
+
.select("*, publications(*), documents(*)")
79
79
+
.eq("publication", publication_uri)
80
80
+
.eq("leaflet", leaflet_id)
81
81
+
.single();
82
82
+
if (!data || identity.atp_did !== data?.publications?.identity_did)
83
83
+
throw new Error("No draft or not publisher");
84
84
+
draft = data;
85
85
+
existingDocUri = draft?.doc;
86
86
+
} else {
87
87
+
// Publishing standalone - use leaflets_to_documents
88
88
+
let { data } = await supabaseServerClient
89
89
+
.from("leaflets_to_documents")
90
90
+
.select("*, documents(*)")
91
91
+
.eq("leaflet", leaflet_id)
92
92
+
.single();
93
93
+
draft = data;
94
94
+
existingDocUri = draft?.document;
95
95
+
}
96
96
+
77
97
let { data } = await supabaseServerClient.rpc("get_facts", {
78
98
root: root_entity,
79
99
});
···
91
111
let record: PubLeafletDocument.Record = {
92
112
$type: "pub.leaflet.document",
93
113
author: credentialSession.did!,
94
94
-
publication: publication_uri,
114
114
+
...(publication_uri && { publication: publication_uri }),
95
115
publishedAt: new Date().toISOString(),
96
116
...existingRecord,
97
117
title: title || "Untitled",
···
118
138
}),
119
139
],
120
140
};
121
121
-
let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr();
141
141
+
142
142
+
// Keep the same rkey if updating an existing document
143
143
+
let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
122
144
let { data: result } = await agent.com.atproto.repo.putRecord({
123
145
rkey,
124
146
repo: credentialSession.did!,
···
127
149
validate: false, //TODO publish the lexicon so we can validate!
128
150
});
129
151
152
152
+
// Optimistically create database entries
130
153
await supabaseServerClient.from("documents").upsert({
131
154
uri: result.uri,
132
155
data: record as Json,
133
156
});
134
134
-
await Promise.all([
135
135
-
//Optimistically put these in!
136
136
-
supabaseServerClient.from("documents_in_publications").upsert({
137
137
-
publication: record.publication,
157
157
+
158
158
+
if (publication_uri) {
159
159
+
// Publishing to a publication - update both tables
160
160
+
await Promise.all([
161
161
+
supabaseServerClient.from("documents_in_publications").upsert({
162
162
+
publication: publication_uri,
163
163
+
document: result.uri,
164
164
+
}),
165
165
+
supabaseServerClient
166
166
+
.from("leaflets_in_publications")
167
167
+
.update({
168
168
+
doc: result.uri,
169
169
+
})
170
170
+
.eq("leaflet", leaflet_id)
171
171
+
.eq("publication", publication_uri),
172
172
+
]);
173
173
+
} else {
174
174
+
// Publishing standalone - update leaflets_to_documents
175
175
+
await supabaseServerClient.from("leaflets_to_documents").upsert({
176
176
+
leaflet: leaflet_id,
138
177
document: result.uri,
139
139
-
}),
140
140
-
supabaseServerClient
141
141
-
.from("leaflets_in_publications")
142
142
-
.update({
143
143
-
doc: result.uri,
144
144
-
})
145
145
-
.eq("leaflet", leaflet_id)
146
146
-
.eq("publication", publication_uri),
147
147
-
]);
178
178
+
title: title || "Untitled",
179
179
+
description: description || "",
180
180
+
});
181
181
+
}
148
182
149
183
return { rkey, record: JSON.parse(JSON.stringify(record)) };
150
184
}
+5
app/(home-pages)/home/HomeLayout.tsx
···
38
38
GetLeafletDataReturnType["result"]["data"],
39
39
null
40
40
>["leaflets_in_publications"];
41
41
+
leaflets_to_documents?: Exclude<
42
42
+
GetLeafletDataReturnType["result"]["data"],
43
43
+
null
44
44
+
>["leaflets_to_documents"];
41
45
};
42
46
};
43
47
···
218
222
value={{
219
223
...leaflet,
220
224
leaflets_in_publications: leaflet.leaflets_in_publications || [],
225
225
+
leaflets_to_documents: leaflet.leaflets_to_documents || [],
221
226
blocked_by_admin: null,
222
227
custom_domain_routes: [],
223
228
}}
+30
-16
app/[leaflet_id]/actions/PublishButton.tsx
···
60
60
let [isLoading, setIsLoading] = useState(false);
61
61
let { data: pub, mutate } = useLeafletPublicationData();
62
62
let { permission_token, rootEntity } = useReplicache();
63
63
+
let { identity } = useIdentityData();
63
64
let toaster = useToaster();
64
65
65
66
return (
···
68
69
icon={<PublishSmall className="shrink-0" />}
69
70
label={isLoading ? <DotLoader /> : "Update!"}
70
71
onClick={async () => {
71
71
-
if (!pub || !pub.publications) return;
72
72
+
if (!pub) return;
72
73
setIsLoading(true);
73
74
let doc = await publishToPublication({
74
75
root_entity: rootEntity,
75
75
-
publication_uri: pub.publications.uri,
76
76
+
publication_uri: pub.publications?.uri,
76
77
leaflet_id: permission_token.id,
77
78
title: pub.title,
78
79
description: pub.description,
79
80
});
80
81
setIsLoading(false);
81
82
mutate();
83
83
+
84
84
+
// Generate URL based on whether it's in a publication or standalone
85
85
+
let docUrl = pub.publications
86
86
+
? `${getPublicationURL(pub.publications)}/${doc?.rkey}`
87
87
+
: `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`;
88
88
+
82
89
toaster({
83
90
content: (
84
91
<div>
85
92
{pub.doc ? "Updated! " : "Published! "}
86
86
-
<SpeedyLink
87
87
-
href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`}
88
88
-
>
89
89
-
link
90
90
-
</SpeedyLink>
93
93
+
<SpeedyLink href={docUrl}>link</SpeedyLink>
91
94
</div>
92
95
),
93
96
type: "success",
···
169
172
<hr className="border-border-light mt-3 mb-2" />
170
173
171
174
<div className="flex gap-2 items-center place-self-end">
172
172
-
<SaveAsDraftButton
173
173
-
selectedPub={selectedPub}
174
174
-
leafletId={permission_token.id}
175
175
-
metadata={{ title: title, description }}
176
176
-
entitiesToDelete={entitiesToDelete}
177
177
-
/>
175
175
+
{selectedPub !== "looseleaf" && (
176
176
+
<SaveAsDraftButton
177
177
+
selectedPub={selectedPub}
178
178
+
leafletId={permission_token.id}
179
179
+
metadata={{ title: title, description }}
180
180
+
entitiesToDelete={entitiesToDelete}
181
181
+
/>
182
182
+
)}
178
183
<ButtonPrimary
179
184
disabled={selectedPub === undefined}
180
185
onClick={async (e) => {
181
186
if (!selectedPub) return;
182
187
e.preventDefault();
183
188
if (selectedPub === "create") return;
184
184
-
router.push(
185
185
-
`${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`,
186
186
-
);
189
189
+
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)}`,
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)}`,
198
198
+
);
199
199
+
}
187
200
}}
188
201
>
189
202
Next{selectedPub === "create" && ": Create Pub!"}
···
305
318
let pubRecord = p.record as PubLeafletPublication.Record;
306
319
return (
307
320
<PubOption
321
321
+
key={p.uri}
308
322
selected={props.selectedPub === p.uri}
309
323
onSelect={() => props.setSelectedPub(p.uri)}
310
324
>
+57
-10
app/[leaflet_id]/publish/PublishPost.tsx
···
18
18
editorStateToFacetedText,
19
19
} from "./BskyPostEditorProsemirror";
20
20
import { EditorState } from "prosemirror-state";
21
21
+
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
22
22
+
import { PubIcon } from "components/ActionBar/Publications";
21
23
22
24
type Props = {
23
25
title: string;
···
25
27
root_entity: string;
26
28
profile: ProfileViewDetailed;
27
29
description: string;
28
28
-
publication_uri: string;
30
30
+
publication_uri?: string;
29
31
record?: PubLeafletPublication.Record;
30
32
posts_in_pub?: number;
31
33
};
···
75
77
});
76
78
if (!doc) return;
77
79
78
78
-
let post_url = `https://${props.record?.base_path}/${doc.rkey}`;
80
80
+
// Generate post URL based on whether it's in a publication or standalone
81
81
+
let post_url = props.record?.base_path
82
82
+
? `https://${props.record.base_path}/${doc.rkey}`
83
83
+
: `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`;
84
84
+
79
85
let [text, facets] = editorStateRef.current
80
86
? editorStateToFacetedText(editorStateRef.current)
81
87
: [];
···
103
109
}}
104
110
>
105
111
<div className="container flex flex-col gap-2 sm:p-3 p-4">
112
112
+
<PublishingTo
113
113
+
publication_uri={props.publication_uri}
114
114
+
record={props.record}
115
115
+
/>
116
116
+
<hr className="border-border-light my-1" />
106
117
<Radio
107
118
checked={shareOption === "quiet"}
108
119
onChange={(e) => {
···
199
210
);
200
211
};
201
212
213
213
+
const PublishingTo = (props: {
214
214
+
publication_uri?: string;
215
215
+
record?: PubLeafletPublication.Record;
216
216
+
}) => {
217
217
+
if (props.publication_uri && props.record) {
218
218
+
return (
219
219
+
<div className="flex flex-col gap-1">
220
220
+
<div className="text-sm text-tertiary">Publishing to…</div>
221
221
+
<div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]">
222
222
+
<PubIcon record={props.record} uri={props.publication_uri} />
223
223
+
<div className="font-bold text-secondary">{props.record.name}</div>
224
224
+
</div>
225
225
+
</div>
226
226
+
);
227
227
+
}
228
228
+
229
229
+
return (
230
230
+
<div className="flex flex-col gap-1">
231
231
+
<div className="text-sm text-tertiary">Publishing as…</div>
232
232
+
<div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]">
233
233
+
<LooseLeafSmall className="shrink-0" />
234
234
+
<div className="font-bold text-secondary">Looseleaf</div>
235
235
+
</div>
236
236
+
</div>
237
237
+
);
238
238
+
};
239
239
+
202
240
const PublishPostSuccess = (props: {
203
241
post_url: string;
204
204
-
publication_uri: string;
242
242
+
publication_uri?: string;
205
243
record: Props["record"];
206
244
posts_in_pub: number;
207
245
}) => {
208
208
-
let uri = new AtUri(props.publication_uri);
246
246
+
let uri = props.publication_uri ? new AtUri(props.publication_uri) : null;
209
247
return (
210
248
<div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto">
211
249
<PublishIllustration posts_in_pub={props.posts_in_pub} />
212
250
<h2 className="pt-2">Published!</h2>
213
213
-
<Link
214
214
-
className="hover:no-underline! font-bold place-self-center pt-2"
215
215
-
href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`}
216
216
-
>
217
217
-
<ButtonPrimary>Back to Dashboard</ButtonPrimary>
218
218
-
</Link>
251
251
+
{uri && props.record ? (
252
252
+
<Link
253
253
+
className="hover:no-underline! font-bold place-self-center pt-2"
254
254
+
href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`}
255
255
+
>
256
256
+
<ButtonPrimary>Back to Dashboard</ButtonPrimary>
257
257
+
</Link>
258
258
+
) : (
259
259
+
<Link
260
260
+
className="hover:no-underline! font-bold place-self-center pt-2"
261
261
+
href="/"
262
262
+
>
263
263
+
<ButtonPrimary>Back to Home</ButtonPrimary>
264
264
+
</Link>
265
265
+
)}
219
266
<a href={props.post_url}>See published post</a>
220
267
</div>
221
268
);
+33
-17
app/[leaflet_id]/publish/page.tsx
···
32
32
*,
33
33
documents_in_publications(count)
34
34
),
35
35
-
documents(*))`,
35
35
+
documents(*)),
36
36
+
leaflets_to_documents(
37
37
+
*,
38
38
+
documents(*)
39
39
+
)`,
36
40
)
37
41
.eq("id", leaflet_id)
38
42
.single();
39
43
let rootEntity = data?.root_entity;
44
44
+
45
45
+
// Try to find publication from leaflets_in_publications first
40
46
let publication = data?.leaflets_in_publications[0]?.publications;
47
47
+
48
48
+
// If not found, check if publication_uri is in searchParams
41
49
if (!publication) {
42
50
let pub_uri = (await props.searchParams).publication_uri;
43
43
-
if (!pub_uri) return;
44
44
-
console.log(decodeURIComponent(pub_uri));
45
45
-
let { data, error } = await supabaseServerClient
46
46
-
.from("publications")
47
47
-
.select("*, documents_in_publications(count)")
48
48
-
.eq("uri", decodeURIComponent(pub_uri))
49
49
-
.single();
50
50
-
console.log(error);
51
51
-
publication = data;
51
51
+
if (pub_uri) {
52
52
+
console.log(decodeURIComponent(pub_uri));
53
53
+
let { data: pubData, error } = await supabaseServerClient
54
54
+
.from("publications")
55
55
+
.select("*, documents_in_publications(count)")
56
56
+
.eq("uri", decodeURIComponent(pub_uri))
57
57
+
.single();
58
58
+
console.log(error);
59
59
+
publication = pubData;
60
60
+
}
52
61
}
53
53
-
if (!data || !rootEntity || !publication)
62
62
+
63
63
+
// Check basic data requirements
64
64
+
if (!data || !rootEntity)
54
65
return (
55
66
<div>
56
67
missin something
···
60
71
61
72
let identity = await getIdentityData();
62
73
if (!identity || !identity.atp_did) return null;
74
74
+
75
75
+
// Get title and description from either source
63
76
let title =
64
77
data.leaflets_in_publications[0]?.title ||
65
65
-
decodeURIComponent((await props.searchParams).title);
78
78
+
data.leaflets_to_documents[0]?.title ||
79
79
+
decodeURIComponent((await props.searchParams).title || "");
66
80
let description =
67
81
data.leaflets_in_publications[0]?.description ||
68
68
-
decodeURIComponent((await props.searchParams).description);
69
69
-
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
82
82
+
data.leaflets_to_documents[0]?.description ||
83
83
+
decodeURIComponent((await props.searchParams).description || "");
70
84
85
85
+
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
71
86
let profile = await agent.getProfile({ actor: identity.atp_did });
87
87
+
72
88
return (
73
89
<ReplicacheProvider
74
90
rootEntity={rootEntity}
···
82
98
profile={profile.data}
83
99
title={title}
84
100
description={description}
85
85
-
publication_uri={publication.uri}
86
86
-
record={publication.record as PubLeafletPublication.Record}
87
87
-
posts_in_pub={publication.documents_in_publications[0].count}
101
101
+
publication_uri={publication?.uri}
102
102
+
record={publication?.record as PubLeafletPublication.Record | undefined}
103
103
+
posts_in_pub={publication?.documents_in_publications[0]?.count}
88
104
/>
89
105
</ReplicacheProvider>
90
106
);
+3
-1
app/api/rpc/[command]/get_leaflet_data.ts
···
7
7
>;
8
8
9
9
const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`;
10
10
+
const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`;
10
11
export const get_leaflet_data = makeRoute({
11
12
route: "get_leaflet_data",
12
13
input: z.object({
···
20
21
`*,
21
22
permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))),
22
23
custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*),
23
23
-
${leaflets_in_publications_query}`,
24
24
+
${leaflets_in_publications_query},
25
25
+
${leaflets_to_documents_query}`,
24
26
)
25
27
.eq("id", token_id)
26
28
.single();
+20
-6
components/PageSWRDataProvider.tsx
···
66
66
};
67
67
export function useLeafletPublicationData() {
68
68
let { data, mutate } = useLeafletData();
69
69
+
70
70
+
// First check for leaflets in publications
71
71
+
let pubData =
72
72
+
data?.leaflets_in_publications?.[0] ||
73
73
+
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
74
74
+
(p) => p.leaflets_in_publications.length,
75
75
+
)?.leaflets_in_publications?.[0];
76
76
+
77
77
+
// If not found, check for standalone documents
78
78
+
if (!pubData && data?.leaflets_to_documents?.[0]) {
79
79
+
// Transform standalone document data to match the expected format
80
80
+
let standaloneDoc = data.leaflets_to_documents[0];
81
81
+
pubData = {
82
82
+
...standaloneDoc,
83
83
+
publications: null, // No publication for standalone docs
84
84
+
doc: standaloneDoc.document,
85
85
+
} as any;
86
86
+
}
87
87
+
69
88
return {
70
70
-
data:
71
71
-
data?.leaflets_in_publications?.[0] ||
72
72
-
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
73
73
-
(p) => p.leaflets_in_publications.length,
74
74
-
)?.leaflets_in_publications?.[0] ||
75
75
-
null,
89
89
+
data: pubData || null,
76
90
mutate,
77
91
};
78
92
}