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
make save as draft and publish work to existing pub
awarm.space
4 months ago
a9757f65
6da45f9b
+206
-59
6 changed files
expand all
collapse all
unified
split
actions
createPublicationDraft.ts
publications
moveLeafletToPublication.ts
updateLeafletDraftMetadata.ts
app
[leaflet_id]
Footer.tsx
actions
PublishButton.tsx
publish
page.tsx
+6
actions/createPublicationDraft.ts
···
11
11
redirectUser: false,
12
12
firstBlockType: "text",
13
13
});
14
14
+
let { data: publication } = await supabaseServerClient
15
15
+
.from("publications")
16
16
+
.select("*")
17
17
+
.eq("uri", publication_uri)
18
18
+
.single();
19
19
+
if (publication?.identity_did !== identity.atp_did) return;
14
20
15
21
await supabaseServerClient
16
22
.from("leaflets_in_publications")
+33
actions/publications/moveLeafletToPublication.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { getIdentityData } from "actions/getIdentityData";
4
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
5
+
6
6
+
export async function moveLeafletToPublication(
7
7
+
leaflet_id: string,
8
8
+
publication_uri: string,
9
9
+
metadata: { title: string; description: string },
10
10
+
entitiesToDelete: string[],
11
11
+
) {
12
12
+
let identity = await getIdentityData();
13
13
+
if (!identity || !identity.atp_did) return null;
14
14
+
let { data: publication } = await supabaseServerClient
15
15
+
.from("publications")
16
16
+
.select("*")
17
17
+
.eq("uri", publication_uri)
18
18
+
.single();
19
19
+
if (publication?.identity_did !== identity.atp_did) return;
20
20
+
21
21
+
await supabaseServerClient.from("leaflets_in_publications").insert({
22
22
+
publication: publication_uri,
23
23
+
leaflet: leaflet_id,
24
24
+
doc: null,
25
25
+
title: metadata.title,
26
26
+
description: metadata.description,
27
27
+
});
28
28
+
29
29
+
await supabaseServerClient
30
30
+
.from("entities")
31
31
+
.delete()
32
32
+
.in("id", entitiesToDelete);
33
33
+
}
-26
actions/publications/updateLeafletDraftMetadata.ts
···
1
1
-
"use server";
2
2
-
3
3
-
import { getIdentityData } from "actions/getIdentityData";
4
4
-
import { supabaseServerClient } from "supabase/serverClient";
5
5
-
6
6
-
export async function updateLeafletDraftMetadata(
7
7
-
leafletID: string,
8
8
-
publication_uri: string,
9
9
-
title: string,
10
10
-
description: string,
11
11
-
) {
12
12
-
let identity = await getIdentityData();
13
13
-
if (!identity?.atp_did) return null;
14
14
-
let { data: publication } = await supabaseServerClient
15
15
-
.from("publications")
16
16
-
.select()
17
17
-
.eq("uri", publication_uri)
18
18
-
.single();
19
19
-
if (!publication || publication.identity_did !== identity.atp_did)
20
20
-
return null;
21
21
-
await supabaseServerClient
22
22
-
.from("leaflets_in_publications")
23
23
-
.update({ title, description })
24
24
-
.eq("leaflet", leafletID)
25
25
-
.eq("publication", publication_uri);
26
26
-
}
+1
-1
app/[leaflet_id]/Footer.tsx
···
46
46
<HomeButton />
47
47
)}
48
48
49
49
-
<PublishButton />
49
49
+
<PublishButton entityID={props.entityID} />
50
50
<ShareOptions />
51
51
<ThemePopover entityID={props.entityID} />
52
52
</ActionFooter>
+136
-25
app/[leaflet_id]/actions/PublishButton.tsx
···
1
1
+
"use client";
1
2
import { publishToPublication } from "actions/publishToPublication";
2
3
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
3
4
import { ActionButton } from "components/ActionBar/ActionButton";
4
5
import {
5
6
PubIcon,
6
7
PubListEmptyContent,
8
8
+
PubListEmptyIllo,
7
9
} from "components/ActionBar/Publications";
8
10
import { ButtonPrimary, ButtonTertiary } from "components/Buttons";
9
11
import { AddSmall } from "components/Icons/AddSmall";
···
12
14
import { useIdentityData } from "components/IdentityProvider";
13
15
import { InputWithLabel } from "components/Input";
14
16
import { Menu, MenuItem } from "components/Layout";
15
15
-
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
17
17
+
import {
18
18
+
useLeafletDomains,
19
19
+
useLeafletPublicationData,
20
20
+
} from "components/PageSWRDataProvider";
16
21
import { Popover } from "components/Popover";
17
22
import { SpeedyLink } from "components/SpeedyLink";
18
23
import { useToaster } from "components/Toast";
19
24
import { DotLoader } from "components/utils/DotLoader";
20
25
import { PubLeafletPublication } from "lexicons/api";
21
21
-
import { useParams, useRouter } from "next/navigation";
26
26
+
import { useParams, useRouter, useSearchParams } from "next/navigation";
22
27
import { useState, useMemo } from "react";
23
28
import { useIsMobile } from "src/hooks/isMobile";
24
29
import { useReplicache, useEntity } from "src/replicache";
···
27
32
import * as Y from "yjs";
28
33
import * as base64 from "base64-js";
29
34
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
35
35
+
import { BlueskyLogin } from "app/login/LoginForm";
36
36
+
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
30
37
31
38
export const PublishButton = (props: { entityID: string }) => {
32
39
let { data: pub } = useLeafletPublicationData();
···
92
99
93
100
const PublishToPublicationButton = (props: { entityID: string }) => {
94
101
let { identity } = useIdentityData();
102
102
+
let { permission_token } = useReplicache();
103
103
+
let query = useSearchParams();
104
104
+
console.log(query.get("publish"));
105
105
+
let [open, setOpen] = useState(query.get("publish") !== null);
95
106
96
107
let isMobile = useIsMobile();
97
108
identity && identity.atp_did && identity.publications.length > 0;
98
109
let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined);
110
110
+
let router = useRouter();
111
111
+
let { title, entitiesToDelete } = useTitle(props.entityID);
112
112
+
let [description, setDescription] = useState("");
99
113
100
114
return (
101
115
<Popover
102
116
asChild
117
117
+
open={open}
118
118
+
onOpenChange={(o) => setOpen(o)}
103
119
side={isMobile ? "top" : "right"}
104
120
align={isMobile ? "center" : "start"}
105
121
className="sm:max-w-sm w-[1000px]"
···
112
128
}
113
129
>
114
130
{!identity || !identity.atp_did ? (
115
115
-
// this component is also used on Home to populate the sidebar when PubList is empty
116
116
-
// when user doesn't have an AT Proto account, and redirects back to the doc (hopefully with publish open?
117
131
<div className="-mx-2 -my-1">
118
118
-
<PubListEmptyContent compact />
132
132
+
<div
133
133
+
className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`}
134
134
+
>
135
135
+
<div className="mx-auto pt-2 scale-90">
136
136
+
<PubListEmptyIllo />
137
137
+
</div>
138
138
+
<div className="pt-1 font-bold">Publish on AT Proto</div>
139
139
+
{
140
140
+
<>
141
141
+
<div className="pb-2 text-secondary text-xs">
142
142
+
Link a Bluesky account to start <br /> a publishing on AT
143
143
+
Proto
144
144
+
</div>
145
145
+
146
146
+
<BlueskyLogin
147
147
+
compact
148
148
+
redirectRoute={`/${permission_token.id}?publish`}
149
149
+
/>
150
150
+
</>
151
151
+
}
152
152
+
</div>
119
153
</div>
120
154
) : (
121
155
<div className="flex flex-col">
122
122
-
<PostDetailsForm entityID={props.entityID} />
156
156
+
<PostDetailsForm
157
157
+
title={title}
158
158
+
description={description}
159
159
+
setDescription={setDescription}
160
160
+
/>
123
161
<hr className="border-border-light my-3" />
124
162
<div>
125
163
<PubSelector
···
131
169
<hr className="border-border-light mt-3 mb-2" />
132
170
133
171
<div className="flex gap-2 items-center place-self-end">
134
134
-
<ButtonTertiary>Save as Draft</ButtonTertiary>
135
135
-
<ButtonPrimary disabled={selectedPub === undefined}>
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
+
/>
178
178
+
<ButtonPrimary
179
179
+
disabled={selectedPub === undefined}
180
180
+
onClick={async (e) => {
181
181
+
if (!selectedPub) return;
182
182
+
e.preventDefault();
183
183
+
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
+
);
187
187
+
}}
188
188
+
>
136
189
Next{selectedPub === "create" && ": Create Pub!"}
137
190
</ButtonPrimary>
138
191
</div>
···
142
195
);
143
196
};
144
197
145
145
-
const PostDetailsForm = (props: { entityID: string }) => {
146
146
-
let [description, setDescription] = useState("");
198
198
+
const SaveAsDraftButton = (props: {
199
199
+
selectedPub: string | undefined;
200
200
+
leafletId: string;
201
201
+
metadata: { title: string; description: string };
202
202
+
entitiesToDelete: string[];
203
203
+
}) => {
204
204
+
let { mutate } = useLeafletPublicationData();
205
205
+
let { rep } = useReplicache();
206
206
+
let [isLoading, setIsLoading] = useState(false);
147
207
148
148
-
let rootPage = useEntity(props.entityID, "root/page")[0].data.value;
149
149
-
let firstBlock = useBlocks(rootPage)[0];
150
150
-
let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value;
208
208
+
return (
209
209
+
<ButtonTertiary
210
210
+
onClick={async (e) => {
211
211
+
if (!props.selectedPub) return;
212
212
+
if (props.selectedPub === "create") return;
213
213
+
e.preventDefault();
214
214
+
setIsLoading(true);
215
215
+
await moveLeafletToPublication(
216
216
+
props.leafletId,
217
217
+
props.selectedPub,
218
218
+
props.metadata,
219
219
+
props.entitiesToDelete,
220
220
+
);
221
221
+
await Promise.all([rep?.pull(), mutate()]);
222
222
+
setIsLoading(false);
223
223
+
}}
224
224
+
>
225
225
+
{isLoading ? <DotLoader /> : "Save as Draft"}
226
226
+
</ButtonTertiary>
227
227
+
);
228
228
+
};
151
229
152
152
-
const leafletTitle = useMemo(() => {
153
153
-
if (!firstBlockText) return "Untitled";
154
154
-
let doc = new Y.Doc();
155
155
-
const update = base64.toByteArray(firstBlockText);
156
156
-
Y.applyUpdate(doc, update);
157
157
-
let nodes = doc.getXmlElement("prosemirror").toArray();
158
158
-
return YJSFragmentToString(nodes[0]) || "Untitled";
159
159
-
}, [firstBlockText]);
160
160
-
230
230
+
const PostDetailsForm = (props: {
231
231
+
title: string;
232
232
+
description: string;
233
233
+
setDescription: (d: string) => void;
234
234
+
}) => {
161
235
return (
162
236
<div className=" flex flex-col gap-1">
163
237
<div className="text-sm text-tertiary">Post Details</div>
164
238
<div className="flex flex-col gap-2">
165
165
-
<InputWithLabel label="Title" value={leafletTitle} disabled />
239
239
+
<InputWithLabel label="Title" value={props.title} disabled />
166
240
<InputWithLabel
167
241
label="Description (optional)"
168
242
textarea
169
169
-
value={description}
243
243
+
value={props.description}
170
244
className="h-[4lh]"
171
171
-
onChange={(e) => setDescription(e.currentTarget.value)}
245
245
+
onChange={(e) => props.setDescription(e.currentTarget.value)}
172
246
/>
173
247
</div>
174
248
</div>
···
271
345
</button>
272
346
);
273
347
};
348
348
+
349
349
+
let useTitle = (entityID: string) => {
350
350
+
let rootPage = useEntity(entityID, "root/page")[0].data.value;
351
351
+
let firstBlock = useBlocks(rootPage)[0]?.value;
352
352
+
353
353
+
let firstBlockText = useEntity(firstBlock, "block/text")?.data.value;
354
354
+
355
355
+
const leafletTitle = useMemo(() => {
356
356
+
if (!firstBlockText) return "Untitled";
357
357
+
let doc = new Y.Doc();
358
358
+
const update = base64.toByteArray(firstBlockText);
359
359
+
Y.applyUpdate(doc, update);
360
360
+
let nodes = doc.getXmlElement("prosemirror").toArray();
361
361
+
return YJSFragmentToString(nodes[0]) || "Untitled";
362
362
+
}, [firstBlockText]);
363
363
+
364
364
+
let secondBlock = useBlocks(rootPage)[1];
365
365
+
let secondBlockTextValue = useEntity(secondBlock.value, "block/text")?.data
366
366
+
.value;
367
367
+
const secondBlockText = useMemo(() => {
368
368
+
if (!secondBlockTextValue) return "";
369
369
+
let doc = new Y.Doc();
370
370
+
const update = base64.toByteArray(secondBlockTextValue);
371
371
+
Y.applyUpdate(doc, update);
372
372
+
let nodes = doc.getXmlElement("prosemirror").toArray();
373
373
+
return YJSFragmentToString(nodes[0]) || "";
374
374
+
}, [firstBlockText]);
375
375
+
376
376
+
let entitiesToDelete = useMemo(() => {
377
377
+
let etod = [firstBlock];
378
378
+
if (secondBlockText.trim() === "" && secondBlock.type === "text")
379
379
+
etod.push(secondBlock.value);
380
380
+
return etod;
381
381
+
}, [firstBlock, secondBlockText, secondBlock]);
382
382
+
383
383
+
return { title: leafletTitle, entitiesToDelete };
384
384
+
};
+30
-7
app/[leaflet_id]/publish/page.tsx
···
13
13
type Props = {
14
14
// this is now a token id not leaflet! Should probs rename
15
15
params: Promise<{ leaflet_id: string }>;
16
16
+
searchParams: Promise<{
17
17
+
publication_uri: string;
18
18
+
title: string;
19
19
+
description: string;
20
20
+
}>;
16
21
};
17
22
export default async function PublishLeafletPage(props: Props) {
18
23
let leaflet_id = (await props.params).leaflet_id;
···
32
37
.eq("id", leaflet_id)
33
38
.single();
34
39
let rootEntity = data?.root_entity;
35
35
-
if (!data || !rootEntity || !data.leaflets_in_publications[0])
40
40
+
let publication = data?.leaflets_in_publications[0]?.publications;
41
41
+
if (!publication) {
42
42
+
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;
52
52
+
}
53
53
+
if (!data || !rootEntity || !publication)
36
54
return (
37
55
<div>
38
56
missin something
···
42
60
43
61
let identity = await getIdentityData();
44
62
if (!identity || !identity.atp_did) return null;
45
45
-
let pub = data.leaflets_in_publications[0];
63
63
+
let title =
64
64
+
data.leaflets_in_publications[0]?.title ||
65
65
+
decodeURIComponent((await props.searchParams).title);
66
66
+
let description =
67
67
+
data.leaflets_in_publications[0]?.description ||
68
68
+
decodeURIComponent((await props.searchParams).description);
46
69
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
47
70
48
71
let profile = await agent.getProfile({ actor: identity.atp_did });
···
57
80
leaflet_id={leaflet_id}
58
81
root_entity={rootEntity}
59
82
profile={profile.data}
60
60
-
title={pub.title}
61
61
-
publication_uri={pub.publication}
62
62
-
description={pub.description}
63
63
-
record={pub.publications?.record as PubLeafletPublication.Record}
64
64
-
posts_in_pub={pub.publications?.documents_in_publications[0].count}
83
83
+
title={title}
84
84
+
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}
65
88
/>
66
89
</ReplicacheProvider>
67
90
);