+6
actions/createPublicationDraft.ts
+6
actions/createPublicationDraft.ts
···
11
11
redirectUser: false,
12
12
firstBlockType: "text",
13
13
});
14
+
let { data: publication } = await supabaseServerClient
15
+
.from("publications")
16
+
.select("*")
17
+
.eq("uri", publication_uri)
18
+
.single();
19
+
if (publication?.identity_did !== identity.atp_did) return;
14
20
15
21
await supabaseServerClient
16
22
.from("leaflets_in_publications")
+127
actions/deleteLeaflet.ts
+127
actions/deleteLeaflet.ts
···
1
1
"use server";
2
+
import { refresh } from "next/cache";
2
3
3
4
import { drizzle } from "drizzle-orm/node-postgres";
4
5
import {
···
9
10
import { eq } from "drizzle-orm";
10
11
import { PermissionToken } from "src/replicache";
11
12
import { pool } from "supabase/pool";
13
+
import { getIdentityData } from "./getIdentityData";
14
+
import { supabaseServerClient } from "supabase/serverClient";
12
15
13
16
export async function deleteLeaflet(permission_token: PermissionToken) {
14
17
const client = await pool.connect();
15
18
const db = drizzle(client);
19
+
20
+
// Get the current user's identity
21
+
let identity = await getIdentityData();
22
+
23
+
// Check publication and document ownership in one query
24
+
let { data: tokenData } = await supabaseServerClient
25
+
.from("permission_tokens")
26
+
.select(
27
+
`
28
+
id,
29
+
leaflets_in_publications(publication, publications!inner(identity_did)),
30
+
leaflets_to_documents(document, documents!inner(uri))
31
+
`,
32
+
)
33
+
.eq("id", permission_token.id)
34
+
.single();
35
+
36
+
if (tokenData) {
37
+
// Check if leaflet is in a publication
38
+
const leafletInPubs = tokenData.leaflets_in_publications || [];
39
+
if (leafletInPubs.length > 0) {
40
+
if (!identity) {
41
+
throw new Error(
42
+
"Unauthorized: You must be logged in to delete a leaflet in a publication",
43
+
);
44
+
}
45
+
const isOwner = leafletInPubs.some(
46
+
(pub: any) => pub.publications.identity_did === identity.atp_did,
47
+
);
48
+
if (!isOwner) {
49
+
throw new Error(
50
+
"Unauthorized: You must own the publication to delete this leaflet",
51
+
);
52
+
}
53
+
}
54
+
55
+
// Check if there's a standalone published document
56
+
const leafletDocs = tokenData.leaflets_to_documents || [];
57
+
if (leafletDocs.length > 0) {
58
+
if (!identity) {
59
+
throw new Error(
60
+
"Unauthorized: You must be logged in to delete a published leaflet",
61
+
);
62
+
}
63
+
for (let leafletDoc of leafletDocs) {
64
+
const docUri = leafletDoc.documents?.uri;
65
+
// Extract the DID from the document URI (format: at://did:plc:xxx/...)
66
+
if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) {
67
+
throw new Error(
68
+
"Unauthorized: You must own the published document to delete this leaflet",
69
+
);
70
+
}
71
+
}
72
+
}
73
+
}
74
+
16
75
await db.transaction(async (tx) => {
17
76
let [token] = await tx
18
77
.select()
···
32
91
.where(eq(permission_tokens.id, permission_token.id));
33
92
});
34
93
client.release();
94
+
95
+
refresh();
96
+
return;
97
+
}
98
+
99
+
export async function archivePost(token: string) {
100
+
let identity = await getIdentityData();
101
+
if (!identity) throw new Error("No Identity");
102
+
103
+
// Archive on homepage
104
+
await supabaseServerClient
105
+
.from("permission_token_on_homepage")
106
+
.update({ archived: true })
107
+
.eq("token", token)
108
+
.eq("identity", identity.id);
109
+
110
+
// Check if leaflet is in any publications where user is the creator
111
+
let { data: leafletInPubs } = await supabaseServerClient
112
+
.from("leaflets_in_publications")
113
+
.select("publication, publications!inner(identity_did)")
114
+
.eq("leaflet", token);
115
+
116
+
if (leafletInPubs) {
117
+
for (let pub of leafletInPubs) {
118
+
if (pub.publications.identity_did === identity.atp_did) {
119
+
await supabaseServerClient
120
+
.from("leaflets_in_publications")
121
+
.update({ archived: true })
122
+
.eq("leaflet", token)
123
+
.eq("publication", pub.publication);
124
+
}
125
+
}
126
+
}
127
+
128
+
refresh();
129
+
return;
130
+
}
131
+
132
+
export async function unarchivePost(token: string) {
133
+
let identity = await getIdentityData();
134
+
if (!identity) throw new Error("No Identity");
135
+
136
+
// Unarchive on homepage
137
+
await supabaseServerClient
138
+
.from("permission_token_on_homepage")
139
+
.update({ archived: false })
140
+
.eq("token", token)
141
+
.eq("identity", identity.id);
142
+
143
+
// Check if leaflet is in any publications where user is the creator
144
+
let { data: leafletInPubs } = await supabaseServerClient
145
+
.from("leaflets_in_publications")
146
+
.select("publication, publications!inner(identity_did)")
147
+
.eq("leaflet", token);
148
+
149
+
if (leafletInPubs) {
150
+
for (let pub of leafletInPubs) {
151
+
if (pub.publications.identity_did === identity.atp_did) {
152
+
await supabaseServerClient
153
+
.from("leaflets_in_publications")
154
+
.update({ archived: false })
155
+
.eq("leaflet", token)
156
+
.eq("publication", pub.publication);
157
+
}
158
+
}
159
+
}
160
+
161
+
refresh();
35
162
return;
36
163
}
+2
actions/getIdentityData.ts
+2
actions/getIdentityData.ts
···
24
24
entity_sets(entities(facts(*)))
25
25
)),
26
26
permission_token_on_homepage(
27
+
archived,
27
28
created_at,
28
29
permission_tokens!inner(
29
30
id,
30
31
root_entity,
31
32
permission_token_rights(*),
33
+
leaflets_to_documents(*, documents(*)),
32
34
leaflets_in_publications(*, publications(*), documents(*))
33
35
)
34
36
)
+33
actions/publications/moveLeafletToPublication.ts
+33
actions/publications/moveLeafletToPublication.ts
···
1
+
"use server";
2
+
3
+
import { getIdentityData } from "actions/getIdentityData";
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
+
6
+
export async function moveLeafletToPublication(
7
+
leaflet_id: string,
8
+
publication_uri: string,
9
+
metadata: { title: string; description: string },
10
+
entitiesToDelete: string[],
11
+
) {
12
+
let identity = await getIdentityData();
13
+
if (!identity || !identity.atp_did) return null;
14
+
let { data: publication } = await supabaseServerClient
15
+
.from("publications")
16
+
.select("*")
17
+
.eq("uri", publication_uri)
18
+
.single();
19
+
if (publication?.identity_did !== identity.atp_did) return;
20
+
21
+
await supabaseServerClient.from("leaflets_in_publications").insert({
22
+
publication: publication_uri,
23
+
leaflet: leaflet_id,
24
+
doc: null,
25
+
title: metadata.title,
26
+
description: metadata.description,
27
+
});
28
+
29
+
await supabaseServerClient
30
+
.from("entities")
31
+
.delete()
32
+
.in("id", entitiesToDelete);
33
+
}
-26
actions/publications/updateLeafletDraftMetadata.ts
-26
actions/publications/updateLeafletDraftMetadata.ts
···
1
-
"use server";
2
-
3
-
import { getIdentityData } from "actions/getIdentityData";
4
-
import { supabaseServerClient } from "supabase/serverClient";
5
-
6
-
export async function updateLeafletDraftMetadata(
7
-
leafletID: string,
8
-
publication_uri: string,
9
-
title: string,
10
-
description: string,
11
-
) {
12
-
let identity = await getIdentityData();
13
-
if (!identity?.atp_did) return null;
14
-
let { data: publication } = await supabaseServerClient
15
-
.from("publications")
16
-
.select()
17
-
.eq("uri", publication_uri)
18
-
.single();
19
-
if (!publication || publication.identity_did !== identity.atp_did)
20
-
return null;
21
-
await supabaseServerClient
22
-
.from("leaflets_in_publications")
23
-
.update({ title, description })
24
-
.eq("leaflet", leafletID)
25
-
.eq("publication", publication_uri);
26
-
}
+396
-69
actions/publishToPublication.ts
+396
-69
actions/publishToPublication.ts
···
32
32
import { scanIndexLocal } from "src/replicache/utils";
33
33
import type { Fact } from "src/replicache";
34
34
import type { Attribute } from "src/replicache/attributes";
35
-
import {
36
-
Delta,
37
-
YJSFragmentToString,
38
-
} from "components/Blocks/TextBlock/RenderYJSFragment";
35
+
import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
39
36
import { ids } from "lexicons/api/lexicons";
40
37
import { BlobRef } from "@atproto/lexicon";
41
38
import { AtUri } from "@atproto/syntax";
···
44
41
import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
45
42
import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
46
43
import { Lock } from "src/utils/lock";
44
+
import type { PubLeafletPublication } from "lexicons/api";
45
+
import {
46
+
ColorToRGB,
47
+
ColorToRGBA,
48
+
} from "components/ThemeManager/colorToLexicons";
49
+
import { parseColor } from "@react-stately/color";
50
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
51
+
import { v7 } from "uuid";
47
52
48
53
export async function publishToPublication({
49
54
root_entity,
···
51
56
leaflet_id,
52
57
title,
53
58
description,
59
+
tags,
60
+
entitiesToDelete,
54
61
}: {
55
62
root_entity: string;
56
-
publication_uri: string;
63
+
publication_uri?: string;
57
64
leaflet_id: string;
58
65
title?: string;
59
66
description?: string;
67
+
tags?: string[];
68
+
entitiesToDelete?: string[];
60
69
}) {
61
70
const oauthClient = await createOauthClient();
62
71
let identity = await getIdentityData();
···
66
75
let agent = new AtpBaseClient(
67
76
credentialSession.fetchHandler.bind(credentialSession),
68
77
);
69
-
let { data: draft } = await supabaseServerClient
70
-
.from("leaflets_in_publications")
71
-
.select("*, publications(*), documents(*)")
72
-
.eq("publication", publication_uri)
73
-
.eq("leaflet", leaflet_id)
74
-
.single();
75
-
if (!draft || identity.atp_did !== draft?.publications?.identity_did)
76
-
throw new Error("No draft or not publisher");
78
+
79
+
// Check if we're publishing to a publication or standalone
80
+
let draft: any = null;
81
+
let existingDocUri: string | null = null;
82
+
83
+
if (publication_uri) {
84
+
// Publishing to a publication - use leaflets_in_publications
85
+
let { data, error } = await supabaseServerClient
86
+
.from("publications")
87
+
.select("*, leaflets_in_publications(*, documents(*))")
88
+
.eq("uri", publication_uri)
89
+
.eq("leaflets_in_publications.leaflet", leaflet_id)
90
+
.single();
91
+
console.log(error);
92
+
93
+
if (!data || identity.atp_did !== data?.identity_did)
94
+
throw new Error("No draft or not publisher");
95
+
draft = data.leaflets_in_publications[0];
96
+
existingDocUri = draft?.doc;
97
+
} else {
98
+
// Publishing standalone - use leaflets_to_documents
99
+
let { data } = await supabaseServerClient
100
+
.from("leaflets_to_documents")
101
+
.select("*, documents(*)")
102
+
.eq("leaflet", leaflet_id)
103
+
.single();
104
+
draft = data;
105
+
existingDocUri = draft?.document;
106
+
}
107
+
108
+
// Heuristic: Remove title entities if this is the first time publishing
109
+
// (when coming from a standalone leaflet with entitiesToDelete passed in)
110
+
if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
111
+
await supabaseServerClient
112
+
.from("entities")
113
+
.delete()
114
+
.in("id", entitiesToDelete);
115
+
}
116
+
77
117
let { data } = await supabaseServerClient.rpc("get_facts", {
78
118
root: root_entity,
79
119
});
80
120
let facts = (data as unknown as Fact<Attribute>[]) || [];
81
121
82
-
let { firstPageBlocks, pages } = await processBlocksToPages(
122
+
let { pages } = await processBlocksToPages(
83
123
facts,
84
124
agent,
85
125
root_entity,
···
88
128
89
129
let existingRecord =
90
130
(draft?.documents?.data as PubLeafletDocument.Record | undefined) || {};
131
+
132
+
// Extract theme for standalone documents (not for publications)
133
+
let theme: PubLeafletPublication.Theme | undefined;
134
+
if (!publication_uri) {
135
+
theme = await extractThemeFromFacts(facts, root_entity, agent);
136
+
}
137
+
91
138
let record: PubLeafletDocument.Record = {
139
+
publishedAt: new Date().toISOString(),
140
+
...existingRecord,
92
141
$type: "pub.leaflet.document",
93
142
author: credentialSession.did!,
94
-
publication: publication_uri,
95
-
publishedAt: new Date().toISOString(),
96
-
...existingRecord,
143
+
...(publication_uri && { publication: publication_uri }),
144
+
...(theme && { theme }),
97
145
title: title || "Untitled",
98
146
description: description || "",
99
-
pages: [
100
-
{
101
-
$type: "pub.leaflet.pages.linearDocument",
102
-
blocks: firstPageBlocks,
103
-
},
104
-
...pages.map((p) => {
105
-
if (p.type === "canvas") {
106
-
return {
107
-
$type: "pub.leaflet.pages.canvas" as const,
108
-
id: p.id,
109
-
blocks: p.blocks as PubLeafletPagesCanvas.Block[],
110
-
};
111
-
} else {
112
-
return {
113
-
$type: "pub.leaflet.pages.linearDocument" as const,
114
-
id: p.id,
115
-
blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
116
-
};
117
-
}
118
-
}),
119
-
],
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 {
151
+
$type: "pub.leaflet.pages.canvas" as const,
152
+
id: p.id,
153
+
blocks: p.blocks as PubLeafletPagesCanvas.Block[],
154
+
};
155
+
} else {
156
+
return {
157
+
$type: "pub.leaflet.pages.linearDocument" as const,
158
+
id: p.id,
159
+
blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
160
+
};
161
+
}
162
+
}),
120
163
};
121
-
let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr();
164
+
165
+
// Keep the same rkey if updating an existing document
166
+
let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
122
167
let { data: result } = await agent.com.atproto.repo.putRecord({
123
168
rkey,
124
169
repo: credentialSession.did!,
···
127
172
validate: false, //TODO publish the lexicon so we can validate!
128
173
});
129
174
175
+
// Optimistically create database entries
130
176
await supabaseServerClient.from("documents").upsert({
131
177
uri: result.uri,
132
178
data: record as Json,
133
179
});
134
-
await Promise.all([
135
-
//Optimistically put these in!
136
-
supabaseServerClient.from("documents_in_publications").upsert({
137
-
publication: record.publication,
138
-
document: result.uri,
139
-
}),
140
-
supabaseServerClient
141
-
.from("leaflets_in_publications")
142
-
.update({
180
+
181
+
if (publication_uri) {
182
+
// Publishing to a publication - update both tables
183
+
await Promise.all([
184
+
supabaseServerClient.from("documents_in_publications").upsert({
185
+
publication: publication_uri,
186
+
document: result.uri,
187
+
}),
188
+
supabaseServerClient.from("leaflets_in_publications").upsert({
143
189
doc: result.uri,
144
-
})
145
-
.eq("leaflet", leaflet_id)
146
-
.eq("publication", publication_uri),
147
-
]);
190
+
leaflet: leaflet_id,
191
+
publication: publication_uri,
192
+
title: title,
193
+
description: description,
194
+
}),
195
+
]);
196
+
} else {
197
+
// Publishing standalone - update leaflets_to_documents
198
+
await supabaseServerClient.from("leaflets_to_documents").upsert({
199
+
leaflet: leaflet_id,
200
+
document: result.uri,
201
+
title: title || "Untitled",
202
+
description: description || "",
203
+
});
204
+
205
+
// Heuristic: Remove title entities if this is the first time publishing standalone
206
+
// (when entitiesToDelete is provided and there's no existing document)
207
+
if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
208
+
await supabaseServerClient
209
+
.from("entities")
210
+
.delete()
211
+
.in("id", entitiesToDelete);
212
+
}
213
+
}
214
+
215
+
// Create notifications for mentions (only on first publish)
216
+
if (!existingDocUri) {
217
+
await createMentionNotifications(result.uri, record, credentialSession.did!);
218
+
}
148
219
149
220
return { rkey, record: JSON.parse(JSON.stringify(record)) };
150
221
}
···
169
240
170
241
let firstEntity = scan.eav(root_entity, "root/page")?.[0];
171
242
if (!firstEntity) throw new Error("No root page");
172
-
let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
173
-
let b = await blocksToRecord(blocks, did);
174
-
return { firstPageBlocks: b, pages };
243
+
244
+
// Check if the first page is a canvas or linear document
245
+
let [pageType] = scan.eav(firstEntity.data.value, "page/type");
246
+
247
+
if (pageType?.data.value === "canvas") {
248
+
// First page is a canvas
249
+
let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did);
250
+
pages.unshift({
251
+
id: firstEntity.data.value,
252
+
blocks: canvasBlocks,
253
+
type: "canvas",
254
+
});
255
+
} else {
256
+
// First page is a linear document
257
+
let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
258
+
let b = await blocksToRecord(blocks, did);
259
+
pages.unshift({
260
+
id: firstEntity.data.value,
261
+
blocks: b,
262
+
type: "doc",
263
+
});
264
+
}
265
+
266
+
return { pages };
175
267
176
268
async function uploadImage(src: string) {
177
269
let data = await fetch(src);
···
213
305
if (!b) return [];
214
306
let block: PubLeafletPagesLinearDocument.Block = {
215
307
$type: "pub.leaflet.pages.linearDocument#block",
216
-
alignment,
217
308
block: b,
218
309
};
310
+
if (alignment) block.alignment = alignment;
219
311
return [block];
220
312
} else {
221
313
let block: PubLeafletPagesLinearDocument.Block = {
···
257
349
Y.applyUpdate(doc, update);
258
350
let nodes = doc.getXmlElement("prosemirror").toArray();
259
351
let stringValue = YJSFragmentToString(nodes[0]);
260
-
let facets = YJSFragmentToFacets(nodes[0]);
352
+
let { facets } = YJSFragmentToFacets(nodes[0]);
261
353
return [stringValue, facets] as const;
262
354
};
263
355
if (b.type === "card") {
···
313
405
let [stringValue, facets] = getBlockContent(b.value);
314
406
let block: $Typed<PubLeafletBlocksHeader.Main> = {
315
407
$type: "pub.leaflet.blocks.header",
316
-
level: headingLevel?.data.value || 1,
408
+
level: Math.floor(headingLevel?.data.value || 1),
317
409
plaintext: stringValue,
318
410
facets,
319
411
};
···
346
438
let block: $Typed<PubLeafletBlocksIframe.Main> = {
347
439
$type: "pub.leaflet.blocks.iframe",
348
440
url: url.data.value,
349
-
height: height?.data.value || 600,
441
+
height: Math.floor(height?.data.value || 600),
350
442
};
351
443
return block;
352
444
}
···
360
452
$type: "pub.leaflet.blocks.image",
361
453
image: blobref,
362
454
aspectRatio: {
363
-
height: image.data.height,
364
-
width: image.data.width,
455
+
height: Math.floor(image.data.height),
456
+
width: Math.floor(image.data.width),
365
457
},
366
458
alt: altText ? altText.data.value : undefined,
367
459
};
···
518
610
519
611
function YJSFragmentToFacets(
520
612
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
521
-
): PubLeafletRichtextFacet.Main[] {
613
+
byteOffset: number = 0,
614
+
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
522
615
if (node.constructor === Y.XmlElement) {
523
-
return node
524
-
.toArray()
525
-
.map((f) => YJSFragmentToFacets(f))
526
-
.flat();
616
+
// Handle inline mention nodes
617
+
if (node.nodeName === "didMention") {
618
+
const text = node.getAttribute("text") || "";
619
+
const unicodestring = new UnicodeString(text);
620
+
const facet: PubLeafletRichtextFacet.Main = {
621
+
index: {
622
+
byteStart: byteOffset,
623
+
byteEnd: byteOffset + unicodestring.length,
624
+
},
625
+
features: [
626
+
{
627
+
$type: "pub.leaflet.richtext.facet#didMention",
628
+
did: node.getAttribute("did"),
629
+
},
630
+
],
631
+
};
632
+
return { facets: [facet], byteLength: unicodestring.length };
633
+
}
634
+
635
+
if (node.nodeName === "atMention") {
636
+
const text = node.getAttribute("text") || "";
637
+
const unicodestring = new UnicodeString(text);
638
+
const facet: PubLeafletRichtextFacet.Main = {
639
+
index: {
640
+
byteStart: byteOffset,
641
+
byteEnd: byteOffset + unicodestring.length,
642
+
},
643
+
features: [
644
+
{
645
+
$type: "pub.leaflet.richtext.facet#atMention",
646
+
atURI: node.getAttribute("atURI"),
647
+
},
648
+
],
649
+
};
650
+
return { facets: [facet], byteLength: unicodestring.length };
651
+
}
652
+
653
+
if (node.nodeName === "hard_break") {
654
+
const unicodestring = new UnicodeString("\n");
655
+
return { facets: [], byteLength: unicodestring.length };
656
+
}
657
+
658
+
// For other elements (like paragraph), process children
659
+
let allFacets: PubLeafletRichtextFacet.Main[] = [];
660
+
let currentOffset = byteOffset;
661
+
for (const child of node.toArray()) {
662
+
const result = YJSFragmentToFacets(child, currentOffset);
663
+
allFacets.push(...result.facets);
664
+
currentOffset += result.byteLength;
665
+
}
666
+
return { facets: allFacets, byteLength: currentOffset - byteOffset };
527
667
}
668
+
528
669
if (node.constructor === Y.XmlText) {
529
670
let facets: PubLeafletRichtextFacet.Main[] = [];
530
671
let delta = node.toDelta() as Delta[];
531
-
let byteStart = 0;
672
+
let byteStart = byteOffset;
673
+
let totalLength = 0;
532
674
for (let d of delta) {
533
675
let unicodestring = new UnicodeString(d.insert);
534
676
let facet: PubLeafletRichtextFacet.Main = {
···
561
703
});
562
704
if (facet.features.length > 0) facets.push(facet);
563
705
byteStart += unicodestring.length;
706
+
totalLength += unicodestring.length;
564
707
}
565
-
return facets;
708
+
return { facets, byteLength: totalLength };
566
709
}
567
-
return [];
710
+
return { facets: [], byteLength: 0 };
568
711
}
569
712
570
713
type ExcludeString<T> = T extends string
···
572
715
? never
573
716
: T /* maybe literal, not the whole `string` */
574
717
: T; /* not a string */
718
+
719
+
async function extractThemeFromFacts(
720
+
facts: Fact<any>[],
721
+
root_entity: string,
722
+
agent: AtpBaseClient,
723
+
): Promise<PubLeafletPublication.Theme | undefined> {
724
+
let scan = scanIndexLocal(facts);
725
+
let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data
726
+
.value;
727
+
let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data
728
+
.value;
729
+
let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value;
730
+
let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0]
731
+
?.data.value;
732
+
let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value;
733
+
let showPageBackground = !scan.eav(
734
+
root_entity,
735
+
"theme/card-border-hidden",
736
+
)?.[0]?.data.value;
737
+
let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0];
738
+
let backgroundImageRepeat = scan.eav(
739
+
root_entity,
740
+
"theme/background-image-repeat",
741
+
)?.[0];
742
+
743
+
let theme: PubLeafletPublication.Theme = {
744
+
showPageBackground: showPageBackground ?? true,
745
+
};
746
+
747
+
if (pageBackground)
748
+
theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
749
+
if (cardBackground)
750
+
theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`));
751
+
if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`));
752
+
if (accentBackground)
753
+
theme.accentBackground = ColorToRGB(
754
+
parseColor(`hsba(${accentBackground})`),
755
+
);
756
+
if (accentText)
757
+
theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`));
758
+
759
+
// Upload background image if present
760
+
if (backgroundImage?.data) {
761
+
let imageData = await fetch(backgroundImage.data.src);
762
+
if (imageData.status === 200) {
763
+
let binary = await imageData.blob();
764
+
let blob = await agent.com.atproto.repo.uploadBlob(binary, {
765
+
headers: { "Content-Type": binary.type },
766
+
});
767
+
768
+
theme.backgroundImage = {
769
+
$type: "pub.leaflet.theme.backgroundImage",
770
+
image: blob.data.blob,
771
+
repeat: backgroundImageRepeat?.data.value ? true : false,
772
+
...(backgroundImageRepeat?.data.value && {
773
+
width: Math.floor(backgroundImageRepeat.data.value),
774
+
}),
775
+
};
776
+
}
777
+
}
778
+
779
+
// Only return theme if at least one property is set
780
+
if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) {
781
+
return theme;
782
+
}
783
+
784
+
return undefined;
785
+
}
786
+
787
+
/**
788
+
* Extract mentions from a published document and create notifications
789
+
*/
790
+
async function createMentionNotifications(
791
+
documentUri: string,
792
+
record: PubLeafletDocument.Record,
793
+
authorDid: string,
794
+
) {
795
+
const mentionedDids = new Set<string>();
796
+
const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
797
+
const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
798
+
799
+
// Extract mentions from all text blocks in all pages
800
+
for (const page of record.pages) {
801
+
if (page.$type === "pub.leaflet.pages.linearDocument") {
802
+
const linearPage = page as PubLeafletPagesLinearDocument.Main;
803
+
for (const blockWrapper of linearPage.blocks) {
804
+
const block = blockWrapper.block;
805
+
if (block.$type === "pub.leaflet.blocks.text") {
806
+
const textBlock = block as PubLeafletBlocksText.Main;
807
+
if (textBlock.facets) {
808
+
for (const facet of textBlock.facets) {
809
+
for (const feature of facet.features) {
810
+
// Check for DID mentions
811
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
812
+
if (feature.did !== authorDid) {
813
+
mentionedDids.add(feature.did);
814
+
}
815
+
}
816
+
// Check for AT URI mentions (publications and documents)
817
+
if (PubLeafletRichtextFacet.isAtMention(feature)) {
818
+
const uri = new AtUri(feature.atURI);
819
+
820
+
if (uri.collection === "pub.leaflet.publication") {
821
+
// Get the publication owner's DID
822
+
const { data: publication } = await supabaseServerClient
823
+
.from("publications")
824
+
.select("identity_did")
825
+
.eq("uri", feature.atURI)
826
+
.single();
827
+
828
+
if (publication && publication.identity_did !== authorDid) {
829
+
mentionedPublications.set(publication.identity_did, feature.atURI);
830
+
}
831
+
} else if (uri.collection === "pub.leaflet.document") {
832
+
// Get the document owner's DID
833
+
const { data: document } = await supabaseServerClient
834
+
.from("documents")
835
+
.select("uri, data")
836
+
.eq("uri", feature.atURI)
837
+
.single();
838
+
839
+
if (document) {
840
+
const docRecord = document.data as PubLeafletDocument.Record;
841
+
if (docRecord.author !== authorDid) {
842
+
mentionedDocuments.set(docRecord.author, feature.atURI);
843
+
}
844
+
}
845
+
}
846
+
}
847
+
}
848
+
}
849
+
}
850
+
}
851
+
}
852
+
}
853
+
}
854
+
855
+
// Create notifications for DID mentions
856
+
for (const did of mentionedDids) {
857
+
const notification: Notification = {
858
+
id: v7(),
859
+
recipient: did,
860
+
data: {
861
+
type: "mention",
862
+
document_uri: documentUri,
863
+
mention_type: "did",
864
+
},
865
+
};
866
+
await supabaseServerClient.from("notifications").insert(notification);
867
+
await pingIdentityToUpdateNotification(did);
868
+
}
869
+
870
+
// Create notifications for publication mentions
871
+
for (const [recipientDid, publicationUri] of mentionedPublications) {
872
+
const notification: Notification = {
873
+
id: v7(),
874
+
recipient: recipientDid,
875
+
data: {
876
+
type: "mention",
877
+
document_uri: documentUri,
878
+
mention_type: "publication",
879
+
mentioned_uri: publicationUri,
880
+
},
881
+
};
882
+
await supabaseServerClient.from("notifications").insert(notification);
883
+
await pingIdentityToUpdateNotification(recipientDid);
884
+
}
885
+
886
+
// Create notifications for document mentions
887
+
for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
888
+
const notification: Notification = {
889
+
id: v7(),
890
+
recipient: recipientDid,
891
+
data: {
892
+
type: "mention",
893
+
document_uri: documentUri,
894
+
mention_type: "document",
895
+
mentioned_uri: mentionedDocUri,
896
+
},
897
+
};
898
+
await supabaseServerClient.from("notifications").insert(notification);
899
+
await pingIdentityToUpdateNotification(recipientDid);
900
+
}
901
+
}
+25
actions/searchTags.ts
+25
actions/searchTags.ts
···
1
+
"use server";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
4
+
export type TagSearchResult = {
5
+
name: string;
6
+
document_count: number;
7
+
};
8
+
9
+
export async function searchTags(
10
+
query: string,
11
+
): Promise<TagSearchResult[] | null> {
12
+
const searchQuery = query.trim().toLowerCase();
13
+
14
+
// Use raw SQL query to extract and aggregate tags
15
+
const { data, error } = await supabaseServerClient.rpc("search_tags", {
16
+
search_query: searchQuery,
17
+
});
18
+
19
+
if (error) {
20
+
console.error("Error searching tags:", error);
21
+
return null;
22
+
}
23
+
24
+
return data;
25
+
}
+1
-1
actions/subscriptions/sendPostToSubscribers.ts
+1
-1
actions/subscriptions/sendPostToSubscribers.ts
+1
-1
actions/subscriptions/subscribeToMailboxWithEmail.ts
+1
-1
actions/subscriptions/subscribeToMailboxWithEmail.ts
···
11
11
import type { Attribute } from "src/replicache/attributes";
12
12
import { Database } from "supabase/database.types";
13
13
import * as Y from "yjs";
14
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
14
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
15
15
import { pool } from "supabase/pool";
16
16
17
17
let supabase = createServerClient<Database>(
+2
-1
app/(home-pages)/discover/PubListing.tsx
+2
-1
app/(home-pages)/discover/PubListing.tsx
···
1
1
"use client";
2
2
import { AtUri } from "@atproto/syntax";
3
3
import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
4
+
import { SubscribeWithBluesky } from "app/lish/Subscribe";
4
5
import { PubIcon } from "components/ActionBar/Publications";
5
6
import { Separator } from "components/Layout";
6
7
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
···
16
17
},
17
18
) => {
18
19
let record = props.record as PubLeafletPublication.Record;
19
-
let theme = usePubTheme(record);
20
+
let theme = usePubTheme(record.theme);
20
21
let backgroundImage = record?.theme?.backgroundImage?.image?.ref
21
22
? blobRefToSrc(
22
23
record?.theme?.backgroundImage?.image?.ref,
+1
-2
app/(home-pages)/home/Actions/Actions.tsx
+1
-2
app/(home-pages)/home/Actions/Actions.tsx
···
1
1
"use client";
2
2
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
3
3
import { CreateNewLeafletButton } from "./CreateNewButton";
4
-
import { HelpPopover } from "components/HelpPopover";
4
+
import { HelpButton } from "app/[leaflet_id]/actions/HelpButton";
5
5
import { AccountSettings } from "./AccountSettings";
6
6
import { useIdentityData } from "components/IdentityProvider";
7
7
import { useReplicache } from "src/replicache";
···
18
18
) : (
19
19
<LoginActionButton />
20
20
)}
21
-
<HelpPopover />
22
21
</>
23
22
);
24
23
};
-48
app/(home-pages)/home/Actions/CreateNewButton.tsx
-48
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
1
1
"use client";
2
2
3
3
import { createNewLeaflet } from "actions/createNewLeaflet";
4
-
import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate";
5
4
import { ActionButton } from "components/ActionBar/ActionButton";
6
5
import { AddTiny } from "components/Icons/AddTiny";
7
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
8
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
9
-
import { TemplateSmall } from "components/Icons/TemplateSmall";
10
8
import { Menu, MenuItem } from "components/Layout";
11
9
import { useIsMobile } from "src/hooks/isMobile";
12
-
import { create } from "zustand";
13
-
import { combine, createJSONStorage, persist } from "zustand/middleware";
14
10
15
-
export const useTemplateState = create(
16
-
persist(
17
-
combine(
18
-
{
19
-
templates: [] as { id: string; name: string }[],
20
-
},
21
-
(set) => ({
22
-
removeTemplate: (template: { id: string }) =>
23
-
set((state) => {
24
-
return {
25
-
templates: state.templates.filter((t) => t.id !== template.id),
26
-
};
27
-
}),
28
-
addTemplate: (template: { id: string; name: string }) =>
29
-
set((state) => {
30
-
if (state.templates.find((t) => t.id === template.id)) return state;
31
-
return { templates: [...state.templates, template] };
32
-
}),
33
-
}),
34
-
),
35
-
{
36
-
name: "home-templates",
37
-
storage: createJSONStorage(() => localStorage),
38
-
},
39
-
),
40
-
);
41
11
export const CreateNewLeafletButton = (props: {}) => {
42
12
let isMobile = useIsMobile();
43
-
let templates = useTemplateState((s) => s.templates);
44
13
let openNewLeaflet = (id: string) => {
45
14
if (isMobile) {
46
15
window.location.href = `/${id}?focusFirstBlock`;
···
96
65
</div>
97
66
</div>
98
67
</MenuItem>
99
-
{templates.length > 0 && (
100
-
<hr className="border-border-light mx-2 mb-0.5" />
101
-
)}
102
-
{templates.map((t) => {
103
-
return (
104
-
<MenuItem
105
-
key={t.id}
106
-
onSelect={async () => {
107
-
let id = await createNewLeafletFromTemplate(t.id, false);
108
-
if (!id.error) openNewLeaflet(id.id);
109
-
}}
110
-
>
111
-
<TemplateSmall />
112
-
New {t.name}
113
-
</MenuItem>
114
-
);
115
-
})}
116
68
</Menu>
117
69
);
118
70
};
+49
-42
app/(home-pages)/home/HomeLayout.tsx
+49
-42
app/(home-pages)/home/HomeLayout.tsx
···
21
21
} from "components/PageLayouts/DashboardLayout";
22
22
import { Actions } from "./Actions/Actions";
23
23
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
24
-
import { useTemplateState } from "./Actions/CreateNewButton";
25
24
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
26
25
import { useState } from "react";
27
26
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
31
30
PublicationBanner,
32
31
} from "./HomeEmpty/HomeEmpty";
33
32
34
-
type Leaflet = {
33
+
export type Leaflet = {
35
34
added_at: string;
35
+
archived?: boolean | null;
36
36
token: PermissionToken & {
37
37
leaflets_in_publications?: Exclude<
38
38
GetLeafletDataReturnType["result"]["data"],
39
39
null
40
40
>["leaflets_in_publications"];
41
+
leaflets_to_documents?: Exclude<
42
+
GetLeafletDataReturnType["result"]["data"],
43
+
null
44
+
>["leaflets_to_documents"];
41
45
};
42
46
};
43
47
···
68
72
let { identity } = useIdentityData();
69
73
70
74
let hasPubs = !identity || identity.publications.length === 0 ? false : true;
71
-
let hasTemplates =
72
-
useTemplateState((s) => s.templates).length === 0 ? false : true;
75
+
let hasArchived =
76
+
identity &&
77
+
identity.permission_token_on_homepage.filter(
78
+
(leaflet) => leaflet.archived === true,
79
+
).length > 0;
73
80
74
81
return (
75
82
<DashboardLayout
···
87
94
setSearchValueAction={setSearchValue}
88
95
hasBackgroundImage={hasBackgroundImage}
89
96
hasPubs={hasPubs}
90
-
hasTemplates={hasTemplates}
97
+
hasArchived={!!hasArchived}
91
98
/>
92
99
),
93
100
content: (
···
127
134
...identity.permission_token_on_homepage.reduce(
128
135
(acc, tok) => {
129
136
let title =
130
-
tok.permission_tokens.leaflets_in_publications[0]?.title;
137
+
tok.permission_tokens.leaflets_in_publications[0]?.title ||
138
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
131
139
if (title) acc[tok.permission_tokens.root_entity] = title;
132
140
return acc;
133
141
},
···
147
155
? identity.permission_token_on_homepage.map((ptoh) => ({
148
156
added_at: ptoh.created_at,
149
157
token: ptoh.permission_tokens as PermissionToken,
158
+
archived: ptoh.archived,
150
159
}))
151
160
: localLeaflets
152
161
.sort((a, b) => (a.added_at > b.added_at ? -1 : 1))
···
204
213
w-full
205
214
${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `}
206
215
>
207
-
{props.leaflets.map(({ token: leaflet, added_at }, index) => (
216
+
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
208
217
<ReplicacheProvider
209
218
disablePull
210
219
initialFactsOnly={!!identity}
···
218
227
value={{
219
228
...leaflet,
220
229
leaflets_in_publications: leaflet.leaflets_in_publications || [],
230
+
leaflets_to_documents: leaflet.leaflets_to_documents || [],
221
231
blocked_by_admin: null,
222
232
custom_domain_routes: [],
223
233
}}
224
234
>
225
235
<LeafletListItem
226
-
title={props?.titles?.[leaflet.root_entity] || "Untitled"}
227
-
token={leaflet}
228
-
draft={!!leaflet.leaflets_in_publications?.length}
229
-
published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)}
230
-
publishedAt={
231
-
leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents
232
-
?.indexed_at
233
-
}
234
-
leaflet_id={leaflet.root_entity}
236
+
title={props?.titles?.[leaflet.root_entity]}
237
+
archived={archived}
235
238
loggedIn={!!identity}
236
239
display={display}
237
240
added_at={added_at}
···
260
263
261
264
let sortedLeaflets = leaflets.sort((a, b) => {
262
265
if (sort === "alphabetical") {
263
-
if (titles[a.token.root_entity] === titles[b.token.root_entity]) {
266
+
let titleA = titles[a.token.root_entity] ?? "Untitled";
267
+
let titleB = titles[b.token.root_entity] ?? "Untitled";
268
+
269
+
if (titleA === titleB) {
264
270
return a.added_at > b.added_at ? -1 : 1;
265
271
} else {
266
-
return titles[a.token.root_entity].toLocaleLowerCase() >
267
-
titles[b.token.root_entity].toLocaleLowerCase()
268
-
? 1
269
-
: -1;
272
+
return titleA.toLocaleLowerCase() > titleB.toLocaleLowerCase() ? 1 : -1;
270
273
}
271
274
} else {
272
275
return a.added_at === b.added_at
···
279
282
}
280
283
});
281
284
282
-
let allTemplates = useTemplateState((s) => s.templates);
283
-
let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => {
284
-
let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc);
285
-
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
286
-
let docs = !leaflet.leaflets_in_publications?.length;
287
-
let templates = !!allTemplates.find((t) => t.id === leaflet.id);
288
-
// If no filters are active, show all
289
-
if (
290
-
!filter.drafts &&
291
-
!filter.published &&
292
-
!filter.docs &&
293
-
!filter.templates
294
-
)
295
-
return true;
285
+
let filteredLeaflets = sortedLeaflets.filter(
286
+
({ token: leaflet, archived: archived }) => {
287
+
let published =
288
+
!!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
289
+
!!leaflet.leaflets_to_documents?.find((l) => l.document);
290
+
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
291
+
let docs = !leaflet.leaflets_in_publications?.length && !archived;
292
+
293
+
// If no filters are active, show everything that is not archived
294
+
if (
295
+
!filter.drafts &&
296
+
!filter.published &&
297
+
!filter.docs &&
298
+
!filter.archived
299
+
)
300
+
return archived === false || archived === null || archived == undefined;
296
301
297
-
return (
298
-
(filter.drafts && drafts) ||
299
-
(filter.published && published) ||
300
-
(filter.docs && docs) ||
301
-
(filter.templates && templates)
302
-
);
303
-
});
302
+
//if a filter is on, return itemsd of that filter that are also NOT archived
303
+
return (
304
+
(filter.drafts && drafts && !archived) ||
305
+
(filter.published && published && !archived) ||
306
+
(filter.docs && docs && !archived) ||
307
+
(filter.archived && archived)
308
+
);
309
+
},
310
+
);
304
311
if (searchValue === "") return filteredLeaflets;
305
312
let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => {
306
313
return titles[leaflet.root_entity]
+29
-57
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
+29
-57
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
···
1
1
"use client";
2
-
import { PermissionToken } from "src/replicache";
2
+
import { useEntity } from "src/replicache";
3
3
import { LeafletOptions } from "./LeafletOptions";
4
-
import Link from "next/link";
5
-
import { useState } from "react";
6
-
import { theme } from "tailwind.config";
7
-
import { TemplateSmall } from "components/Icons/TemplateSmall";
8
4
import { timeAgo } from "src/utils/timeAgo";
5
+
import { usePageTitle } from "components/utils/UpdateLeafletTitle";
6
+
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
9
7
10
8
export const LeafletInfo = (props: {
11
9
title?: string;
12
-
draft?: boolean;
13
-
published?: boolean;
14
-
token: PermissionToken;
15
-
leaflet_id: string;
16
-
loggedIn: boolean;
17
-
isTemplate: boolean;
18
10
className?: string;
19
11
display: "grid" | "list";
20
12
added_at: string;
21
-
publishedAt?: string;
13
+
archived?: boolean | null;
14
+
loggedIn: boolean;
22
15
}) => {
23
-
let [prefetch, setPrefetch] = useState(false);
16
+
const pubStatus = useLeafletPublicationStatus();
24
17
let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : "";
18
+
let prettyPublishedAt = pubStatus?.publishedAt
19
+
? timeAgo(pubStatus.publishedAt)
20
+
: "";
25
21
26
-
let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : "";
22
+
// Look up root page first, like UpdateLeafletTitle does
23
+
let firstPage = useEntity(pubStatus?.leafletId ?? "", "root/page")[0];
24
+
let entityID = firstPage?.data.value || pubStatus?.leafletId || "";
25
+
let titleFromDb = usePageTitle(entityID);
26
+
27
+
let title = props.title ?? titleFromDb ?? "Untitled";
27
28
28
29
return (
29
30
<div
30
31
className={`leafletInfo w-full min-w-0 flex flex-col ${props.className}`}
31
32
>
32
33
<div className="flex justify-between items-center shrink-0 max-w-full gap-2 leading-tight overflow-hidden">
33
-
<Link
34
-
onMouseEnter={() => setPrefetch(true)}
35
-
onPointerDown={() => setPrefetch(true)}
36
-
prefetch={prefetch}
37
-
href={`/${props.token.id}`}
38
-
className="no-underline sm:hover:no-underline text-primary grow min-w-0"
39
-
>
40
-
<h3 className="sm:text-lg text-base truncate w-full min-w-0">
41
-
{props.title}
42
-
</h3>
43
-
</Link>
34
+
<h3 className="sm:text-lg text-base truncate w-full min-w-0">
35
+
{title}
36
+
</h3>
44
37
<div className="flex gap-1 shrink-0">
45
-
{props.isTemplate && props.display === "list" ? (
46
-
<TemplateSmall
47
-
fill={theme.colors["bg-page"]}
48
-
className="text-tertiary"
49
-
/>
50
-
) : null}
51
-
<LeafletOptions
52
-
leaflet={props.token}
53
-
isTemplate={props.isTemplate}
54
-
loggedIn={props.loggedIn}
55
-
added_at={props.added_at}
56
-
/>
38
+
<LeafletOptions archived={props.archived} loggedIn={props.loggedIn} />
57
39
</div>
58
40
</div>
59
-
<Link
60
-
onMouseEnter={() => setPrefetch(true)}
61
-
onPointerDown={() => setPrefetch(true)}
62
-
prefetch={prefetch}
63
-
href={`/${props.token.id}`}
64
-
className="no-underline sm:hover:no-underline text-primary w-full"
65
-
>
66
-
{props.draft || props.published ? (
41
+
<div className="flex gap-2 items-center">
42
+
{props.archived ? (
43
+
<div className="text-xs text-tertiary truncate">Archived</div>
44
+
) : pubStatus?.draftInPublication || pubStatus?.isPublished ? (
67
45
<div
68
-
className={`text-xs ${props.published ? "font-bold text-tertiary" : "text-tertiary"}`}
46
+
className={`text-xs w-max grow truncate ${pubStatus?.isPublished ? "font-bold text-tertiary" : "text-tertiary"}`}
69
47
>
70
-
{props.published
48
+
{pubStatus?.isPublished
71
49
? `Published ${prettyPublishedAt}`
72
50
: `Draft ${prettyCreatedAt}`}
73
51
</div>
74
52
) : (
75
-
<div className="text-xs text-tertiary">{prettyCreatedAt}</div>
53
+
<div className="text-xs text-tertiary grow w-max truncate">
54
+
{prettyCreatedAt}
55
+
</div>
76
56
)}
77
-
</Link>
78
-
{props.isTemplate && props.display === "grid" ? (
79
-
<div className="absolute -top-2 right-1">
80
-
<TemplateSmall
81
-
className="text-tertiary"
82
-
fill={theme.colors["bg-page"]}
83
-
/>
84
-
</div>
85
-
) : null}
57
+
</div>
86
58
</div>
87
59
);
88
60
};
+45
-26
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
+45
-26
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
1
1
"use client";
2
-
import { PermissionToken } from "src/replicache";
3
-
import { useTemplateState } from "../Actions/CreateNewButton";
4
2
import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview";
5
3
import { LeafletInfo } from "./LeafletInfo";
6
4
import { useState, useRef, useEffect } from "react";
5
+
import { SpeedyLink } from "components/SpeedyLink";
6
+
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
7
8
8
export const LeafletListItem = (props: {
9
-
token: PermissionToken;
10
-
leaflet_id: string;
9
+
archived?: boolean | null;
11
10
loggedIn: boolean;
12
11
display: "list" | "grid";
13
12
cardBorderHidden: boolean;
14
13
added_at: string;
15
-
title: string;
16
-
draft?: boolean;
17
-
published?: boolean;
18
-
publishedAt?: string;
14
+
title?: string;
19
15
index: number;
20
16
isHidden: boolean;
21
17
showPreview?: boolean;
22
18
}) => {
23
-
let isTemplate = useTemplateState(
24
-
(s) => !!s.templates.find((t) => t.id === props.token.id),
25
-
);
26
-
19
+
const pubStatus = useLeafletPublicationStatus();
27
20
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
28
21
let previewRef = useRef<HTMLDivElement | null>(null);
29
22
···
45
38
return () => observer.disconnect();
46
39
}, [previewRef]);
47
40
41
+
const tokenId = pubStatus?.shareLink ?? "";
42
+
48
43
if (props.display === "list")
49
44
return (
50
45
<>
51
46
<div
52
47
ref={previewRef}
53
-
className={`gap-3 w-full ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border"}`}
48
+
className={`relative flex gap-3 w-full
49
+
${props.isHidden ? "hidden" : "flex"}
50
+
${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
54
51
style={{
55
52
backgroundColor: props.cardBorderHidden
56
53
? "transparent"
57
54
: "rgba(var(--bg-page), var(--bg-page-alpha))",
58
-
59
-
display: props.isHidden ? "none" : "flex",
60
55
}}
61
56
>
62
-
{props.showPreview && (
63
-
<LeafletListPreview isVisible={isOnScreen} {...props} />
64
-
)}
65
-
<LeafletInfo isTemplate={isTemplate} {...props} />
57
+
<SpeedyLink
58
+
href={`/${tokenId}`}
59
+
className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`}
60
+
/>
61
+
{props.showPreview && <LeafletListPreview isVisible={isOnScreen} />}
62
+
<LeafletInfo
63
+
title={props.title}
64
+
display={props.display}
65
+
added_at={props.added_at}
66
+
archived={props.archived}
67
+
loggedIn={props.loggedIn}
68
+
/>
66
69
</div>
67
70
{props.cardBorderHidden && (
68
71
<hr
···
77
80
return (
78
81
<div
79
82
ref={previewRef}
80
-
className={`leafletGridListItem relative
81
-
flex flex-col gap-1 p-1 h-52
83
+
className={`
84
+
relative
85
+
flex flex-col gap-1 p-1 h-52 w-full
82
86
block-border border-border! hover:outline-border
87
+
${props.isHidden ? "hidden" : "flex"}
83
88
`}
84
89
style={{
85
90
backgroundColor: props.cardBorderHidden
86
91
? "transparent"
87
92
: "rgba(var(--bg-page), var(--bg-page-alpha))",
88
-
89
-
display: props.isHidden ? "none" : "flex",
90
93
}}
91
94
>
95
+
<SpeedyLink
96
+
href={`/${tokenId}`}
97
+
className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`}
98
+
/>
92
99
<div className="grow">
93
-
<LeafletGridPreview {...props} isVisible={isOnScreen} />
100
+
<LeafletGridPreview isVisible={isOnScreen} />
94
101
</div>
95
102
<LeafletInfo
96
-
isTemplate={isTemplate}
97
103
className="px-1 pb-0.5 shrink-0"
98
-
{...props}
104
+
title={props.title}
105
+
display={props.display}
106
+
added_at={props.added_at}
107
+
archived={props.archived}
108
+
loggedIn={props.loggedIn}
99
109
/>
100
110
</div>
101
111
);
102
112
};
113
+
114
+
const LeafletLink = (props: { id: string; className: string }) => {
115
+
return (
116
+
<SpeedyLink
117
+
href={`/${props.id}`}
118
+
className={`no-underline hover:no-underline! text-primary ${props.className}`}
119
+
/>
120
+
);
121
+
};
+301
-170
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+301
-170
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
···
1
1
"use client";
2
2
3
3
import { Menu, MenuItem } from "components/Layout";
4
-
import { useReplicache, type PermissionToken } from "src/replicache";
5
-
import { hideDoc } from "../storage";
6
4
import { useState } from "react";
7
-
import { ButtonPrimary } from "components/Buttons";
8
-
import { useTemplateState } from "../Actions/CreateNewButton";
9
-
import { useSmoker, useToaster } from "components/Toast";
10
-
import { removeLeafletFromHome } from "actions/removeLeafletFromHome";
11
-
import { useIdentityData } from "components/IdentityProvider";
5
+
import { ButtonPrimary, ButtonTertiary } from "components/Buttons";
6
+
import { useToaster } from "components/Toast";
7
+
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
8
+
import { DeleteSmall } from "components/Icons/DeleteSmall";
9
+
import {
10
+
archivePost,
11
+
deleteLeaflet,
12
+
unarchivePost,
13
+
} from "actions/deleteLeaflet";
14
+
import { ArchiveSmall } from "components/Icons/ArchiveSmall";
15
+
import { UnpublishSmall } from "components/Icons/UnpublishSmall";
16
+
import {
17
+
deletePost,
18
+
unpublishPost,
19
+
} from "app/lish/[did]/[publication]/dashboard/deletePost";
20
+
import { ShareSmall } from "components/Icons/ShareSmall";
12
21
import { HideSmall } from "components/Icons/HideSmall";
13
-
import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny";
14
-
import { TemplateRemoveSmall } from "components/Icons/TemplateRemoveSmall";
15
-
import { TemplateSmall } from "components/Icons/TemplateSmall";
16
-
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
17
-
import { addLeafletToHome } from "actions/addLeafletToHome";
22
+
import { hideDoc } from "../storage";
23
+
24
+
import {
25
+
useIdentityData,
26
+
mutateIdentityData,
27
+
} from "components/IdentityProvider";
28
+
import {
29
+
usePublicationData,
30
+
mutatePublicationData,
31
+
} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
32
+
import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions";
33
+
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
18
34
19
35
export const LeafletOptions = (props: {
20
-
leaflet: PermissionToken;
21
-
isTemplate: boolean;
22
-
loggedIn: boolean;
23
-
added_at: string;
36
+
archived?: boolean | null;
37
+
loggedIn?: boolean;
24
38
}) => {
25
-
let { mutate: mutateIdentity } = useIdentityData();
26
-
let [state, setState] = useState<"normal" | "template">("normal");
39
+
const pubStatus = useLeafletPublicationStatus();
40
+
let [state, setState] = useState<"normal" | "areYouSure">("normal");
27
41
let [open, setOpen] = useState(false);
28
-
let smoker = useSmoker();
29
-
let toaster = useToaster();
42
+
let { identity } = useIdentityData();
43
+
let isPublicationOwner =
44
+
!!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did);
30
45
return (
31
46
<>
32
47
<Menu
···
38
53
}}
39
54
trigger={
40
55
<div
41
-
className="text-secondary shrink-0"
56
+
className="text-secondary shrink-0 relative"
42
57
onClick={(e) => {
43
58
e.preventDefault;
44
59
e.stopPropagation;
···
49
64
}
50
65
>
51
66
{state === "normal" ? (
52
-
<>
53
-
{!props.isTemplate ? (
54
-
<MenuItem
55
-
onSelect={(e) => {
56
-
e.preventDefault();
57
-
setState("template");
58
-
}}
59
-
>
60
-
<TemplateSmall /> Add as Template
61
-
</MenuItem>
62
-
) : (
63
-
<MenuItem
64
-
onSelect={(e) => {
65
-
useTemplateState.getState().removeTemplate(props.leaflet);
66
-
let newLeafletButton =
67
-
document.getElementById("new-leaflet-button");
68
-
if (!newLeafletButton) return;
69
-
let rect = newLeafletButton.getBoundingClientRect();
70
-
smoker({
71
-
static: true,
72
-
text: <strong>Removed template!</strong>,
73
-
position: {
74
-
y: rect.top,
75
-
x: rect.right + 5,
76
-
},
77
-
});
78
-
}}
79
-
>
80
-
<TemplateRemoveSmall /> Remove from Templates
81
-
</MenuItem>
82
-
)}
83
-
<MenuItem
84
-
onSelect={async () => {
85
-
if (props.loggedIn) {
86
-
mutateIdentity(
87
-
(s) => {
88
-
if (!s) return s;
89
-
return {
90
-
...s,
91
-
permission_token_on_homepage:
92
-
s.permission_token_on_homepage.filter(
93
-
(ptrh) =>
94
-
ptrh.permission_tokens.id !== props.leaflet.id,
95
-
),
96
-
};
97
-
},
98
-
{ revalidate: false },
99
-
);
100
-
await removeLeafletFromHome([props.leaflet.id]);
101
-
mutateIdentity();
102
-
} else {
103
-
hideDoc(props.leaflet);
104
-
}
105
-
toaster({
106
-
content: (
107
-
<div className="font-bold">
108
-
Doc removed!{" "}
109
-
<UndoRemoveFromHomeButton
110
-
leaflet={props.leaflet}
111
-
added_at={props.added_at}
112
-
/>
113
-
</div>
114
-
),
115
-
type: "success",
116
-
});
117
-
}}
118
-
>
119
-
<HideSmall />
120
-
Remove from Home
121
-
</MenuItem>
122
-
</>
123
-
) : state === "template" ? (
124
-
<AddTemplateForm
125
-
leaflet={props.leaflet}
126
-
close={() => setOpen(false)}
127
-
/>
67
+
!props.loggedIn ? (
68
+
<LoggedOutOptions setState={setState} />
69
+
) : pubStatus?.documentUri && isPublicationOwner ? (
70
+
<PublishedPostOptions setState={setState} />
71
+
) : (
72
+
<DefaultOptions setState={setState} archived={props.archived} />
73
+
)
74
+
) : state === "areYouSure" ? (
75
+
<DeleteAreYouSureForm backToMenu={() => setState("normal")} />
128
76
) : null}
129
77
</Menu>
130
78
</>
131
79
);
132
80
};
133
81
134
-
const UndoRemoveFromHomeButton = (props: {
135
-
leaflet: PermissionToken;
136
-
added_at: string | undefined;
82
+
const DefaultOptions = (props: {
83
+
setState: (s: "areYouSure") => void;
84
+
archived?: boolean | null;
137
85
}) => {
138
-
let toaster = useToaster();
139
-
let { mutate } = useIdentityData();
86
+
const pubStatus = useLeafletPublicationStatus();
87
+
const toaster = useToaster();
88
+
const { setArchived } = useArchiveMutations();
89
+
const { identity } = useIdentityData();
90
+
const tokenId = pubStatus?.token.id;
91
+
const itemType = pubStatus?.draftInPublication ? "Draft" : "Leaflet";
92
+
93
+
// Check if this is a published post/document and if user is the owner
94
+
const isPublishedPostOwner =
95
+
!!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did);
96
+
const canDelete = !pubStatus?.documentUri || isPublishedPostOwner;
97
+
140
98
return (
141
-
<button
142
-
onClick={async (e) => {
143
-
await mutate(
144
-
(identity) => {
145
-
if (!identity) return;
146
-
return {
147
-
...identity,
148
-
permission_token_on_homepage: [
149
-
...identity.permission_token_on_homepage,
150
-
{
151
-
created_at: props.added_at || new Date().toISOString(),
152
-
permission_tokens: {
153
-
...props.leaflet,
154
-
leaflets_in_publications: [],
155
-
},
156
-
},
157
-
],
158
-
};
159
-
},
160
-
{ revalidate: false },
161
-
);
162
-
await addLeafletToHome(props.leaflet.id);
163
-
await mutate();
99
+
<>
100
+
<EditLinkShareButton link={pubStatus?.shareLink ?? ""} />
101
+
<hr className="border-border-light" />
102
+
<MenuItem
103
+
onSelect={async () => {
104
+
if (!tokenId) return;
105
+
setArchived(tokenId, !props.archived);
106
+
107
+
if (!props.archived) {
108
+
await archivePost(tokenId);
109
+
toaster({
110
+
content: (
111
+
<div className="font-bold flex gap-2 items-center">
112
+
Archived {itemType}!
113
+
<ButtonTertiary
114
+
className="underline text-accent-2!"
115
+
onClick={async () => {
116
+
setArchived(tokenId, false);
117
+
await unarchivePost(tokenId);
118
+
toaster({
119
+
content: <div className="font-bold">Unarchived!</div>,
120
+
type: "success",
121
+
});
122
+
}}
123
+
>
124
+
Undo?
125
+
</ButtonTertiary>
126
+
</div>
127
+
),
128
+
type: "success",
129
+
});
130
+
} else {
131
+
await unarchivePost(tokenId);
132
+
toaster({
133
+
content: <div className="font-bold">Unarchived!</div>,
134
+
type: "success",
135
+
});
136
+
}
137
+
}}
138
+
>
139
+
<ArchiveSmall />
140
+
{!props.archived ? " Archive" : "Unarchive"} {itemType}
141
+
</MenuItem>
142
+
{canDelete && (
143
+
<DeleteForeverMenuItem
144
+
onSelect={(e) => {
145
+
e.preventDefault();
146
+
props.setState("areYouSure");
147
+
}}
148
+
/>
149
+
)}
150
+
</>
151
+
);
152
+
};
153
+
154
+
const LoggedOutOptions = (props: { setState: (s: "areYouSure") => void }) => {
155
+
const pubStatus = useLeafletPublicationStatus();
156
+
const toaster = useToaster();
164
157
165
-
toaster({
166
-
content: <div className="font-bold">Recovered Doc!</div>,
167
-
type: "success",
168
-
});
169
-
}}
170
-
className="underline"
171
-
>
172
-
Undo?
173
-
</button>
158
+
return (
159
+
<>
160
+
<EditLinkShareButton link={`/${pubStatus?.shareLink ?? ""}`} />
161
+
<hr className="border-border-light" />
162
+
<MenuItem
163
+
onSelect={() => {
164
+
if (pubStatus?.token) hideDoc(pubStatus.token);
165
+
toaster({
166
+
content: <div className="font-bold">Removed from Home!</div>,
167
+
type: "success",
168
+
});
169
+
}}
170
+
>
171
+
<HideSmall />
172
+
Remove from Home
173
+
</MenuItem>
174
+
<DeleteForeverMenuItem
175
+
onSelect={(e) => {
176
+
e.preventDefault();
177
+
props.setState("areYouSure");
178
+
}}
179
+
/>
180
+
</>
174
181
);
175
182
};
176
183
177
-
const AddTemplateForm = (props: {
178
-
leaflet: PermissionToken;
179
-
close: () => void;
184
+
const PublishedPostOptions = (props: {
185
+
setState: (s: "areYouSure") => void;
180
186
}) => {
181
-
let [name, setName] = useState("");
182
-
let smoker = useSmoker();
183
-
return (
184
-
<div className="flex flex-col gap-2 px-3 py-1">
185
-
<label className="font-bold flex flex-col gap-1 text-secondary">
186
-
Template Name
187
-
<input
188
-
value={name}
189
-
onChange={(e) => setName(e.target.value)}
190
-
type="text"
191
-
className=" text-primary font-normal border border-border rounded-md outline-hidden px-2 py-1 w-64"
192
-
/>
193
-
</label>
187
+
const pubStatus = useLeafletPublicationStatus();
188
+
const toaster = useToaster();
189
+
const postLink = pubStatus?.postShareLink ?? "";
190
+
const isFullUrl = postLink.includes("http");
194
191
195
-
<ButtonPrimary
196
-
onClick={() => {
197
-
useTemplateState.getState().addTemplate({
198
-
name,
199
-
id: props.leaflet.id,
192
+
return (
193
+
<>
194
+
<ShareButton
195
+
text={
196
+
<div className="flex gap-2">
197
+
<ShareSmall />
198
+
Copy Post Link
199
+
</div>
200
+
}
201
+
smokerText="Link copied!"
202
+
id="get-link"
203
+
link={postLink}
204
+
fullLink={isFullUrl ? postLink : undefined}
205
+
/>
206
+
<hr className="border-border-light" />
207
+
<MenuItem
208
+
onSelect={async () => {
209
+
if (pubStatus?.documentUri) {
210
+
await unpublishPost(pubStatus.documentUri);
211
+
}
212
+
toaster({
213
+
content: <div className="font-bold">Unpublished Post!</div>,
214
+
type: "success",
200
215
});
201
-
let newLeafletButton = document.getElementById("new-leaflet-button");
202
-
if (!newLeafletButton) return;
203
-
let rect = newLeafletButton.getBoundingClientRect();
204
-
smoker({
205
-
static: true,
206
-
text: <strong>Added {name}!</strong>,
207
-
position: {
208
-
y: rect.top,
209
-
x: rect.right + 5,
210
-
},
211
-
});
212
-
props.close();
213
216
}}
214
-
className="place-self-end"
215
217
>
216
-
Add Template
217
-
</ButtonPrimary>
218
+
<UnpublishSmall />
219
+
<div className="flex flex-col">
220
+
Unpublish Post
221
+
<div className="text-tertiary text-sm font-normal!">
222
+
Move this post back into drafts
223
+
</div>
224
+
</div>
225
+
</MenuItem>
226
+
<DeleteForeverMenuItem
227
+
onSelect={(e) => {
228
+
e.preventDefault();
229
+
props.setState("areYouSure");
230
+
}}
231
+
subtext="Post"
232
+
/>
233
+
</>
234
+
);
235
+
};
236
+
237
+
const DeleteAreYouSureForm = (props: { backToMenu: () => void }) => {
238
+
const pubStatus = useLeafletPublicationStatus();
239
+
const toaster = useToaster();
240
+
const { removeFromLists } = useArchiveMutations();
241
+
const tokenId = pubStatus?.token.id;
242
+
243
+
const itemType = pubStatus?.documentUri
244
+
? "Post"
245
+
: pubStatus?.draftInPublication
246
+
? "Draft"
247
+
: "Leaflet";
248
+
249
+
return (
250
+
<div className="flex flex-col justify-center p-2 text-center">
251
+
<div className="text-primary font-bold"> Are you sure?</div>
252
+
<div className="text-sm text-secondary">
253
+
This will delete it forever for everyone!
254
+
</div>
255
+
<div className="flex gap-2 mx-auto items-center mt-2">
256
+
<ButtonTertiary onClick={() => props.backToMenu()}>
257
+
Nevermind
258
+
</ButtonTertiary>
259
+
<ButtonPrimary
260
+
onClick={async () => {
261
+
if (tokenId) removeFromLists(tokenId);
262
+
if (pubStatus?.documentUri) {
263
+
await deletePost(pubStatus.documentUri);
264
+
}
265
+
if (pubStatus?.token) deleteLeaflet(pubStatus.token);
266
+
267
+
toaster({
268
+
content: <div className="font-bold">Deleted {itemType}!</div>,
269
+
type: "success",
270
+
});
271
+
}}
272
+
>
273
+
Delete it!
274
+
</ButtonPrimary>
275
+
</div>
218
276
</div>
219
277
);
220
278
};
279
+
280
+
// Shared menu items
281
+
const EditLinkShareButton = (props: { link: string }) => (
282
+
<ShareButton
283
+
text={
284
+
<div className="flex gap-2">
285
+
<ShareSmall />
286
+
Copy Edit Link
287
+
</div>
288
+
}
289
+
subtext=""
290
+
smokerText="Link copied!"
291
+
id="get-link"
292
+
link={props.link}
293
+
/>
294
+
);
295
+
296
+
const DeleteForeverMenuItem = (props: {
297
+
onSelect: (e: Event) => void;
298
+
subtext?: string;
299
+
}) => (
300
+
<MenuItem onSelect={props.onSelect}>
301
+
<DeleteSmall />
302
+
{props.subtext ? (
303
+
<div className="flex flex-col">
304
+
Delete {props.subtext}
305
+
<div className="text-tertiary text-sm font-normal!">
306
+
Unpublish AND delete
307
+
</div>
308
+
</div>
309
+
) : (
310
+
"Delete Forever"
311
+
)}
312
+
</MenuItem>
313
+
);
314
+
315
+
// Helper to update archived state in both identity and publication data
316
+
function useArchiveMutations() {
317
+
const { mutate: mutatePub } = usePublicationData();
318
+
const { mutate: mutateIdentity } = useIdentityData();
319
+
320
+
return {
321
+
setArchived: (tokenId: string, archived: boolean) => {
322
+
mutateIdentityData(mutateIdentity, (data) => {
323
+
const item = data.permission_token_on_homepage.find(
324
+
(p) => p.permission_tokens?.id === tokenId,
325
+
);
326
+
if (item) item.archived = archived;
327
+
});
328
+
mutatePublicationData(mutatePub, (data) => {
329
+
const item = data.publication?.leaflets_in_publications.find(
330
+
(l) => l.permission_tokens?.id === tokenId,
331
+
);
332
+
if (item) item.archived = archived;
333
+
});
334
+
},
335
+
removeFromLists: (tokenId: string) => {
336
+
mutateIdentityData(mutateIdentity, (data) => {
337
+
data.permission_token_on_homepage =
338
+
data.permission_token_on_homepage.filter(
339
+
(p) => p.permission_tokens?.id !== tokenId,
340
+
);
341
+
});
342
+
mutatePublicationData(mutatePub, (data) => {
343
+
if (!data.publication) return;
344
+
data.publication.leaflets_in_publications =
345
+
data.publication.leaflets_in_publications.filter(
346
+
(l) => l.permission_tokens?.id !== tokenId,
347
+
);
348
+
});
349
+
},
350
+
};
351
+
}
+49
-112
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
+49
-112
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
···
3
3
ThemeBackgroundProvider,
4
4
ThemeProvider,
5
5
} from "components/ThemeManager/ThemeProvider";
6
-
import {
7
-
PermissionToken,
8
-
useEntity,
9
-
useReferenceToEntity,
10
-
} from "src/replicache";
11
-
import { useTemplateState } from "../Actions/CreateNewButton";
6
+
import { useEntity, useReferenceToEntity } from "src/replicache";
12
7
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
13
8
import { LeafletContent } from "./LeafletContent";
14
9
import { Tooltip } from "components/Tooltip";
15
-
import { useState } from "react";
16
-
import Link from "next/link";
17
-
import { SpeedyLink } from "components/SpeedyLink";
10
+
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
11
+
import { CSSProperties } from "react";
18
12
19
-
export const LeafletListPreview = (props: {
20
-
draft?: boolean;
21
-
published?: boolean;
22
-
isVisible: boolean;
23
-
token: PermissionToken;
24
-
leaflet_id: string;
25
-
loggedIn: boolean;
26
-
}) => {
27
-
let root =
28
-
useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity ||
29
-
props.leaflet_id;
30
-
let firstPage = useEntity(root, "root/page")[0];
31
-
let page = firstPage?.data.value || root;
13
+
function useLeafletPreviewData() {
14
+
const pubStatus = useLeafletPublicationStatus();
15
+
const leafletId = pubStatus?.leafletId ?? "";
16
+
const root =
17
+
useReferenceToEntity("root/page", leafletId)[0]?.entity || leafletId;
18
+
const firstPage = useEntity(root, "root/page")[0];
19
+
const page = firstPage?.data.value || root;
32
20
33
-
let cardBorderHidden = useCardBorderHidden(root);
34
-
let rootBackgroundImage = useEntity(root, "theme/card-background-image");
35
-
let rootBackgroundRepeat = useEntity(
21
+
const cardBorderHidden = useCardBorderHidden(root);
22
+
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
23
+
const rootBackgroundRepeat = useEntity(
36
24
root,
37
25
"theme/card-background-image-repeat",
38
26
);
39
-
let rootBackgroundOpacity = useEntity(
27
+
const rootBackgroundOpacity = useEntity(
40
28
root,
41
29
"theme/card-background-image-opacity",
42
30
);
43
31
32
+
const contentWrapperStyle: CSSProperties = cardBorderHidden
33
+
? {}
34
+
: {
35
+
backgroundImage: rootBackgroundImage
36
+
? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})`
37
+
: undefined,
38
+
backgroundRepeat: rootBackgroundRepeat ? "repeat" : "no-repeat",
39
+
backgroundPosition: "center",
40
+
backgroundSize: !rootBackgroundRepeat
41
+
? "cover"
42
+
: rootBackgroundRepeat?.data.value / 3,
43
+
opacity:
44
+
rootBackgroundImage?.data.src && rootBackgroundOpacity
45
+
? rootBackgroundOpacity.data.value
46
+
: 1,
47
+
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))",
48
+
};
49
+
50
+
const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`;
51
+
52
+
return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass };
53
+
}
54
+
55
+
export const LeafletListPreview = (props: { isVisible: boolean }) => {
56
+
const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } =
57
+
useLeafletPreviewData();
58
+
44
59
return (
45
60
<Tooltip
46
61
open={true}
···
77
92
<ThemeProvider local entityID={root} className="rounded-sm">
78
93
<ThemeBackgroundProvider entityID={root}>
79
94
<div className="leafletPreview grow shrink-0 h-44 w-64 px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none rounded-[2px] ">
80
-
<div
81
-
className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`}
82
-
style={
83
-
cardBorderHidden
84
-
? {}
85
-
: {
86
-
backgroundImage: rootBackgroundImage
87
-
? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})`
88
-
: undefined,
89
-
backgroundRepeat: rootBackgroundRepeat
90
-
? "repeat"
91
-
: "no-repeat",
92
-
backgroundPosition: "center",
93
-
backgroundSize: !rootBackgroundRepeat
94
-
? "cover"
95
-
: rootBackgroundRepeat?.data.value / 3,
96
-
opacity:
97
-
rootBackgroundImage?.data.src && rootBackgroundOpacity
98
-
? rootBackgroundOpacity.data.value
99
-
: 1,
100
-
backgroundColor:
101
-
"rgba(var(--bg-page), var(--bg-page-alpha))",
102
-
}
103
-
}
104
-
>
95
+
<div className={contentWrapperClass} style={contentWrapperStyle}>
105
96
<LeafletContent entityID={page} isOnScreen={props.isVisible} />
106
97
</div>
107
98
</div>
···
111
102
);
112
103
};
113
104
114
-
export const LeafletGridPreview = (props: {
115
-
draft?: boolean;
116
-
published?: boolean;
117
-
token: PermissionToken;
118
-
leaflet_id: string;
119
-
loggedIn: boolean;
120
-
isVisible: boolean;
121
-
}) => {
122
-
let root =
123
-
useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity ||
124
-
props.leaflet_id;
125
-
let firstPage = useEntity(root, "root/page")[0];
126
-
let page = firstPage?.data.value || root;
105
+
export const LeafletGridPreview = (props: { isVisible: boolean }) => {
106
+
const { root, page, contentWrapperStyle, contentWrapperClass } =
107
+
useLeafletPreviewData();
127
108
128
-
let cardBorderHidden = useCardBorderHidden(root);
129
-
let rootBackgroundImage = useEntity(root, "theme/card-background-image");
130
-
let rootBackgroundRepeat = useEntity(
131
-
root,
132
-
"theme/card-background-image-repeat",
133
-
);
134
-
let rootBackgroundOpacity = useEntity(
135
-
root,
136
-
"theme/card-background-image-opacity",
137
-
);
138
109
return (
139
110
<ThemeProvider local entityID={root} className="w-full!">
140
-
<div className="border border-border-light rounded-md w-full h-full overflow-hidden relative">
141
-
<div className="relative w-full h-full">
111
+
<div className="border border-border-light rounded-md w-full h-full overflow-hidden ">
112
+
<div className="w-full h-full">
142
113
<ThemeBackgroundProvider entityID={root}>
143
114
<div
144
115
inert
145
-
className="leafletPreview relative grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none"
116
+
className="leafletPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none"
146
117
>
147
-
<div
148
-
className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`}
149
-
style={
150
-
cardBorderHidden
151
-
? {}
152
-
: {
153
-
backgroundImage: rootBackgroundImage
154
-
? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})`
155
-
: undefined,
156
-
backgroundRepeat: rootBackgroundRepeat
157
-
? "repeat"
158
-
: "no-repeat",
159
-
backgroundPosition: "center",
160
-
backgroundSize: !rootBackgroundRepeat
161
-
? "cover"
162
-
: rootBackgroundRepeat?.data.value / 3,
163
-
opacity:
164
-
rootBackgroundImage?.data.src && rootBackgroundOpacity
165
-
? rootBackgroundOpacity.data.value
166
-
: 1,
167
-
backgroundColor:
168
-
"rgba(var(--bg-page), var(--bg-page-alpha))",
169
-
}
170
-
}
171
-
>
118
+
<div className={contentWrapperClass} style={contentWrapperStyle}>
172
119
<LeafletContent entityID={page} isOnScreen={props.isVisible} />
173
120
</div>
174
121
</div>
175
122
</ThemeBackgroundProvider>
176
123
</div>
177
-
<LeafletPreviewLink id={props.token.id} />
178
124
</div>
179
125
</ThemeProvider>
180
126
);
181
127
};
182
-
183
-
const LeafletPreviewLink = (props: { id: string }) => {
184
-
return (
185
-
<SpeedyLink
186
-
href={`/${props.id}`}
187
-
className={`hello no-underline sm:hover:no-underline text-primary absolute inset-0 w-full h-full bg-bg-test`}
188
-
/>
189
-
);
190
-
};
+2
-1
app/(home-pages)/home/page.tsx
+2
-1
app/(home-pages)/home/page.tsx
···
29
29
...auth_res?.permission_token_on_homepage.reduce(
30
30
(acc, tok) => {
31
31
let title =
32
-
tok.permission_tokens.leaflets_in_publications[0]?.title;
32
+
tok.permission_tokens.leaflets_in_publications[0]?.title ||
33
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
33
34
if (title) acc[tok.permission_tokens.root_entity] = title;
34
35
return acc;
35
36
},
+116
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
+116
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
+
"use client";
2
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
+
import { useState } from "react";
5
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
+
import { Fact, PermissionToken } from "src/replicache";
7
+
import { Attribute } from "src/replicache/attributes";
8
+
import { Actions } from "../home/Actions/Actions";
9
+
import { callRPC } from "app/api/rpc/client";
10
+
import { useIdentityData } from "components/IdentityProvider";
11
+
import useSWR from "swr";
12
+
import { getHomeDocs } from "../home/storage";
13
+
import { Leaflet, LeafletList } from "../home/HomeLayout";
14
+
15
+
export const LooseleafsLayout = (props: {
16
+
entityID: string | null;
17
+
titles: { [root_entity: string]: string };
18
+
initialFacts: {
19
+
[root_entity: string]: Fact<Attribute>[];
20
+
};
21
+
}) => {
22
+
let [searchValue, setSearchValue] = useState("");
23
+
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
24
+
25
+
useDebouncedEffect(
26
+
() => {
27
+
setDebouncedSearchValue(searchValue);
28
+
},
29
+
200,
30
+
[searchValue],
31
+
);
32
+
33
+
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
34
+
return (
35
+
<DashboardLayout
36
+
id="looseleafs"
37
+
cardBorderHidden={cardBorderHidden}
38
+
currentPage="looseleafs"
39
+
defaultTab="home"
40
+
actions={<Actions />}
41
+
tabs={{
42
+
home: {
43
+
controls: null,
44
+
content: (
45
+
<LooseleafList
46
+
titles={props.titles}
47
+
initialFacts={props.initialFacts}
48
+
cardBorderHidden={cardBorderHidden}
49
+
searchValue={debouncedSearchValue}
50
+
/>
51
+
),
52
+
},
53
+
}}
54
+
/>
55
+
);
56
+
};
57
+
58
+
export const LooseleafList = (props: {
59
+
titles: { [root_entity: string]: string };
60
+
initialFacts: {
61
+
[root_entity: string]: Fact<Attribute>[];
62
+
};
63
+
searchValue: string;
64
+
cardBorderHidden: boolean;
65
+
}) => {
66
+
let { identity } = useIdentityData();
67
+
let { data: initialFacts } = useSWR(
68
+
"home-leaflet-data",
69
+
async () => {
70
+
if (identity) {
71
+
let { result } = await callRPC("getFactsFromHomeLeaflets", {
72
+
tokens: identity.permission_token_on_homepage.map(
73
+
(ptrh) => ptrh.permission_tokens.root_entity,
74
+
),
75
+
});
76
+
let titles = {
77
+
...result.titles,
78
+
...identity.permission_token_on_homepage.reduce(
79
+
(acc, tok) => {
80
+
let title =
81
+
tok.permission_tokens.leaflets_in_publications[0]?.title ||
82
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
83
+
if (title) acc[tok.permission_tokens.root_entity] = title;
84
+
return acc;
85
+
},
86
+
{} as { [k: string]: string },
87
+
),
88
+
};
89
+
return { ...result, titles };
90
+
}
91
+
},
92
+
{ fallbackData: { facts: props.initialFacts, titles: props.titles } },
93
+
);
94
+
95
+
let leaflets: Leaflet[] = identity
96
+
? identity.permission_token_on_homepage
97
+
.filter(
98
+
(ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0,
99
+
)
100
+
.map((ptoh) => ({
101
+
added_at: ptoh.created_at,
102
+
token: ptoh.permission_tokens as PermissionToken,
103
+
}))
104
+
: [];
105
+
return (
106
+
<LeafletList
107
+
defaultDisplay="list"
108
+
searchValue={props.searchValue}
109
+
leaflets={leaflets}
110
+
titles={initialFacts?.titles || {}}
111
+
cardBorderHidden={props.cardBorderHidden}
112
+
initialFacts={initialFacts?.facts || {}}
113
+
showPreview
114
+
/>
115
+
);
116
+
};
+47
app/(home-pages)/looseleafs/page.tsx
+47
app/(home-pages)/looseleafs/page.tsx
···
1
+
import { getIdentityData } from "actions/getIdentityData";
2
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
+
import { Actions } from "../home/Actions/Actions";
4
+
import { Fact } from "src/replicache";
5
+
import { Attribute } from "src/replicache/attributes";
6
+
import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets";
7
+
import { supabaseServerClient } from "supabase/serverClient";
8
+
import { LooseleafsLayout } from "./LooseleafsLayout";
9
+
10
+
export default async function Home() {
11
+
let auth_res = await getIdentityData();
12
+
13
+
let [allLeafletFacts] = await Promise.all([
14
+
auth_res
15
+
? getFactsFromHomeLeaflets.handler(
16
+
{
17
+
tokens: auth_res.permission_token_on_homepage.map(
18
+
(r) => r.permission_tokens.root_entity,
19
+
),
20
+
},
21
+
{ supabase: supabaseServerClient },
22
+
)
23
+
: undefined,
24
+
]);
25
+
26
+
let home_docs_initialFacts = allLeafletFacts?.result || {};
27
+
28
+
return (
29
+
<LooseleafsLayout
30
+
entityID={auth_res?.home_leaflet?.root_entity || null}
31
+
titles={{
32
+
...home_docs_initialFacts.titles,
33
+
...auth_res?.permission_token_on_homepage.reduce(
34
+
(acc, tok) => {
35
+
let title =
36
+
tok.permission_tokens.leaflets_in_publications[0]?.title ||
37
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
38
+
if (title) acc[tok.permission_tokens.root_entity] = title;
39
+
return acc;
40
+
},
41
+
{} as { [k: string]: string },
42
+
),
43
+
}}
44
+
initialFacts={home_docs_initialFacts.facts || {}}
45
+
/>
46
+
);
47
+
}
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
···
1
+
import {
2
+
AppBskyActorProfile,
3
+
PubLeafletComment,
4
+
PubLeafletDocument,
5
+
PubLeafletPublication,
6
+
} from "lexicons/api";
7
+
import { HydratedCommentMentionNotification } from "src/notifications";
8
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
9
+
import { MentionTiny } from "components/Icons/MentionTiny";
10
+
import {
11
+
CommentInNotification,
12
+
ContentLayout,
13
+
Notification,
14
+
} from "./Notification";
15
+
import { AtUri } from "@atproto/api";
16
+
17
+
export const CommentMentionNotification = (
18
+
props: HydratedCommentMentionNotification,
19
+
) => {
20
+
const docRecord = props.commentData.documents
21
+
?.data as PubLeafletDocument.Record;
22
+
const commentRecord = props.commentData.record as PubLeafletComment.Record;
23
+
const profileRecord = props.commentData.bsky_profiles
24
+
?.record as AppBskyActorProfile.Record;
25
+
const pubRecord = props.commentData.documents?.documents_in_publications[0]
26
+
?.publications?.record as PubLeafletPublication.Record | undefined;
27
+
const docUri = new AtUri(props.commentData.documents?.uri!);
28
+
const rkey = docUri.rkey;
29
+
const did = docUri.host;
30
+
31
+
const href = pubRecord
32
+
? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`
33
+
: `/p/${did}/${rkey}?interactionDrawer=comments`;
34
+
35
+
const commenter = props.commenterHandle
36
+
? `@${props.commenterHandle}`
37
+
: "Someone";
38
+
39
+
let actionText: React.ReactNode;
40
+
let mentionedDocRecord = props.mentionedDocument
41
+
?.data as PubLeafletDocument.Record;
42
+
43
+
if (props.mention_type === "did") {
44
+
actionText = <>{commenter} mentioned you in a comment</>;
45
+
} else if (
46
+
props.mention_type === "publication" &&
47
+
props.mentionedPublication
48
+
) {
49
+
const mentionedPubRecord = props.mentionedPublication
50
+
.record as PubLeafletPublication.Record;
51
+
actionText = (
52
+
<>
53
+
{commenter} mentioned your publication{" "}
54
+
<span className="italic">{mentionedPubRecord.name}</span> in a comment
55
+
</>
56
+
);
57
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
58
+
actionText = (
59
+
<>
60
+
{commenter} mentioned your post{" "}
61
+
<span className="italic">{mentionedDocRecord.title}</span> in a comment
62
+
</>
63
+
);
64
+
} else {
65
+
actionText = <>{commenter} mentioned you in a comment</>;
66
+
}
67
+
68
+
return (
69
+
<Notification
70
+
timestamp={props.created_at}
71
+
href={href}
72
+
icon={<MentionTiny />}
73
+
actionText={actionText}
74
+
content={
75
+
<ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}>
76
+
<CommentInNotification
77
+
className=""
78
+
avatar={
79
+
profileRecord?.avatar?.ref &&
80
+
blobRefToSrc(
81
+
profileRecord?.avatar?.ref,
82
+
props.commentData.bsky_profiles?.did || "",
83
+
)
84
+
}
85
+
displayName={
86
+
profileRecord?.displayName ||
87
+
props.commentData.bsky_profiles?.handle ||
88
+
"Someone"
89
+
}
90
+
index={[]}
91
+
plaintext={commentRecord.plaintext}
92
+
facets={commentRecord.facets}
93
+
/>
94
+
</ContentLayout>
95
+
}
96
+
/>
97
+
);
98
+
};
+9
-3
app/(home-pages)/notifications/CommentNotication.tsx
+9
-3
app/(home-pages)/notifications/CommentNotication.tsx
···
27
27
props.commentData.bsky_profiles?.handle ||
28
28
"Someone";
29
29
const pubRecord = props.commentData.documents?.documents_in_publications[0]
30
-
?.publications?.record as PubLeafletPublication.Record;
31
-
let rkey = new AtUri(props.commentData.documents?.uri!).rkey;
30
+
?.publications?.record as PubLeafletPublication.Record | undefined;
31
+
let docUri = new AtUri(props.commentData.documents?.uri!);
32
+
let rkey = docUri.rkey;
33
+
let did = docUri.host;
34
+
35
+
const href = pubRecord
36
+
? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`
37
+
: `/p/${did}/${rkey}?interactionDrawer=comments`;
32
38
33
39
return (
34
40
<Notification
35
41
timestamp={props.commentData.indexed_at}
36
-
href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`}
42
+
href={href}
37
43
icon={<CommentTiny />}
38
44
actionText={<>{displayName} commented on your post</>}
39
45
content={
+55
-40
app/(home-pages)/notifications/MentionNotification.tsx
+55
-40
app/(home-pages)/notifications/MentionNotification.tsx
···
1
1
import { MentionTiny } from "components/Icons/MentionTiny";
2
2
import { ContentLayout, Notification } from "./Notification";
3
+
import { HydratedMentionNotification } from "src/notifications";
4
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
+
import { Agent, AtUri } from "@atproto/api";
3
6
4
-
export const DummyPostMentionNotification = (props: {}) => {
5
-
return (
6
-
<Notification
7
-
timestamp={""}
8
-
href="/"
9
-
icon={<MentionTiny />}
10
-
actionText={<>celine mentioned your post</>}
11
-
content={
12
-
<ContentLayout
13
-
postTitle={"Post Title Here"}
14
-
pubRecord={{ name: "My Publication" } as any}
15
-
>
16
-
I'm just gonna put the description here. The surrounding context is
17
-
just sort of a pain to figure out
18
-
<div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary">
19
-
<div className="font-bold">Title of the Mentioned Post</div>
20
-
<div className="text-tertiary">
21
-
And here is the description that follows it
22
-
</div>
23
-
</div>
24
-
</ContentLayout>
25
-
}
26
-
/>
27
-
);
28
-
};
7
+
export const MentionNotification = (props: HydratedMentionNotification) => {
8
+
const docRecord = props.document.data as PubLeafletDocument.Record;
9
+
const pubRecord = props.document.documents_in_publications?.[0]?.publications
10
+
?.record as PubLeafletPublication.Record | undefined;
11
+
const docUri = new AtUri(props.document.uri);
12
+
const rkey = docUri.rkey;
13
+
const did = docUri.host;
14
+
15
+
const href = pubRecord
16
+
? `https://${pubRecord.base_path}/${rkey}`
17
+
: `/p/${did}/${rkey}`;
18
+
19
+
let actionText: React.ReactNode;
20
+
let mentionedItemName: string | undefined;
21
+
let mentionedDocRecord = props.mentionedDocument
22
+
?.data as PubLeafletDocument.Record;
23
+
24
+
const mentioner = props.documentCreatorHandle
25
+
? `@${props.documentCreatorHandle}`
26
+
: "Someone";
27
+
28
+
if (props.mention_type === "did") {
29
+
actionText = <>{mentioner} mentioned you</>;
30
+
} else if (
31
+
props.mention_type === "publication" &&
32
+
props.mentionedPublication
33
+
) {
34
+
const mentionedPubRecord = props.mentionedPublication
35
+
.record as PubLeafletPublication.Record;
36
+
mentionedItemName = mentionedPubRecord.name;
37
+
actionText = (
38
+
<>
39
+
{mentioner} mentioned your publication{" "}
40
+
<span className="italic">{mentionedItemName}</span>
41
+
</>
42
+
);
43
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
44
+
mentionedItemName = mentionedDocRecord.title;
45
+
actionText = (
46
+
<>
47
+
{mentioner} mentioned your post{" "}
48
+
<span className="italic">{mentionedItemName}</span>
49
+
</>
50
+
);
51
+
} else {
52
+
actionText = <>{mentioner} mentioned you</>;
53
+
}
29
54
30
-
export const DummyUserMentionNotification = (props: {
31
-
cardBorderHidden: boolean;
32
-
}) => {
33
55
return (
34
56
<Notification
35
-
timestamp={""}
36
-
href="/"
57
+
timestamp={props.created_at}
58
+
href={href}
37
59
icon={<MentionTiny />}
38
-
actionText={<>celine mentioned you</>}
60
+
actionText={actionText}
39
61
content={
40
-
<ContentLayout
41
-
postTitle={"Post Title Here"}
42
-
pubRecord={{ name: "My Publication" } as any}
43
-
>
44
-
<div>
45
-
...llo this is the content of a post or whatever here it comes{" "}
46
-
<span className="text-accent-contrast">@celine </span> and here it
47
-
was! ooooh heck yeah the high is unre...
48
-
</div>
62
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
63
+
{docRecord.description && docRecord.description}
49
64
</ContentLayout>
50
65
}
51
66
/>
+3
-3
app/(home-pages)/notifications/Notification.tsx
+3
-3
app/(home-pages)/notifications/Notification.tsx
···
69
69
<div
70
70
className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`}
71
71
>
72
-
<div className="text-tertiary text-sm italic font-bold pb-1">
72
+
<div className="text-tertiary text-sm italic font-bold ">
73
73
{props.postTitle}
74
74
</div>
75
-
{props.children}
75
+
{props.children && <div className="mb-2 text-sm">{props.children}</div>}
76
76
{props.pubRecord && (
77
77
<>
78
-
<hr className="mt-3 mb-1 border-border-light" />
78
+
<hr className="mt-1 mb-1 border-border-light" />
79
79
<a
80
80
href={`https://${props.pubRecord.base_path}`}
81
81
className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+13
-1
app/(home-pages)/notifications/NotificationList.tsx
+13
-1
app/(home-pages)/notifications/NotificationList.tsx
···
7
7
import { ReplyNotification } from "./ReplyNotification";
8
8
import { useIdentityData } from "components/IdentityProvider";
9
9
import { FollowNotification } from "./FollowNotification";
10
+
import { QuoteNotification } from "./QuoteNotification";
11
+
import { MentionNotification } from "./MentionNotification";
12
+
import { CommentMentionNotification } from "./CommentMentionNotification";
10
13
11
14
export function NotificationList({
12
15
notifications,
···
23
26
}, 500);
24
27
}, []);
25
28
26
-
if (notifications.length !== 0)
29
+
if (notifications.length === 0)
27
30
return (
28
31
<div className="w-full text-sm flex flex-col gap-1 container italic text-tertiary text-center sm:p-4 p-3">
29
32
<div className="text-base font-bold">no notifications yet...</div>
···
41
44
}
42
45
if (n.type === "subscribe") {
43
46
return <FollowNotification key={n.id} {...n} />;
47
+
}
48
+
if (n.type === "quote") {
49
+
return <QuoteNotification key={n.id} {...n} />;
50
+
}
51
+
if (n.type === "mention") {
52
+
return <MentionNotification key={n.id} {...n} />;
53
+
}
54
+
if (n.type === "comment_mention") {
55
+
return <CommentMentionNotification key={n.id} {...n} />;
44
56
}
45
57
})}
46
58
</div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
+48
app/(home-pages)/notifications/QuoteNotification.tsx
···
1
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
2
+
import { ContentLayout, Notification } from "./Notification";
3
+
import { HydratedQuoteNotification } from "src/notifications";
4
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
+
import { AtUri } from "@atproto/api";
6
+
import { Avatar } from "components/Avatar";
7
+
8
+
export const QuoteNotification = (props: HydratedQuoteNotification) => {
9
+
const postView = props.bskyPost.post_view as any;
10
+
const author = postView.author;
11
+
const displayName = author.displayName || author.handle || "Someone";
12
+
const docRecord = props.document.data as PubLeafletDocument.Record;
13
+
const pubRecord = props.document.documents_in_publications[0]?.publications
14
+
?.record as PubLeafletPublication.Record | undefined;
15
+
const docUri = new AtUri(props.document.uri);
16
+
const rkey = docUri.rkey;
17
+
const did = docUri.host;
18
+
const postText = postView.record?.text || "";
19
+
20
+
const href = pubRecord
21
+
? `https://${pubRecord.base_path}/${rkey}`
22
+
: `/p/${did}/${rkey}`;
23
+
24
+
return (
25
+
<Notification
26
+
timestamp={props.created_at}
27
+
href={href}
28
+
icon={<QuoteTiny />}
29
+
actionText={<>{displayName} quoted your post</>}
30
+
content={
31
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
+
<div className="flex gap-2 text-sm w-full">
33
+
<Avatar
34
+
src={author.avatar}
35
+
displayName={displayName}
36
+
/>
37
+
<pre
38
+
style={{ wordBreak: "break-word" }}
39
+
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
40
+
>
41
+
{postText}
42
+
</pre>
43
+
</div>
44
+
</ContentLayout>
45
+
}
46
+
/>
47
+
);
48
+
};
+9
-3
app/(home-pages)/notifications/ReplyNotification.tsx
+9
-3
app/(home-pages)/notifications/ReplyNotification.tsx
···
34
34
props.parentData?.bsky_profiles?.handle ||
35
35
"Someone";
36
36
37
-
let rkey = new AtUri(props.commentData.documents?.uri!).rkey;
37
+
let docUri = new AtUri(props.commentData.documents?.uri!);
38
+
let rkey = docUri.rkey;
39
+
let did = docUri.host;
38
40
const pubRecord = props.commentData.documents?.documents_in_publications[0]
39
-
?.publications?.record as PubLeafletPublication.Record;
41
+
?.publications?.record as PubLeafletPublication.Record | undefined;
42
+
43
+
const href = pubRecord
44
+
? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`
45
+
: `/p/${did}/${rkey}?interactionDrawer=comments`;
40
46
41
47
return (
42
48
<Notification
43
49
timestamp={props.commentData.indexed_at}
44
-
href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`}
50
+
href={href}
45
51
icon={<ReplyTiny />}
46
52
actionText={`${displayName} replied to your comment`}
47
53
content={
+7
-192
app/(home-pages)/reader/ReaderContent.tsx
+7
-192
app/(home-pages)/reader/ReaderContent.tsx
···
1
1
"use client";
2
-
import { AtUri } from "@atproto/api";
3
-
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
-
import { PubIcon } from "components/ActionBar/Publications";
5
2
import { ButtonPrimary } from "components/Buttons";
6
-
import { CommentTiny } from "components/Icons/CommentTiny";
7
3
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
8
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
9
-
import { Separator } from "components/Layout";
10
-
import { SpeedyLink } from "components/SpeedyLink";
11
-
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
12
-
import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
13
-
import { useSmoker } from "components/Toast";
14
-
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
15
-
import { blobRefToSrc } from "src/utils/blobRefToSrc";
16
-
import { Json } from "supabase/database.types";
17
4
import type { Cursor, Post } from "./getReaderFeed";
18
5
import useSWRInfinite from "swr/infinite";
19
6
import { getReaderFeed } from "./getReaderFeed";
20
7
import { useEffect, useRef } from "react";
21
-
import { useRouter } from "next/navigation";
22
8
import Link from "next/link";
23
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
9
+
import { PostListing } from "components/PostListing";
24
10
25
11
export const ReaderContent = (props: {
26
12
posts: Post[];
···
28
14
}) => {
29
15
const getKey = (
30
16
pageIndex: number,
31
-
previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
32
21
) => {
33
22
// Reached the end
34
23
if (previousPageData && !previousPageData.nextCursor) return null;
···
40
29
return ["reader-feed", previousPageData?.nextCursor] as const;
41
30
};
42
31
43
-
const { data, error, size, setSize, isValidating } = useSWRInfinite(
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
44
33
getKey,
45
34
([_, cursor]) => getReaderFeed(cursor),
46
35
{
···
79
68
return (
80
69
<div className="flex flex-col gap-3 relative">
81
70
{allPosts.map((p) => (
82
-
<Post {...p} key={p.documents.uri} />
71
+
<PostListing {...p} key={p.documents.uri} />
83
72
))}
84
73
{/* Trigger element for loading more posts */}
85
74
<div
···
96
85
);
97
86
};
98
87
99
-
const Post = (props: Post) => {
100
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
101
-
102
-
let postRecord = props.documents.data as PubLeafletDocument.Record;
103
-
let postUri = new AtUri(props.documents.uri);
104
-
105
-
let theme = usePubTheme(pubRecord);
106
-
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
107
-
? blobRefToSrc(
108
-
pubRecord?.theme?.backgroundImage?.image?.ref,
109
-
new AtUri(props.publication.uri).host,
110
-
)
111
-
: null;
112
-
113
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
114
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
115
-
116
-
let showPageBackground = pubRecord.theme?.showPageBackground;
117
-
118
-
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
119
-
let comments =
120
-
pubRecord.preferences?.showComments === false
121
-
? 0
122
-
: props.documents.comments_on_documents?.[0]?.count || 0;
123
-
124
-
return (
125
-
<BaseThemeProvider {...theme} local>
126
-
<div
127
-
style={{
128
-
backgroundImage: `url(${backgroundImage})`,
129
-
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
130
-
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
131
-
}}
132
-
className={`no-underline! flex flex-row gap-2 w-full relative
133
-
bg-bg-leaflet
134
-
border border-border-light rounded-lg
135
-
sm:p-2 p-2 selected-outline
136
-
hover:outline-accent-contrast hover:border-accent-contrast
137
-
`}
138
-
>
139
-
<a
140
-
className="h-full w-full absolute top-0 left-0"
141
-
href={`${props.publication.href}/${postUri.rkey}`}
142
-
/>
143
-
<div
144
-
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
145
-
style={{
146
-
backgroundColor: showPageBackground
147
-
? "rgba(var(--bg-page), var(--bg-page-alpha))"
148
-
: "transparent",
149
-
}}
150
-
>
151
-
<h3 className="text-primary truncate">{postRecord.title}</h3>
152
-
153
-
<p className="text-secondary">{postRecord.description}</p>
154
-
<div className="flex gap-2 justify-between items-end">
155
-
<div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3">
156
-
<PubInfo
157
-
href={props.publication.href}
158
-
pubRecord={pubRecord}
159
-
uri={props.publication.uri}
160
-
/>
161
-
<Separator classname="h-4 !min-h-0 md:block hidden" />
162
-
<PostInfo
163
-
author={props.author || ""}
164
-
publishedAt={postRecord.publishedAt}
165
-
/>
166
-
</div>
167
-
168
-
<PostInterations
169
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
170
-
quotesCount={quotes}
171
-
commentsCount={comments}
172
-
showComments={pubRecord.preferences?.showComments}
173
-
/>
174
-
</div>
175
-
</div>
176
-
</div>
177
-
</BaseThemeProvider>
178
-
);
179
-
};
180
-
181
-
const PubInfo = (props: {
182
-
href: string;
183
-
pubRecord: PubLeafletPublication.Record;
184
-
uri: string;
185
-
}) => {
186
-
return (
187
-
<a
188
-
href={props.href}
189
-
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0"
190
-
>
191
-
<PubIcon small record={props.pubRecord} uri={props.uri} />
192
-
{props.pubRecord.name}
193
-
</a>
194
-
);
195
-
};
196
-
197
-
const PostInfo = (props: {
198
-
author: string;
199
-
publishedAt: string | undefined;
200
-
}) => {
201
-
const formattedDate = useLocalizedDate(
202
-
props.publishedAt || new Date().toISOString(),
203
-
{
204
-
year: "numeric",
205
-
month: "short",
206
-
day: "numeric",
207
-
},
208
-
);
209
-
210
-
return (
211
-
<div className="flex flex-wrap gap-2 grow items-center shrink-0">
212
-
{props.author}
213
-
{props.publishedAt && (
214
-
<>
215
-
<Separator classname="h-4 !min-h-0" />
216
-
{formattedDate}{" "}
217
-
</>
218
-
)}
219
-
</div>
220
-
);
221
-
};
222
-
223
-
const PostInterations = (props: {
224
-
quotesCount: number;
225
-
commentsCount: number;
226
-
postUrl: string;
227
-
showComments: boolean | undefined;
228
-
}) => {
229
-
let smoker = useSmoker();
230
-
let interactionsAvailable =
231
-
props.quotesCount > 0 ||
232
-
(props.showComments !== false && props.commentsCount > 0);
233
-
234
-
return (
235
-
<div className={`flex gap-2 text-tertiary text-sm items-center`}>
236
-
{props.quotesCount === 0 ? null : (
237
-
<div className={`flex gap-1 items-center `} aria-label="Post quotes">
238
-
<QuoteTiny aria-hidden /> {props.quotesCount}
239
-
</div>
240
-
)}
241
-
{props.showComments === false || props.commentsCount === 0 ? null : (
242
-
<div className={`flex gap-1 items-center`} aria-label="Post comments">
243
-
<CommentTiny aria-hidden /> {props.commentsCount}
244
-
</div>
245
-
)}
246
-
{interactionsAvailable && <Separator classname="h-4 !min-h-0" />}
247
-
<button
248
-
id={`copy-post-link-${props.postUrl}`}
249
-
className="flex gap-1 items-center hover:font-bold relative"
250
-
onClick={(e) => {
251
-
e.stopPropagation();
252
-
e.preventDefault();
253
-
let mouseX = e.clientX;
254
-
let mouseY = e.clientY;
255
-
256
-
if (!props.postUrl) return;
257
-
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
258
-
259
-
smoker({
260
-
text: <strong>Copied Link!</strong>,
261
-
position: {
262
-
y: mouseY,
263
-
x: mouseX,
264
-
},
265
-
});
266
-
}}
267
-
>
268
-
Share
269
-
</button>
270
-
</div>
271
-
);
272
-
};
273
88
export const ReaderEmpty = () => {
274
89
return (
275
90
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
+68
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
+68
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
1
+
"use server";
2
+
3
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
+
import { AtUri } from "@atproto/api";
6
+
import { Json } from "supabase/database.types";
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
9
+
10
+
export async function getDocumentsByTag(
11
+
tag: string,
12
+
): Promise<{ posts: Post[] }> {
13
+
// Query documents that have this tag
14
+
const { data: documents, error } = await supabaseServerClient
15
+
.from("documents")
16
+
.select(
17
+
`*,
18
+
comments_on_documents(count),
19
+
document_mentions_in_bsky(count),
20
+
documents_in_publications(publications(*))`,
21
+
)
22
+
.contains("data->tags", `["${tag}"]`)
23
+
.order("indexed_at", { ascending: false })
24
+
.limit(50);
25
+
26
+
if (error) {
27
+
console.error("Error fetching documents by tag:", error);
28
+
return { posts: [] };
29
+
}
30
+
31
+
const posts = await Promise.all(
32
+
documents.map(async (doc) => {
33
+
const pub = doc.documents_in_publications[0]?.publications;
34
+
35
+
// Skip if document doesn't have a publication
36
+
if (!pub) {
37
+
return null;
38
+
}
39
+
40
+
const uri = new AtUri(doc.uri);
41
+
const handle = await idResolver.did.resolve(uri.host);
42
+
43
+
const post: Post = {
44
+
publication: {
45
+
href: getPublicationURL(pub),
46
+
pubRecord: pub?.record || null,
47
+
uri: pub?.uri || "",
48
+
},
49
+
author: handle?.alsoKnownAs?.[0]
50
+
? `@${handle.alsoKnownAs[0].slice(5)}`
51
+
: null,
52
+
documents: {
53
+
comments_on_documents: doc.comments_on_documents,
54
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
55
+
data: doc.data,
56
+
uri: doc.uri,
57
+
indexed_at: doc.indexed_at,
58
+
},
59
+
};
60
+
return post;
61
+
}),
62
+
);
63
+
64
+
// Filter out null entries (documents without publications)
65
+
return {
66
+
posts: posts.filter((p): p is Post => p !== null),
67
+
};
68
+
}
+75
app/(home-pages)/tag/[tag]/page.tsx
+75
app/(home-pages)/tag/[tag]/page.tsx
···
1
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
2
+
import { Tag } from "components/Tags";
3
+
import { PostListing } from "components/PostListing";
4
+
import { getDocumentsByTag } from "./getDocumentsByTag";
5
+
import { TagTiny } from "components/Icons/TagTiny";
6
+
7
+
export default async function TagPage(props: {
8
+
params: Promise<{ tag: string }>;
9
+
}) {
10
+
const params = await props.params;
11
+
const decodedTag = decodeURIComponent(params.tag);
12
+
const { posts } = await getDocumentsByTag(decodedTag);
13
+
14
+
return (
15
+
<DashboardLayout
16
+
id="tag"
17
+
cardBorderHidden={false}
18
+
currentPage="tag"
19
+
defaultTab="default"
20
+
actions={null}
21
+
tabs={{
22
+
default: {
23
+
controls: null,
24
+
content: <TagContent tag={decodedTag} posts={posts} />,
25
+
},
26
+
}}
27
+
/>
28
+
);
29
+
}
30
+
31
+
const TagContent = (props: {
32
+
tag: string;
33
+
posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"];
34
+
}) => {
35
+
return (
36
+
<div className="max-w-prose mx-auto w-full grow shrink-0">
37
+
<div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4">
38
+
<TagHeader tag={props.tag} postCount={props.posts.length} />
39
+
</div>
40
+
<div className="pt-6 flex flex-col gap-3">
41
+
{props.posts.length === 0 ? (
42
+
<EmptyState tag={props.tag} />
43
+
) : (
44
+
props.posts.map((post) => (
45
+
<PostListing key={post.documents.uri} {...post} />
46
+
))
47
+
)}
48
+
</div>
49
+
</div>
50
+
);
51
+
};
52
+
53
+
const TagHeader = (props: { tag: string; postCount: number }) => {
54
+
return (
55
+
<div className="flex flex-col leading-tight items-center">
56
+
<div className="flex items-center gap-3 text-xl font-bold text-primary">
57
+
<TagTiny className="scale-150" />
58
+
<h1>{props.tag}</h1>
59
+
</div>
60
+
<div className="text-tertiary text-sm">
61
+
{props.postCount} {props.postCount === 1 ? "post" : "posts"}
62
+
</div>
63
+
</div>
64
+
);
65
+
};
66
+
67
+
const EmptyState = (props: { tag: string }) => {
68
+
return (
69
+
<div className="flex flex-col gap-2 items-center justify-center p-8 text-center">
70
+
<div className="text-tertiary">
71
+
No posts found with the tag "{props.tag}"
72
+
</div>
73
+
</div>
74
+
);
75
+
};
-98
app/[leaflet_id]/Actions.tsx
-98
app/[leaflet_id]/Actions.tsx
···
1
-
import { publishToPublication } from "actions/publishToPublication";
2
-
import {
3
-
getBasePublicationURL,
4
-
getPublicationURL,
5
-
} from "app/lish/createPub/getPublicationURL";
6
-
import { ActionButton } from "components/ActionBar/ActionButton";
7
-
import { GoBackSmall } from "components/Icons/GoBackSmall";
8
-
import { PublishSmall } from "components/Icons/PublishSmall";
9
-
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
10
-
import { SpeedyLink } from "components/SpeedyLink";
11
-
import { useToaster } from "components/Toast";
12
-
import { DotLoader } from "components/utils/DotLoader";
13
-
import { useParams, useRouter } from "next/navigation";
14
-
import { useState } from "react";
15
-
import { useReplicache } from "src/replicache";
16
-
import { Json } from "supabase/database.types";
17
-
18
-
export const BackToPubButton = (props: {
19
-
publication: {
20
-
identity_did: string;
21
-
indexed_at: string;
22
-
name: string;
23
-
record: Json;
24
-
uri: string;
25
-
};
26
-
}) => {
27
-
return (
28
-
<SpeedyLink
29
-
href={`${getBasePublicationURL(props.publication)}/dashboard`}
30
-
className="hover:no-underline!"
31
-
>
32
-
<ActionButton
33
-
icon={<GoBackSmall className="shrink-0" />}
34
-
label="To Pub"
35
-
/>
36
-
</SpeedyLink>
37
-
);
38
-
};
39
-
40
-
export const PublishButton = () => {
41
-
let { data: pub } = useLeafletPublicationData();
42
-
let params = useParams();
43
-
let router = useRouter();
44
-
if (!pub?.doc)
45
-
return (
46
-
<ActionButton
47
-
primary
48
-
icon={<PublishSmall className="shrink-0" />}
49
-
label={"Publish!"}
50
-
onClick={() => {
51
-
router.push(`/${params.leaflet_id}/publish`);
52
-
}}
53
-
/>
54
-
);
55
-
56
-
return <UpdateButton />;
57
-
};
58
-
59
-
const UpdateButton = () => {
60
-
let [isLoading, setIsLoading] = useState(false);
61
-
let { data: pub, mutate } = useLeafletPublicationData();
62
-
let { permission_token, rootEntity } = useReplicache();
63
-
let toaster = useToaster();
64
-
65
-
return (
66
-
<ActionButton
67
-
primary
68
-
icon={<PublishSmall className="shrink-0" />}
69
-
label={isLoading ? <DotLoader /> : "Update!"}
70
-
onClick={async () => {
71
-
if (!pub || !pub.publications) return;
72
-
setIsLoading(true);
73
-
let doc = await publishToPublication({
74
-
root_entity: rootEntity,
75
-
publication_uri: pub.publications.uri,
76
-
leaflet_id: permission_token.id,
77
-
title: pub.title,
78
-
description: pub.description,
79
-
});
80
-
setIsLoading(false);
81
-
mutate();
82
-
toaster({
83
-
content: (
84
-
<div>
85
-
{pub.doc ? "Updated! " : "Published! "}
86
-
<SpeedyLink
87
-
href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`}
88
-
>
89
-
link
90
-
</SpeedyLink>
91
-
</div>
92
-
),
93
-
type: "success",
94
-
});
95
-
}}
96
-
/>
97
-
);
98
-
};
+12
-28
app/[leaflet_id]/Sidebar.tsx
+12
-28
app/[leaflet_id]/Sidebar.tsx
···
1
1
"use client";
2
-
import { ActionButton } from "components/ActionBar/ActionButton";
3
2
import { Sidebar } from "components/ActionBar/Sidebar";
4
3
import { useEntitySetContext } from "components/EntitySetProvider";
5
-
import { HelpPopover } from "components/HelpPopover";
6
-
import { HomeButton } from "components/HomeButton";
4
+
import { HelpButton } from "app/[leaflet_id]/actions/HelpButton";
5
+
import { HomeButton } from "app/[leaflet_id]/actions/HomeButton";
7
6
import { Media } from "components/Media";
8
7
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
9
-
import { ShareOptions } from "components/ShareOptions";
8
+
import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions";
10
9
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
10
+
import { PublishButton } from "./actions/PublishButton";
11
11
import { Watermark } from "components/Watermark";
12
-
import { useUIState } from "src/useUIState";
13
-
import { BackToPubButton, PublishButton } from "./Actions";
12
+
import { BackToPubButton } from "./actions/BackToPubButton";
14
13
import { useIdentityData } from "components/IdentityProvider";
15
14
import { useReplicache } from "src/replicache";
16
15
···
29
28
<div className="sidebarContainer flex flex-col justify-end h-full w-16 relative">
30
29
{entity_set.permissions.write && (
31
30
<Sidebar>
31
+
<PublishButton entityID={rootEntity} />
32
+
<ShareOptions />
33
+
<ThemePopover entityID={rootEntity} />
34
+
<HelpButton />
35
+
<hr className="text-border" />
32
36
{pub?.publications &&
33
37
identity?.atp_did &&
34
38
pub.publications.identity_did === identity.atp_did ? (
35
-
<>
36
-
<PublishButton />
37
-
<ShareOptions />
38
-
<ThemePopover entityID={rootEntity} />
39
-
<HelpPopover />
40
-
<hr className="text-border" />
41
-
<BackToPubButton publication={pub.publications} />
42
-
</>
39
+
<BackToPubButton publication={pub.publications} />
43
40
) : (
44
-
<>
45
-
<ShareOptions />
46
-
<ThemePopover entityID={rootEntity} />
47
-
<HelpPopover />
48
-
<hr className="text-border" />
49
-
<HomeButton />
50
-
</>
41
+
<HomeButton />
51
42
)}
52
43
</Sidebar>
53
44
)}
···
59
50
</Media>
60
51
);
61
52
}
62
-
63
-
const blurPage = () => {
64
-
useUIState.setState(() => ({
65
-
focusedEntity: null,
66
-
selectedBlocks: [],
67
-
}));
68
-
};
+27
app/[leaflet_id]/actions/BackToPubButton.tsx
+27
app/[leaflet_id]/actions/BackToPubButton.tsx
···
1
+
import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL";
2
+
import { ActionButton } from "components/ActionBar/ActionButton";
3
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
4
+
import { SpeedyLink } from "components/SpeedyLink";
5
+
import { Json } from "supabase/database.types";
6
+
7
+
export const BackToPubButton = (props: {
8
+
publication: {
9
+
identity_did: string;
10
+
indexed_at: string;
11
+
name: string;
12
+
record: Json;
13
+
uri: string;
14
+
};
15
+
}) => {
16
+
return (
17
+
<SpeedyLink
18
+
href={`${getBasePublicationURL(props.publication)}/dashboard`}
19
+
className="hover:no-underline!"
20
+
>
21
+
<ActionButton
22
+
icon={<GoBackSmall className="shrink-0" />}
23
+
label="To Pub"
24
+
/>
25
+
</SpeedyLink>
26
+
);
27
+
};
+173
app/[leaflet_id]/actions/HelpButton.tsx
+173
app/[leaflet_id]/actions/HelpButton.tsx
···
1
+
"use client";
2
+
import { ShortcutKey } from "../../../components/Layout";
3
+
import { Media } from "../../../components/Media";
4
+
import { Popover } from "../../../components/Popover";
5
+
import { metaKey } from "src/utils/metaKey";
6
+
import { useEntitySetContext } from "../../../components/EntitySetProvider";
7
+
import { useState } from "react";
8
+
import { ActionButton } from "components/ActionBar/ActionButton";
9
+
import { HelpSmall } from "../../../components/Icons/HelpSmall";
10
+
import { isMac } from "src/utils/isDevice";
11
+
import { useIsMobile } from "src/hooks/isMobile";
12
+
13
+
export const HelpButton = (props: { noShortcuts?: boolean }) => {
14
+
let entity_set = useEntitySetContext();
15
+
let isMobile = useIsMobile();
16
+
17
+
return entity_set.permissions.write ? (
18
+
<Popover
19
+
side={isMobile ? "top" : "right"}
20
+
align={isMobile ? "center" : "start"}
21
+
asChild
22
+
className="max-w-xs w-full"
23
+
trigger={<ActionButton icon={<HelpSmall />} label="About" />}
24
+
>
25
+
<div className="flex flex-col text-sm gap-2 text-secondary">
26
+
{/* about links */}
27
+
<HelpLink text="๐ Leaflet Manual" url="https://about.leaflet.pub" />
28
+
<HelpLink text="๐ก Make with Leaflet" url="https://make.leaflet.pub" />
29
+
<HelpLink
30
+
text="โจ Explore Publications"
31
+
url="https://leaflet.pub/discover"
32
+
/>
33
+
<HelpLink text="๐ฃ Newsletter" url="https://buttondown.com/leaflet" />
34
+
{/* contact links */}
35
+
<div className="columns-2 gap-2">
36
+
<HelpLink
37
+
text="๐ฆ Bluesky"
38
+
url="https://bsky.app/profile/leaflet.pub"
39
+
/>
40
+
<HelpLink text="๐ Email" url="mailto:contact@leaflet.pub" />
41
+
</div>
42
+
{/* keyboard shortcuts: desktop only */}
43
+
<Media mobile={false}>
44
+
{!props.noShortcuts && (
45
+
<>
46
+
<hr className="text-border my-1" />
47
+
<div className="flex flex-col gap-1">
48
+
<Label>Text Shortcuts</Label>
49
+
<KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} />
50
+
<KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} />
51
+
<KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} />
52
+
<KeyboardShortcut
53
+
name="Highlight"
54
+
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]}
55
+
/>
56
+
<KeyboardShortcut
57
+
name="Strikethrough"
58
+
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]}
59
+
/>
60
+
<KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} />
61
+
62
+
<Label>Block Shortcuts</Label>
63
+
{/* shift + up/down arrows (or click + drag): select multiple blocks */}
64
+
<KeyboardShortcut
65
+
name="Move Block Up"
66
+
keys={["Shift", metaKey(), "โ"]}
67
+
/>
68
+
<KeyboardShortcut
69
+
name="Move Block Down"
70
+
keys={["Shift", metaKey(), "โ"]}
71
+
/>
72
+
{/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */}
73
+
{/* cmd/ctrl + up/down arrows: go to beginning / end of doc */}
74
+
75
+
<Label>Canvas Shortcuts</Label>
76
+
<OtherShortcut name="Add Block" description="Double click" />
77
+
<OtherShortcut name="Select Block" description="Long press" />
78
+
79
+
<Label>Outliner Shortcuts</Label>
80
+
<KeyboardShortcut
81
+
name="Make List"
82
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]}
83
+
/>
84
+
{/* tab / shift + tab: indent / outdent */}
85
+
<KeyboardShortcut
86
+
name="Toggle Checkbox"
87
+
keys={[metaKey(), "Enter"]}
88
+
/>
89
+
<KeyboardShortcut
90
+
name="Toggle Fold"
91
+
keys={[metaKey(), "Shift", "Enter"]}
92
+
/>
93
+
<KeyboardShortcut
94
+
name="Fold All"
95
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ"]}
96
+
/>
97
+
<KeyboardShortcut
98
+
name="Unfold All"
99
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ"]}
100
+
/>
101
+
</div>
102
+
</>
103
+
)}
104
+
</Media>
105
+
{/* links: terms and privacy */}
106
+
<hr className="text-border my-1" />
107
+
{/* <HelpLink
108
+
text="Terms and Privacy Policy"
109
+
url="https://leaflet.pub/legal"
110
+
/> */}
111
+
<div>
112
+
<a href="https://leaflet.pub/legal" target="_blank">
113
+
Terms and Privacy Policy
114
+
</a>
115
+
</div>
116
+
</div>
117
+
</Popover>
118
+
) : null;
119
+
};
120
+
121
+
const KeyboardShortcut = (props: { name: string; keys: string[] }) => {
122
+
return (
123
+
<div className="flex gap-2 justify-between items-center">
124
+
{props.name}
125
+
<div className="flex gap-1 items-center font-bold">
126
+
{props.keys.map((key, index) => {
127
+
return <ShortcutKey key={index}>{key}</ShortcutKey>;
128
+
})}
129
+
</div>
130
+
</div>
131
+
);
132
+
};
133
+
134
+
const OtherShortcut = (props: { name: string; description: string }) => {
135
+
return (
136
+
<div className="flex justify-between items-center">
137
+
<span>{props.name}</span>
138
+
<span>
139
+
<strong>{props.description}</strong>
140
+
</span>
141
+
</div>
142
+
);
143
+
};
144
+
145
+
const Label = (props: { children: React.ReactNode }) => {
146
+
return <div className="text-tertiary font-bold pt-2 ">{props.children}</div>;
147
+
};
148
+
149
+
const HelpLink = (props: { url: string; text: string }) => {
150
+
const [isHovered, setIsHovered] = useState(false);
151
+
const handleMouseEnter = () => {
152
+
setIsHovered(true);
153
+
};
154
+
const handleMouseLeave = () => {
155
+
setIsHovered(false);
156
+
};
157
+
return (
158
+
<a
159
+
href={props.url}
160
+
target="_blank"
161
+
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
162
+
style={{
163
+
backgroundColor: isHovered
164
+
? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)"
165
+
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
+
}}
167
+
onMouseEnter={handleMouseEnter}
168
+
onMouseLeave={handleMouseLeave}
169
+
>
170
+
<strong>{props.text}</strong>
171
+
</a>
172
+
);
173
+
};
+74
app/[leaflet_id]/actions/HomeButton.tsx
+74
app/[leaflet_id]/actions/HomeButton.tsx
···
1
+
"use client";
2
+
import Link from "next/link";
3
+
import { useEntitySetContext } from "../../../components/EntitySetProvider";
4
+
import { ActionButton } from "components/ActionBar/ActionButton";
5
+
import { useSearchParams } from "next/navigation";
6
+
import { useIdentityData } from "../../../components/IdentityProvider";
7
+
import { useReplicache } from "src/replicache";
8
+
import { addLeafletToHome } from "actions/addLeafletToHome";
9
+
import { useSmoker } from "../../../components/Toast";
10
+
import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall";
11
+
import { HomeSmall } from "../../../components/Icons/HomeSmall";
12
+
import { produce } from "immer";
13
+
14
+
export function HomeButton() {
15
+
let { permissions } = useEntitySetContext();
16
+
let searchParams = useSearchParams();
17
+
18
+
return (
19
+
<>
20
+
<Link
21
+
href="/home"
22
+
prefetch
23
+
className="hover:no-underline"
24
+
style={{ textDecorationLine: "none !important" }}
25
+
>
26
+
<ActionButton icon={<HomeSmall />} label="Go Home" />
27
+
</Link>
28
+
{<AddToHomeButton />}
29
+
</>
30
+
);
31
+
}
32
+
33
+
const AddToHomeButton = (props: {}) => {
34
+
let { permission_token } = useReplicache();
35
+
let { identity, mutate } = useIdentityData();
36
+
let smoker = useSmoker();
37
+
if (
38
+
identity?.permission_token_on_homepage.find(
39
+
(pth) => pth.permission_tokens.id === permission_token.id,
40
+
) ||
41
+
!identity
42
+
)
43
+
return null;
44
+
return (
45
+
<ActionButton
46
+
onClick={async (e) => {
47
+
await addLeafletToHome(permission_token.id);
48
+
mutate((identity) => {
49
+
if (!identity) return;
50
+
return produce<typeof identity>((draft) => {
51
+
draft.permission_token_on_homepage.push({
52
+
created_at: new Date().toISOString(),
53
+
archived: null,
54
+
permission_tokens: {
55
+
...permission_token,
56
+
leaflets_to_documents: [],
57
+
leaflets_in_publications: [],
58
+
},
59
+
});
60
+
})(identity);
61
+
});
62
+
smoker({
63
+
position: {
64
+
x: e.clientX + 64,
65
+
y: e.clientY,
66
+
},
67
+
text: "Leaflet added to your home!",
68
+
});
69
+
}}
70
+
icon={<AddToHomeSmall />}
71
+
label="Add to Home"
72
+
/>
73
+
);
74
+
};
+432
app/[leaflet_id]/actions/PublishButton.tsx
+432
app/[leaflet_id]/actions/PublishButton.tsx
···
1
+
"use client";
2
+
import { publishToPublication } from "actions/publishToPublication";
3
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
+
import { ActionButton } from "components/ActionBar/ActionButton";
5
+
import {
6
+
PubIcon,
7
+
PubListEmptyContent,
8
+
PubListEmptyIllo,
9
+
} from "components/ActionBar/Publications";
10
+
import { ButtonPrimary, ButtonTertiary } from "components/Buttons";
11
+
import { AddSmall } from "components/Icons/AddSmall";
12
+
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
13
+
import { PublishSmall } from "components/Icons/PublishSmall";
14
+
import { useIdentityData } from "components/IdentityProvider";
15
+
import { InputWithLabel } from "components/Input";
16
+
import { Menu, MenuItem } from "components/Layout";
17
+
import {
18
+
useLeafletDomains,
19
+
useLeafletPublicationData,
20
+
} from "components/PageSWRDataProvider";
21
+
import { Popover } from "components/Popover";
22
+
import { SpeedyLink } from "components/SpeedyLink";
23
+
import { useToaster } from "components/Toast";
24
+
import { DotLoader } from "components/utils/DotLoader";
25
+
import { PubLeafletPublication } from "lexicons/api";
26
+
import { useParams, useRouter, useSearchParams } from "next/navigation";
27
+
import { useState, useMemo } from "react";
28
+
import { useIsMobile } from "src/hooks/isMobile";
29
+
import { useReplicache, useEntity } from "src/replicache";
30
+
import { useSubscribe } from "src/replicache/useSubscribe";
31
+
import { Json } from "supabase/database.types";
32
+
import {
33
+
useBlocks,
34
+
useCanvasBlocksWithType,
35
+
} from "src/hooks/queries/useBlocks";
36
+
import * as Y from "yjs";
37
+
import * as base64 from "base64-js";
38
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
39
+
import { BlueskyLogin } from "app/login/LoginForm";
40
+
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41
+
import { AddTiny } from "components/Icons/AddTiny";
42
+
43
+
export const PublishButton = (props: { entityID: string }) => {
44
+
let { data: pub } = useLeafletPublicationData();
45
+
let params = useParams();
46
+
let router = useRouter();
47
+
48
+
if (!pub) return <PublishToPublicationButton entityID={props.entityID} />;
49
+
if (!pub?.doc)
50
+
return (
51
+
<ActionButton
52
+
primary
53
+
icon={<PublishSmall className="shrink-0" />}
54
+
label={"Publish!"}
55
+
onClick={() => {
56
+
router.push(`/${params.leaflet_id}/publish`);
57
+
}}
58
+
/>
59
+
);
60
+
61
+
return <UpdateButton />;
62
+
};
63
+
64
+
const UpdateButton = () => {
65
+
let [isLoading, setIsLoading] = useState(false);
66
+
let { data: pub, mutate } = useLeafletPublicationData();
67
+
let { permission_token, rootEntity, rep } = useReplicache();
68
+
let { identity } = useIdentityData();
69
+
let toaster = useToaster();
70
+
71
+
// Get tags from Replicache state (same as draft editor)
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
78
+
icon={<PublishSmall className="shrink-0" />}
79
+
label={isLoading ? <DotLoader /> : "Update!"}
80
+
onClick={async () => {
81
+
if (!pub) return;
82
+
setIsLoading(true);
83
+
let doc = await publishToPublication({
84
+
root_entity: rootEntity,
85
+
publication_uri: pub.publications?.uri,
86
+
leaflet_id: permission_token.id,
87
+
title: pub.title,
88
+
description: pub.description,
89
+
tags: currentTags,
90
+
});
91
+
setIsLoading(false);
92
+
mutate();
93
+
94
+
// Generate URL based on whether it's in a publication or standalone
95
+
let docUrl = pub.publications
96
+
? `${getPublicationURL(pub.publications)}/${doc?.rkey}`
97
+
: `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`;
98
+
99
+
toaster({
100
+
content: (
101
+
<div>
102
+
{pub.doc ? "Updated! " : "Published! "}
103
+
<SpeedyLink href={docUrl}>link</SpeedyLink>
104
+
</div>
105
+
),
106
+
type: "success",
107
+
});
108
+
}}
109
+
/>
110
+
);
111
+
};
112
+
113
+
const PublishToPublicationButton = (props: { entityID: string }) => {
114
+
let { identity } = useIdentityData();
115
+
let { permission_token } = useReplicache();
116
+
let query = useSearchParams();
117
+
let [open, setOpen] = useState(query.get("publish") !== null);
118
+
119
+
let isMobile = useIsMobile();
120
+
identity && identity.atp_did && identity.publications.length > 0;
121
+
let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined);
122
+
let router = useRouter();
123
+
let { title, entitiesToDelete } = useTitle(props.entityID);
124
+
let [description, setDescription] = useState("");
125
+
126
+
return (
127
+
<Popover
128
+
asChild
129
+
open={open}
130
+
onOpenChange={(o) => setOpen(o)}
131
+
side={isMobile ? "top" : "right"}
132
+
align={isMobile ? "center" : "start"}
133
+
className="sm:max-w-sm w-[1000px]"
134
+
trigger={
135
+
<ActionButton
136
+
primary
137
+
icon={<PublishSmall className="shrink-0" />}
138
+
label={"Publish on ATP"}
139
+
/>
140
+
}
141
+
>
142
+
{!identity || !identity.atp_did ? (
143
+
<div className="-mx-2 -my-1">
144
+
<div
145
+
className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`}
146
+
>
147
+
<div className="mx-auto pt-2 scale-90">
148
+
<PubListEmptyIllo />
149
+
</div>
150
+
<div className="pt-1 font-bold">Publish on AT Proto</div>
151
+
{
152
+
<>
153
+
<div className="pb-2 text-secondary text-xs">
154
+
Link a Bluesky account to start <br /> a publishing on AT
155
+
Proto
156
+
</div>
157
+
158
+
<BlueskyLogin
159
+
compact
160
+
redirectRoute={`/${permission_token.id}?publish`}
161
+
/>
162
+
</>
163
+
}
164
+
</div>
165
+
</div>
166
+
) : (
167
+
<div className="flex flex-col">
168
+
<PostDetailsForm
169
+
title={title}
170
+
description={description}
171
+
setDescription={setDescription}
172
+
/>
173
+
<hr className="border-border-light my-3" />
174
+
<div>
175
+
<PubSelector
176
+
publications={identity.publications}
177
+
selectedPub={selectedPub}
178
+
setSelectedPub={setSelectedPub}
179
+
/>
180
+
</div>
181
+
<hr className="border-border-light mt-3 mb-2" />
182
+
183
+
<div className="flex gap-2 items-center place-self-end">
184
+
{selectedPub !== "looseleaf" && selectedPub && (
185
+
<SaveAsDraftButton
186
+
selectedPub={selectedPub}
187
+
leafletId={permission_token.id}
188
+
metadata={{ title: title, description }}
189
+
entitiesToDelete={entitiesToDelete}
190
+
/>
191
+
)}
192
+
<ButtonPrimary
193
+
disabled={selectedPub === undefined}
194
+
onClick={async (e) => {
195
+
if (!selectedPub) return;
196
+
e.preventDefault();
197
+
if (selectedPub === "create") return;
198
+
199
+
// For looseleaf, navigate without publication_uri
200
+
if (selectedPub === "looseleaf") {
201
+
router.push(
202
+
`${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
203
+
);
204
+
} else {
205
+
router.push(
206
+
`${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
207
+
);
208
+
}
209
+
}}
210
+
>
211
+
Next{selectedPub === "create" && ": Create Pub!"}
212
+
</ButtonPrimary>
213
+
</div>
214
+
</div>
215
+
)}
216
+
</Popover>
217
+
);
218
+
};
219
+
220
+
const SaveAsDraftButton = (props: {
221
+
selectedPub: string | undefined;
222
+
leafletId: string;
223
+
metadata: { title: string; description: string };
224
+
entitiesToDelete: string[];
225
+
}) => {
226
+
let { mutate } = useLeafletPublicationData();
227
+
let { rep } = useReplicache();
228
+
let [isLoading, setIsLoading] = useState(false);
229
+
230
+
return (
231
+
<ButtonTertiary
232
+
onClick={async (e) => {
233
+
if (!props.selectedPub) return;
234
+
if (props.selectedPub === "create") return;
235
+
e.preventDefault();
236
+
setIsLoading(true);
237
+
await moveLeafletToPublication(
238
+
props.leafletId,
239
+
props.selectedPub,
240
+
props.metadata,
241
+
props.entitiesToDelete,
242
+
);
243
+
await Promise.all([rep?.pull(), mutate()]);
244
+
setIsLoading(false);
245
+
}}
246
+
>
247
+
{isLoading ? <DotLoader /> : "Save as Draft"}
248
+
</ButtonTertiary>
249
+
);
250
+
};
251
+
252
+
const PostDetailsForm = (props: {
253
+
title: string;
254
+
description: string;
255
+
setDescription: (d: string) => void;
256
+
}) => {
257
+
return (
258
+
<div className=" flex flex-col gap-1">
259
+
<div className="text-sm text-tertiary">Post Details</div>
260
+
<div className="flex flex-col gap-2">
261
+
<InputWithLabel label="Title" value={props.title} disabled />
262
+
<InputWithLabel
263
+
label="Description (optional)"
264
+
textarea
265
+
value={props.description}
266
+
className="h-[4lh]"
267
+
onChange={(e) => props.setDescription(e.currentTarget.value)}
268
+
/>
269
+
</div>
270
+
</div>
271
+
);
272
+
};
273
+
274
+
const PubSelector = (props: {
275
+
selectedPub: string | undefined;
276
+
setSelectedPub: (s: string) => void;
277
+
publications: {
278
+
identity_did: string;
279
+
indexed_at: string;
280
+
name: string;
281
+
record: Json | null;
282
+
uri: string;
283
+
}[];
284
+
}) => {
285
+
// HEY STILL TO DO
286
+
// test out logged out, logged in but no pubs, and pubbed up flows
287
+
288
+
return (
289
+
<div className="flex flex-col gap-1">
290
+
<div className="text-sm text-tertiary">Publish toโฆ</div>
291
+
{props.publications.length === 0 || props.publications === undefined ? (
292
+
<div className="flex flex-col gap-1">
293
+
<div className="flex gap-2 menuItem">
294
+
<LooseLeafSmall className="shrink-0" />
295
+
<div className="flex flex-col leading-snug">
296
+
<div className="text-secondary font-bold">
297
+
Publish as Looseleaf
298
+
</div>
299
+
<div className="text-tertiary text-sm font-normal">
300
+
Publish this as a one off doc to AT Proto
301
+
</div>
302
+
</div>
303
+
</div>
304
+
<div className="flex gap-2 px-2 py-1 ">
305
+
<PublishSmall className="shrink-0 text-border" />
306
+
<div className="flex flex-col leading-snug">
307
+
<div className="text-border font-bold">
308
+
Publish to Publication
309
+
</div>
310
+
<div className="text-border text-sm font-normal">
311
+
Publish your writing to a blog on AT Proto
312
+
</div>
313
+
<hr className="my-2 drashed border-border-light border-dashed" />
314
+
<div className="text-tertiary text-sm font-normal ">
315
+
You don't have any Publications yet.{" "}
316
+
<a target="_blank" href="/lish/createPub">
317
+
Create one
318
+
</a>{" "}
319
+
to get started!
320
+
</div>
321
+
</div>
322
+
</div>
323
+
</div>
324
+
) : (
325
+
<div className="flex flex-col gap-1">
326
+
<PubOption
327
+
selected={props.selectedPub === "looseleaf"}
328
+
onSelect={() => props.setSelectedPub("looseleaf")}
329
+
>
330
+
<LooseLeafSmall />
331
+
Publish as Looseleaf
332
+
</PubOption>
333
+
<hr className="border-border-light border-dashed " />
334
+
{props.publications.map((p) => {
335
+
let pubRecord = p.record as PubLeafletPublication.Record;
336
+
return (
337
+
<PubOption
338
+
key={p.uri}
339
+
selected={props.selectedPub === p.uri}
340
+
onSelect={() => props.setSelectedPub(p.uri)}
341
+
>
342
+
<>
343
+
<PubIcon record={pubRecord} uri={p.uri} />
344
+
{p.name}
345
+
</>
346
+
</PubOption>
347
+
);
348
+
})}
349
+
<div className="flex items-center px-2 py-1 text-accent-contrast gap-2">
350
+
<AddTiny className="m-1 shrink-0" />
351
+
352
+
<a target="_blank" href="/lish/createPub">
353
+
Start a new Publication
354
+
</a>
355
+
</div>
356
+
</div>
357
+
)}
358
+
</div>
359
+
);
360
+
};
361
+
362
+
const PubOption = (props: {
363
+
selected: boolean;
364
+
onSelect: () => void;
365
+
children: React.ReactNode;
366
+
}) => {
367
+
return (
368
+
<button
369
+
className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`}
370
+
onClick={() => {
371
+
props.onSelect();
372
+
}}
373
+
>
374
+
{props.children}
375
+
</button>
376
+
);
377
+
};
378
+
379
+
let useTitle = (entityID: string) => {
380
+
let rootPage = useEntity(entityID, "root/page")[0].data.value;
381
+
let canvasBlocks = useCanvasBlocksWithType(rootPage).filter(
382
+
(b) => b.type === "text" || b.type === "heading",
383
+
);
384
+
let blocks = useBlocks(rootPage).filter(
385
+
(b) => b.type === "text" || b.type === "heading",
386
+
);
387
+
let firstBlock = canvasBlocks[0] || blocks[0];
388
+
389
+
let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value;
390
+
391
+
const leafletTitle = useMemo(() => {
392
+
if (!firstBlockText) return "Untitled";
393
+
let doc = new Y.Doc();
394
+
const update = base64.toByteArray(firstBlockText);
395
+
Y.applyUpdate(doc, update);
396
+
let nodes = doc.getXmlElement("prosemirror").toArray();
397
+
return YJSFragmentToString(nodes[0]) || "Untitled";
398
+
}, [firstBlockText]);
399
+
400
+
// Only handle second block logic for linear documents, not canvas
401
+
let isCanvas = canvasBlocks.length > 0;
402
+
let secondBlock = !isCanvas ? blocks[1] : undefined;
403
+
let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text")
404
+
?.data.value;
405
+
const secondBlockText = useMemo(() => {
406
+
if (!secondBlockTextValue) return "";
407
+
let doc = new Y.Doc();
408
+
const update = base64.toByteArray(secondBlockTextValue);
409
+
Y.applyUpdate(doc, update);
410
+
let nodes = doc.getXmlElement("prosemirror").toArray();
411
+
return YJSFragmentToString(nodes[0]) || "";
412
+
}, [secondBlockTextValue]);
413
+
414
+
let entitiesToDelete = useMemo(() => {
415
+
let etod: string[] = [];
416
+
// Only delete first block if it's a heading type
417
+
if (firstBlock?.type === "heading") {
418
+
etod.push(firstBlock.value);
419
+
}
420
+
// Delete second block if it's empty text (only for linear documents)
421
+
if (
422
+
!isCanvas &&
423
+
secondBlockText.trim() === "" &&
424
+
secondBlock?.type === "text"
425
+
) {
426
+
etod.push(secondBlock.value);
427
+
}
428
+
return etod;
429
+
}, [firstBlock, secondBlockText, secondBlock, isCanvas]);
430
+
431
+
return { title: leafletTitle, entitiesToDelete };
432
+
};
+4
-2
app/[leaflet_id]/icon.tsx
+4
-2
app/[leaflet_id]/icon.tsx
···
24
24
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
25
25
{ cookies: {} },
26
26
);
27
-
export default async function Icon(props: { params: { leaflet_id: string } }) {
27
+
export default async function Icon(props: {
28
+
params: Promise<{ leaflet_id: string }>;
29
+
}) {
28
30
let res = await supabase
29
31
.from("permission_tokens")
30
32
.select("*, permission_token_rights(*)")
31
-
.eq("id", props.params.leaflet_id)
33
+
.eq("id", (await props.params).leaflet_id)
32
34
.single();
33
35
let rootEntity = res.data?.root_entity;
34
36
let outlineColor, fillColor;
+3
-2
app/[leaflet_id]/opengraph-image.tsx
+3
-2
app/[leaflet_id]/opengraph-image.tsx
···
4
4
export const revalidate = 60;
5
5
6
6
export default async function OpenGraphImage(props: {
7
-
params: { leaflet_id: string };
7
+
params: Promise<{ leaflet_id: string }>;
8
8
}) {
9
-
return getMicroLinkOgImage(`/${props.params.leaflet_id}`);
9
+
let params = await props.params;
10
+
return getMicroLinkOgImage(`/${params.leaflet_id}`);
10
11
}
+3
-6
app/[leaflet_id]/page.tsx
+3
-6
app/[leaflet_id]/page.tsx
···
4
4
5
5
import type { Fact } from "src/replicache";
6
6
import type { Attribute } from "src/replicache/attributes";
7
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
7
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
8
8
import { Leaflet } from "./Leaflet";
9
9
import { scanIndexLocal } from "src/replicache/utils";
10
10
import { getRSVPData } from "actions/getRSVPData";
···
13
13
import { supabaseServerClient } from "supabase/serverClient";
14
14
import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data";
15
15
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
16
+
import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
16
17
17
18
export const preferredRegion = ["sfo1"];
18
19
export const dynamic = "force-dynamic";
···
70
71
);
71
72
let rootEntity = res.data?.root_entity;
72
73
if (!rootEntity || !res.data) return { title: "Leaflet not found" };
73
-
let publication_data =
74
-
res.data?.leaflets_in_publications?.[0] ||
75
-
res.data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
76
-
(p) => p.leaflets_in_publications.length,
77
-
)?.leaflets_in_publications?.[0];
74
+
let publication_data = getPublicationMetadataFromLeafletData(res.data);
78
75
if (publication_data) {
79
76
return {
80
77
title: publication_data.title || "Untitled",
+170
-296
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
+170
-296
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
1
1
"use client";
2
-
import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
-
import {
4
-
useState,
5
-
useCallback,
6
-
useRef,
7
-
useLayoutEffect,
8
-
useEffect,
9
-
} from "react";
10
-
import { createPortal } from "react-dom";
11
-
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
12
-
import * as Popover from "@radix-ui/react-popover";
13
-
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
2
+
import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
+
import { useEffect, useState, useCallback, useRef, useLayoutEffect } from "react";
4
+
import { EditorState } from "prosemirror-state";
14
5
import { EditorView } from "prosemirror-view";
15
6
import { Schema, MarkSpec, Mark } from "prosemirror-model";
16
7
import { baseKeymap } from "prosemirror-commands";
···
19
10
import { inputRules, InputRule } from "prosemirror-inputrules";
20
11
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21
12
import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
13
+
import { schema } from "components/Blocks/TextBlock/schema";
14
+
import { Mention, MentionAutocomplete } from "components/Mention";
22
15
23
16
// Schema with only links, mentions, and hashtags marks
24
17
const bskyPostSchema = new Schema({
···
134
127
return tr;
135
128
});
136
129
}
137
-
138
130
export function BlueskyPostEditorProsemirror(props: {
139
-
editorStateRef: React.MutableRefObject<EditorState | null>;
131
+
editorStateRef: React.RefObject<EditorState | null>;
140
132
initialContent?: string;
141
133
onCharCountChange?: (count: number) => void;
142
134
}) {
143
135
const mountRef = useRef<HTMLDivElement | null>(null);
144
136
const viewRef = useRef<EditorView | null>(null);
145
137
const [editorState, setEditorState] = useState<EditorState | null>(null);
146
-
const [mentionState, setMentionState] = useState<{
147
-
active: boolean;
148
-
range: { from: number; to: number } | null;
149
-
selectedMention: { handle: string; did: string } | null;
150
-
}>({ active: false, range: null, selectedMention: null });
138
+
const [mentionOpen, setMentionOpen] = useState(false);
139
+
const [mentionCoords, setMentionCoords] = useState<{
140
+
top: number;
141
+
left: number;
142
+
} | null>(null);
143
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
144
+
145
+
const openMentionAutocomplete = useCallback(() => {
146
+
if (!viewRef.current) return;
147
+
const view = viewRef.current;
148
+
const pos = view.state.selection.from;
149
+
setMentionInsertPos(pos);
150
+
const coords = view.coordsAtPos(pos - 1);
151
+
152
+
// Get coordinates relative to the positioned parent container
153
+
const editorEl = view.dom;
154
+
const container = editorEl.closest(".relative") as HTMLElement | null;
155
+
156
+
if (container) {
157
+
const containerRect = container.getBoundingClientRect();
158
+
setMentionCoords({
159
+
top: coords.bottom - containerRect.top,
160
+
left: coords.left - containerRect.left,
161
+
});
162
+
} else {
163
+
setMentionCoords({
164
+
top: coords.bottom,
165
+
left: coords.left,
166
+
});
167
+
}
168
+
setMentionOpen(true);
169
+
}, []);
151
170
152
171
const handleMentionSelect = useCallback(
153
-
(
154
-
mention: { handle: string; did: string },
155
-
range: { from: number; to: number },
156
-
) => {
157
-
if (!viewRef.current) return;
172
+
(mention: Mention) => {
173
+
if (!viewRef.current || mentionInsertPos === null) return;
158
174
const view = viewRef.current;
159
-
const { from, to } = range;
175
+
const from = mentionInsertPos - 1;
176
+
const to = mentionInsertPos;
160
177
const tr = view.state.tr;
161
178
162
-
// Delete the query text (keep the @)
163
-
tr.delete(from + 1, to);
179
+
// Delete the @ symbol
180
+
tr.delete(from, to);
164
181
165
-
// Insert the mention text after the @
166
-
const mentionText = mention.handle;
167
-
tr.insertText(mentionText, from + 1);
168
-
169
-
// Apply mention mark to @ and handle
170
-
tr.addMark(
171
-
from,
172
-
from + 1 + mentionText.length,
173
-
bskyPostSchema.marks.mention.create({ did: mention.did }),
174
-
);
175
-
176
-
// Add a space after the mention
177
-
tr.insertText(" ", from + 1 + mentionText.length);
182
+
if (mention.type === "did") {
183
+
// Insert @handle with mention mark
184
+
const mentionText = "@" + mention.handle;
185
+
tr.insertText(mentionText, from);
186
+
tr.addMark(
187
+
from,
188
+
from + mentionText.length,
189
+
bskyPostSchema.marks.mention.create({ did: mention.did }),
190
+
);
191
+
tr.insertText(" ", from + mentionText.length);
192
+
} else if (mention.type === "publication") {
193
+
// Insert publication name as a link
194
+
const linkText = mention.name;
195
+
tr.insertText(linkText, from);
196
+
tr.addMark(
197
+
from,
198
+
from + linkText.length,
199
+
bskyPostSchema.marks.link.create({ href: mention.url }),
200
+
);
201
+
tr.insertText(" ", from + linkText.length);
202
+
} else if (mention.type === "post") {
203
+
// Insert post title as a link
204
+
const linkText = mention.title;
205
+
tr.insertText(linkText, from);
206
+
tr.addMark(
207
+
from,
208
+
from + linkText.length,
209
+
bskyPostSchema.marks.link.create({ href: mention.url }),
210
+
);
211
+
tr.insertText(" ", from + linkText.length);
212
+
}
178
213
179
214
view.dispatch(tr);
180
215
view.focus();
181
216
},
182
-
[],
217
+
[mentionInsertPos],
183
218
);
184
219
185
-
const mentionStateRef = useRef(mentionState);
186
-
mentionStateRef.current = mentionState;
220
+
const handleMentionOpenChange = useCallback((open: boolean) => {
221
+
setMentionOpen(open);
222
+
if (!open) {
223
+
setMentionCoords(null);
224
+
setMentionInsertPos(null);
225
+
}
226
+
}, []);
187
227
188
228
useLayoutEffect(() => {
189
229
if (!mountRef.current) return;
190
230
231
+
// Input rule to trigger mention autocomplete when @ is typed
232
+
const mentionInputRule = new InputRule(
233
+
/(?:^|\s)@$/,
234
+
(state, match, start, end) => {
235
+
setTimeout(() => openMentionAutocomplete(), 0);
236
+
return null;
237
+
},
238
+
);
239
+
191
240
const initialState = EditorState.create({
192
241
schema: bskyPostSchema,
193
242
doc: props.initialContent
···
200
249
})
201
250
: undefined,
202
251
plugins: [
203
-
inputRules({ rules: [createHashtagInputRule()] }),
252
+
inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }),
204
253
keymap({
205
254
"Mod-z": undo,
206
255
"Mod-y": redo,
207
256
"Shift-Mod-z": redo,
208
-
Enter: (state, dispatch) => {
209
-
// Check if mention autocomplete is active
210
-
const currentMentionState = mentionStateRef.current;
211
-
if (
212
-
currentMentionState.active &&
213
-
currentMentionState.selectedMention &&
214
-
currentMentionState.range
215
-
) {
216
-
handleMentionSelect(
217
-
currentMentionState.selectedMention,
218
-
currentMentionState.range,
219
-
);
220
-
return true;
221
-
}
222
-
// Otherwise let the default Enter behavior happen (new paragraph)
223
-
return false;
224
-
},
225
257
}),
226
258
keymap(baseKeymap),
227
259
autolink({
···
245
277
view.updateState(newState);
246
278
setEditorState(newState);
247
279
props.editorStateRef.current = newState;
248
-
props.onCharCountChange?.(newState.doc.textContent.length);
280
+
props.onCharCountChange?.(
281
+
newState.doc.textContent.length + newState.doc.children.length - 1,
282
+
);
249
283
},
250
284
},
251
285
);
···
256
290
view.destroy();
257
291
viewRef.current = null;
258
292
};
259
-
}, [handleMentionSelect]);
293
+
}, [openMentionAutocomplete]);
294
+
295
+
const haveContent = (editorState?.doc.textContent.length ?? 0) > 0
296
+
297
+
// Warn if there's content in the editor on page change, unload or reload.
298
+
useWarnOnUnsavedChanges(haveContent);
260
299
261
300
return (
262
301
<div className="relative w-full h-full group">
263
-
{editorState && (
264
-
<MentionAutocomplete
265
-
editorState={editorState}
266
-
view={viewRef}
267
-
onSelect={handleMentionSelect}
268
-
onMentionStateChange={(active, range, selectedMention) => {
269
-
setMentionState({ active, range, selectedMention });
270
-
}}
271
-
/>
272
-
)}
273
-
{editorState?.doc.textContent.length === 0 && (
302
+
<MentionAutocomplete
303
+
open={mentionOpen}
304
+
onOpenChange={handleMentionOpenChange}
305
+
view={viewRef}
306
+
onSelect={handleMentionSelect}
307
+
coords={mentionCoords}
308
+
placeholder="Search people..."
309
+
/>
310
+
{!haveContent && (
274
311
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
275
312
Write a post to share your writing!
276
313
</div>
277
314
)}
278
315
<div
279
316
ref={mountRef}
280
-
className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm"
317
+
className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm"
281
318
style={{
282
319
wordWrap: "break-word",
283
320
overflowWrap: "break-word",
···
288
325
);
289
326
}
290
327
291
-
function MentionAutocomplete(props: {
292
-
editorState: EditorState;
293
-
view: React.RefObject<EditorView | null>;
294
-
onSelect: (
295
-
mention: { handle: string; did: string },
296
-
range: { from: number; to: number },
297
-
) => void;
298
-
onMentionStateChange: (
299
-
active: boolean,
300
-
range: { from: number; to: number } | null,
301
-
selectedMention: { handle: string; did: string } | null,
302
-
) => void;
303
-
}) {
304
-
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
305
-
const [mentionRange, setMentionRange] = useState<{
306
-
from: number;
307
-
to: number;
308
-
} | null>(null);
309
-
const [mentionCoords, setMentionCoords] = useState<{
310
-
top: number;
311
-
left: number;
312
-
} | null>(null);
313
-
314
-
const { suggestionIndex, setSuggestionIndex, suggestions } =
315
-
useMentionSuggestions(mentionQuery);
316
-
317
-
// Check for mention pattern whenever editor state changes
318
-
useEffect(() => {
319
-
const { $from } = props.editorState.selection;
320
-
const textBefore = $from.parent.textBetween(
321
-
Math.max(0, $from.parentOffset - 50),
322
-
$from.parentOffset,
323
-
null,
324
-
"\ufffc",
325
-
);
326
-
327
-
// Look for @ followed by word characters before cursor
328
-
const match = textBefore.match(/@([\w.]*)$/);
329
-
330
-
if (match && props.view.current) {
331
-
const queryBefore = match[1];
332
-
const from = $from.pos - queryBefore.length - 1;
333
-
334
-
// Get text after cursor to find the rest of the handle
335
-
const textAfter = $from.parent.textBetween(
336
-
$from.parentOffset,
337
-
Math.min($from.parent.content.size, $from.parentOffset + 50),
338
-
null,
339
-
"\ufffc",
340
-
);
341
-
342
-
// Match word characters after cursor until space or end
343
-
const afterMatch = textAfter.match(/^([\w.]*)/);
344
-
const queryAfter = afterMatch ? afterMatch[1] : "";
345
-
346
-
// Combine the full handle
347
-
const query = queryBefore + queryAfter;
348
-
const to = $from.pos + queryAfter.length;
349
-
350
-
setMentionQuery(query);
351
-
setMentionRange({ from, to });
352
-
353
-
// Get coordinates for the autocomplete popup
354
-
const coords = props.view.current.coordsAtPos(from);
355
-
setMentionCoords({
356
-
top: coords.bottom + window.scrollY,
357
-
left: coords.left + window.scrollX,
358
-
});
359
-
setSuggestionIndex(0);
360
-
} else {
361
-
setMentionQuery(null);
362
-
setMentionRange(null);
363
-
setMentionCoords(null);
364
-
}
365
-
}, [props.editorState, props.view, setSuggestionIndex]);
366
-
367
-
// Update parent's mention state
368
-
useEffect(() => {
369
-
const active = mentionQuery !== null && suggestions.length > 0;
370
-
const selectedMention =
371
-
active && suggestions[suggestionIndex]
372
-
? suggestions[suggestionIndex]
373
-
: null;
374
-
props.onMentionStateChange(active, mentionRange, selectedMention);
375
-
}, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
376
-
377
-
// Handle keyboard navigation for arrow keys only
378
-
useEffect(() => {
379
-
if (!mentionQuery || !props.view.current) return;
380
-
381
-
const handleKeyDown = (e: KeyboardEvent) => {
382
-
if (suggestions.length === 0) return;
383
-
384
-
if (e.key === "ArrowUp") {
385
-
e.preventDefault();
386
-
if (suggestionIndex > 0) {
387
-
setSuggestionIndex((i) => i - 1);
388
-
}
389
-
} else if (e.key === "ArrowDown") {
390
-
e.preventDefault();
391
-
if (suggestionIndex < suggestions.length - 1) {
392
-
setSuggestionIndex((i) => i + 1);
393
-
}
394
-
}
395
-
};
396
-
397
-
const dom = props.view.current.dom;
398
-
dom.addEventListener("keydown", handleKeyDown);
399
-
400
-
return () => {
401
-
dom.removeEventListener("keydown", handleKeyDown);
402
-
};
403
-
}, [
404
-
mentionQuery,
405
-
suggestions,
406
-
suggestionIndex,
407
-
props.view,
408
-
setSuggestionIndex,
409
-
]);
410
-
411
-
if (!mentionCoords || suggestions.length === 0) return null;
412
-
413
-
// The styles in this component should match the Menu styles in components/Layout.tsx
414
-
return (
415
-
<Popover.Root open>
416
-
{createPortal(
417
-
<Popover.Anchor
418
-
style={{
419
-
top: mentionCoords.top,
420
-
left: mentionCoords.left,
421
-
position: "absolute",
422
-
}}
423
-
/>,
424
-
document.body,
425
-
)}
426
-
<Popover.Portal>
427
-
<Popover.Content
428
-
side="bottom"
429
-
align="start"
430
-
sideOffset={4}
431
-
collisionPadding={20}
432
-
onOpenAutoFocus={(e) => e.preventDefault()}
433
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`}
434
-
>
435
-
<ul className="list-none p-0 text-sm">
436
-
{suggestions.map((result, index) => {
437
-
return (
438
-
<div
439
-
className={`
440
-
MenuItem
441
-
font-bold z-10 py-1 px-3
442
-
text-left text-secondary
443
-
flex gap-2
444
-
${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""}
445
-
hover:bg-border-light hover:text-secondary
446
-
outline-none
447
-
`}
448
-
key={result.did}
449
-
onClick={() => {
450
-
if (mentionRange) {
451
-
props.onSelect(result, mentionRange);
452
-
setMentionQuery(null);
453
-
setMentionRange(null);
454
-
setMentionCoords(null);
455
-
}
456
-
}}
457
-
onMouseDown={(e) => e.preventDefault()}
458
-
>
459
-
@{result.handle}
460
-
</div>
461
-
);
462
-
})}
463
-
</ul>
464
-
</Popover.Content>
465
-
</Popover.Portal>
466
-
</Popover.Root>
467
-
);
468
-
}
469
-
470
-
function useMentionSuggestions(query: string | null) {
471
-
const [suggestionIndex, setSuggestionIndex] = useState(0);
472
-
const [suggestions, setSuggestions] = useState<
473
-
{ handle: string; did: string }[]
474
-
>([]);
475
-
476
-
useDebouncedEffect(
477
-
async () => {
478
-
if (!query) {
479
-
setSuggestions([]);
480
-
return;
481
-
}
482
-
483
-
const agent = new Agent("https://public.api.bsky.app");
484
-
const result = await agent.searchActorsTypeahead({
485
-
q: query,
486
-
limit: 8,
487
-
});
488
-
setSuggestions(
489
-
result.data.actors.map((actor) => ({
490
-
handle: actor.handle,
491
-
did: actor.did,
492
-
})),
493
-
);
494
-
},
495
-
300,
496
-
[query],
497
-
);
498
-
499
-
useEffect(() => {
500
-
if (suggestionIndex > suggestions.length - 1) {
501
-
setSuggestionIndex(Math.max(0, suggestions.length - 1));
502
-
}
503
-
}, [suggestionIndex, suggestions.length]);
504
-
505
-
return {
506
-
suggestions,
507
-
suggestionIndex,
508
-
setSuggestionIndex,
509
-
};
510
-
}
511
-
512
328
/**
513
329
* Converts a ProseMirror editor state to Bluesky post facets.
514
330
* Extracts mentions, links, and hashtags from the editor state and returns them
···
593
409
594
410
return features;
595
411
}
412
+
413
+
export const addMentionToEditor = (
414
+
mention: Mention,
415
+
range: { from: number; to: number },
416
+
view: EditorView,
417
+
) => {
418
+
console.log("view", view);
419
+
if (!view) return;
420
+
const { from, to } = range;
421
+
const tr = view.state.tr;
422
+
423
+
if (mention.type == "did") {
424
+
// Delete the @ and any query text
425
+
tr.delete(from, to);
426
+
// Insert didMention inline node
427
+
const mentionText = "@" + mention.handle;
428
+
const didMentionNode = schema.nodes.didMention.create({
429
+
did: mention.did,
430
+
text: mentionText,
431
+
});
432
+
tr.insert(from, didMentionNode);
433
+
}
434
+
if (mention.type === "publication" || mention.type === "post") {
435
+
// Delete the @ and any query text
436
+
tr.delete(from, to);
437
+
let name = mention.type == "post" ? mention.title : mention.name;
438
+
// Insert atMention inline node
439
+
const atMentionNode = schema.nodes.atMention.create({
440
+
atURI: mention.uri,
441
+
text: name,
442
+
});
443
+
tr.insert(from, atMentionNode);
444
+
}
445
+
console.log("yo", mention);
446
+
447
+
// Add a space after the mention
448
+
tr.insertText(" ", from + 1);
449
+
450
+
view.dispatch(tr);
451
+
view.focus();
452
+
};
453
+
454
+
455
+
function useWarnOnUnsavedChanges(hasUnsavedContent: boolean) {
456
+
useEffect(() => {
457
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
458
+
if (hasUnsavedContent) {
459
+
e.preventDefault();
460
+
// Chrome requires returnValue to be set
461
+
e.returnValue = "";
462
+
}
463
+
};
464
+
window.addEventListener("beforeunload", handleBeforeUnload);
465
+
return () => {
466
+
window.removeEventListener("beforeunload", handleBeforeUnload);
467
+
};
468
+
}, [hasUnsavedContent]);
469
+
}
+202
-91
app/[leaflet_id]/publish/PublishPost.tsx
+202
-91
app/[leaflet_id]/publish/PublishPost.tsx
···
6
6
import { Radio } from "components/Checkbox";
7
7
import { useParams } from "next/navigation";
8
8
import Link from "next/link";
9
-
import { AutosizeTextarea } from "components/utils/AutosizeTextarea";
9
+
10
10
import { PubLeafletPublication } from "lexicons/api";
11
11
import { publishPostToBsky } from "./publishBskyPost";
12
12
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
13
13
import { AtUri } from "@atproto/syntax";
14
14
import { PublishIllustration } from "./PublishIllustration/PublishIllustration";
15
15
import { useReplicache } from "src/replicache";
16
+
import { useSubscribe } from "src/replicache/useSubscribe";
16
17
import {
17
18
BlueskyPostEditorProsemirror,
18
19
editorStateToFacetedText,
19
20
} from "./BskyPostEditorProsemirror";
20
21
import { EditorState } from "prosemirror-state";
22
+
import { TagSelector } from "../../../components/Tags";
23
+
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
24
+
import { PubIcon } from "components/ActionBar/Publications";
21
25
22
26
type Props = {
23
27
title: string;
···
25
29
root_entity: string;
26
30
profile: ProfileViewDetailed;
27
31
description: string;
28
-
publication_uri: string;
32
+
publication_uri?: string;
29
33
record?: PubLeafletPublication.Record;
30
34
posts_in_pub?: number;
35
+
entitiesToDelete?: string[];
36
+
hasDraft: boolean;
31
37
};
32
38
33
39
export function PublishPost(props: Props) {
···
35
41
{ state: "default" } | { state: "success"; post_url: string }
36
42
>({ state: "default" });
37
43
return (
38
-
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center">
44
+
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary">
39
45
{publishState.state === "default" ? (
40
46
<PublishPostForm setPublishState={setPublishState} {...props} />
41
47
) : (
···
55
61
setPublishState: (s: { state: "success"; post_url: string }) => void;
56
62
} & Props,
57
63
) => {
58
-
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
59
64
let editorStateRef = useRef<EditorState | null>(null);
60
-
let [isLoading, setIsLoading] = useState(false);
61
65
let [charCount, setCharCount] = useState(0);
66
+
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
67
+
let [isLoading, setIsLoading] = useState(false);
62
68
let params = useParams();
63
69
let { rep } = useReplicache();
64
70
71
+
// For publications with drafts, use Replicache; otherwise use local state
72
+
let replicacheTags = useSubscribe(rep, (tx) =>
73
+
tx.get<string[]>("publication_tags"),
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
80
+
? Array.isArray(replicacheTags)
81
+
? replicacheTags
82
+
: []
83
+
: localTags;
84
+
85
+
// Update tags via Replicache mutation or local state depending on context
86
+
const handleTagsChange = async (newTags: string[]) => {
87
+
if (hasDraft) {
88
+
await rep?.mutate.updatePublicationDraft({
89
+
tags: newTags,
90
+
});
91
+
} else {
92
+
setLocalTags(newTags);
93
+
}
94
+
};
95
+
65
96
async function submit() {
66
97
if (isLoading) return;
67
98
setIsLoading(true);
···
72
103
leaflet_id: props.leaflet_id,
73
104
title: props.title,
74
105
description: props.description,
106
+
tags: currentTags,
107
+
entitiesToDelete: props.entitiesToDelete,
75
108
});
76
109
if (!doc) return;
77
110
78
-
let post_url = `https://${props.record?.base_path}/${doc.rkey}`;
111
+
// Generate post URL based on whether it's in a publication or standalone
112
+
let post_url = props.record?.base_path
113
+
? `https://${props.record.base_path}/${doc.rkey}`
114
+
: `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`;
115
+
79
116
let [text, facets] = editorStateRef.current
80
117
? editorStateToFacetedText(editorStateRef.current)
81
118
: [];
···
94
131
}
95
132
96
133
return (
97
-
<div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3">
98
-
<h3>Publish Options</h3>
134
+
<div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary">
99
135
<form
100
136
onSubmit={(e) => {
101
137
e.preventDefault();
102
138
submit();
103
139
}}
104
140
>
105
-
<div className="container flex flex-col gap-2 sm:p-3 p-4">
106
-
<Radio
107
-
checked={shareOption === "quiet"}
108
-
onChange={(e) => {
109
-
if (e.target === e.currentTarget) {
110
-
setShareOption("quiet");
111
-
}
112
-
}}
113
-
name="share-options"
114
-
id="share-quietly"
115
-
value="Share Quietly"
116
-
>
117
-
<div className="flex flex-col">
118
-
<div className="font-bold">Share Quietly</div>
119
-
<div className="text-sm text-tertiary font-normal">
120
-
No one will be notified about this post
121
-
</div>
122
-
</div>
123
-
</Radio>
124
-
<Radio
125
-
checked={shareOption === "bluesky"}
126
-
onChange={(e) => {
127
-
if (e.target === e.currentTarget) {
128
-
setShareOption("bluesky");
129
-
}
130
-
}}
131
-
name="share-options"
132
-
id="share-bsky"
133
-
value="Share on Bluesky"
134
-
>
135
-
<div className="flex flex-col">
136
-
<div className="font-bold">Share on Bluesky</div>
137
-
<div className="text-sm text-tertiary font-normal">
138
-
Pub subscribers will be updated via a custom Bluesky feed
139
-
</div>
140
-
</div>
141
-
</Radio>
141
+
<div className="container flex flex-col gap-3 sm:p-3 p-4">
142
+
<PublishingTo
143
+
publication_uri={props.publication_uri}
144
+
record={props.record}
145
+
/>
146
+
<hr className="border-border" />
147
+
<ShareOptions
148
+
setShareOption={setShareOption}
149
+
shareOption={shareOption}
150
+
charCount={charCount}
151
+
setCharCount={setCharCount}
152
+
editorStateRef={editorStateRef}
153
+
{...props}
154
+
/>
155
+
<hr className="border-border " />
156
+
<div className="flex flex-col gap-2">
157
+
<h4>Tags</h4>
158
+
<TagSelector
159
+
selectedTags={currentTags}
160
+
setSelectedTags={handleTagsChange}
161
+
/>
162
+
</div>
163
+
<hr className="border-border mb-2" />
142
164
143
-
<div
144
-
className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`}
145
-
>
146
-
<div className="opaque-container p-3 rounded-lg!">
147
-
<div className="flex gap-2">
148
-
<img
149
-
className="rounded-full w-[42px] h-[42px] shrink-0"
150
-
src={props.profile.avatar}
151
-
/>
152
-
<div className="flex flex-col w-full">
153
-
<div className="flex gap-2 pb-1">
154
-
<p className="font-bold">{props.profile.displayName}</p>
155
-
<p className="text-tertiary">@{props.profile.handle}</p>
156
-
</div>
157
-
<div className="flex flex-col">
158
-
<BlueskyPostEditorProsemirror
159
-
editorStateRef={editorStateRef}
160
-
onCharCountChange={setCharCount}
161
-
/>
162
-
</div>
163
-
<div className="opaque-container overflow-hidden flex flex-col mt-4 w-full">
164
-
{/* <div className="h-[260px] w-full bg-test" /> */}
165
-
<div className="flex flex-col p-2">
166
-
<div className="font-bold">{props.title}</div>
167
-
<div className="text-tertiary">{props.description}</div>
168
-
<hr className="border-border-light mt-2 mb-1" />
169
-
<p className="text-xs text-tertiary">
170
-
{props.record?.base_path}
171
-
</p>
172
-
</div>
173
-
</div>
174
-
<div className="text-xs text-secondary italic place-self-end pt-2">
175
-
{charCount}/300
176
-
</div>
177
-
</div>
178
-
</div>
179
-
</div>
180
-
</div>
181
165
<div className="flex justify-between">
182
166
<Link
183
167
className="hover:no-underline! font-bold"
···
199
183
);
200
184
};
201
185
186
+
const ShareOptions = (props: {
187
+
shareOption: "quiet" | "bluesky";
188
+
setShareOption: (option: typeof props.shareOption) => void;
189
+
charCount: number;
190
+
setCharCount: (c: number) => void;
191
+
editorStateRef: React.MutableRefObject<EditorState | null>;
192
+
title: string;
193
+
profile: ProfileViewDetailed;
194
+
description: string;
195
+
record?: PubLeafletPublication.Record;
196
+
}) => {
197
+
return (
198
+
<div className="flex flex-col gap-2">
199
+
<h4>Notifications</h4>
200
+
<Radio
201
+
checked={props.shareOption === "quiet"}
202
+
onChange={(e) => {
203
+
if (e.target === e.currentTarget) {
204
+
props.setShareOption("quiet");
205
+
}
206
+
}}
207
+
name="share-options"
208
+
id="share-quietly"
209
+
value="Share Quietly"
210
+
>
211
+
<div className="flex flex-col">
212
+
<div className="font-bold">Share Quietly</div>
213
+
<div className="text-sm text-tertiary font-normal">
214
+
No one will be notified about this post
215
+
</div>
216
+
</div>
217
+
</Radio>
218
+
<Radio
219
+
checked={props.shareOption === "bluesky"}
220
+
onChange={(e) => {
221
+
if (e.target === e.currentTarget) {
222
+
props.setShareOption("bluesky");
223
+
}
224
+
}}
225
+
name="share-options"
226
+
id="share-bsky"
227
+
value="Share on Bluesky"
228
+
>
229
+
<div className="flex flex-col">
230
+
<div className="font-bold">Share on Bluesky</div>
231
+
<div className="text-sm text-tertiary font-normal">
232
+
Pub subscribers will be updated via a custom Bluesky feed
233
+
</div>
234
+
</div>
235
+
</Radio>
236
+
<div
237
+
className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`}
238
+
>
239
+
<div className="opaque-container py-2 px-3 text-sm rounded-lg!">
240
+
<div className="flex gap-2">
241
+
<img
242
+
className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0"
243
+
src={props.profile.avatar}
244
+
/>
245
+
<div className="flex flex-col w-full">
246
+
<div className="flex gap-2 ">
247
+
<p className="font-bold">{props.profile.displayName}</p>
248
+
<p className="text-tertiary">@{props.profile.handle}</p>
249
+
</div>
250
+
<div className="flex flex-col">
251
+
<BlueskyPostEditorProsemirror
252
+
editorStateRef={props.editorStateRef}
253
+
onCharCountChange={props.setCharCount}
254
+
/>
255
+
</div>
256
+
<div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full">
257
+
<div className="flex flex-col p-2">
258
+
<div className="font-bold">{props.title}</div>
259
+
<div className="text-tertiary">{props.description}</div>
260
+
<hr className="border-border mt-2 mb-1" />
261
+
<p className="text-xs text-tertiary">
262
+
{props.record?.base_path}
263
+
</p>
264
+
</div>
265
+
</div>
266
+
<div className="text-xs text-secondary italic place-self-end pt-2">
267
+
{props.charCount}/300
268
+
</div>
269
+
</div>
270
+
</div>
271
+
</div>
272
+
</div>
273
+
</div>
274
+
);
275
+
};
276
+
277
+
const PublishingTo = (props: {
278
+
publication_uri?: string;
279
+
record?: PubLeafletPublication.Record;
280
+
}) => {
281
+
if (props.publication_uri && props.record) {
282
+
return (
283
+
<div className="flex flex-col gap-1">
284
+
<h3>Publishing to</h3>
285
+
<div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]">
286
+
<PubIcon record={props.record} uri={props.publication_uri} />
287
+
<div className="font-bold text-secondary">{props.record.name}</div>
288
+
</div>
289
+
</div>
290
+
);
291
+
}
292
+
293
+
return (
294
+
<div className="flex flex-col gap-1">
295
+
<h3>Publishing as</h3>
296
+
<div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]">
297
+
<LooseLeafSmall className="shrink-0" />
298
+
<div className="font-bold text-secondary">Looseleaf</div>
299
+
</div>
300
+
</div>
301
+
);
302
+
};
303
+
202
304
const PublishPostSuccess = (props: {
203
305
post_url: string;
204
-
publication_uri: string;
306
+
publication_uri?: string;
205
307
record: Props["record"];
206
308
posts_in_pub: number;
207
309
}) => {
208
-
let uri = new AtUri(props.publication_uri);
310
+
let uri = props.publication_uri ? new AtUri(props.publication_uri) : null;
209
311
return (
210
312
<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
313
<PublishIllustration posts_in_pub={props.posts_in_pub} />
212
314
<h2 className="pt-2">Published!</h2>
213
-
<Link
214
-
className="hover:no-underline! font-bold place-self-center pt-2"
215
-
href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`}
216
-
>
217
-
<ButtonPrimary>Back to Dashboard</ButtonPrimary>
218
-
</Link>
315
+
{uri && props.record ? (
316
+
<Link
317
+
className="hover:no-underline! font-bold place-self-center pt-2"
318
+
href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`}
319
+
>
320
+
<ButtonPrimary>Back to Dashboard</ButtonPrimary>
321
+
</Link>
322
+
) : (
323
+
<Link
324
+
className="hover:no-underline! font-bold place-self-center pt-2"
325
+
href="/"
326
+
>
327
+
<ButtonPrimary>Back to Home</ButtonPrimary>
328
+
</Link>
329
+
)}
219
330
<a href={props.post_url}>See published post</a>
220
331
</div>
221
332
);
+69
-9
app/[leaflet_id]/publish/page.tsx
+69
-9
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
+
searchParams: Promise<{
17
+
publication_uri: string;
18
+
title: string;
19
+
description: string;
20
+
entitiesToDelete: string;
21
+
}>;
16
22
};
17
23
export default async function PublishLeafletPage(props: Props) {
18
24
let leaflet_id = (await props.params).leaflet_id;
···
27
33
*,
28
34
documents_in_publications(count)
29
35
),
30
-
documents(*))`,
36
+
documents(*)),
37
+
leaflets_to_documents(
38
+
*,
39
+
documents(*)
40
+
)`,
31
41
)
32
42
.eq("id", leaflet_id)
33
43
.single();
34
44
let rootEntity = data?.root_entity;
35
-
if (!data || !rootEntity || !data.leaflets_in_publications[0])
45
+
46
+
// Try to find publication from leaflets_in_publications first
47
+
let publication = data?.leaflets_in_publications[0]?.publications;
48
+
49
+
// If not found, check if publication_uri is in searchParams
50
+
if (!publication) {
51
+
let pub_uri = (await props.searchParams).publication_uri;
52
+
if (pub_uri) {
53
+
console.log(decodeURIComponent(pub_uri));
54
+
let { data: pubData, error } = await supabaseServerClient
55
+
.from("publications")
56
+
.select("*, documents_in_publications(count)")
57
+
.eq("uri", decodeURIComponent(pub_uri))
58
+
.single();
59
+
console.log(error);
60
+
publication = pubData;
61
+
}
62
+
}
63
+
64
+
// Check basic data requirements
65
+
if (!data || !rootEntity)
36
66
return (
37
67
<div>
38
68
missin something
···
42
72
43
73
let identity = await getIdentityData();
44
74
if (!identity || !identity.atp_did) return null;
45
-
let pub = data.leaflets_in_publications[0];
46
-
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
47
75
76
+
// Get title and description from either source
77
+
let title =
78
+
data.leaflets_in_publications[0]?.title ||
79
+
data.leaflets_to_documents[0]?.title ||
80
+
decodeURIComponent((await props.searchParams).title || "");
81
+
let description =
82
+
data.leaflets_in_publications[0]?.description ||
83
+
data.leaflets_to_documents[0]?.description ||
84
+
decodeURIComponent((await props.searchParams).description || "");
85
+
86
+
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
48
87
let profile = await agent.getProfile({ actor: identity.atp_did });
88
+
89
+
// Parse entitiesToDelete from URL params
90
+
let searchParams = await props.searchParams;
91
+
let entitiesToDelete: string[] = [];
92
+
try {
93
+
if (searchParams.entitiesToDelete) {
94
+
entitiesToDelete = JSON.parse(
95
+
decodeURIComponent(searchParams.entitiesToDelete),
96
+
);
97
+
}
98
+
} catch (e) {
99
+
// If parsing fails, just use empty array
100
+
}
101
+
102
+
// Check if a draft record exists (either in a publication or standalone)
103
+
let hasDraft =
104
+
data.leaflets_in_publications.length > 0 ||
105
+
data.leaflets_to_documents.length > 0;
106
+
49
107
return (
50
108
<ReplicacheProvider
51
109
rootEntity={rootEntity}
···
57
115
leaflet_id={leaflet_id}
58
116
root_entity={rootEntity}
59
117
profile={profile.data}
60
-
title={pub.title}
61
-
publication_uri={pub.publication}
62
-
description={pub.description}
63
-
record={pub.publications?.record as PubLeafletPublication.Record}
64
-
posts_in_pub={pub.publications?.documents_in_publications[0].count}
118
+
title={title}
119
+
description={description}
120
+
publication_uri={publication?.uri}
121
+
record={publication?.record as PubLeafletPublication.Record | undefined}
122
+
posts_in_pub={publication?.documents_in_publications[0]?.count}
123
+
entitiesToDelete={entitiesToDelete}
124
+
hasDraft={hasDraft}
65
125
/>
66
126
</ReplicacheProvider>
67
127
);
+80
-17
app/api/inngest/functions/index_post_mention.ts
+80
-17
app/api/inngest/functions/index_post_mention.ts
···
3
3
import { AtpAgent, AtUri } from "@atproto/api";
4
4
import { Json } from "supabase/database.types";
5
5
import { ids } from "lexicons/api/lexicons";
6
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
7
+
import { v7 } from "uuid";
8
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
6
9
7
10
export const index_post_mention = inngest.createFunction(
8
11
{ id: "index_post_mention" },
···
11
14
let url = new URL(event.data.document_link);
12
15
let path = url.pathname.split("/").filter(Boolean);
13
16
14
-
let { data: pub, error } = await supabaseServerClient
15
-
.from("publications")
16
-
.select("*")
17
-
.eq("record->>base_path", url.host)
18
-
.single();
17
+
// Check if this is a standalone document URL (/p/didOrHandle/rkey/...)
18
+
const isStandaloneDoc = path[0] === "p" && path.length >= 3;
19
+
20
+
let documentUri: string;
21
+
let authorDid: string;
22
+
23
+
if (isStandaloneDoc) {
24
+
// Standalone doc: /p/didOrHandle/rkey/l-quote/...
25
+
const didOrHandle = decodeURIComponent(path[1]);
26
+
const rkey = path[2];
27
+
28
+
// Resolve handle to DID if necessary
29
+
let did = didOrHandle;
30
+
if (!didOrHandle.startsWith("did:")) {
31
+
const resolved = await step.run("resolve-handle", async () => {
32
+
return idResolver.handle.resolve(didOrHandle);
33
+
});
34
+
if (!resolved) {
35
+
return { message: `Could not resolve handle: ${didOrHandle}` };
36
+
}
37
+
did = resolved;
38
+
}
19
39
20
-
if (!pub) {
21
-
return {
22
-
message: `No publication found for ${url.host}/${path[0]}`,
23
-
error,
24
-
};
40
+
documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString();
41
+
authorDid = did;
42
+
} else {
43
+
// Publication post: look up by custom domain
44
+
let { data: pub, error } = await supabaseServerClient
45
+
.from("publications")
46
+
.select("*")
47
+
.eq("record->>base_path", url.host)
48
+
.single();
49
+
50
+
if (!pub) {
51
+
return {
52
+
message: `No publication found for ${url.host}/${path[0]}`,
53
+
error,
54
+
};
55
+
}
56
+
57
+
documentUri = AtUri.make(
58
+
pub.identity_did,
59
+
ids.PubLeafletDocument,
60
+
path[0],
61
+
).toString();
62
+
authorDid = pub.identity_did;
25
63
}
26
64
27
65
let bsky_post = await step.run("get-bsky-post-data", async () => {
···
38
76
}
39
77
40
78
await step.run("index-bsky-post", async () => {
41
-
await supabaseServerClient.from("bsky_posts").insert({
79
+
await supabaseServerClient.from("bsky_posts").upsert({
42
80
uri: bsky_post.uri,
43
81
cid: bsky_post.cid,
44
82
post_view: bsky_post as Json,
45
83
});
46
-
await supabaseServerClient.from("document_mentions_in_bsky").insert({
84
+
await supabaseServerClient.from("document_mentions_in_bsky").upsert({
47
85
uri: bsky_post.uri,
48
-
document: AtUri.make(
49
-
pub.identity_did,
50
-
ids.PubLeafletDocument,
51
-
path[0],
52
-
).toString(),
86
+
document: documentUri,
53
87
link: event.data.document_link,
54
88
});
89
+
});
90
+
91
+
await step.run("create-notification", async () => {
92
+
// Only create notification if the quote is from someone other than the author
93
+
if (bsky_post.author.did !== authorDid) {
94
+
// Check if a notification already exists for this post and recipient
95
+
const { data: existingNotification } = await supabaseServerClient
96
+
.from("notifications")
97
+
.select("id")
98
+
.eq("recipient", authorDid)
99
+
.eq("data->>type", "quote")
100
+
.eq("data->>bsky_post_uri", bsky_post.uri)
101
+
.eq("data->>document_uri", documentUri)
102
+
.single();
103
+
104
+
if (!existingNotification) {
105
+
const notification: Notification = {
106
+
id: v7(),
107
+
recipient: authorDid,
108
+
data: {
109
+
type: "quote",
110
+
bsky_post_uri: bsky_post.uri,
111
+
document_uri: documentUri,
112
+
},
113
+
};
114
+
await supabaseServerClient.from("notifications").insert(notification);
115
+
await pingIdentityToUpdateNotification(authorDid);
116
+
}
117
+
}
55
118
});
56
119
},
57
120
);
+1
-1
app/api/link_previews/route.ts
+1
-1
app/api/link_previews/route.ts
+145
app/api/pub_icon/route.ts
+145
app/api/pub_icon/route.ts
···
1
+
import { AtUri } from "@atproto/syntax";
2
+
import { IdResolver } from "@atproto/identity";
3
+
import { NextRequest, NextResponse } from "next/server";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import sharp from "sharp";
7
+
8
+
const idResolver = new IdResolver();
9
+
10
+
export const runtime = "nodejs";
11
+
12
+
export async function GET(req: NextRequest) {
13
+
const searchParams = req.nextUrl.searchParams;
14
+
const bgColor = searchParams.get("bg") || "#0000E1";
15
+
const fgColor = searchParams.get("fg") || "#FFFFFF";
16
+
17
+
try {
18
+
const at_uri = searchParams.get("at_uri");
19
+
20
+
if (!at_uri) {
21
+
return new NextResponse(null, { status: 400 });
22
+
}
23
+
24
+
// Parse the AT URI
25
+
let uri: AtUri;
26
+
try {
27
+
uri = new AtUri(at_uri);
28
+
} catch (e) {
29
+
return new NextResponse(null, { status: 400 });
30
+
}
31
+
32
+
let publicationRecord: PubLeafletPublication.Record | null = null;
33
+
let publicationUri: string;
34
+
35
+
// Check if it's a document or publication
36
+
if (uri.collection === "pub.leaflet.document") {
37
+
// Query the documents_in_publications table to get the publication
38
+
const { data: docInPub } = await supabaseServerClient
39
+
.from("documents_in_publications")
40
+
.select("publication, publications(record)")
41
+
.eq("document", at_uri)
42
+
.single();
43
+
44
+
if (!docInPub || !docInPub.publications) {
45
+
return new NextResponse(null, { status: 404 });
46
+
}
47
+
48
+
publicationUri = docInPub.publication;
49
+
publicationRecord = docInPub.publications
50
+
.record as PubLeafletPublication.Record;
51
+
} else if (uri.collection === "pub.leaflet.publication") {
52
+
// Query the publications table directly
53
+
const { data: publication } = await supabaseServerClient
54
+
.from("publications")
55
+
.select("record, uri")
56
+
.eq("uri", at_uri)
57
+
.single();
58
+
59
+
if (!publication || !publication.record) {
60
+
return new NextResponse(null, { status: 404 });
61
+
}
62
+
63
+
publicationUri = publication.uri;
64
+
publicationRecord = publication.record as PubLeafletPublication.Record;
65
+
} else {
66
+
// Not a supported collection
67
+
return new NextResponse(null, { status: 404 });
68
+
}
69
+
70
+
// Check if the publication has an icon
71
+
if (!publicationRecord?.icon) {
72
+
// Generate a placeholder with the first letter of the publication name
73
+
const firstLetter = (publicationRecord?.name || "?")
74
+
.slice(0, 1)
75
+
.toUpperCase();
76
+
77
+
// Create a simple SVG placeholder with theme colors
78
+
const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
79
+
<rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/>
80
+
<text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text>
81
+
</svg>`;
82
+
83
+
return new NextResponse(svg, {
84
+
headers: {
85
+
"Content-Type": "image/svg+xml",
86
+
"Cache-Control":
87
+
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000",
88
+
"CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000",
89
+
},
90
+
});
91
+
}
92
+
93
+
// Parse the publication URI to get the DID
94
+
const pubUri = new AtUri(publicationUri);
95
+
96
+
// Get the CID from the icon blob
97
+
const cid = (publicationRecord.icon.ref as unknown as { $link: string })[
98
+
"$link"
99
+
];
100
+
101
+
// Fetch the blob from the PDS
102
+
const identity = await idResolver.did.resolve(pubUri.host);
103
+
const service = identity?.service?.find((f) => f.id === "#atproto_pds");
104
+
if (!service) return new NextResponse(null, { status: 404 });
105
+
106
+
const blobResponse = await fetch(
107
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`,
108
+
{
109
+
headers: {
110
+
"Accept-Encoding": "gzip, deflate, br, zstd",
111
+
},
112
+
},
113
+
);
114
+
115
+
if (!blobResponse.ok) {
116
+
return new NextResponse(null, { status: 404 });
117
+
}
118
+
119
+
// Get the image buffer
120
+
const imageBuffer = await blobResponse.arrayBuffer();
121
+
122
+
// Resize to 96x96 using Sharp
123
+
const resizedImage = await sharp(Buffer.from(imageBuffer))
124
+
.resize(96, 96, {
125
+
fit: "cover",
126
+
position: "center",
127
+
})
128
+
.webp({ quality: 90 })
129
+
.toBuffer();
130
+
131
+
// Return with caching headers
132
+
return new NextResponse(resizedImage, {
133
+
headers: {
134
+
"Content-Type": "image/webp",
135
+
// Cache for 1 hour, but serve stale for much longer while revalidating
136
+
"Cache-Control":
137
+
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000",
138
+
"CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000",
139
+
},
140
+
});
141
+
} catch (error) {
142
+
console.error("Error fetching publication icon:", error);
143
+
return new NextResponse(null, { status: 500 });
144
+
}
145
+
}
+34
-5
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
+34
-5
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
···
4
4
import { makeRoute } from "../lib";
5
5
import type { Env } from "./route";
6
6
import { scanIndexLocal } from "src/replicache/utils";
7
-
import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
8
7
import * as base64 from "base64-js";
9
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
8
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
10
9
import { applyUpdate, Doc } from "yjs";
11
10
12
11
export const getFactsFromHomeLeaflets = makeRoute({
···
35
34
let scan = scanIndexLocal(facts[token]);
36
35
let [root] = scan.eav(token, "root/page");
37
36
let rootEntity = root?.data.value || token;
38
-
let [title] = getBlocksWithTypeLocal(facts[token], rootEntity).filter(
39
-
(b) => b.type === "text" || b.type === "heading",
40
-
);
37
+
38
+
// Check page type to determine which blocks to look up
39
+
let [pageType] = scan.eav(rootEntity, "page/type");
40
+
let isCanvas = pageType?.data.value === "canvas";
41
+
42
+
// Get blocks and sort by position
43
+
let rawBlocks = isCanvas
44
+
? scan.eav(rootEntity, "canvas/block").sort((a, b) => {
45
+
if (a.data.position.y === b.data.position.y)
46
+
return a.data.position.x - b.data.position.x;
47
+
return a.data.position.y - b.data.position.y;
48
+
})
49
+
: scan.eav(rootEntity, "card/block").sort((a, b) => {
50
+
if (a.data.position === b.data.position)
51
+
return a.id > b.id ? 1 : -1;
52
+
return a.data.position > b.data.position ? 1 : -1;
53
+
});
54
+
55
+
// Map to get type and filter for text/heading
56
+
let blocks = rawBlocks
57
+
.map((b) => {
58
+
let type = scan.eav(b.data.value, "block/type")[0];
59
+
if (
60
+
!type ||
61
+
(type.data.value !== "text" && type.data.value !== "heading")
62
+
)
63
+
return null;
64
+
return b.data;
65
+
})
66
+
.filter((b): b is NonNullable<typeof b> => b !== null);
67
+
68
+
let title = blocks[0];
69
+
41
70
if (!title) titles[token] = "Untitled";
42
71
else {
43
72
let [content] = scan.eav(title.value, "block/text");
+4
-2
app/api/rpc/[command]/get_leaflet_data.ts
+4
-2
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
+
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({
···
18
19
.from("permission_tokens")
19
20
.select(
20
21
`*,
21
-
permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))),
22
+
permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))),
22
23
custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*),
23
-
${leaflets_in_publications_query}`,
24
+
${leaflets_in_publications_query},
25
+
${leaflets_to_documents_query}`,
24
26
)
25
27
.eq("id", token_id)
26
28
.single();
+6
app/api/rpc/[command]/pull.ts
+6
app/api/rpc/[command]/pull.ts
···
73
73
let publication_data = data.publications as {
74
74
description: string;
75
75
title: string;
76
+
tags: string[];
76
77
}[];
77
78
let pub_patch = publication_data?.[0]
78
79
? [
···
85
86
op: "put",
86
87
key: "publication_title",
87
88
value: publication_data[0].title,
89
+
},
90
+
{
91
+
op: "put",
92
+
key: "publication_tags",
93
+
value: publication_data[0].tags || [],
88
94
},
89
95
]
90
96
: [];
+4
app/api/rpc/[command]/route.ts
+4
app/api/rpc/[command]/route.ts
···
11
11
} from "./domain_routes";
12
12
import { get_leaflet_data } from "./get_leaflet_data";
13
13
import { get_publication_data } from "./get_publication_data";
14
+
import { search_publication_names } from "./search_publication_names";
15
+
import { search_publication_documents } from "./search_publication_documents";
14
16
15
17
let supabase = createClient<Database>(
16
18
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
35
37
get_leaflet_subdomain_status,
36
38
get_leaflet_data,
37
39
get_publication_data,
40
+
search_publication_names,
41
+
search_publication_documents,
38
42
];
39
43
export async function POST(
40
44
req: Request,
+52
app/api/rpc/[command]/search_publication_documents.ts
+52
app/api/rpc/[command]/search_publication_documents.ts
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { z } from "zod";
3
+
import { makeRoute } from "../lib";
4
+
import type { Env } from "./route";
5
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
+
7
+
export type SearchPublicationDocumentsReturnType = Awaited<
8
+
ReturnType<(typeof search_publication_documents)["handler"]>
9
+
>;
10
+
11
+
export const search_publication_documents = makeRoute({
12
+
route: "search_publication_documents",
13
+
input: z.object({
14
+
publication_uri: z.string(),
15
+
query: z.string(),
16
+
limit: z.number().optional().default(10),
17
+
}),
18
+
handler: async (
19
+
{ publication_uri, query, limit },
20
+
{ supabase }: Pick<Env, "supabase">,
21
+
) => {
22
+
// Get documents in the publication, filtering by title using JSON operator
23
+
// Also join with publications to get the record for URL construction
24
+
const { data: documents, error } = await supabase
25
+
.from("documents_in_publications")
26
+
.select(
27
+
"document, documents!inner(uri, data), publications!inner(uri, record)",
28
+
)
29
+
.eq("publication", publication_uri)
30
+
.ilike("documents.data->>title", `%${query}%`)
31
+
.limit(limit);
32
+
33
+
if (error) {
34
+
throw new Error(
35
+
`Failed to search publication documents: ${error.message}`,
36
+
);
37
+
}
38
+
39
+
const result = documents.map((d) => {
40
+
const docUri = new AtUri(d.documents.uri);
41
+
const pubUrl = getPublicationURL(d.publications);
42
+
43
+
return {
44
+
uri: d.documents.uri,
45
+
title: (d.documents.data as { title?: string })?.title || "Untitled",
46
+
url: `${pubUrl}/${docUri.rkey}`,
47
+
};
48
+
});
49
+
50
+
return { result: { documents: result } };
51
+
},
52
+
});
+39
app/api/rpc/[command]/search_publication_names.ts
+39
app/api/rpc/[command]/search_publication_names.ts
···
1
+
import { z } from "zod";
2
+
import { makeRoute } from "../lib";
3
+
import type { Env } from "./route";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
6
+
export type SearchPublicationNamesReturnType = Awaited<
7
+
ReturnType<(typeof search_publication_names)["handler"]>
8
+
>;
9
+
10
+
export const search_publication_names = makeRoute({
11
+
route: "search_publication_names",
12
+
input: z.object({
13
+
query: z.string(),
14
+
limit: z.number().optional().default(10),
15
+
}),
16
+
handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => {
17
+
// Search publications by name in record (case-insensitive partial match)
18
+
const { data: publications, error } = await supabase
19
+
.from("publications")
20
+
.select("uri, record")
21
+
.ilike("record->>name", `%${query}%`)
22
+
.limit(limit);
23
+
24
+
if (error) {
25
+
throw new Error(`Failed to search publications: ${error.message}`);
26
+
}
27
+
28
+
const result = publications.map((p) => {
29
+
const record = p.record as { name?: string };
30
+
return {
31
+
uri: p.uri,
32
+
name: record.name || "Untitled",
33
+
url: getPublicationURL(p),
34
+
};
35
+
});
36
+
37
+
return { result: { publications: result } };
38
+
},
39
+
});
+14
app/globals.css
+14
app/globals.css
···
211
211
212
212
/* END GLOBAL STYLING */
213
213
}
214
+
215
+
img {
216
+
font-size: 0;
217
+
}
218
+
214
219
button:hover {
215
220
cursor: pointer;
216
221
}
···
291
296
@apply py-[1.5px];
292
297
}
293
298
299
+
/* Underline mention nodes when selected in ProseMirror */
300
+
.ProseMirror .atMention.ProseMirror-selectednode,
301
+
.ProseMirror .didMention.ProseMirror-selectednode {
302
+
text-decoration: underline;
303
+
}
304
+
294
305
.ProseMirror:focus-within .selection-highlight {
295
306
background-color: transparent;
296
307
}
···
339
350
@apply focus-within:outline-offset-1;
340
351
341
352
@apply disabled:border-border-light;
353
+
@apply disabled:hover:border-border-light;
342
354
@apply disabled:bg-border-light;
343
355
@apply disabled:text-tertiary;
344
356
}
···
413
425
outline: none !important;
414
426
cursor: pointer;
415
427
background-color: transparent;
428
+
display: flex;
429
+
gap: 0.5rem;
416
430
417
431
:hover {
418
432
text-decoration: none !important;
+36
-206
app/lish/Subscribe.tsx
+36
-206
app/lish/Subscribe.tsx
···
23
23
import { useSearchParams } from "next/navigation";
24
24
import LoginForm from "app/login/LoginForm";
25
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
-
import { SpeedyLink } from "components/SpeedyLink";
27
-
28
-
type State =
29
-
| { state: "email" }
30
-
| { state: "code"; token: string }
31
-
| { state: "success" };
32
-
export const SubscribeButton = (props: {
33
-
compact?: boolean;
34
-
publication: string;
35
-
}) => {
36
-
let { identity, mutate } = useIdentityData();
37
-
let [emailInputValue, setEmailInputValue] = useState("");
38
-
let [codeInputValue, setCodeInputValue] = useState("");
39
-
let [state, setState] = useState<State>({ state: "email" });
40
-
41
-
if (state.state === "email") {
42
-
return (
43
-
<div className="flex gap-2">
44
-
<div className="flex relative w-full max-w-sm">
45
-
<Input
46
-
type="email"
47
-
className="input-with-border pr-[104px]! py-1! grow w-full"
48
-
placeholder={
49
-
props.compact ? "subscribe with email..." : "email here..."
50
-
}
51
-
disabled={!!identity?.email}
52
-
value={identity?.email ? identity.email : emailInputValue}
53
-
onChange={(e) => {
54
-
setEmailInputValue(e.currentTarget.value);
55
-
}}
56
-
/>
57
-
<ButtonPrimary
58
-
compact
59
-
className="absolute right-1 top-1 outline-0!"
60
-
onClick={async () => {
61
-
if (identity?.email) {
62
-
await subscribeToPublicationWithEmail(props.publication);
63
-
//optimistically could add!
64
-
await mutate();
65
-
return;
66
-
}
67
-
let tokenID = await requestAuthEmailToken(emailInputValue);
68
-
setState({ state: "code", token: tokenID });
69
-
}}
70
-
>
71
-
{props.compact ? (
72
-
<ArrowRightTiny className="w-4 h-6" />
73
-
) : (
74
-
"Subscribe"
75
-
)}
76
-
</ButtonPrimary>
77
-
</div>
78
-
{/* <ShareButton /> */}
79
-
</div>
80
-
);
81
-
}
82
-
if (state.state === "code") {
83
-
return (
84
-
<div
85
-
className="w-full flex flex-col justify-center place-items-center p-4 rounded-md"
86
-
style={{
87
-
background:
88
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
89
-
}}
90
-
>
91
-
<div className="flex flex-col leading-snug text-secondary">
92
-
<div>Please enter the code we sent to </div>
93
-
<div className="italic font-bold">{emailInputValue}</div>
94
-
</div>
95
-
96
-
<ConfirmCodeInput
97
-
publication={props.publication}
98
-
token={state.token}
99
-
codeInputValue={codeInputValue}
100
-
setCodeInputValue={setCodeInputValue}
101
-
setState={setState}
102
-
/>
103
-
104
-
<button
105
-
className="text-accent-contrast text-sm mt-1"
106
-
onClick={() => {
107
-
setState({ state: "email" });
108
-
}}
109
-
>
110
-
Re-enter Email
111
-
</button>
112
-
</div>
113
-
);
114
-
}
115
-
116
-
if (state.state === "success") {
117
-
return (
118
-
<div
119
-
className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`}
120
-
style={{
121
-
background:
122
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
123
-
}}
124
-
>
125
-
<div className="flex gap-2 leading-snug font-bold italic">
126
-
<div>You're subscribed!</div>
127
-
{/* <ShareButton /> */}
128
-
</div>
129
-
</div>
130
-
);
131
-
}
132
-
};
133
-
134
-
export const ShareButton = () => {
135
-
return (
136
-
<button className="text-accent-contrast">
137
-
<ShareSmall />
138
-
</button>
139
-
);
140
-
};
141
-
142
-
const ConfirmCodeInput = (props: {
143
-
codeInputValue: string;
144
-
token: string;
145
-
setCodeInputValue: (value: string) => void;
146
-
setState: (state: State) => void;
147
-
publication: string;
148
-
}) => {
149
-
let { mutate } = useIdentityData();
150
-
return (
151
-
<div className="relative w-fit mt-2">
152
-
<Input
153
-
type="text"
154
-
pattern="[0-9]"
155
-
className="input-with-border pr-[88px]! py-1! max-w-[156px]"
156
-
placeholder="000000"
157
-
value={props.codeInputValue}
158
-
onChange={(e) => {
159
-
props.setCodeInputValue(e.currentTarget.value);
160
-
}}
161
-
/>
162
-
<ButtonPrimary
163
-
compact
164
-
className="absolute right-1 top-1 outline-0!"
165
-
onClick={async () => {
166
-
console.log(
167
-
await confirmEmailAuthToken(props.token, props.codeInputValue),
168
-
);
169
-
170
-
await subscribeToPublicationWithEmail(props.publication);
171
-
//optimistically could add!
172
-
await mutate();
173
-
props.setState({ state: "success" });
174
-
return;
175
-
}}
176
-
>
177
-
Confirm
178
-
</ButtonPrimary>
179
-
</div>
180
-
);
181
-
};
182
26
183
27
export const SubscribeWithBluesky = (props: {
184
-
isPost?: boolean;
185
28
pubName: string;
186
29
pub_uri: string;
187
30
base_url: string;
···
208
51
}
209
52
return (
210
53
<div className="flex flex-col gap-2 text-center justify-center">
211
-
{props.isPost && (
212
-
<div className="text-sm text-tertiary font-bold">
213
-
Get updates from {props.pubName}!
214
-
</div>
215
-
)}
216
54
<div className="flex flex-row gap-2 place-self-center">
217
55
<BlueskySubscribeButton
218
56
pub_uri={props.pub_uri}
···
231
69
);
232
70
};
233
71
234
-
const ManageSubscription = (props: {
235
-
isPost?: boolean;
236
-
pubName: string;
72
+
export const ManageSubscription = (props: {
237
73
pub_uri: string;
238
74
subscribers: { identity: string }[];
239
75
base_url: string;
···
248
84
});
249
85
}, null);
250
86
return (
251
-
<div
252
-
className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`}
87
+
<Popover
88
+
trigger={
89
+
<div className="text-accent-contrast text-sm">Manage Subscription</div>
90
+
}
253
91
>
254
-
<div className="font-bold text-tertiary text-sm">
255
-
You're Subscribed{props.isPost ? ` to ` : "!"}
256
-
{props.isPost && (
257
-
<SpeedyLink href={props.base_url} className="text-accent-contrast">
258
-
{props.pubName}
259
-
</SpeedyLink>
260
-
)}
261
-
</div>
262
-
<Popover
263
-
trigger={<div className="text-accent-contrast text-sm">Manage</div>}
264
-
>
265
-
<div className="max-w-sm flex flex-col gap-1">
266
-
<h4>Update Options</h4>
92
+
<div className="max-w-sm flex flex-col gap-1">
93
+
<h4>Update Options</h4>
267
94
268
-
{!hasFeed && (
269
-
<a
270
-
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
271
-
target="_blank"
272
-
className=" place-self-center"
273
-
>
274
-
<ButtonPrimary fullWidth compact className="!px-4">
275
-
View Bluesky Custom Feed
276
-
</ButtonPrimary>
277
-
</a>
278
-
)}
279
-
95
+
{!hasFeed && (
280
96
<a
281
-
href={`${props.base_url}/rss`}
282
-
className="flex"
97
+
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
283
98
target="_blank"
284
-
aria-label="Subscribe to RSS"
99
+
className=" place-self-center"
285
100
>
286
-
<ButtonPrimary fullWidth compact>
287
-
Get RSS
101
+
<ButtonPrimary fullWidth compact className="!px-4">
102
+
View Bluesky Custom Feed
288
103
</ButtonPrimary>
289
104
</a>
105
+
)}
290
106
291
-
<hr className="border-border-light my-1" />
107
+
<a
108
+
href={`${props.base_url}/rss`}
109
+
className="flex"
110
+
target="_blank"
111
+
aria-label="Subscribe to RSS"
112
+
>
113
+
<ButtonPrimary fullWidth compact>
114
+
Get RSS
115
+
</ButtonPrimary>
116
+
</a>
292
117
293
-
<form action={unsubscribe}>
294
-
<button className="font-bold text-accent-contrast w-max place-self-center">
295
-
{unsubscribePending ? <DotLoader /> : "Unsubscribe"}
296
-
</button>
297
-
</form>
298
-
</div>{" "}
299
-
</Popover>
300
-
</div>
118
+
<hr className="border-border-light my-1" />
119
+
120
+
<form action={unsubscribe}>
121
+
<button className="font-bold text-accent-contrast w-max place-self-center">
122
+
{unsubscribePending ? <DotLoader /> : "Unsubscribe"}
123
+
</button>
124
+
</form>
125
+
</div>
126
+
</Popover>
301
127
);
302
128
};
303
129
···
430
256
</Dialog.Root>
431
257
);
432
258
};
259
+
260
+
export const SubscribeOnPost = () => {
261
+
return <div></div>;
262
+
};
+24
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
+24
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
···
1
+
"use client";
2
+
3
+
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
4
+
5
+
export function PublicationHomeLayout(props: {
6
+
uri: string;
7
+
showPageBackground: boolean;
8
+
children: React.ReactNode;
9
+
}) {
10
+
let { ref } = usePreserveScroll<HTMLDivElement>(props.uri);
11
+
return (
12
+
<div
13
+
ref={props.showPageBackground ? null : ref}
14
+
className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`}
15
+
>
16
+
<div
17
+
ref={!props.showPageBackground ? null : ref}
18
+
className={`pub sm:max-w-prose max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`}
19
+
>
20
+
{props.children}
21
+
</div>
22
+
</div>
23
+
);
24
+
}
+39
-3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
+39
-3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
1
1
import { UnicodeString } from "@atproto/api";
2
2
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { didToBlueskyUrl } from "src/utils/mentionUtils";
4
+
import { AtMentionLink } from "components/AtMentionLink";
3
5
4
6
type Facet = PubLeafletRichtextFacet.Main;
5
7
export function BaseTextBlock(props: {
···
22
24
let isStrikethrough = segment.facet?.find(
23
25
PubLeafletRichtextFacet.isStrikethrough,
24
26
);
27
+
let isDidMention = segment.facet?.find(
28
+
PubLeafletRichtextFacet.isDidMention,
29
+
);
30
+
let isAtMention = segment.facet?.find(
31
+
PubLeafletRichtextFacet.isAtMention,
32
+
);
25
33
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
26
34
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
27
35
let isHighlighted = segment.facet?.find(
···
36
44
${isStrikethrough ? "line-through decoration-tertiary" : ""}
37
45
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
38
46
47
+
// Split text by newlines and insert <br> tags
48
+
const textParts = segment.text.split('\n');
49
+
const renderedText = textParts.flatMap((part, i) =>
50
+
i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part]
51
+
);
52
+
39
53
if (isCode) {
40
54
children.push(
41
55
<code key={counter} className={className} id={id?.id}>
42
-
{segment.text}
56
+
{renderedText}
43
57
</code>,
44
58
);
59
+
} else if (isDidMention) {
60
+
children.push(
61
+
<a
62
+
key={counter}
63
+
href={didToBlueskyUrl(isDidMention.did)}
64
+
className={`text-accent-contrast hover:underline cursor-pointer ${className}`}
65
+
target="_blank"
66
+
rel="noopener noreferrer"
67
+
>
68
+
{renderedText}
69
+
</a>,
70
+
);
71
+
} else if (isAtMention) {
72
+
children.push(
73
+
<AtMentionLink
74
+
key={counter}
75
+
atURI={isAtMention.atURI}
76
+
className={className}
77
+
>
78
+
{renderedText}
79
+
</AtMentionLink>,
80
+
);
45
81
} else if (link) {
46
82
children.push(
47
83
<a
···
50
86
className={`text-accent-contrast hover:underline ${className}`}
51
87
target="_blank"
52
88
>
53
-
{segment.text}
89
+
{renderedText}
54
90
</a>,
55
91
);
56
92
} else {
57
93
children.push(
58
94
<span key={counter} className={className} id={id?.id}>
59
-
{segment.text}
95
+
{renderedText}
60
96
</span>,
61
97
);
62
98
}
+25
-31
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+25
-31
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
21
21
import { PostHeader } from "./PostHeader/PostHeader";
22
22
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
23
23
import { PollData } from "./fetchPollData";
24
+
import { SharedPageProps } from "./PostPages";
25
+
import { useIsMobile } from "src/hooks/isMobile";
24
26
25
27
export function CanvasPage({
26
-
document,
27
28
blocks,
28
-
did,
29
-
profile,
30
-
preferences,
31
-
pubRecord,
32
-
prerenderedCodeBlocks,
33
-
bskyPostData,
34
-
pollData,
35
-
document_uri,
36
-
pageId,
37
-
pageOptions,
38
-
fullPageScroll,
39
29
pages,
40
-
}: {
41
-
document_uri: string;
42
-
document: PostPageData;
30
+
...props
31
+
}: Omit<SharedPageProps, "allPages"> & {
43
32
blocks: PubLeafletPagesCanvas.Block[];
44
-
profile: ProfileViewDetailed;
45
-
pubRecord: PubLeafletPublication.Record;
46
-
did: string;
47
-
prerenderedCodeBlocks?: Map<string, string>;
48
-
bskyPostData: AppBskyFeedDefs.PostView[];
49
-
pollData: PollData[];
50
-
preferences: { showComments?: boolean };
51
-
pageId?: string;
52
-
pageOptions?: React.ReactNode;
53
-
fullPageScroll: boolean;
54
33
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
55
34
}) {
35
+
const {
36
+
document,
37
+
did,
38
+
profile,
39
+
preferences,
40
+
pubRecord,
41
+
theme,
42
+
prerenderedCodeBlocks,
43
+
bskyPostData,
44
+
pollData,
45
+
document_uri,
46
+
pageId,
47
+
pageOptions,
48
+
fullPageScroll,
49
+
hasPageBackground,
50
+
} = props;
56
51
if (!document) return null;
57
52
58
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
59
53
let isSubpage = !!pageId;
60
54
let drawer = useDrawerOpen(document_uri);
61
55
···
213
207
quotesCount: number | undefined;
214
208
commentsCount: number | undefined;
215
209
}) => {
210
+
let isMobile = useIsMobile();
216
211
return (
217
-
<div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
212
+
<div className="flex flex-row gap-3 items-center absolute top-3 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
218
213
<Interactions
219
214
quotesCount={props.quotesCount || 0}
220
215
commentsCount={props.commentsCount || 0}
221
-
compact
222
216
showComments={props.preferences.showComments}
223
217
pageId={props.pageId}
224
218
/>
···
226
220
<>
227
221
<Separator classname="h-5" />
228
222
<Popover
229
-
side="left"
230
-
align="start"
231
-
className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
223
+
side="bottom"
224
+
align="end"
225
+
className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`}
232
226
trigger={<InfoSmall />}
233
227
>
234
228
<PostHeader
+146
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
+146
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
···
1
+
import { AtpAgent } from "@atproto/api";
2
+
import { AtUri } from "@atproto/syntax";
3
+
import { ids } from "lexicons/api/lexicons";
4
+
import {
5
+
PubLeafletBlocksBskyPost,
6
+
PubLeafletDocument,
7
+
PubLeafletPagesLinearDocument,
8
+
PubLeafletPagesCanvas,
9
+
PubLeafletPublication,
10
+
} from "lexicons/api";
11
+
import { QuoteHandler } from "./QuoteHandler";
12
+
import {
13
+
PublicationBackgroundProvider,
14
+
PublicationThemeProvider,
15
+
} from "components/ThemeManager/PublicationThemeProvider";
16
+
import { getPostPageData } from "./getPostPageData";
17
+
import { PostPageContextProvider } from "./PostPageContext";
18
+
import { PostPages } from "./PostPages";
19
+
import { extractCodeBlocks } from "./extractCodeBlocks";
20
+
import { LeafletLayout } from "components/LeafletLayout";
21
+
import { fetchPollData } from "./fetchPollData";
22
+
23
+
export async function DocumentPageRenderer({
24
+
did,
25
+
rkey,
26
+
}: {
27
+
did: string;
28
+
rkey: string;
29
+
}) {
30
+
let agent = new AtpAgent({
31
+
service: "https://public.api.bsky.app",
32
+
fetch: (...args) =>
33
+
fetch(args[0], {
34
+
...args[1],
35
+
next: { revalidate: 3600 },
36
+
}),
37
+
});
38
+
39
+
let [document, profile] = await Promise.all([
40
+
getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()),
41
+
agent.getProfile({ actor: did }),
42
+
]);
43
+
44
+
if (!document?.data)
45
+
return (
46
+
<div className="bg-bg-leaflet h-full p-3 text-center relative">
47
+
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full">
48
+
<div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 ">
49
+
<h3>Sorry, post not found!</h3>
50
+
<p>
51
+
This may be a glitch on our end. If the issue persists please{" "}
52
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
53
+
</p>
54
+
</div>
55
+
</div>
56
+
</div>
57
+
);
58
+
59
+
let record = document.data as PubLeafletDocument.Record;
60
+
let bskyPosts =
61
+
record.pages.flatMap((p) => {
62
+
let page = p as PubLeafletPagesLinearDocument.Main;
63
+
return page.blocks?.filter(
64
+
(b) => b.block.$type === ids.PubLeafletBlocksBskyPost,
65
+
);
66
+
}) || [];
67
+
68
+
// Batch bsky posts into groups of 25 and fetch in parallel
69
+
let bskyPostBatches = [];
70
+
for (let i = 0; i < bskyPosts.length; i += 25) {
71
+
bskyPostBatches.push(bskyPosts.slice(i, i + 25));
72
+
}
73
+
74
+
let bskyPostResponses = await Promise.all(
75
+
bskyPostBatches.map((batch) =>
76
+
agent.getPosts(
77
+
{
78
+
uris: batch.map((p) => {
79
+
let block = p?.block as PubLeafletBlocksBskyPost.Main;
80
+
return block.postRef.uri;
81
+
}),
82
+
},
83
+
{ headers: {} },
84
+
),
85
+
),
86
+
);
87
+
88
+
let bskyPostData =
89
+
bskyPostResponses.length > 0
90
+
? bskyPostResponses.flatMap((response) => response.data.posts)
91
+
: [];
92
+
93
+
// Extract poll blocks and fetch vote data
94
+
let pollBlocks = record.pages.flatMap((p) => {
95
+
let page = p as PubLeafletPagesLinearDocument.Main;
96
+
return (
97
+
page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) ||
98
+
[]
99
+
);
100
+
});
101
+
let pollData = await fetchPollData(
102
+
pollBlocks.map((b) => (b.block as any).pollRef.uri),
103
+
);
104
+
105
+
// Get theme from publication or document (for standalone docs)
106
+
let pubRecord = document.documents_in_publications[0]?.publications
107
+
?.record as PubLeafletPublication.Record | undefined;
108
+
let theme = pubRecord?.theme || record.theme || null;
109
+
let pub_creator =
110
+
document.documents_in_publications[0]?.publications?.identity_did || did;
111
+
let isStandalone = !pubRecord;
112
+
113
+
let firstPage = record.pages[0];
114
+
115
+
let firstPageBlocks =
116
+
(
117
+
firstPage as
118
+
| PubLeafletPagesLinearDocument.Main
119
+
| PubLeafletPagesCanvas.Main
120
+
).blocks || [];
121
+
let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks);
122
+
123
+
return (
124
+
<PostPageContextProvider value={document}>
125
+
<PublicationThemeProvider theme={theme} pub_creator={pub_creator} isStandalone={isStandalone}>
126
+
<PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}>
127
+
<LeafletLayout>
128
+
<PostPages
129
+
document_uri={document.uri}
130
+
preferences={pubRecord?.preferences || {}}
131
+
pubRecord={pubRecord}
132
+
profile={JSON.parse(JSON.stringify(profile.data))}
133
+
document={document}
134
+
bskyPostData={bskyPostData}
135
+
did={did}
136
+
prerenderedCodeBlocks={prerenderedCodeBlocks}
137
+
pollData={pollData}
138
+
/>
139
+
</LeafletLayout>
140
+
141
+
<QuoteHandler />
142
+
</PublicationBackgroundProvider>
143
+
</PublicationThemeProvider>
144
+
</PostPageContextProvider>
145
+
);
146
+
}
+223
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+223
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
8
8
import { EditorState, TextSelection } from "prosemirror-state";
9
9
import { EditorView } from "prosemirror-view";
10
10
import { history, redo, undo } from "prosemirror-history";
11
+
import { InputRule, inputRules } from "prosemirror-inputrules";
11
12
import {
12
13
MutableRefObject,
13
14
RefObject,
15
+
useCallback,
14
16
useEffect,
15
17
useLayoutEffect,
16
18
useRef,
···
36
38
import { CloseTiny } from "components/Icons/CloseTiny";
37
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
38
40
import { betterIsUrl } from "src/utils/isURL";
41
+
import { Mention, MentionAutocomplete } from "components/Mention";
42
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
43
+
44
+
const addMentionToEditor = (
45
+
mention: Mention,
46
+
range: { from: number; to: number },
47
+
view: EditorView,
48
+
) => {
49
+
if (!view) return;
50
+
const { from, to } = range;
51
+
const tr = view.state.tr;
52
+
53
+
if (mention.type === "did") {
54
+
// Delete the @ and any query text
55
+
tr.delete(from, to);
56
+
// Insert didMention inline node
57
+
const mentionText = "@" + mention.handle;
58
+
const didMentionNode = multiBlockSchema.nodes.didMention.create({
59
+
did: mention.did,
60
+
text: mentionText,
61
+
});
62
+
tr.insert(from, didMentionNode);
63
+
// Add a space after the mention
64
+
tr.insertText(" ", from + 1);
65
+
}
66
+
if (mention.type === "publication" || mention.type === "post") {
67
+
// Delete the @ and any query text
68
+
tr.delete(from, to);
69
+
let name = mention.type === "post" ? mention.title : mention.name;
70
+
// Insert atMention inline node
71
+
const atMentionNode = multiBlockSchema.nodes.atMention.create({
72
+
atURI: mention.uri,
73
+
text: name,
74
+
});
75
+
tr.insert(from, atMentionNode);
76
+
// Add a space after the mention
77
+
tr.insertText(" ", from + 1);
78
+
}
79
+
80
+
view.dispatch(tr);
81
+
view.focus();
82
+
};
39
83
40
84
export function CommentBox(props: {
41
85
doc_uri: string;
···
50
94
commentBox: { quote },
51
95
} = useInteractionState(props.doc_uri);
52
96
let [loading, setLoading] = useState(false);
97
+
let view = useRef<null | EditorView>(null);
98
+
99
+
// Mention autocomplete state
100
+
const [mentionOpen, setMentionOpen] = useState(false);
101
+
const [mentionCoords, setMentionCoords] = useState<{
102
+
top: number;
103
+
left: number;
104
+
} | null>(null);
105
+
// Use a ref for insert position to avoid stale closure issues
106
+
const mentionInsertPosRef = useRef<number | null>(null);
107
+
108
+
// Use a ref for the callback so input rules can access it
109
+
const openMentionAutocompleteRef = useRef<() => void>(() => {});
110
+
openMentionAutocompleteRef.current = () => {
111
+
if (!view.current) return;
53
112
54
-
const handleSubmit = async () => {
113
+
const pos = view.current.state.selection.from;
114
+
mentionInsertPosRef.current = pos;
115
+
116
+
// Get coordinates for the popup relative to the positioned parent
117
+
const coords = view.current.coordsAtPos(pos - 1);
118
+
119
+
// Find the relative positioned parent container
120
+
const editorEl = view.current.dom;
121
+
const container = editorEl.closest(".relative") as HTMLElement | null;
122
+
123
+
if (container) {
124
+
const containerRect = container.getBoundingClientRect();
125
+
setMentionCoords({
126
+
top: coords.bottom - containerRect.top,
127
+
left: coords.left - containerRect.left,
128
+
});
129
+
} else {
130
+
setMentionCoords({
131
+
top: coords.bottom,
132
+
left: coords.left,
133
+
});
134
+
}
135
+
setMentionOpen(true);
136
+
};
137
+
138
+
const handleMentionSelect = useCallback((mention: Mention) => {
139
+
if (!view.current || mentionInsertPosRef.current === null) return;
140
+
141
+
const from = mentionInsertPosRef.current - 1;
142
+
const to = mentionInsertPosRef.current;
143
+
144
+
addMentionToEditor(mention, { from, to }, view.current);
145
+
view.current.focus();
146
+
}, []);
147
+
148
+
const handleMentionOpenChange = useCallback((open: boolean) => {
149
+
setMentionOpen(open);
150
+
if (!open) {
151
+
setMentionCoords(null);
152
+
mentionInsertPosRef.current = null;
153
+
}
154
+
}, []);
155
+
156
+
// Use a ref for handleSubmit so keyboard shortcuts can access it
157
+
const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
158
+
handleSubmitRef.current = async () => {
55
159
if (loading || !view.current) return;
56
160
57
161
setLoading(true);
···
114
218
"Mod-y": redo,
115
219
"Shift-Mod-z": redo,
116
220
"Ctrl-Enter": () => {
117
-
handleSubmit();
221
+
handleSubmitRef.current();
118
222
return true;
119
223
},
120
224
"Meta-Enter": () => {
121
-
handleSubmit();
225
+
handleSubmitRef.current();
122
226
return true;
123
227
},
124
228
}),
···
128
232
shouldAutoLink: () => true,
129
233
defaultProtocol: "https",
130
234
}),
235
+
// Input rules for @ mentions
236
+
inputRules({
237
+
rules: [
238
+
// @ at start of line or after space
239
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
240
+
setTimeout(() => openMentionAutocompleteRef.current(), 0);
241
+
return null;
242
+
}),
243
+
],
244
+
}),
131
245
history(),
132
246
],
133
247
}),
134
248
);
135
-
let view = useRef<null | EditorView>(null);
136
249
useLayoutEffect(() => {
137
250
if (!mountRef.current) return;
138
251
view.current = new EditorView(
···
187
300
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
188
301
if (!direct) return;
189
302
if (node.nodeSize - 2 <= _pos) return;
303
+
304
+
const nodeAt1 = node.nodeAt(_pos - 1);
305
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
306
+
307
+
// Check for link marks
190
308
let mark =
191
-
node
192
-
.nodeAt(_pos - 1)
193
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link) ||
194
-
node
195
-
.nodeAt(Math.max(_pos - 2, 0))
196
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link);
309
+
nodeAt1?.marks.find(
310
+
(f) => f.type === multiBlockSchema.marks.link,
311
+
) ||
312
+
nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link);
197
313
if (mark) {
198
314
window.open(mark.attrs.href, "_blank");
315
+
return;
316
+
}
317
+
318
+
// Check for didMention inline nodes
319
+
if (nodeAt1?.type === multiBlockSchema.nodes.didMention) {
320
+
window.open(
321
+
didToBlueskyUrl(nodeAt1.attrs.did),
322
+
"_blank",
323
+
"noopener,noreferrer",
324
+
);
325
+
return;
326
+
}
327
+
if (nodeAt2?.type === multiBlockSchema.nodes.didMention) {
328
+
window.open(
329
+
didToBlueskyUrl(nodeAt2.attrs.did),
330
+
"_blank",
331
+
"noopener,noreferrer",
332
+
);
333
+
return;
334
+
}
335
+
336
+
// Check for atMention inline nodes (publications/documents)
337
+
if (nodeAt1?.type === multiBlockSchema.nodes.atMention) {
338
+
window.open(
339
+
atUriToUrl(nodeAt1.attrs.atURI),
340
+
"_blank",
341
+
"noopener,noreferrer",
342
+
);
343
+
return;
344
+
}
345
+
if (nodeAt2?.type === multiBlockSchema.nodes.atMention) {
346
+
window.open(
347
+
atUriToUrl(nodeAt2.attrs.atURI),
348
+
"_blank",
349
+
"noopener,noreferrer",
350
+
);
351
+
return;
199
352
}
200
353
},
201
354
dispatchTransaction(tr) {
···
236
389
<div className="w-full relative group">
237
390
<pre
238
391
ref={mountRef}
392
+
onFocus={() => {
393
+
// Close mention dropdown when editor gains focus (reset stale state)
394
+
handleMentionOpenChange(false);
395
+
}}
396
+
onBlur={(e) => {
397
+
// Close mention dropdown when editor loses focus
398
+
// But not if focus moved to the mention autocomplete
399
+
const relatedTarget = e.relatedTarget as HTMLElement | null;
400
+
if (!relatedTarget?.closest(".dropdownMenu")) {
401
+
handleMentionOpenChange(false);
402
+
}
403
+
}}
239
404
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
240
405
/>
241
406
<IOSBS view={view} />
407
+
<MentionAutocomplete
408
+
open={mentionOpen}
409
+
onOpenChange={handleMentionOpenChange}
410
+
view={view}
411
+
onSelect={handleMentionSelect}
412
+
coords={mentionCoords}
413
+
/>
242
414
</div>
243
415
<div className="flex justify-between pt-1">
244
416
<div className="flex gap-1">
···
261
433
view={view}
262
434
/>
263
435
</div>
264
-
<ButtonPrimary compact onClick={handleSubmit}>
436
+
<ButtonPrimary compact onClick={() => handleSubmitRef.current()}>
265
437
{loading ? <DotLoader /> : <ShareSmall />}
266
438
</ButtonPrimary>
267
439
</div>
···
328
500
facets.push(facet);
329
501
}
330
502
}
503
+
504
+
fullText += text;
505
+
byteOffset += unicodeString.length;
506
+
} else if (node.type.name === "didMention") {
507
+
// Handle DID mention nodes
508
+
const text = node.attrs.text || "";
509
+
const unicodeString = new UnicodeString(text);
510
+
511
+
facets.push({
512
+
index: {
513
+
byteStart: byteOffset,
514
+
byteEnd: byteOffset + unicodeString.length,
515
+
},
516
+
features: [
517
+
{
518
+
$type: "pub.leaflet.richtext.facet#didMention",
519
+
did: node.attrs.did,
520
+
},
521
+
],
522
+
});
523
+
524
+
fullText += text;
525
+
byteOffset += unicodeString.length;
526
+
} else if (node.type.name === "atMention") {
527
+
// Handle AT-URI mention nodes (publications and documents)
528
+
const text = node.attrs.text || "";
529
+
const unicodeString = new UnicodeString(text);
530
+
531
+
facets.push({
532
+
index: {
533
+
byteStart: byteOffset,
534
+
byteEnd: byteOffset + unicodeString.length,
535
+
},
536
+
features: [
537
+
{
538
+
$type: "pub.leaflet.richtext.facet#atMention",
539
+
atURI: node.attrs.atURI,
540
+
},
541
+
],
542
+
});
331
543
332
544
fullText += text;
333
545
byteOffset += unicodeString.length;
+98
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
+98
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
10
10
import { Json } from "supabase/database.types";
11
11
import {
12
12
Notification,
13
+
NotificationData,
13
14
pingIdentityToUpdateNotification,
14
15
} from "src/notifications";
15
16
import { v7 } from "uuid";
···
84
85
parent_uri: args.comment.replyTo,
85
86
},
86
87
});
88
+
}
89
+
90
+
// Create mention notifications from comment facets
91
+
const mentionNotifications = createCommentMentionNotifications(
92
+
args.comment.facets,
93
+
uri.toString(),
94
+
credentialSession.did!,
95
+
);
96
+
notifications.push(...mentionNotifications);
97
+
98
+
// Insert all notifications and ping recipients
99
+
if (notifications.length > 0) {
87
100
// SOMEDAY: move this out the action with inngest or workflows
88
101
await supabaseServerClient.from("notifications").insert(notifications);
89
-
await pingIdentityToUpdateNotification(recipient);
102
+
103
+
// Ping all unique recipients
104
+
const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))];
105
+
await Promise.all(
106
+
uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)),
107
+
);
90
108
}
91
109
92
110
return {
···
95
113
uri: uri.toString(),
96
114
};
97
115
}
116
+
117
+
/**
118
+
* Creates mention notifications from comment facets
119
+
* Handles didMention (people) and atMention (publications/documents)
120
+
*/
121
+
function createCommentMentionNotifications(
122
+
facets: PubLeafletRichtextFacet.Main[],
123
+
commentUri: string,
124
+
commenterDid: string,
125
+
): Notification[] {
126
+
const notifications: Notification[] = [];
127
+
const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications
128
+
129
+
for (const facet of facets) {
130
+
for (const feature of facet.features) {
131
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
132
+
// DID mention - notify the mentioned person directly
133
+
const recipientDid = feature.did;
134
+
135
+
// Don't notify yourself
136
+
if (recipientDid === commenterDid) continue;
137
+
// Avoid duplicate notifications to the same person
138
+
if (notifiedRecipients.has(recipientDid)) continue;
139
+
notifiedRecipients.add(recipientDid);
140
+
141
+
notifications.push({
142
+
id: v7(),
143
+
recipient: recipientDid,
144
+
data: {
145
+
type: "comment_mention",
146
+
comment_uri: commentUri,
147
+
mention_type: "did",
148
+
},
149
+
});
150
+
} else if (PubLeafletRichtextFacet.isAtMention(feature)) {
151
+
// AT-URI mention - notify the owner of the publication/document
152
+
try {
153
+
const mentionedUri = new AtUri(feature.atURI);
154
+
const recipientDid = mentionedUri.host;
155
+
156
+
// Don't notify yourself
157
+
if (recipientDid === commenterDid) continue;
158
+
// Avoid duplicate notifications to the same person for the same mentioned item
159
+
const dedupeKey = `${recipientDid}:${feature.atURI}`;
160
+
if (notifiedRecipients.has(dedupeKey)) continue;
161
+
notifiedRecipients.add(dedupeKey);
162
+
163
+
if (mentionedUri.collection === "pub.leaflet.publication") {
164
+
notifications.push({
165
+
id: v7(),
166
+
recipient: recipientDid,
167
+
data: {
168
+
type: "comment_mention",
169
+
comment_uri: commentUri,
170
+
mention_type: "publication",
171
+
mentioned_uri: feature.atURI,
172
+
},
173
+
});
174
+
} else if (mentionedUri.collection === "pub.leaflet.document") {
175
+
notifications.push({
176
+
id: v7(),
177
+
recipient: recipientDid,
178
+
data: {
179
+
type: "comment_mention",
180
+
comment_uri: commentUri,
181
+
mention_type: "document",
182
+
mentioned_uri: feature.atURI,
183
+
},
184
+
});
185
+
}
186
+
} catch (error) {
187
+
console.error("Failed to parse AT-URI for mention:", feature.atURI, error);
188
+
}
189
+
}
190
+
}
191
+
}
192
+
193
+
return notifications;
194
+
}
+208
-30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+208
-30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
9
9
import { useContext } from "react";
10
10
import { PostPageContext } from "../PostPageContext";
11
11
import { scrollIntoView } from "src/utils/scrollIntoView";
12
+
import { TagTiny } from "components/Icons/TagTiny";
13
+
import { Tag } from "components/Tags";
14
+
import { Popover } from "components/Popover";
12
15
import { PostPageData } from "../getPostPageData";
13
-
import { PubLeafletComment } from "lexicons/api";
16
+
import { PubLeafletComment, PubLeafletPublication } from "lexicons/api";
14
17
import { prefetchQuotesData } from "./Quotes";
18
+
import { useIdentityData } from "components/IdentityProvider";
19
+
import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe";
20
+
import { EditTiny } from "components/Icons/EditTiny";
21
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
15
22
16
23
export type InteractionState = {
17
24
drawerOpen: undefined | boolean;
···
99
106
export const Interactions = (props: {
100
107
quotesCount: number;
101
108
commentsCount: number;
102
-
compact?: boolean;
103
109
className?: string;
104
110
showComments?: boolean;
105
111
pageId?: string;
106
112
}) => {
107
113
const data = useContext(PostPageContext);
108
114
const document_uri = data?.uri;
115
+
let { identity } = useIdentityData();
109
116
if (!document_uri)
110
117
throw new Error("document_uri not available in PostPageContext");
111
118
···
117
124
}
118
125
};
119
126
127
+
const tags = (data?.data as any)?.tags as string[] | undefined;
128
+
const tagCount = tags?.length || 0;
129
+
120
130
return (
121
-
<div
122
-
className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`}
123
-
>
124
-
<button
125
-
className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`}
126
-
onClick={() => {
127
-
if (!drawerOpen || drawer !== "quotes")
128
-
openInteractionDrawer("quotes", document_uri, props.pageId);
129
-
else setInteractionState(document_uri, { drawerOpen: false });
130
-
}}
131
-
onMouseEnter={handleQuotePrefetch}
132
-
onTouchStart={handleQuotePrefetch}
133
-
aria-label="Post quotes"
134
-
>
135
-
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
136
-
{!props.compact && (
137
-
<span
138
-
aria-hidden
139
-
>{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span>
140
-
)}
141
-
</button>
131
+
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
132
+
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
133
+
134
+
{props.quotesCount > 0 && (
135
+
<button
136
+
className="flex w-fit gap-2 items-center"
137
+
onClick={() => {
138
+
if (!drawerOpen || drawer !== "quotes")
139
+
openInteractionDrawer("quotes", document_uri, props.pageId);
140
+
else setInteractionState(document_uri, { drawerOpen: false });
141
+
}}
142
+
onMouseEnter={handleQuotePrefetch}
143
+
onTouchStart={handleQuotePrefetch}
144
+
aria-label="Post quotes"
145
+
>
146
+
<QuoteTiny aria-hidden /> {props.quotesCount}
147
+
</button>
148
+
)}
142
149
{props.showComments === false ? null : (
143
150
<button
144
-
className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`}
151
+
className="flex gap-2 items-center w-fit"
145
152
onClick={() => {
146
153
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
147
154
openInteractionDrawer("comments", document_uri, props.pageId);
···
149
156
}}
150
157
aria-label="Post comments"
151
158
>
152
-
<CommentTiny aria-hidden /> {props.commentsCount}{" "}
153
-
{!props.compact && (
154
-
<span
155
-
aria-hidden
156
-
>{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span>
157
-
)}
159
+
<CommentTiny aria-hidden /> {props.commentsCount}
158
160
</button>
159
161
)}
160
162
</div>
161
163
);
162
164
};
163
165
166
+
export const ExpandedInteractions = (props: {
167
+
quotesCount: number;
168
+
commentsCount: number;
169
+
className?: string;
170
+
showComments?: boolean;
171
+
pageId?: string;
172
+
}) => {
173
+
const data = useContext(PostPageContext);
174
+
let { identity } = useIdentityData();
175
+
176
+
const document_uri = data?.uri;
177
+
if (!document_uri)
178
+
throw new Error("document_uri not available in PostPageContext");
179
+
180
+
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
181
+
182
+
const handleQuotePrefetch = () => {
183
+
if (data?.quotesAndMentions) {
184
+
prefetchQuotesData(data.quotesAndMentions);
185
+
}
186
+
};
187
+
let publication = data?.documents_in_publications[0]?.publications;
188
+
189
+
const tags = (data?.data as any)?.tags as string[] | undefined;
190
+
const tagCount = tags?.length || 0;
191
+
192
+
let subscribed =
193
+
identity?.atp_did &&
194
+
publication?.publication_subscriptions &&
195
+
publication?.publication_subscriptions.find(
196
+
(s) => s.identity === identity.atp_did,
197
+
);
198
+
199
+
let isAuthor =
200
+
identity &&
201
+
identity.atp_did ===
202
+
data.documents_in_publications[0]?.publications?.identity_did &&
203
+
data.leaflets_in_publications[0];
204
+
205
+
return (
206
+
<div
207
+
className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`}
208
+
>
209
+
{!subscribed && !isAuthor && publication && publication.record && (
210
+
<div className="text-center flex flex-col accent-container rounded-md mb-3">
211
+
<div className="flex flex-col py-4">
212
+
<div className="leading-snug flex flex-col pb-2 text-sm">
213
+
<div className="font-bold">Subscribe to {publication.name}</div>{" "}
214
+
to get updates in Reader, RSS, or via Bluesky Feed
215
+
</div>
216
+
<SubscribeWithBluesky
217
+
pubName={publication.name}
218
+
pub_uri={publication.uri}
219
+
base_url={
220
+
(publication.record as PubLeafletPublication.Record)
221
+
.base_path || ""
222
+
}
223
+
subscribers={publication?.publication_subscriptions}
224
+
/>
225
+
</div>
226
+
</div>
227
+
)}
228
+
{tagCount > 0 && (
229
+
<>
230
+
<hr className="border-border-light mb-3" />
231
+
232
+
<TagList tags={tags} className="mb-3" />
233
+
</>
234
+
)}
235
+
<hr className="border-border-light mb-3 " />
236
+
<div className="flex gap-2 justify-between">
237
+
<div className="flex gap-2">
238
+
{props.quotesCount > 0 && (
239
+
<button
240
+
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
241
+
onClick={() => {
242
+
if (!drawerOpen || drawer !== "quotes")
243
+
openInteractionDrawer("quotes", document_uri, props.pageId);
244
+
else setInteractionState(document_uri, { drawerOpen: false });
245
+
}}
246
+
onMouseEnter={handleQuotePrefetch}
247
+
onTouchStart={handleQuotePrefetch}
248
+
aria-label="Post quotes"
249
+
>
250
+
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
251
+
<span
252
+
aria-hidden
253
+
>{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span>
254
+
</button>
255
+
)}
256
+
{props.showComments === false ? null : (
257
+
<button
258
+
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
259
+
onClick={() => {
260
+
if (
261
+
!drawerOpen ||
262
+
drawer !== "comments" ||
263
+
pageId !== props.pageId
264
+
)
265
+
openInteractionDrawer("comments", document_uri, props.pageId);
266
+
else setInteractionState(document_uri, { drawerOpen: false });
267
+
}}
268
+
aria-label="Post comments"
269
+
>
270
+
<CommentTiny aria-hidden />{" "}
271
+
{props.commentsCount > 0 ? (
272
+
<span aria-hidden>
273
+
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
274
+
</span>
275
+
) : (
276
+
"Comment"
277
+
)}
278
+
</button>
279
+
)}
280
+
</div>
281
+
<EditButton document={data} />
282
+
{subscribed && publication && (
283
+
<ManageSubscription
284
+
base_url={getPublicationURL(publication)}
285
+
pub_uri={publication.uri}
286
+
subscribers={publication.publication_subscriptions}
287
+
/>
288
+
)}
289
+
</div>
290
+
</div>
291
+
);
292
+
};
293
+
294
+
const TagPopover = (props: {
295
+
tagCount: number;
296
+
tags: string[] | undefined;
297
+
}) => {
298
+
return (
299
+
<Popover
300
+
className="p-2! max-w-xs"
301
+
trigger={
302
+
<div className="tags flex gap-1 items-center ">
303
+
<TagTiny /> {props.tagCount}
304
+
</div>
305
+
}
306
+
>
307
+
<TagList tags={props.tags} className="text-secondary!" />
308
+
</Popover>
309
+
);
310
+
};
311
+
312
+
const TagList = (props: { className?: string; tags: string[] | undefined }) => {
313
+
if (!props.tags) return;
314
+
return (
315
+
<div className="flex gap-1 flex-wrap">
316
+
{props.tags.map((tag, index) => (
317
+
<Tag name={tag} key={index} className={props.className} />
318
+
))}
319
+
</div>
320
+
);
321
+
};
164
322
export function getQuoteCount(document: PostPageData, pageId?: string) {
165
323
if (!document) return;
166
324
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
···
198
356
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
199
357
).length;
200
358
}
359
+
360
+
const EditButton = (props: { document: PostPageData }) => {
361
+
let { identity } = useIdentityData();
362
+
if (!props.document) return;
363
+
if (
364
+
identity &&
365
+
identity.atp_did ===
366
+
props.document.documents_in_publications[0]?.publications?.identity_did &&
367
+
props.document.leaflets_in_publications[0]
368
+
)
369
+
return (
370
+
<a
371
+
href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`}
372
+
className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1"
373
+
>
374
+
<EditTiny /> Edit Post
375
+
</a>
376
+
);
377
+
return;
378
+
};
+24
-66
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+24
-66
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
11
11
import { SubscribeWithBluesky } from "app/lish/Subscribe";
12
12
import { EditTiny } from "components/Icons/EditTiny";
13
13
import {
14
+
ExpandedInteractions,
14
15
getCommentCount,
15
16
getQuoteCount,
16
17
Interactions,
···
23
24
import { PageWrapper } from "components/Pages/Page";
24
25
import { decodeQuotePosition } from "./quotePosition";
25
26
import { PollData } from "./fetchPollData";
27
+
import { SharedPageProps } from "./PostPages";
26
28
27
29
export function LinearDocumentPage({
28
-
document,
29
30
blocks,
30
-
did,
31
-
profile,
32
-
preferences,
33
-
pubRecord,
34
-
prerenderedCodeBlocks,
35
-
bskyPostData,
36
-
document_uri,
37
-
pageId,
38
-
pageOptions,
39
-
pollData,
40
-
fullPageScroll,
41
-
}: {
42
-
document_uri: string;
43
-
document: PostPageData;
31
+
...props
32
+
}: Omit<SharedPageProps, "allPages"> & {
44
33
blocks: PubLeafletPagesLinearDocument.Block[];
45
-
profile?: ProfileViewDetailed;
46
-
pubRecord: PubLeafletPublication.Record;
47
-
did: string;
48
-
prerenderedCodeBlocks?: Map<string, string>;
49
-
bskyPostData: AppBskyFeedDefs.PostView[];
50
-
pollData: PollData[];
51
-
preferences: { showComments?: boolean };
52
-
pageId?: string;
53
-
pageOptions?: React.ReactNode;
54
-
fullPageScroll: boolean;
55
34
}) {
56
-
let { identity } = useIdentityData();
35
+
const {
36
+
document,
37
+
did,
38
+
profile,
39
+
preferences,
40
+
pubRecord,
41
+
theme,
42
+
prerenderedCodeBlocks,
43
+
bskyPostData,
44
+
pollData,
45
+
document_uri,
46
+
pageId,
47
+
pageOptions,
48
+
fullPageScroll,
49
+
hasPageBackground,
50
+
} = props;
57
51
let drawer = useDrawerOpen(document_uri);
58
52
59
-
if (!document || !document.documents_in_publications[0].publications)
60
-
return null;
53
+
if (!document) return null;
61
54
62
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
63
55
let record = document.data as PubLeafletDocument.Record;
64
56
65
57
const isSubpage = !!pageId;
···
92
84
did={did}
93
85
prerenderedCodeBlocks={prerenderedCodeBlocks}
94
86
/>
95
-
<Interactions
87
+
88
+
<ExpandedInteractions
96
89
pageId={pageId}
97
90
showComments={preferences.showComments}
98
91
commentsCount={getCommentCount(document, pageId) || 0}
99
92
quotesCount={getQuoteCount(document, pageId) || 0}
100
93
/>
101
-
{!isSubpage && (
102
-
<>
103
-
<hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" />
104
-
<div className="sm:px-4 px-3">
105
-
{identity &&
106
-
identity.atp_did ===
107
-
document.documents_in_publications[0]?.publications
108
-
?.identity_did &&
109
-
document.leaflets_in_publications[0] ? (
110
-
<a
111
-
href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`}
112
-
className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto"
113
-
>
114
-
<EditTiny /> Edit Post
115
-
</a>
116
-
) : (
117
-
<SubscribeWithBluesky
118
-
isPost
119
-
base_url={getPublicationURL(
120
-
document.documents_in_publications[0].publications,
121
-
)}
122
-
pub_uri={
123
-
document.documents_in_publications[0].publications.uri
124
-
}
125
-
subscribers={
126
-
document.documents_in_publications[0].publications
127
-
.publication_subscriptions
128
-
}
129
-
pubName={
130
-
document.documents_in_publications[0].publications.name
131
-
}
132
-
/>
133
-
)}
134
-
</div>
135
-
</>
136
-
)}
94
+
{!hasPageBackground && <div className={`spacer h-8 w-full`} />}
137
95
</PageWrapper>
138
96
</>
139
97
);
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
59
59
return (
60
60
<div
61
61
//The postContent class is important for QuoteHandler
62
-
className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`}
62
+
className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`}
63
63
>
64
64
{blocks.map((b, index) => {
65
65
return (
···
293
293
}
294
294
case PubLeafletBlocksImage.isMain(b.block): {
295
295
return (
296
-
<div className={`relative flex ${alignment}`} {...blockProps}>
296
+
<div
297
+
className={`imageBlock relative flex ${alignment}`}
298
+
{...blockProps}
299
+
>
297
300
<img
298
301
alt={b.block.alt}
299
302
height={b.block.aspectRatio?.height}
···
321
324
return (
322
325
// all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
323
326
<blockquote
324
-
className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
327
+
className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
325
328
{...blockProps}
326
329
>
327
330
<TextBlock
···
336
339
}
337
340
case PubLeafletBlocksText.isMain(b.block):
338
341
return (
339
-
<p className={` ${className}`} {...blockProps}>
342
+
<p className={`textBlock ${className}`} {...blockProps}>
340
343
<TextBlock
341
344
facets={b.block.facets}
342
345
plaintext={b.block.plaintext}
···
349
352
case PubLeafletBlocksHeader.isMain(b.block): {
350
353
if (b.block.level === 1)
351
354
return (
352
-
<h2 className={`${className}`} {...blockProps}>
355
+
<h2 className={`h1Block ${className}`} {...blockProps}>
353
356
<TextBlock
354
357
{...b.block}
355
358
index={index}
···
360
363
);
361
364
if (b.block.level === 2)
362
365
return (
363
-
<h3 className={`${className}`} {...blockProps}>
366
+
<h3 className={`h2Block ${className}`} {...blockProps}>
364
367
<TextBlock
365
368
{...b.block}
366
369
index={index}
···
371
374
);
372
375
if (b.block.level === 3)
373
376
return (
374
-
<h4 className={`${className}`} {...blockProps}>
377
+
<h4 className={`h3Block ${className}`} {...blockProps}>
375
378
<TextBlock
376
379
{...b.block}
377
380
index={index}
···
383
386
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
384
387
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
385
388
return (
386
-
<h6 className={`${className}`} {...blockProps}>
389
+
<h6 className={`h6Block ${className}`} {...blockProps}>
387
390
<TextBlock
388
391
{...b.block}
389
392
index={index}
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
···
1
-
"use client";
2
-
3
-
import { Media } from "components/Media";
4
-
import {
5
-
Interactions,
6
-
useInteractionState,
7
-
} from "../Interactions/Interactions";
8
-
import { useState, useEffect } from "react";
9
-
import { Json } from "supabase/database.types";
10
-
11
-
// export const CollapsedPostHeader = (props: {
12
-
// title: string;
13
-
// pubIcon?: string;
14
-
// quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
15
-
// }) => {
16
-
// let [headerVisible, setHeaderVisible] = useState(false);
17
-
// let { drawerOpen: open } = useInteractionState();
18
-
19
-
// useEffect(() => {
20
-
// let post = window.document.getElementById("post-page");
21
-
22
-
// function handleScroll() {
23
-
// let postHeader = window.document
24
-
// .getElementById("post-header")
25
-
// ?.getBoundingClientRect();
26
-
// if (postHeader && postHeader.bottom <= 0) {
27
-
// setHeaderVisible(true);
28
-
// } else {
29
-
// setHeaderVisible(false);
30
-
// }
31
-
// }
32
-
// post?.addEventListener("scroll", handleScroll);
33
-
// return () => {
34
-
// post?.removeEventListener("scroll", handleScroll);
35
-
// };
36
-
// }, []);
37
-
// if (!headerVisible) return;
38
-
// if (open) return;
39
-
// return (
40
-
// <Media
41
-
// mobile
42
-
// className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3"
43
-
// >
44
-
// <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 ">
45
-
// <div className="text-tertiary font-bold text-sm truncate pr-1 grow">
46
-
// {props.title}
47
-
// </div>
48
-
// <div className="flex gap-2 ">
49
-
// <Interactions compact quotes={props.quotes.length} />
50
-
// <div
51
-
// style={{
52
-
// backgroundRepeat: "no-repeat",
53
-
// backgroundPosition: "center",
54
-
// backgroundSize: "cover",
55
-
// backgroundImage: `url(${props.pubIcon})`,
56
-
// }}
57
-
// className="shrink-0 w-4 h-4 rounded-full mt-[2px]"
58
-
// />
59
-
// </div>
60
-
// </div>
61
-
// </Media>
62
-
// );
63
-
// };
+75
-52
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+75
-52
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
16
16
import { EditTiny } from "components/Icons/EditTiny";
17
17
import { SpeedyLink } from "components/SpeedyLink";
18
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
+
import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page";
20
+
import { Separator } from "components/Layout";
19
21
20
22
export function PostHeader(props: {
21
23
data: PostPageData;
···
27
29
28
30
let record = document?.data as PubLeafletDocument.Record;
29
31
let profile = props.profile;
30
-
let pub = props.data?.documents_in_publications[0].publications;
31
-
let pubRecord = pub?.record as PubLeafletPublication.Record;
32
+
let pub = props.data?.documents_in_publications[0]?.publications;
32
33
33
34
const formattedDate = useLocalizedDate(
34
35
record.publishedAt || new Date().toISOString(),
···
36
37
year: "numeric",
37
38
month: "long",
38
39
day: "2-digit",
39
-
}
40
+
},
40
41
);
41
42
42
-
if (!document?.data || !document.documents_in_publications[0].publications)
43
-
return;
43
+
if (!document?.data) return;
44
44
return (
45
-
<div
46
-
className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2"
47
-
id="post-header"
48
-
>
49
-
<div className="pubHeader flex flex-col pb-5">
50
-
<div className="flex justify-between w-full">
51
-
<SpeedyLink
52
-
className="font-bold hover:no-underline text-accent-contrast"
53
-
href={
54
-
document &&
55
-
getPublicationURL(
56
-
document.documents_in_publications[0].publications,
57
-
)
58
-
}
59
-
>
60
-
{pub?.name}
61
-
</SpeedyLink>
45
+
<PostHeaderLayout
46
+
pubLink={
47
+
<>
48
+
{pub && (
49
+
<SpeedyLink
50
+
className="font-bold hover:no-underline text-accent-contrast"
51
+
href={document && getPublicationURL(pub)}
52
+
>
53
+
{pub?.name}
54
+
</SpeedyLink>
55
+
)}
62
56
{identity &&
63
-
identity.atp_did ===
64
-
document.documents_in_publications[0]?.publications
65
-
.identity_did &&
57
+
pub &&
58
+
identity.atp_did === pub.identity_did &&
66
59
document.leaflets_in_publications[0] && (
67
60
<a
68
61
className=" rounded-full flex place-items-center"
···
71
64
<EditTiny className="shrink-0" />
72
65
</a>
73
66
)}
74
-
</div>
75
-
<h2 className="">{record.title}</h2>
76
-
{record.description ? (
77
-
<p className="italic text-secondary">{record.description}</p>
78
-
) : null}
79
-
80
-
<div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap">
81
-
{profile ? (
82
-
<>
83
-
<a
84
-
className="text-tertiary"
85
-
href={`https://bsky.app/profile/${profile.handle}`}
86
-
>
87
-
by {profile.displayName || profile.handle}
88
-
</a>
89
-
</>
90
-
) : null}
91
-
{record.publishedAt ? (
92
-
<>
93
-
|
94
-
<p>{formattedDate}</p>
95
-
</>
96
-
) : null}
97
-
|{" "}
67
+
</>
68
+
}
69
+
postTitle={record.title}
70
+
postDescription={record.description}
71
+
postInfo={
72
+
<>
73
+
<div className="flex flex-row gap-2 items-center">
74
+
{profile ? (
75
+
<>
76
+
<a
77
+
className="text-tertiary"
78
+
href={`https://bsky.app/profile/${profile.handle}`}
79
+
>
80
+
{profile.displayName || profile.handle}
81
+
</a>
82
+
</>
83
+
) : null}
84
+
{record.publishedAt ? (
85
+
<>
86
+
<Separator classname="h-4!" />
87
+
<p>{formattedDate}</p>
88
+
</>
89
+
) : null}
90
+
</div>
98
91
<Interactions
99
92
showComments={props.preferences.showComments}
100
-
compact
101
93
quotesCount={getQuoteCount(document) || 0}
102
94
commentsCount={getCommentCount(document) || 0}
103
95
/>
104
-
</div>
96
+
</>
97
+
}
98
+
/>
99
+
);
100
+
}
101
+
102
+
export const PostHeaderLayout = (props: {
103
+
pubLink: React.ReactNode;
104
+
postTitle: React.ReactNode | undefined;
105
+
postDescription: React.ReactNode | undefined;
106
+
postInfo: React.ReactNode;
107
+
}) => {
108
+
return (
109
+
<div
110
+
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
+
id="post-header"
112
+
>
113
+
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
114
+
{props.pubLink}
115
+
</div>
116
+
<h2
117
+
className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`}
118
+
>
119
+
{props.postTitle ? props.postTitle : "Untitled"}
120
+
</h2>
121
+
{props.postDescription ? (
122
+
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
+
{props.postDescription}
124
+
</p>
125
+
) : null}
126
+
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
+
{props.postInfo}
105
128
</div>
106
129
</div>
107
130
);
108
-
}
131
+
};
+110
-75
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+110
-75
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
98
98
};
99
99
});
100
100
101
+
// Shared props type for both page components
102
+
export type SharedPageProps = {
103
+
document: PostPageData;
104
+
did: string;
105
+
profile: ProfileViewDetailed;
106
+
preferences: { showComments?: boolean };
107
+
pubRecord?: PubLeafletPublication.Record;
108
+
theme?: PubLeafletPublication.Theme | null;
109
+
prerenderedCodeBlocks?: Map<string, string>;
110
+
bskyPostData: AppBskyFeedDefs.PostView[];
111
+
pollData: PollData[];
112
+
document_uri: string;
113
+
fullPageScroll: boolean;
114
+
hasPageBackground: boolean;
115
+
pageId?: string;
116
+
pageOptions?: React.ReactNode;
117
+
allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
118
+
};
119
+
120
+
// Component that renders either Canvas or Linear page based on page type
121
+
function PageRenderer({
122
+
page,
123
+
...sharedProps
124
+
}: {
125
+
page: PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main;
126
+
} & SharedPageProps) {
127
+
const isCanvas = PubLeafletPagesCanvas.isMain(page);
128
+
129
+
if (isCanvas) {
130
+
return (
131
+
<CanvasPage
132
+
{...sharedProps}
133
+
blocks={(page as PubLeafletPagesCanvas.Main).blocks || []}
134
+
pages={sharedProps.allPages}
135
+
/>
136
+
);
137
+
}
138
+
139
+
return (
140
+
<LinearDocumentPage
141
+
{...sharedProps}
142
+
blocks={(page as PubLeafletPagesLinearDocument.Main).blocks || []}
143
+
/>
144
+
);
145
+
}
146
+
101
147
export function PostPages({
102
148
document,
103
-
blocks,
104
149
did,
105
150
profile,
106
151
preferences,
···
112
157
}: {
113
158
document_uri: string;
114
159
document: PostPageData;
115
-
blocks: PubLeafletPagesLinearDocument.Block[];
116
160
profile: ProfileViewDetailed;
117
-
pubRecord: PubLeafletPublication.Record;
161
+
pubRecord?: PubLeafletPublication.Record;
118
162
did: string;
119
163
prerenderedCodeBlocks?: Map<string, string>;
120
164
bskyPostData: AppBskyFeedDefs.PostView[];
···
123
167
}) {
124
168
let drawer = useDrawerOpen(document_uri);
125
169
useInitializeOpenPages();
126
-
let pages = useOpenPages();
127
-
if (!document || !document.documents_in_publications[0].publications)
128
-
return null;
170
+
let openPageIds = useOpenPages();
171
+
if (!document) return null;
129
172
130
-
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
131
173
let record = document.data as PubLeafletDocument.Record;
174
+
let theme = pubRecord?.theme || record.theme || null;
175
+
// For publication posts, respect the publication's showPageBackground setting
176
+
// For standalone documents, default to showing page background
177
+
let isInPublication = !!pubRecord;
178
+
let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true;
132
179
let quotesAndMentions = document.quotesAndMentions;
133
180
134
-
let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0;
181
+
let firstPage = record.pages[0] as
182
+
| PubLeafletPagesLinearDocument.Main
183
+
| PubLeafletPagesCanvas.Main;
184
+
185
+
// Canvas pages don't support fullPageScroll well due to fixed 1272px width
186
+
let firstPageIsCanvas = PubLeafletPagesCanvas.isMain(firstPage);
187
+
188
+
// Shared props used for all pages
189
+
const sharedProps: SharedPageProps = {
190
+
document,
191
+
did,
192
+
profile,
193
+
preferences,
194
+
pubRecord,
195
+
theme,
196
+
prerenderedCodeBlocks,
197
+
bskyPostData,
198
+
pollData,
199
+
document_uri,
200
+
hasPageBackground,
201
+
allPages: record.pages as (
202
+
| PubLeafletPagesLinearDocument.Main
203
+
| PubLeafletPagesCanvas.Main
204
+
)[],
205
+
fullPageScroll:
206
+
!hasPageBackground &&
207
+
!drawer &&
208
+
openPageIds.length === 0 &&
209
+
!firstPageIsCanvas,
210
+
};
211
+
135
212
return (
136
213
<>
137
-
{!fullPageScroll && <BookendSpacer />}
138
-
<LinearDocumentPage
139
-
document={document}
140
-
blocks={blocks}
141
-
did={did}
142
-
profile={profile}
143
-
fullPageScroll={fullPageScroll}
144
-
pollData={pollData}
145
-
preferences={preferences}
146
-
pubRecord={pubRecord}
147
-
prerenderedCodeBlocks={prerenderedCodeBlocks}
148
-
bskyPostData={bskyPostData}
149
-
document_uri={document_uri}
150
-
/>
214
+
{!sharedProps.fullPageScroll && <BookendSpacer />}
215
+
216
+
<PageRenderer page={firstPage} {...sharedProps} />
151
217
152
218
{drawer && !drawer.pageId && (
153
219
<InteractionDrawer
154
220
document_uri={document.uri}
155
221
comments={
156
-
pubRecord.preferences?.showComments === false
222
+
pubRecord?.preferences?.showComments === false
157
223
? []
158
224
: document.comments_on_documents
159
225
}
···
162
228
/>
163
229
)}
164
230
165
-
{pages.map((p) => {
231
+
{openPageIds.map((pageId) => {
166
232
let page = record.pages.find(
167
-
(page) =>
233
+
(p) =>
168
234
(
169
-
page as
235
+
p as
170
236
| PubLeafletPagesLinearDocument.Main
171
237
| PubLeafletPagesCanvas.Main
172
-
).id === p,
238
+
).id === pageId,
173
239
) as
174
240
| PubLeafletPagesLinearDocument.Main
175
241
| PubLeafletPagesCanvas.Main
176
242
| undefined;
177
-
if (!page) return null;
178
243
179
-
const isCanvas = PubLeafletPagesCanvas.isMain(page);
244
+
if (!page) return null;
180
245
181
246
return (
182
-
<Fragment key={p}>
247
+
<Fragment key={pageId}>
183
248
<SandwichSpacer />
184
-
{isCanvas ? (
185
-
<CanvasPage
186
-
fullPageScroll={false}
187
-
document={document}
188
-
blocks={(page as PubLeafletPagesCanvas.Main).blocks}
189
-
did={did}
190
-
preferences={preferences}
191
-
profile={profile}
192
-
pubRecord={pubRecord}
193
-
prerenderedCodeBlocks={prerenderedCodeBlocks}
194
-
pollData={pollData}
195
-
bskyPostData={bskyPostData}
196
-
document_uri={document_uri}
197
-
pageId={page.id}
198
-
pages={record.pages as PubLeafletPagesLinearDocument.Main[]}
199
-
pageOptions={
200
-
<PageOptions
201
-
onClick={() => closePage(page?.id!)}
202
-
hasPageBackground={hasPageBackground}
203
-
/>
204
-
}
205
-
/>
206
-
) : (
207
-
<LinearDocumentPage
208
-
fullPageScroll={false}
209
-
document={document}
210
-
blocks={(page as PubLeafletPagesLinearDocument.Main).blocks}
211
-
did={did}
212
-
preferences={preferences}
213
-
pubRecord={pubRecord}
214
-
pollData={pollData}
215
-
prerenderedCodeBlocks={prerenderedCodeBlocks}
216
-
bskyPostData={bskyPostData}
217
-
document_uri={document_uri}
218
-
pageId={page.id}
219
-
pageOptions={
220
-
<PageOptions
221
-
onClick={() => closePage(page?.id!)}
222
-
hasPageBackground={hasPageBackground}
223
-
/>
224
-
}
225
-
/>
226
-
)}
249
+
<PageRenderer
250
+
page={page}
251
+
{...sharedProps}
252
+
fullPageScroll={false}
253
+
pageId={page.id}
254
+
pageOptions={
255
+
<PageOptions
256
+
onClick={() => closePage(page.id!)}
257
+
hasPageBackground={hasPageBackground}
258
+
/>
259
+
}
260
+
/>
227
261
{drawer && drawer.pageId === page.id && (
228
262
<InteractionDrawer
229
263
pageId={page.id}
230
264
document_uri={document.uri}
231
265
comments={
232
-
pubRecord.preferences?.showComments === false
266
+
pubRecord?.preferences?.showComments === false
233
267
? []
234
268
: document.comments_on_documents
235
269
}
···
240
274
</Fragment>
241
275
);
242
276
})}
243
-
{!fullPageScroll && <BookendSpacer />}
277
+
278
+
{!sharedProps.fullPageScroll && <BookendSpacer />}
244
279
</>
245
280
);
246
281
}
+4
-5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
+4
-5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
106
106
<div className="grow">
107
107
{title && (
108
108
<div
109
-
className={`pageBlockOne outline-none resize-none align-top flex gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
109
+
className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
110
110
>
111
111
<TextBlock
112
112
facets={title.facets}
···
118
118
)}
119
119
{description && (
120
120
<div
121
-
className={`pageBlockLineTwo outline-none resize-none align-top flex gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
121
+
className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
122
122
>
123
123
<TextBlock
124
124
facets={description.facets}
···
151
151
let previewRef = useRef<HTMLDivElement | null>(null);
152
152
let { rootEntity } = useReplicache();
153
153
let data = useContext(PostPageContext);
154
-
let theme = data?.documents_in_publications[0]?.publications
155
-
?.record as PubLeafletPublication.Record;
154
+
let theme = data?.theme;
156
155
let pageWidth = `var(--page-width-unitless)`;
157
-
let cardBorderHidden = !theme.theme?.showPageBackground;
156
+
let cardBorderHidden = !theme?.showPageBackground;
158
157
return (
159
158
<div
160
159
ref={previewRef}
+3
-2
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
+3
-2
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
···
1
1
import {
2
2
PubLeafletDocument,
3
3
PubLeafletPagesLinearDocument,
4
+
PubLeafletPagesCanvas,
4
5
PubLeafletBlocksCode,
5
6
} from "lexicons/api";
6
7
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
7
8
8
9
export async function extractCodeBlocks(
9
-
blocks: PubLeafletPagesLinearDocument.Block[],
10
+
blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[],
10
11
): Promise<Map<string, string>> {
11
12
const codeBlocks = new Map<string, string>();
12
13
13
-
// Process all pages in the document
14
+
// Process all blocks (works for both linear and canvas)
14
15
for (let i = 0; i < blocks.length; i++) {
15
16
const block = blocks[i];
16
17
const currentIndex = [i];
+12
-3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+12
-3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
1
1
import { supabaseServerClient } from "supabase/serverClient";
2
2
import { AtUri } from "@atproto/syntax";
3
-
import { PubLeafletPublication } from "lexicons/api";
3
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
4
4
5
5
export async function getPostPageData(uri: string) {
6
6
let { data: document } = await supabaseServerClient
···
23
23
// Fetch constellation backlinks for mentions
24
24
const pubRecord = document.documents_in_publications[0]?.publications
25
25
?.record as PubLeafletPublication.Record;
26
-
const rkey = new AtUri(uri).rkey;
27
-
const postUrl = `https://${pubRecord?.base_path}/${rkey}`;
26
+
let aturi = new AtUri(uri);
27
+
const postUrl = pubRecord
28
+
? `https://${pubRecord?.base_path}/${aturi.rkey}`
29
+
: `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`;
28
30
const constellationBacklinks = await getConstellationBacklinks(postUrl);
29
31
30
32
// Deduplicate constellation backlinks (same post could appear in both links and embeds)
···
43
45
...uniqueBacklinks,
44
46
];
45
47
48
+
let theme =
49
+
(
50
+
document?.documents_in_publications[0]?.publications
51
+
?.record as PubLeafletPublication.Record
52
+
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
53
+
46
54
return {
47
55
...document,
48
56
quotesAndMentions,
57
+
theme,
49
58
};
50
59
}
51
60
+4
-3
app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts
+4
-3
app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts
···
5
5
export const revalidate = 60;
6
6
7
7
export default async function OpenGraphImage(props: {
8
-
params: { publication: string; did: string; rkey: string; quote: string };
8
+
params: Promise<{ publication: string; did: string; rkey: string; quote: string }>;
9
9
}) {
10
-
let quotePosition = decodeQuotePosition(props.params.quote);
10
+
let params = await props.params;
11
+
let quotePosition = decodeQuotePosition(params.quote);
11
12
return getMicroLinkOgImage(
12
-
`/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/l-quote/${props.params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`,
13
+
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`,
13
14
{
14
15
width: 620,
15
16
height: 324,
+3
-2
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
+3
-2
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
···
4
4
export const revalidate = 60;
5
5
6
6
export default async function OpenGraphImage(props: {
7
-
params: { publication: string; did: string; rkey: string };
7
+
params: Promise<{ publication: string; did: string; rkey: string }>;
8
8
}) {
9
+
let params = await props.params;
9
10
return getMicroLinkOgImage(
10
-
`/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/`,
11
+
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
11
12
);
12
13
}
+14
-156
app/lish/[did]/[publication]/[rkey]/page.tsx
+14
-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
-
import {
5
-
PubLeafletBlocksBskyPost,
6
-
PubLeafletDocument,
7
-
PubLeafletPagesLinearDocument,
8
-
PubLeafletPublication,
9
-
} from "lexicons/api";
4
+
import { PubLeafletDocument } from "lexicons/api";
10
5
import { Metadata } from "next";
11
-
import { AtpAgent } from "@atproto/api";
12
-
import { QuoteHandler } from "./QuoteHandler";
13
-
import { InteractionDrawer } from "./Interactions/InteractionDrawer";
14
-
import {
15
-
PublicationBackgroundProvider,
16
-
PublicationThemeProvider,
17
-
} from "components/ThemeManager/PublicationThemeProvider";
18
-
import { getPostPageData } from "./getPostPageData";
19
-
import { PostPageContextProvider } from "./PostPageContext";
20
-
import { PostPages } from "./PostPages";
21
-
import { extractCodeBlocks } from "./extractCodeBlocks";
22
-
import { LeafletLayout } from "components/LeafletLayout";
23
-
import { fetchPollData } from "./fetchPollData";
6
+
import { DocumentPageRenderer } from "./DocumentPageRenderer";
24
7
25
8
export async function generateMetadata(props: {
26
9
params: Promise<{ publication: string; did: string; rkey: string }>;
···
42
25
43
26
return {
44
27
icons: {
28
+
icon: {
29
+
url:
30
+
process.env.NODE_ENV === "development"
31
+
? `/lish/${did}/${params.publication}/icon`
32
+
: "/icon",
33
+
sizes: "32x32",
34
+
type: "image/png",
35
+
},
45
36
other: {
46
37
rel: "alternate",
47
38
url: document.uri,
···
57
48
export default async function Post(props: {
58
49
params: Promise<{ publication: string; did: string; rkey: string }>;
59
50
}) {
60
-
let did = decodeURIComponent((await props.params).did);
51
+
let params = await props.params;
52
+
let did = decodeURIComponent(params.did);
53
+
61
54
if (!did)
62
55
return (
63
56
<div className="p-4 text-lg text-center flex flex-col gap-4">
···
68
61
</p>
69
62
</div>
70
63
);
71
-
let agent = new AtpAgent({
72
-
service: "https://public.api.bsky.app",
73
-
fetch: (...args) =>
74
-
fetch(args[0], {
75
-
...args[1],
76
-
next: { revalidate: 3600 },
77
-
}),
78
-
});
79
-
let [document, profile] = await Promise.all([
80
-
getPostPageData(
81
-
AtUri.make(
82
-
did,
83
-
ids.PubLeafletDocument,
84
-
(await props.params).rkey,
85
-
).toString(),
86
-
),
87
-
agent.getProfile({ actor: did }),
88
-
]);
89
-
if (!document?.data || !document.documents_in_publications[0].publications)
90
-
return (
91
-
<div className="bg-bg-leaflet h-full p-3 text-center relative">
92
-
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full">
93
-
<div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 ">
94
-
<h3>Sorry, post not found!</h3>
95
-
<p>
96
-
This may be a glitch on our end. If the issue persists please{" "}
97
-
<a href="mailto:contact@leaflet.pub">send us a note</a>.
98
-
</p>
99
-
</div>
100
-
</div>
101
-
</div>
102
-
);
103
-
let record = document.data as PubLeafletDocument.Record;
104
-
let bskyPosts =
105
-
record.pages.flatMap((p) => {
106
-
let page = p as PubLeafletPagesLinearDocument.Main;
107
-
return page.blocks?.filter(
108
-
(b) => b.block.$type === ids.PubLeafletBlocksBskyPost,
109
-
);
110
-
}) || [];
111
64
112
-
// Batch bsky posts into groups of 25 and fetch in parallel
113
-
let bskyPostBatches = [];
114
-
for (let i = 0; i < bskyPosts.length; i += 25) {
115
-
bskyPostBatches.push(bskyPosts.slice(i, i + 25));
116
-
}
117
-
118
-
let bskyPostResponses = await Promise.all(
119
-
bskyPostBatches.map((batch) =>
120
-
agent.getPosts(
121
-
{
122
-
uris: batch.map((p) => {
123
-
let block = p?.block as PubLeafletBlocksBskyPost.Main;
124
-
return block.postRef.uri;
125
-
}),
126
-
},
127
-
{ headers: {} },
128
-
),
129
-
),
130
-
);
131
-
132
-
let bskyPostData =
133
-
bskyPostResponses.length > 0
134
-
? bskyPostResponses.flatMap((response) => response.data.posts)
135
-
: [];
136
-
137
-
// Extract poll blocks and fetch vote data
138
-
let pollBlocks = record.pages.flatMap((p) => {
139
-
let page = p as PubLeafletPagesLinearDocument.Main;
140
-
return (
141
-
page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) ||
142
-
[]
143
-
);
144
-
});
145
-
let pollData = await fetchPollData(
146
-
pollBlocks.map((b) => (b.block as any).pollRef.uri),
147
-
);
148
-
149
-
let pubRecord = document.documents_in_publications[0]?.publications
150
-
.record as PubLeafletPublication.Record;
151
-
152
-
let firstPage = record.pages[0];
153
-
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
154
-
if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
155
-
blocks = firstPage.blocks || [];
156
-
}
157
-
158
-
let prerenderedCodeBlocks = await extractCodeBlocks(blocks);
159
-
160
-
return (
161
-
<PostPageContextProvider value={document}>
162
-
<PublicationThemeProvider
163
-
record={pubRecord}
164
-
pub_creator={
165
-
document.documents_in_publications[0].publications.identity_did
166
-
}
167
-
>
168
-
<PublicationBackgroundProvider
169
-
record={pubRecord}
170
-
pub_creator={
171
-
document.documents_in_publications[0].publications.identity_did
172
-
}
173
-
>
174
-
{/*
175
-
TODO: SCROLL PAGE TO FIT DRAWER
176
-
If the drawer fits without scrolling, dont scroll
177
-
If both drawer and page fit if you scrolled it, scroll it all into the center
178
-
If the drawer and pafe doesn't all fit, scroll to drawer
179
-
180
-
TODO: SROLL BAR
181
-
If there is no drawer && there is no page bg, scroll the entire page
182
-
If there is either a drawer open OR a page background, scroll just the post content
183
-
184
-
TODO: HIGHLIGHTING BORKED
185
-
on chrome, if you scroll backward, things stop working
186
-
seems like if you use an older browser, sel direction is not a thing yet
187
-
*/}
188
-
<LeafletLayout>
189
-
<PostPages
190
-
document_uri={document.uri}
191
-
preferences={pubRecord.preferences || {}}
192
-
pubRecord={pubRecord}
193
-
profile={JSON.parse(JSON.stringify(profile.data))}
194
-
document={document}
195
-
bskyPostData={bskyPostData}
196
-
did={did}
197
-
blocks={blocks}
198
-
prerenderedCodeBlocks={prerenderedCodeBlocks}
199
-
pollData={pollData}
200
-
/>
201
-
</LeafletLayout>
202
-
203
-
<QuoteHandler />
204
-
</PublicationBackgroundProvider>
205
-
</PublicationThemeProvider>
206
-
</PostPageContextProvider>
207
-
);
65
+
return <DocumentPageRenderer did={did} rkey={params.rkey} />;
208
66
}
+3
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
+3
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
···
26
26
cardBorderHidden={!props.showPageBackground}
27
27
leaflets={leaflets_in_publications
28
28
.filter((l) => !l.documents)
29
+
.filter((l) => !l.archived)
29
30
.map((l) => {
30
31
return {
32
+
archived: l.archived,
33
+
added_at: "",
31
34
token: {
32
35
...l.permission_tokens!,
33
36
leaflets_in_publications: [
···
39
42
},
40
43
],
41
44
},
42
-
added_at: "",
43
45
};
44
46
})}
45
47
initialFacts={pub_data.leaflet_data.facts || {}}
+22
-2
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
+22
-2
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
···
2
2
3
3
import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data";
4
4
import { callRPC } from "app/api/rpc/client";
5
-
import { createContext, useContext } from "react";
6
-
import useSWR, { SWRConfig } from "swr";
5
+
import { createContext, useContext, useEffect } from "react";
6
+
import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr";
7
+
import { produce, Draft } from "immer";
8
+
9
+
export type PublicationData = GetPublicationDataReturnType["result"];
7
10
8
11
const PublicationContext = createContext({ name: "", did: "" });
9
12
export function PublicationSWRDataProvider(props: {
···
13
16
children: React.ReactNode;
14
17
}) {
15
18
let key = `publication-data-${props.publication_did}-${props.publication_rkey}`;
19
+
useEffect(() => {
20
+
console.log("UPDATING");
21
+
mutate(key, props.publication_data);
22
+
}, [props.publication_data]);
16
23
return (
17
24
<PublicationContext
18
25
value={{ name: props.publication_rkey, did: props.publication_did }}
···
41
48
);
42
49
return { data, mutate };
43
50
}
51
+
52
+
export function mutatePublicationData(
53
+
mutate: KeyedMutator<PublicationData>,
54
+
recipe: (draft: Draft<NonNullable<PublicationData>>) => void,
55
+
) {
56
+
mutate(
57
+
(data) => {
58
+
if (!data) return data;
59
+
return produce(data, recipe);
60
+
},
61
+
{ revalidate: false },
62
+
);
63
+
}
+131
-136
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+131
-136
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
···
1
1
"use client";
2
2
import { AtUri } from "@atproto/syntax";
3
-
import { PubLeafletDocument } from "lexicons/api";
3
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
4
4
import { EditTiny } from "components/Icons/EditTiny";
5
5
6
6
import { usePublicationData } from "./PublicationSWRProvider";
···
13
13
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
14
14
import { DeleteSmall } from "components/Icons/DeleteSmall";
15
15
import { ShareSmall } from "components/Icons/ShareSmall";
16
-
import { ShareButton } from "components/ShareOptions";
16
+
import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions";
17
17
import { SpeedyLink } from "components/SpeedyLink";
18
18
import { QuoteTiny } from "components/Icons/QuoteTiny";
19
19
import { CommentTiny } from "components/Icons/CommentTiny";
20
+
import { InteractionPreview } from "components/InteractionsPreview";
20
21
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
22
+
import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions";
23
+
import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
21
24
22
25
export function PublishedPostsList(props: {
23
26
searchValue: string;
···
26
29
let { data } = usePublicationData();
27
30
let params = useParams();
28
31
let { publication } = data!;
32
+
let pubRecord = publication?.record as PubLeafletPublication.Record;
33
+
29
34
if (!publication) return null;
30
35
if (publication.documents_in_publications.length === 0)
31
36
return (
···
53
58
(l) => doc.documents && l.doc === doc.documents.uri,
54
59
);
55
60
let uri = new AtUri(doc.documents.uri);
56
-
let record = doc.documents.data as PubLeafletDocument.Record;
61
+
let postRecord = doc.documents.data as PubLeafletDocument.Record;
57
62
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
58
63
let comments = doc.documents.comments_on_documents[0]?.count || 0;
64
+
let tags = (postRecord?.tags as string[] | undefined) || [];
65
+
66
+
let postLink = data?.publication
67
+
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
68
+
: "";
59
69
60
70
return (
61
71
<Fragment key={doc.documents?.uri}>
···
75
85
href={`${getPublicationURL(publication)}/${uri.rkey}`}
76
86
>
77
87
<h3 className="text-primary grow leading-snug">
78
-
{record.title}
88
+
{postRecord.title}
79
89
</h3>
80
90
</a>
81
91
<div className="flex justify-start align-top flex-row gap-1">
82
-
{leaflet && (
83
-
<SpeedyLink
84
-
className="pt-[6px]"
85
-
href={`/${leaflet.leaflet}`}
86
-
>
87
-
<EditTiny />
88
-
</SpeedyLink>
92
+
{leaflet && leaflet.permission_tokens && (
93
+
<>
94
+
<SpeedyLink
95
+
className="pt-[6px]"
96
+
href={`/${leaflet.leaflet}`}
97
+
>
98
+
<EditTiny />
99
+
</SpeedyLink>
100
+
101
+
<StaticLeafletDataContext
102
+
value={{
103
+
...leaflet.permission_tokens,
104
+
leaflets_in_publications: [
105
+
{
106
+
...leaflet,
107
+
publications: publication,
108
+
documents: doc.documents
109
+
? {
110
+
uri: doc.documents.uri,
111
+
indexed_at: doc.documents.indexed_at,
112
+
data: doc.documents.data,
113
+
}
114
+
: null,
115
+
},
116
+
],
117
+
leaflets_to_documents: [],
118
+
blocked_by_admin: null,
119
+
custom_domain_routes: [],
120
+
}}
121
+
>
122
+
<LeafletOptions loggedIn={true} />
123
+
</StaticLeafletDataContext>
124
+
</>
89
125
)}
90
-
<Options document_uri={doc.documents.uri} />
91
126
</div>
92
127
</div>
93
128
94
-
{record.description ? (
129
+
{postRecord.description ? (
95
130
<p className="italic text-secondary">
96
-
{record.description}
131
+
{postRecord.description}
97
132
</p>
98
133
) : null}
99
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3">
100
-
{record.publishedAt ? (
101
-
<PublishedDate dateString={record.publishedAt} />
134
+
<div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3">
135
+
{postRecord.publishedAt ? (
136
+
<PublishedDate dateString={postRecord.publishedAt} />
102
137
) : null}
103
-
{(comments > 0 || quotes > 0) && record.publishedAt
104
-
? " | "
105
-
: ""}
106
-
{quotes > 0 && (
107
-
<SpeedyLink
108
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
109
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
110
-
>
111
-
<QuoteTiny /> {quotes}
112
-
</SpeedyLink>
113
-
)}
114
-
{comments > 0 && quotes > 0 ? " " : ""}
115
-
{comments > 0 && (
116
-
<SpeedyLink
117
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
118
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
119
-
>
120
-
<CommentTiny /> {comments}
121
-
</SpeedyLink>
122
-
)}
138
+
<InteractionPreview
139
+
quotesCount={quotes}
140
+
commentsCount={comments}
141
+
tags={tags}
142
+
showComments={pubRecord?.preferences?.showComments}
143
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
144
+
/>
123
145
</div>
124
146
</div>
125
147
</div>
···
133
155
);
134
156
}
135
157
136
-
let Options = (props: { document_uri: string }) => {
137
-
return (
138
-
<Menu
139
-
align="end"
140
-
alignOffset={20}
141
-
asChild
142
-
trigger={
143
-
<button className="text-secondary rounded-md selected-outline border-transparent! hover:border-border! h-min">
144
-
<MoreOptionsVerticalTiny />
145
-
</button>
146
-
}
147
-
>
148
-
<>
149
-
<OptionsMenu document_uri={props.document_uri} />
150
-
</>
151
-
</Menu>
152
-
);
153
-
};
158
+
// function OptionsMenu(props: { document_uri: string }) {
159
+
// let { mutate, data } = usePublicationData();
160
+
// let [state, setState] = useState<"normal" | "confirm">("normal");
154
161
155
-
function OptionsMenu(props: { document_uri: string }) {
156
-
let { mutate, data } = usePublicationData();
157
-
let [state, setState] = useState<"normal" | "confirm">("normal");
162
+
// if (state === "normal") {
163
+
// return (
164
+
// <>
165
+
// <ShareButton
166
+
// className="justify-end"
167
+
// text={
168
+
// <div className="flex gap-2">
169
+
// Share Post Link
170
+
// <ShareSmall />
171
+
// </div>
172
+
// }
173
+
// subtext=""
174
+
// smokerText="Post link copied!"
175
+
// id="get-post-link"
176
+
// fullLink={postLink?.includes("https") ? postLink : undefined}
177
+
// link={postLink}
178
+
// />
158
179
159
-
let postLink = data?.publication
160
-
? `${getPublicationURL(data?.publication)}/${new AtUri(props.document_uri).rkey}`
161
-
: null;
162
-
163
-
if (state === "normal") {
164
-
return (
165
-
<>
166
-
<ShareButton
167
-
className="justify-end"
168
-
text={
169
-
<div className="flex gap-2">
170
-
Share Post Link
171
-
<ShareSmall />
172
-
</div>
173
-
}
174
-
subtext=""
175
-
smokerText="Post link copied!"
176
-
id="get-post-link"
177
-
fullLink={postLink?.includes("https") ? postLink : undefined}
178
-
link={postLink}
179
-
/>
180
-
181
-
<hr className="border-border-light" />
182
-
<MenuItem
183
-
className="justify-end"
184
-
onSelect={async (e) => {
185
-
e.preventDefault();
186
-
setState("confirm");
187
-
return;
188
-
}}
189
-
>
190
-
Delete Post
191
-
<DeleteSmall />
192
-
</MenuItem>
193
-
</>
194
-
);
195
-
}
196
-
if (state === "confirm") {
197
-
return (
198
-
<div className="flex flex-col items-center font-bold text-secondary px-2 py-1">
199
-
Are you sure?
200
-
<div className="text-sm text-tertiary font-normal">
201
-
This action cannot be undone!
202
-
</div>
203
-
<ButtonPrimary
204
-
className="mt-2"
205
-
onClick={async () => {
206
-
await mutate((data) => {
207
-
if (!data) return data;
208
-
return {
209
-
...data,
210
-
publication: {
211
-
...data.publication!,
212
-
leaflets_in_publications:
213
-
data.publication?.leaflets_in_publications.filter(
214
-
(l) => l.doc !== props.document_uri,
215
-
) || [],
216
-
documents_in_publications:
217
-
data.publication?.documents_in_publications.filter(
218
-
(d) => d.documents?.uri !== props.document_uri,
219
-
) || [],
220
-
},
221
-
};
222
-
}, false);
223
-
await deletePost(props.document_uri);
224
-
}}
225
-
>
226
-
Delete
227
-
</ButtonPrimary>
228
-
</div>
229
-
);
230
-
}
231
-
}
180
+
// <hr className="border-border-light" />
181
+
// <MenuItem
182
+
// className="justify-end"
183
+
// onSelect={async (e) => {
184
+
// e.preventDefault();
185
+
// setState("confirm");
186
+
// return;
187
+
// }}
188
+
// >
189
+
// Delete Post
190
+
// <DeleteSmall />
191
+
// </MenuItem>
192
+
// </>
193
+
// );
194
+
// }
195
+
// if (state === "confirm") {
196
+
// return (
197
+
// <div className="flex flex-col items-center font-bold text-secondary px-2 py-1">
198
+
// Are you sure?
199
+
// <div className="text-sm text-tertiary font-normal">
200
+
// This action cannot be undone!
201
+
// </div>
202
+
// <ButtonPrimary
203
+
// className="mt-2"
204
+
// onClick={async () => {
205
+
// await mutate((data) => {
206
+
// if (!data) return data;
207
+
// return {
208
+
// ...data,
209
+
// publication: {
210
+
// ...data.publication!,
211
+
// leaflets_in_publications:
212
+
// data.publication?.leaflets_in_publications.filter(
213
+
// (l) => l.doc !== props.document_uri,
214
+
// ) || [],
215
+
// documents_in_publications:
216
+
// data.publication?.documents_in_publications.filter(
217
+
// (d) => d.documents?.uri !== props.document_uri,
218
+
// ) || [],
219
+
// },
220
+
// };
221
+
// }, false);
222
+
// await deletePost(props.document_uri);
223
+
// }}
224
+
// >
225
+
// Delete
226
+
// </ButtonPrimary>
227
+
// </div>
228
+
// );
229
+
// }
230
+
//}
232
231
233
232
function PublishedDate(props: { dateString: string }) {
234
233
const formattedDate = useLocalizedDate(props.dateString, {
···
237
236
day: "2-digit",
238
237
});
239
238
240
-
return (
241
-
<p className="text-sm text-tertiary">
242
-
Published {formattedDate}
243
-
</p>
244
-
);
239
+
return <p className="text-sm text-tertiary">Published {formattedDate}</p>;
245
240
}
+23
app/lish/[did]/[publication]/dashboard/deletePost.ts
+23
app/lish/[did]/[publication]/dashboard/deletePost.ts
···
30
30
.delete()
31
31
.eq("doc", document_uri),
32
32
]);
33
+
34
+
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
35
+
}
36
+
37
+
export async function unpublishPost(document_uri: string) {
38
+
let identity = await getIdentityData();
39
+
if (!identity || !identity.atp_did) throw new Error("No Identity");
40
+
41
+
const oauthClient = await createOauthClient();
42
+
let credentialSession = await oauthClient.restore(identity.atp_did);
43
+
let agent = new AtpBaseClient(
44
+
credentialSession.fetchHandler.bind(credentialSession),
45
+
);
46
+
let uri = new AtUri(document_uri);
47
+
if (uri.host !== identity.atp_did) return;
48
+
49
+
await Promise.all([
50
+
agent.pub.leaflet.document.delete({
51
+
repo: credentialSession.did,
52
+
rkey: uri.rkey,
53
+
}),
54
+
supabaseServerClient.from("documents").delete().eq("uri", document_uri),
55
+
]);
33
56
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
34
57
}
+67
app/lish/[did]/[publication]/icon/route.ts
+67
app/lish/[did]/[publication]/icon/route.ts
···
1
+
import { NextRequest } from "next/server";
2
+
import { IdResolver } from "@atproto/identity";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import sharp from "sharp";
7
+
import { redirect } from "next/navigation";
8
+
9
+
let idResolver = new IdResolver();
10
+
11
+
export const dynamic = "force-dynamic";
12
+
13
+
export async function GET(
14
+
request: NextRequest,
15
+
props: { params: Promise<{ did: string; publication: string }> },
16
+
) {
17
+
console.log("are we getting here?");
18
+
const params = await props.params;
19
+
try {
20
+
let did = decodeURIComponent(params.did);
21
+
let uri;
22
+
if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
23
+
uri = AtUri.make(
24
+
did,
25
+
"pub.leaflet.publication",
26
+
params.publication,
27
+
).toString();
28
+
}
29
+
let { data: publication } = await supabaseServerClient
30
+
.from("publications")
31
+
.select(
32
+
`*,
33
+
publication_subscriptions(*),
34
+
documents_in_publications(documents(*))
35
+
`,
36
+
)
37
+
.eq("identity_did", did)
38
+
.or(`name.eq."${params.publication}", uri.eq."${uri}"`)
39
+
.single();
40
+
41
+
let record = publication?.record as PubLeafletPublication.Record | null;
42
+
if (!record?.icon) return redirect("/icon.png");
43
+
44
+
let identity = await idResolver.did.resolve(did);
45
+
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
46
+
if (!service) return redirect("/icon.png");
47
+
let cid = (record.icon.ref as unknown as { $link: string })["$link"];
48
+
const response = await fetch(
49
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
50
+
);
51
+
let blob = await response.blob();
52
+
let resizedImage = await sharp(await blob.arrayBuffer())
53
+
.resize({ width: 32, height: 32 })
54
+
.toBuffer();
55
+
return new Response(new Uint8Array(resizedImage), {
56
+
headers: {
57
+
"Content-Type": "image/png",
58
+
"CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400",
59
+
"Cache-Control":
60
+
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
61
+
},
62
+
});
63
+
} catch (e) {
64
+
console.log(e);
65
+
return redirect("/icon.png");
66
+
}
67
+
}
-69
app/lish/[did]/[publication]/icon.ts
-69
app/lish/[did]/[publication]/icon.ts
···
1
-
import { NextRequest } from "next/server";
2
-
import { IdResolver } from "@atproto/identity";
3
-
import { AtUri } from "@atproto/syntax";
4
-
import { PubLeafletPublication } from "lexicons/api";
5
-
import { supabaseServerClient } from "supabase/serverClient";
6
-
import sharp from "sharp";
7
-
import { redirect } from "next/navigation";
8
-
9
-
let idResolver = new IdResolver();
10
-
11
-
export const size = {
12
-
width: 32,
13
-
height: 32,
14
-
};
15
-
16
-
export const contentType = "image/png";
17
-
export default async function Icon({
18
-
params,
19
-
}: {
20
-
params: { did: string; publication: string };
21
-
}) {
22
-
try {
23
-
let did = decodeURIComponent(params.did);
24
-
let uri;
25
-
if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
26
-
uri = AtUri.make(
27
-
did,
28
-
"pub.leaflet.publication",
29
-
params.publication,
30
-
).toString();
31
-
}
32
-
let { data: publication } = await supabaseServerClient
33
-
.from("publications")
34
-
.select(
35
-
`*,
36
-
publication_subscriptions(*),
37
-
documents_in_publications(documents(*))
38
-
`,
39
-
)
40
-
.eq("identity_did", did)
41
-
.or(`name.eq."${params.publication}", uri.eq."${uri}"`)
42
-
.single();
43
-
44
-
let record = publication?.record as PubLeafletPublication.Record | null;
45
-
if (!record?.icon) return redirect("/icon.png");
46
-
47
-
let identity = await idResolver.did.resolve(did);
48
-
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
49
-
if (!service) return null;
50
-
let cid = (record.icon.ref as unknown as { $link: string })["$link"];
51
-
const response = await fetch(
52
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
53
-
);
54
-
let blob = await response.blob();
55
-
let resizedImage = await sharp(await blob.arrayBuffer())
56
-
.resize({ width: 32, height: 32 })
57
-
.toBuffer();
58
-
return new Response(new Uint8Array(resizedImage), {
59
-
headers: {
60
-
"Content-Type": "image/png",
61
-
"CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400",
62
-
"Cache-Control":
63
-
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
64
-
},
65
-
});
66
-
} catch (e) {
67
-
return redirect("/icon.png");
68
-
}
69
-
}
+8
app/lish/[did]/[publication]/layout.tsx
+8
app/lish/[did]/[publication]/layout.tsx
···
47
47
title: pubRecord?.name || "Untitled Publication",
48
48
description: pubRecord?.description || "",
49
49
icons: {
50
+
icon: {
51
+
url:
52
+
process.env.NODE_ENV === "development"
53
+
? `/lish/${did}/${publication_name}/icon`
54
+
: "/icon",
55
+
sizes: "32x32",
56
+
type: "image/png",
57
+
},
50
58
other: {
51
59
rel: "alternate",
52
60
url: publication.uri,
+3
-2
app/lish/[did]/[publication]/opengraph-image.ts
+3
-2
app/lish/[did]/[publication]/opengraph-image.ts
···
4
4
export const revalidate = 60;
5
5
6
6
export default async function OpenGraphImage(props: {
7
-
params: { publication: string; did: string };
7
+
params: Promise<{ publication: string; did: string }>;
8
8
}) {
9
+
let params = await props.params;
9
10
return getMicroLinkOgImage(
10
-
`/lish/${encodeURIComponent(props.params.did)}/${encodeURIComponent(props.params.publication)}/`,
11
+
`/lish/${encodeURIComponent(params.did)}/${encodeURIComponent(params.publication)}/`,
11
12
);
12
13
}
+108
-120
app/lish/[did]/[publication]/page.tsx
+108
-120
app/lish/[did]/[publication]/page.tsx
···
14
14
import { SpeedyLink } from "components/SpeedyLink";
15
15
import { QuoteTiny } from "components/Icons/QuoteTiny";
16
16
import { CommentTiny } from "components/Icons/CommentTiny";
17
+
import { InteractionPreview } from "components/InteractionsPreview";
17
18
import { LocalizedDate } from "./LocalizedDate";
19
+
import { PublicationHomeLayout } from "./PublicationHomeLayout";
18
20
19
21
export default async function Publication(props: {
20
22
params: Promise<{ publication: string; did: string }>;
···
59
61
try {
60
62
return (
61
63
<PublicationThemeProvider
62
-
record={record}
64
+
theme={record?.theme}
63
65
pub_creator={publication.identity_did}
64
66
>
65
67
<PublicationBackgroundProvider
66
-
record={record}
68
+
theme={record?.theme}
67
69
pub_creator={publication.identity_did}
68
70
>
69
-
<div
70
-
className={`pubWrapper flex flex-col sm:py-6 h-full ${showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`}
71
+
<PublicationHomeLayout
72
+
uri={publication.uri}
73
+
showPageBackground={!!showPageBackground}
71
74
>
72
-
<div
73
-
className={`pub sm:max-w-prose max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`}
74
-
>
75
-
<div className="pubHeader flex flex-col pb-8 w-full text-center justify-center ">
76
-
{record?.icon && (
77
-
<div
78
-
className="shrink-0 w-10 h-10 rounded-full mx-auto"
79
-
style={{
80
-
backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`,
81
-
backgroundRepeat: "no-repeat",
82
-
backgroundPosition: "center",
83
-
backgroundSize: "cover",
84
-
}}
85
-
/>
86
-
)}
87
-
<h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 ">
88
-
{publication.name}
89
-
</h2>
90
-
<p className="sm:text-lg text-secondary">
91
-
{record?.description}{" "}
75
+
<div className="pubHeader flex flex-col pb-8 w-full text-center justify-center ">
76
+
{record?.icon && (
77
+
<div
78
+
className="shrink-0 w-10 h-10 rounded-full mx-auto"
79
+
style={{
80
+
backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`,
81
+
backgroundRepeat: "no-repeat",
82
+
backgroundPosition: "center",
83
+
backgroundSize: "cover",
84
+
}}
85
+
/>
86
+
)}
87
+
<h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 ">
88
+
{publication.name}
89
+
</h2>
90
+
<p className="sm:text-lg text-secondary">
91
+
{record?.description}{" "}
92
+
</p>
93
+
{profile && (
94
+
<p className="italic text-tertiary sm:text-base text-sm">
95
+
<strong className="">by {profile.displayName}</strong>{" "}
96
+
<a
97
+
className="text-tertiary"
98
+
href={`https://bsky.app/profile/${profile.handle}`}
99
+
>
100
+
@{profile.handle}
101
+
</a>
92
102
</p>
93
-
{profile && (
94
-
<p className="italic text-tertiary sm:text-base text-sm">
95
-
<strong className="">by {profile.displayName}</strong>{" "}
96
-
<a
97
-
className="text-tertiary"
98
-
href={`https://bsky.app/profile/${profile.handle}`}
99
-
>
100
-
@{profile.handle}
101
-
</a>
102
-
</p>
103
-
)}
104
-
<div className="sm:pt-4 pt-4">
105
-
<SubscribeWithBluesky
106
-
base_url={getPublicationURL(publication)}
107
-
pubName={publication.name}
108
-
pub_uri={publication.uri}
109
-
subscribers={publication.publication_subscriptions}
110
-
/>
111
-
</div>
103
+
)}
104
+
<div className="sm:pt-4 pt-4">
105
+
<SubscribeWithBluesky
106
+
base_url={getPublicationURL(publication)}
107
+
pubName={publication.name}
108
+
pub_uri={publication.uri}
109
+
subscribers={publication.publication_subscriptions}
110
+
/>
112
111
</div>
113
-
<div className="publicationPostList w-full flex flex-col gap-4">
114
-
{publication.documents_in_publications
115
-
.filter((d) => !!d?.documents)
116
-
.sort((a, b) => {
117
-
let aRecord = a.documents
118
-
?.data! as PubLeafletDocument.Record;
119
-
let bRecord = b.documents
120
-
?.data! as PubLeafletDocument.Record;
121
-
const aDate = aRecord.publishedAt
122
-
? new Date(aRecord.publishedAt)
123
-
: new Date(0);
124
-
const bDate = bRecord.publishedAt
125
-
? new Date(bRecord.publishedAt)
126
-
: new Date(0);
127
-
return bDate.getTime() - aDate.getTime(); // Sort by most recent first
128
-
})
129
-
.map((doc) => {
130
-
if (!doc.documents) return null;
131
-
let uri = new AtUri(doc.documents.uri);
132
-
let doc_record = doc.documents
133
-
.data as PubLeafletDocument.Record;
134
-
let quotes =
135
-
doc.documents.document_mentions_in_bsky[0].count || 0;
136
-
let comments =
137
-
record?.preferences?.showComments === false
138
-
? 0
139
-
: doc.documents.comments_on_documents[0].count || 0;
112
+
</div>
113
+
<div className="publicationPostList w-full flex flex-col gap-4">
114
+
{publication.documents_in_publications
115
+
.filter((d) => !!d?.documents)
116
+
.sort((a, b) => {
117
+
let aRecord = a.documents?.data! as PubLeafletDocument.Record;
118
+
let bRecord = b.documents?.data! as PubLeafletDocument.Record;
119
+
const aDate = aRecord.publishedAt
120
+
? new Date(aRecord.publishedAt)
121
+
: new Date(0);
122
+
const bDate = bRecord.publishedAt
123
+
? new Date(bRecord.publishedAt)
124
+
: new Date(0);
125
+
return bDate.getTime() - aDate.getTime(); // Sort by most recent first
126
+
})
127
+
.map((doc) => {
128
+
if (!doc.documents) return null;
129
+
let uri = new AtUri(doc.documents.uri);
130
+
let doc_record = doc.documents
131
+
.data as PubLeafletDocument.Record;
132
+
let quotes =
133
+
doc.documents.document_mentions_in_bsky[0].count || 0;
134
+
let comments =
135
+
record?.preferences?.showComments === false
136
+
? 0
137
+
: doc.documents.comments_on_documents[0].count || 0;
138
+
let tags = (doc_record?.tags as string[] | undefined) || [];
140
139
141
-
return (
142
-
<React.Fragment key={doc.documents?.uri}>
143
-
<div className="flex w-full grow flex-col ">
144
-
<SpeedyLink
145
-
href={`${getPublicationURL(publication)}/${uri.rkey}`}
146
-
className="publishedPost hover:no-underline! flex flex-col"
147
-
>
148
-
<h3 className="text-primary">{doc_record.title}</h3>
149
-
<p className="italic text-secondary">
150
-
{doc_record.description}
151
-
</p>
152
-
</SpeedyLink>
140
+
return (
141
+
<React.Fragment key={doc.documents?.uri}>
142
+
<div className="flex w-full grow flex-col ">
143
+
<SpeedyLink
144
+
href={`${getPublicationURL(publication)}/${uri.rkey}`}
145
+
className="publishedPost hover:no-underline! flex flex-col"
146
+
>
147
+
<h3 className="text-primary">{doc_record.title}</h3>
148
+
<p className="italic text-secondary">
149
+
{doc_record.description}
150
+
</p>
151
+
</SpeedyLink>
153
152
154
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2">
155
-
<p className="text-sm text-tertiary ">
156
-
{doc_record.publishedAt && (
157
-
<LocalizedDate
158
-
dateString={doc_record.publishedAt}
159
-
options={{
160
-
year: "numeric",
161
-
month: "long",
162
-
day: "2-digit",
163
-
}}
164
-
/>
165
-
)}{" "}
166
-
</p>
167
-
{comments > 0 || quotes > 0 ? "| " : ""}
168
-
{quotes > 0 && (
169
-
<SpeedyLink
170
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
171
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
172
-
>
173
-
<QuoteTiny /> {quotes}
174
-
</SpeedyLink>
175
-
)}
176
-
{comments > 0 &&
177
-
record?.preferences?.showComments !== false && (
178
-
<SpeedyLink
179
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
180
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
181
-
>
182
-
<CommentTiny /> {comments}
183
-
</SpeedyLink>
184
-
)}
185
-
</div>
153
+
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2">
154
+
<p className="text-sm text-tertiary ">
155
+
{doc_record.publishedAt && (
156
+
<LocalizedDate
157
+
dateString={doc_record.publishedAt}
158
+
options={{
159
+
year: "numeric",
160
+
month: "long",
161
+
day: "2-digit",
162
+
}}
163
+
/>
164
+
)}{" "}
165
+
</p>
166
+
{comments > 0 || quotes > 0 ? "| " : ""}
167
+
<InteractionPreview
168
+
quotesCount={quotes}
169
+
commentsCount={comments}
170
+
tags={tags}
171
+
postUrl=""
172
+
showComments={record?.preferences?.showComments}
173
+
/>
186
174
</div>
187
-
<hr className="last:hidden border-border-light" />
188
-
</React.Fragment>
189
-
);
190
-
})}
191
-
</div>
175
+
</div>
176
+
<hr className="last:hidden border-border-light" />
177
+
</React.Fragment>
178
+
);
179
+
})}
192
180
</div>
193
-
</div>
181
+
</PublicationHomeLayout>
194
182
</PublicationBackgroundProvider>
195
183
</PublicationThemeProvider>
196
184
);
+6
-7
app/lish/createPub/CreatePubForm.tsx
+6
-7
app/lish/createPub/CreatePubForm.tsx
···
127
127
onChange={(e) => setShowInDiscover(e.target.checked)}
128
128
>
129
129
<div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
130
-
<p className="font-bold italic">
131
-
Show In{" "}
130
+
<p className="font-bold italic">Show In Discover</p>
131
+
<p className="text-sm text-tertiary font-normal">
132
+
Your posts will appear on our{" "}
132
133
<a href="/discover" target="_blank">
133
134
Discover
134
-
</a>
135
-
</p>
136
-
<p className="text-sm text-tertiary font-normal">
137
-
You'll be able to change this later!
135
+
</a>{" "}
136
+
page. You can change this at any time!
138
137
</p>
139
138
</div>
140
139
</Checkbox>
141
140
<hr className="border-border-light" />
142
141
143
-
<div className="flex w-full justify-center">
142
+
<div className="flex w-full justify-end">
144
143
<ButtonPrimary
145
144
type="submit"
146
145
disabled={
+5
-2
app/lish/createPub/UpdatePubForm.tsx
+5
-2
app/lish/createPub/UpdatePubForm.tsx
···
66
66
if (!pubData) return;
67
67
e.preventDefault();
68
68
props.setLoadingAction(true);
69
-
console.log("step 1:update");
70
69
let data = await updatePublication({
71
70
uri: pubData.uri,
72
71
name: nameValue,
···
171
170
</a>
172
171
</p>
173
172
<p className="text-xs text-tertiary font-normal">
174
-
This publication will appear on our public Discover page
173
+
Your posts will appear on our{" "}
174
+
<a href="/discover" target="_blank">
175
+
Discover
176
+
</a>{" "}
177
+
page. You can change this at any time!
175
178
</p>
176
179
</div>
177
180
</Checkbox>
+1
app/lish/createPub/createPublication.ts
+1
app/lish/createPub/createPublication.ts
+3
-11
app/lish/createPub/getPublicationURL.ts
+3
-11
app/lish/createPub/getPublicationURL.ts
···
3
3
import { isProductionDomain } from "src/utils/isProductionDeployment";
4
4
import { Json } from "supabase/database.types";
5
5
6
-
export function getPublicationURL(pub: {
7
-
uri: string;
8
-
name: string;
9
-
record: Json;
10
-
}) {
6
+
export function getPublicationURL(pub: { uri: string; record: Json }) {
11
7
let record = pub.record as PubLeafletPublication.Record;
12
8
if (isProductionDomain() && record?.base_path)
13
9
return `https://${record.base_path}`;
14
10
else return getBasePublicationURL(pub);
15
11
}
16
12
17
-
export function getBasePublicationURL(pub: {
18
-
uri: string;
19
-
name: string;
20
-
record: Json;
21
-
}) {
13
+
export function getBasePublicationURL(pub: { uri: string; record: Json }) {
22
14
let record = pub.record as PubLeafletPublication.Record;
23
15
let aturi = new AtUri(pub.uri);
24
-
return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name || pub.name)}`;
16
+
return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`;
25
17
}
+1
-1
app/lish/createPub/page.tsx
+1
-1
app/lish/createPub/page.tsx
···
26
26
<div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto">
27
27
<div className="createPubFormWrapper h-fit w-full flex flex-col gap-4">
28
28
<h2 className="text-center">Create Your Publication!</h2>
29
-
<div className="container w-full p-3">
29
+
<div className="opaque-container w-full sm:py-4 p-3">
30
30
<CreatePubForm />
31
31
</div>
32
32
</div>
+91
app/lish/uri/[uri]/route.ts
+91
app/lish/uri/[uri]/route.ts
···
1
+
import { NextRequest, NextResponse } from "next/server";
2
+
import { AtUri } from "@atproto/api";
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
6
+
/**
7
+
* Redirect route for AT URIs (publications and documents)
8
+
* Redirects to the actual hosted domains from publication records
9
+
*/
10
+
export async function GET(
11
+
request: NextRequest,
12
+
{ params }: { params: Promise<{ uri: string }> }
13
+
) {
14
+
try {
15
+
const { uri: uriParam } = await params;
16
+
const atUriString = decodeURIComponent(uriParam);
17
+
const uri = new AtUri(atUriString);
18
+
19
+
if (uri.collection === "pub.leaflet.publication") {
20
+
// Get the publication record to retrieve base_path
21
+
const { data: publication } = await supabaseServerClient
22
+
.from("publications")
23
+
.select("record")
24
+
.eq("uri", atUriString)
25
+
.single();
26
+
27
+
if (!publication?.record) {
28
+
return new NextResponse("Publication not found", { status: 404 });
29
+
}
30
+
31
+
const record = publication.record as PubLeafletPublication.Record;
32
+
const basePath = record.base_path;
33
+
34
+
if (!basePath) {
35
+
return new NextResponse("Publication has no base_path", { status: 404 });
36
+
}
37
+
38
+
// Redirect to the publication's hosted domain (temporary redirect since base_path can change)
39
+
return NextResponse.redirect(basePath, 307);
40
+
} else if (uri.collection === "pub.leaflet.document") {
41
+
// Document link - need to find the publication it belongs to
42
+
const { data: docInPub } = await supabaseServerClient
43
+
.from("documents_in_publications")
44
+
.select("publication, publications!inner(record)")
45
+
.eq("document", atUriString)
46
+
.single();
47
+
48
+
if (docInPub?.publication && docInPub.publications) {
49
+
// Document is in a publication - redirect to domain/rkey
50
+
const record = docInPub.publications.record as PubLeafletPublication.Record;
51
+
const basePath = record.base_path;
52
+
53
+
if (!basePath) {
54
+
return new NextResponse("Publication has no base_path", { status: 404 });
55
+
}
56
+
57
+
// Ensure basePath ends without trailing slash
58
+
const cleanBasePath = basePath.endsWith("/")
59
+
? basePath.slice(0, -1)
60
+
: basePath;
61
+
62
+
// Redirect to the document on the publication's domain (temporary redirect since base_path can change)
63
+
return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307);
64
+
}
65
+
66
+
// If not in a publication, check if it's a standalone document
67
+
const { data: doc } = await supabaseServerClient
68
+
.from("documents")
69
+
.select("uri")
70
+
.eq("uri", atUriString)
71
+
.single();
72
+
73
+
if (doc) {
74
+
// Standalone document - redirect to /p/did/rkey (temporary redirect)
75
+
return NextResponse.redirect(
76
+
new URL(`/p/${uri.host}/${uri.rkey}`, request.url),
77
+
307
78
+
);
79
+
}
80
+
81
+
// Document not found
82
+
return new NextResponse("Document not found", { status: 404 });
83
+
}
84
+
85
+
// Unsupported collection type
86
+
return new NextResponse("Unsupported URI type", { status: 400 });
87
+
} catch (error) {
88
+
console.error("Error resolving AT URI:", error);
89
+
return new NextResponse("Invalid URI", { status: 400 });
90
+
}
91
+
}
+1
-1
app/login/LoginForm.tsx
+1
-1
app/login/LoginForm.tsx
···
213
213
</ButtonPrimary>
214
214
<button
215
215
type="button"
216
-
className={`${props.compact ? "text-xs" : "text-sm"} text-accent-contrast place-self-center mt-[6px]`}
216
+
className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`}
217
217
onClick={() => setSigningWithHandle(true)}
218
218
>
219
219
use an ATProto handle
+20
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts
+20
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts
···
1
+
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
+
import { decodeQuotePosition } from "app/lish/[did]/[publication]/[rkey]/quotePosition";
3
+
4
+
export const runtime = "edge";
5
+
export const revalidate = 60;
6
+
7
+
export default async function OpenGraphImage(props: {
8
+
params: Promise<{ didOrHandle: string; rkey: string; quote: string }>;
9
+
}) {
10
+
let params = await props.params;
11
+
let quotePosition = decodeQuotePosition(params.quote);
12
+
return getMicroLinkOgImage(
13
+
`/p/${decodeURIComponent(params.didOrHandle)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`,
14
+
{
15
+
width: 620,
16
+
height: 324,
17
+
deviceScaleFactor: 2,
18
+
},
19
+
);
20
+
}
+8
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx
+8
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx
+13
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
+13
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
+
}
+90
app/p/[didOrHandle]/[rkey]/page.tsx
+90
app/p/[didOrHandle]/[rkey]/page.tsx
···
1
+
import { supabaseServerClient } from "supabase/serverClient";
2
+
import { AtUri } from "@atproto/syntax";
3
+
import { ids } from "lexicons/api/lexicons";
4
+
import { PubLeafletDocument } from "lexicons/api";
5
+
import { Metadata } from "next";
6
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
+
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
+
9
+
export async function generateMetadata(props: {
10
+
params: Promise<{ didOrHandle: string; rkey: string }>;
11
+
}): Promise<Metadata> {
12
+
let params = await props.params;
13
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
14
+
15
+
// Resolve handle to DID if necessary
16
+
let did = didOrHandle;
17
+
if (!didOrHandle.startsWith("did:")) {
18
+
try {
19
+
let resolved = await idResolver.handle.resolve(didOrHandle);
20
+
if (resolved) did = resolved;
21
+
} catch (e) {
22
+
return { title: "404" };
23
+
}
24
+
}
25
+
26
+
let { data: document } = await supabaseServerClient
27
+
.from("documents")
28
+
.select("*, documents_in_publications(publications(*))")
29
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey))
30
+
.single();
31
+
32
+
if (!document) return { title: "404" };
33
+
34
+
let docRecord = document.data as PubLeafletDocument.Record;
35
+
36
+
// For documents in publications, include publication name
37
+
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
+
39
+
return {
40
+
icons: {
41
+
other: {
42
+
rel: "alternate",
43
+
url: document.uri,
44
+
},
45
+
},
46
+
title: publicationName
47
+
? `${docRecord.title} - ${publicationName}`
48
+
: docRecord.title,
49
+
description: docRecord?.description || "",
50
+
};
51
+
}
52
+
53
+
export default async function StandaloneDocumentPage(props: {
54
+
params: Promise<{ didOrHandle: string; rkey: string }>;
55
+
}) {
56
+
let params = await props.params;
57
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
58
+
59
+
// Resolve handle to DID if necessary
60
+
let did = didOrHandle;
61
+
if (!didOrHandle.startsWith("did:")) {
62
+
try {
63
+
let resolved = await idResolver.handle.resolve(didOrHandle);
64
+
if (!resolved) {
65
+
return (
66
+
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
+
<p>Sorry, can't resolve handle.</p>
68
+
<p>
69
+
This may be a glitch on our end. If the issue persists please{" "}
70
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
+
</p>
72
+
</div>
73
+
);
74
+
}
75
+
did = resolved;
76
+
} catch (e) {
77
+
return (
78
+
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
+
<p>Sorry, can't resolve handle.</p>
80
+
<p>
81
+
This may be a glitch on our end. If the issue persists please{" "}
82
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
+
</p>
84
+
</div>
85
+
);
86
+
}
87
+
}
88
+
89
+
return <DocumentPageRenderer did={did} rkey={params.rkey} />;
90
+
}
-159
app/templates/TemplateList.tsx
-159
app/templates/TemplateList.tsx
···
1
-
"use client";
2
-
3
-
import { ButtonPrimary } from "components/Buttons";
4
-
import Image from "next/image";
5
-
import Link from "next/link";
6
-
import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate";
7
-
import { AddTiny } from "components/Icons/AddTiny";
8
-
9
-
export function LeafletTemplate(props: {
10
-
title: string;
11
-
description?: string;
12
-
image: string;
13
-
alt: string;
14
-
templateID: string; // readonly id for the leaflet that will be duplicated
15
-
}) {
16
-
return (
17
-
<div className="flex flex-col gap-4">
18
-
<div className="flex flex-col gap-2">
19
-
<div className="max-w-[274px] h-[154px] relative">
20
-
<Image
21
-
className="absolute top-0 left-0 rounded-md w-full h-full object-cover"
22
-
src={props.image}
23
-
alt={props.alt}
24
-
width={274}
25
-
height={154}
26
-
/>
27
-
</div>
28
-
</div>
29
-
<div className={`flex flex-col ${props.description ? "gap-4" : "gap-2"}`}>
30
-
<div className="gap-0">
31
-
<h3 className="font-bold text-center text-secondary">
32
-
{props.title}
33
-
</h3>
34
-
{props.description && (
35
-
<div className="text-tertiary text-sm font-normal text-center">
36
-
{props.description}
37
-
</div>
38
-
)}
39
-
</div>
40
-
<div className="flex sm:flex-row flex-col gap-2 justify-center items-center bottom-4">
41
-
<Link
42
-
href={`https://leaflet.pub/` + props.templateID}
43
-
target="_blank"
44
-
className="no-underline hover:no-underline"
45
-
>
46
-
<ButtonPrimary className="bg-primary hover:outline-hidden! hover:scale-105 hover:rotate-3 transition-all">
47
-
Preview
48
-
</ButtonPrimary>
49
-
</Link>
50
-
<ButtonPrimary
51
-
className=" hover:outline-hidden! hover:scale-105 hover:-rotate-2 transition-all"
52
-
onClick={async () => {
53
-
let id = await createNewLeafletFromTemplate(
54
-
props.templateID,
55
-
false,
56
-
);
57
-
window.open(`/${id}`, "_blank");
58
-
}}
59
-
>
60
-
Create
61
-
<AddTiny />
62
-
</ButtonPrimary>
63
-
</div>
64
-
</div>
65
-
</div>
66
-
);
67
-
}
68
-
69
-
export function TemplateList(props: {
70
-
name: string;
71
-
description?: string;
72
-
children: React.ReactNode;
73
-
}) {
74
-
return (
75
-
<div className="templateLeafletGrid flex flex-col gap-6">
76
-
<div className="flex flex-col gap-0 text-center">
77
-
<h3 className="text-[24px]">{props.name}</h3>
78
-
<p className="text-secondary">{props.description}</p>
79
-
</div>
80
-
<div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-8 gap-x-6 sm:gap-6 grow pb-8">
81
-
{props.children}
82
-
</div>
83
-
</div>
84
-
);
85
-
}
86
-
87
-
export function TemplateListThemes() {
88
-
return (
89
-
<>
90
-
<TemplateList
91
-
name="Themes"
92
-
description="A small sampling of Leaflet's infinite theme possibilities!"
93
-
>
94
-
<LeafletTemplate
95
-
title="Foliage"
96
-
image="/templates/template-foliage-548x308.jpg"
97
-
alt="preview image of Foliage theme, with lots of green and leafy bg"
98
-
templateID="e4323c1d-15c1-407d-afaf-e5d772a35f0e"
99
-
/>
100
-
<LeafletTemplate
101
-
title="Lunar"
102
-
image="/templates/template-lunar-548x308.jpg"
103
-
alt="preview image of Lunar theme, with dark grey, red, and moon bg"
104
-
templateID="219d14ab-096c-4b48-83ee-36446e335c3e"
105
-
/>
106
-
<LeafletTemplate
107
-
title="Paper"
108
-
image="/templates/template-paper-548x308.jpg"
109
-
alt="preview image of Paper theme, with red, gold, green and marbled paper bg"
110
-
templateID="9b28ceea-0220-42ac-87e6-3976d156f653"
111
-
/>
112
-
<LeafletTemplate
113
-
title="Oceanic"
114
-
image="/templates/template-oceanic-548x308.jpg"
115
-
alt="preview image of Oceanic theme, with dark and light blue and ocean bg"
116
-
templateID="a65a56d7-713d-437e-9c42-f18bdc6fe2a7"
117
-
/>
118
-
</TemplateList>
119
-
</>
120
-
);
121
-
}
122
-
123
-
export function TemplateListExamples() {
124
-
return (
125
-
<TemplateList
126
-
name="Examples"
127
-
description="Creative documents you can make and share with Leaflet"
128
-
>
129
-
<LeafletTemplate
130
-
title="Reading List"
131
-
description="Make a list for your own reading, or share recs with friends!"
132
-
image="/templates/template-reading-548x308.jpg"
133
-
alt="preview image of Reading List template, with a few sections and example books as sub-pages"
134
-
templateID="a5655b68-fe7a-4494-bda6-c9847523b2f6"
135
-
/>
136
-
<LeafletTemplate
137
-
title="Travel Plan"
138
-
description="Organize a trip โ notes, logistics, itinerary, even a shared scrapbook"
139
-
image="/templates/template-travel-548x308.jpg"
140
-
alt="preview image of a Travel Plan template, with pages for itinerary, logistics, research, and a travel diary canvas"
141
-
templateID="4d6f1392-dfd3-4015-925d-df55b7da5566"
142
-
/>
143
-
<LeafletTemplate
144
-
title="Gift Guide"
145
-
description="Share your favorite things โ products, restaurants, moviesโฆ"
146
-
image="/templates/template-gift-548x308.jpg"
147
-
alt="preview image for a Gift Guide template, with three blank canvases for different categories"
148
-
templateID="de73df29-35d9-4a43-a441-7ce45ad3b498"
149
-
/>
150
-
<LeafletTemplate
151
-
title="Event Page"
152
-
description="Host an event โ from a single meetup, to a whole conference!"
153
-
image="/templates/template-event-548x308.jpg"
154
-
alt="preview image for an Event Page template, with an event info section and linked pages / canvases for more info"
155
-
templateID="23d8a4ec-b2f6-438a-933d-726d2188974d"
156
-
/>
157
-
</TemplateList>
158
-
);
159
-
}
-108
app/templates/icon.tsx
-108
app/templates/icon.tsx
···
1
-
// NOTE: duplicated from home/icon.tsx
2
-
// we could make it different so it's clear it's not your personal colors?
3
-
4
-
import { ImageResponse } from "next/og";
5
-
import type { Fact } from "src/replicache";
6
-
import type { Attribute } from "src/replicache/attributes";
7
-
import { Database } from "../../supabase/database.types";
8
-
import { createServerClient } from "@supabase/ssr";
9
-
import { parseHSBToRGB } from "src/utils/parseHSB";
10
-
import { cookies } from "next/headers";
11
-
12
-
// Route segment config
13
-
export const revalidate = 0;
14
-
export const preferredRegion = ["sfo1"];
15
-
export const dynamic = "force-dynamic";
16
-
export const fetchCache = "force-no-store";
17
-
18
-
// Image metadata
19
-
export const size = {
20
-
width: 32,
21
-
height: 32,
22
-
};
23
-
export const contentType = "image/png";
24
-
25
-
// Image generation
26
-
let supabase = createServerClient<Database>(
27
-
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
28
-
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
29
-
{ cookies: {} },
30
-
);
31
-
export default async function Icon() {
32
-
let cookieStore = await cookies();
33
-
let identity = cookieStore.get("identity");
34
-
let rootEntity: string | null = null;
35
-
if (identity) {
36
-
let res = await supabase
37
-
.from("identities")
38
-
.select(
39
-
`*,
40
-
permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)),
41
-
permission_token_on_homepage(
42
-
*, permission_tokens(*, permission_token_rights(*))
43
-
)
44
-
`,
45
-
)
46
-
.eq("id", identity?.value)
47
-
.single();
48
-
rootEntity = res.data?.permission_tokens?.root_entity || null;
49
-
}
50
-
let outlineColor, fillColor;
51
-
if (rootEntity) {
52
-
let { data } = await supabase.rpc("get_facts", {
53
-
root: rootEntity,
54
-
});
55
-
let initialFacts = (data as unknown as Fact<Attribute>[]) || [];
56
-
let themePageBG = initialFacts.find(
57
-
(f) => f.attribute === "theme/card-background",
58
-
) as Fact<"theme/card-background"> | undefined;
59
-
60
-
let themePrimary = initialFacts.find(
61
-
(f) => f.attribute === "theme/primary",
62
-
) as Fact<"theme/primary"> | undefined;
63
-
64
-
outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`);
65
-
66
-
fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`);
67
-
}
68
-
69
-
return new ImageResponse(
70
-
(
71
-
// ImageResponse JSX element
72
-
<div style={{ display: "flex" }}>
73
-
<svg
74
-
width="32"
75
-
height="32"
76
-
viewBox="0 0 32 32"
77
-
fill="none"
78
-
xmlns="http://www.w3.org/2000/svg"
79
-
>
80
-
{/* outline */}
81
-
<path
82
-
fillRule="evenodd"
83
-
clipRule="evenodd"
84
-
d="M3.09628 21.8809C2.1044 23.5376 1.19806 25.3395 0.412496 27.2953C-0.200813 28.8223 0.539843 30.5573 2.06678 31.1706C3.59372 31.7839 5.32873 31.0433 5.94204 29.5163C6.09732 29.1297 6.24696 28.7489 6.39151 28.3811L6.39286 28.3777C6.94334 26.9769 7.41811 25.7783 7.99246 24.6987C8.63933 24.6636 9.37895 24.6582 10.2129 24.6535L10.3177 24.653C11.8387 24.6446 13.6711 24.6345 15.2513 24.3147C16.8324 23.9947 18.789 23.2382 19.654 21.2118C19.8881 20.6633 20.1256 19.8536 19.9176 19.0311C19.98 19.0311 20.044 19.031 20.1096 19.031C20.1447 19.031 20.1805 19.0311 20.2169 19.0311C21.0513 19.0316 22.2255 19.0324 23.2752 18.7469C24.5 18.4137 25.7878 17.6248 26.3528 15.9629C26.557 15.3624 26.5948 14.7318 26.4186 14.1358C26.4726 14.1262 26.528 14.1165 26.5848 14.1065C26.6121 14.1018 26.6398 14.0969 26.6679 14.092C27.3851 13.9667 28.3451 13.7989 29.1653 13.4921C29.963 13.1936 31.274 12.5268 31.6667 10.9987C31.8906 10.1277 31.8672 9.20568 31.3642 8.37294C31.1551 8.02669 30.889 7.75407 30.653 7.55302C30.8728 7.27791 31.1524 6.89517 31.345 6.47292C31.6791 5.74032 31.8513 4.66394 31.1679 3.61078C30.3923 2.4155 29.0623 2.2067 28.4044 2.1526C27.7203 2.09635 26.9849 2.15644 26.4564 2.2042C26.3846 2.02839 26.2858 1.84351 26.1492 1.66106C25.4155 0.681263 24.2775 0.598914 23.6369 0.61614C22.3428 0.650943 21.3306 1.22518 20.5989 1.82076C20.2149 2.13334 19.8688 2.48545 19.5698 2.81786C18.977 2.20421 18.1625 1.90193 17.3552 1.77751C15.7877 1.53594 14.5082 2.58853 13.6056 3.74374C12.4805 5.18375 11.7295 6.8566 10.7361 8.38059C10.3814 8.14984 9.83685 7.89945 9.16529 7.93065C8.05881 7.98204 7.26987 8.73225 6.79424 9.24551C5.96656 10.1387 5.46273 11.5208 5.10424 12.7289C4.71615 14.0368 4.38077 15.5845 4.06569 17.1171C3.87054 18.0664 3.82742 18.5183 4.01638 20.2489C3.43705 21.1826 3.54993 21.0505 3.09628 21.8809Z"
85
-
fill={outlineColor ? outlineColor : "#FFFFFF"}
86
-
/>
87
-
88
-
{/* fill */}
89
-
<path
90
-
fillRule="evenodd"
91
-
clipRule="evenodd"
92
-
d="M9.86889 10.2435C10.1927 10.528 10.5723 10.8615 11.3911 10.5766C11.9265 10.3903 12.6184 9.17682 13.3904 7.82283C14.5188 5.84367 15.8184 3.56431 17.0505 3.7542C18.5368 3.98325 18.4453 4.80602 18.3749 5.43886C18.3255 5.88274 18.2866 6.23317 18.8098 6.21972C19.3427 6.20601 19.8613 5.57971 20.4632 4.8529C21.2945 3.84896 22.2847 2.65325 23.6906 2.61544C24.6819 2.58879 24.6663 3.01595 24.6504 3.44913C24.6403 3.72602 24.63 4.00537 24.8826 4.17024C25.1314 4.33266 25.7571 4.2759 26.4763 4.21065C27.6294 4.10605 29.023 3.97963 29.4902 4.6995C29.9008 5.33235 29.3776 5.96135 28.8762 6.56423C28.4514 7.07488 28.0422 7.56679 28.2293 8.02646C28.3819 8.40149 28.6952 8.61278 29.0024 8.81991C29.5047 9.15866 29.9905 9.48627 29.7297 10.5009C29.4539 11.5737 27.7949 11.8642 26.2398 12.1366C24.937 12.3647 23.7072 12.5801 23.4247 13.2319C23.2475 13.6407 23.5414 13.8311 23.8707 14.0444C24.2642 14.2992 24.7082 14.5869 24.4592 15.3191C23.8772 17.031 21.9336 17.031 20.1095 17.0311C18.5438 17.0311 17.0661 17.0311 16.6131 18.1137C16.3515 18.7387 16.7474 18.849 17.1818 18.9701C17.7135 19.1183 18.3029 19.2826 17.8145 20.4267C16.8799 22.6161 13.3934 22.6357 10.2017 22.6536C9.03136 22.6602 7.90071 22.6665 6.95003 22.7795C6.84152 22.7924 6.74527 22.8547 6.6884 22.948C5.81361 24.3834 5.19318 25.9622 4.53139 27.6462C4.38601 28.0162 4.23862 28.3912 4.08611 28.7709C3.88449 29.2729 3.31413 29.5163 2.81217 29.3147C2.31021 29.1131 2.06673 28.5427 2.26834 28.0408C3.01927 26.1712 3.88558 24.452 4.83285 22.8739C6.37878 20.027 9.42621 16.5342 12.6488 13.9103C15.5162 11.523 18.2544 9.73614 21.4413 8.38026C21.8402 8.21054 21.7218 7.74402 21.3053 7.86437C18.4789 8.68119 15.9802 10.3013 13.3904 11.9341C10.5735 13.71 8.21288 16.1115 6.76027 17.8575C6.50414 18.1653 5.94404 17.9122 6.02468 17.5199C6.65556 14.4512 7.30668 11.6349 8.26116 10.605C9.16734 9.62708 9.47742 9.8995 9.86889 10.2435Z"
93
-
fill={fillColor ? fillColor : "#272727"}
94
-
/>
95
-
</svg>
96
-
</div>
97
-
),
98
-
// ImageResponse options
99
-
{
100
-
// For convenience, we can re-use the exported icons size metadata
101
-
// config to also set the ImageResponse's width and height.
102
-
...size,
103
-
headers: {
104
-
"Cache-Control": "no-cache",
105
-
},
106
-
},
107
-
);
108
-
}
-29
app/templates/page.tsx
-29
app/templates/page.tsx
···
1
-
import Link from "next/link";
2
-
import { TemplateListExamples, TemplateListThemes } from "./TemplateList";
3
-
import { ActionButton } from "components/ActionBar/ActionButton";
4
-
import { HomeSmall } from "components/Icons/HomeSmall";
5
-
6
-
export const metadata = {
7
-
title: "Leaflet Templates",
8
-
description: "example themes and documents you can use!",
9
-
};
10
-
11
-
export default function Templates() {
12
-
return (
13
-
<div className="flex h-full bg-bg-leaflet">
14
-
<div className="home relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col-reverse px-4 sm:px-6 ">
15
-
<div className="homeOptions z-10 shrink-0 sm:static absolute bottom-0 place-self-end sm:place-self-start flex sm:flex-col flex-row-reverse gap-2 sm:w-fit w-full items-center pb-2 pt-1 sm:pt-7">
16
-
{/* NOT using <HomeButton /> b/c it does a permission check we don't need */}
17
-
<Link href="/home">
18
-
<ActionButton icon={<HomeSmall />} label="Go Home" />
19
-
</Link>
20
-
</div>
21
-
<div className="flex flex-col gap-10 py-6 pt-3 sm:pt-6 sm:pb-12 sm:pl-6 grow w-full h-full overflow-y-scroll no-scrollbar">
22
-
<h1 className="text-center">Template Library</h1>
23
-
<TemplateListThemes />
24
-
<TemplateListExamples />
25
-
</div>
26
-
</div>
27
-
</div>
28
-
);
29
-
}
+20
-17
appview/index.ts
+20
-17
appview/index.ts
···
104
104
data: record.value as Json,
105
105
});
106
106
if (docResult.error) console.log(docResult.error);
107
-
let publicationURI = new AtUri(record.value.publication);
107
+
if (record.value.publication) {
108
+
let publicationURI = new AtUri(record.value.publication);
109
+
110
+
if (publicationURI.host !== evt.uri.host) {
111
+
console.log("Unauthorized to create post!");
112
+
return;
113
+
}
114
+
let docInPublicationResult = await supabase
115
+
.from("documents_in_publications")
116
+
.upsert({
117
+
publication: record.value.publication,
118
+
document: evt.uri.toString(),
119
+
});
120
+
await supabase
121
+
.from("documents_in_publications")
122
+
.delete()
123
+
.neq("publication", record.value.publication)
124
+
.eq("document", evt.uri.toString());
108
125
109
-
if (publicationURI.host !== evt.uri.host) {
110
-
console.log("Unauthorized to create post!");
111
-
return;
126
+
if (docInPublicationResult.error)
127
+
console.log(docInPublicationResult.error);
112
128
}
113
-
let docInPublicationResult = await supabase
114
-
.from("documents_in_publications")
115
-
.upsert({
116
-
publication: record.value.publication,
117
-
document: evt.uri.toString(),
118
-
});
119
-
await supabase
120
-
.from("documents_in_publications")
121
-
.delete()
122
-
.neq("publication", record.value.publication)
123
-
.eq("document", evt.uri.toString());
124
-
if (docInPublicationResult.error)
125
-
console.log(docInPublicationResult.error);
126
129
}
127
130
if (evt.event === "delete") {
128
131
await supabase.from("documents").delete().eq("uri", evt.uri.toString());
+1
-1
components/ActionBar/ActionButton.tsx
+1
-1
components/ActionBar/ActionButton.tsx
···
3
3
import { useContext, useEffect } from "react";
4
4
import { SidebarContext } from "./Sidebar";
5
5
import React, { forwardRef, type JSX } from "react";
6
-
import { PopoverOpenContext } from "components/Popover";
6
+
import { PopoverOpenContext } from "components/Popover/PopoverContext";
7
7
8
8
type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">;
9
9
+49
-24
components/ActionBar/Publications.tsx
+49
-24
components/ActionBar/Publications.tsx
···
12
12
import { PublishSmall } from "components/Icons/PublishSmall";
13
13
import { Popover } from "components/Popover";
14
14
import { BlueskyLogin } from "app/login/LoginForm";
15
-
import { ButtonPrimary } from "components/Buttons";
15
+
import { ButtonSecondary } from "components/Buttons";
16
16
import { useIsMobile } from "src/hooks/isMobile";
17
17
import { useState } from "react";
18
+
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
19
+
import { navPages } from "./Navigation";
18
20
19
21
export const PublicationButtons = (props: {
22
+
currentPage: navPages;
20
23
currentPubUri: string | undefined;
21
24
}) => {
22
25
let { identity } = useIdentityData();
26
+
let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
27
+
(f) =>
28
+
f.permission_tokens.leaflets_to_documents &&
29
+
f.permission_tokens.leaflets_to_documents[0]?.document,
30
+
);
23
31
24
32
// don't show pub list button if not logged in or no pub list
25
33
// we show a "start a pub" banner instead
26
34
if (!identity || !identity.atp_did || identity.publications.length === 0)
27
35
return <PubListEmpty />;
36
+
28
37
return (
29
38
<div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0">
39
+
{hasLooseleafs && (
40
+
<>
41
+
<SpeedyLink
42
+
href={`/looseleafs`}
43
+
className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full"
44
+
>
45
+
{/*TODO How should i get if this is the current page or not?
46
+
theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/}
47
+
<ActionButton
48
+
label="Looseleafs"
49
+
icon={<LooseLeafSmall />}
50
+
nav
51
+
className={
52
+
props.currentPage === "looseleafs"
53
+
? "bg-bg-page! border-border!"
54
+
: ""
55
+
}
56
+
/>
57
+
</SpeedyLink>
58
+
<hr className="border-border-light border-dashed mx-1" />
59
+
</>
60
+
)}
61
+
30
62
{identity.publications?.map((d) => {
31
63
return (
32
64
<PublicationOption
33
65
{...d}
34
66
key={d.uri}
35
67
record={d.record}
36
-
asActionButton
37
68
current={d.uri === props.currentPubUri}
38
69
/>
39
70
);
···
52
83
uri: string;
53
84
name: string;
54
85
record: Json;
55
-
asActionButton?: boolean;
56
86
current?: boolean;
57
87
}) => {
58
88
let record = props.record as PubLeafletPublication.Record | null;
···
63
93
href={`${getBasePublicationURL(props)}/dashboard`}
64
94
className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full"
65
95
>
66
-
{props.asActionButton ? (
67
-
<ActionButton
68
-
label={record.name}
69
-
icon={<PubIcon record={record} uri={props.uri} />}
70
-
nav
71
-
className={props.current ? "bg-bg-page! border-border!" : ""}
72
-
/>
73
-
) : (
74
-
<>
75
-
<PubIcon record={record} uri={props.uri} />
76
-
<div className="truncate">{record.name}</div>
77
-
</>
78
-
)}
96
+
<ActionButton
97
+
label={record.name}
98
+
icon={<PubIcon record={record} uri={props.uri} />}
99
+
nav
100
+
className={props.current ? "bg-bg-page! border-border!" : ""}
101
+
/>
79
102
</SpeedyLink>
80
103
);
81
104
};
82
105
83
106
const PubListEmpty = () => {
84
-
let { identity } = useIdentityData();
85
107
let isMobile = useIsMobile();
86
108
87
109
let [state, setState] = useState<"default" | "info">("default");
···
98
120
/>
99
121
);
100
122
101
-
if (isMobile && state === "info") return <PublishPopoverContent />;
123
+
if (isMobile && state === "info") return <PubListEmptyContent />;
102
124
else
103
125
return (
104
126
<Popover
105
127
side="right"
106
128
align="start"
107
129
className="p-1! max-w-56"
130
+
asChild
108
131
trigger={
109
132
<ActionButton
110
133
label="Publish"
···
114
137
/>
115
138
}
116
139
>
117
-
<PublishPopoverContent />
140
+
<PubListEmptyContent />
118
141
</Popover>
119
142
);
120
143
};
121
144
122
-
const PublishPopoverContent = () => {
145
+
export const PubListEmptyContent = (props: { compact?: boolean }) => {
123
146
let { identity } = useIdentityData();
124
147
125
148
return (
126
-
<div className="bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm">
149
+
<div
150
+
className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`}
151
+
>
127
152
<div className="mx-auto pt-2 scale-90">
128
153
<PubListEmptyIllo />
129
154
</div>
···
136
161
on AT Proto
137
162
</div>
138
163
<SpeedyLink href={`lish/createPub`} className=" hover:no-underline!">
139
-
<ButtonPrimary className="text-sm mx-auto" compact>
164
+
<ButtonSecondary className="text-sm mx-auto" compact>
140
165
Start a Publication!
141
-
</ButtonPrimary>
166
+
</ButtonSecondary>
142
167
</SpeedyLink>
143
168
</>
144
169
) : (
145
170
// no ATProto account and no pubs
146
171
<>
147
172
<div className="pb-2 text-secondary text-xs">
148
-
Link a Bluesky account to start a new publication on AT Proto
173
+
Link a Bluesky account to start <br /> a new publication on AT Proto
149
174
</div>
150
175
151
176
<BlueskyLogin compact />
+46
components/AtMentionLink.tsx
+46
components/AtMentionLink.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { atUriToUrl } from "src/utils/mentionUtils";
3
+
4
+
/**
5
+
* Component for rendering at-uri mentions (publications and documents) as clickable links.
6
+
* NOTE: This component's styling and behavior should match the ProseMirror schema rendering
7
+
* in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other.
8
+
*/
9
+
export function AtMentionLink({
10
+
atURI,
11
+
children,
12
+
className = "",
13
+
}: {
14
+
atURI: string;
15
+
children: React.ReactNode;
16
+
className?: string;
17
+
}) {
18
+
const aturi = new AtUri(atURI);
19
+
const isPublication = aturi.collection === "pub.leaflet.publication";
20
+
const isDocument = aturi.collection === "pub.leaflet.document";
21
+
22
+
// Show publication icon if available
23
+
const icon =
24
+
isPublication || isDocument ? (
25
+
<img
26
+
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
+
className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
28
+
alt=""
29
+
width="20"
30
+
height="20"
31
+
loading="lazy"
32
+
/>
33
+
) : null;
34
+
35
+
return (
36
+
<a
37
+
href={atUriToUrl(atURI)}
38
+
target="_blank"
39
+
rel="noopener noreferrer"
40
+
className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
+
>
42
+
{icon}
43
+
{children}
44
+
</a>
45
+
);
46
+
}
+43
-6
components/Blocks/BaseTextareaBlock.tsx
+43
-6
components/Blocks/BaseTextareaBlock.tsx
···
5
5
import { BlockProps } from "./Block";
6
6
import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea";
7
7
import { focusBlock } from "src/utils/focusBlock";
8
+
import { generateKeyBetween } from "fractional-indexing";
9
+
import { v7 } from "uuid";
10
+
import { elementId } from "src/utils/elementId";
11
+
import { Replicache } from "replicache";
12
+
import { ReplicacheMutators } from "src/replicache";
8
13
9
-
export function BaseTextareaBlock(
10
-
props: AutosizeTextareaProps & {
11
-
block: Pick<BlockProps, "previousBlock" | "nextBlock">;
12
-
},
13
-
) {
14
-
let { block, ...passDownProps } = props;
14
+
type BaseTextareaBlockProps = AutosizeTextareaProps & {
15
+
block: Pick<
16
+
BlockProps,
17
+
"previousBlock" | "nextBlock" | "parent" | "position" | "nextPosition"
18
+
>;
19
+
rep?: Replicache<ReplicacheMutators> | null;
20
+
permissionSet?: string;
21
+
};
22
+
23
+
export function BaseTextareaBlock(props: BaseTextareaBlockProps) {
24
+
let { block, rep, permissionSet, ...passDownProps } = props;
15
25
return (
16
26
<AsyncValueAutosizeTextarea
17
27
{...passDownProps}
18
28
noWrap
19
29
onKeyDown={(e) => {
30
+
// Shift-Enter or Ctrl-Enter: create new text block below and focus it
31
+
if (
32
+
(e.shiftKey || e.ctrlKey || e.metaKey) &&
33
+
e.key === "Enter" &&
34
+
rep &&
35
+
permissionSet
36
+
) {
37
+
e.preventDefault();
38
+
let newEntityID = v7();
39
+
rep.mutate.addBlock({
40
+
parent: block.parent,
41
+
type: "text",
42
+
factID: v7(),
43
+
permission_set: permissionSet,
44
+
position: generateKeyBetween(
45
+
block.position,
46
+
block.nextPosition || null,
47
+
),
48
+
newEntityID,
49
+
});
50
+
51
+
setTimeout(() => {
52
+
document.getElementById(elementId.block(newEntityID).text)?.focus();
53
+
}, 10);
54
+
return true;
55
+
}
56
+
20
57
if (e.key === "ArrowUp") {
21
58
let selection = e.currentTarget.selectionStart;
22
59
+4
-2
components/Blocks/BlockCommandBar.tsx
+4
-2
components/Blocks/BlockCommandBar.tsx
···
37
37
const clearCommandSearchText = () => {
38
38
if (!props.entityID) return;
39
39
const entityID = props.entityID;
40
-
40
+
41
41
const existingState = useEditorStates.getState().editorStates[entityID];
42
42
if (!existingState) return;
43
43
···
69
69
setHighlighted(commandResults[0].name);
70
70
}
71
71
}, [commandResults, setHighlighted, highlighted]);
72
+
72
73
useEffect(() => {
73
74
let listener = async (e: KeyboardEvent) => {
74
75
let reverseDir = ref.current?.dataset.side === "top";
···
118
119
return;
119
120
}
120
121
};
122
+
121
123
window.addEventListener("keydown", listener);
122
124
123
125
return () => window.removeEventListener("keydown", listener);
···
200
202
201
203
return (
202
204
<button
203
-
className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`}
205
+
className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`}
204
206
onMouseOver={() => {
205
207
props.setHighlighted(props.name);
206
208
}}
+13
-4
components/Blocks/BlockCommands.tsx
+13
-4
components/Blocks/BlockCommands.tsx
···
2
2
import { useUIState } from "src/useUIState";
3
3
4
4
import { generateKeyBetween } from "fractional-indexing";
5
-
import { focusPage } from "components/Pages";
5
+
import { focusPage } from "src/utils/focusPage";
6
6
import { v7 } from "uuid";
7
7
import { Replicache } from "replicache";
8
8
import { useEditorStates } from "src/state/useEditorState";
9
9
import { elementId } from "src/utils/elementId";
10
10
import { UndoManager } from "src/undoManager";
11
11
import { focusBlock } from "src/utils/focusBlock";
12
-
import { usePollBlockUIState } from "./PollBlock";
13
-
import { focusElement } from "components/Input";
12
+
import { usePollBlockUIState } from "./PollBlock/pollBlockState";
13
+
import { focusElement } from "src/utils/focusElement";
14
14
import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";
15
15
import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
16
16
import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
···
32
32
import { BlockMathSmall } from "components/Icons/BlockMathSmall";
33
33
import { BlockCodeSmall } from "components/Icons/BlockCodeSmall";
34
34
import { QuoteSmall } from "components/Icons/QuoteSmall";
35
+
import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
35
36
36
37
type Props = {
37
38
parent: string;
···
310
311
type: "block",
311
312
hiddenInPublication: false,
312
313
onSelect: async (rep, props) => {
313
-
createBlockWithType(rep, props, "code");
314
+
let entity = await createBlockWithType(rep, props, "code");
315
+
let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
316
+
if (lastLang) {
317
+
await rep.mutate.assertFact({
318
+
entity,
319
+
attribute: "block/code-language",
320
+
data: { type: "string", value: lastLang },
321
+
});
322
+
}
314
323
},
315
324
},
316
325
+6
-1
components/Blocks/CodeBlock.tsx
+6
-1
components/Blocks/CodeBlock.tsx
···
13
13
import { useEntitySetContext } from "components/EntitySetProvider";
14
14
import { flushSync } from "react-dom";
15
15
import { elementId } from "src/utils/elementId";
16
+
import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
16
17
17
18
export function CodeBlock(props: BlockProps) {
18
19
let { rep, rootEntity } = useReplicache();
···
25
26
let focusedBlock = useUIState(
26
27
(s) => s.focusedEntity?.entityID === props.entityID,
27
28
);
28
-
let { permissions } = useEntitySetContext();
29
+
let entity_set = useEntitySetContext();
30
+
let { permissions } = entity_set;
29
31
const [html, setHTML] = useState<string | null>(null);
30
32
31
33
useLayoutEffect(() => {
···
100
102
}}
101
103
value={lang}
102
104
onChange={async (e) => {
105
+
localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value);
103
106
await rep?.mutate.assertFact({
104
107
attribute: "block/code-language",
105
108
entity: props.entityID,
···
123
126
data-entityid={props.entityID}
124
127
id={elementId.block(props.entityID).input}
125
128
block={props}
129
+
rep={rep}
130
+
permissionSet={entity_set.set}
126
131
spellCheck={false}
127
132
autoCapitalize="none"
128
133
autoCorrect="off"
+2
-120
components/Blocks/DeleteBlock.tsx
+2
-120
components/Blocks/DeleteBlock.tsx
···
1
-
import {
2
-
Fact,
3
-
ReplicacheMutators,
4
-
useEntity,
5
-
useReplicache,
6
-
} from "src/replicache";
7
-
import { Replicache } from "replicache";
8
-
import { useUIState } from "src/useUIState";
9
-
import { scanIndex } from "src/replicache/utils";
10
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
11
-
import { focusBlock } from "src/utils/focusBlock";
1
+
import { Fact, useReplicache } from "src/replicache";
12
2
import { ButtonPrimary } from "components/Buttons";
13
3
import { CloseTiny } from "components/Icons/CloseTiny";
4
+
import { deleteBlock } from "src/utils/deleteBlock";
14
5
15
6
export const AreYouSure = (props: {
16
7
entityID: string[] | string;
···
82
73
);
83
74
};
84
75
85
-
export async function deleteBlock(
86
-
entities: string[],
87
-
rep: Replicache<ReplicacheMutators>,
88
-
) {
89
-
// get what pagess we need to close as a result of deleting this block
90
-
let pagesToClose = [] as string[];
91
-
for (let entity of entities) {
92
-
let [type] = await rep.query((tx) =>
93
-
scanIndex(tx).eav(entity, "block/type"),
94
-
);
95
-
if (type.data.value === "card") {
96
-
let [childPages] = await rep?.query(
97
-
(tx) => scanIndex(tx).eav(entity, "block/card") || [],
98
-
);
99
-
pagesToClose = [childPages?.data.value];
100
-
}
101
-
if (type.data.value === "mailbox") {
102
-
let [archive] = await rep?.query(
103
-
(tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [],
104
-
);
105
-
let [draft] = await rep?.query(
106
-
(tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [],
107
-
);
108
-
pagesToClose = [archive?.data.value, draft?.data.value];
109
-
}
110
-
}
111
-
112
-
// the next and previous blocks in the block list
113
-
// if the focused thing is a page and not a block, return
114
-
let focusedBlock = useUIState.getState().focusedEntity;
115
-
let parent =
116
-
focusedBlock?.entityType === "page"
117
-
? focusedBlock.entityID
118
-
: focusedBlock?.parent;
119
-
120
-
if (parent) {
121
-
let parentType = await rep?.query((tx) =>
122
-
scanIndex(tx).eav(parent, "page/type"),
123
-
);
124
-
if (parentType[0]?.data.value === "canvas") {
125
-
useUIState
126
-
.getState()
127
-
.setFocusedBlock({ entityType: "page", entityID: parent });
128
-
useUIState.getState().setSelectedBlocks([]);
129
-
} else {
130
-
let siblings =
131
-
(await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
132
-
133
-
let selectedBlocks = useUIState.getState().selectedBlocks;
134
-
let firstSelected = selectedBlocks[0];
135
-
let lastSelected = selectedBlocks[entities.length - 1];
136
-
137
-
let prevBlock =
138
-
siblings?.[
139
-
siblings.findIndex((s) => s.value === firstSelected?.value) - 1
140
-
];
141
-
let prevBlockType = await rep?.query((tx) =>
142
-
scanIndex(tx).eav(prevBlock?.value, "block/type"),
143
-
);
144
-
145
-
let nextBlock =
146
-
siblings?.[
147
-
siblings.findIndex((s) => s.value === lastSelected.value) + 1
148
-
];
149
-
let nextBlockType = await rep?.query((tx) =>
150
-
scanIndex(tx).eav(nextBlock?.value, "block/type"),
151
-
);
152
-
153
-
if (prevBlock) {
154
-
useUIState.getState().setSelectedBlock({
155
-
value: prevBlock.value,
156
-
parent: prevBlock.parent,
157
-
});
158
-
159
-
focusBlock(
160
-
{
161
-
value: prevBlock.value,
162
-
type: prevBlockType?.[0].data.value,
163
-
parent: prevBlock.parent,
164
-
},
165
-
{ type: "end" },
166
-
);
167
-
} else {
168
-
useUIState.getState().setSelectedBlock({
169
-
value: nextBlock.value,
170
-
parent: nextBlock.parent,
171
-
});
172
-
173
-
focusBlock(
174
-
{
175
-
value: nextBlock.value,
176
-
type: nextBlockType?.[0]?.data.value,
177
-
parent: nextBlock.parent,
178
-
},
179
-
{ type: "start" },
180
-
);
181
-
}
182
-
}
183
-
}
184
-
185
-
pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
186
-
await Promise.all(
187
-
entities.map((entity) =>
188
-
rep?.mutate.removeBlock({
189
-
blockEntity: entity,
190
-
}),
191
-
),
192
-
);
193
-
}
+65
-16
components/Blocks/EmbedBlock.tsx
+65
-16
components/Blocks/EmbedBlock.tsx
···
10
10
import { Input } from "components/Input";
11
11
import { isUrl } from "src/utils/isURL";
12
12
import { elementId } from "src/utils/elementId";
13
-
import { deleteBlock } from "./DeleteBlock";
14
13
import { focusBlock } from "src/utils/focusBlock";
15
14
import { useDrag } from "src/hooks/useDrag";
16
15
import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
17
16
import { CheckTiny } from "components/Icons/CheckTiny";
17
+
import { DotLoader } from "components/utils/DotLoader";
18
+
import {
19
+
LinkPreviewBody,
20
+
LinkPreviewMetadataResult,
21
+
} from "app/api/link_previews/route";
18
22
19
23
export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => {
20
24
let { permissions } = useEntitySetContext();
···
132
136
133
137
let entity_set = useEntitySetContext();
134
138
let [linkValue, setLinkValue] = useState("");
139
+
let [loading, setLoading] = useState(false);
135
140
let { rep } = useReplicache();
136
141
let submit = async () => {
137
142
let entity = props.entityID;
···
149
154
}
150
155
let link = linkValue;
151
156
if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
152
-
// these mutations = simpler subset of addLinkBlock
153
157
if (!rep) return;
154
-
await rep.mutate.assertFact({
155
-
entity: entity,
156
-
attribute: "block/type",
157
-
data: { type: "block-type-union", value: "embed" },
158
-
});
159
-
await rep?.mutate.assertFact({
160
-
entity: entity,
161
-
attribute: "embed/url",
162
-
data: {
163
-
type: "string",
164
-
value: link,
165
-
},
166
-
});
158
+
159
+
// Try to get embed URL from iframely, fallback to direct URL
160
+
setLoading(true);
161
+
try {
162
+
let res = await fetch("/api/link_previews", {
163
+
headers: { "Content-Type": "application/json" },
164
+
method: "POST",
165
+
body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
166
+
});
167
+
168
+
let embedUrl = link;
169
+
let embedHeight = 360;
170
+
171
+
if (res.status === 200) {
172
+
let data = await (res.json() as LinkPreviewMetadataResult);
173
+
if (data.success && data.data.links?.player?.[0]) {
174
+
let embed = data.data.links.player[0];
175
+
embedUrl = embed.href;
176
+
embedHeight = embed.media?.height || 300;
177
+
}
178
+
}
179
+
180
+
await rep.mutate.assertFact([
181
+
{
182
+
entity: entity,
183
+
attribute: "embed/url",
184
+
data: {
185
+
type: "string",
186
+
value: embedUrl,
187
+
},
188
+
},
189
+
{
190
+
entity: entity,
191
+
attribute: "embed/height",
192
+
data: {
193
+
type: "number",
194
+
value: embedHeight,
195
+
},
196
+
},
197
+
]);
198
+
} catch {
199
+
// On any error, fallback to using the URL directly
200
+
await rep.mutate.assertFact([
201
+
{
202
+
entity: entity,
203
+
attribute: "embed/url",
204
+
data: {
205
+
type: "string",
206
+
value: link,
207
+
},
208
+
},
209
+
]);
210
+
} finally {
211
+
setLoading(false);
212
+
}
167
213
};
168
214
let smoker = useSmoker();
169
215
···
171
217
<form
172
218
onSubmit={(e) => {
173
219
e.preventDefault();
220
+
if (loading) return;
174
221
let rect = document
175
222
.getElementById("embed-block-submit")
176
223
?.getBoundingClientRect();
···
212
259
<button
213
260
type="submit"
214
261
id="embed-block-submit"
262
+
disabled={loading}
215
263
className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
216
264
onMouseDown={(e) => {
217
265
e.preventDefault();
266
+
if (loading) return;
218
267
if (!linkValue || linkValue === "") {
219
268
smoker({
220
269
error: true,
···
234
283
submit();
235
284
}}
236
285
>
237
-
<CheckTiny />
286
+
{loading ? <DotLoader /> : <CheckTiny />}
238
287
</button>
239
288
</div>
240
289
</form>
+2
-1
components/Blocks/ExternalLinkBlock.tsx
+2
-1
components/Blocks/ExternalLinkBlock.tsx
···
8
8
import { v7 } from "uuid";
9
9
import { useSmoker } from "components/Toast";
10
10
import { Separator } from "components/Layout";
11
-
import { focusElement, Input } from "components/Input";
11
+
import { Input } from "components/Input";
12
+
import { focusElement } from "src/utils/focusElement";
12
13
import { isUrl } from "src/utils/isURL";
13
14
import { elementId } from "src/utils/elementId";
14
15
import { focusBlock } from "src/utils/focusBlock";
+1
-1
components/Blocks/MailboxBlock.tsx
+1
-1
components/Blocks/MailboxBlock.tsx
···
9
9
import { useEntitySetContext } from "components/EntitySetProvider";
10
10
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
11
11
import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
12
-
import { focusPage } from "components/Pages";
12
+
import { focusPage } from "src/utils/focusPage";
13
13
import { v7 } from "uuid";
14
14
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
15
15
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1
-1
components/Blocks/PageLinkBlock.tsx
+1
-1
components/Blocks/PageLinkBlock.tsx
···
2
2
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
3
3
import { focusBlock } from "src/utils/focusBlock";
4
4
5
-
import { focusPage } from "components/Pages";
5
+
import { focusPage } from "src/utils/focusPage";
6
6
import { useEntity, useReplicache } from "src/replicache";
7
7
import { useUIState } from "src/useUIState";
8
8
import { RenderedTextBlock } from "components/Blocks/TextBlock";
+501
components/Blocks/PollBlock/index.tsx
+501
components/Blocks/PollBlock/index.tsx
···
1
+
import { useUIState } from "src/useUIState";
2
+
import { BlockProps } from "../Block";
3
+
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
+
import { useCallback, useEffect, useState } from "react";
5
+
import { Input } from "components/Input";
6
+
import { focusElement } from "src/utils/focusElement";
7
+
import { Separator } from "components/Layout";
8
+
import { useEntitySetContext } from "components/EntitySetProvider";
9
+
import { theme } from "tailwind.config";
10
+
import { useEntity, useReplicache } from "src/replicache";
11
+
import { v7 } from "uuid";
12
+
import {
13
+
useLeafletPublicationData,
14
+
usePollData,
15
+
} from "components/PageSWRDataProvider";
16
+
import { voteOnPoll } from "actions/pollActions";
17
+
import { elementId } from "src/utils/elementId";
18
+
import { CheckTiny } from "components/Icons/CheckTiny";
19
+
import { CloseTiny } from "components/Icons/CloseTiny";
20
+
import { PublicationPollBlock } from "../PublicationPollBlock";
21
+
import { usePollBlockUIState } from "./pollBlockState";
22
+
23
+
export const PollBlock = (props: BlockProps) => {
24
+
let { data: pub } = useLeafletPublicationData();
25
+
if (!pub) return <LeafletPollBlock {...props} />;
26
+
return <PublicationPollBlock {...props} />;
27
+
};
28
+
29
+
export const LeafletPollBlock = (props: BlockProps) => {
30
+
let isSelected = useUIState((s) =>
31
+
s.selectedBlocks.find((b) => b.value === props.entityID),
32
+
);
33
+
let { permissions } = useEntitySetContext();
34
+
35
+
let { data: pollData } = usePollData();
36
+
let hasVoted =
37
+
pollData?.voter_token &&
38
+
pollData.polls.find(
39
+
(v) =>
40
+
v.poll_votes_on_entity.voter_token === pollData.voter_token &&
41
+
v.poll_votes_on_entity.poll_entity === props.entityID,
42
+
);
43
+
44
+
let pollState = usePollBlockUIState((s) => s[props.entityID]?.state);
45
+
if (!pollState) {
46
+
if (hasVoted) pollState = "results";
47
+
else pollState = "voting";
48
+
}
49
+
50
+
const setPollState = useCallback(
51
+
(state: "editing" | "voting" | "results") => {
52
+
usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } }));
53
+
},
54
+
[],
55
+
);
56
+
57
+
let votes =
58
+
pollData?.polls.filter(
59
+
(v) => v.poll_votes_on_entity.poll_entity === props.entityID,
60
+
) || [];
61
+
let totalVotes = votes.length;
62
+
63
+
return (
64
+
<div
65
+
className={`poll flex flex-col gap-2 p-3 w-full
66
+
${isSelected ? "block-border-selected " : "block-border"}`}
67
+
style={{
68
+
backgroundColor:
69
+
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
70
+
}}
71
+
>
72
+
{pollState === "editing" ? (
73
+
<EditPoll
74
+
totalVotes={totalVotes}
75
+
votes={votes.map((v) => v.poll_votes_on_entity)}
76
+
entityID={props.entityID}
77
+
close={() => {
78
+
if (hasVoted) setPollState("results");
79
+
else setPollState("voting");
80
+
}}
81
+
/>
82
+
) : pollState === "results" ? (
83
+
<PollResults
84
+
entityID={props.entityID}
85
+
pollState={pollState}
86
+
setPollState={setPollState}
87
+
hasVoted={!!hasVoted}
88
+
/>
89
+
) : (
90
+
<PollVote
91
+
entityID={props.entityID}
92
+
onSubmit={() => setPollState("results")}
93
+
pollState={pollState}
94
+
setPollState={setPollState}
95
+
hasVoted={!!hasVoted}
96
+
/>
97
+
)}
98
+
</div>
99
+
);
100
+
};
101
+
102
+
const PollVote = (props: {
103
+
entityID: string;
104
+
onSubmit: () => void;
105
+
pollState: "editing" | "voting" | "results";
106
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
107
+
hasVoted: boolean;
108
+
}) => {
109
+
let { data, mutate } = usePollData();
110
+
let { permissions } = useEntitySetContext();
111
+
112
+
let pollOptions = useEntity(props.entityID, "poll/options");
113
+
let currentVotes = data?.voter_token
114
+
? data.polls
115
+
.filter(
116
+
(p) =>
117
+
p.poll_votes_on_entity.poll_entity === props.entityID &&
118
+
p.poll_votes_on_entity.voter_token === data.voter_token,
119
+
)
120
+
.map((v) => v.poll_votes_on_entity.option_entity)
121
+
: [];
122
+
let [selectedPollOptions, setSelectedPollOptions] =
123
+
useState<string[]>(currentVotes);
124
+
125
+
return (
126
+
<>
127
+
{pollOptions.map((option, index) => (
128
+
<PollVoteButton
129
+
key={option.data.value}
130
+
selected={selectedPollOptions.includes(option.data.value)}
131
+
toggleSelected={() =>
132
+
setSelectedPollOptions((s) =>
133
+
s.includes(option.data.value)
134
+
? s.filter((s) => s !== option.data.value)
135
+
: [...s, option.data.value],
136
+
)
137
+
}
138
+
entityID={option.data.value}
139
+
/>
140
+
))}
141
+
<div className="flex justify-between items-center">
142
+
<div className="flex justify-end gap-2">
143
+
{permissions.write && (
144
+
<button
145
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
146
+
onClick={() => {
147
+
props.setPollState("editing");
148
+
}}
149
+
>
150
+
Edit Options
151
+
</button>
152
+
)}
153
+
154
+
{permissions.write && <Separator classname="h-6" />}
155
+
<PollStateToggle
156
+
setPollState={props.setPollState}
157
+
pollState={props.pollState}
158
+
hasVoted={props.hasVoted}
159
+
/>
160
+
</div>
161
+
<ButtonPrimary
162
+
className="place-self-end"
163
+
onClick={async () => {
164
+
await voteOnPoll(props.entityID, selectedPollOptions);
165
+
mutate((oldState) => {
166
+
if (!oldState || !oldState.voter_token) return;
167
+
return {
168
+
...oldState,
169
+
polls: [
170
+
...oldState.polls.filter(
171
+
(p) =>
172
+
!(
173
+
p.poll_votes_on_entity.voter_token ===
174
+
oldState.voter_token &&
175
+
p.poll_votes_on_entity.poll_entity == props.entityID
176
+
),
177
+
),
178
+
...selectedPollOptions.map((option_entity) => ({
179
+
poll_votes_on_entity: {
180
+
option_entity,
181
+
entities: { set: "" },
182
+
poll_entity: props.entityID,
183
+
voter_token: oldState.voter_token!,
184
+
},
185
+
})),
186
+
],
187
+
};
188
+
});
189
+
props.onSubmit();
190
+
}}
191
+
disabled={
192
+
selectedPollOptions.length === 0 ||
193
+
(selectedPollOptions.length === currentVotes.length &&
194
+
selectedPollOptions.every((s) => currentVotes.includes(s)))
195
+
}
196
+
>
197
+
Vote!
198
+
</ButtonPrimary>
199
+
</div>
200
+
</>
201
+
);
202
+
};
203
+
const PollVoteButton = (props: {
204
+
entityID: string;
205
+
selected: boolean;
206
+
toggleSelected: () => void;
207
+
}) => {
208
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
209
+
if (!optionName) return null;
210
+
if (props.selected)
211
+
return (
212
+
<div className="flex gap-2 items-center">
213
+
<ButtonPrimary
214
+
className={`pollOption grow max-w-full flex`}
215
+
onClick={() => {
216
+
props.toggleSelected();
217
+
}}
218
+
>
219
+
{optionName}
220
+
</ButtonPrimary>
221
+
</div>
222
+
);
223
+
return (
224
+
<div className="flex gap-2 items-center">
225
+
<ButtonSecondary
226
+
className={`pollOption grow max-w-full flex`}
227
+
onClick={() => {
228
+
props.toggleSelected();
229
+
}}
230
+
>
231
+
{optionName}
232
+
</ButtonSecondary>
233
+
</div>
234
+
);
235
+
};
236
+
237
+
const PollResults = (props: {
238
+
entityID: string;
239
+
pollState: "editing" | "voting" | "results";
240
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
241
+
hasVoted: boolean;
242
+
}) => {
243
+
let { data } = usePollData();
244
+
let { permissions } = useEntitySetContext();
245
+
let pollOptions = useEntity(props.entityID, "poll/options");
246
+
let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
247
+
let votesByOptions = pollData?.votesByOption || {};
248
+
let highestVotes = Math.max(...Object.values(votesByOptions));
249
+
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
250
+
(winningEntities, [entity, votes]) => {
251
+
if (votes === highestVotes) winningEntities.push(entity);
252
+
return winningEntities;
253
+
},
254
+
[],
255
+
);
256
+
return (
257
+
<>
258
+
{pollOptions.map((p) => (
259
+
<PollResult
260
+
key={p.id}
261
+
winner={winningOptionEntities.includes(p.data.value)}
262
+
entityID={p.data.value}
263
+
totalVotes={pollData?.unique_votes || 0}
264
+
votes={pollData?.votesByOption[p.data.value] || 0}
265
+
/>
266
+
))}
267
+
<div className="flex gap-2">
268
+
{permissions.write && (
269
+
<button
270
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
271
+
onClick={() => {
272
+
props.setPollState("editing");
273
+
}}
274
+
>
275
+
Edit Options
276
+
</button>
277
+
)}
278
+
279
+
{permissions.write && <Separator classname="h-6" />}
280
+
<PollStateToggle
281
+
setPollState={props.setPollState}
282
+
pollState={props.pollState}
283
+
hasVoted={props.hasVoted}
284
+
/>
285
+
</div>
286
+
</>
287
+
);
288
+
};
289
+
290
+
const PollResult = (props: {
291
+
entityID: string;
292
+
votes: number;
293
+
totalVotes: number;
294
+
winner: boolean;
295
+
}) => {
296
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
297
+
return (
298
+
<div
299
+
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
300
+
>
301
+
<div
302
+
style={{
303
+
WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
304
+
paintOrder: "stroke fill",
305
+
}}
306
+
className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
307
+
>
308
+
<div className="grow max-w-full truncate">{optionName}</div>
309
+
<div>{props.votes}</div>
310
+
</div>
311
+
<div
312
+
className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
313
+
>
314
+
<div
315
+
className={`bg-accent-contrast rounded-[2px] m-0.5`}
316
+
style={{
317
+
maskImage: "var(--hatchSVG)",
318
+
maskRepeat: "repeat repeat",
319
+
320
+
...(props.votes === 0
321
+
? { width: "4px" }
322
+
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
323
+
}}
324
+
/>
325
+
<div />
326
+
</div>
327
+
</div>
328
+
);
329
+
};
330
+
331
+
const EditPoll = (props: {
332
+
votes: { option_entity: string }[];
333
+
totalVotes: number;
334
+
entityID: string;
335
+
close: () => void;
336
+
}) => {
337
+
let pollOptions = useEntity(props.entityID, "poll/options");
338
+
let { rep } = useReplicache();
339
+
let permission_set = useEntitySetContext();
340
+
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
341
+
[k: string]: string;
342
+
}>({});
343
+
return (
344
+
<>
345
+
{props.totalVotes > 0 && (
346
+
<div className="text-sm italic text-tertiary">
347
+
You can't edit options people already voted for!
348
+
</div>
349
+
)}
350
+
351
+
{pollOptions.length === 0 && (
352
+
<div className="text-center italic text-tertiary text-sm">
353
+
no options yet...
354
+
</div>
355
+
)}
356
+
{pollOptions.map((p) => (
357
+
<EditPollOption
358
+
key={p.id}
359
+
entityID={p.data.value}
360
+
pollEntity={props.entityID}
361
+
disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
362
+
localNameState={localPollOptionNames[p.data.value]}
363
+
setLocalNameState={setLocalPollOptionNames}
364
+
/>
365
+
))}
366
+
367
+
<button
368
+
className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
369
+
onClick={async () => {
370
+
let pollOptionEntity = v7();
371
+
await rep?.mutate.addPollOption({
372
+
pollEntity: props.entityID,
373
+
pollOptionEntity,
374
+
pollOptionName: "",
375
+
permission_set: permission_set.set,
376
+
factID: v7(),
377
+
});
378
+
379
+
focusElement(
380
+
document.getElementById(
381
+
elementId.block(props.entityID).pollInput(pollOptionEntity),
382
+
) as HTMLInputElement | null,
383
+
);
384
+
}}
385
+
>
386
+
Add an Option
387
+
</button>
388
+
389
+
<hr className="border-border" />
390
+
<ButtonPrimary
391
+
className="place-self-end"
392
+
onClick={async () => {
393
+
// remove any poll options that have no name
394
+
// look through the localPollOptionNames object and remove any options that have no name
395
+
let emptyOptions = Object.entries(localPollOptionNames).filter(
396
+
([optionEntity, optionName]) => optionName === "",
397
+
);
398
+
await Promise.all(
399
+
emptyOptions.map(
400
+
async ([entity]) =>
401
+
await rep?.mutate.removePollOption({
402
+
optionEntity: entity,
403
+
}),
404
+
),
405
+
);
406
+
407
+
await rep?.mutate.assertFact(
408
+
Object.entries(localPollOptionNames)
409
+
.filter(([, name]) => !!name)
410
+
.map(([entity, name]) => ({
411
+
entity,
412
+
attribute: "poll-option/name",
413
+
data: { type: "string", value: name },
414
+
})),
415
+
);
416
+
props.close();
417
+
}}
418
+
>
419
+
Save <CheckTiny />
420
+
</ButtonPrimary>
421
+
</>
422
+
);
423
+
};
424
+
425
+
const EditPollOption = (props: {
426
+
entityID: string;
427
+
pollEntity: string;
428
+
localNameState: string | undefined;
429
+
setLocalNameState: (
430
+
s: (s: { [k: string]: string }) => { [k: string]: string },
431
+
) => void;
432
+
disabled: boolean;
433
+
}) => {
434
+
let { rep } = useReplicache();
435
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
436
+
useEffect(() => {
437
+
props.setLocalNameState((s) => ({
438
+
...s,
439
+
[props.entityID]: optionName || "",
440
+
}));
441
+
}, [optionName, props.setLocalNameState, props.entityID]);
442
+
443
+
return (
444
+
<div className="flex gap-2 items-center">
445
+
<Input
446
+
id={elementId.block(props.pollEntity).pollInput(props.entityID)}
447
+
type="text"
448
+
className="pollOptionInput w-full input-with-border"
449
+
placeholder="Option here..."
450
+
disabled={props.disabled}
451
+
value={
452
+
props.localNameState === undefined ? optionName : props.localNameState
453
+
}
454
+
onChange={(e) => {
455
+
props.setLocalNameState((s) => ({
456
+
...s,
457
+
[props.entityID]: e.target.value,
458
+
}));
459
+
}}
460
+
onKeyDown={(e) => {
461
+
if (e.key === "Backspace" && !e.currentTarget.value) {
462
+
e.preventDefault();
463
+
rep?.mutate.removePollOption({ optionEntity: props.entityID });
464
+
}
465
+
}}
466
+
/>
467
+
468
+
<button
469
+
tabIndex={-1}
470
+
disabled={props.disabled}
471
+
className="text-accent-contrast disabled:text-border"
472
+
onMouseDown={async () => {
473
+
await rep?.mutate.removePollOption({ optionEntity: props.entityID });
474
+
}}
475
+
>
476
+
<CloseTiny />
477
+
</button>
478
+
</div>
479
+
);
480
+
};
481
+
482
+
const PollStateToggle = (props: {
483
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
484
+
hasVoted: boolean;
485
+
pollState: "editing" | "voting" | "results";
486
+
}) => {
487
+
return (
488
+
<button
489
+
className="text-sm text-accent-contrast sm:hover:underline"
490
+
onClick={() => {
491
+
props.setPollState(props.pollState === "voting" ? "results" : "voting");
492
+
}}
493
+
>
494
+
{props.pollState === "voting"
495
+
? "See Results"
496
+
: props.hasVoted
497
+
? "Change Vote"
498
+
: "Back to Poll"}
499
+
</button>
500
+
);
501
+
};
+8
components/Blocks/PollBlock/pollBlockState.ts
+8
components/Blocks/PollBlock/pollBlockState.ts
-507
components/Blocks/PollBlock.tsx
-507
components/Blocks/PollBlock.tsx
···
1
-
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "./Block";
3
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
-
import { useCallback, useEffect, useState } from "react";
5
-
import { focusElement, Input } from "components/Input";
6
-
import { Separator } from "components/Layout";
7
-
import { useEntitySetContext } from "components/EntitySetProvider";
8
-
import { theme } from "tailwind.config";
9
-
import { useEntity, useReplicache } from "src/replicache";
10
-
import { v7 } from "uuid";
11
-
import {
12
-
useLeafletPublicationData,
13
-
usePollData,
14
-
} from "components/PageSWRDataProvider";
15
-
import { voteOnPoll } from "actions/pollActions";
16
-
import { create } from "zustand";
17
-
import { elementId } from "src/utils/elementId";
18
-
import { CheckTiny } from "components/Icons/CheckTiny";
19
-
import { CloseTiny } from "components/Icons/CloseTiny";
20
-
import { PublicationPollBlock } from "./PublicationPollBlock";
21
-
22
-
export let usePollBlockUIState = create(
23
-
() =>
24
-
({}) as {
25
-
[entity: string]: { state: "editing" | "voting" | "results" } | undefined;
26
-
},
27
-
);
28
-
29
-
export const PollBlock = (props: BlockProps) => {
30
-
let { data: pub } = useLeafletPublicationData();
31
-
if (!pub) return <LeafletPollBlock {...props} />;
32
-
return <PublicationPollBlock {...props} />;
33
-
};
34
-
35
-
export const LeafletPollBlock = (props: BlockProps) => {
36
-
let isSelected = useUIState((s) =>
37
-
s.selectedBlocks.find((b) => b.value === props.entityID),
38
-
);
39
-
let { permissions } = useEntitySetContext();
40
-
41
-
let { data: pollData } = usePollData();
42
-
let hasVoted =
43
-
pollData?.voter_token &&
44
-
pollData.polls.find(
45
-
(v) =>
46
-
v.poll_votes_on_entity.voter_token === pollData.voter_token &&
47
-
v.poll_votes_on_entity.poll_entity === props.entityID,
48
-
);
49
-
50
-
let pollState = usePollBlockUIState((s) => s[props.entityID]?.state);
51
-
if (!pollState) {
52
-
if (hasVoted) pollState = "results";
53
-
else pollState = "voting";
54
-
}
55
-
56
-
const setPollState = useCallback(
57
-
(state: "editing" | "voting" | "results") => {
58
-
usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } }));
59
-
},
60
-
[],
61
-
);
62
-
63
-
let votes =
64
-
pollData?.polls.filter(
65
-
(v) => v.poll_votes_on_entity.poll_entity === props.entityID,
66
-
) || [];
67
-
let totalVotes = votes.length;
68
-
69
-
return (
70
-
<div
71
-
className={`poll flex flex-col gap-2 p-3 w-full
72
-
${isSelected ? "block-border-selected " : "block-border"}`}
73
-
style={{
74
-
backgroundColor:
75
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
76
-
}}
77
-
>
78
-
{pollState === "editing" ? (
79
-
<EditPoll
80
-
totalVotes={totalVotes}
81
-
votes={votes.map((v) => v.poll_votes_on_entity)}
82
-
entityID={props.entityID}
83
-
close={() => {
84
-
if (hasVoted) setPollState("results");
85
-
else setPollState("voting");
86
-
}}
87
-
/>
88
-
) : pollState === "results" ? (
89
-
<PollResults
90
-
entityID={props.entityID}
91
-
pollState={pollState}
92
-
setPollState={setPollState}
93
-
hasVoted={!!hasVoted}
94
-
/>
95
-
) : (
96
-
<PollVote
97
-
entityID={props.entityID}
98
-
onSubmit={() => setPollState("results")}
99
-
pollState={pollState}
100
-
setPollState={setPollState}
101
-
hasVoted={!!hasVoted}
102
-
/>
103
-
)}
104
-
</div>
105
-
);
106
-
};
107
-
108
-
const PollVote = (props: {
109
-
entityID: string;
110
-
onSubmit: () => void;
111
-
pollState: "editing" | "voting" | "results";
112
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
113
-
hasVoted: boolean;
114
-
}) => {
115
-
let { data, mutate } = usePollData();
116
-
let { permissions } = useEntitySetContext();
117
-
118
-
let pollOptions = useEntity(props.entityID, "poll/options");
119
-
let currentVotes = data?.voter_token
120
-
? data.polls
121
-
.filter(
122
-
(p) =>
123
-
p.poll_votes_on_entity.poll_entity === props.entityID &&
124
-
p.poll_votes_on_entity.voter_token === data.voter_token,
125
-
)
126
-
.map((v) => v.poll_votes_on_entity.option_entity)
127
-
: [];
128
-
let [selectedPollOptions, setSelectedPollOptions] =
129
-
useState<string[]>(currentVotes);
130
-
131
-
return (
132
-
<>
133
-
{pollOptions.map((option, index) => (
134
-
<PollVoteButton
135
-
key={option.data.value}
136
-
selected={selectedPollOptions.includes(option.data.value)}
137
-
toggleSelected={() =>
138
-
setSelectedPollOptions((s) =>
139
-
s.includes(option.data.value)
140
-
? s.filter((s) => s !== option.data.value)
141
-
: [...s, option.data.value],
142
-
)
143
-
}
144
-
entityID={option.data.value}
145
-
/>
146
-
))}
147
-
<div className="flex justify-between items-center">
148
-
<div className="flex justify-end gap-2">
149
-
{permissions.write && (
150
-
<button
151
-
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
152
-
onClick={() => {
153
-
props.setPollState("editing");
154
-
}}
155
-
>
156
-
Edit Options
157
-
</button>
158
-
)}
159
-
160
-
{permissions.write && <Separator classname="h-6" />}
161
-
<PollStateToggle
162
-
setPollState={props.setPollState}
163
-
pollState={props.pollState}
164
-
hasVoted={props.hasVoted}
165
-
/>
166
-
</div>
167
-
<ButtonPrimary
168
-
className="place-self-end"
169
-
onClick={async () => {
170
-
await voteOnPoll(props.entityID, selectedPollOptions);
171
-
mutate((oldState) => {
172
-
if (!oldState || !oldState.voter_token) return;
173
-
return {
174
-
...oldState,
175
-
polls: [
176
-
...oldState.polls.filter(
177
-
(p) =>
178
-
!(
179
-
p.poll_votes_on_entity.voter_token ===
180
-
oldState.voter_token &&
181
-
p.poll_votes_on_entity.poll_entity == props.entityID
182
-
),
183
-
),
184
-
...selectedPollOptions.map((option_entity) => ({
185
-
poll_votes_on_entity: {
186
-
option_entity,
187
-
entities: { set: "" },
188
-
poll_entity: props.entityID,
189
-
voter_token: oldState.voter_token!,
190
-
},
191
-
})),
192
-
],
193
-
};
194
-
});
195
-
props.onSubmit();
196
-
}}
197
-
disabled={
198
-
selectedPollOptions.length === 0 ||
199
-
(selectedPollOptions.length === currentVotes.length &&
200
-
selectedPollOptions.every((s) => currentVotes.includes(s)))
201
-
}
202
-
>
203
-
Vote!
204
-
</ButtonPrimary>
205
-
</div>
206
-
</>
207
-
);
208
-
};
209
-
const PollVoteButton = (props: {
210
-
entityID: string;
211
-
selected: boolean;
212
-
toggleSelected: () => void;
213
-
}) => {
214
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
215
-
if (!optionName) return null;
216
-
if (props.selected)
217
-
return (
218
-
<div className="flex gap-2 items-center">
219
-
<ButtonPrimary
220
-
className={`pollOption grow max-w-full flex`}
221
-
onClick={() => {
222
-
props.toggleSelected();
223
-
}}
224
-
>
225
-
{optionName}
226
-
</ButtonPrimary>
227
-
</div>
228
-
);
229
-
return (
230
-
<div className="flex gap-2 items-center">
231
-
<ButtonSecondary
232
-
className={`pollOption grow max-w-full flex`}
233
-
onClick={() => {
234
-
props.toggleSelected();
235
-
}}
236
-
>
237
-
{optionName}
238
-
</ButtonSecondary>
239
-
</div>
240
-
);
241
-
};
242
-
243
-
const PollResults = (props: {
244
-
entityID: string;
245
-
pollState: "editing" | "voting" | "results";
246
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
247
-
hasVoted: boolean;
248
-
}) => {
249
-
let { data } = usePollData();
250
-
let { permissions } = useEntitySetContext();
251
-
let pollOptions = useEntity(props.entityID, "poll/options");
252
-
let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
253
-
let votesByOptions = pollData?.votesByOption || {};
254
-
let highestVotes = Math.max(...Object.values(votesByOptions));
255
-
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
256
-
(winningEntities, [entity, votes]) => {
257
-
if (votes === highestVotes) winningEntities.push(entity);
258
-
return winningEntities;
259
-
},
260
-
[],
261
-
);
262
-
return (
263
-
<>
264
-
{pollOptions.map((p) => (
265
-
<PollResult
266
-
key={p.id}
267
-
winner={winningOptionEntities.includes(p.data.value)}
268
-
entityID={p.data.value}
269
-
totalVotes={pollData?.unique_votes || 0}
270
-
votes={pollData?.votesByOption[p.data.value] || 0}
271
-
/>
272
-
))}
273
-
<div className="flex gap-2">
274
-
{permissions.write && (
275
-
<button
276
-
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
277
-
onClick={() => {
278
-
props.setPollState("editing");
279
-
}}
280
-
>
281
-
Edit Options
282
-
</button>
283
-
)}
284
-
285
-
{permissions.write && <Separator classname="h-6" />}
286
-
<PollStateToggle
287
-
setPollState={props.setPollState}
288
-
pollState={props.pollState}
289
-
hasVoted={props.hasVoted}
290
-
/>
291
-
</div>
292
-
</>
293
-
);
294
-
};
295
-
296
-
const PollResult = (props: {
297
-
entityID: string;
298
-
votes: number;
299
-
totalVotes: number;
300
-
winner: boolean;
301
-
}) => {
302
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
303
-
return (
304
-
<div
305
-
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
306
-
>
307
-
<div
308
-
style={{
309
-
WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
310
-
paintOrder: "stroke fill",
311
-
}}
312
-
className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
313
-
>
314
-
<div className="grow max-w-full truncate">{optionName}</div>
315
-
<div>{props.votes}</div>
316
-
</div>
317
-
<div
318
-
className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
319
-
>
320
-
<div
321
-
className={`bg-accent-contrast rounded-[2px] m-0.5`}
322
-
style={{
323
-
maskImage: "var(--hatchSVG)",
324
-
maskRepeat: "repeat repeat",
325
-
326
-
...(props.votes === 0
327
-
? { width: "4px" }
328
-
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
329
-
}}
330
-
/>
331
-
<div />
332
-
</div>
333
-
</div>
334
-
);
335
-
};
336
-
337
-
const EditPoll = (props: {
338
-
votes: { option_entity: string }[];
339
-
totalVotes: number;
340
-
entityID: string;
341
-
close: () => void;
342
-
}) => {
343
-
let pollOptions = useEntity(props.entityID, "poll/options");
344
-
let { rep } = useReplicache();
345
-
let permission_set = useEntitySetContext();
346
-
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
347
-
[k: string]: string;
348
-
}>({});
349
-
return (
350
-
<>
351
-
{props.totalVotes > 0 && (
352
-
<div className="text-sm italic text-tertiary">
353
-
You can't edit options people already voted for!
354
-
</div>
355
-
)}
356
-
357
-
{pollOptions.length === 0 && (
358
-
<div className="text-center italic text-tertiary text-sm">
359
-
no options yet...
360
-
</div>
361
-
)}
362
-
{pollOptions.map((p) => (
363
-
<EditPollOption
364
-
key={p.id}
365
-
entityID={p.data.value}
366
-
pollEntity={props.entityID}
367
-
disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
368
-
localNameState={localPollOptionNames[p.data.value]}
369
-
setLocalNameState={setLocalPollOptionNames}
370
-
/>
371
-
))}
372
-
373
-
<button
374
-
className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
375
-
onClick={async () => {
376
-
let pollOptionEntity = v7();
377
-
await rep?.mutate.addPollOption({
378
-
pollEntity: props.entityID,
379
-
pollOptionEntity,
380
-
pollOptionName: "",
381
-
permission_set: permission_set.set,
382
-
factID: v7(),
383
-
});
384
-
385
-
focusElement(
386
-
document.getElementById(
387
-
elementId.block(props.entityID).pollInput(pollOptionEntity),
388
-
) as HTMLInputElement | null,
389
-
);
390
-
}}
391
-
>
392
-
Add an Option
393
-
</button>
394
-
395
-
<hr className="border-border" />
396
-
<ButtonPrimary
397
-
className="place-self-end"
398
-
onClick={async () => {
399
-
// remove any poll options that have no name
400
-
// look through the localPollOptionNames object and remove any options that have no name
401
-
let emptyOptions = Object.entries(localPollOptionNames).filter(
402
-
([optionEntity, optionName]) => optionName === "",
403
-
);
404
-
await Promise.all(
405
-
emptyOptions.map(
406
-
async ([entity]) =>
407
-
await rep?.mutate.removePollOption({
408
-
optionEntity: entity,
409
-
}),
410
-
),
411
-
);
412
-
413
-
await rep?.mutate.assertFact(
414
-
Object.entries(localPollOptionNames)
415
-
.filter(([, name]) => !!name)
416
-
.map(([entity, name]) => ({
417
-
entity,
418
-
attribute: "poll-option/name",
419
-
data: { type: "string", value: name },
420
-
})),
421
-
);
422
-
props.close();
423
-
}}
424
-
>
425
-
Save <CheckTiny />
426
-
</ButtonPrimary>
427
-
</>
428
-
);
429
-
};
430
-
431
-
const EditPollOption = (props: {
432
-
entityID: string;
433
-
pollEntity: string;
434
-
localNameState: string | undefined;
435
-
setLocalNameState: (
436
-
s: (s: { [k: string]: string }) => { [k: string]: string },
437
-
) => void;
438
-
disabled: boolean;
439
-
}) => {
440
-
let { rep } = useReplicache();
441
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
442
-
useEffect(() => {
443
-
props.setLocalNameState((s) => ({
444
-
...s,
445
-
[props.entityID]: optionName || "",
446
-
}));
447
-
}, [optionName, props.setLocalNameState, props.entityID]);
448
-
449
-
return (
450
-
<div className="flex gap-2 items-center">
451
-
<Input
452
-
id={elementId.block(props.pollEntity).pollInput(props.entityID)}
453
-
type="text"
454
-
className="pollOptionInput w-full input-with-border"
455
-
placeholder="Option here..."
456
-
disabled={props.disabled}
457
-
value={
458
-
props.localNameState === undefined ? optionName : props.localNameState
459
-
}
460
-
onChange={(e) => {
461
-
props.setLocalNameState((s) => ({
462
-
...s,
463
-
[props.entityID]: e.target.value,
464
-
}));
465
-
}}
466
-
onKeyDown={(e) => {
467
-
if (e.key === "Backspace" && !e.currentTarget.value) {
468
-
e.preventDefault();
469
-
rep?.mutate.removePollOption({ optionEntity: props.entityID });
470
-
}
471
-
}}
472
-
/>
473
-
474
-
<button
475
-
tabIndex={-1}
476
-
disabled={props.disabled}
477
-
className="text-accent-contrast disabled:text-border"
478
-
onMouseDown={async () => {
479
-
await rep?.mutate.removePollOption({ optionEntity: props.entityID });
480
-
}}
481
-
>
482
-
<CloseTiny />
483
-
</button>
484
-
</div>
485
-
);
486
-
};
487
-
488
-
const PollStateToggle = (props: {
489
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
490
-
hasVoted: boolean;
491
-
pollState: "editing" | "voting" | "results";
492
-
}) => {
493
-
return (
494
-
<button
495
-
className="text-sm text-accent-contrast sm:hover:underline"
496
-
onClick={() => {
497
-
props.setPollState(props.pollState === "voting" ? "results" : "voting");
498
-
}}
499
-
>
500
-
{props.pollState === "voting"
501
-
? "See Results"
502
-
: props.hasVoted
503
-
? "Change Vote"
504
-
: "Back to Poll"}
505
-
</button>
506
-
);
507
-
};
+2
-1
components/Blocks/PublicationPollBlock.tsx
+2
-1
components/Blocks/PublicationPollBlock.tsx
···
1
1
import { useUIState } from "src/useUIState";
2
2
import { BlockProps } from "./Block";
3
3
import { useMemo } from "react";
4
-
import { focusElement, AsyncValueInput } from "components/Input";
4
+
import { AsyncValueInput } from "components/Input";
5
+
import { focusElement } from "src/utils/focusElement";
5
6
import { useEntitySetContext } from "components/EntitySetProvider";
6
7
import { useEntity, useReplicache } from "src/replicache";
7
8
import { v7 } from "uuid";
+2
-2
components/Blocks/RSVPBlock/SendUpdate.tsx
+2
-2
components/Blocks/RSVPBlock/SendUpdate.tsx
···
9
9
import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS";
10
10
import { useReplicache } from "src/replicache";
11
11
import { Checkbox } from "components/Checkbox";
12
-
import { usePublishLink } from "components/ShareOptions";
12
+
import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions";
13
13
14
14
export function SendUpdateButton(props: { entityID: string }) {
15
-
let publishLink = usePublishLink();
15
+
let publishLink = useReadOnlyShareLink();
16
16
let { permissions } = useEntitySetContext();
17
17
let { permission_token } = useReplicache();
18
18
let [input, setInput] = useState("");
+36
-32
components/Blocks/TextBlock/RenderYJSFragment.tsx
+36
-32
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
3
3
import { CSSProperties, Fragment } from "react";
4
4
import { theme } from "tailwind.config";
5
5
import * as base64 from "base64-js";
6
+
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
+
import { AtMentionLink } from "components/AtMentionLink";
8
+
import { Delta } from "src/utils/yjsFragmentToString";
6
9
7
10
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
8
11
export function RenderYJSFragment({
···
27
30
return (
28
31
<BlockWrapper wrapper={wrapper} attrs={attrs}>
29
32
{children.length === 0 ? (
30
-
<div />
33
+
<br />
31
34
) : (
32
35
node.toArray().map((node, index) => {
33
36
if (node.constructor === XmlText) {
···
60
63
);
61
64
}
62
65
66
+
if (node.constructor === XmlElement && node.nodeName === "hard_break") {
67
+
return <br key={index} />;
68
+
}
69
+
70
+
// Handle didMention inline nodes
71
+
if (node.constructor === XmlElement && node.nodeName === "didMention") {
72
+
const did = node.getAttribute("did") || "";
73
+
const text = node.getAttribute("text") || "";
74
+
return (
75
+
<a
76
+
href={didToBlueskyUrl(did)}
77
+
target="_blank"
78
+
rel="noopener noreferrer"
79
+
key={index}
80
+
className="text-accent-contrast hover:underline cursor-pointer"
81
+
>
82
+
{text}
83
+
</a>
84
+
);
85
+
}
86
+
87
+
// Handle atMention inline nodes
88
+
if (node.constructor === XmlElement && node.nodeName === "atMention") {
89
+
const atURI = node.getAttribute("atURI") || "";
90
+
const text = node.getAttribute("text") || "";
91
+
return (
92
+
<AtMentionLink key={index} atURI={atURI}>
93
+
{text}
94
+
</AtMentionLink>
95
+
);
96
+
}
97
+
63
98
return null;
64
99
})
65
100
)}
···
97
132
}
98
133
};
99
134
100
-
export type Delta = {
101
-
insert: string;
102
-
attributes?: {
103
-
strong?: {};
104
-
code?: {};
105
-
em?: {};
106
-
underline?: {};
107
-
strikethrough?: {};
108
-
highlight?: { color: string };
109
-
link?: { href: string };
110
-
};
111
-
};
112
-
113
135
function attributesToStyle(d: Delta) {
114
136
let props = {
115
137
style: {},
···
140
162
return props;
141
163
}
142
164
143
-
export function YJSFragmentToString(
144
-
node: XmlElement | XmlText | XmlHook,
145
-
): string {
146
-
if (node.constructor === XmlElement) {
147
-
return node
148
-
.toArray()
149
-
.map((f) => YJSFragmentToString(f))
150
-
.join("");
151
-
}
152
-
if (node.constructor === XmlText) {
153
-
return (node.toDelta() as Delta[])
154
-
.map((d) => {
155
-
return d.insert;
156
-
})
157
-
.join("");
158
-
}
159
-
return "";
160
-
}
+109
-14
components/Blocks/TextBlock/index.tsx
+109
-14
components/Blocks/TextBlock/index.tsx
···
1
-
import { useRef, useEffect, useState } from "react";
1
+
import { useRef, useEffect, useState, useCallback } from "react";
2
2
import { elementId } from "src/utils/elementId";
3
3
import { useReplicache, useEntity } from "src/replicache";
4
4
import { isVisible } from "src/utils/isVisible";
5
5
import { EditorState, TextSelection } from "prosemirror-state";
6
+
import { EditorView } from "prosemirror-view";
6
7
import { RenderYJSFragment } from "./RenderYJSFragment";
7
8
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
8
9
import { BlockProps } from "../Block";
···
23
24
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
24
25
import { DotLoader } from "components/utils/DotLoader";
25
26
import { useMountProsemirror } from "./mountProsemirror";
27
+
import { schema } from "./schema";
28
+
29
+
import { Mention, MentionAutocomplete } from "components/Mention";
30
+
import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
26
31
27
32
const HeadingStyle = {
28
33
1: "text-xl font-bold",
···
183
188
let editorState = useEditorStates(
184
189
(s) => s.editorStates[props.entityID],
185
190
)?.editor;
191
+
const {
192
+
viewRef,
193
+
mentionOpen,
194
+
mentionCoords,
195
+
openMentionAutocomplete,
196
+
handleMentionSelect,
197
+
handleMentionOpenChange,
198
+
} = useMentionState(props.entityID);
186
199
187
200
let { mountRef, actionTimeout } = useMountProsemirror({
188
201
props,
202
+
openMentionAutocomplete,
189
203
});
190
204
191
205
return (
···
199
213
? "blockquote pt-3"
200
214
: "blockquote"
201
215
: ""
202
-
}
203
-
204
-
`}
216
+
}`}
205
217
>
206
218
<pre
207
219
data-entityid={props.entityID}
···
224
236
}
225
237
}}
226
238
onFocus={() => {
239
+
handleMentionOpenChange(false);
227
240
setTimeout(() => {
228
241
useUIState.getState().setSelectedBlock(props);
229
242
useUIState.setState(() => ({
···
249
262
${props.className}`}
250
263
ref={mountRef}
251
264
/>
265
+
{focused && (
266
+
<MentionAutocomplete
267
+
open={mentionOpen}
268
+
onOpenChange={handleMentionOpenChange}
269
+
view={viewRef}
270
+
onSelect={handleMentionSelect}
271
+
coords={mentionCoords}
272
+
/>
273
+
)}
252
274
{editorState?.doc.textContent.length === 0 &&
253
275
props.previousBlock === null &&
254
276
props.nextBlock === null ? (
···
439
461
);
440
462
};
441
463
442
-
const useMentionState = () => {
443
-
const [editorState, setEditorState] = useState<EditorState | null>(null);
444
-
const [mentionState, setMentionState] = useState<{
445
-
active: boolean;
446
-
range: { from: number; to: number } | null;
447
-
selectedMention: { handle: string; did: string } | null;
448
-
}>({ active: false, range: null, selectedMention: null });
449
-
const mentionStateRef = useRef(mentionState);
450
-
mentionStateRef.current = mentionState;
451
-
return { mentionStateRef };
464
+
const useMentionState = (entityID: string) => {
465
+
let view = useEditorStates((s) => s.editorStates[entityID])?.view;
466
+
let viewRef = useRef(view || null);
467
+
viewRef.current = view || null;
468
+
469
+
const [mentionOpen, setMentionOpen] = useState(false);
470
+
const [mentionCoords, setMentionCoords] = useState<{
471
+
top: number;
472
+
left: number;
473
+
} | null>(null);
474
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
475
+
476
+
// Close autocomplete when this block is no longer focused
477
+
const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID);
478
+
useEffect(() => {
479
+
if (!isFocused) {
480
+
setMentionOpen(false);
481
+
setMentionCoords(null);
482
+
setMentionInsertPos(null);
483
+
}
484
+
}, [isFocused]);
485
+
486
+
const openMentionAutocomplete = useCallback(() => {
487
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
488
+
if (!view) return;
489
+
490
+
// Get the position right after the @ we just inserted
491
+
const pos = view.state.selection.from;
492
+
setMentionInsertPos(pos);
493
+
494
+
// Get coordinates for the popup relative to the positioned parent
495
+
const coords = view.coordsAtPos(pos - 1); // Position of the @
496
+
497
+
// Find the relative positioned parent container
498
+
const editorEl = view.dom;
499
+
const container = editorEl.closest('.relative') as HTMLElement | null;
500
+
501
+
if (container) {
502
+
const containerRect = container.getBoundingClientRect();
503
+
setMentionCoords({
504
+
top: coords.bottom - containerRect.top,
505
+
left: coords.left - containerRect.left,
506
+
});
507
+
} else {
508
+
setMentionCoords({
509
+
top: coords.bottom,
510
+
left: coords.left,
511
+
});
512
+
}
513
+
setMentionOpen(true);
514
+
}, [entityID]);
515
+
516
+
const handleMentionSelect = useCallback(
517
+
(mention: Mention) => {
518
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
519
+
if (!view || mentionInsertPos === null) return;
520
+
521
+
// The @ is at mentionInsertPos - 1, we need to replace it with the mention
522
+
const from = mentionInsertPos - 1;
523
+
const to = mentionInsertPos;
524
+
525
+
addMentionToEditor(mention, { from, to }, view);
526
+
view.focus();
527
+
},
528
+
[entityID, mentionInsertPos],
529
+
);
530
+
531
+
const handleMentionOpenChange = useCallback((open: boolean) => {
532
+
setMentionOpen(open);
533
+
if (!open) {
534
+
setMentionCoords(null);
535
+
setMentionInsertPos(null);
536
+
}
537
+
}, []);
538
+
539
+
return {
540
+
viewRef,
541
+
mentionOpen,
542
+
mentionCoords,
543
+
openMentionAutocomplete,
544
+
handleMentionSelect,
545
+
handleMentionOpenChange,
546
+
};
452
547
};
+32
-3
components/Blocks/TextBlock/inputRules.ts
+32
-3
components/Blocks/TextBlock/inputRules.ts
···
11
11
import { schema } from "./schema";
12
12
import { useUIState } from "src/useUIState";
13
13
import { flushSync } from "react-dom";
14
+
import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
14
15
export const inputrules = (
15
16
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
16
17
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18
+
openMentionAutocomplete?: () => void,
17
19
) =>
18
20
inputRules({
19
21
//Strikethrough
···
108
110
109
111
// Code Block
110
112
new InputRule(/^```\s$/, (state, match) => {
111
-
flushSync(() =>
113
+
flushSync(() => {
112
114
repRef.current?.mutate.assertFact({
113
115
entity: propsRef.current.entityID,
114
116
attribute: "block/type",
115
117
data: { type: "block-type-union", value: "code" },
116
-
}),
117
-
);
118
+
});
119
+
let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
120
+
if (lastLang) {
121
+
repRef.current?.mutate.assertFact({
122
+
entity: propsRef.current.entityID,
123
+
attribute: "block/code-language",
124
+
data: { type: "string", value: lastLang },
125
+
});
126
+
}
127
+
});
118
128
setTimeout(() => {
119
129
focusBlock({ ...propsRef.current, type: "code" }, { type: "start" });
120
130
}, 20);
···
180
190
data: { type: "number", value: headingLevel },
181
191
});
182
192
return tr;
193
+
}),
194
+
195
+
// Mention - @ at start of line, after space, or after hard break
196
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
197
+
if (!openMentionAutocomplete) return null;
198
+
// Schedule opening the autocomplete after the transaction is applied
199
+
setTimeout(() => openMentionAutocomplete(), 0);
200
+
return null; // Let the @ be inserted normally
201
+
}),
202
+
// Mention - @ immediately after a hard break (hard breaks are nodes, not text)
203
+
new InputRule(/@$/, (state, match, start, end) => {
204
+
if (!openMentionAutocomplete) return null;
205
+
// Check if the character before @ is a hard break node
206
+
const $pos = state.doc.resolve(start);
207
+
const nodeBefore = $pos.nodeBefore;
208
+
if (nodeBefore && nodeBefore.type.name === "hard_break") {
209
+
setTimeout(() => openMentionAutocomplete(), 0);
210
+
}
211
+
return null; // Let the @ be inserted normally
183
212
}),
184
213
],
185
214
});
+10
-13
components/Blocks/TextBlock/keymap.ts
+10
-13
components/Blocks/TextBlock/keymap.ts
···
17
17
import { schema } from "./schema";
18
18
import { useUIState } from "src/useUIState";
19
19
import { setEditorState, useEditorStates } from "src/state/useEditorState";
20
-
import { focusPage } from "components/Pages";
20
+
import { focusPage } from "src/utils/focusPage";
21
21
import { v7 } from "uuid";
22
22
import { scanIndex } from "src/replicache/utils";
23
23
import { indent, outdent } from "src/utils/list-operations";
24
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
25
import { isTextBlock } from "src/utils/isTextBlock";
26
26
import { UndoManager } from "src/undoManager";
27
-
28
27
type PropsRef = RefObject<
29
28
BlockProps & {
30
29
entity_set: { set: string };
···
35
34
propsRef: PropsRef,
36
35
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
37
36
um: UndoManager,
38
-
multiLine?: boolean,
37
+
openMentionAutocomplete: () => void,
39
38
) =>
40
39
({
41
40
"Meta-b": toggleMark(schema.marks.strong),
···
138
137
),
139
138
"Shift-Backspace": backspace(propsRef, repRef),
140
139
Enter: (state, dispatch, view) => {
141
-
if (multiLine && state.doc.content.size - state.selection.anchor > 1)
142
-
return false;
143
-
return um.withUndoGroup(() =>
144
-
enter(propsRef, repRef)(state, dispatch, view),
145
-
);
140
+
return um.withUndoGroup(() => {
141
+
return enter(propsRef, repRef)(state, dispatch, view);
142
+
});
146
143
},
147
144
"Shift-Enter": (state, dispatch, view) => {
148
-
if (multiLine) {
149
-
return baseKeymap.Enter(state, dispatch, view);
145
+
// Insert a hard break
146
+
let hardBreak = schema.nodes.hard_break.create();
147
+
if (dispatch) {
148
+
dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView());
150
149
}
151
-
return um.withUndoGroup(() =>
152
-
enter(propsRef, repRef)(state, dispatch, view),
153
-
);
150
+
return true;
154
151
},
155
152
"Ctrl-Enter": CtrlEnter(propsRef, repRef),
156
153
"Meta-Enter": CtrlEnter(propsRef, repRef),
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
···
23
23
import { useHandlePaste } from "./useHandlePaste";
24
24
import { BlockProps } from "../Block";
25
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
26
27
27
-
export function useMountProsemirror({ props }: { props: BlockProps }) {
28
+
export function useMountProsemirror({
29
+
props,
30
+
openMentionAutocomplete,
31
+
}: {
32
+
props: BlockProps;
33
+
openMentionAutocomplete: () => void;
34
+
}) {
28
35
let { entityID, parent } = props;
29
36
let rep = useReplicache();
30
37
let mountRef = useRef<HTMLPreElement | null>(null);
···
44
51
useLayoutEffect(() => {
45
52
if (!mountRef.current) return;
46
53
47
-
const km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
54
+
const km = TextBlockKeymap(
55
+
propsRef,
56
+
repRef,
57
+
rep.undoManager,
58
+
openMentionAutocomplete,
59
+
);
48
60
const editor = EditorState.create({
49
61
schema: schema,
50
62
plugins: [
51
63
ySyncPlugin(value),
52
64
keymap(km),
53
-
inputrules(propsRef, repRef),
65
+
inputrules(propsRef, repRef, openMentionAutocomplete),
54
66
keymap(baseKeymap),
55
67
highlightSelectionPlugin,
56
68
autolink({
···
69
81
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
70
82
if (!direct) return;
71
83
if (node.nodeSize - 2 <= _pos) return;
72
-
let mark =
73
-
node
74
-
.nodeAt(_pos - 1)
75
-
?.marks.find((f) => f.type === schema.marks.link) ||
76
-
node
77
-
.nodeAt(Math.max(_pos - 2, 0))
78
-
?.marks.find((f) => f.type === schema.marks.link);
79
-
if (mark) {
80
-
window.open(mark.attrs.href, "_blank");
84
+
85
+
// Check for marks at the clicked position
86
+
const nodeAt1 = node.nodeAt(_pos - 1);
87
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
88
+
89
+
// Check for link marks
90
+
let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
91
+
nodeAt2?.marks.find((f) => f.type === schema.marks.link);
92
+
if (linkMark) {
93
+
window.open(linkMark.attrs.href, "_blank");
94
+
return;
95
+
}
96
+
97
+
// Check for didMention inline nodes
98
+
if (nodeAt1?.type === schema.nodes.didMention) {
99
+
window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer");
100
+
return;
101
+
}
102
+
if (nodeAt2?.type === schema.nodes.didMention) {
103
+
window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer");
104
+
return;
105
+
}
106
+
107
+
// Check for atMention inline nodes
108
+
if (nodeAt1?.type === schema.nodes.atMention) {
109
+
const url = atUriToUrl(nodeAt1.attrs.atURI);
110
+
window.open(url, "_blank", "noopener,noreferrer");
111
+
return;
112
+
}
113
+
if (nodeAt2?.type === schema.nodes.atMention) {
114
+
const url = atUriToUrl(nodeAt2.attrs.atURI);
115
+
window.open(url, "_blank", "noopener,noreferrer");
116
+
return;
81
117
}
82
118
},
83
119
dispatchTransaction,
+107
-1
components/Blocks/TextBlock/schema.ts
+107
-1
components/Blocks/TextBlock/schema.ts
···
1
-
import { Schema, Node, MarkSpec } from "prosemirror-model";
1
+
import { AtUri } from "@atproto/api";
2
+
import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
2
3
import { marks } from "prosemirror-schema-basic";
3
4
import { theme } from "tailwind.config";
4
5
···
115
116
text: {
116
117
group: "inline",
117
118
},
119
+
hard_break: {
120
+
group: "inline",
121
+
inline: true,
122
+
selectable: false,
123
+
parseDOM: [{ tag: "br" }],
124
+
toDOM: () => ["br"] as const,
125
+
},
126
+
atMention: {
127
+
attrs: {
128
+
atURI: {},
129
+
text: { default: "" },
130
+
},
131
+
group: "inline",
132
+
inline: true,
133
+
atom: true,
134
+
selectable: true,
135
+
draggable: true,
136
+
parseDOM: [
137
+
{
138
+
tag: "span.atMention",
139
+
getAttrs(dom: HTMLElement) {
140
+
return {
141
+
atURI: dom.getAttribute("data-at-uri"),
142
+
text: dom.textContent || "",
143
+
};
144
+
},
145
+
},
146
+
],
147
+
toDOM(node) {
148
+
// NOTE: This rendering should match the AtMentionLink component in
149
+
// components/AtMentionLink.tsx. If you update one, update the other.
150
+
let className = "atMention text-accent-contrast";
151
+
let aturi = new AtUri(node.attrs.atURI);
152
+
if (aturi.collection === "pub.leaflet.publication")
153
+
className += " font-bold";
154
+
if (aturi.collection === "pub.leaflet.document") className += " italic";
155
+
156
+
// For publications and documents, show icon
157
+
if (
158
+
aturi.collection === "pub.leaflet.publication" ||
159
+
aturi.collection === "pub.leaflet.document"
160
+
) {
161
+
return [
162
+
"span",
163
+
{
164
+
class: className,
165
+
"data-at-uri": node.attrs.atURI,
166
+
},
167
+
[
168
+
"img",
169
+
{
170
+
src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`,
171
+
class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top",
172
+
alt: "",
173
+
width: "16",
174
+
height: "16",
175
+
loading: "lazy",
176
+
},
177
+
],
178
+
node.attrs.text,
179
+
];
180
+
}
181
+
182
+
return [
183
+
"span",
184
+
{
185
+
class: className,
186
+
"data-at-uri": node.attrs.atURI,
187
+
},
188
+
node.attrs.text,
189
+
];
190
+
},
191
+
} as NodeSpec,
192
+
didMention: {
193
+
attrs: {
194
+
did: {},
195
+
text: { default: "" },
196
+
},
197
+
group: "inline",
198
+
inline: true,
199
+
atom: true,
200
+
selectable: true,
201
+
draggable: true,
202
+
parseDOM: [
203
+
{
204
+
tag: "span.didMention",
205
+
getAttrs(dom: HTMLElement) {
206
+
return {
207
+
did: dom.getAttribute("data-did"),
208
+
text: dom.textContent || "",
209
+
};
210
+
},
211
+
},
212
+
],
213
+
toDOM(node) {
214
+
return [
215
+
"span",
216
+
{
217
+
class: "didMention text-accent-contrast",
218
+
"data-did": node.attrs.did,
219
+
},
220
+
node.attrs.text,
221
+
];
222
+
},
223
+
} as NodeSpec,
118
224
},
119
225
};
120
226
export const schema = new Schema(baseSchema);
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
···
12
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13
13
import { useEntitySetContext } from "components/EntitySetProvider";
14
14
import { Replicache } from "replicache";
15
-
import { deleteBlock } from "./DeleteBlock";
15
+
import { deleteBlock } from "src/utils/deleteBlock";
16
16
import { entities } from "drizzle/schema";
17
17
import { scanIndex } from "src/replicache/utils";
18
18
+1
-1
components/Blocks/useBlockMouseHandlers.ts
+1
-1
components/Blocks/useBlockMouseHandlers.ts
···
1
-
import { useSelectingMouse } from "components/SelectionManager";
1
+
import { useSelectingMouse } from "components/SelectionManager/selectionState";
2
2
import { MouseEvent, useCallback, useRef } from "react";
3
3
import { useUIState } from "src/useUIState";
4
4
import { Block } from "./Block";
+35
-21
components/Buttons.tsx
+35
-21
components/Buttons.tsx
···
10
10
import { PopoverArrow } from "./Icons/PopoverArrow";
11
11
12
12
type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">;
13
+
13
14
export const ButtonPrimary = forwardRef<
14
15
HTMLButtonElement,
15
16
ButtonProps & {
···
35
36
m-0 h-max
36
37
${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"}
37
38
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
38
-
bg-accent-1 outline-transparent border border-accent-1
39
-
rounded-md text-base font-bold text-accent-2
39
+
bg-accent-1 disabled:bg-border-light
40
+
border border-accent-1 rounded-md disabled:border-border-light
41
+
outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
42
+
text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border
40
43
flex gap-2 items-center justify-center shrink-0
41
-
transparent-outline focus:outline-accent-1 hover:outline-accent-1 outline-offset-1
42
-
disabled:bg-border-light disabled:border-border-light disabled:text-border disabled:hover:text-border
43
44
${className}
44
45
`}
45
46
>
···
70
71
<button
71
72
{...buttonProps}
72
73
ref={ref}
73
-
className={`m-0 h-max
74
+
className={`
75
+
m-0 h-max
74
76
${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"}
75
-
${props.compact ? "py-0 px-1" : "px-2 py-0.5 "}
76
-
bg-bg-page outline-transparent
77
-
rounded-md text-base font-bold text-accent-contrast
78
-
flex gap-2 items-center justify-center shrink-0
79
-
transparent-outline focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
80
-
border border-accent-contrast
81
-
disabled:bg-border-light disabled:text-border disabled:hover:text-border
82
-
${props.className}
83
-
`}
77
+
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
78
+
bg-bg-page disabled:bg-border-light
79
+
border border-accent-contrast rounded-md
80
+
outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
81
+
text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border
82
+
flex gap-2 items-center justify-center shrink-0
83
+
${props.className}
84
+
`}
84
85
>
85
86
{props.children}
86
87
</button>
···
92
93
HTMLButtonElement,
93
94
{
94
95
fullWidth?: boolean;
96
+
fullWidthOnMobile?: boolean;
95
97
children: React.ReactNode;
96
98
compact?: boolean;
97
99
} & ButtonProps
98
100
>((props, ref) => {
99
-
let { fullWidth, children, compact, ...buttonProps } = props;
101
+
let {
102
+
className,
103
+
fullWidth,
104
+
fullWidthOnMobile,
105
+
compact,
106
+
children,
107
+
...buttonProps
108
+
} = props;
100
109
return (
101
110
<button
102
111
{...buttonProps}
103
112
ref={ref}
104
-
className={`m-0 h-max ${fullWidth ? "w-full" : "w-max"} ${compact ? "px-0" : "px-1"}
105
-
bg-transparent text-base font-bold text-accent-contrast
106
-
flex gap-2 items-center justify-center shrink-0
107
-
hover:underline disabled:text-border
108
-
${props.className}
109
-
`}
113
+
className={`
114
+
m-0 h-max
115
+
${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"}
116
+
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
117
+
bg-transparent hover:bg-[var(--accent-light)]
118
+
border border-transparent rounded-md hover:border-[var(--accent-light)]
119
+
outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
120
+
text-base font-bold text-accent-contrast disabled:text-border
121
+
flex gap-2 items-center justify-center shrink-0
122
+
${props.className}
123
+
`}
110
124
>
111
125
{children}
112
126
</button>
-173
components/HelpPopover.tsx
-173
components/HelpPopover.tsx
···
1
-
"use client";
2
-
import { ShortcutKey } from "./Layout";
3
-
import { Media } from "./Media";
4
-
import { Popover } from "./Popover";
5
-
import { metaKey } from "src/utils/metaKey";
6
-
import { useEntitySetContext } from "./EntitySetProvider";
7
-
import { useState } from "react";
8
-
import { ActionButton } from "components/ActionBar/ActionButton";
9
-
import { HelpSmall } from "./Icons/HelpSmall";
10
-
import { isMac } from "src/utils/isDevice";
11
-
import { useIsMobile } from "src/hooks/isMobile";
12
-
13
-
export const HelpPopover = (props: { noShortcuts?: boolean }) => {
14
-
let entity_set = useEntitySetContext();
15
-
let isMobile = useIsMobile();
16
-
17
-
return entity_set.permissions.write ? (
18
-
<Popover
19
-
side={isMobile ? "top" : "right"}
20
-
align={isMobile ? "center" : "start"}
21
-
asChild
22
-
className="max-w-xs w-full"
23
-
trigger={<ActionButton icon={<HelpSmall />} label="About" />}
24
-
>
25
-
<div className="flex flex-col text-sm gap-2 text-secondary">
26
-
{/* about links */}
27
-
<HelpLink text="๐ Leaflet Manual" url="https://about.leaflet.pub" />
28
-
<HelpLink text="๐ก Make with Leaflet" url="https://make.leaflet.pub" />
29
-
<HelpLink
30
-
text="โจ Explore Publications"
31
-
url="https://leaflet.pub/discover"
32
-
/>
33
-
<HelpLink text="๐ฃ Newsletter" url="https://buttondown.com/leaflet" />
34
-
{/* contact links */}
35
-
<div className="columns-2 gap-2">
36
-
<HelpLink
37
-
text="๐ฆ Bluesky"
38
-
url="https://bsky.app/profile/leaflet.pub"
39
-
/>
40
-
<HelpLink text="๐ Email" url="mailto:contact@leaflet.pub" />
41
-
</div>
42
-
{/* keyboard shortcuts: desktop only */}
43
-
<Media mobile={false}>
44
-
{!props.noShortcuts && (
45
-
<>
46
-
<hr className="text-border my-1" />
47
-
<div className="flex flex-col gap-1">
48
-
<Label>Text Shortcuts</Label>
49
-
<KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} />
50
-
<KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} />
51
-
<KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} />
52
-
<KeyboardShortcut
53
-
name="Highlight"
54
-
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]}
55
-
/>
56
-
<KeyboardShortcut
57
-
name="Strikethrough"
58
-
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]}
59
-
/>
60
-
<KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} />
61
-
62
-
<Label>Block Shortcuts</Label>
63
-
{/* shift + up/down arrows (or click + drag): select multiple blocks */}
64
-
<KeyboardShortcut
65
-
name="Move Block Up"
66
-
keys={["Shift", metaKey(), "โ"]}
67
-
/>
68
-
<KeyboardShortcut
69
-
name="Move Block Down"
70
-
keys={["Shift", metaKey(), "โ"]}
71
-
/>
72
-
{/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */}
73
-
{/* cmd/ctrl + up/down arrows: go to beginning / end of doc */}
74
-
75
-
<Label>Canvas Shortcuts</Label>
76
-
<OtherShortcut name="Add Block" description="Double click" />
77
-
<OtherShortcut name="Select Block" description="Long press" />
78
-
79
-
<Label>Outliner Shortcuts</Label>
80
-
<KeyboardShortcut
81
-
name="Make List"
82
-
keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]}
83
-
/>
84
-
{/* tab / shift + tab: indent / outdent */}
85
-
<KeyboardShortcut
86
-
name="Toggle Checkbox"
87
-
keys={[metaKey(), "Enter"]}
88
-
/>
89
-
<KeyboardShortcut
90
-
name="Toggle Fold"
91
-
keys={[metaKey(), "Shift", "Enter"]}
92
-
/>
93
-
<KeyboardShortcut
94
-
name="Fold All"
95
-
keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ"]}
96
-
/>
97
-
<KeyboardShortcut
98
-
name="Unfold All"
99
-
keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ"]}
100
-
/>
101
-
</div>
102
-
</>
103
-
)}
104
-
</Media>
105
-
{/* links: terms and privacy */}
106
-
<hr className="text-border my-1" />
107
-
{/* <HelpLink
108
-
text="Terms and Privacy Policy"
109
-
url="https://leaflet.pub/legal"
110
-
/> */}
111
-
<div>
112
-
<a href="https://leaflet.pub/legal" target="_blank">
113
-
Terms and Privacy Policy
114
-
</a>
115
-
</div>
116
-
</div>
117
-
</Popover>
118
-
) : null;
119
-
};
120
-
121
-
const KeyboardShortcut = (props: { name: string; keys: string[] }) => {
122
-
return (
123
-
<div className="flex gap-2 justify-between items-center">
124
-
{props.name}
125
-
<div className="flex gap-1 items-center font-bold">
126
-
{props.keys.map((key, index) => {
127
-
return <ShortcutKey key={index}>{key}</ShortcutKey>;
128
-
})}
129
-
</div>
130
-
</div>
131
-
);
132
-
};
133
-
134
-
const OtherShortcut = (props: { name: string; description: string }) => {
135
-
return (
136
-
<div className="flex justify-between items-center">
137
-
<span>{props.name}</span>
138
-
<span>
139
-
<strong>{props.description}</strong>
140
-
</span>
141
-
</div>
142
-
);
143
-
};
144
-
145
-
const Label = (props: { children: React.ReactNode }) => {
146
-
return <div className="text-tertiary font-bold pt-2 ">{props.children}</div>;
147
-
};
148
-
149
-
const HelpLink = (props: { url: string; text: string }) => {
150
-
const [isHovered, setIsHovered] = useState(false);
151
-
const handleMouseEnter = () => {
152
-
setIsHovered(true);
153
-
};
154
-
const handleMouseLeave = () => {
155
-
setIsHovered(false);
156
-
};
157
-
return (
158
-
<a
159
-
href={props.url}
160
-
target="_blank"
161
-
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
162
-
style={{
163
-
backgroundColor: isHovered
164
-
? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)"
165
-
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
-
}}
167
-
onMouseEnter={handleMouseEnter}
168
-
onMouseLeave={handleMouseLeave}
169
-
>
170
-
<strong>{props.text}</strong>
171
-
</a>
172
-
);
173
-
};
-76
components/HomeButton.tsx
-76
components/HomeButton.tsx
···
1
-
"use client";
2
-
import Link from "next/link";
3
-
import { useEntitySetContext } from "./EntitySetProvider";
4
-
import { ActionButton } from "components/ActionBar/ActionButton";
5
-
import { useParams, useSearchParams } from "next/navigation";
6
-
import { useIdentityData } from "./IdentityProvider";
7
-
import { useReplicache } from "src/replicache";
8
-
import { addLeafletToHome } from "actions/addLeafletToHome";
9
-
import { useSmoker } from "./Toast";
10
-
import { AddToHomeSmall } from "./Icons/AddToHomeSmall";
11
-
import { HomeSmall } from "./Icons/HomeSmall";
12
-
import { permission } from "process";
13
-
14
-
export function HomeButton() {
15
-
let { permissions } = useEntitySetContext();
16
-
let searchParams = useSearchParams();
17
-
18
-
return (
19
-
<>
20
-
<Link
21
-
href="/home"
22
-
prefetch
23
-
className="hover:no-underline"
24
-
style={{ textDecorationLine: "none !important" }}
25
-
>
26
-
<ActionButton icon={<HomeSmall />} label="Go Home" />
27
-
</Link>
28
-
{<AddToHomeButton />}
29
-
</>
30
-
);
31
-
}
32
-
33
-
const AddToHomeButton = (props: {}) => {
34
-
let { permission_token } = useReplicache();
35
-
let { identity, mutate } = useIdentityData();
36
-
let smoker = useSmoker();
37
-
if (
38
-
identity?.permission_token_on_homepage.find(
39
-
(pth) => pth.permission_tokens.id === permission_token.id,
40
-
) ||
41
-
!identity
42
-
)
43
-
return null;
44
-
return (
45
-
<ActionButton
46
-
onClick={async (e) => {
47
-
await addLeafletToHome(permission_token.id);
48
-
mutate((identity) => {
49
-
if (!identity) return;
50
-
return {
51
-
...identity,
52
-
permission_token_on_homepage: [
53
-
...identity.permission_token_on_homepage,
54
-
{
55
-
created_at: new Date().toISOString(),
56
-
permission_tokens: {
57
-
...permission_token,
58
-
leaflets_in_publications: [],
59
-
},
60
-
},
61
-
],
62
-
};
63
-
});
64
-
smoker({
65
-
position: {
66
-
x: e.clientX + 64,
67
-
y: e.clientY,
68
-
},
69
-
text: "Leaflet added to your home!",
70
-
});
71
-
}}
72
-
icon={<AddToHomeSmall />}
73
-
label="Add to Home"
74
-
/>
75
-
);
76
-
};
+21
components/Icons/ArchiveSmall.tsx
+21
components/Icons/ArchiveSmall.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const ArchiveSmall = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="24"
7
+
height="24"
8
+
viewBox="0 0 24 24"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
{...props}
12
+
>
13
+
<path
14
+
fillRule="evenodd"
15
+
clipRule="evenodd"
16
+
d="M14.3935 2.33729C14.4781 2.30741 14.5682 2.29611 14.6576 2.30415C14.7774 2.31514 14.897 2.32836 15.0165 2.34211C15.2401 2.36784 15.5571 2.40755 15.9337 2.46375C16.6844 2.57577 17.6834 2.755 18.6552 3.02334C20.043 3.40654 21.1623 4.08204 21.9307 4.65549C22.3161 4.94319 22.6172 5.20811 22.8237 5.40315C22.9788 5.5496 23.0813 5.6572 23.1271 5.70673C23.3287 5.92633 23.375 6.26081 23.1986 6.51162C23.0315 6.74906 22.723 6.84022 22.4537 6.73167C22.0456 6.56715 21.4938 6.48314 21.0486 6.65428C20.807 6.74717 20.531 6.94113 20.3218 7.3713L20.6009 7.19094C20.7969 7.06426 21.0472 7.05737 21.2499 7.17306C21.4527 7.28875 21.574 7.50775 21.5646 7.74096L21.2277 16.1284C21.2197 16.3285 21.1162 16.5127 20.9494 16.6237L11.9336 22.6232C11.7666 22.7343 11.5564 22.7585 11.3685 22.6883L2.23473 19.2743C2.00112 19.187 1.84179 18.9692 1.82933 18.7201L1.40252 10.1857C1.39041 9.94356 1.5194 9.71628 1.73347 9.60253L2.89319 8.98631C3.19801 8.82434 3.57642 8.94015 3.73838 9.24497C3.8855 9.52184 3.80344 9.85944 3.55872 10.0404L4.46834 10.3669C4.529 10.1684 4.63256 9.64884 4.57793 9.06783C4.51992 8.45086 4.29459 7.8533 3.74994 7.45779C3.09256 6.98978 2.55044 6.51789 2.315 6.27264C2.07596 6.02363 2.08403 5.62799 2.33304 5.38894C2.58204 5.14989 2.97769 5.15797 3.21674 5.40697C3.38499 5.58224 3.87255 6.01278 4.49863 6.45635C5.12762 6.90198 5.83958 7.31975 6.4589 7.5144C7.00579 7.68628 7.7553 7.62969 8.5369 7.43649C9.3015 7.24751 10.0054 6.95105 10.4074 6.74228C10.5756 6.65494 10.7743 6.64864 10.9477 6.72514C12.2233 7.28795 12.9191 8.50607 13.2891 9.66169C13.5067 10.3415 13.6259 11.0415 13.6803 11.6632L15.3414 10.5898C15.3412 10.5032 15.3407 10.4155 15.3403 10.3268C15.3336 9.034 15.3259 7.52674 16.0328 6.1972C15.7338 6.16682 15.3912 6.12949 15.0302 6.08539C13.9285 5.95083 12.5649 5.74352 11.7833 5.45362C11.0189 5.17008 10.3102 4.75223 9.80152 4.41446C9.6696 4.32685 9.54977 4.24371 9.4444 4.16843C9.26969 4.41598 9.11811 4.6909 8.99766 4.9675C8.79907 5.42358 8.71173 5.82238 8.71173 6.05267C8.71173 6.39784 8.43191 6.67767 8.08673 6.67767C7.74155 6.67767 7.46173 6.39784 7.46173 6.05267C7.46173 5.58769 7.61509 5.01162 7.8516 4.46846C8.09203 3.91632 8.44552 3.33542 8.89963 2.8725C9.12701 2.64071 9.4943 2.62192 9.74446 2.82883L9.74577 2.8299C9.80956 2.88191 9.87475 2.93223 9.94039 2.98188C10.0714 3.08094 10.2612 3.21923 10.493 3.37315C10.9612 3.68404 11.5799 4.04492 12.218 4.28164C12.8391 4.512 14.0548 4.70696 15.1817 4.84461C15.7313 4.91174 16.2384 4.96292 16.6084 4.99732C16.8076 5.01584 17.007 5.03362 17.2065 5.04896C17.4444 5.06698 17.6512 5.21883 17.7397 5.44036C17.8282 5.66191 17.7828 5.9145 17.6228 6.09143C16.7171 7.09276 16.6045 8.33681 16.5923 9.78143L18.8039 8.35222C18.7998 8.30706 18.8006 8.26075 18.8068 8.21391C19.0047 6.71062 19.6821 5.84043 20.6001 5.48753C20.6783 5.45746 20.7569 5.4317 20.8356 5.40989C20.1821 4.96625 19.3286 4.50604 18.3225 4.22826C17.4178 3.97844 16.4732 3.80809 15.7493 3.70006C15.3886 3.64625 15.0857 3.60832 14.8736 3.58392C14.8084 3.57642 14.7519 3.57021 14.705 3.56521C14.6894 3.57354 14.6728 3.58282 14.6556 3.59303C14.5489 3.65657 14.4711 3.72644 14.4347 3.7856C14.2538 4.07957 13.8688 4.17123 13.5749 3.99032C13.2809 3.80941 13.1892 3.42445 13.3701 3.13047C13.5575 2.82606 13.8293 2.63024 14.0162 2.51897C14.1352 2.44809 14.2601 2.38531 14.3906 2.33829L14.3921 2.33776L14.3935 2.33729ZM12.4675 12.447C12.4635 11.7846 12.3687 10.8866 12.0986 10.0428C11.8096 9.1402 11.353 8.39584 10.6886 7.99621C10.209 8.21933 9.54785 8.47423 8.83684 8.64998C7.98278 8.86108 6.96103 8.98249 6.08412 8.70689C5.98146 8.67463 5.87826 8.63824 5.77495 8.59834C5.79615 8.71819 5.81166 8.83611 5.82244 8.95081C5.89602 9.73333 5.75996 10.4455 5.64541 10.7895L11.68 12.9559L12.4675 12.447ZM4.77065 13.1487C4.60756 13.0891 4.43494 13.2099 4.43494 13.3835V14.9513C4.43494 15.1613 4.5662 15.3489 4.76351 15.421L8.55169 16.8036C8.71479 16.8631 8.88741 16.7423 8.88741 16.5687V15.001C8.88741 14.7909 8.75614 14.6033 8.55884 14.5313L4.77065 13.1487ZM2.69778 11.0594L11.1256 14.085L11.0552 17.5412C11.0482 17.8863 11.3222 18.1718 11.6673 18.1788C12.0124 18.1859 12.2979 17.9118 12.3049 17.5667L12.3778 13.9933L20.2673 8.89485L19.9915 15.7596L12.2366 20.9201L12.2469 20.4127C12.254 20.0676 11.9799 19.7821 11.6348 19.7751C11.2897 19.768 11.0042 20.0421 10.9972 20.3872L10.9804 21.2088L3.05725 18.2473L2.69778 11.0594Z"
17
+
fill="currentColor"
18
+
/>
19
+
</svg>
20
+
);
21
+
};
+21
components/Icons/GoBackTiny.tsx
+21
components/Icons/GoBackTiny.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const GoBackTiny = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="16"
7
+
height="16"
8
+
viewBox="0 0 16 16"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
>
12
+
<path
13
+
d="M7.40426 3L2.19592 8M2.19592 8L7.40426 13M2.19592 8H13.8041"
14
+
stroke="currentColor"
15
+
strokeWidth="2"
16
+
strokeLinecap="round"
17
+
strokeLinejoin="round"
18
+
/>
19
+
</svg>
20
+
);
21
+
};
+19
components/Icons/LooseleafSmall.tsx
+19
components/Icons/LooseleafSmall.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const LooseLeafSmall = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="24"
7
+
height="24"
8
+
viewBox="0 0 24 24"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
{...props}
12
+
>
13
+
<path
14
+
d="M16.5339 4.65788L21.9958 5.24186C22.4035 5.28543 22.7014 5.6481 22.6638 6.05632C22.5159 7.65303 22.3525 9.87767 22.0925 11.9186C21.9621 12.9418 21.805 13.9374 21.6091 14.8034C21.4166 15.6542 21.1733 16.442 20.8454 17.0104C20.1989 18.131 19.0036 18.9569 17.9958 19.4782C17.4793 19.7453 16.9792 19.9495 16.569 20.0827C16.3649 20.1489 16.1724 20.2013 16.0046 20.234C15.8969 20.255 15.7254 20.2816 15.5495 20.2682C15.5466 20.2681 15.5423 20.2684 15.5378 20.2682C15.527 20.2678 15.5112 20.267 15.4919 20.2663C15.4526 20.2647 15.3959 20.2623 15.3239 20.2584C15.1788 20.2506 14.9699 20.2366 14.7116 20.2145C14.1954 20.1703 13.4757 20.0909 12.6598 19.9489C11.0477 19.6681 8.97633 19.1301 7.36198 18.0807C6.70824 17.6557 5.95381 17.064 5.21842 16.4469C5.09798 16.5214 4.97261 16.591 4.81803 16.6706C4.28341 16.9455 3.71779 17.0389 3.17935 16.9137C2.64094 16.7885 2.20091 16.4608 1.89126 16.0231C1.28226 15.1618 1.16463 13.8852 1.5729 12.5514L1.60708 12.4606C1.7005 12.255 1.88295 12.1001 2.10513 12.0436C2.35906 11.9792 2.62917 12.0524 2.81607 12.236L2.82486 12.2448C2.8309 12.2507 2.84033 12.2596 2.8522 12.2712C2.87664 12.295 2.91343 12.3309 2.9606 12.3766C3.05513 12.4682 3.19281 12.6016 3.3649 12.7653C3.70953 13.0931 4.19153 13.5443 4.73795 14.0378C5.84211 15.0349 7.17372 16.1691 8.17937 16.8229C9.53761 17.7059 11.3696 18.2017 12.9177 18.4713C13.6815 18.6043 14.3565 18.679 14.8395 18.7204C15.0804 18.741 15.2731 18.7533 15.404 18.7604C15.4691 18.7639 15.5195 18.7659 15.5524 18.7672C15.5684 18.7679 15.5809 18.7689 15.5886 18.7692H15.5983L15.6374 18.7731C15.6457 18.7724 15.671 18.7704 15.7175 18.7614C15.8087 18.7436 15.9399 18.7095 16.1052 18.6559C16.4345 18.549 16.8594 18.3773 17.3063 18.1461C18.2257 17.6706 19.1147 17.0089 19.5466 16.2604C19.7578 15.8941 19.9618 15.2874 20.1462 14.4723C20.3271 13.6723 20.4767 12.7294 20.6042 11.7292C20.8232 10.0102 20.9711 8.17469 21.1042 6.65397L16.3747 6.14909C15.963 6.10498 15.6648 5.73562 15.7087 5.3239C15.7528 4.91222 16.1222 4.61399 16.5339 4.65788ZM12.0593 13.1315L12.2038 13.1647L12.3776 13.235C12.7592 13.4197 12.9689 13.7541 13.0837 14.0573C13.2089 14.3885 13.2545 14.7654 13.2858 15.0573C13.3144 15.3233 13.3319 15.5214 13.361 15.6774C13.4345 15.6215 13.5233 15.5493 13.6413 15.4479C13.7924 15.318 14.0034 15.1374 14.2429 15.0114C14.4965 14.878 14.8338 14.7772 15.2175 14.8747C15.5354 14.9556 15.7394 15.1539 15.8679 15.3229C15.9757 15.4648 16.0814 15.6631 16.1247 15.736C16.1889 15.8438 16.2218 15.8788 16.239 15.8922C16.2438 15.896 16.2462 15.8979 16.2497 15.8991C16.2541 15.9005 16.2717 15.9049 16.3093 15.9049C16.6541 15.9051 16.934 16.1851 16.9343 16.5299C16.9343 16.875 16.6543 17.1548 16.3093 17.1549C15.9766 17.1549 15.6957 17.0542 15.4694 16.8776C15.2617 16.7153 15.1322 16.5129 15.0505 16.3756C14.9547 16.2147 14.9262 16.1561 14.8815 16.0944C14.8684 16.0989 14.849 16.1051 14.8249 16.1178C14.7289 16.1684 14.6182 16.2555 14.4557 16.3952C14.3175 16.514 14.1171 16.6946 13.9069 16.821C13.6882 16.9524 13.3571 17.0902 12.9684 16.9938C12.4305 16.8602 12.2473 16.3736 12.1764 16.1051C12.1001 15.8159 12.0709 15.4542 12.0427 15.1911C12.0102 14.8884 11.9751 14.662 11.9138 14.4997C11.9011 14.4662 11.8884 14.4403 11.8776 14.4206C11.7899 14.4801 11.6771 14.5721 11.5329 14.7047C11.3855 14.8404 11.181 15.0386 11.0016 15.196C10.8175 15.3575 10.5936 15.5364 10.3512 15.6569C10.19 15.737 9.99118 15.7919 9.77214 15.7594C9.55026 15.7264 9.38367 15.6153 9.27019 15.5045C9.08085 15.3197 8.96362 15.0503 8.91081 14.9391C8.8766 14.8671 8.85074 14.814 8.82585 14.7692C8.541 14.777 8.27798 14.5891 8.20378 14.3014C8.11797 13.9674 8.31907 13.6269 8.653 13.5407L8.79558 13.5124C8.93966 13.4936 9.0875 13.5034 9.23308 13.5485C9.42396 13.6076 9.569 13.7155 9.67449 13.8239C9.85113 14.0055 9.96389 14.244 10.027 14.3776C10.0723 14.3417 10.124 14.3034 10.1774 14.2565C10.3474 14.1073 10.4942 13.9615 10.6862 13.7848C10.8571 13.6276 11.0614 13.4475 11.2731 13.32C11.4428 13.2178 11.7294 13.081 12.0593 13.1315ZM2.84537 14.3366C2.88081 14.6965 2.98677 14.9742 3.11588 15.1569C3.24114 15.334 3.38295 15.4211 3.5192 15.4528C3.63372 15.4794 3.79473 15.4775 4.00553 15.3932C3.9133 15.3109 3.82072 15.2311 3.73209 15.151C3.40947 14.8597 3.10909 14.5828 2.84537 14.3366ZM8.73601 3.86003C9.14672 3.91292 9.43715 4.28918 9.38445 4.69987C9.25964 5.66903 9.14642 7.35598 8.87077 9.02018C8.59001 10.7151 8.11848 12.5766 7.20085 14.1003C6.98712 14.4551 6.52539 14.5698 6.17057 14.3561C5.81623 14.1423 5.70216 13.6814 5.91569 13.3268C6.68703 12.0463 7.121 10.4066 7.39128 8.77506C7.66663 7.11265 7.74965 5.64618 7.89616 4.50847C7.94916 4.09794 8.32546 3.80744 8.73601 3.86003ZM11.7614 8.36784C12.1238 8.21561 12.4973 8.25977 12.8054 8.46452C13.0762 8.64474 13.2601 8.92332 13.3884 9.18912C13.5214 9.46512 13.6241 9.79028 13.7009 10.1354C13.7561 10.3842 13.7827 10.6162 13.8034 10.8044C13.8257 11.0069 13.8398 11.1363 13.864 11.2438C13.8806 11.3174 13.8959 11.3474 13.9011 11.3561C13.9095 11.3609 13.9289 11.3695 13.9655 11.3786C14.0484 11.3991 14.0814 11.3929 14.0895 11.3913C14.1027 11.3885 14.1323 11.3804 14.2028 11.3366C14.3137 11.2677 14.6514 11.0042 15.0563 10.8288L15.1364 10.7985C15.3223 10.7392 15.4987 10.7526 15.6335 10.7838C15.7837 10.8188 15.918 10.883 16.0231 10.9421C16.2276 11.057 16.4458 11.2251 16.613 11.3503C16.8019 11.4917 16.9527 11.5999 17.0827 11.6676C17.1539 11.7047 17.1908 11.7142 17.2009 11.7165L17.2849 11.7047C17.5751 11.6944 17.8425 11.8891 17.9138 12.1823C17.995 12.5174 17.7897 12.8554 17.4548 12.9372C17.0733 13.0299 16.7253 12.8909 16.5046 12.776C16.2705 12.6541 16.042 12.4845 15.864 12.3512C15.6704 12.2064 15.5344 12.1038 15.4216 12.0387C15.2178 12.1436 15.1125 12.2426 14.862 12.3981C14.7283 12.4811 14.5564 12.5716 14.3415 12.6159C14.1216 12.6611 13.8975 12.6501 13.6647 12.5924C13.3819 12.5222 13.1344 12.3858 12.9479 12.1657C12.7701 11.9555 12.689 11.7172 12.6442 11.5182C12.601 11.3259 12.58 11.112 12.5612 10.9411C12.5408 10.7561 12.5194 10.5827 12.4802 10.4059C12.4169 10.1215 12.3411 9.89526 12.2624 9.73209C12.2296 9.66404 12.1981 9.61255 12.1716 9.57487C12.1263 9.61576 12.0615 9.68493 11.9802 9.7985C11.8864 9.92952 11.7821 10.0922 11.6589 10.2838C11.5393 10.4698 11.4043 10.6782 11.2634 10.8786C11.123 11.0782 10.9664 11.2843 10.7975 11.4635C10.633 11.6381 10.4285 11.8185 10.1862 11.9342C9.87476 12.0828 9.50095 11.9507 9.35222 11.6393C9.20377 11.3279 9.33594 10.9551 9.64714 10.8063C9.69148 10.7851 9.77329 10.7282 9.88835 10.6061C9.99931 10.4883 10.1167 10.3365 10.2409 10.1598C10.3647 9.98378 10.4855 9.79617 10.6071 9.60709C10.7249 9.42397 10.8479 9.23258 10.9636 9.07096C11.1814 8.76677 11.4424 8.50191 11.7614 8.36784ZM12.4304 2.81218C13.631 2.81246 14.6042 3.78628 14.6042 4.98698C14.6041 5.39899 14.4869 5.78271 14.2878 6.111L15.0007 6.9069C15.2772 7.21532 15.2515 7.689 14.9431 7.96549C14.6347 8.24164 14.1609 8.21606 13.8845 7.90788L13.1139 7.0485C12.8988 7.11984 12.6695 7.16075 12.4304 7.16081C11.2296 7.16081 10.2558 6.18766 10.2555 4.98698C10.2555 3.7861 11.2295 2.81218 12.4304 2.81218ZM12.4304 4.31218C12.0579 4.31218 11.7555 4.61453 11.7555 4.98698C11.7558 5.35924 12.058 5.66081 12.4304 5.66081C12.8024 5.66053 13.104 5.35907 13.1042 4.98698C13.1042 4.6147 12.8026 4.31246 12.4304 4.31218Z"
15
+
fill="currentColor"
16
+
/>
17
+
</svg>
18
+
);
19
+
};
+19
components/Icons/TagTiny.tsx
+19
components/Icons/TagTiny.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const TagTiny = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="16"
7
+
height="16"
8
+
viewBox="0 0 16 16"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
{...props}
12
+
>
13
+
<path
14
+
d="M3.70775 9.003C3.96622 8.90595 4.25516 9.03656 4.35228 9.29499C4.37448 9.35423 4.38309 9.41497 4.38255 9.47468C4.38208 9.6765 4.25946 9.86621 4.05931 9.94148C3.36545 10.2021 2.74535 10.833 2.42747 11.5479C2.33495 11.7561 2.27242 11.9608 2.239 12.1573C2.15817 12.6374 2.25357 13.069 2.52513 13.3858C2.92043 13.8467 3.51379 14.0403 4.20189 14.0665C4.88917 14.0925 5.59892 13.9482 6.12571 13.8126C7.09158 13.5639 7.81893 13.6157 8.29954 13.9415C8.67856 14.1986 8.83462 14.578 8.8347 14.9298C8.83502 15.0506 8.81652 15.1682 8.78294 15.2764C8.7009 15.5398 8.42049 15.6873 8.15696 15.6055C7.89935 15.5253 7.75386 15.2555 7.82396 14.9971C7.82572 14.9905 7.8258 14.9833 7.82786 14.9766C7.83167 14.9643 7.834 14.9503 7.8347 14.9356C7.83623 14.8847 7.8147 14.823 7.739 14.7716C7.61179 14.6853 7.23586 14.5616 6.37474 14.7833C5.81779 14.9266 4.99695 15.1 4.1638 15.0684C3.33126 15.0368 2.41412 14.7967 1.76536 14.0401C1.30175 13.4992 1.16206 12.8427 1.22728 12.1993C1.23863 12.086 1.25554 11.9732 1.27903 11.8614C1.28235 11.8457 1.28624 11.8302 1.28978 11.8145C1.34221 11.5817 1.41832 11.3539 1.51439 11.1378C1.92539 10.2136 2.72927 9.37064 3.70775 9.003ZM13.8972 7.54695C14.124 7.38948 14.4359 7.44622 14.5935 7.67292C14.7508 7.89954 14.6948 8.21063 14.4685 8.36823L8.65892 12.4044C8.24041 12.695 7.74265 12.8515 7.23314 12.8516H3.9138C3.63794 12.8515 3.41315 12.6274 3.41282 12.3516C3.41282 12.0755 3.63769 11.8517 3.9138 11.8516H7.23216C7.538 11.8516 7.8374 11.7575 8.0886 11.5831L13.8972 7.54695ZM10.1609 0.550851C10.6142 0.235853 11.2372 0.347685 11.5525 0.800851L14.6091 5.19734C14.9239 5.65063 14.8121 6.27369 14.3591 6.58894L7.88841 11.087C7.63297 11.2645 7.32837 11.3586 7.01732 11.3555L4.1804 11.3262C3.76371 11.3218 3.38443 11.1921 3.072 10.9776C3.23822 10.7748 3.43062 10.5959 3.63646 10.4503C3.96958 10.5767 4.35782 10.5421 4.67259 10.3233C5.17899 9.97084 5.30487 9.27438 4.95286 8.76765C4.60048 8.26108 3.90304 8.13639 3.39622 8.48835C3.17656 8.64127 3.02799 8.85895 2.9597 9.09773C2.69658 9.26211 2.45194 9.45783 2.23118 9.67585C2.17892 9.38285 2.19133 9.07163 2.28294 8.76081L3.14818 5.8282C3.24483 5.50092 3.45101 5.21639 3.73118 5.02155L10.1609 0.550851ZM8.76732 3.73835L9.73607 4.91023L8.68626 5.41804L7.79466 6.24323L7.04857 4.91804L6.26634 5.45417L7.22923 6.63386L5.72337 7.40437L6.34739 8.31355L7.60814 7.18464L8.37767 8.53132L9.15989 7.99421L8.17454 6.79792L9.27708 6.25788L10.1179 5.46589L10.8786 6.81452L11.6609 6.27741L10.6745 5.07917L12.1882 4.30476L11.5642 3.39558L10.2976 4.52839L9.54954 3.20124L8.76732 3.73835Z"
15
+
fill="currentColor"
16
+
/>
17
+
</svg>
18
+
);
19
+
};
-21
components/Icons/TemplateRemoveSmall.tsx
-21
components/Icons/TemplateRemoveSmall.tsx
···
1
-
import { Props } from "./Props";
2
-
3
-
export const TemplateRemoveSmall = (props: Props) => {
4
-
return (
5
-
<svg
6
-
width="24"
7
-
height="24"
8
-
viewBox="0 0 24 24"
9
-
fill="none"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
{...props}
12
-
>
13
-
<path
14
-
fillRule="evenodd"
15
-
clipRule="evenodd"
16
-
d="M21.6598 1.22969C22.0503 0.839167 22.6835 0.839167 23.074 1.22969C23.4646 1.62021 23.4646 2.25338 23.074 2.6439L21.9991 3.71887C22 3.72121 22.001 3.72355 22.002 3.7259L21.0348 4.69374C21.0347 4.69033 21.0345 4.68693 21.0344 4.68353L17.2882 8.42972L17.2977 8.43313L16.3813 9.35011L16.3714 9.34656L15.5955 10.1224L15.6058 10.1261L14.6894 11.0431L14.6787 11.0393L14.3959 11.3221L14.4067 11.326L13.4903 12.2429L13.479 12.2389L12.8919 12.8261L12.9034 12.8302L10.2156 15.5198L10.2028 15.5152L9.35969 16.3583C9.36255 16.3614 9.36541 16.3645 9.36826 16.3676L7.20585 18.5314C7.19871 18.5321 7.19159 18.5328 7.18448 18.5335L6.26611 19.4519C6.27069 19.4539 6.27528 19.4559 6.27989 19.4579L5.40679 20.3316C5.40244 20.3291 5.39809 20.3267 5.39376 20.3242L2.54817 23.1698C2.15765 23.5603 1.52448 23.5603 1.13396 23.1698C0.743434 22.7793 0.743433 22.1461 1.13396 21.7556L4.57518 18.3144C4.5862 18.296 4.59778 18.2779 4.6099 18.2599C4.72342 18.0917 4.86961 17.964 5.02393 17.8656L6.39488 16.4947C6.25376 16.4822 6.10989 16.4734 5.96441 16.4685C5.20904 16.4433 4.461 16.5264 3.88183 16.7201C3.2818 16.9207 2.99485 17.1912 2.91069 17.4452C2.80892 17.7525 2.47737 17.919 2.17013 17.8173C1.8629 17.7155 1.69634 17.3839 1.79811 17.0767C2.05627 16.2973 2.78206 15.852 3.51019 15.6085C4.2592 15.3581 5.15477 15.2689 6.00346 15.2972C6.48903 15.3133 6.97583 15.3686 7.42782 15.4617L8.11942 14.7701L7.89431 14.6896C7.7838 14.6501 7.69213 14.5705 7.63742 14.4667L5.91365 11.1952C5.86162 11.0964 5.84836 10.9944 5.86434 10.9002L5.85245 10.9196L5.11563 9.4308C4.96523 9.11293 5.04515 8.78343 5.24544 8.56361L5.25054 8.55806C5.25749 8.55058 5.26457 8.54323 5.2718 8.53601L6.43022 7.3457C6.6445 7.11834 6.97346 7.03892 7.26837 7.14439L9.80363 8.05107L12.9624 7.10485C13.1067 7.02062 13.2859 6.99834 13.4555 7.05901L14.4322 7.40831C14.7942 6.69891 14.93 5.89897 15.0777 5.02873L15.0777 5.02872L15.0958 4.9222C15.2586 3.96572 15.4529 2.86736 16.1798 2.04515C17.0056 1.11114 18.7307 0.837125 20.2663 1.83615C20.4285 1.94168 20.5821 2.05061 20.7266 2.16294L21.6598 1.22969ZM19.8899 2.99965C19.8075 2.93935 19.72 2.87895 19.6271 2.81856C18.4897 2.07854 17.4326 2.39759 17.0579 2.82147C16.5869 3.3541 16.4234 4.10723 16.2512 5.11887L16.2231 5.28522L16.2231 5.28523C16.1304 5.83581 16.0274 6.44661 15.8342 7.05527L19.8899 2.99965ZM14.288 8.60148L13.2682 8.23675L11.6654 8.71688L13.5122 9.37736L14.288 8.60148ZM12.5953 10.2942L9.59692 9.22187L9.58424 9.21734L7.10654 8.33124L6.82935 8.61605L12.3125 10.577L12.5953 10.2942ZM11.3957 11.4938L6.56005 9.76447L6.04788 10.6006C6.16458 10.5123 6.32269 10.4767 6.48628 10.5352L10.8085 12.081L11.3957 11.4938ZM17.0099 12.2569L16.2294 11.9778L15.313 12.8948L16.8798 13.4551L18.7426 16.9905L18.0747 17.8398L19.1912 18.2615C19.6607 18.4294 20.1033 18.1358 20.2179 17.728L20.7391 16.3648C20.824 16.1511 20.8112 15.9108 20.7039 15.7071L19.124 12.7086L18.8949 11.321L18.8931 11.3104L18.8904 11.2969C18.8874 11.234 18.8742 11.1705 18.8497 11.1087L18.3522 9.8537L16.5121 11.6949L16.5482 11.7078L16.5582 11.7115L17.1419 11.9202L17.0099 12.2569ZM12.0382 16.1716L14.7261 13.482L16.0553 13.9574C16.1658 13.9969 16.2575 14.0764 16.3122 14.1803L18.0359 17.4518C18.2352 17.83 17.8658 18.2557 17.4633 18.1118L12.0382 16.1716ZM8.44038 19.7717L7.26492 20.9479C7.80247 21.0274 8.35468 21.0252 8.82243 20.8811C9.24804 20.7499 9.52382 20.5096 9.73008 20.285C9.79978 20.2091 9.87046 20.1246 9.92979 20.0536L9.92981 20.0536L9.92999 20.0534L9.9306 20.0527C9.95072 20.0286 9.96953 20.0061 9.98653 19.9861C10.0618 19.8973 10.1248 19.8281 10.1905 19.7694C10.307 19.6651 10.4472 19.579 10.6908 19.5395C10.9182 19.5027 11.2529 19.5041 11.7567 19.6004C11.6943 19.6815 11.6359 19.764 11.5823 19.8476C11.3276 20.2439 11.1352 20.7322 11.2038 21.2293C11.3097 21.9955 11.8139 22.4463 12.3522 22.6544C12.8626 22.8518 13.4377 22.8513 13.8631 22.731C14.7279 22.4863 15.6213 21.724 15.4107 20.664C15.3105 20.1591 14.9656 19.7211 14.4516 19.3701C14.3677 19.3128 14.2783 19.2571 14.1833 19.203C14.5987 19.0436 14.9889 19.0051 15.2828 19.1025C15.59 19.2042 15.9215 19.0377 16.0233 18.7304C16.1251 18.4232 15.9585 18.0916 15.6513 17.9899C14.6724 17.6656 13.5751 18.0821 12.7766 18.6397C12.6141 18.5938 12.4436 18.5504 12.265 18.5097C11.5394 18.3444 10.9698 18.307 10.5035 18.3825C10.018 18.4612 9.67586 18.657 9.40877 18.8961C9.28262 19.009 9.17853 19.1268 9.09296 19.2277C9.06342 19.2625 9.03731 19.2937 9.0131 19.3227L9.01295 19.3228C8.9605 19.3856 8.91697 19.4377 8.86686 19.4922C8.73917 19.6313 8.63185 19.7134 8.47726 19.761C8.46519 19.7648 8.45289 19.7683 8.44038 19.7717ZM12.5683 20.4811C12.3863 20.7644 12.3505 20.965 12.3648 21.0689C12.4003 21.3259 12.5445 21.4722 12.7749 21.5613C13.0331 21.6611 13.3469 21.659 13.544 21.6032C14.1554 21.4302 14.2952 21.0637 14.2612 20.8923C14.2391 20.7814 14.1422 20.578 13.7907 20.338C13.6005 20.2082 13.347 20.076 13.0173 19.9508C12.8341 20.1242 12.681 20.3057 12.5683 20.4811Z"
17
-
fill="currentColor"
18
-
/>
19
-
</svg>
20
-
);
21
-
};
-25
components/Icons/TemplateSmall.tsx
-25
components/Icons/TemplateSmall.tsx
···
1
-
import { Props } from "./Props";
2
-
3
-
export const TemplateSmall = (props: Props & { fill?: string }) => {
4
-
return (
5
-
<svg
6
-
width="24"
7
-
height="24"
8
-
viewBox="0 0 24 24"
9
-
fill="none"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
{...props}
12
-
>
13
-
<path
14
-
d="M14.1876 3.5073C14.3657 2.68428 14.8409 1.80449 15.1974 1.39941L15.2085 1.38682C15.5258 1.02605 16.1664 0.297788 17.7348 0.0551971C19.7272 -0.252968 22.338 1.22339 23.1781 3.53026C23.9464 5.63998 22.4863 7.65134 21.1778 8.49107C20.443 8.96256 19.8776 9.29865 19.5389 9.6655C19.6381 9.88024 19.8755 10.4623 19.9945 10.8588C20.1304 11.312 20.1356 11.8263 20.2444 12.3342C20.6412 13.1008 21.4615 14.6122 21.6483 14.9894C21.9441 15.5868 22.0637 16.0554 21.901 16.59C21.7793 16.99 21.3809 18.0037 21.2098 18.4064C21.1134 18.6333 20.6741 19.1794 20.165 19.3516C19.5207 19.5694 19.2 19.533 18.2867 19.1682C17.9231 19.3768 17.3068 19.3194 17.0874 19.2128C16.9902 19.5392 16.6234 19.8695 16.4353 20.0055C16.5008 20.1749 16.6684 20.619 16.5759 21.4191C16.4257 22.7176 14.6119 24.4819 12.2763 23.8544C10.5744 23.3971 10.2099 22.1002 10.0744 21.5462C8.16651 22.8209 5.74592 21.9772 4.43632 21.1133C3.44653 20.4603 3.16063 19.4467 3.2199 18.7888C2.57837 19.147 1.33433 19.2159 0.756062 17.9729C0.320217 17.036 0.838862 15.6535 2.49397 14.7706C3.56898 14.1971 5.01017 14.061 6.14456 14.136C5.47545 12.9417 4.17774 10.4051 3.97777 9.74456C3.72779 8.91889 3.94746 8.3129 4.30348 7.88113C4.6595 7.44936 5.21244 6.90396 5.75026 6.38129C6.28808 5.85862 7.06074 5.85862 7.7349 6.07072C8.27424 6.2404 9.36352 6.65146 9.84074 6.83578C10.5069 6.63086 11.9689 6.18102 12.4877 6.02101C13.0065 5.861 13.184 5.78543 13.7188 5.90996C13.8302 5.37643 14.0045 4.35336 14.1876 3.5073Z"
15
-
fill={props.fill || "transparent"}
16
-
/>
17
-
<path
18
-
fillRule="evenodd"
19
-
clipRule="evenodd"
20
-
d="M19.6271 2.81856C18.4896 2.07854 17.4326 2.39759 17.0578 2.82147C16.5869 3.3541 16.4234 4.10723 16.2512 5.11887L16.2231 5.28522L16.2231 5.28523C16.0919 6.06363 15.9405 6.96241 15.5423 7.80533L17.4557 8.48962C18.0778 7.71969 18.7304 7.28473 19.2974 6.92363L19.3687 6.87829C20.0258 6.46022 20.473 6.17579 20.7913 5.5972C21.0667 5.09643 21.0978 4.64884 20.9415 4.23092C20.7767 3.79045 20.3738 3.3044 19.6271 2.81856ZM15.0777 5.02873C14.9299 5.89897 14.7941 6.69891 14.4321 7.4083L13.4555 7.05901C13.2858 6.99834 13.1067 7.02061 12.9624 7.10485L9.80359 8.05107L7.26833 7.14438C6.97342 7.03892 6.64447 7.11834 6.43018 7.3457L5.27176 8.53601C5.26453 8.54323 5.25745 8.55058 5.2505 8.55806L5.2454 8.56361C5.04511 8.78343 4.9652 9.11292 5.1156 9.43079L5.85241 10.9196L5.8643 10.9002C5.84832 10.9944 5.86158 11.0964 5.91361 11.1952L7.63738 14.4667C7.6921 14.5705 7.78376 14.6501 7.89428 14.6896L17.4633 18.1118C17.8658 18.2557 18.2352 17.83 18.0359 17.4518L16.3121 14.1803C16.2574 14.0764 16.1657 13.9969 16.0552 13.9574L6.48624 10.5352C6.32266 10.4767 6.16454 10.5123 6.04784 10.6006L6.56002 9.76447L16.8798 13.4551L18.7426 16.9905L18.0747 17.8398L19.1912 18.2615C19.6606 18.4294 20.1033 18.1358 20.2179 17.728L20.7391 16.3648C20.8239 16.1511 20.8112 15.9108 20.7039 15.7071L19.124 12.7086L18.8949 11.321C18.8935 11.3129 18.892 11.3049 18.8904 11.2969C18.8874 11.234 18.8741 11.1705 18.8496 11.1087L18.1936 9.45372C18.7455 8.68856 19.3357 8.28878 19.927 7.9122C19.9681 7.88603 20.0096 7.85977 20.0514 7.83331C20.6663 7.44436 21.3511 7.01112 21.8182 6.16211C22.2345 5.40522 22.3314 4.60167 22.0392 3.82037C21.7555 3.06161 21.1334 2.40034 20.2662 1.83615C18.7307 0.837123 17.0056 1.11114 16.1798 2.04515C15.4528 2.86736 15.2586 3.96572 15.0958 4.92219L15.0777 5.02872L15.0777 5.02873ZM13.2681 8.23675L11.6653 8.71688L16.3567 10.3947L16.6254 9.4374L13.2681 8.23675ZM16.5481 11.7078L16.5582 11.7114L17.1419 11.9202L17.0098 12.2569L6.82932 8.61605L7.1065 8.33124L9.5842 9.21734L9.59688 9.22187L16.5481 11.7078ZM12.5683 20.4811C12.3863 20.7644 12.3505 20.965 12.3648 21.0689C12.4003 21.3259 12.5444 21.4722 12.7748 21.5613C13.0331 21.6611 13.3469 21.659 13.544 21.6032C14.1553 21.4302 14.2952 21.0637 14.2611 20.8923C14.2391 20.7814 14.1421 20.578 13.7906 20.338C13.6004 20.2082 13.3469 20.076 13.0173 19.9508C12.834 20.1242 12.681 20.3057 12.5683 20.4811ZM11.7567 19.6004C11.6942 19.6815 11.6359 19.764 11.5822 19.8476C11.3276 20.2439 11.1351 20.7322 11.2038 21.2293C11.3096 21.9955 11.8139 22.4463 12.3521 22.6544C12.8626 22.8518 13.4377 22.8513 13.863 22.731C14.7279 22.4863 15.6213 21.724 15.4107 20.664C15.3104 20.1591 14.9656 19.7211 14.4515 19.3701C14.3677 19.3128 14.2783 19.2571 14.1833 19.203C14.5987 19.0436 14.9889 19.0051 15.2827 19.1025C15.59 19.2042 15.9215 19.0377 16.0233 18.7304C16.125 18.4232 15.9585 18.0916 15.6513 17.9899C14.6724 17.6656 13.5751 18.0821 12.7766 18.6397C12.6141 18.5938 12.4436 18.5504 12.265 18.5097C11.5393 18.3444 10.9698 18.307 10.5034 18.3825C10.018 18.4612 9.67582 18.657 9.40873 18.8961C9.28258 19.009 9.17849 19.1268 9.09292 19.2277C9.06338 19.2625 9.03727 19.2937 9.01306 19.3227L9.01291 19.3228C8.96046 19.3856 8.91693 19.4377 8.86682 19.4922C8.73913 19.6313 8.63181 19.7134 8.47722 19.761C8.03942 19.896 7.30137 19.8237 6.60705 19.5851C6.27195 19.4699 5.98787 19.3293 5.79222 19.1916C5.64379 19.0871 5.59428 19.019 5.58047 19L5.58045 19C5.57827 18.997 5.57698 18.9952 5.57634 18.9947C5.57144 18.9579 5.57397 18.938 5.57539 18.9305C5.57674 18.9233 5.57829 18.9201 5.58128 18.9156C5.59031 18.9023 5.63142 18.8546 5.76375 18.7965C6.04383 18.6735 6.48291 18.6061 7.03421 18.5487C7.12534 18.5392 7.22003 18.5299 7.31675 18.5205L7.31734 18.5205L7.31774 18.5204C7.75337 18.478 8.22986 18.4315 8.60602 18.3399C8.83695 18.2837 9.10046 18.1956 9.31444 18.0333C9.55604 17.8501 9.73703 17.5659 9.72457 17.1949C9.71117 16.7955 9.50249 16.4807 9.2559 16.2553C9.01235 16.0327 8.69774 15.863 8.36729 15.7333C7.70363 15.4729 6.85166 15.3254 6.00343 15.2972C5.15473 15.2689 4.25916 15.3581 3.51015 15.6085C2.78202 15.852 2.05623 16.2973 1.79807 17.0767C1.6963 17.3839 1.86287 17.7155 2.1701 17.8173C2.47733 17.919 2.80889 17.7525 2.91065 17.4452C2.99481 17.1912 3.28176 16.9207 3.8818 16.7201C4.46096 16.5264 5.209 16.4433 5.96437 16.4685C6.7202 16.4937 7.43275 16.6256 7.93908 16.8243C8.19363 16.9243 8.36538 17.0292 8.46519 17.1204C8.4773 17.1315 8.4878 17.1419 8.49689 17.1515C8.45501 17.1668 8.39992 17.1838 8.3287 17.2012C8.04154 17.2711 7.67478 17.3072 7.24492 17.3496L7.24413 17.3497L7.24246 17.3498C7.13635 17.3603 7.02639 17.3711 6.91284 17.3829C6.38763 17.4376 5.76632 17.5153 5.29238 17.7234C5.0477 17.8309 4.78839 17.9954 4.60986 18.2599C4.42009 18.541 4.36482 18.8707 4.42432 19.213C4.49899 19.6426 4.83826 19.9534 5.11763 20.15C5.42736 20.368 5.81812 20.5533 6.22607 20.6935C7.01783 20.9656 8.03865 21.1226 8.82239 20.8811C9.248 20.7499 9.52379 20.5096 9.73004 20.285C9.79974 20.2091 9.87042 20.1246 9.92975 20.0536L9.92977 20.0536L9.92995 20.0534C9.9503 20.0291 9.96932 20.0063 9.98649 19.9861C10.0618 19.8973 10.1248 19.8281 10.1905 19.7694C10.3069 19.6651 10.4472 19.579 10.6908 19.5395C10.9181 19.5027 11.2529 19.5041 11.7567 19.6004Z"
21
-
fill="currentColor"
22
-
/>
23
-
</svg>
24
-
);
25
-
};
+19
components/Icons/UnpublishSmall.tsx
+19
components/Icons/UnpublishSmall.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const UnpublishSmall = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="24"
7
+
height="24"
8
+
viewBox="0 0 24 24"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
{...props}
12
+
>
13
+
<path
14
+
d="M15.5207 11.5526C15.9624 11.2211 16.5896 11.3101 16.9211 11.7518L18.9162 14.411L21.5754 12.4158C22.017 12.0845 22.6433 12.1735 22.9748 12.6151C23.306 13.0568 23.2172 13.684 22.7756 14.0155L20.1164 16.0106L22.1115 18.6698C22.4425 19.1114 22.3537 19.7378 21.9123 20.0692C21.4707 20.4006 20.8434 20.3114 20.5119 19.87L18.5168 17.2108L15.8576 19.2059C15.416 19.537 14.7897 19.4479 14.4582 19.0067C14.1267 18.565 14.2158 17.9378 14.6574 17.6063L17.3166 15.6112L15.3215 12.952C14.9902 12.5103 15.0792 11.8841 15.5207 11.5526ZM12.2062 4.29378C13.7932 3.59008 15.5128 3.49569 16.9767 4.29769C19.1391 5.48261 19.9471 8.15954 19.5314 10.8885C19.4793 11.2296 19.1606 11.4638 18.8195 11.4119C18.4786 11.3598 18.2444 11.042 18.2961 10.701C18.669 8.25384 17.8985 6.22855 16.3761 5.39436C15.5192 4.92484 14.4833 4.85746 13.4006 5.1805C13.3522 5.21491 13.3004 5.24633 13.2414 5.26644C13.0411 5.33451 12.8498 5.39707 12.6662 5.45686C12.6176 5.47894 12.5684 5.50065 12.5197 5.52425C11.1279 6.19898 9.77207 7.47892 8.81657 9.22249C7.86108 10.9662 7.51225 12.7985 7.69254 14.3348C7.87314 15.8723 8.57043 17.0593 9.65739 17.6551C10.3281 18.0226 11.1012 18.1431 11.9211 18.0272C12.2625 17.9791 12.5786 18.2161 12.6271 18.5575C12.6754 18.8992 12.4375 19.216 12.0959 19.2645C11.0448 19.4131 9.99397 19.2653 9.0568 18.7518C7.96346 18.1527 7.21589 17.1633 6.79801 15.9862C6.74111 15.914 6.69783 15.829 6.67692 15.7332C6.5875 15.3237 6.4571 14.8734 6.30387 14.4188C6.00205 14.7748 5.69607 15.0308 5.37419 15.1834C5.04355 15.3401 4.70719 15.3838 4.38102 15.327C4.06576 15.272 3.79527 15.129 3.57145 14.9696C2.96057 14.5342 2.36597 14.0627 1.89274 13.5487C1.4209 13.036 1.0333 12.4423 0.8986 11.7596C0.842171 11.4736 0.768809 11.1336 0.89274 10.5985C0.997303 10.1475 1.23987 9.57405 1.69059 8.73226L1.60758 8.66585C1.60246 8.66173 1.59696 8.65743 1.59196 8.65315C1.16612 8.2884 1.07023 7.69032 1.08708 7.21468C1.1054 6.69843 1.25893 6.12189 1.54411 5.6014C1.81576 5.10576 2.17253 4.65997 2.58903 4.35433C3.00424 4.04981 3.53772 3.84664 4.10661 3.97737C4.12165 3.98084 4.13775 3.98453 4.15251 3.98909L5.22575 4.3221C5.62556 4.21028 6.05447 4.1958 6.48747 4.32015L6.54801 4.34065L6.54997 4.34163C6.55156 4.34227 6.55431 4.34319 6.55778 4.34456C6.56529 4.34752 6.57742 4.35226 6.59294 4.35823C6.62402 4.3702 6.67024 4.3877 6.72868 4.40901C6.84618 4.45186 7.01173 4.50951 7.20133 4.56819C7.59399 4.6897 8.04168 4.79978 8.382 4.81624C9.99154 4.89405 10.8568 4.72942 12.2062 4.29378ZM12.5441 6.13655C13.7669 5.47408 15.1231 5.29219 16.256 5.91292C17.1747 6.41641 17.7296 7.33256 17.9572 8.39729C18.0148 8.66723 17.8433 8.93322 17.5734 8.99104C17.3035 9.04869 17.0375 8.8771 16.9797 8.60726C16.7956 7.74535 16.3745 7.11819 15.7756 6.78987C15.0408 6.38732 14.0621 6.45197 13.0216 7.01546C12.7704 7.15159 12.5186 7.31527 12.2716 7.50472C13.0464 8.19627 13.6187 8.92334 13.9347 9.64632C14.2881 10.4549 14.3328 11.2901 13.9328 12.0203C13.5333 12.7492 12.7922 13.1542 11.9211 13.2918C11.1394 13.4153 10.2177 13.3313 9.2277 13.0614C9.20118 13.3705 9.19947 13.6697 9.21989 13.9539C9.30483 15.1342 9.77626 15.9936 10.5109 16.3963C10.8983 16.6086 11.346 16.6898 11.8351 16.6405C12.1098 16.6128 12.3552 16.8131 12.383 17.0877C12.4107 17.3624 12.2103 17.6077 11.9357 17.6356C11.2725 17.7026 10.6177 17.5951 10.0304 17.2733C8.89778 16.6525 8.32161 15.4121 8.22184 14.0252C8.12182 12.6321 8.49018 11.0188 9.32243 9.49983C10.1548 7.98089 11.316 6.80199 12.5441 6.13655ZM2.67204 9.54866C2.32412 10.2204 2.17134 10.6184 2.11051 10.8807C2.04887 11.1469 2.07605 11.2695 2.12516 11.5184C2.19851 11.8898 2.4242 12.2809 2.81169 12.702C3.1981 13.1217 3.71082 13.5349 4.29606 13.952C4.42383 14.043 4.52152 14.0826 4.59489 14.0955C4.65746 14.1064 4.73234 14.1036 4.83805 14.0535C5.04286 13.9565 5.35376 13.6844 5.76383 13.035C5.42543 12.2826 5.08809 11.7185 4.84391 11.4735C4.57886 11.2075 4.20518 10.9304 3.87907 10.7108C3.71974 10.6035 3.57875 10.514 3.4777 10.452C3.42724 10.421 3.3866 10.3967 3.35954 10.3807C3.34614 10.3728 3.33581 10.366 3.32926 10.3621L3.32047 10.3582C3.29879 10.3457 3.278 10.3312 3.25797 10.3162C2.98299 10.1101 2.79521 9.83996 2.67204 9.54866ZM11.5216 8.17561C11.0336 8.67806 10.5807 9.28455 10.1994 9.9803C9.81804 10.6763 9.54956 11.3844 9.38883 12.0662C10.3261 12.3341 11.1364 12.4037 11.7648 12.3045C12.4323 12.1991 12.8487 11.9177 13.0558 11.5399C13.2683 11.1518 13.2832 10.6541 13.0177 10.0467C12.7657 9.47024 12.2702 8.82723 11.5216 8.17561ZM9.63883 6.07112C9.45477 6.07962 9.26355 6.08427 9.06266 6.08382C9.01613 6.11598 8.96536 6.1545 8.91032 6.20003C8.71163 6.36444 8.4977 6.58912 8.28434 6.84651C7.85781 7.36118 7.46925 7.96403 7.24626 8.37093C6.99703 8.82575 6.71681 9.39869 6.51969 9.97542C6.34987 10.4725 6.25688 10.9316 6.26969 11.3055C6.3691 11.4655 6.46736 11.6376 6.56266 11.8182C6.76355 10.7536 7.14751 9.66653 7.71989 8.6219C8.25537 7.64475 8.9105 6.78559 9.63883 6.07112ZM6.12516 5.51741C5.92665 5.46415 5.72213 5.47396 5.50895 5.54378C5.15736 5.78936 4.57147 6.28659 4.28727 6.81136C3.94853 7.43736 3.7629 8.31657 3.71598 8.67561C3.71568 8.67793 3.71436 8.68015 3.71403 8.68245C3.72929 8.72056 3.74152 8.76064 3.74919 8.80257C3.79805 9.07007 3.89591 9.222 3.99626 9.30354L3.99723 9.3055C4.02922 9.32447 4.07496 9.35213 4.13102 9.38655C4.24364 9.45571 4.40052 9.5546 4.57731 9.67366C4.82014 9.83722 5.11483 10.0498 5.39079 10.283C5.44136 10.068 5.50384 9.85578 5.5734 9.65218C5.79598 9.00089 6.10514 8.37255 6.3693 7.89046C6.61869 7.4354 7.0422 6.77704 7.51481 6.20686C7.57748 6.13127 7.64175 6.05648 7.70719 5.98323C7.39142 5.92263 7.08276 5.84103 6.83219 5.76351C6.61847 5.69737 6.43222 5.63106 6.29997 5.58284C6.23424 5.55887 6.1809 5.53953 6.14372 5.52522C6.13705 5.52265 6.1308 5.51963 6.12516 5.51741ZM3.81559 5.19319C3.71663 5.17448 3.55572 5.19609 3.32926 5.36214C3.09558 5.53353 2.84889 5.82236 2.64079 6.20198C2.4462 6.55708 2.34736 6.94361 2.3361 7.25862C2.3235 7.61435 2.42004 7.7163 2.40446 7.70296L2.81657 8.03304C2.92255 7.54286 3.11192 6.88062 3.40739 6.33479C3.61396 5.95324 3.91707 5.60514 4.21794 5.31722L3.81559 5.19319Z"
15
+
fill="currentColor"
16
+
/>
17
+
</svg>
18
+
);
19
+
};
+18
-1
components/IdentityProvider.tsx
+18
-1
components/IdentityProvider.tsx
···
4
4
import useSWR, { KeyedMutator, mutate } from "swr";
5
5
import { DashboardState } from "./PageLayouts/DashboardLayout";
6
6
import { supabaseBrowserClient } from "supabase/browserClient";
7
+
import { produce, Draft } from "immer";
7
8
8
9
export type InterfaceState = {
9
10
dashboards: { [id: string]: DashboardState | undefined };
10
11
};
11
-
type Identity = Awaited<ReturnType<typeof getIdentityData>>;
12
+
export type Identity = Awaited<ReturnType<typeof getIdentityData>>;
12
13
let IdentityContext = createContext({
13
14
identity: null as Identity,
14
15
mutate: (() => {}) as KeyedMutator<Identity>,
15
16
});
16
17
export const useIdentityData = () => useContext(IdentityContext);
18
+
19
+
export function mutateIdentityData(
20
+
mutate: KeyedMutator<Identity>,
21
+
recipe: (draft: Draft<NonNullable<Identity>>) => void,
22
+
) {
23
+
mutate(
24
+
(data) => {
25
+
if (!data) return data;
26
+
return produce(data, recipe);
27
+
},
28
+
{ revalidate: false },
29
+
);
30
+
}
17
31
export function IdentityContextProvider(props: {
18
32
children: React.ReactNode;
19
33
initialValue: Identity;
···
21
35
let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), {
22
36
fallbackData: props.initialValue,
23
37
});
38
+
useEffect(() => {
39
+
mutate(props.initialValue);
40
+
}, [props.initialValue]);
24
41
useEffect(() => {
25
42
if (!identity?.atp_did) return;
26
43
let supabase = supabaseBrowserClient();
+11
-36
components/Input.tsx
+11
-36
components/Input.tsx
···
2
2
import { useEffect, useRef, useState, type JSX } from "react";
3
3
import { onMouseDown } from "src/utils/iosInputMouseDown";
4
4
import { isIOS } from "src/utils/isDevice";
5
+
import { focusElement } from "src/utils/focusElement";
5
6
6
7
export const Input = (
7
8
props: {
···
58
59
);
59
60
};
60
61
61
-
export const focusElement = (el?: HTMLInputElement | null) => {
62
-
if (!isIOS()) {
63
-
el?.focus();
64
-
return;
65
-
}
66
-
67
-
let fakeInput = document.createElement("input");
68
-
fakeInput.setAttribute("type", "text");
69
-
fakeInput.style.position = "fixed";
70
-
fakeInput.style.height = "0px";
71
-
fakeInput.style.width = "0px";
72
-
fakeInput.style.fontSize = "16px"; // disable auto zoom
73
-
document.body.appendChild(fakeInput);
74
-
fakeInput.focus();
75
-
setTimeout(() => {
76
-
if (!el) return;
77
-
el.style.transform = "translateY(-2000px)";
78
-
el?.focus();
79
-
fakeInput.remove();
80
-
el.value = " ";
81
-
el.setSelectionRange(1, 1);
82
-
requestAnimationFrame(() => {
83
-
if (el) {
84
-
el.style.transform = "";
85
-
}
86
-
});
87
-
setTimeout(() => {
88
-
if (!el) return;
89
-
el.value = "";
90
-
el.setSelectionRange(0, 0);
91
-
}, 50);
92
-
}, 20);
93
-
};
94
-
95
62
export const InputWithLabel = (
96
63
props: {
97
64
label: string;
···
100
67
JSX.IntrinsicElements["textarea"],
101
68
) => {
102
69
let { label, textarea, ...inputProps } = props;
103
-
let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`;
70
+
let style = `
71
+
appearance-none resize-none w-full
72
+
bg-transparent
73
+
outline-hidden focus:outline-0
74
+
font-normal not-italic text-base text-primary disabled:text-tertiary
75
+
disabled:cursor-not-allowed
76
+
${props.className}`;
104
77
return (
105
-
<label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!">
78
+
<label
79
+
className={`input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]! ${props.disabled && "bg-border-light! cursor-not-allowed! hover:border-border!"}`}
80
+
>
106
81
{props.label}
107
82
{textarea ? (
108
83
<textarea {...inputProps} className={style} />
+114
components/InteractionsPreview.tsx
+114
components/InteractionsPreview.tsx
···
1
+
"use client";
2
+
import { Separator } from "./Layout";
3
+
import { CommentTiny } from "./Icons/CommentTiny";
4
+
import { QuoteTiny } from "./Icons/QuoteTiny";
5
+
import { useSmoker } from "./Toast";
6
+
import { Tag } from "./Tags";
7
+
import { Popover } from "./Popover";
8
+
import { TagTiny } from "./Icons/TagTiny";
9
+
import { SpeedyLink } from "./SpeedyLink";
10
+
11
+
export const InteractionPreview = (props: {
12
+
quotesCount: number;
13
+
commentsCount: number;
14
+
tags?: string[];
15
+
postUrl: string;
16
+
showComments: boolean | undefined;
17
+
share?: boolean;
18
+
}) => {
19
+
let smoker = useSmoker();
20
+
let interactionsAvailable =
21
+
props.quotesCount > 0 ||
22
+
(props.showComments !== false && props.commentsCount > 0);
23
+
24
+
const tagsCount = props.tags?.length || 0;
25
+
26
+
return (
27
+
<div
28
+
className={`flex gap-2 text-tertiary text-sm items-center self-start`}
29
+
>
30
+
{tagsCount === 0 ? null : (
31
+
<>
32
+
<TagPopover tags={props.tags!} />
33
+
{interactionsAvailable || props.share ? (
34
+
<Separator classname="h-4!" />
35
+
) : null}
36
+
</>
37
+
)}
38
+
39
+
{props.quotesCount === 0 ? null : (
40
+
<SpeedyLink
41
+
aria-label="Post quotes"
42
+
href={`${props.postUrl}?interactionDrawer=quotes`}
43
+
className="flex flex-row gap-1 text-sm items-center text-accent-contrast!"
44
+
>
45
+
<QuoteTiny /> {props.quotesCount}
46
+
</SpeedyLink>
47
+
)}
48
+
{props.showComments === false || props.commentsCount === 0 ? null : (
49
+
<SpeedyLink
50
+
aria-label="Post comments"
51
+
href={`${props.postUrl}?interactionDrawer=comments`}
52
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
53
+
>
54
+
<CommentTiny /> {props.commentsCount}
55
+
</SpeedyLink>
56
+
)}
57
+
{interactionsAvailable && props.share ? (
58
+
<Separator classname="h-4! !min-h-0" />
59
+
) : null}
60
+
{props.share && (
61
+
<>
62
+
<button
63
+
id={`copy-post-link-${props.postUrl}`}
64
+
className="flex gap-1 items-center hover:text-accent-contrast relative"
65
+
onClick={(e) => {
66
+
e.stopPropagation();
67
+
e.preventDefault();
68
+
let mouseX = e.clientX;
69
+
let mouseY = e.clientY;
70
+
71
+
if (!props.postUrl) return;
72
+
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
73
+
74
+
smoker({
75
+
text: <strong>Copied Link!</strong>,
76
+
position: {
77
+
y: mouseY,
78
+
x: mouseX,
79
+
},
80
+
});
81
+
}}
82
+
>
83
+
Share
84
+
</button>
85
+
</>
86
+
)}
87
+
</div>
88
+
);
89
+
};
90
+
91
+
const TagPopover = (props: { tags: string[] }) => {
92
+
return (
93
+
<Popover
94
+
className="p-2! max-w-xs"
95
+
trigger={
96
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast ">
97
+
<TagTiny /> {props.tags.length}
98
+
</div>
99
+
}
100
+
>
101
+
<TagList tags={props.tags} className="text-secondary!" />
102
+
</Popover>
103
+
);
104
+
};
105
+
106
+
const TagList = (props: { tags: string[]; className?: string }) => {
107
+
return (
108
+
<div className="flex gap-1 flex-wrap">
109
+
{props.tags.map((tag, index) => (
110
+
<Tag name={tag} key={index} className={props.className} />
111
+
))}
112
+
</div>
113
+
);
114
+
};
+3
-4
components/Layout.tsx
+3
-4
components/Layout.tsx
···
1
+
"use client";
1
2
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2
3
import { theme } from "tailwind.config";
3
4
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
4
5
import { PopoverArrow } from "./Icons/PopoverArrow";
5
-
import { PopoverOpenContext } from "./Popover";
6
+
import { PopoverOpenContext } from "./Popover/PopoverContext";
6
7
import { useState } from "react";
7
8
8
9
export const Separator = (props: { classname?: string }) => {
9
-
return (
10
-
<div className={`min-h-full border-r border-border ${props.classname}`} />
11
-
);
10
+
return <div className={`h-full border-r border-border ${props.classname}`} />;
12
11
};
13
12
14
13
export const Menu = (props: {
+543
components/Mention.tsx
+543
components/Mention.tsx
···
1
+
"use client";
2
+
import { Agent } from "@atproto/api";
3
+
import { useState, useEffect, Fragment, useRef, useCallback } from "react";
4
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
5
+
import * as Popover from "@radix-ui/react-popover";
6
+
import { EditorView } from "prosemirror-view";
7
+
import { callRPC } from "app/api/rpc/client";
8
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
9
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
10
+
import { SearchTiny } from "components/Icons/SearchTiny";
11
+
import { CloseTiny } from "./Icons/CloseTiny";
12
+
import { GoToArrow } from "./Icons/GoToArrow";
13
+
import { GoBackTiny } from "./Icons/GoBackTiny";
14
+
15
+
export function MentionAutocomplete(props: {
16
+
open: boolean;
17
+
onOpenChange: (open: boolean) => void;
18
+
view: React.RefObject<EditorView | null>;
19
+
onSelect: (mention: Mention) => void;
20
+
coords: { top: number; left: number } | null;
21
+
placeholder?: string;
22
+
}) {
23
+
const [searchQuery, setSearchQuery] = useState("");
24
+
const [noResults, setNoResults] = useState(false);
25
+
const inputRef = useRef<HTMLInputElement>(null);
26
+
const contentRef = useRef<HTMLDivElement>(null);
27
+
28
+
const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } =
29
+
useMentionSuggestions(searchQuery);
30
+
31
+
// Clear search when scope changes
32
+
const handleScopeChange = useCallback(
33
+
(newScope: MentionScope) => {
34
+
setSearchQuery("");
35
+
setSuggestionIndex(0);
36
+
setScope(newScope);
37
+
},
38
+
[setScope, setSuggestionIndex],
39
+
);
40
+
41
+
// Focus input when opened
42
+
useEffect(() => {
43
+
if (props.open && inputRef.current) {
44
+
// Small delay to ensure the popover is mounted
45
+
setTimeout(() => inputRef.current?.focus(), 0);
46
+
}
47
+
}, [props.open]);
48
+
49
+
// Reset state when closed
50
+
useEffect(() => {
51
+
if (!props.open) {
52
+
setSearchQuery("");
53
+
setScope({ type: "default" });
54
+
setSuggestionIndex(0);
55
+
setNoResults(false);
56
+
}
57
+
}, [props.open, setScope, setSuggestionIndex]);
58
+
59
+
// Handle timeout for showing "No results found"
60
+
useEffect(() => {
61
+
if (searchQuery && suggestions.length === 0) {
62
+
setNoResults(false);
63
+
const timer = setTimeout(() => {
64
+
setNoResults(true);
65
+
}, 2000);
66
+
return () => clearTimeout(timer);
67
+
} else {
68
+
setNoResults(false);
69
+
}
70
+
}, [searchQuery, suggestions.length]);
71
+
72
+
// Handle keyboard navigation
73
+
const handleKeyDown = (e: React.KeyboardEvent) => {
74
+
if (e.key === "Escape") {
75
+
e.preventDefault();
76
+
props.onOpenChange(false);
77
+
props.view.current?.focus();
78
+
return;
79
+
}
80
+
81
+
if (e.key === "Backspace" && searchQuery === "") {
82
+
// Backspace at the start of input closes autocomplete and refocuses editor
83
+
e.preventDefault();
84
+
props.onOpenChange(false);
85
+
props.view.current?.focus();
86
+
return;
87
+
}
88
+
89
+
// Reverse arrow key direction when popover is rendered above
90
+
const isReversed = contentRef.current?.dataset.side === "top";
91
+
const upKey = isReversed ? "ArrowDown" : "ArrowUp";
92
+
const downKey = isReversed ? "ArrowUp" : "ArrowDown";
93
+
94
+
if (e.key === upKey) {
95
+
e.preventDefault();
96
+
if (suggestionIndex > 0) {
97
+
setSuggestionIndex((i) => i - 1);
98
+
}
99
+
} else if (e.key === downKey) {
100
+
e.preventDefault();
101
+
if (suggestionIndex < suggestions.length - 1) {
102
+
setSuggestionIndex((i) => i + 1);
103
+
}
104
+
} else if (e.key === "Tab") {
105
+
const selectedSuggestion = suggestions[suggestionIndex];
106
+
if (selectedSuggestion?.type === "publication") {
107
+
e.preventDefault();
108
+
handleScopeChange({
109
+
type: "publication",
110
+
uri: selectedSuggestion.uri,
111
+
name: selectedSuggestion.name,
112
+
});
113
+
}
114
+
} else if (e.key === "Enter") {
115
+
e.preventDefault();
116
+
const selectedSuggestion = suggestions[suggestionIndex];
117
+
if (selectedSuggestion) {
118
+
props.onSelect(selectedSuggestion);
119
+
props.onOpenChange(false);
120
+
}
121
+
} else if (
122
+
e.key === " " &&
123
+
searchQuery === "" &&
124
+
scope.type === "default"
125
+
) {
126
+
// Space immediately after opening closes the autocomplete
127
+
e.preventDefault();
128
+
props.onOpenChange(false);
129
+
// Insert a space after the @ in the editor
130
+
if (props.view.current) {
131
+
const view = props.view.current;
132
+
const tr = view.state.tr.insertText(" ");
133
+
view.dispatch(tr);
134
+
view.focus();
135
+
}
136
+
}
137
+
};
138
+
139
+
if (!props.open || !props.coords) return null;
140
+
141
+
const getHeader = (type: Mention["type"], scope?: MentionScope) => {
142
+
switch (type) {
143
+
case "did":
144
+
return "People";
145
+
case "publication":
146
+
return "Publications";
147
+
case "post":
148
+
if (scope) {
149
+
return (
150
+
<ScopeHeader
151
+
scope={scope}
152
+
handleScopeChange={() => {
153
+
handleScopeChange({ type: "default" });
154
+
}}
155
+
/>
156
+
);
157
+
} else return "Posts";
158
+
}
159
+
};
160
+
161
+
const sortedSuggestions = [...suggestions].sort((a, b) => {
162
+
const order: Mention["type"][] = ["did", "publication", "post"];
163
+
return order.indexOf(a.type) - order.indexOf(b.type);
164
+
});
165
+
166
+
return (
167
+
<Popover.Root open>
168
+
<Popover.Anchor
169
+
style={{
170
+
top: props.coords.top - 24,
171
+
left: props.coords.left,
172
+
height: 24,
173
+
position: "absolute",
174
+
}}
175
+
/>
176
+
<Popover.Portal>
177
+
<Popover.Content
178
+
ref={contentRef}
179
+
align="start"
180
+
sideOffset={4}
181
+
collisionPadding={32}
182
+
onOpenAutoFocus={(e) => e.preventDefault()}
183
+
className={`dropdownMenu group/mention-menu z-20 bg-bg-page
184
+
flex data-[side=top]:flex-col-reverse flex-col
185
+
p-1 gap-1 text-primary
186
+
border border-border rounded-md shadow-md
187
+
sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width)
188
+
max-h-(--radix-popover-content-available-height)
189
+
overflow-hidden`}
190
+
>
191
+
{/* Dropdown Header - sticky */}
192
+
<div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0">
193
+
<div className="flex items-center gap-1 flex-1 min-w-0 text-primary">
194
+
<div className="text-tertiary">
195
+
<SearchTiny className="w-4 h-4 shrink-0" />
196
+
</div>
197
+
<input
198
+
ref={inputRef}
199
+
size={100}
200
+
type="text"
201
+
value={searchQuery}
202
+
onChange={(e) => {
203
+
setSearchQuery(e.target.value);
204
+
setSuggestionIndex(0);
205
+
}}
206
+
onKeyDown={handleKeyDown}
207
+
autoFocus
208
+
placeholder={
209
+
scope.type === "publication"
210
+
? "Search posts..."
211
+
: props.placeholder ?? "Search people & publications..."
212
+
}
213
+
className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary"
214
+
/>
215
+
</div>
216
+
</div>
217
+
<div className="overflow-y-auto flex-1 min-h-0">
218
+
{sortedSuggestions.length === 0 && noResults && (
219
+
<div className="text-sm text-tertiary italic px-3 py-1 text-center">
220
+
No results found
221
+
</div>
222
+
)}
223
+
<ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse">
224
+
{sortedSuggestions.map((result, index) => {
225
+
const prevResult = sortedSuggestions[index - 1];
226
+
const showHeader =
227
+
index === 0 ||
228
+
(prevResult && prevResult.type !== result.type);
229
+
230
+
return (
231
+
<Fragment
232
+
key={result.type === "did" ? result.did : result.uri}
233
+
>
234
+
{showHeader && (
235
+
<>
236
+
{index > 0 && (
237
+
<hr className="border-border-light mx-1 my-1" />
238
+
)}
239
+
<div className="text-xs text-tertiary font-bold pt-1 px-2">
240
+
{getHeader(result.type, scope)}
241
+
</div>
242
+
</>
243
+
)}
244
+
{result.type === "did" ? (
245
+
<DidResult
246
+
onClick={() => {
247
+
props.onSelect(result);
248
+
props.onOpenChange(false);
249
+
}}
250
+
onMouseDown={(e) => e.preventDefault()}
251
+
displayName={result.displayName}
252
+
handle={result.handle}
253
+
avatar={result.avatar}
254
+
selected={index === suggestionIndex}
255
+
/>
256
+
) : result.type === "publication" ? (
257
+
<PublicationResult
258
+
onClick={() => {
259
+
props.onSelect(result);
260
+
props.onOpenChange(false);
261
+
}}
262
+
onMouseDown={(e) => e.preventDefault()}
263
+
pubName={result.name}
264
+
uri={result.uri}
265
+
selected={index === suggestionIndex}
266
+
onPostsClick={() => {
267
+
handleScopeChange({
268
+
type: "publication",
269
+
uri: result.uri,
270
+
name: result.name,
271
+
});
272
+
}}
273
+
/>
274
+
) : (
275
+
<PostResult
276
+
onClick={() => {
277
+
props.onSelect(result);
278
+
props.onOpenChange(false);
279
+
}}
280
+
onMouseDown={(e) => e.preventDefault()}
281
+
title={result.title}
282
+
selected={index === suggestionIndex}
283
+
/>
284
+
)}
285
+
</Fragment>
286
+
);
287
+
})}
288
+
</ul>
289
+
</div>
290
+
</Popover.Content>
291
+
</Popover.Portal>
292
+
</Popover.Root>
293
+
);
294
+
}
295
+
296
+
const Result = (props: {
297
+
result: React.ReactNode;
298
+
subtext?: React.ReactNode;
299
+
icon?: React.ReactNode;
300
+
onClick: () => void;
301
+
onMouseDown: (e: React.MouseEvent) => void;
302
+
selected?: boolean;
303
+
}) => {
304
+
return (
305
+
<button
306
+
className={`
307
+
menuItem w-full flex-row! gap-2!
308
+
text-secondary leading-snug text-sm
309
+
${props.subtext ? "py-1!" : "py-2!"}
310
+
${props.selected ? "bg-[var(--accent-light)]!" : ""}`}
311
+
onClick={() => {
312
+
props.onClick();
313
+
}}
314
+
onMouseDown={(e) => props.onMouseDown(e)}
315
+
>
316
+
{props.icon}
317
+
<div className="flex flex-col min-w-0 flex-1">
318
+
<div
319
+
className={`flex gap-2 items-center w-full truncate justify-between`}
320
+
>
321
+
{props.result}
322
+
</div>
323
+
{props.subtext && (
324
+
<div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]">
325
+
{props.subtext}
326
+
</div>
327
+
)}
328
+
</div>
329
+
</button>
330
+
);
331
+
};
332
+
333
+
const ScopeButton = (props: {
334
+
onClick: () => void;
335
+
children: React.ReactNode;
336
+
}) => {
337
+
return (
338
+
<span
339
+
className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer"
340
+
onClick={(e) => {
341
+
e.preventDefault();
342
+
e.stopPropagation();
343
+
props.onClick();
344
+
}}
345
+
onMouseDown={(e) => {
346
+
e.preventDefault();
347
+
e.stopPropagation();
348
+
}}
349
+
>
350
+
{props.children} <ArrowRightTiny className="scale-80" />
351
+
</span>
352
+
);
353
+
};
354
+
355
+
const DidResult = (props: {
356
+
displayName?: string;
357
+
handle: string;
358
+
avatar?: string;
359
+
onClick: () => void;
360
+
onMouseDown: (e: React.MouseEvent) => void;
361
+
selected?: boolean;
362
+
}) => {
363
+
return (
364
+
<Result
365
+
icon={
366
+
props.avatar ? (
367
+
<img
368
+
src={props.avatar}
369
+
alt=""
370
+
className="w-5 h-5 rounded-full shrink-0"
371
+
/>
372
+
) : (
373
+
<div className="w-5 h-5 rounded-full bg-border shrink-0" />
374
+
)
375
+
}
376
+
result={props.displayName ? props.displayName : props.handle}
377
+
subtext={props.displayName && `@${props.handle}`}
378
+
onClick={props.onClick}
379
+
onMouseDown={props.onMouseDown}
380
+
selected={props.selected}
381
+
/>
382
+
);
383
+
};
384
+
385
+
const PublicationResult = (props: {
386
+
pubName: string;
387
+
uri: string;
388
+
onClick: () => void;
389
+
onMouseDown: (e: React.MouseEvent) => void;
390
+
selected?: boolean;
391
+
onPostsClick: () => void;
392
+
}) => {
393
+
return (
394
+
<Result
395
+
icon={
396
+
<img
397
+
src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`}
398
+
alt=""
399
+
className="w-5 h-5 rounded-full shrink-0"
400
+
/>
401
+
}
402
+
result={
403
+
<>
404
+
<div className="truncate w-full grow min-w-0">{props.pubName}</div>
405
+
<ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton>
406
+
</>
407
+
}
408
+
onClick={props.onClick}
409
+
onMouseDown={props.onMouseDown}
410
+
selected={props.selected}
411
+
/>
412
+
);
413
+
};
414
+
415
+
const PostResult = (props: {
416
+
title: string;
417
+
onClick: () => void;
418
+
onMouseDown: (e: React.MouseEvent) => void;
419
+
selected?: boolean;
420
+
}) => {
421
+
return (
422
+
<Result
423
+
result={<div className="truncate w-full">{props.title}</div>}
424
+
onClick={props.onClick}
425
+
onMouseDown={props.onMouseDown}
426
+
selected={props.selected}
427
+
/>
428
+
);
429
+
};
430
+
431
+
const ScopeHeader = (props: {
432
+
scope: MentionScope;
433
+
handleScopeChange: () => void;
434
+
}) => {
435
+
if (props.scope.type === "default") return;
436
+
if (props.scope.type === "publication")
437
+
return (
438
+
<button
439
+
className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs"
440
+
onClick={() => props.handleScopeChange()}
441
+
onMouseDown={(e) => e.preventDefault()}
442
+
>
443
+
<GoBackTiny className="shrink-0 " />
444
+
445
+
<div className="grow w-full truncate text-left">
446
+
Posts from {props.scope.name}
447
+
</div>
448
+
</button>
449
+
);
450
+
};
451
+
452
+
export type Mention =
453
+
| {
454
+
type: "did";
455
+
handle: string;
456
+
did: string;
457
+
displayName?: string;
458
+
avatar?: string;
459
+
}
460
+
| { type: "publication"; uri: string; name: string; url: string }
461
+
| { type: "post"; uri: string; title: string; url: string };
462
+
463
+
export type MentionScope =
464
+
| { type: "default" }
465
+
| { type: "publication"; uri: string; name: string };
466
+
function useMentionSuggestions(query: string | null) {
467
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
468
+
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
469
+
const [scope, setScope] = useState<MentionScope>({ type: "default" });
470
+
471
+
// Clear suggestions immediately when scope changes
472
+
const setScopeAndClear = useCallback((newScope: MentionScope) => {
473
+
setSuggestions([]);
474
+
setScope(newScope);
475
+
}, []);
476
+
477
+
useDebouncedEffect(
478
+
async () => {
479
+
if (!query && scope.type === "default") {
480
+
setSuggestions([]);
481
+
return;
482
+
}
483
+
484
+
if (scope.type === "publication") {
485
+
// Search within the publication's documents
486
+
const documents = await callRPC(`search_publication_documents`, {
487
+
publication_uri: scope.uri,
488
+
query: query || "",
489
+
limit: 10,
490
+
});
491
+
setSuggestions(
492
+
documents.result.documents.map((d) => ({
493
+
type: "post" as const,
494
+
uri: d.uri,
495
+
title: d.title,
496
+
url: d.url,
497
+
})),
498
+
);
499
+
} else {
500
+
// Default scope: search people and publications
501
+
const agent = new Agent("https://public.api.bsky.app");
502
+
const [result, publications] = await Promise.all([
503
+
agent.searchActorsTypeahead({
504
+
q: query || "",
505
+
limit: 8,
506
+
}),
507
+
callRPC(`search_publication_names`, { query: query || "", limit: 8 }),
508
+
]);
509
+
setSuggestions([
510
+
...result.data.actors.map((actor) => ({
511
+
type: "did" as const,
512
+
handle: actor.handle,
513
+
did: actor.did,
514
+
displayName: actor.displayName,
515
+
avatar: actor.avatar,
516
+
})),
517
+
...publications.result.publications.map((p) => ({
518
+
type: "publication" as const,
519
+
uri: p.uri,
520
+
name: p.name,
521
+
url: p.url,
522
+
})),
523
+
]);
524
+
}
525
+
},
526
+
300,
527
+
[query, scope],
528
+
);
529
+
530
+
useEffect(() => {
531
+
if (suggestionIndex > suggestions.length - 1) {
532
+
setSuggestionIndex(Math.max(0, suggestions.length - 1));
533
+
}
534
+
}, [suggestionIndex, suggestions.length]);
535
+
536
+
return {
537
+
suggestions,
538
+
suggestionIndex,
539
+
setSuggestionIndex,
540
+
scope,
541
+
setScope: setScopeAndClear,
542
+
};
543
+
}
+27
-23
components/PageLayouts/DashboardLayout.tsx
+27
-23
components/PageLayouts/DashboardLayout.tsx
···
33
33
drafts: boolean;
34
34
published: boolean;
35
35
docs: boolean;
36
-
templates: boolean;
36
+
archived: boolean;
37
37
};
38
38
};
39
39
···
45
45
const defaultDashboardState: DashboardState = {
46
46
display: undefined,
47
47
sort: undefined,
48
-
filter: { drafts: false, published: false, docs: false, templates: false },
48
+
filter: {
49
+
drafts: false,
50
+
published: false,
51
+
docs: false,
52
+
archived: false,
53
+
},
49
54
};
50
55
51
56
export const useDashboardStore = create<DashboardStore>((set, get) => ({
···
255
260
hasBackgroundImage: boolean;
256
261
defaultDisplay: Exclude<DashboardState["display"], undefined>;
257
262
hasPubs: boolean;
258
-
hasTemplates: boolean;
263
+
hasArchived: boolean;
259
264
}) => {
260
265
let { display, sort } = useDashboardState();
261
266
display = display || props.defaultDisplay;
···
276
281
<DisplayToggle setState={setState} display={display} />
277
282
<Separator classname="h-4 min-h-4!" />
278
283
279
-
{props.hasPubs || props.hasTemplates ? (
284
+
{props.hasPubs ? (
280
285
<>
281
-
{props.hasPubs}
282
-
{props.hasTemplates}
283
286
<FilterOptions
284
287
hasPubs={props.hasPubs}
285
-
hasTemplates={props.hasTemplates}
288
+
hasArchived={props.hasArchived}
286
289
/>
287
290
<Separator classname="h-4 min-h-4!" />{" "}
288
291
</>
···
369
372
);
370
373
}
371
374
372
-
const FilterOptions = (props: { hasPubs: boolean; hasTemplates: boolean }) => {
375
+
const FilterOptions = (props: {
376
+
hasPubs: boolean;
377
+
hasArchived: boolean;
378
+
}) => {
373
379
let { filter } = useDashboardState();
374
380
let setState = useSetDashboardState();
375
381
let filterCount = Object.values(filter).filter(Boolean).length;
···
406
412
</>
407
413
)}
408
414
409
-
{props.hasTemplates && (
410
-
<>
411
-
<Checkbox
412
-
small
413
-
checked={filter.templates}
414
-
onChange={(e) =>
415
-
setState({
416
-
filter: { ...filter, templates: !!e.target.checked },
417
-
})
418
-
}
419
-
>
420
-
Templates
421
-
</Checkbox>
422
-
</>
415
+
{props.hasArchived && (
416
+
<Checkbox
417
+
small
418
+
checked={filter.archived}
419
+
onChange={(e) =>
420
+
setState({
421
+
filter: { ...filter, archived: !!e.target.checked },
422
+
})
423
+
}
424
+
>
425
+
Archived
426
+
</Checkbox>
423
427
)}
424
428
<Checkbox
425
429
small
···
441
445
docs: false,
442
446
published: false,
443
447
drafts: false,
444
-
templates: false,
448
+
archived: false,
445
449
},
446
450
});
447
451
}}
+52
-6
components/PageSWRDataProvider.tsx
+52
-6
components/PageSWRDataProvider.tsx
···
7
7
import { getPollData } from "actions/pollActions";
8
8
import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
9
9
import { createContext, useContext } from "react";
10
+
import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
11
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
12
+
import { AtUri } from "@atproto/syntax";
10
13
11
14
export const StaticLeafletDataContext = createContext<
12
15
null | GetLeafletDataReturnType["result"]["data"]
···
66
69
};
67
70
export function useLeafletPublicationData() {
68
71
let { data, mutate } = useLeafletData();
72
+
73
+
// First check for leaflets in publications
74
+
let pubData = getPublicationMetadataFromLeafletData(data);
75
+
69
76
return {
70
-
data:
71
-
data?.leaflets_in_publications?.[0] ||
72
-
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
73
-
(p) => p.leaflets_in_publications.length,
74
-
)?.leaflets_in_publications?.[0] ||
75
-
null,
77
+
data: pubData || null,
76
78
mutate,
77
79
};
78
80
}
···
80
82
let { data, mutate } = useLeafletData();
81
83
return { data: data?.custom_domain_routes, mutate: mutate };
82
84
}
85
+
86
+
export function useLeafletPublicationStatus() {
87
+
const data = useContext(StaticLeafletDataContext);
88
+
if (!data) return null;
89
+
90
+
const publishedInPublication = data.leaflets_in_publications?.find(
91
+
(l) => l.doc,
92
+
);
93
+
const publishedStandalone = data.leaflets_to_documents?.find(
94
+
(l) => !!l.documents,
95
+
);
96
+
97
+
const documentUri =
98
+
publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
99
+
100
+
// Compute the full post URL for sharing
101
+
let postShareLink: string | undefined;
102
+
if (publishedInPublication?.publications && publishedInPublication.documents) {
103
+
// Published in a publication - use publication URL + document rkey
104
+
const docUri = new AtUri(publishedInPublication.documents.uri);
105
+
postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
106
+
} else if (publishedStandalone?.document) {
107
+
// Standalone published post - use /p/{did}/{rkey} format
108
+
const docUri = new AtUri(publishedStandalone.document);
109
+
postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
110
+
}
111
+
112
+
return {
113
+
token: data,
114
+
leafletId: data.root_entity,
115
+
shareLink: data.id,
116
+
// Draft state - in a publication but not yet published
117
+
draftInPublication:
118
+
data.leaflets_in_publications?.[0]?.publication ?? undefined,
119
+
// Published state
120
+
isPublished: !!(publishedInPublication || publishedStandalone),
121
+
publishedAt:
122
+
publishedInPublication?.documents?.indexed_at ??
123
+
publishedStandalone?.documents?.indexed_at,
124
+
documentUri,
125
+
// Full URL for sharing published posts
126
+
postShareLink,
127
+
};
128
+
}
+5
-2
components/Pages/Page.tsx
+5
-2
components/Pages/Page.tsx
···
12
12
import { Blocks } from "components/Blocks";
13
13
import { PublicationMetadata } from "./PublicationMetadata";
14
14
import { useCardBorderHidden } from "./useCardBorderHidden";
15
-
import { focusPage } from ".";
15
+
import { focusPage } from "src/utils/focusPage";
16
16
import { PageOptions } from "./PageOptions";
17
17
import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
18
18
import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
19
+
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
19
20
20
21
export function Page(props: {
21
22
entityID: string;
···
60
61
/>
61
62
}
62
63
>
63
-
{props.first && (
64
+
{props.first && pageType === "doc" && (
64
65
<>
65
66
<PublicationMetadata />
66
67
</>
···
83
84
pageType: "canvas" | "doc";
84
85
drawerOpen: boolean | undefined;
85
86
}) => {
87
+
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
86
88
return (
87
89
// this div wraps the contents AND the page options.
88
90
// it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions
···
95
97
it needs to be a separate div so that the user can scroll from anywhere on the page if there isn't a card border
96
98
*/}
97
99
<div
100
+
ref={ref}
98
101
onClick={props.onClickAction}
99
102
id={props.id}
100
103
className={`
+158
-80
components/Pages/PublicationMetadata.tsx
+158
-80
components/Pages/PublicationMetadata.tsx
···
1
1
import Link from "next/link";
2
2
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
3
-
import { useRef } from "react";
3
+
import { useRef, useState } from "react";
4
4
import { useReplicache } from "src/replicache";
5
5
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
6
6
import { Separator } from "components/Layout";
7
7
import { AtUri } from "@atproto/syntax";
8
-
import { PubLeafletDocument } from "lexicons/api";
8
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
9
9
import {
10
10
getBasePublicationURL,
11
11
getPublicationURL,
···
13
13
import { useSubscribe } from "src/replicache/useSubscribe";
14
14
import { useEntitySetContext } from "components/EntitySetProvider";
15
15
import { timeAgo } from "src/utils/timeAgo";
16
+
import { CommentTiny } from "components/Icons/CommentTiny";
17
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
18
+
import { TagTiny } from "components/Icons/TagTiny";
19
+
import { Popover } from "components/Popover";
20
+
import { TagSelector } from "components/Tags";
16
21
import { useIdentityData } from "components/IdentityProvider";
22
+
import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
17
23
export const PublicationMetadata = () => {
18
24
let { rep } = useReplicache();
19
25
let { data: pub } = useLeafletPublicationData();
···
23
29
tx.get<string>("publication_description"),
24
30
);
25
31
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
32
+
let pubRecord = pub?.publications?.record as
33
+
| PubLeafletPublication.Record
34
+
| undefined;
26
35
let publishedAt = record?.publishedAt;
27
36
28
-
if (!pub || !pub.publications) return null;
37
+
if (!pub) return null;
29
38
30
39
if (typeof title !== "string") {
31
40
title = pub?.title || "";
···
33
42
if (typeof description !== "string") {
34
43
description = pub?.description || "";
35
44
}
45
+
let tags = true;
46
+
36
47
return (
37
-
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
38
-
<div className="flex gap-2">
39
-
<Link
40
-
href={
41
-
identity?.atp_did === pub.publications?.identity_did
42
-
? `${getBasePublicationURL(pub.publications)}/dashboard`
43
-
: getPublicationURL(pub.publications)
44
-
}
45
-
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
46
-
>
47
-
{pub.publications?.name}
48
-
</Link>
49
-
<div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md ">
50
-
Editor
48
+
<PostHeaderLayout
49
+
pubLink={
50
+
<div className="flex gap-2 items-center">
51
+
{pub.publications && (
52
+
<Link
53
+
href={
54
+
identity?.atp_did === pub.publications?.identity_did
55
+
? `${getBasePublicationURL(pub.publications)}/dashboard`
56
+
: getPublicationURL(pub.publications)
57
+
}
58
+
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
59
+
>
60
+
{pub.publications?.name}
61
+
</Link>
62
+
)}
63
+
<div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md ">
64
+
DRAFT
65
+
</div>
51
66
</div>
52
-
</div>
53
-
<TextField
54
-
className="text-xl font-bold outline-hidden bg-transparent"
55
-
value={title}
56
-
onChange={async (newTitle) => {
57
-
await rep?.mutate.updatePublicationDraft({
58
-
title: newTitle,
59
-
description,
60
-
});
61
-
}}
62
-
placeholder="Untitled"
63
-
/>
64
-
<TextField
65
-
placeholder="add an optional description..."
66
-
className="italic text-secondary outline-hidden bg-transparent"
67
-
value={description}
68
-
onChange={async (newDescription) => {
69
-
await rep?.mutate.updatePublicationDraft({
70
-
title,
71
-
description: newDescription,
72
-
});
73
-
}}
74
-
/>
75
-
{pub.doc ? (
76
-
<div className="flex flex-row items-center gap-2 pt-3">
77
-
<p className="text-sm text-tertiary">
78
-
Published {publishedAt && timeAgo(publishedAt)}
79
-
</p>
80
-
<Separator classname="h-4" />
81
-
<Link
82
-
target="_blank"
83
-
className="text-sm"
84
-
href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`}
85
-
>
86
-
View Post
87
-
</Link>
88
-
</div>
89
-
) : (
90
-
<p className="text-sm text-tertiary pt-2">Draft</p>
91
-
)}
92
-
</div>
67
+
}
68
+
postTitle={
69
+
<TextField
70
+
className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent"
71
+
value={title}
72
+
onChange={async (newTitle) => {
73
+
await rep?.mutate.updatePublicationDraft({
74
+
title: newTitle,
75
+
description,
76
+
});
77
+
}}
78
+
placeholder="Untitled"
79
+
/>
80
+
}
81
+
postDescription={
82
+
<TextField
83
+
placeholder="add an optional description..."
84
+
className="pt-1 italic text-secondary outline-hidden bg-transparent"
85
+
value={description}
86
+
onChange={async (newDescription) => {
87
+
await rep?.mutate.updatePublicationDraft({
88
+
title,
89
+
description: newDescription,
90
+
});
91
+
}}
92
+
/>
93
+
}
94
+
postInfo={
95
+
<>
96
+
{pub.doc ? (
97
+
<div className="flex gap-2 items-center">
98
+
<p className="text-sm text-tertiary">
99
+
Published {publishedAt && timeAgo(publishedAt)}
100
+
</p>
101
+
102
+
<Link
103
+
target="_blank"
104
+
className="text-sm"
105
+
href={
106
+
pub.publications
107
+
? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
108
+
: `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}`
109
+
}
110
+
>
111
+
View
112
+
</Link>
113
+
</div>
114
+
) : (
115
+
<p>Draft</p>
116
+
)}
117
+
<div className="flex gap-2 text-border items-center">
118
+
{tags && (
119
+
<>
120
+
<AddTags />
121
+
<Separator classname="h-4!" />
122
+
</>
123
+
)}
124
+
<div className="flex gap-1 items-center">
125
+
<QuoteTiny />โ
126
+
</div>
127
+
{pubRecord?.preferences?.showComments && (
128
+
<div className="flex gap-1 items-center">
129
+
<CommentTiny />โ
130
+
</div>
131
+
)}
132
+
</div>
133
+
</>
134
+
}
135
+
/>
93
136
);
94
137
};
95
138
···
169
212
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
170
213
let publishedAt = record?.publishedAt;
171
214
172
-
if (!pub || !pub.publications) return null;
215
+
if (!pub) return null;
173
216
174
217
return (
175
-
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
176
-
<div className="text-accent-contrast font-bold hover:no-underline">
177
-
{pub.publications?.name}
178
-
</div>
218
+
<PostHeaderLayout
219
+
pubLink={
220
+
<div className="text-accent-contrast font-bold hover:no-underline">
221
+
{pub.publications?.name}
222
+
</div>
223
+
}
224
+
postTitle={pub.title}
225
+
postDescription={pub.description}
226
+
postInfo={
227
+
pub.doc ? (
228
+
<p>Published {publishedAt && timeAgo(publishedAt)}</p>
229
+
) : (
230
+
<p>Draft</p>
231
+
)
232
+
}
233
+
/>
234
+
);
235
+
};
236
+
237
+
const AddTags = () => {
238
+
let { data: pub } = useLeafletPublicationData();
239
+
let { rep } = useReplicache();
240
+
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
241
+
242
+
// Get tags from Replicache local state or published document
243
+
let replicacheTags = useSubscribe(rep, (tx) =>
244
+
tx.get<string[]>("publication_tags"),
245
+
);
179
246
180
-
<div
181
-
className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`}
182
-
>
183
-
{pub.title ? pub.title : "Untitled"}
184
-
</div>
185
-
<div className="italic text-secondary outline-hidden bg-transparent">
186
-
{pub.description}
187
-
</div>
247
+
// Determine which tags to use - prioritize Replicache state
248
+
let tags: string[] = [];
249
+
if (Array.isArray(replicacheTags)) {
250
+
tags = replicacheTags;
251
+
} else if (record?.tags && Array.isArray(record.tags)) {
252
+
tags = record.tags as string[];
253
+
}
254
+
255
+
// Update tags in replicache local state
256
+
const handleTagsChange = async (newTags: string[]) => {
257
+
// Store tags in replicache for next publish/update
258
+
await rep?.mutate.updatePublicationDraft({
259
+
tags: newTags,
260
+
});
261
+
};
188
262
189
-
{pub.doc ? (
190
-
<div className="flex flex-row items-center gap-2 pt-3">
191
-
<p className="text-sm text-tertiary">
192
-
Published {publishedAt && timeAgo(publishedAt)}
193
-
</p>
263
+
return (
264
+
<Popover
265
+
className="p-2! w-full min-w-xs"
266
+
trigger={
267
+
<div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary">
268
+
<TagTiny />{" "}
269
+
{tags.length > 0
270
+
? `${tags.length} Tag${tags.length === 1 ? "" : "s"}`
271
+
: "Add Tags"}
194
272
</div>
195
-
) : (
196
-
<p className="text-sm text-tertiary pt-2">Draft</p>
197
-
)}
198
-
</div>
273
+
}
274
+
>
275
+
<TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
276
+
</Popover>
199
277
);
200
278
};
+2
-75
components/Pages/index.tsx
+2
-75
components/Pages/index.tsx
···
4
4
import { useUIState } from "src/useUIState";
5
5
import { useSearchParams } from "next/navigation";
6
6
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { elementId } from "src/utils/elementId";
7
+
import { useEntity } from "src/replicache";
9
8
10
-
import { Replicache } from "replicache";
11
-
import { Fact, ReplicacheMutators, useEntity } from "src/replicache";
12
-
13
-
import { scanIndex } from "src/replicache/utils";
14
-
import { CardThemeProvider } from "../ThemeManager/ThemeProvider";
15
-
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
16
9
import { useCardBorderHidden } from "./useCardBorderHidden";
17
10
import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout";
18
11
import { LeafletSidebar } from "app/[leaflet_id]/Sidebar";
···
62
55
);
63
56
}
64
57
65
-
export async function focusPage(
66
-
pageID: string,
67
-
rep: Replicache<ReplicacheMutators>,
68
-
focusFirstBlock?: "focusFirstBlock",
69
-
) {
70
-
// if this page is already focused,
71
-
let focusedBlock = useUIState.getState().focusedEntity;
72
-
// else set this page as focused
73
-
useUIState.setState(() => ({
74
-
focusedEntity: {
75
-
entityType: "page",
76
-
entityID: pageID,
77
-
},
78
-
}));
79
-
80
-
setTimeout(async () => {
81
-
//scroll to page
82
-
83
-
scrollIntoViewIfNeeded(
84
-
document.getElementById(elementId.page(pageID).container),
85
-
false,
86
-
"smooth",
87
-
);
88
-
89
-
// if we asked that the function focus the first block, focus the first block
90
-
if (focusFirstBlock === "focusFirstBlock") {
91
-
let firstBlock = await rep.query(async (tx) => {
92
-
let type = await scanIndex(tx).eav(pageID, "page/type");
93
-
let blocks = await scanIndex(tx).eav(
94
-
pageID,
95
-
type[0]?.data.value === "canvas" ? "canvas/block" : "card/block",
96
-
);
97
-
98
-
let firstBlock = blocks[0];
99
-
100
-
if (!firstBlock) {
101
-
return null;
102
-
}
103
-
104
-
let blockType = (
105
-
await tx
106
-
.scan<
107
-
Fact<"block/type">
108
-
>({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` })
109
-
.toArray()
110
-
)[0];
111
-
112
-
if (!blockType) return null;
113
-
114
-
return {
115
-
value: firstBlock.data.value,
116
-
type: blockType.data.value,
117
-
parent: firstBlock.entity,
118
-
position: firstBlock.data.position,
119
-
};
120
-
});
121
-
122
-
if (firstBlock) {
123
-
setTimeout(() => {
124
-
focusBlock(firstBlock, { type: "start" });
125
-
}, 500);
126
-
}
127
-
}
128
-
}, 50);
129
-
}
130
-
131
-
export const blurPage = () => {
58
+
const blurPage = () => {
132
59
useUIState.setState(() => ({
133
60
focusedEntity: null,
134
61
selectedBlocks: [],
+3
components/Popover/PopoverContext.ts
+3
components/Popover/PopoverContext.ts
+87
components/Popover/index.tsx
+87
components/Popover/index.tsx
···
1
+
"use client";
2
+
import * as RadixPopover from "@radix-ui/react-popover";
3
+
import { theme } from "tailwind.config";
4
+
import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider";
5
+
import { useEffect, useState } from "react";
6
+
import { PopoverArrow } from "../Icons/PopoverArrow";
7
+
import { PopoverOpenContext } from "./PopoverContext";
8
+
export const Popover = (props: {
9
+
trigger: React.ReactNode;
10
+
disabled?: boolean;
11
+
children: React.ReactNode;
12
+
align?: "start" | "end" | "center";
13
+
side?: "top" | "bottom" | "left" | "right";
14
+
sideOffset?: number;
15
+
background?: string;
16
+
border?: string;
17
+
className?: string;
18
+
open?: boolean;
19
+
onOpenChange?: (open: boolean) => void;
20
+
onOpenAutoFocus?: (e: Event) => void;
21
+
asChild?: boolean;
22
+
arrowFill?: string;
23
+
noArrow?: boolean;
24
+
}) => {
25
+
let [open, setOpen] = useState(props.open || false);
26
+
useEffect(() => {
27
+
if (props.open !== undefined) setOpen(props.open);
28
+
}, [props.open]);
29
+
return (
30
+
<RadixPopover.Root
31
+
open={props.open}
32
+
onOpenChange={(o) => {
33
+
setOpen(o);
34
+
props.onOpenChange?.(o);
35
+
}}
36
+
>
37
+
<PopoverOpenContext value={open}>
38
+
<RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}>
39
+
{props.trigger}
40
+
</RadixPopover.Trigger>
41
+
<RadixPopover.Portal>
42
+
<NestedCardThemeProvider>
43
+
<RadixPopover.Content
44
+
className={`
45
+
z-20 bg-bg-page
46
+
px-3 py-2
47
+
max-w-(--radix-popover-content-available-width)
48
+
max-h-(--radix-popover-content-available-height)
49
+
border border-border rounded-md shadow-md
50
+
overflow-y-scroll
51
+
${props.className}
52
+
`}
53
+
side={props.side}
54
+
align={props.align ? props.align : "center"}
55
+
sideOffset={props.sideOffset ? props.sideOffset : 4}
56
+
collisionPadding={16}
57
+
onOpenAutoFocus={props.onOpenAutoFocus}
58
+
>
59
+
{props.children}
60
+
{!props.noArrow && (
61
+
<RadixPopover.Arrow
62
+
asChild
63
+
width={16}
64
+
height={8}
65
+
viewBox="0 0 16 8"
66
+
>
67
+
<PopoverArrow
68
+
arrowFill={
69
+
props.arrowFill
70
+
? props.arrowFill
71
+
: props.background
72
+
? props.background
73
+
: theme.colors["bg-page"]
74
+
}
75
+
arrowStroke={
76
+
props.border ? props.border : theme.colors["border"]
77
+
}
78
+
/>
79
+
</RadixPopover.Arrow>
80
+
)}
81
+
</RadixPopover.Content>
82
+
</NestedCardThemeProvider>
83
+
</RadixPopover.Portal>
84
+
</PopoverOpenContext>
85
+
</RadixPopover.Root>
86
+
);
87
+
};
-84
components/Popover.tsx
-84
components/Popover.tsx
···
1
-
"use client";
2
-
import * as RadixPopover from "@radix-ui/react-popover";
3
-
import { theme } from "tailwind.config";
4
-
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
-
import { createContext, useEffect, useState } from "react";
6
-
import { PopoverArrow } from "./Icons/PopoverArrow";
7
-
8
-
export const PopoverOpenContext = createContext(false);
9
-
export const Popover = (props: {
10
-
trigger: React.ReactNode;
11
-
disabled?: boolean;
12
-
children: React.ReactNode;
13
-
align?: "start" | "end" | "center";
14
-
side?: "top" | "bottom" | "left" | "right";
15
-
background?: string;
16
-
border?: string;
17
-
className?: string;
18
-
open?: boolean;
19
-
onOpenChange?: (open: boolean) => void;
20
-
onOpenAutoFocus?: (e: Event) => void;
21
-
asChild?: boolean;
22
-
arrowFill?: string;
23
-
}) => {
24
-
let [open, setOpen] = useState(props.open || false);
25
-
useEffect(() => {
26
-
if (props.open !== undefined) setOpen(props.open);
27
-
}, [props.open]);
28
-
return (
29
-
<RadixPopover.Root
30
-
open={props.open}
31
-
onOpenChange={(o) => {
32
-
setOpen(o);
33
-
props.onOpenChange?.(o);
34
-
}}
35
-
>
36
-
<PopoverOpenContext value={open}>
37
-
<RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}>
38
-
{props.trigger}
39
-
</RadixPopover.Trigger>
40
-
<RadixPopover.Portal>
41
-
<NestedCardThemeProvider>
42
-
<RadixPopover.Content
43
-
className={`
44
-
z-20 bg-bg-page
45
-
px-3 py-2
46
-
max-w-(--radix-popover-content-available-width)
47
-
max-h-(--radix-popover-content-available-height)
48
-
border border-border rounded-md shadow-md
49
-
overflow-y-scroll
50
-
${props.className}
51
-
`}
52
-
side={props.side}
53
-
align={props.align ? props.align : "center"}
54
-
sideOffset={4}
55
-
collisionPadding={16}
56
-
onOpenAutoFocus={props.onOpenAutoFocus}
57
-
>
58
-
{props.children}
59
-
<RadixPopover.Arrow
60
-
asChild
61
-
width={16}
62
-
height={8}
63
-
viewBox="0 0 16 8"
64
-
>
65
-
<PopoverArrow
66
-
arrowFill={
67
-
props.arrowFill
68
-
? props.arrowFill
69
-
: props.background
70
-
? props.background
71
-
: theme.colors["bg-page"]
72
-
}
73
-
arrowStroke={
74
-
props.border ? props.border : theme.colors["border"]
75
-
}
76
-
/>
77
-
</RadixPopover.Arrow>
78
-
</RadixPopover.Content>
79
-
</NestedCardThemeProvider>
80
-
</RadixPopover.Portal>
81
-
</PopoverOpenContext>
82
-
</RadixPopover.Root>
83
-
);
84
-
};
+132
components/PostListing.tsx
+132
components/PostListing.tsx
···
1
+
"use client";
2
+
import { AtUri } from "@atproto/api";
3
+
import { PubIcon } from "components/ActionBar/Publications";
4
+
import { CommentTiny } from "components/Icons/CommentTiny";
5
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
6
+
import { Separator } from "components/Layout";
7
+
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
8
+
import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
9
+
import { useSmoker } from "components/Toast";
10
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
11
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
12
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
13
+
14
+
import Link from "next/link";
15
+
import { InteractionPreview } from "./InteractionsPreview";
16
+
17
+
export const PostListing = (props: Post) => {
18
+
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
19
+
20
+
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
+
let postUri = new AtUri(props.documents.uri);
22
+
23
+
let theme = usePubTheme(pubRecord.theme);
24
+
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
25
+
? blobRefToSrc(
26
+
pubRecord?.theme?.backgroundImage?.image?.ref,
27
+
new AtUri(props.publication.uri).host,
28
+
)
29
+
: null;
30
+
31
+
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
32
+
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
33
+
34
+
let showPageBackground = pubRecord.theme?.showPageBackground;
35
+
36
+
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
37
+
let comments =
38
+
pubRecord.preferences?.showComments === false
39
+
? 0
40
+
: props.documents.comments_on_documents?.[0]?.count || 0;
41
+
let tags = (postRecord?.tags as string[] | undefined) || [];
42
+
43
+
return (
44
+
<BaseThemeProvider {...theme} local>
45
+
<div
46
+
style={{
47
+
backgroundImage: `url(${backgroundImage})`,
48
+
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
49
+
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
50
+
}}
51
+
className={`no-underline! flex flex-row gap-2 w-full relative
52
+
bg-bg-leaflet
53
+
border border-border-light rounded-lg
54
+
sm:p-2 p-2 selected-outline
55
+
hover:outline-accent-contrast hover:border-accent-contrast
56
+
`}
57
+
>
58
+
<Link
59
+
className="h-full w-full absolute top-0 left-0"
60
+
href={`${props.publication.href}/${postUri.rkey}`}
61
+
/>
62
+
<div
63
+
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
64
+
style={{
65
+
backgroundColor: showPageBackground
66
+
? "rgba(var(--bg-page), var(--bg-page-alpha))"
67
+
: "transparent",
68
+
}}
69
+
>
70
+
<h3 className="text-primary truncate">{postRecord.title}</h3>
71
+
72
+
<p className="text-secondary italic">{postRecord.description}</p>
73
+
<div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full">
74
+
<PubInfo
75
+
href={props.publication.href}
76
+
pubRecord={pubRecord}
77
+
uri={props.publication.uri}
78
+
/>
79
+
<div className="flex flex-row justify-between gap-2 items-center w-full">
80
+
<PostInfo publishedAt={postRecord.publishedAt} />
81
+
<InteractionPreview
82
+
postUrl={`${props.publication.href}/${postUri.rkey}`}
83
+
quotesCount={quotes}
84
+
commentsCount={comments}
85
+
tags={tags}
86
+
showComments={pubRecord.preferences?.showComments}
87
+
share
88
+
/>
89
+
</div>
90
+
</div>
91
+
</div>
92
+
</div>
93
+
</BaseThemeProvider>
94
+
);
95
+
};
96
+
97
+
const PubInfo = (props: {
98
+
href: string;
99
+
pubRecord: PubLeafletPublication.Record;
100
+
uri: string;
101
+
}) => {
102
+
return (
103
+
<div className="flex flex-col md:w-auto shrink-0 w-full">
104
+
<hr className="md:hidden block border-border-light mb-2" />
105
+
<Link
106
+
href={props.href}
107
+
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0"
108
+
>
109
+
<PubIcon small record={props.pubRecord} uri={props.uri} />
110
+
{props.pubRecord.name}
111
+
</Link>
112
+
</div>
113
+
);
114
+
};
115
+
116
+
const PostInfo = (props: { publishedAt: string | undefined }) => {
117
+
return (
118
+
<div className="flex gap-2 items-center shrink-0 self-start">
119
+
{props.publishedAt && (
120
+
<>
121
+
<div className="shrink-0">
122
+
{new Date(props.publishedAt).toLocaleDateString("en-US", {
123
+
year: "numeric",
124
+
month: "short",
125
+
day: "numeric",
126
+
})}
127
+
</div>
128
+
</>
129
+
)}
130
+
</div>
131
+
);
132
+
};
-1
components/Providers/RequestHeadersProvider.tsx
-1
components/Providers/RequestHeadersProvider.tsx
+717
components/SelectionManager/index.tsx
+717
components/SelectionManager/index.tsx
···
1
+
"use client";
2
+
import { useEffect, useRef, useState } from "react";
3
+
import { useReplicache } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { scanIndex } from "src/replicache/utils";
6
+
import { focusBlock } from "src/utils/focusBlock";
7
+
import { useEditorStates } from "src/state/useEditorState";
8
+
import { useEntitySetContext } from "../EntitySetProvider";
9
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
10
+
import { indent, outdent, outdentFull } from "src/utils/list-operations";
11
+
import { addShortcut, Shortcut } from "src/shortcuts";
12
+
import { elementId } from "src/utils/elementId";
13
+
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
14
+
import { copySelection } from "src/utils/copySelection";
15
+
import { useIsMobile } from "src/hooks/isMobile";
16
+
import { deleteBlock } from "src/utils/deleteBlock";
17
+
import { schema } from "../Blocks/TextBlock/schema";
18
+
import { MarkType } from "prosemirror-model";
19
+
import { useSelectingMouse, getSortedSelection } from "./selectionState";
20
+
21
+
//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
22
+
// How does this relate to *when dragging* ?
23
+
24
+
export function SelectionManager() {
25
+
let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
26
+
let entity_set = useEntitySetContext();
27
+
let { rep, undoManager } = useReplicache();
28
+
let isMobile = useIsMobile();
29
+
useEffect(() => {
30
+
if (!entity_set.permissions.write || !rep) return;
31
+
const getSortedSelectionBound = getSortedSelection.bind(null, rep);
32
+
let shortcuts: Shortcut[] = [
33
+
{
34
+
metaKey: true,
35
+
key: "ArrowUp",
36
+
handler: async () => {
37
+
let [firstBlock] =
38
+
(await rep?.query((tx) =>
39
+
getBlocksWithType(
40
+
tx,
41
+
useUIState.getState().selectedBlocks[0].parent,
42
+
),
43
+
)) || [];
44
+
if (firstBlock) focusBlock(firstBlock, { type: "start" });
45
+
},
46
+
},
47
+
{
48
+
metaKey: true,
49
+
key: "ArrowDown",
50
+
handler: async () => {
51
+
let blocks =
52
+
(await rep?.query((tx) =>
53
+
getBlocksWithType(
54
+
tx,
55
+
useUIState.getState().selectedBlocks[0].parent,
56
+
),
57
+
)) || [];
58
+
let folded = useUIState.getState().foldedBlocks;
59
+
blocks = blocks.filter(
60
+
(f) =>
61
+
!f.listData ||
62
+
!f.listData.path.find(
63
+
(path) =>
64
+
folded.includes(path.entity) && f.value !== path.entity,
65
+
),
66
+
);
67
+
let lastBlock = blocks[blocks.length - 1];
68
+
if (lastBlock) focusBlock(lastBlock, { type: "end" });
69
+
},
70
+
},
71
+
{
72
+
metaKey: true,
73
+
altKey: true,
74
+
key: ["l", "ยฌ"],
75
+
handler: async () => {
76
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
77
+
for (let block of sortedBlocks) {
78
+
if (!block.listData) {
79
+
await rep?.mutate.assertFact({
80
+
entity: block.value,
81
+
attribute: "block/is-list",
82
+
data: { type: "boolean", value: true },
83
+
});
84
+
} else {
85
+
outdentFull(block, rep);
86
+
}
87
+
}
88
+
},
89
+
},
90
+
{
91
+
metaKey: true,
92
+
shift: true,
93
+
key: ["ArrowDown", "J"],
94
+
handler: async () => {
95
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
96
+
let block = sortedBlocks[0];
97
+
let nextBlock = siblings
98
+
.slice(siblings.findIndex((s) => s.value === block.value) + 1)
99
+
.find(
100
+
(f) =>
101
+
f.listData &&
102
+
block.listData &&
103
+
!f.listData.path.find((f) => f.entity === block.value),
104
+
);
105
+
if (
106
+
nextBlock?.listData &&
107
+
block.listData &&
108
+
nextBlock.listData.depth === block.listData.depth - 1
109
+
) {
110
+
if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
111
+
useUIState.getState().toggleFold(nextBlock.value);
112
+
await rep?.mutate.moveBlock({
113
+
block: block.value,
114
+
oldParent: block.listData?.parent,
115
+
newParent: nextBlock.value,
116
+
position: { type: "first" },
117
+
});
118
+
} else {
119
+
await rep?.mutate.moveBlockDown({
120
+
entityID: block.value,
121
+
parent: block.listData?.parent || block.parent,
122
+
});
123
+
}
124
+
},
125
+
},
126
+
{
127
+
metaKey: true,
128
+
shift: true,
129
+
key: ["ArrowUp", "K"],
130
+
handler: async () => {
131
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
132
+
let block = sortedBlocks[0];
133
+
let previousBlock =
134
+
siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
135
+
if (previousBlock.value === block.listData?.parent) {
136
+
previousBlock =
137
+
siblings?.[
138
+
siblings.findIndex((s) => s.value === block.value) - 2
139
+
];
140
+
}
141
+
142
+
if (
143
+
previousBlock?.listData &&
144
+
block.listData &&
145
+
block.listData.depth > 1 &&
146
+
!previousBlock.listData.path.find(
147
+
(f) => f.entity === block.listData?.parent,
148
+
)
149
+
) {
150
+
let depth = block.listData.depth;
151
+
let newParent = previousBlock.listData.path.find(
152
+
(f) => f.depth === depth - 1,
153
+
);
154
+
if (!newParent) return;
155
+
if (useUIState.getState().foldedBlocks.includes(newParent.entity))
156
+
useUIState.getState().toggleFold(newParent.entity);
157
+
rep?.mutate.moveBlock({
158
+
block: block.value,
159
+
oldParent: block.listData?.parent,
160
+
newParent: newParent.entity,
161
+
position: { type: "end" },
162
+
});
163
+
} else {
164
+
rep?.mutate.moveBlockUp({
165
+
entityID: block.value,
166
+
parent: block.listData?.parent || block.parent,
167
+
});
168
+
}
169
+
},
170
+
},
171
+
172
+
{
173
+
metaKey: true,
174
+
shift: true,
175
+
key: "Enter",
176
+
handler: async () => {
177
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
178
+
if (!sortedBlocks[0].listData) return;
179
+
useUIState.getState().toggleFold(sortedBlocks[0].value);
180
+
},
181
+
},
182
+
];
183
+
if (moreThanOneSelected)
184
+
shortcuts = shortcuts.concat([
185
+
{
186
+
metaKey: true,
187
+
key: "u",
188
+
handler: async () => {
189
+
let [sortedBlocks] = await getSortedSelectionBound();
190
+
toggleMarkInBlocks(
191
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
192
+
schema.marks.underline,
193
+
);
194
+
},
195
+
},
196
+
{
197
+
metaKey: true,
198
+
key: "i",
199
+
handler: async () => {
200
+
let [sortedBlocks] = await getSortedSelectionBound();
201
+
toggleMarkInBlocks(
202
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
203
+
schema.marks.em,
204
+
);
205
+
},
206
+
},
207
+
{
208
+
metaKey: true,
209
+
key: "b",
210
+
handler: async () => {
211
+
let [sortedBlocks] = await getSortedSelectionBound();
212
+
toggleMarkInBlocks(
213
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
214
+
schema.marks.strong,
215
+
);
216
+
},
217
+
},
218
+
{
219
+
metaAndCtrl: true,
220
+
key: "h",
221
+
handler: async () => {
222
+
let [sortedBlocks] = await getSortedSelectionBound();
223
+
toggleMarkInBlocks(
224
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
225
+
schema.marks.highlight,
226
+
{
227
+
color: useUIState.getState().lastUsedHighlight,
228
+
},
229
+
);
230
+
},
231
+
},
232
+
{
233
+
metaAndCtrl: true,
234
+
key: "x",
235
+
handler: async () => {
236
+
let [sortedBlocks] = await getSortedSelectionBound();
237
+
toggleMarkInBlocks(
238
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
239
+
schema.marks.strikethrough,
240
+
);
241
+
},
242
+
},
243
+
]);
244
+
let removeListener = addShortcut(
245
+
shortcuts.map((shortcut) => ({
246
+
...shortcut,
247
+
handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
248
+
})),
249
+
);
250
+
let listener = async (e: KeyboardEvent) =>
251
+
undoManager.withUndoGroup(async () => {
252
+
//used here and in cut
253
+
const deleteBlocks = async () => {
254
+
if (!entity_set.permissions.write) return;
255
+
if (moreThanOneSelected) {
256
+
e.preventDefault();
257
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
258
+
let selectedBlocks = useUIState.getState().selectedBlocks;
259
+
let firstBlock = sortedBlocks[0];
260
+
261
+
await rep?.mutate.removeBlock(
262
+
selectedBlocks.map((block) => ({ blockEntity: block.value })),
263
+
);
264
+
useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
265
+
266
+
let nextBlock =
267
+
siblings?.[
268
+
siblings.findIndex((s) => s.value === firstBlock.value) - 1
269
+
];
270
+
if (nextBlock) {
271
+
useUIState.getState().setSelectedBlock({
272
+
value: nextBlock.value,
273
+
parent: nextBlock.parent,
274
+
});
275
+
let type = await rep?.query((tx) =>
276
+
scanIndex(tx).eav(nextBlock.value, "block/type"),
277
+
);
278
+
if (!type?.[0]) return;
279
+
if (
280
+
type[0]?.data.value === "text" ||
281
+
type[0]?.data.value === "heading"
282
+
)
283
+
focusBlock(
284
+
{
285
+
value: nextBlock.value,
286
+
type: "text",
287
+
parent: nextBlock.parent,
288
+
},
289
+
{ type: "end" },
290
+
);
291
+
}
292
+
}
293
+
};
294
+
if (e.key === "Backspace" || e.key === "Delete") {
295
+
deleteBlocks();
296
+
}
297
+
if (e.key === "ArrowUp") {
298
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
299
+
let focusedBlock = useUIState.getState().focusedEntity;
300
+
if (!e.shiftKey && !e.ctrlKey) {
301
+
if (e.defaultPrevented) return;
302
+
if (sortedBlocks.length === 1) return;
303
+
let firstBlock = sortedBlocks[0];
304
+
if (!firstBlock) return;
305
+
let type = await rep?.query((tx) =>
306
+
scanIndex(tx).eav(firstBlock.value, "block/type"),
307
+
);
308
+
if (!type?.[0]) return;
309
+
useUIState.getState().setSelectedBlock(firstBlock);
310
+
focusBlock(
311
+
{ ...firstBlock, type: type[0].data.value },
312
+
{ type: "start" },
313
+
);
314
+
} else {
315
+
if (e.defaultPrevented) return;
316
+
if (
317
+
sortedBlocks.length <= 1 ||
318
+
!focusedBlock ||
319
+
focusedBlock.entityType === "page"
320
+
)
321
+
return;
322
+
let b = focusedBlock;
323
+
let focusedBlockIndex = sortedBlocks.findIndex(
324
+
(s) => s.value == b.entityID,
325
+
);
326
+
if (focusedBlockIndex === 0) {
327
+
let index = siblings.findIndex((s) => s.value === b.entityID);
328
+
let nextSelectedBlock = siblings[index - 1];
329
+
if (!nextSelectedBlock) return;
330
+
331
+
scrollIntoViewIfNeeded(
332
+
document.getElementById(
333
+
elementId.block(nextSelectedBlock.value).container,
334
+
),
335
+
false,
336
+
);
337
+
useUIState.getState().addBlockToSelection({
338
+
...nextSelectedBlock,
339
+
});
340
+
useUIState.getState().setFocusedBlock({
341
+
entityType: "block",
342
+
parent: nextSelectedBlock.parent,
343
+
entityID: nextSelectedBlock.value,
344
+
});
345
+
} else {
346
+
let nextBlock = sortedBlocks[sortedBlocks.length - 2];
347
+
useUIState.getState().setFocusedBlock({
348
+
entityType: "block",
349
+
parent: b.parent,
350
+
entityID: nextBlock.value,
351
+
});
352
+
scrollIntoViewIfNeeded(
353
+
document.getElementById(
354
+
elementId.block(nextBlock.value).container,
355
+
),
356
+
false,
357
+
);
358
+
if (sortedBlocks.length === 2) {
359
+
useEditorStates
360
+
.getState()
361
+
.editorStates[nextBlock.value]?.view?.focus();
362
+
}
363
+
useUIState
364
+
.getState()
365
+
.removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
366
+
}
367
+
}
368
+
}
369
+
if (e.key === "ArrowLeft") {
370
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
371
+
if (sortedSelection.length === 1) return;
372
+
let firstBlock = sortedSelection[0];
373
+
if (!firstBlock) return;
374
+
let type = await rep?.query((tx) =>
375
+
scanIndex(tx).eav(firstBlock.value, "block/type"),
376
+
);
377
+
if (!type?.[0]) return;
378
+
useUIState.getState().setSelectedBlock(firstBlock);
379
+
focusBlock(
380
+
{ ...firstBlock, type: type[0].data.value },
381
+
{ type: "start" },
382
+
);
383
+
}
384
+
if (e.key === "ArrowRight") {
385
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
386
+
if (sortedSelection.length === 1) return;
387
+
let lastBlock = sortedSelection[sortedSelection.length - 1];
388
+
if (!lastBlock) return;
389
+
let type = await rep?.query((tx) =>
390
+
scanIndex(tx).eav(lastBlock.value, "block/type"),
391
+
);
392
+
if (!type?.[0]) return;
393
+
useUIState.getState().setSelectedBlock(lastBlock);
394
+
focusBlock(
395
+
{ ...lastBlock, type: type[0].data.value },
396
+
{ type: "end" },
397
+
);
398
+
}
399
+
if (e.key === "Tab") {
400
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
401
+
if (sortedSelection.length <= 1) return;
402
+
e.preventDefault();
403
+
if (e.shiftKey) {
404
+
for (let i = siblings.length - 1; i >= 0; i--) {
405
+
let block = siblings[i];
406
+
if (!sortedSelection.find((s) => s.value === block.value))
407
+
continue;
408
+
if (
409
+
sortedSelection.find((s) => s.value === block.listData?.parent)
410
+
)
411
+
continue;
412
+
let parentoffset = 1;
413
+
let previousBlock = siblings[i - parentoffset];
414
+
while (
415
+
previousBlock &&
416
+
sortedSelection.find((s) => previousBlock.value === s.value)
417
+
) {
418
+
parentoffset += 1;
419
+
previousBlock = siblings[i - parentoffset];
420
+
}
421
+
if (!block.listData || !previousBlock.listData) continue;
422
+
outdent(block, previousBlock, rep);
423
+
}
424
+
} else {
425
+
for (let i = 0; i < siblings.length; i++) {
426
+
let block = siblings[i];
427
+
if (!sortedSelection.find((s) => s.value === block.value))
428
+
continue;
429
+
if (
430
+
sortedSelection.find((s) => s.value === block.listData?.parent)
431
+
)
432
+
continue;
433
+
let parentoffset = 1;
434
+
let previousBlock = siblings[i - parentoffset];
435
+
while (
436
+
previousBlock &&
437
+
sortedSelection.find((s) => previousBlock.value === s.value)
438
+
) {
439
+
parentoffset += 1;
440
+
previousBlock = siblings[i - parentoffset];
441
+
}
442
+
if (!block.listData || !previousBlock.listData) continue;
443
+
indent(block, previousBlock, rep);
444
+
}
445
+
}
446
+
}
447
+
if (e.key === "ArrowDown") {
448
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
449
+
let focusedBlock = useUIState.getState().focusedEntity;
450
+
if (!e.shiftKey) {
451
+
if (sortedSelection.length === 1) return;
452
+
let lastBlock = sortedSelection[sortedSelection.length - 1];
453
+
if (!lastBlock) return;
454
+
let type = await rep?.query((tx) =>
455
+
scanIndex(tx).eav(lastBlock.value, "block/type"),
456
+
);
457
+
if (!type?.[0]) return;
458
+
useUIState.getState().setSelectedBlock(lastBlock);
459
+
focusBlock(
460
+
{ ...lastBlock, type: type[0].data.value },
461
+
{ type: "end" },
462
+
);
463
+
}
464
+
if (e.shiftKey) {
465
+
if (e.defaultPrevented) return;
466
+
if (
467
+
sortedSelection.length <= 1 ||
468
+
!focusedBlock ||
469
+
focusedBlock.entityType === "page"
470
+
)
471
+
return;
472
+
let b = focusedBlock;
473
+
let focusedBlockIndex = sortedSelection.findIndex(
474
+
(s) => s.value == b.entityID,
475
+
);
476
+
if (focusedBlockIndex === sortedSelection.length - 1) {
477
+
let index = siblings.findIndex((s) => s.value === b.entityID);
478
+
let nextSelectedBlock = siblings[index + 1];
479
+
if (!nextSelectedBlock) return;
480
+
useUIState.getState().addBlockToSelection({
481
+
...nextSelectedBlock,
482
+
});
483
+
484
+
scrollIntoViewIfNeeded(
485
+
document.getElementById(
486
+
elementId.block(nextSelectedBlock.value).container,
487
+
),
488
+
false,
489
+
);
490
+
useUIState.getState().setFocusedBlock({
491
+
entityType: "block",
492
+
parent: nextSelectedBlock.parent,
493
+
entityID: nextSelectedBlock.value,
494
+
});
495
+
} else {
496
+
let nextBlock = sortedSelection[1];
497
+
useUIState
498
+
.getState()
499
+
.removeBlockFromSelection({ value: b.entityID });
500
+
scrollIntoViewIfNeeded(
501
+
document.getElementById(
502
+
elementId.block(nextBlock.value).container,
503
+
),
504
+
false,
505
+
);
506
+
useUIState.getState().setFocusedBlock({
507
+
entityType: "block",
508
+
parent: b.parent,
509
+
entityID: nextBlock.value,
510
+
});
511
+
if (sortedSelection.length === 2) {
512
+
useEditorStates
513
+
.getState()
514
+
.editorStates[nextBlock.value]?.view?.focus();
515
+
}
516
+
}
517
+
}
518
+
}
519
+
if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) {
520
+
if (!rep) return;
521
+
if (e.shiftKey || (e.metaKey && e.ctrlKey)) return;
522
+
let [, , selectionWithFoldedChildren] =
523
+
await getSortedSelectionBound();
524
+
if (!selectionWithFoldedChildren) return;
525
+
let el = document.activeElement as HTMLElement;
526
+
if (
527
+
el?.tagName === "LABEL" ||
528
+
el?.tagName === "INPUT" ||
529
+
el?.tagName === "TEXTAREA"
530
+
) {
531
+
return;
532
+
}
533
+
534
+
if (
535
+
el.contentEditable === "true" &&
536
+
selectionWithFoldedChildren.length <= 1
537
+
)
538
+
return;
539
+
e.preventDefault();
540
+
await copySelection(rep, selectionWithFoldedChildren);
541
+
if (e.key === "x") deleteBlocks();
542
+
}
543
+
});
544
+
window.addEventListener("keydown", listener);
545
+
return () => {
546
+
removeListener();
547
+
window.removeEventListener("keydown", listener);
548
+
};
549
+
}, [moreThanOneSelected, rep, entity_set.permissions.write]);
550
+
551
+
let [mouseDown, setMouseDown] = useState(false);
552
+
let initialContentEditableParent = useRef<null | Node>(null);
553
+
let savedSelection = useRef<SavedRange[] | null>(undefined);
554
+
useEffect(() => {
555
+
if (isMobile) return;
556
+
if (!entity_set.permissions.write) return;
557
+
let mouseDownListener = (e: MouseEvent) => {
558
+
if ((e.target as Element).getAttribute("data-draggable")) return;
559
+
let contentEditableParent = getContentEditableParent(e.target as Node);
560
+
if (contentEditableParent) {
561
+
setMouseDown(true);
562
+
let entityID = (contentEditableParent as Element).getAttribute(
563
+
"data-entityid",
564
+
);
565
+
useSelectingMouse.setState({ start: entityID });
566
+
}
567
+
initialContentEditableParent.current = contentEditableParent;
568
+
};
569
+
let mouseUpListener = (e: MouseEvent) => {
570
+
savedSelection.current = null;
571
+
if (
572
+
initialContentEditableParent.current &&
573
+
!(e.target as Element).getAttribute("data-draggable") &&
574
+
getContentEditableParent(e.target as Node) !==
575
+
initialContentEditableParent.current
576
+
) {
577
+
setTimeout(() => {
578
+
window.getSelection()?.removeAllRanges();
579
+
}, 5);
580
+
}
581
+
initialContentEditableParent.current = null;
582
+
useSelectingMouse.setState({ start: null });
583
+
setMouseDown(false);
584
+
};
585
+
window.addEventListener("mousedown", mouseDownListener);
586
+
window.addEventListener("mouseup", mouseUpListener);
587
+
return () => {
588
+
window.removeEventListener("mousedown", mouseDownListener);
589
+
window.removeEventListener("mouseup", mouseUpListener);
590
+
};
591
+
}, [entity_set.permissions.write, isMobile]);
592
+
useEffect(() => {
593
+
if (!mouseDown) return;
594
+
if (isMobile) return;
595
+
let mouseMoveListener = (e: MouseEvent) => {
596
+
if (e.buttons !== 1) return;
597
+
if (initialContentEditableParent.current) {
598
+
if (
599
+
initialContentEditableParent.current ===
600
+
getContentEditableParent(e.target as Node)
601
+
) {
602
+
if (savedSelection.current) {
603
+
restoreSelection(savedSelection.current);
604
+
}
605
+
savedSelection.current = null;
606
+
return;
607
+
}
608
+
if (!savedSelection.current) savedSelection.current = saveSelection();
609
+
window.getSelection()?.removeAllRanges();
610
+
}
611
+
};
612
+
window.addEventListener("mousemove", mouseMoveListener);
613
+
return () => {
614
+
window.removeEventListener("mousemove", mouseMoveListener);
615
+
};
616
+
}, [mouseDown, isMobile]);
617
+
return null;
618
+
}
619
+
620
+
type SavedRange = {
621
+
startContainer: Node;
622
+
startOffset: number;
623
+
endContainer: Node;
624
+
endOffset: number;
625
+
direction: "forward" | "backward";
626
+
};
627
+
function saveSelection() {
628
+
let selection = window.getSelection();
629
+
if (selection && selection.rangeCount > 0) {
630
+
let ranges: SavedRange[] = [];
631
+
for (let i = 0; i < selection.rangeCount; i++) {
632
+
let range = selection.getRangeAt(i);
633
+
ranges.push({
634
+
startContainer: range.startContainer,
635
+
startOffset: range.startOffset,
636
+
endContainer: range.endContainer,
637
+
endOffset: range.endOffset,
638
+
direction:
639
+
selection.anchorNode === range.startContainer &&
640
+
selection.anchorOffset === range.startOffset
641
+
? "forward"
642
+
: "backward",
643
+
});
644
+
}
645
+
return ranges;
646
+
}
647
+
return [];
648
+
}
649
+
650
+
function restoreSelection(savedRanges: SavedRange[]) {
651
+
if (savedRanges && savedRanges.length > 0) {
652
+
let selection = window.getSelection();
653
+
if (!selection) return;
654
+
selection.removeAllRanges();
655
+
for (let i = 0; i < savedRanges.length; i++) {
656
+
let range = document.createRange();
657
+
range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset);
658
+
range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset);
659
+
660
+
selection.addRange(range);
661
+
662
+
// If the direction is backward, collapse the selection to the end and then extend it backward
663
+
if (savedRanges[i].direction === "backward") {
664
+
selection.collapseToEnd();
665
+
selection.extend(
666
+
savedRanges[i].startContainer,
667
+
savedRanges[i].startOffset,
668
+
);
669
+
}
670
+
}
671
+
}
672
+
}
673
+
674
+
function getContentEditableParent(e: Node | null): Node | null {
675
+
let element: Node | null = e;
676
+
while (element && element !== document) {
677
+
if (
678
+
(element as HTMLElement).contentEditable === "true" ||
679
+
(element as HTMLElement).getAttribute("data-editable-block")
680
+
) {
681
+
return element;
682
+
}
683
+
element = element.parentNode;
684
+
}
685
+
return null;
686
+
}
687
+
688
+
689
+
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
690
+
let everyBlockHasMark = blocks.reduce((acc, block) => {
691
+
let editor = useEditorStates.getState().editorStates[block];
692
+
if (!editor) return acc;
693
+
let { view } = editor;
694
+
let from = 0;
695
+
let to = view.state.doc.content.size;
696
+
let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark);
697
+
return acc && hasMarkInRange;
698
+
}, true);
699
+
for (let block of blocks) {
700
+
let editor = useEditorStates.getState().editorStates[block];
701
+
if (!editor) return;
702
+
let { view } = editor;
703
+
let tr = view.state.tr;
704
+
705
+
let from = 0;
706
+
let to = view.state.doc.content.size;
707
+
708
+
tr.setMeta("bulkOp", true);
709
+
if (everyBlockHasMark) {
710
+
tr.removeMark(from, to, mark);
711
+
} else {
712
+
tr.addMark(from, to, mark.create(attrs));
713
+
}
714
+
715
+
view.dispatch(tr);
716
+
}
717
+
}
+48
components/SelectionManager/selectionState.ts
+48
components/SelectionManager/selectionState.ts
···
1
+
import { create } from "zustand";
2
+
import { Replicache } from "replicache";
3
+
import { ReplicacheMutators } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
+
7
+
export const useSelectingMouse = create(() => ({
8
+
start: null as null | string,
9
+
}));
10
+
11
+
export const getSortedSelection = async (
12
+
rep: Replicache<ReplicacheMutators>,
13
+
) => {
14
+
let selectedBlocks = useUIState.getState().selectedBlocks;
15
+
let foldedBlocks = useUIState.getState().foldedBlocks;
16
+
if (!selectedBlocks[0]) return [[], []];
17
+
let siblings =
18
+
(await rep?.query((tx) =>
19
+
getBlocksWithType(tx, selectedBlocks[0].parent),
20
+
)) || [];
21
+
let sortedBlocks = siblings.filter((s) => {
22
+
let selected = selectedBlocks.find((sb) => sb.value === s.value);
23
+
return selected;
24
+
});
25
+
let sortedBlocksWithChildren = siblings.filter((s) => {
26
+
let selected = selectedBlocks.find((sb) => sb.value === s.value);
27
+
if (s.listData && !selected) {
28
+
//Select the children of folded list blocks (in order to copy them)
29
+
return s.listData.path.find(
30
+
(p) =>
31
+
selectedBlocks.find((sb) => sb.value === p.entity) &&
32
+
foldedBlocks.includes(p.entity),
33
+
);
34
+
}
35
+
return selected;
36
+
});
37
+
return [
38
+
sortedBlocks,
39
+
siblings.filter(
40
+
(f) =>
41
+
!f.listData ||
42
+
!f.listData.path.find(
43
+
(p) => foldedBlocks.includes(p.entity) && p.entity !== f.value,
44
+
),
45
+
),
46
+
sortedBlocksWithChildren,
47
+
];
48
+
};
-763
components/SelectionManager.tsx
-763
components/SelectionManager.tsx
···
1
-
"use client";
2
-
import { useEffect, useRef, useState } from "react";
3
-
import { create } from "zustand";
4
-
import { ReplicacheMutators, useReplicache } from "src/replicache";
5
-
import { useUIState } from "src/useUIState";
6
-
import { scanIndex } from "src/replicache/utils";
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { useEditorStates } from "src/state/useEditorState";
9
-
import { useEntitySetContext } from "./EntitySetProvider";
10
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
11
-
import { v7 } from "uuid";
12
-
import { indent, outdent, outdentFull } from "src/utils/list-operations";
13
-
import { addShortcut, Shortcut } from "src/shortcuts";
14
-
import { htmlToMarkdown } from "src/htmlMarkdownParsers";
15
-
import { elementId } from "src/utils/elementId";
16
-
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
17
-
import { copySelection } from "src/utils/copySelection";
18
-
import { isTextBlock } from "src/utils/isTextBlock";
19
-
import { useIsMobile } from "src/hooks/isMobile";
20
-
import { deleteBlock } from "./Blocks/DeleteBlock";
21
-
import { Replicache } from "replicache";
22
-
import { schema } from "./Blocks/TextBlock/schema";
23
-
import { TextSelection } from "prosemirror-state";
24
-
import { MarkType } from "prosemirror-model";
25
-
export const useSelectingMouse = create(() => ({
26
-
start: null as null | string,
27
-
}));
28
-
29
-
//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
30
-
// How does this relate to *when dragging* ?
31
-
32
-
export function SelectionManager() {
33
-
let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
34
-
let entity_set = useEntitySetContext();
35
-
let { rep, undoManager } = useReplicache();
36
-
let isMobile = useIsMobile();
37
-
useEffect(() => {
38
-
if (!entity_set.permissions.write || !rep) return;
39
-
const getSortedSelectionBound = getSortedSelection.bind(null, rep);
40
-
let shortcuts: Shortcut[] = [
41
-
{
42
-
metaKey: true,
43
-
key: "ArrowUp",
44
-
handler: async () => {
45
-
let [firstBlock] =
46
-
(await rep?.query((tx) =>
47
-
getBlocksWithType(
48
-
tx,
49
-
useUIState.getState().selectedBlocks[0].parent,
50
-
),
51
-
)) || [];
52
-
if (firstBlock) focusBlock(firstBlock, { type: "start" });
53
-
},
54
-
},
55
-
{
56
-
metaKey: true,
57
-
key: "ArrowDown",
58
-
handler: async () => {
59
-
let blocks =
60
-
(await rep?.query((tx) =>
61
-
getBlocksWithType(
62
-
tx,
63
-
useUIState.getState().selectedBlocks[0].parent,
64
-
),
65
-
)) || [];
66
-
let folded = useUIState.getState().foldedBlocks;
67
-
blocks = blocks.filter(
68
-
(f) =>
69
-
!f.listData ||
70
-
!f.listData.path.find(
71
-
(path) =>
72
-
folded.includes(path.entity) && f.value !== path.entity,
73
-
),
74
-
);
75
-
let lastBlock = blocks[blocks.length - 1];
76
-
if (lastBlock) focusBlock(lastBlock, { type: "end" });
77
-
},
78
-
},
79
-
{
80
-
metaKey: true,
81
-
altKey: true,
82
-
key: ["l", "ยฌ"],
83
-
handler: async () => {
84
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
85
-
for (let block of sortedBlocks) {
86
-
if (!block.listData) {
87
-
await rep?.mutate.assertFact({
88
-
entity: block.value,
89
-
attribute: "block/is-list",
90
-
data: { type: "boolean", value: true },
91
-
});
92
-
} else {
93
-
outdentFull(block, rep);
94
-
}
95
-
}
96
-
},
97
-
},
98
-
{
99
-
metaKey: true,
100
-
shift: true,
101
-
key: ["ArrowDown", "J"],
102
-
handler: async () => {
103
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
104
-
let block = sortedBlocks[0];
105
-
let nextBlock = siblings
106
-
.slice(siblings.findIndex((s) => s.value === block.value) + 1)
107
-
.find(
108
-
(f) =>
109
-
f.listData &&
110
-
block.listData &&
111
-
!f.listData.path.find((f) => f.entity === block.value),
112
-
);
113
-
if (
114
-
nextBlock?.listData &&
115
-
block.listData &&
116
-
nextBlock.listData.depth === block.listData.depth - 1
117
-
) {
118
-
if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
119
-
useUIState.getState().toggleFold(nextBlock.value);
120
-
await rep?.mutate.moveBlock({
121
-
block: block.value,
122
-
oldParent: block.listData?.parent,
123
-
newParent: nextBlock.value,
124
-
position: { type: "first" },
125
-
});
126
-
} else {
127
-
await rep?.mutate.moveBlockDown({
128
-
entityID: block.value,
129
-
parent: block.listData?.parent || block.parent,
130
-
});
131
-
}
132
-
},
133
-
},
134
-
{
135
-
metaKey: true,
136
-
shift: true,
137
-
key: ["ArrowUp", "K"],
138
-
handler: async () => {
139
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
140
-
let block = sortedBlocks[0];
141
-
let previousBlock =
142
-
siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
143
-
if (previousBlock.value === block.listData?.parent) {
144
-
previousBlock =
145
-
siblings?.[
146
-
siblings.findIndex((s) => s.value === block.value) - 2
147
-
];
148
-
}
149
-
150
-
if (
151
-
previousBlock?.listData &&
152
-
block.listData &&
153
-
block.listData.depth > 1 &&
154
-
!previousBlock.listData.path.find(
155
-
(f) => f.entity === block.listData?.parent,
156
-
)
157
-
) {
158
-
let depth = block.listData.depth;
159
-
let newParent = previousBlock.listData.path.find(
160
-
(f) => f.depth === depth - 1,
161
-
);
162
-
if (!newParent) return;
163
-
if (useUIState.getState().foldedBlocks.includes(newParent.entity))
164
-
useUIState.getState().toggleFold(newParent.entity);
165
-
rep?.mutate.moveBlock({
166
-
block: block.value,
167
-
oldParent: block.listData?.parent,
168
-
newParent: newParent.entity,
169
-
position: { type: "end" },
170
-
});
171
-
} else {
172
-
rep?.mutate.moveBlockUp({
173
-
entityID: block.value,
174
-
parent: block.listData?.parent || block.parent,
175
-
});
176
-
}
177
-
},
178
-
},
179
-
180
-
{
181
-
metaKey: true,
182
-
shift: true,
183
-
key: "Enter",
184
-
handler: async () => {
185
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
186
-
if (!sortedBlocks[0].listData) return;
187
-
useUIState.getState().toggleFold(sortedBlocks[0].value);
188
-
},
189
-
},
190
-
];
191
-
if (moreThanOneSelected)
192
-
shortcuts = shortcuts.concat([
193
-
{
194
-
metaKey: true,
195
-
key: "u",
196
-
handler: async () => {
197
-
let [sortedBlocks] = await getSortedSelectionBound();
198
-
toggleMarkInBlocks(
199
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
200
-
schema.marks.underline,
201
-
);
202
-
},
203
-
},
204
-
{
205
-
metaKey: true,
206
-
key: "i",
207
-
handler: async () => {
208
-
let [sortedBlocks] = await getSortedSelectionBound();
209
-
toggleMarkInBlocks(
210
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
211
-
schema.marks.em,
212
-
);
213
-
},
214
-
},
215
-
{
216
-
metaKey: true,
217
-
key: "b",
218
-
handler: async () => {
219
-
let [sortedBlocks] = await getSortedSelectionBound();
220
-
toggleMarkInBlocks(
221
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
222
-
schema.marks.strong,
223
-
);
224
-
},
225
-
},
226
-
{
227
-
metaAndCtrl: true,
228
-
key: "h",
229
-
handler: async () => {
230
-
let [sortedBlocks] = await getSortedSelectionBound();
231
-
toggleMarkInBlocks(
232
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
233
-
schema.marks.highlight,
234
-
{
235
-
color: useUIState.getState().lastUsedHighlight,
236
-
},
237
-
);
238
-
},
239
-
},
240
-
{
241
-
metaAndCtrl: true,
242
-
key: "x",
243
-
handler: async () => {
244
-
let [sortedBlocks] = await getSortedSelectionBound();
245
-
toggleMarkInBlocks(
246
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
247
-
schema.marks.strikethrough,
248
-
);
249
-
},
250
-
},
251
-
]);
252
-
let removeListener = addShortcut(
253
-
shortcuts.map((shortcut) => ({
254
-
...shortcut,
255
-
handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
256
-
})),
257
-
);
258
-
let listener = async (e: KeyboardEvent) =>
259
-
undoManager.withUndoGroup(async () => {
260
-
//used here and in cut
261
-
const deleteBlocks = async () => {
262
-
if (!entity_set.permissions.write) return;
263
-
if (moreThanOneSelected) {
264
-
e.preventDefault();
265
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
266
-
let selectedBlocks = useUIState.getState().selectedBlocks;
267
-
let firstBlock = sortedBlocks[0];
268
-
269
-
await rep?.mutate.removeBlock(
270
-
selectedBlocks.map((block) => ({ blockEntity: block.value })),
271
-
);
272
-
useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
273
-
274
-
let nextBlock =
275
-
siblings?.[
276
-
siblings.findIndex((s) => s.value === firstBlock.value) - 1
277
-
];
278
-
if (nextBlock) {
279
-
useUIState.getState().setSelectedBlock({
280
-
value: nextBlock.value,
281
-
parent: nextBlock.parent,
282
-
});
283
-
let type = await rep?.query((tx) =>
284
-
scanIndex(tx).eav(nextBlock.value, "block/type"),
285
-
);
286
-
if (!type?.[0]) return;
287
-
if (
288
-
type[0]?.data.value === "text" ||
289
-
type[0]?.data.value === "heading"
290
-
)
291
-
focusBlock(
292
-
{
293
-
value: nextBlock.value,
294
-
type: "text",
295
-
parent: nextBlock.parent,
296
-
},
297
-
{ type: "end" },
298
-
);
299
-
}
300
-
}
301
-
};
302
-
if (e.key === "Backspace" || e.key === "Delete") {
303
-
deleteBlocks();
304
-
}
305
-
if (e.key === "ArrowUp") {
306
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
307
-
let focusedBlock = useUIState.getState().focusedEntity;
308
-
if (!e.shiftKey && !e.ctrlKey) {
309
-
if (e.defaultPrevented) return;
310
-
if (sortedBlocks.length === 1) return;
311
-
let firstBlock = sortedBlocks[0];
312
-
if (!firstBlock) return;
313
-
let type = await rep?.query((tx) =>
314
-
scanIndex(tx).eav(firstBlock.value, "block/type"),
315
-
);
316
-
if (!type?.[0]) return;
317
-
useUIState.getState().setSelectedBlock(firstBlock);
318
-
focusBlock(
319
-
{ ...firstBlock, type: type[0].data.value },
320
-
{ type: "start" },
321
-
);
322
-
} else {
323
-
if (e.defaultPrevented) return;
324
-
if (
325
-
sortedBlocks.length <= 1 ||
326
-
!focusedBlock ||
327
-
focusedBlock.entityType === "page"
328
-
)
329
-
return;
330
-
let b = focusedBlock;
331
-
let focusedBlockIndex = sortedBlocks.findIndex(
332
-
(s) => s.value == b.entityID,
333
-
);
334
-
if (focusedBlockIndex === 0) {
335
-
let index = siblings.findIndex((s) => s.value === b.entityID);
336
-
let nextSelectedBlock = siblings[index - 1];
337
-
if (!nextSelectedBlock) return;
338
-
339
-
scrollIntoViewIfNeeded(
340
-
document.getElementById(
341
-
elementId.block(nextSelectedBlock.value).container,
342
-
),
343
-
false,
344
-
);
345
-
useUIState.getState().addBlockToSelection({
346
-
...nextSelectedBlock,
347
-
});
348
-
useUIState.getState().setFocusedBlock({
349
-
entityType: "block",
350
-
parent: nextSelectedBlock.parent,
351
-
entityID: nextSelectedBlock.value,
352
-
});
353
-
} else {
354
-
let nextBlock = sortedBlocks[sortedBlocks.length - 2];
355
-
useUIState.getState().setFocusedBlock({
356
-
entityType: "block",
357
-
parent: b.parent,
358
-
entityID: nextBlock.value,
359
-
});
360
-
scrollIntoViewIfNeeded(
361
-
document.getElementById(
362
-
elementId.block(nextBlock.value).container,
363
-
),
364
-
false,
365
-
);
366
-
if (sortedBlocks.length === 2) {
367
-
useEditorStates
368
-
.getState()
369
-
.editorStates[nextBlock.value]?.view?.focus();
370
-
}
371
-
useUIState
372
-
.getState()
373
-
.removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
374
-
}
375
-
}
376
-
}
377
-
if (e.key === "ArrowLeft") {
378
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
379
-
if (sortedSelection.length === 1) return;
380
-
let firstBlock = sortedSelection[0];
381
-
if (!firstBlock) return;
382
-
let type = await rep?.query((tx) =>
383
-
scanIndex(tx).eav(firstBlock.value, "block/type"),
384
-
);
385
-
if (!type?.[0]) return;
386
-
useUIState.getState().setSelectedBlock(firstBlock);
387
-
focusBlock(
388
-
{ ...firstBlock, type: type[0].data.value },
389
-
{ type: "start" },
390
-
);
391
-
}
392
-
if (e.key === "ArrowRight") {
393
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
394
-
if (sortedSelection.length === 1) return;
395
-
let lastBlock = sortedSelection[sortedSelection.length - 1];
396
-
if (!lastBlock) return;
397
-
let type = await rep?.query((tx) =>
398
-
scanIndex(tx).eav(lastBlock.value, "block/type"),
399
-
);
400
-
if (!type?.[0]) return;
401
-
useUIState.getState().setSelectedBlock(lastBlock);
402
-
focusBlock(
403
-
{ ...lastBlock, type: type[0].data.value },
404
-
{ type: "end" },
405
-
);
406
-
}
407
-
if (e.key === "Tab") {
408
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
409
-
if (sortedSelection.length <= 1) return;
410
-
e.preventDefault();
411
-
if (e.shiftKey) {
412
-
for (let i = siblings.length - 1; i >= 0; i--) {
413
-
let block = siblings[i];
414
-
if (!sortedSelection.find((s) => s.value === block.value))
415
-
continue;
416
-
if (
417
-
sortedSelection.find((s) => s.value === block.listData?.parent)
418
-
)
419
-
continue;
420
-
let parentoffset = 1;
421
-
let previousBlock = siblings[i - parentoffset];
422
-
while (
423
-
previousBlock &&
424
-
sortedSelection.find((s) => previousBlock.value === s.value)
425
-
) {
426
-
parentoffset += 1;
427
-
previousBlock = siblings[i - parentoffset];
428
-
}
429
-
if (!block.listData || !previousBlock.listData) continue;
430
-
outdent(block, previousBlock, rep);
431
-
}
432
-
} else {
433
-
for (let i = 0; i < siblings.length; i++) {
434
-
let block = siblings[i];
435
-
if (!sortedSelection.find((s) => s.value === block.value))
436
-
continue;
437
-
if (
438
-
sortedSelection.find((s) => s.value === block.listData?.parent)
439
-
)
440
-
continue;
441
-
let parentoffset = 1;
442
-
let previousBlock = siblings[i - parentoffset];
443
-
while (
444
-
previousBlock &&
445
-
sortedSelection.find((s) => previousBlock.value === s.value)
446
-
) {
447
-
parentoffset += 1;
448
-
previousBlock = siblings[i - parentoffset];
449
-
}
450
-
if (!block.listData || !previousBlock.listData) continue;
451
-
indent(block, previousBlock, rep);
452
-
}
453
-
}
454
-
}
455
-
if (e.key === "ArrowDown") {
456
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
457
-
let focusedBlock = useUIState.getState().focusedEntity;
458
-
if (!e.shiftKey) {
459
-
if (sortedSelection.length === 1) return;
460
-
let lastBlock = sortedSelection[sortedSelection.length - 1];
461
-
if (!lastBlock) return;
462
-
let type = await rep?.query((tx) =>
463
-
scanIndex(tx).eav(lastBlock.value, "block/type"),
464
-
);
465
-
if (!type?.[0]) return;
466
-
useUIState.getState().setSelectedBlock(lastBlock);
467
-
focusBlock(
468
-
{ ...lastBlock, type: type[0].data.value },
469
-
{ type: "end" },
470
-
);
471
-
}
472
-
if (e.shiftKey) {
473
-
if (e.defaultPrevented) return;
474
-
if (
475
-
sortedSelection.length <= 1 ||
476
-
!focusedBlock ||
477
-
focusedBlock.entityType === "page"
478
-
)
479
-
return;
480
-
let b = focusedBlock;
481
-
let focusedBlockIndex = sortedSelection.findIndex(
482
-
(s) => s.value == b.entityID,
483
-
);
484
-
if (focusedBlockIndex === sortedSelection.length - 1) {
485
-
let index = siblings.findIndex((s) => s.value === b.entityID);
486
-
let nextSelectedBlock = siblings[index + 1];
487
-
if (!nextSelectedBlock) return;
488
-
useUIState.getState().addBlockToSelection({
489
-
...nextSelectedBlock,
490
-
});
491
-
492
-
scrollIntoViewIfNeeded(
493
-
document.getElementById(
494
-
elementId.block(nextSelectedBlock.value).container,
495
-
),
496
-
false,
497
-
);
498
-
useUIState.getState().setFocusedBlock({
499
-
entityType: "block",
500
-
parent: nextSelectedBlock.parent,
501
-
entityID: nextSelectedBlock.value,
502
-
});
503
-
} else {
504
-
let nextBlock = sortedSelection[1];
505
-
useUIState
506
-
.getState()
507
-
.removeBlockFromSelection({ value: b.entityID });
508
-
scrollIntoViewIfNeeded(
509
-
document.getElementById(
510
-
elementId.block(nextBlock.value).container,
511
-
),
512
-
false,
513
-
);
514
-
useUIState.getState().setFocusedBlock({
515
-
entityType: "block",
516
-
parent: b.parent,
517
-
entityID: nextBlock.value,
518
-
});
519
-
if (sortedSelection.length === 2) {
520
-
useEditorStates
521
-
.getState()
522
-
.editorStates[nextBlock.value]?.view?.focus();
523
-
}
524
-
}
525
-
}
526
-
}
527
-
if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) {
528
-
if (!rep) return;
529
-
if (e.shiftKey || (e.metaKey && e.ctrlKey)) return;
530
-
let [, , selectionWithFoldedChildren] =
531
-
await getSortedSelectionBound();
532
-
if (!selectionWithFoldedChildren) return;
533
-
let el = document.activeElement as HTMLElement;
534
-
if (
535
-
el?.tagName === "LABEL" ||
536
-
el?.tagName === "INPUT" ||
537
-
el?.tagName === "TEXTAREA"
538
-
) {
539
-
return;
540
-
}
541
-
542
-
if (
543
-
el.contentEditable === "true" &&
544
-
selectionWithFoldedChildren.length <= 1
545
-
)
546
-
return;
547
-
e.preventDefault();
548
-
await copySelection(rep, selectionWithFoldedChildren);
549
-
if (e.key === "x") deleteBlocks();
550
-
}
551
-
});
552
-
window.addEventListener("keydown", listener);
553
-
return () => {
554
-
removeListener();
555
-
window.removeEventListener("keydown", listener);
556
-
};
557
-
}, [moreThanOneSelected, rep, entity_set.permissions.write]);
558
-
559
-
let [mouseDown, setMouseDown] = useState(false);
560
-
let initialContentEditableParent = useRef<null | Node>(null);
561
-
let savedSelection = useRef<SavedRange[] | null>(undefined);
562
-
useEffect(() => {
563
-
if (isMobile) return;
564
-
if (!entity_set.permissions.write) return;
565
-
let mouseDownListener = (e: MouseEvent) => {
566
-
if ((e.target as Element).getAttribute("data-draggable")) return;
567
-
let contentEditableParent = getContentEditableParent(e.target as Node);
568
-
if (contentEditableParent) {
569
-
setMouseDown(true);
570
-
let entityID = (contentEditableParent as Element).getAttribute(
571
-
"data-entityid",
572
-
);
573
-
useSelectingMouse.setState({ start: entityID });
574
-
}
575
-
initialContentEditableParent.current = contentEditableParent;
576
-
};
577
-
let mouseUpListener = (e: MouseEvent) => {
578
-
savedSelection.current = null;
579
-
if (
580
-
initialContentEditableParent.current &&
581
-
!(e.target as Element).getAttribute("data-draggable") &&
582
-
getContentEditableParent(e.target as Node) !==
583
-
initialContentEditableParent.current
584
-
) {
585
-
setTimeout(() => {
586
-
window.getSelection()?.removeAllRanges();
587
-
}, 5);
588
-
}
589
-
initialContentEditableParent.current = null;
590
-
useSelectingMouse.setState({ start: null });
591
-
setMouseDown(false);
592
-
};
593
-
window.addEventListener("mousedown", mouseDownListener);
594
-
window.addEventListener("mouseup", mouseUpListener);
595
-
return () => {
596
-
window.removeEventListener("mousedown", mouseDownListener);
597
-
window.removeEventListener("mouseup", mouseUpListener);
598
-
};
599
-
}, [entity_set.permissions.write, isMobile]);
600
-
useEffect(() => {
601
-
if (!mouseDown) return;
602
-
if (isMobile) return;
603
-
let mouseMoveListener = (e: MouseEvent) => {
604
-
if (e.buttons !== 1) return;
605
-
if (initialContentEditableParent.current) {
606
-
if (
607
-
initialContentEditableParent.current ===
608
-
getContentEditableParent(e.target as Node)
609
-
) {
610
-
if (savedSelection.current) {
611
-
restoreSelection(savedSelection.current);
612
-
}
613
-
savedSelection.current = null;
614
-
return;
615
-
}
616
-
if (!savedSelection.current) savedSelection.current = saveSelection();
617
-
window.getSelection()?.removeAllRanges();
618
-
}
619
-
};
620
-
window.addEventListener("mousemove", mouseMoveListener);
621
-
return () => {
622
-
window.removeEventListener("mousemove", mouseMoveListener);
623
-
};
624
-
}, [mouseDown, isMobile]);
625
-
return null;
626
-
}
627
-
628
-
type SavedRange = {
629
-
startContainer: Node;
630
-
startOffset: number;
631
-
endContainer: Node;
632
-
endOffset: number;
633
-
direction: "forward" | "backward";
634
-
};
635
-
export function saveSelection() {
636
-
let selection = window.getSelection();
637
-
if (selection && selection.rangeCount > 0) {
638
-
let ranges: SavedRange[] = [];
639
-
for (let i = 0; i < selection.rangeCount; i++) {
640
-
let range = selection.getRangeAt(i);
641
-
ranges.push({
642
-
startContainer: range.startContainer,
643
-
startOffset: range.startOffset,
644
-
endContainer: range.endContainer,
645
-
endOffset: range.endOffset,
646
-
direction:
647
-
selection.anchorNode === range.startContainer &&
648
-
selection.anchorOffset === range.startOffset
649
-
? "forward"
650
-
: "backward",
651
-
});
652
-
}
653
-
return ranges;
654
-
}
655
-
return [];
656
-
}
657
-
658
-
export function restoreSelection(savedRanges: SavedRange[]) {
659
-
if (savedRanges && savedRanges.length > 0) {
660
-
let selection = window.getSelection();
661
-
if (!selection) return;
662
-
selection.removeAllRanges();
663
-
for (let i = 0; i < savedRanges.length; i++) {
664
-
let range = document.createRange();
665
-
range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset);
666
-
range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset);
667
-
668
-
selection.addRange(range);
669
-
670
-
// If the direction is backward, collapse the selection to the end and then extend it backward
671
-
if (savedRanges[i].direction === "backward") {
672
-
selection.collapseToEnd();
673
-
selection.extend(
674
-
savedRanges[i].startContainer,
675
-
savedRanges[i].startOffset,
676
-
);
677
-
}
678
-
}
679
-
}
680
-
}
681
-
682
-
function getContentEditableParent(e: Node | null): Node | null {
683
-
let element: Node | null = e;
684
-
while (element && element !== document) {
685
-
if (
686
-
(element as HTMLElement).contentEditable === "true" ||
687
-
(element as HTMLElement).getAttribute("data-editable-block")
688
-
) {
689
-
return element;
690
-
}
691
-
element = element.parentNode;
692
-
}
693
-
return null;
694
-
}
695
-
696
-
export const getSortedSelection = async (
697
-
rep: Replicache<ReplicacheMutators>,
698
-
) => {
699
-
let selectedBlocks = useUIState.getState().selectedBlocks;
700
-
let foldedBlocks = useUIState.getState().foldedBlocks;
701
-
if (!selectedBlocks[0]) return [[], []];
702
-
let siblings =
703
-
(await rep?.query((tx) =>
704
-
getBlocksWithType(tx, selectedBlocks[0].parent),
705
-
)) || [];
706
-
let sortedBlocks = siblings.filter((s) => {
707
-
let selected = selectedBlocks.find((sb) => sb.value === s.value);
708
-
return selected;
709
-
});
710
-
let sortedBlocksWithChildren = siblings.filter((s) => {
711
-
let selected = selectedBlocks.find((sb) => sb.value === s.value);
712
-
if (s.listData && !selected) {
713
-
//Select the children of folded list blocks (in order to copy them)
714
-
return s.listData.path.find(
715
-
(p) =>
716
-
selectedBlocks.find((sb) => sb.value === p.entity) &&
717
-
foldedBlocks.includes(p.entity),
718
-
);
719
-
}
720
-
return selected;
721
-
});
722
-
return [
723
-
sortedBlocks,
724
-
siblings.filter(
725
-
(f) =>
726
-
!f.listData ||
727
-
!f.listData.path.find(
728
-
(p) => foldedBlocks.includes(p.entity) && p.entity !== f.value,
729
-
),
730
-
),
731
-
sortedBlocksWithChildren,
732
-
];
733
-
};
734
-
735
-
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
736
-
let everyBlockHasMark = blocks.reduce((acc, block) => {
737
-
let editor = useEditorStates.getState().editorStates[block];
738
-
if (!editor) return acc;
739
-
let { view } = editor;
740
-
let from = 0;
741
-
let to = view.state.doc.content.size;
742
-
let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark);
743
-
return acc && hasMarkInRange;
744
-
}, true);
745
-
for (let block of blocks) {
746
-
let editor = useEditorStates.getState().editorStates[block];
747
-
if (!editor) return;
748
-
let { view } = editor;
749
-
let tr = view.state.tr;
750
-
751
-
let from = 0;
752
-
let to = view.state.doc.content.size;
753
-
754
-
tr.setMeta("bulkOp", true);
755
-
if (everyBlockHasMark) {
756
-
tr.removeMark(from, to, mark);
757
-
} else {
758
-
tr.addMark(from, to, mark.create(attrs));
759
-
}
760
-
761
-
view.dispatch(tr);
762
-
}
763
-
}
+296
components/Tags.tsx
+296
components/Tags.tsx
···
1
+
"use client";
2
+
import { CloseTiny } from "components/Icons/CloseTiny";
3
+
import { Input } from "components/Input";
4
+
import { useState, useRef } from "react";
5
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
+
import { Popover } from "components/Popover";
7
+
import Link from "next/link";
8
+
import { searchTags, type TagSearchResult } from "actions/searchTags";
9
+
10
+
export const Tag = (props: {
11
+
name: string;
12
+
selected?: boolean;
13
+
onDelete?: (tag: string) => void;
14
+
className?: string;
15
+
}) => {
16
+
return (
17
+
<div
18
+
className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`}
19
+
>
20
+
<Link
21
+
href={`https://leaflet.pub/tag/${encodeURIComponent(props.name)}`}
22
+
className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`}
23
+
>
24
+
{props.name}{" "}
25
+
</Link>
26
+
{props.selected ? (
27
+
<button
28
+
type="button"
29
+
onClick={() => (props.onDelete ? props.onDelete(props.name) : null)}
30
+
>
31
+
<CloseTiny className="scale-75 pr-1 text-accent-2" />
32
+
</button>
33
+
) : null}
34
+
</div>
35
+
);
36
+
};
37
+
38
+
export const TagSelector = (props: {
39
+
selectedTags: string[];
40
+
setSelectedTags: (tags: string[]) => void;
41
+
}) => {
42
+
return (
43
+
<div className="flex flex-col gap-2 text-primary">
44
+
<TagSearchInput
45
+
selectedTags={props.selectedTags}
46
+
setSelectedTags={props.setSelectedTags}
47
+
/>
48
+
{props.selectedTags.length > 0 ? (
49
+
<div className="flex flex-wrap gap-2 ">
50
+
{props.selectedTags.map((tag) => (
51
+
<Tag
52
+
key={tag}
53
+
name={tag}
54
+
selected
55
+
onDelete={() => {
56
+
props.setSelectedTags(
57
+
props.selectedTags.filter((t) => t !== tag),
58
+
);
59
+
}}
60
+
/>
61
+
))}
62
+
</div>
63
+
) : (
64
+
<div className="text-tertiary italic text-sm h-6">no tags selected</div>
65
+
)}
66
+
</div>
67
+
);
68
+
};
69
+
70
+
export const TagSearchInput = (props: {
71
+
selectedTags: string[];
72
+
setSelectedTags: (tags: string[]) => void;
73
+
}) => {
74
+
let [tagInputValue, setTagInputValue] = useState("");
75
+
let [isOpen, setIsOpen] = useState(false);
76
+
let [highlightedIndex, setHighlightedIndex] = useState(0);
77
+
let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]);
78
+
let [isSearching, setIsSearching] = useState(false);
79
+
80
+
const placeholderInputRef = useRef<HTMLButtonElement | null>(null);
81
+
82
+
let inputWidth = placeholderInputRef.current?.clientWidth;
83
+
84
+
// Fetch tags whenever the input value changes
85
+
useDebouncedEffect(
86
+
async () => {
87
+
setIsSearching(true);
88
+
const results = await searchTags(tagInputValue);
89
+
if (results) {
90
+
setSearchResults(results);
91
+
}
92
+
setIsSearching(false);
93
+
},
94
+
300,
95
+
[tagInputValue],
96
+
);
97
+
98
+
const filteredTags = searchResults
99
+
.filter((tag) => !props.selectedTags.includes(tag.name))
100
+
.filter((tag) =>
101
+
tag.name.toLowerCase().includes(tagInputValue.toLowerCase()),
102
+
);
103
+
104
+
const showResults = tagInputValue.length >= 3;
105
+
106
+
function clearTagInput() {
107
+
setHighlightedIndex(0);
108
+
setTagInputValue("");
109
+
}
110
+
111
+
function selectTag(tag: string) {
112
+
console.log("selected " + tag);
113
+
props.setSelectedTags([...props.selectedTags, tag]);
114
+
clearTagInput();
115
+
}
116
+
117
+
const handleKeyDown = (
118
+
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
119
+
) => {
120
+
if (!isOpen) return;
121
+
122
+
if (e.key === "ArrowDown") {
123
+
e.preventDefault();
124
+
setHighlightedIndex((prev) =>
125
+
prev < filteredTags.length ? prev + 1 : prev,
126
+
);
127
+
} else if (e.key === "ArrowUp") {
128
+
e.preventDefault();
129
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
130
+
} else if (e.key === "Enter") {
131
+
e.preventDefault();
132
+
selectTag(
133
+
userInputResult
134
+
? highlightedIndex === 0
135
+
? tagInputValue
136
+
: filteredTags[highlightedIndex - 1].name
137
+
: filteredTags[highlightedIndex].name,
138
+
);
139
+
clearTagInput();
140
+
} else if (e.key === "Escape") {
141
+
setIsOpen(false);
142
+
}
143
+
};
144
+
145
+
const userInputResult =
146
+
showResults &&
147
+
tagInputValue !== "" &&
148
+
!filteredTags.some((tag) => tag.name === tagInputValue);
149
+
150
+
return (
151
+
<div className="relative">
152
+
<Input
153
+
className="input-with-border grow w-full outline-none!"
154
+
id="placeholder-tag-search-input"
155
+
value={tagInputValue}
156
+
placeholder="search tagsโฆ"
157
+
onChange={(e) => {
158
+
setTagInputValue(e.target.value);
159
+
setIsOpen(true);
160
+
setHighlightedIndex(0);
161
+
}}
162
+
onKeyDown={handleKeyDown}
163
+
onFocus={() => {
164
+
setIsOpen(true);
165
+
document.getElementById("tag-search-input")?.focus();
166
+
}}
167
+
/>
168
+
<Popover
169
+
open={isOpen}
170
+
onOpenChange={() => {
171
+
setIsOpen(!isOpen);
172
+
if (!isOpen)
173
+
setTimeout(() => {
174
+
document.getElementById("tag-search-input")?.focus();
175
+
}, 100);
176
+
}}
177
+
className="w-full p-2! min-w-xs text-primary"
178
+
sideOffset={-39}
179
+
onOpenAutoFocus={(e) => e.preventDefault()}
180
+
asChild
181
+
trigger={
182
+
<button
183
+
ref={placeholderInputRef}
184
+
className="absolute left-0 top-0 right-0 h-[30px]"
185
+
></button>
186
+
}
187
+
noArrow
188
+
>
189
+
<div className="" style={{ width: `${inputWidth}px` }}>
190
+
<Input
191
+
className="input-with-border grow w-full mb-2"
192
+
id="tag-search-input"
193
+
placeholder="search tagsโฆ"
194
+
value={tagInputValue}
195
+
onChange={(e) => {
196
+
setTagInputValue(e.target.value);
197
+
setIsOpen(true);
198
+
setHighlightedIndex(0);
199
+
}}
200
+
onKeyDown={handleKeyDown}
201
+
onFocus={() => {
202
+
setIsOpen(true);
203
+
}}
204
+
/>
205
+
{props.selectedTags.length > 0 ? (
206
+
<div className="flex flex-wrap gap-2 pb-[6px]">
207
+
{props.selectedTags.map((tag) => (
208
+
<Tag
209
+
key={tag}
210
+
name={tag}
211
+
selected
212
+
onDelete={() => {
213
+
props.setSelectedTags(
214
+
props.selectedTags.filter((t) => t !== tag),
215
+
);
216
+
}}
217
+
/>
218
+
))}
219
+
</div>
220
+
) : (
221
+
<div className="text-tertiary italic text-sm h-6">
222
+
no tags selected
223
+
</div>
224
+
)}
225
+
<hr className=" mb-[2px] border-border-light" />
226
+
227
+
{showResults ? (
228
+
<>
229
+
{userInputResult && (
230
+
<TagResult
231
+
key={"userInput"}
232
+
index={0}
233
+
name={tagInputValue}
234
+
tagged={0}
235
+
highlighted={0 === highlightedIndex}
236
+
setHighlightedIndex={setHighlightedIndex}
237
+
onSelect={() => {
238
+
selectTag(tagInputValue);
239
+
}}
240
+
/>
241
+
)}
242
+
{filteredTags.map((tag, i) => (
243
+
<TagResult
244
+
key={tag.name}
245
+
index={userInputResult ? i + 1 : i}
246
+
name={tag.name}
247
+
tagged={tag.document_count}
248
+
highlighted={
249
+
(userInputResult ? i + 1 : i) === highlightedIndex
250
+
}
251
+
setHighlightedIndex={setHighlightedIndex}
252
+
onSelect={() => {
253
+
selectTag(tag.name);
254
+
}}
255
+
/>
256
+
))}
257
+
</>
258
+
) : (
259
+
<div className="text-tertiary italic text-sm py-1">
260
+
type at least 3 characters to search
261
+
</div>
262
+
)}
263
+
</div>
264
+
</Popover>
265
+
</div>
266
+
);
267
+
};
268
+
269
+
const TagResult = (props: {
270
+
name: string;
271
+
tagged: number;
272
+
onSelect: () => void;
273
+
index: number;
274
+
highlighted: boolean;
275
+
setHighlightedIndex: (i: number) => void;
276
+
}) => {
277
+
return (
278
+
<div className="-mx-1">
279
+
<button
280
+
className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`}
281
+
onSelect={(e) => {
282
+
e.preventDefault();
283
+
props.onSelect();
284
+
}}
285
+
onClick={(e) => {
286
+
e.preventDefault();
287
+
props.onSelect();
288
+
}}
289
+
onMouseEnter={(e) => props.setHighlightedIndex(props.index)}
290
+
>
291
+
{props.name}
292
+
<div className="text-tertiary text-sm"> {props.tagged}</div>
293
+
</button>
294
+
</div>
295
+
);
296
+
};
+2
-43
components/ThemeManager/PubThemeSetter.tsx
+2
-43
components/ThemeManager/PubThemeSetter.tsx
···
16
16
import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17
17
import { Separator } from "components/Layout";
18
18
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings";
19
+
import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
19
20
20
21
export type ImageState = {
21
22
src: string;
···
39
40
theme: localPubTheme,
40
41
setTheme,
41
42
changes,
42
-
} = useLocalPubTheme(record, showPageBackground);
43
+
} = useLocalPubTheme(record?.theme, showPageBackground);
43
44
let [image, setImage] = useState<ImageState | null>(
44
45
PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage)
45
46
? {
···
343
344
</div>
344
345
);
345
346
};
346
-
347
-
export function ColorToRGBA(color: Color) {
348
-
if (!color)
349
-
return {
350
-
$type: "pub.leaflet.theme.color#rgba" as const,
351
-
r: 0,
352
-
g: 0,
353
-
b: 0,
354
-
a: 1,
355
-
};
356
-
let c = color.toFormat("rgba");
357
-
const r = c.getChannelValue("red");
358
-
const g = c.getChannelValue("green");
359
-
const b = c.getChannelValue("blue");
360
-
const a = c.getChannelValue("alpha");
361
-
return {
362
-
$type: "pub.leaflet.theme.color#rgba" as const,
363
-
r: Math.round(r),
364
-
g: Math.round(g),
365
-
b: Math.round(b),
366
-
a: Math.round(a * 100),
367
-
};
368
-
}
369
-
function ColorToRGB(color: Color) {
370
-
if (!color)
371
-
return {
372
-
$type: "pub.leaflet.theme.color#rgb" as const,
373
-
r: 0,
374
-
g: 0,
375
-
b: 0,
376
-
};
377
-
let c = color.toFormat("rgb");
378
-
const r = c.getChannelValue("red");
379
-
const g = c.getChannelValue("green");
380
-
const b = c.getChannelValue("blue");
381
-
return {
382
-
$type: "pub.leaflet.theme.color#rgb" as const,
383
-
r: Math.round(r),
384
-
g: Math.round(g),
385
-
b: Math.round(b),
386
-
};
387
-
}
+39
-27
components/ThemeManager/PublicationThemeProvider.tsx
+39
-27
components/ThemeManager/PublicationThemeProvider.tsx
···
2
2
import { useMemo, useState } from "react";
3
3
import { parseColor } from "react-aria-components";
4
4
import { useEntity } from "src/replicache";
5
-
import { getColorContrast } from "./ThemeProvider";
5
+
import { getColorContrast } from "./themeUtils";
6
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
7
import { BaseThemeProvider } from "./ThemeProvider";
8
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
16
16
accentText: "#FFFFFF",
17
17
accentBackground: "#0000FF",
18
18
};
19
+
20
+
// Default page background for standalone leaflets (matches editor default)
21
+
const StandalonePageBackground = "#FFFFFF";
19
22
function parseThemeColor(
20
23
c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba,
21
24
) {
···
26
29
}
27
30
28
31
let useColor = (
29
-
record: PubLeafletPublication.Record | null | undefined,
32
+
theme: PubLeafletPublication.Record["theme"] | null | undefined,
30
33
c: keyof typeof PubThemeDefaults,
31
34
) => {
32
35
return useMemo(() => {
33
-
let v = record?.theme?.[c];
36
+
let v = theme?.[c];
34
37
if (isColor(v)) {
35
38
return parseThemeColor(v);
36
39
} else return parseColor(PubThemeDefaults[c]);
37
-
}, [record?.theme?.[c]]);
40
+
}, [theme?.[c]]);
38
41
};
39
42
let isColor = (
40
43
c: any,
···
53
56
return (
54
57
<PublicationThemeProvider
55
58
pub_creator={pub?.identity_did || ""}
56
-
record={pub?.record as PubLeafletPublication.Record}
59
+
theme={(pub?.record as PubLeafletPublication.Record)?.theme}
57
60
>
58
61
<PublicationBackgroundProvider
59
-
record={pub?.record as PubLeafletPublication.Record}
62
+
theme={(pub?.record as PubLeafletPublication.Record)?.theme}
60
63
pub_creator={pub?.identity_did || ""}
61
64
>
62
65
{props.children}
···
66
69
}
67
70
68
71
export function PublicationBackgroundProvider(props: {
69
-
record?: PubLeafletPublication.Record | null;
72
+
theme?: PubLeafletPublication.Record["theme"] | null;
70
73
pub_creator: string;
71
74
className?: string;
72
75
children: React.ReactNode;
73
76
}) {
74
-
let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref
75
-
? blobRefToSrc(
76
-
props.record?.theme?.backgroundImage?.image?.ref,
77
-
props.pub_creator,
78
-
)
77
+
let backgroundImage = props.theme?.backgroundImage?.image?.ref
78
+
? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator)
79
79
: null;
80
80
81
-
let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat;
82
-
let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500;
81
+
let backgroundImageRepeat = props.theme?.backgroundImage?.repeat;
82
+
let backgroundImageSize = props.theme?.backgroundImage?.width || 500;
83
83
return (
84
84
<div
85
85
className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch"
86
86
style={{
87
-
backgroundImage: `url(${backgroundImage})`,
87
+
backgroundImage: backgroundImage
88
+
? `url(${backgroundImage})`
89
+
: undefined,
88
90
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
89
91
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
90
92
}}
···
96
98
export function PublicationThemeProvider(props: {
97
99
local?: boolean;
98
100
children: React.ReactNode;
99
-
record?: PubLeafletPublication.Record | null;
101
+
theme?: PubLeafletPublication.Record["theme"] | null;
100
102
pub_creator: string;
103
+
isStandalone?: boolean;
101
104
}) {
102
-
let colors = usePubTheme(props.record);
105
+
let colors = usePubTheme(props.theme, props.isStandalone);
103
106
return (
104
107
<BaseThemeProvider local={props.local} {...colors}>
105
108
{props.children}
···
107
110
);
108
111
}
109
112
110
-
export const usePubTheme = (record?: PubLeafletPublication.Record | null) => {
111
-
let bgLeaflet = useColor(record, "backgroundColor");
112
-
let bgPage = useColor(record, "pageBackground");
113
-
bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet;
114
-
let showPageBackground = record?.theme?.showPageBackground;
113
+
export const usePubTheme = (
114
+
theme?: PubLeafletPublication.Record["theme"] | null,
115
+
isStandalone?: boolean,
116
+
) => {
117
+
let bgLeaflet = useColor(theme, "backgroundColor");
118
+
let bgPage = useColor(theme, "pageBackground");
119
+
// For standalone documents, use the editor default page background (#FFFFFF)
120
+
// For publications without explicit pageBackground, use bgLeaflet
121
+
if (isStandalone && !theme?.pageBackground) {
122
+
bgPage = parseColor(StandalonePageBackground);
123
+
} else if (theme && !theme.pageBackground) {
124
+
bgPage = bgLeaflet;
125
+
}
126
+
let showPageBackground = theme?.showPageBackground;
115
127
116
-
let primary = useColor(record, "primary");
128
+
let primary = useColor(theme, "primary");
117
129
118
-
let accent1 = useColor(record, "accentBackground");
119
-
let accent2 = useColor(record, "accentText");
130
+
let accent1 = useColor(theme, "accentBackground");
131
+
let accent2 = useColor(theme, "accentText");
120
132
121
133
let highlight1 = useEntity(null, "theme/highlight-1")?.data.value;
122
134
let highlight2 = useColorAttribute(null, "theme/highlight-2");
···
136
148
};
137
149
138
150
export const useLocalPubTheme = (
139
-
record: PubLeafletPublication.Record | undefined,
151
+
theme: PubLeafletPublication.Record["theme"] | undefined,
140
152
showPageBackground?: boolean,
141
153
) => {
142
-
const pubTheme = usePubTheme(record);
154
+
const pubTheme = usePubTheme(theme);
143
155
const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({});
144
156
145
157
const mergedTheme = useMemo(() => {
+5
-42
components/ThemeManager/ThemeProvider.tsx
+5
-42
components/ThemeManager/ThemeProvider.tsx
···
5
5
CSSProperties,
6
6
useContext,
7
7
useEffect,
8
-
useMemo,
9
-
useState,
10
8
} from "react";
11
9
import {
12
10
colorToString,
···
14
12
useColorAttributeNullable,
15
13
} from "./useColorAttribute";
16
14
import { Color as AriaColor, parseColor } from "react-aria-components";
17
-
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
18
15
19
16
import { useEntity } from "src/replicache";
20
17
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···
23
20
PublicationThemeProvider,
24
21
} from "./PublicationThemeProvider";
25
22
import { PubLeafletPublication } from "lexicons/api";
26
-
27
-
type CSSVariables = {
28
-
"--bg-leaflet": string;
29
-
"--bg-page": string;
30
-
"--primary": string;
31
-
"--accent-1": string;
32
-
"--accent-2": string;
33
-
"--accent-contrast": string;
34
-
"--highlight-1": string;
35
-
"--highlight-2": string;
36
-
"--highlight-3": string;
37
-
};
38
-
39
-
// define the color defaults for everything
40
-
export const ThemeDefaults = {
41
-
"theme/page-background": "#FDFCFA",
42
-
"theme/card-background": "#FFFFFF",
43
-
"theme/primary": "#272727",
44
-
"theme/highlight-1": "#FFFFFF",
45
-
"theme/highlight-2": "#EDD280",
46
-
"theme/highlight-3": "#FFCDC3",
47
-
48
-
//everywhere else, accent-background = accent-1 and accent-text = accent-2.
49
-
// we just need to create a migration pipeline before we can change this
50
-
"theme/accent-text": "#FFFFFF",
51
-
"theme/accent-background": "#0000FF",
52
-
"theme/accent-contrast": "#0000FF",
53
-
};
23
+
import { getColorContrast } from "./themeUtils";
54
24
55
25
// define a function to set an Aria Color to a CSS Variable in RGB
56
26
function setCSSVariableToColor(
···
73
43
return (
74
44
<PublicationThemeProvider
75
45
{...props}
76
-
record={pub.publications?.record as PubLeafletPublication.Record}
46
+
theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme}
77
47
pub_creator={pub.publications?.identity_did}
78
48
/>
79
49
);
···
339
309
return (
340
310
<PublicationBackgroundProvider
341
311
pub_creator={pub?.publications.identity_did || ""}
342
-
record={pub?.publications.record as PubLeafletPublication.Record}
312
+
theme={
313
+
(pub.publications?.record as PubLeafletPublication.Record)?.theme
314
+
}
343
315
>
344
316
{props.children}
345
317
</PublicationBackgroundProvider>
···
366
338
);
367
339
};
368
340
369
-
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
370
-
export function getColorContrast(color1: string, color2: string) {
371
-
ColorSpace.register(sRGB);
372
-
373
-
let parsedColor1 = parse(`rgb(${color1})`);
374
-
let parsedColor2 = parse(`rgb(${color2})`);
375
-
376
-
return contrastLstar(parsedColor1, parsedColor2);
377
-
}
+2
-2
components/ThemeManager/ThemeSetter.tsx
+2
-2
components/ThemeManager/ThemeSetter.tsx
···
70
70
}, [rep, props.entityID]);
71
71
72
72
if (!permission) return null;
73
-
if (pub) return null;
73
+
if (pub?.publications) return null;
74
74
75
75
return (
76
76
<>
···
111
111
}, [rep, props.entityID]);
112
112
113
113
if (!permission) return null;
114
-
if (pub) return null;
114
+
if (pub?.publications) return null;
115
115
return (
116
116
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar">
117
117
<div className="themeBGLeaflet flex">
+44
components/ThemeManager/colorToLexicons.ts
+44
components/ThemeManager/colorToLexicons.ts
···
1
+
import { Color } from "react-aria-components";
2
+
3
+
export function ColorToRGBA(color: Color) {
4
+
if (!color)
5
+
return {
6
+
$type: "pub.leaflet.theme.color#rgba" as const,
7
+
r: 0,
8
+
g: 0,
9
+
b: 0,
10
+
a: 1,
11
+
};
12
+
let c = color.toFormat("rgba");
13
+
const r = c.getChannelValue("red");
14
+
const g = c.getChannelValue("green");
15
+
const b = c.getChannelValue("blue");
16
+
const a = c.getChannelValue("alpha");
17
+
return {
18
+
$type: "pub.leaflet.theme.color#rgba" as const,
19
+
r: Math.round(r),
20
+
g: Math.round(g),
21
+
b: Math.round(b),
22
+
a: Math.round(a * 100),
23
+
};
24
+
}
25
+
26
+
export function ColorToRGB(color: Color) {
27
+
if (!color)
28
+
return {
29
+
$type: "pub.leaflet.theme.color#rgb" as const,
30
+
r: 0,
31
+
g: 0,
32
+
b: 0,
33
+
};
34
+
let c = color.toFormat("rgb");
35
+
const r = c.getChannelValue("red");
36
+
const g = c.getChannelValue("green");
37
+
const b = c.getChannelValue("blue");
38
+
return {
39
+
$type: "pub.leaflet.theme.color#rgb" as const,
40
+
r: Math.round(r),
41
+
g: Math.round(g),
42
+
b: Math.round(b),
43
+
};
44
+
}
+27
components/ThemeManager/themeUtils.ts
+27
components/ThemeManager/themeUtils.ts
···
1
+
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
2
+
3
+
// define the color defaults for everything
4
+
export const ThemeDefaults = {
5
+
"theme/page-background": "#FDFCFA",
6
+
"theme/card-background": "#FFFFFF",
7
+
"theme/primary": "#272727",
8
+
"theme/highlight-1": "#FFFFFF",
9
+
"theme/highlight-2": "#EDD280",
10
+
"theme/highlight-3": "#FFCDC3",
11
+
12
+
//everywhere else, accent-background = accent-1 and accent-text = accent-2.
13
+
// we just need to create a migration pipeline before we can change this
14
+
"theme/accent-text": "#FFFFFF",
15
+
"theme/accent-background": "#0000FF",
16
+
"theme/accent-contrast": "#0000FF",
17
+
};
18
+
19
+
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
20
+
export function getColorContrast(color1: string, color2: string) {
21
+
ColorSpace.register(sRGB);
22
+
23
+
let parsedColor1 = parse(`rgb(${color1})`);
24
+
let parsedColor2 = parse(`rgb(${color2})`);
25
+
26
+
return contrastLstar(parsedColor1, parsedColor2);
27
+
}
+1
-1
components/ThemeManager/useColorAttribute.ts
+1
-1
components/ThemeManager/useColorAttribute.ts
···
2
2
import { Color, parseColor } from "react-aria-components";
3
3
import { useEntity, useReplicache } from "src/replicache";
4
4
import { FilterAttributes } from "src/replicache/attributes";
5
-
import { ThemeDefaults } from "./ThemeProvider";
5
+
import { ThemeDefaults } from "./themeUtils";
6
6
7
7
export function useColorAttribute(
8
8
entity: string | null,
+5
-14
components/Toolbar/BlockToolbar.tsx
+5
-14
components/Toolbar/BlockToolbar.tsx
···
2
2
import { ToolbarButton } from ".";
3
3
import { Separator, ShortcutKey } from "components/Layout";
4
4
import { metaKey } from "src/utils/metaKey";
5
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
5
import { useUIState } from "src/useUIState";
7
6
import { LockBlockButton } from "./LockBlockButton";
8
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
9
8
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
10
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
11
12
12
export const BlockToolbar = (props: {
13
13
setToolbarState: (
···
66
66
67
67
const MoveBlockButtons = () => {
68
68
let { rep } = useReplicache();
69
-
const getSortedSelection = async () => {
70
-
let selectedBlocks = useUIState.getState().selectedBlocks;
71
-
let siblings =
72
-
(await rep?.query((tx) =>
73
-
getBlocksWithType(tx, selectedBlocks[0].parent),
74
-
)) || [];
75
-
let sortedBlocks = siblings.filter((s) =>
76
-
selectedBlocks.find((sb) => sb.value === s.value),
77
-
);
78
-
return [sortedBlocks, siblings];
79
-
};
80
69
return (
81
70
<>
82
71
<ToolbarButton
83
72
hiddenOnCanvas
84
73
onClick={async () => {
85
-
let [sortedBlocks, siblings] = await getSortedSelection();
74
+
if (!rep) return;
75
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
86
76
if (sortedBlocks.length > 1) return;
87
77
let block = sortedBlocks[0];
88
78
let previousBlock =
···
139
129
<ToolbarButton
140
130
hiddenOnCanvas
141
131
onClick={async () => {
142
-
let [sortedBlocks, siblings] = await getSortedSelection();
132
+
if (!rep) return;
133
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
143
134
if (sortedBlocks.length > 1) return;
144
135
let block = sortedBlocks[0];
145
136
let nextBlock = siblings
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
···
8
8
import { LockBlockButton } from "./LockBlockButton";
9
9
import { Props } from "components/Icons/Props";
10
10
import { TextAlignmentButton } from "./TextAlignmentToolbar";
11
-
import { getSortedSelection } from "components/SelectionManager";
11
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
12
12
13
13
export const MultiselectToolbar = (props: {
14
14
setToolbarState: (
+2
-1
components/Toolbar/index.tsx
+2
-1
components/Toolbar/index.tsx
···
13
13
import { TextToolbar } from "./TextToolbar";
14
14
import { BlockToolbar } from "./BlockToolbar";
15
15
import { MultiselectToolbar } from "./MultiSelectToolbar";
16
-
import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock";
16
+
import { AreYouSure } from "components/Blocks/DeleteBlock";
17
+
import { deleteBlock } from "src/utils/deleteBlock";
17
18
import { TooltipButton } from "components/Buttons";
18
19
import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
19
20
import { useIsMobile } from "src/hooks/isMobile";
+1
-1
components/utils/UpdateLeafletTitle.tsx
+1
-1
components/utils/UpdateLeafletTitle.tsx
···
8
8
import { useEntity, useReplicache } from "src/replicache";
9
9
import * as Y from "yjs";
10
10
import * as base64 from "base64-js";
11
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
11
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
12
12
import { useParams, useRouter, useSearchParams } from "next/navigation";
13
13
import { focusBlock } from "src/utils/focusBlock";
14
14
import { useIsMobile } from "src/hooks/isMobile";
+16
-8
drizzle/relations.ts
+16
-8
drizzle/relations.ts
···
1
1
import { relations } from "drizzle-orm/relations";
2
-
import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema";
2
+
import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema";
3
3
4
-
export const publicationsRelations = relations(publications, ({one, many}) => ({
4
+
export const notificationsRelations = relations(notifications, ({one}) => ({
5
5
identity: one(identities, {
6
-
fields: [publications.identity_did],
6
+
fields: [notifications.recipient],
7
7
references: [identities.atp_did]
8
8
}),
9
-
subscribers_to_publications: many(subscribers_to_publications),
10
-
documents_in_publications: many(documents_in_publications),
11
-
publication_domains: many(publication_domains),
12
-
leaflets_in_publications: many(leaflets_in_publications),
13
-
publication_subscriptions: many(publication_subscriptions),
14
9
}));
15
10
16
11
export const identitiesRelations = relations(identities, ({one, many}) => ({
12
+
notifications: many(notifications),
17
13
publications: many(publications),
18
14
email_auth_tokens: many(email_auth_tokens),
19
15
bsky_profiles: many(bsky_profiles),
···
36
32
subscribers_to_publications: many(subscribers_to_publications),
37
33
permission_token_on_homepages: many(permission_token_on_homepage),
38
34
publication_domains: many(publication_domains),
35
+
publication_subscriptions: many(publication_subscriptions),
36
+
}));
37
+
38
+
export const publicationsRelations = relations(publications, ({one, many}) => ({
39
+
identity: one(identities, {
40
+
fields: [publications.identity_did],
41
+
references: [identities.atp_did]
42
+
}),
43
+
subscribers_to_publications: many(subscribers_to_publications),
44
+
documents_in_publications: many(documents_in_publications),
45
+
publication_domains: many(publication_domains),
46
+
leaflets_in_publications: many(leaflets_in_publications),
39
47
publication_subscriptions: many(publication_subscriptions),
40
48
}));
41
49
+10
-2
drizzle/schema.ts
+10
-2
drizzle/schema.ts
···
1
-
import { pgTable, pgEnum, text, jsonb, index, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
1
+
import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
2
2
import { sql } from "drizzle-orm"
3
3
4
4
export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3'])
···
15
15
export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE'])
16
16
export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR'])
17
17
export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in'])
18
-
export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS'])
18
+
export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS', 'VECTOR'])
19
19
20
20
21
21
export const oauth_state_store = pgTable("oauth_state_store", {
22
22
key: text("key").primaryKey().notNull(),
23
23
state: jsonb("state").notNull(),
24
+
});
25
+
26
+
export const notifications = pgTable("notifications", {
27
+
recipient: text("recipient").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ),
28
+
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
29
+
read: boolean("read").default(false).notNull(),
30
+
data: jsonb("data").notNull(),
31
+
id: uuid("id").primaryKey().notNull(),
24
32
});
25
33
26
34
export const publications = pgTable("publications", {
+1
-1
feeds/index.ts
+1
-1
feeds/index.ts
+36
-1
lexicons/api/lexicons.ts
+36
-1
lexicons/api/lexicons.ts
···
1408
1408
description: 'Record containing a document',
1409
1409
record: {
1410
1410
type: 'object',
1411
-
required: ['pages', 'author', 'title', 'publication'],
1411
+
required: ['pages', 'author', 'title'],
1412
1412
properties: {
1413
1413
title: {
1414
1414
type: 'string',
···
1435
1435
author: {
1436
1436
type: 'string',
1437
1437
format: 'at-identifier',
1438
+
},
1439
+
theme: {
1440
+
type: 'ref',
1441
+
ref: 'lex:pub.leaflet.publication#theme',
1442
+
},
1443
+
tags: {
1444
+
type: 'array',
1445
+
items: {
1446
+
type: 'string',
1447
+
maxLength: 50,
1448
+
},
1438
1449
},
1439
1450
pages: {
1440
1451
type: 'array',
···
1861
1872
type: 'union',
1862
1873
refs: [
1863
1874
'lex:pub.leaflet.richtext.facet#link',
1875
+
'lex:pub.leaflet.richtext.facet#didMention',
1876
+
'lex:pub.leaflet.richtext.facet#atMention',
1864
1877
'lex:pub.leaflet.richtext.facet#code',
1865
1878
'lex:pub.leaflet.richtext.facet#highlight',
1866
1879
'lex:pub.leaflet.richtext.facet#underline',
···
1897
1910
properties: {
1898
1911
uri: {
1899
1912
type: 'string',
1913
+
},
1914
+
},
1915
+
},
1916
+
didMention: {
1917
+
type: 'object',
1918
+
description: 'Facet feature for mentioning a did.',
1919
+
required: ['did'],
1920
+
properties: {
1921
+
did: {
1922
+
type: 'string',
1923
+
format: 'did',
1924
+
},
1925
+
},
1926
+
},
1927
+
atMention: {
1928
+
type: 'object',
1929
+
description: 'Facet feature for mentioning an AT URI.',
1930
+
required: ['atURI'],
1931
+
properties: {
1932
+
atURI: {
1933
+
type: 'string',
1934
+
format: 'uri',
1900
1935
},
1901
1936
},
1902
1937
},
+4
-1
lexicons/api/types/pub/leaflet/document.ts
+4
-1
lexicons/api/types/pub/leaflet/document.ts
···
6
6
import { validate as _validate } from '../../../lexicons'
7
7
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
8
import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef'
9
+
import type * as PubLeafletPublication from './publication'
9
10
import type * as PubLeafletPagesLinearDocument from './pages/linearDocument'
10
11
import type * as PubLeafletPagesCanvas from './pages/canvas'
11
12
···
19
20
postRef?: ComAtprotoRepoStrongRef.Main
20
21
description?: string
21
22
publishedAt?: string
22
-
publication: string
23
+
publication?: string
23
24
author: string
25
+
theme?: PubLeafletPublication.Theme
26
+
tags?: string[]
24
27
pages: (
25
28
| $Typed<PubLeafletPagesLinearDocument.Main>
26
29
| $Typed<PubLeafletPagesCanvas.Main>
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
···
20
20
index: ByteSlice
21
21
features: (
22
22
| $Typed<Link>
23
+
| $Typed<DidMention>
24
+
| $Typed<AtMention>
23
25
| $Typed<Code>
24
26
| $Typed<Highlight>
25
27
| $Typed<Underline>
···
72
74
73
75
export function validateLink<V>(v: V) {
74
76
return validate<Link & V>(v, id, hashLink)
77
+
}
78
+
79
+
/** Facet feature for mentioning a did. */
80
+
export interface DidMention {
81
+
$type?: 'pub.leaflet.richtext.facet#didMention'
82
+
did: string
83
+
}
84
+
85
+
const hashDidMention = 'didMention'
86
+
87
+
export function isDidMention<V>(v: V) {
88
+
return is$typed(v, id, hashDidMention)
89
+
}
90
+
91
+
export function validateDidMention<V>(v: V) {
92
+
return validate<DidMention & V>(v, id, hashDidMention)
93
+
}
94
+
95
+
/** Facet feature for mentioning an AT URI. */
96
+
export interface AtMention {
97
+
$type?: 'pub.leaflet.richtext.facet#atMention'
98
+
atURI: string
99
+
}
100
+
101
+
const hashAtMention = 'atMention'
102
+
103
+
export function isAtMention<V>(v: V) {
104
+
return is$typed(v, id, hashAtMention)
105
+
}
106
+
107
+
export function validateAtMention<V>(v: V) {
108
+
return validate<AtMention & V>(v, id, hashAtMention)
75
109
}
76
110
77
111
/** Facet feature for inline code. */
+12
-2
lexicons/pub/leaflet/document.json
+12
-2
lexicons/pub/leaflet/document.json
···
13
13
"required": [
14
14
"pages",
15
15
"author",
16
-
"title",
17
-
"publication"
16
+
"title"
18
17
],
19
18
"properties": {
20
19
"title": {
···
42
41
"author": {
43
42
"type": "string",
44
43
"format": "at-identifier"
44
+
},
45
+
"theme": {
46
+
"type": "ref",
47
+
"ref": "pub.leaflet.publication#theme"
48
+
},
49
+
"tags": {
50
+
"type": "array",
51
+
"items": {
52
+
"type": "string",
53
+
"maxLength": 50
54
+
}
45
55
},
46
56
"pages": {
47
57
"type": "array",
+28
lexicons/pub/leaflet/richtext/facet.json
+28
lexicons/pub/leaflet/richtext/facet.json
···
20
20
"type": "union",
21
21
"refs": [
22
22
"#link",
23
+
"#didMention",
24
+
"#atMention",
23
25
"#code",
24
26
"#highlight",
25
27
"#underline",
···
59
61
"properties": {
60
62
"uri": {
61
63
"type": "string"
64
+
}
65
+
}
66
+
},
67
+
"didMention": {
68
+
"type": "object",
69
+
"description": "Facet feature for mentioning a did.",
70
+
"required": [
71
+
"did"
72
+
],
73
+
"properties": {
74
+
"did": {
75
+
"type": "string",
76
+
"format": "did"
77
+
}
78
+
}
79
+
},
80
+
"atMention": {
81
+
"type": "object",
82
+
"description": "Facet feature for mentioning an AT URI.",
83
+
"required": [
84
+
"atURI"
85
+
],
86
+
"properties": {
87
+
"atURI": {
88
+
"type": "string",
89
+
"format": "uri"
62
90
}
63
91
}
64
92
},
+3
-1
lexicons/src/document.ts
+3
-1
lexicons/src/document.ts
···
14
14
description: "Record containing a document",
15
15
record: {
16
16
type: "object",
17
-
required: ["pages", "author", "title", "publication"],
17
+
required: ["pages", "author", "title"],
18
18
properties: {
19
19
title: { type: "string", maxLength: 1280, maxGraphemes: 128 },
20
20
postRef: { type: "ref", ref: "com.atproto.repo.strongRef" },
···
22
22
publishedAt: { type: "string", format: "datetime" },
23
23
publication: { type: "string", format: "at-uri" },
24
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 } },
25
27
pages: {
26
28
type: "array",
27
29
items: {
+12
lexicons/src/facet.ts
+12
lexicons/src/facet.ts
···
9
9
uri: { type: "string" },
10
10
},
11
11
},
12
+
didMention: {
13
+
type: "object",
14
+
description: "Facet feature for mentioning a did.",
15
+
required: ["did"],
16
+
properties: { did: { type: "string", format: "did" } },
17
+
},
18
+
atMention: {
19
+
type: "object",
20
+
description: "Facet feature for mentioning an AT URI.",
21
+
required: ["atURI"],
22
+
properties: { atURI: { type: "string", format: "uri" } },
23
+
},
12
24
code: {
13
25
type: "object",
14
26
description: "Facet feature for inline code.",
+1
-1
next-env.d.ts
+1
-1
next-env.d.ts
···
1
1
/// <reference types="next" />
2
2
/// <reference types="next/image-types/global" />
3
-
/// <reference path="./.next/types/routes.d.ts" />
3
+
import "./.next/dev/types/routes.d.ts";
4
4
5
5
// NOTE: This file should not be edited
6
6
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2
-2
next.config.js
+2
-2
next.config.js
···
21
21
},
22
22
];
23
23
},
24
-
serverExternalPackages: ["yjs"],
24
+
serverExternalPackages: ["yjs", "pino"],
25
25
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
26
26
images: {
27
27
loader: "custom",
···
31
31
{ protocol: "https", hostname: "bdefzwcumgzjwllsnaej.supabase.co" },
32
32
],
33
33
},
34
+
reactCompiler: true,
34
35
experimental: {
35
-
reactCompiler: true,
36
36
serverActions: {
37
37
bodySizeLimit: "5mb",
38
38
},
+2337
-453
package-lock.json
+2337
-453
package-lock.json
···
21
21
"@hono/node-server": "^1.14.3",
22
22
"@mdx-js/loader": "^3.1.0",
23
23
"@mdx-js/react": "^3.1.0",
24
-
"@next/bundle-analyzer": "^15.3.2",
25
-
"@next/mdx": "15.3.2",
24
+
"@next/bundle-analyzer": "16.0.3",
25
+
"@next/mdx": "16.0.3",
26
26
"@radix-ui/react-dialog": "^1.1.15",
27
27
"@radix-ui/react-dropdown-menu": "^2.1.16",
28
28
"@radix-ui/react-popover": "^1.1.15",
···
48
48
"inngest": "^3.40.1",
49
49
"ioredis": "^5.6.1",
50
50
"katex": "^0.16.22",
51
+
"l": "^0.6.0",
51
52
"linkifyjs": "^4.2.0",
52
53
"luxon": "^3.7.2",
53
54
"multiformats": "^13.3.2",
54
-
"next": "^15.5.3",
55
+
"next": "^16.0.7",
55
56
"pg": "^8.16.3",
56
57
"prosemirror-commands": "^1.5.2",
57
58
"prosemirror-inputrules": "^1.4.0",
···
59
60
"prosemirror-model": "^1.21.0",
60
61
"prosemirror-schema-basic": "^1.2.2",
61
62
"prosemirror-state": "^1.4.3",
62
-
"react": "^19.1.1",
63
+
"react": "19.2.1",
63
64
"react-aria-components": "^1.8.0",
64
65
"react-day-picker": "^9.3.0",
65
-
"react-dom": "^19.1.1",
66
+
"react-dom": "19.2.1",
66
67
"react-use-measure": "^2.1.1",
67
68
"redlock": "^5.0.0-beta.2",
68
69
"rehype-parse": "^9.0.0",
···
92
93
"@types/katex": "^0.16.7",
93
94
"@types/luxon": "^3.7.1",
94
95
"@types/node": "^22.15.17",
95
-
"@types/react": "19.1.3",
96
-
"@types/react-dom": "19.1.3",
96
+
"@types/react": "19.2.6",
97
+
"@types/react-dom": "19.2.3",
97
98
"@types/uuid": "^10.0.0",
98
99
"drizzle-kit": "^0.21.2",
99
100
"esbuild": "^0.25.4",
100
-
"eslint": "8.57.0",
101
-
"eslint-config-next": "^15.5.3",
101
+
"eslint": "^9.39.1",
102
+
"eslint-config-next": "16.0.3",
102
103
"postcss": "^8.4.38",
103
104
"prettier": "3.2.5",
104
105
"supabase": "^1.187.3",
···
567
568
"node": ">=18.7.0"
568
569
}
569
570
},
571
+
"node_modules/@babel/code-frame": {
572
+
"version": "7.27.1",
573
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
574
+
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
575
+
"dev": true,
576
+
"dependencies": {
577
+
"@babel/helper-validator-identifier": "^7.27.1",
578
+
"js-tokens": "^4.0.0",
579
+
"picocolors": "^1.1.1"
580
+
},
581
+
"engines": {
582
+
"node": ">=6.9.0"
583
+
}
584
+
},
585
+
"node_modules/@babel/compat-data": {
586
+
"version": "7.28.5",
587
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
588
+
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
589
+
"dev": true,
590
+
"engines": {
591
+
"node": ">=6.9.0"
592
+
}
593
+
},
594
+
"node_modules/@babel/core": {
595
+
"version": "7.28.5",
596
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
597
+
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
598
+
"dev": true,
599
+
"dependencies": {
600
+
"@babel/code-frame": "^7.27.1",
601
+
"@babel/generator": "^7.28.5",
602
+
"@babel/helper-compilation-targets": "^7.27.2",
603
+
"@babel/helper-module-transforms": "^7.28.3",
604
+
"@babel/helpers": "^7.28.4",
605
+
"@babel/parser": "^7.28.5",
606
+
"@babel/template": "^7.27.2",
607
+
"@babel/traverse": "^7.28.5",
608
+
"@babel/types": "^7.28.5",
609
+
"@jridgewell/remapping": "^2.3.5",
610
+
"convert-source-map": "^2.0.0",
611
+
"debug": "^4.1.0",
612
+
"gensync": "^1.0.0-beta.2",
613
+
"json5": "^2.2.3",
614
+
"semver": "^6.3.1"
615
+
},
616
+
"engines": {
617
+
"node": ">=6.9.0"
618
+
},
619
+
"funding": {
620
+
"type": "opencollective",
621
+
"url": "https://opencollective.com/babel"
622
+
}
623
+
},
624
+
"node_modules/@babel/core/node_modules/json5": {
625
+
"version": "2.2.3",
626
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
627
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
628
+
"dev": true,
629
+
"bin": {
630
+
"json5": "lib/cli.js"
631
+
},
632
+
"engines": {
633
+
"node": ">=6"
634
+
}
635
+
},
636
+
"node_modules/@babel/core/node_modules/semver": {
637
+
"version": "6.3.1",
638
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
639
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
640
+
"dev": true,
641
+
"bin": {
642
+
"semver": "bin/semver.js"
643
+
}
644
+
},
645
+
"node_modules/@babel/generator": {
646
+
"version": "7.28.5",
647
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
648
+
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
649
+
"dev": true,
650
+
"dependencies": {
651
+
"@babel/parser": "^7.28.5",
652
+
"@babel/types": "^7.28.5",
653
+
"@jridgewell/gen-mapping": "^0.3.12",
654
+
"@jridgewell/trace-mapping": "^0.3.28",
655
+
"jsesc": "^3.0.2"
656
+
},
657
+
"engines": {
658
+
"node": ">=6.9.0"
659
+
}
660
+
},
661
+
"node_modules/@babel/helper-compilation-targets": {
662
+
"version": "7.27.2",
663
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
664
+
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
665
+
"dev": true,
666
+
"dependencies": {
667
+
"@babel/compat-data": "^7.27.2",
668
+
"@babel/helper-validator-option": "^7.27.1",
669
+
"browserslist": "^4.24.0",
670
+
"lru-cache": "^5.1.1",
671
+
"semver": "^6.3.1"
672
+
},
673
+
"engines": {
674
+
"node": ">=6.9.0"
675
+
}
676
+
},
677
+
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
678
+
"version": "5.1.1",
679
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
680
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
681
+
"dev": true,
682
+
"dependencies": {
683
+
"yallist": "^3.0.2"
684
+
}
685
+
},
686
+
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
687
+
"version": "6.3.1",
688
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
689
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
690
+
"dev": true,
691
+
"bin": {
692
+
"semver": "bin/semver.js"
693
+
}
694
+
},
695
+
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
696
+
"version": "3.1.1",
697
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
698
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
699
+
"dev": true
700
+
},
701
+
"node_modules/@babel/helper-globals": {
702
+
"version": "7.28.0",
703
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
704
+
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
705
+
"dev": true,
706
+
"engines": {
707
+
"node": ">=6.9.0"
708
+
}
709
+
},
710
+
"node_modules/@babel/helper-module-imports": {
711
+
"version": "7.27.1",
712
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
713
+
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
714
+
"dev": true,
715
+
"dependencies": {
716
+
"@babel/traverse": "^7.27.1",
717
+
"@babel/types": "^7.27.1"
718
+
},
719
+
"engines": {
720
+
"node": ">=6.9.0"
721
+
}
722
+
},
723
+
"node_modules/@babel/helper-module-transforms": {
724
+
"version": "7.28.3",
725
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
726
+
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
727
+
"dev": true,
728
+
"dependencies": {
729
+
"@babel/helper-module-imports": "^7.27.1",
730
+
"@babel/helper-validator-identifier": "^7.27.1",
731
+
"@babel/traverse": "^7.28.3"
732
+
},
733
+
"engines": {
734
+
"node": ">=6.9.0"
735
+
},
736
+
"peerDependencies": {
737
+
"@babel/core": "^7.0.0"
738
+
}
739
+
},
570
740
"node_modules/@babel/helper-string-parser": {
571
741
"version": "7.27.1",
572
742
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
···
577
747
}
578
748
},
579
749
"node_modules/@babel/helper-validator-identifier": {
750
+
"version": "7.28.5",
751
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
752
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
753
+
"engines": {
754
+
"node": ">=6.9.0"
755
+
}
756
+
},
757
+
"node_modules/@babel/helper-validator-option": {
580
758
"version": "7.27.1",
581
-
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
582
-
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
583
-
"license": "MIT",
759
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
760
+
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
761
+
"dev": true,
762
+
"engines": {
763
+
"node": ">=6.9.0"
764
+
}
765
+
},
766
+
"node_modules/@babel/helpers": {
767
+
"version": "7.28.4",
768
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
769
+
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
770
+
"dev": true,
771
+
"dependencies": {
772
+
"@babel/template": "^7.27.2",
773
+
"@babel/types": "^7.28.4"
774
+
},
775
+
"engines": {
776
+
"node": ">=6.9.0"
777
+
}
778
+
},
779
+
"node_modules/@babel/parser": {
780
+
"version": "7.28.5",
781
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
782
+
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
783
+
"dev": true,
784
+
"dependencies": {
785
+
"@babel/types": "^7.28.5"
786
+
},
787
+
"bin": {
788
+
"parser": "bin/babel-parser.js"
789
+
},
790
+
"engines": {
791
+
"node": ">=6.0.0"
792
+
}
793
+
},
794
+
"node_modules/@babel/template": {
795
+
"version": "7.27.2",
796
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
797
+
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
798
+
"dev": true,
799
+
"dependencies": {
800
+
"@babel/code-frame": "^7.27.1",
801
+
"@babel/parser": "^7.27.2",
802
+
"@babel/types": "^7.27.1"
803
+
},
804
+
"engines": {
805
+
"node": ">=6.9.0"
806
+
}
807
+
},
808
+
"node_modules/@babel/traverse": {
809
+
"version": "7.28.5",
810
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
811
+
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
812
+
"dev": true,
813
+
"dependencies": {
814
+
"@babel/code-frame": "^7.27.1",
815
+
"@babel/generator": "^7.28.5",
816
+
"@babel/helper-globals": "^7.28.0",
817
+
"@babel/parser": "^7.28.5",
818
+
"@babel/template": "^7.27.2",
819
+
"@babel/types": "^7.28.5",
820
+
"debug": "^4.3.1"
821
+
},
584
822
"engines": {
585
823
"node": ">=6.9.0"
586
824
}
587
825
},
588
826
"node_modules/@babel/types": {
589
-
"version": "7.27.1",
590
-
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
591
-
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
592
-
"license": "MIT",
827
+
"version": "7.28.5",
828
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
829
+
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
593
830
"dependencies": {
594
831
"@babel/helper-string-parser": "^7.27.1",
595
-
"@babel/helper-validator-identifier": "^7.27.1"
832
+
"@babel/helper-validator-identifier": "^7.28.5"
596
833
},
597
834
"engines": {
598
835
"node": ">=6.9.0"
···
714
951
"source-map-support": "^0.5.21"
715
952
}
716
953
},
954
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
955
+
"version": "0.18.20",
956
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
957
+
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
958
+
"cpu": [
959
+
"arm"
960
+
],
961
+
"dev": true,
962
+
"optional": true,
963
+
"os": [
964
+
"android"
965
+
],
966
+
"engines": {
967
+
"node": ">=12"
968
+
}
969
+
},
970
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
971
+
"version": "0.18.20",
972
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
973
+
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
974
+
"cpu": [
975
+
"arm64"
976
+
],
977
+
"dev": true,
978
+
"optional": true,
979
+
"os": [
980
+
"android"
981
+
],
982
+
"engines": {
983
+
"node": ">=12"
984
+
}
985
+
},
986
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
987
+
"version": "0.18.20",
988
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
989
+
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
990
+
"cpu": [
991
+
"x64"
992
+
],
993
+
"dev": true,
994
+
"optional": true,
995
+
"os": [
996
+
"android"
997
+
],
998
+
"engines": {
999
+
"node": ">=12"
1000
+
}
1001
+
},
1002
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
1003
+
"version": "0.18.20",
1004
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
1005
+
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
1006
+
"cpu": [
1007
+
"arm64"
1008
+
],
1009
+
"dev": true,
1010
+
"optional": true,
1011
+
"os": [
1012
+
"darwin"
1013
+
],
1014
+
"engines": {
1015
+
"node": ">=12"
1016
+
}
1017
+
},
1018
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
1019
+
"version": "0.18.20",
1020
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
1021
+
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
1022
+
"cpu": [
1023
+
"x64"
1024
+
],
1025
+
"dev": true,
1026
+
"optional": true,
1027
+
"os": [
1028
+
"darwin"
1029
+
],
1030
+
"engines": {
1031
+
"node": ">=12"
1032
+
}
1033
+
},
1034
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
1035
+
"version": "0.18.20",
1036
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
1037
+
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
1038
+
"cpu": [
1039
+
"arm64"
1040
+
],
1041
+
"dev": true,
1042
+
"optional": true,
1043
+
"os": [
1044
+
"freebsd"
1045
+
],
1046
+
"engines": {
1047
+
"node": ">=12"
1048
+
}
1049
+
},
1050
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
1051
+
"version": "0.18.20",
1052
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
1053
+
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
1054
+
"cpu": [
1055
+
"x64"
1056
+
],
1057
+
"dev": true,
1058
+
"optional": true,
1059
+
"os": [
1060
+
"freebsd"
1061
+
],
1062
+
"engines": {
1063
+
"node": ">=12"
1064
+
}
1065
+
},
1066
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
1067
+
"version": "0.18.20",
1068
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
1069
+
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
1070
+
"cpu": [
1071
+
"arm"
1072
+
],
1073
+
"dev": true,
1074
+
"optional": true,
1075
+
"os": [
1076
+
"linux"
1077
+
],
1078
+
"engines": {
1079
+
"node": ">=12"
1080
+
}
1081
+
},
1082
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
1083
+
"version": "0.18.20",
1084
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
1085
+
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
1086
+
"cpu": [
1087
+
"arm64"
1088
+
],
1089
+
"dev": true,
1090
+
"optional": true,
1091
+
"os": [
1092
+
"linux"
1093
+
],
1094
+
"engines": {
1095
+
"node": ">=12"
1096
+
}
1097
+
},
1098
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
1099
+
"version": "0.18.20",
1100
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
1101
+
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
1102
+
"cpu": [
1103
+
"ia32"
1104
+
],
1105
+
"dev": true,
1106
+
"optional": true,
1107
+
"os": [
1108
+
"linux"
1109
+
],
1110
+
"engines": {
1111
+
"node": ">=12"
1112
+
}
1113
+
},
1114
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
1115
+
"version": "0.18.20",
1116
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
1117
+
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
1118
+
"cpu": [
1119
+
"loong64"
1120
+
],
1121
+
"dev": true,
1122
+
"optional": true,
1123
+
"os": [
1124
+
"linux"
1125
+
],
1126
+
"engines": {
1127
+
"node": ">=12"
1128
+
}
1129
+
},
1130
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
1131
+
"version": "0.18.20",
1132
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
1133
+
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
1134
+
"cpu": [
1135
+
"mips64el"
1136
+
],
1137
+
"dev": true,
1138
+
"optional": true,
1139
+
"os": [
1140
+
"linux"
1141
+
],
1142
+
"engines": {
1143
+
"node": ">=12"
1144
+
}
1145
+
},
1146
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
1147
+
"version": "0.18.20",
1148
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
1149
+
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
1150
+
"cpu": [
1151
+
"ppc64"
1152
+
],
1153
+
"dev": true,
1154
+
"optional": true,
1155
+
"os": [
1156
+
"linux"
1157
+
],
1158
+
"engines": {
1159
+
"node": ">=12"
1160
+
}
1161
+
},
1162
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
1163
+
"version": "0.18.20",
1164
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
1165
+
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
1166
+
"cpu": [
1167
+
"riscv64"
1168
+
],
1169
+
"dev": true,
1170
+
"optional": true,
1171
+
"os": [
1172
+
"linux"
1173
+
],
1174
+
"engines": {
1175
+
"node": ">=12"
1176
+
}
1177
+
},
1178
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
1179
+
"version": "0.18.20",
1180
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
1181
+
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
1182
+
"cpu": [
1183
+
"s390x"
1184
+
],
1185
+
"dev": true,
1186
+
"optional": true,
1187
+
"os": [
1188
+
"linux"
1189
+
],
1190
+
"engines": {
1191
+
"node": ">=12"
1192
+
}
1193
+
},
717
1194
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
718
1195
"version": "0.18.20",
719
1196
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
···
730
1207
"node": ">=12"
731
1208
}
732
1209
},
1210
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
1211
+
"version": "0.18.20",
1212
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
1213
+
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
1214
+
"cpu": [
1215
+
"x64"
1216
+
],
1217
+
"dev": true,
1218
+
"optional": true,
1219
+
"os": [
1220
+
"netbsd"
1221
+
],
1222
+
"engines": {
1223
+
"node": ">=12"
1224
+
}
1225
+
},
1226
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
1227
+
"version": "0.18.20",
1228
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
1229
+
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
1230
+
"cpu": [
1231
+
"x64"
1232
+
],
1233
+
"dev": true,
1234
+
"optional": true,
1235
+
"os": [
1236
+
"openbsd"
1237
+
],
1238
+
"engines": {
1239
+
"node": ">=12"
1240
+
}
1241
+
},
1242
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
1243
+
"version": "0.18.20",
1244
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
1245
+
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
1246
+
"cpu": [
1247
+
"x64"
1248
+
],
1249
+
"dev": true,
1250
+
"optional": true,
1251
+
"os": [
1252
+
"sunos"
1253
+
],
1254
+
"engines": {
1255
+
"node": ">=12"
1256
+
}
1257
+
},
1258
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
1259
+
"version": "0.18.20",
1260
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
1261
+
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
1262
+
"cpu": [
1263
+
"arm64"
1264
+
],
1265
+
"dev": true,
1266
+
"optional": true,
1267
+
"os": [
1268
+
"win32"
1269
+
],
1270
+
"engines": {
1271
+
"node": ">=12"
1272
+
}
1273
+
},
1274
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
1275
+
"version": "0.18.20",
1276
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
1277
+
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
1278
+
"cpu": [
1279
+
"ia32"
1280
+
],
1281
+
"dev": true,
1282
+
"optional": true,
1283
+
"os": [
1284
+
"win32"
1285
+
],
1286
+
"engines": {
1287
+
"node": ">=12"
1288
+
}
1289
+
},
1290
+
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
1291
+
"version": "0.18.20",
1292
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
1293
+
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
1294
+
"cpu": [
1295
+
"x64"
1296
+
],
1297
+
"dev": true,
1298
+
"optional": true,
1299
+
"os": [
1300
+
"win32"
1301
+
],
1302
+
"engines": {
1303
+
"node": ">=12"
1304
+
}
1305
+
},
733
1306
"node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
734
1307
"version": "0.18.20",
735
1308
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
···
799
1372
"esbuild": "*"
800
1373
}
801
1374
},
1375
+
"node_modules/@esbuild/aix-ppc64": {
1376
+
"version": "0.25.4",
1377
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
1378
+
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
1379
+
"cpu": [
1380
+
"ppc64"
1381
+
],
1382
+
"dev": true,
1383
+
"optional": true,
1384
+
"os": [
1385
+
"aix"
1386
+
],
1387
+
"engines": {
1388
+
"node": ">=18"
1389
+
}
1390
+
},
1391
+
"node_modules/@esbuild/android-arm": {
1392
+
"version": "0.25.4",
1393
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
1394
+
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
1395
+
"cpu": [
1396
+
"arm"
1397
+
],
1398
+
"dev": true,
1399
+
"optional": true,
1400
+
"os": [
1401
+
"android"
1402
+
],
1403
+
"engines": {
1404
+
"node": ">=18"
1405
+
}
1406
+
},
1407
+
"node_modules/@esbuild/android-arm64": {
1408
+
"version": "0.25.4",
1409
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
1410
+
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
1411
+
"cpu": [
1412
+
"arm64"
1413
+
],
1414
+
"dev": true,
1415
+
"optional": true,
1416
+
"os": [
1417
+
"android"
1418
+
],
1419
+
"engines": {
1420
+
"node": ">=18"
1421
+
}
1422
+
},
1423
+
"node_modules/@esbuild/android-x64": {
1424
+
"version": "0.25.4",
1425
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
1426
+
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
1427
+
"cpu": [
1428
+
"x64"
1429
+
],
1430
+
"dev": true,
1431
+
"optional": true,
1432
+
"os": [
1433
+
"android"
1434
+
],
1435
+
"engines": {
1436
+
"node": ">=18"
1437
+
}
1438
+
},
1439
+
"node_modules/@esbuild/darwin-x64": {
1440
+
"version": "0.25.4",
1441
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
1442
+
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
1443
+
"cpu": [
1444
+
"x64"
1445
+
],
1446
+
"dev": true,
1447
+
"optional": true,
1448
+
"os": [
1449
+
"darwin"
1450
+
],
1451
+
"engines": {
1452
+
"node": ">=18"
1453
+
}
1454
+
},
1455
+
"node_modules/@esbuild/freebsd-arm64": {
1456
+
"version": "0.25.4",
1457
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
1458
+
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
1459
+
"cpu": [
1460
+
"arm64"
1461
+
],
1462
+
"dev": true,
1463
+
"optional": true,
1464
+
"os": [
1465
+
"freebsd"
1466
+
],
1467
+
"engines": {
1468
+
"node": ">=18"
1469
+
}
1470
+
},
1471
+
"node_modules/@esbuild/freebsd-x64": {
1472
+
"version": "0.25.4",
1473
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
1474
+
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
1475
+
"cpu": [
1476
+
"x64"
1477
+
],
1478
+
"dev": true,
1479
+
"optional": true,
1480
+
"os": [
1481
+
"freebsd"
1482
+
],
1483
+
"engines": {
1484
+
"node": ">=18"
1485
+
}
1486
+
},
1487
+
"node_modules/@esbuild/linux-arm": {
1488
+
"version": "0.25.4",
1489
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
1490
+
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
1491
+
"cpu": [
1492
+
"arm"
1493
+
],
1494
+
"dev": true,
1495
+
"optional": true,
1496
+
"os": [
1497
+
"linux"
1498
+
],
1499
+
"engines": {
1500
+
"node": ">=18"
1501
+
}
1502
+
},
1503
+
"node_modules/@esbuild/linux-arm64": {
1504
+
"version": "0.25.4",
1505
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
1506
+
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
1507
+
"cpu": [
1508
+
"arm64"
1509
+
],
1510
+
"dev": true,
1511
+
"optional": true,
1512
+
"os": [
1513
+
"linux"
1514
+
],
1515
+
"engines": {
1516
+
"node": ">=18"
1517
+
}
1518
+
},
1519
+
"node_modules/@esbuild/linux-ia32": {
1520
+
"version": "0.25.4",
1521
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
1522
+
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
1523
+
"cpu": [
1524
+
"ia32"
1525
+
],
1526
+
"dev": true,
1527
+
"optional": true,
1528
+
"os": [
1529
+
"linux"
1530
+
],
1531
+
"engines": {
1532
+
"node": ">=18"
1533
+
}
1534
+
},
1535
+
"node_modules/@esbuild/linux-loong64": {
1536
+
"version": "0.25.4",
1537
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
1538
+
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
1539
+
"cpu": [
1540
+
"loong64"
1541
+
],
1542
+
"dev": true,
1543
+
"optional": true,
1544
+
"os": [
1545
+
"linux"
1546
+
],
1547
+
"engines": {
1548
+
"node": ">=18"
1549
+
}
1550
+
},
1551
+
"node_modules/@esbuild/linux-mips64el": {
1552
+
"version": "0.25.4",
1553
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
1554
+
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
1555
+
"cpu": [
1556
+
"mips64el"
1557
+
],
1558
+
"dev": true,
1559
+
"optional": true,
1560
+
"os": [
1561
+
"linux"
1562
+
],
1563
+
"engines": {
1564
+
"node": ">=18"
1565
+
}
1566
+
},
1567
+
"node_modules/@esbuild/linux-ppc64": {
1568
+
"version": "0.25.4",
1569
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
1570
+
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
1571
+
"cpu": [
1572
+
"ppc64"
1573
+
],
1574
+
"dev": true,
1575
+
"optional": true,
1576
+
"os": [
1577
+
"linux"
1578
+
],
1579
+
"engines": {
1580
+
"node": ">=18"
1581
+
}
1582
+
},
1583
+
"node_modules/@esbuild/linux-riscv64": {
1584
+
"version": "0.25.4",
1585
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
1586
+
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
1587
+
"cpu": [
1588
+
"riscv64"
1589
+
],
1590
+
"dev": true,
1591
+
"optional": true,
1592
+
"os": [
1593
+
"linux"
1594
+
],
1595
+
"engines": {
1596
+
"node": ">=18"
1597
+
}
1598
+
},
1599
+
"node_modules/@esbuild/linux-s390x": {
1600
+
"version": "0.25.4",
1601
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
1602
+
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
1603
+
"cpu": [
1604
+
"s390x"
1605
+
],
1606
+
"dev": true,
1607
+
"optional": true,
1608
+
"os": [
1609
+
"linux"
1610
+
],
1611
+
"engines": {
1612
+
"node": ">=18"
1613
+
}
1614
+
},
802
1615
"node_modules/@esbuild/linux-x64": {
803
1616
"version": "0.25.4",
804
1617
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
···
816
1629
"node": ">=18"
817
1630
}
818
1631
},
1632
+
"node_modules/@esbuild/netbsd-arm64": {
1633
+
"version": "0.25.4",
1634
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
1635
+
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
1636
+
"cpu": [
1637
+
"arm64"
1638
+
],
1639
+
"dev": true,
1640
+
"optional": true,
1641
+
"os": [
1642
+
"netbsd"
1643
+
],
1644
+
"engines": {
1645
+
"node": ">=18"
1646
+
}
1647
+
},
1648
+
"node_modules/@esbuild/netbsd-x64": {
1649
+
"version": "0.25.4",
1650
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
1651
+
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
1652
+
"cpu": [
1653
+
"x64"
1654
+
],
1655
+
"dev": true,
1656
+
"optional": true,
1657
+
"os": [
1658
+
"netbsd"
1659
+
],
1660
+
"engines": {
1661
+
"node": ">=18"
1662
+
}
1663
+
},
1664
+
"node_modules/@esbuild/openbsd-arm64": {
1665
+
"version": "0.25.4",
1666
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
1667
+
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
1668
+
"cpu": [
1669
+
"arm64"
1670
+
],
1671
+
"dev": true,
1672
+
"optional": true,
1673
+
"os": [
1674
+
"openbsd"
1675
+
],
1676
+
"engines": {
1677
+
"node": ">=18"
1678
+
}
1679
+
},
1680
+
"node_modules/@esbuild/openbsd-x64": {
1681
+
"version": "0.25.4",
1682
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
1683
+
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
1684
+
"cpu": [
1685
+
"x64"
1686
+
],
1687
+
"dev": true,
1688
+
"optional": true,
1689
+
"os": [
1690
+
"openbsd"
1691
+
],
1692
+
"engines": {
1693
+
"node": ">=18"
1694
+
}
1695
+
},
1696
+
"node_modules/@esbuild/sunos-x64": {
1697
+
"version": "0.25.4",
1698
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
1699
+
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
1700
+
"cpu": [
1701
+
"x64"
1702
+
],
1703
+
"dev": true,
1704
+
"optional": true,
1705
+
"os": [
1706
+
"sunos"
1707
+
],
1708
+
"engines": {
1709
+
"node": ">=18"
1710
+
}
1711
+
},
1712
+
"node_modules/@esbuild/win32-arm64": {
1713
+
"version": "0.25.4",
1714
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
1715
+
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
1716
+
"cpu": [
1717
+
"arm64"
1718
+
],
1719
+
"dev": true,
1720
+
"optional": true,
1721
+
"os": [
1722
+
"win32"
1723
+
],
1724
+
"engines": {
1725
+
"node": ">=18"
1726
+
}
1727
+
},
1728
+
"node_modules/@esbuild/win32-ia32": {
1729
+
"version": "0.25.4",
1730
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
1731
+
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
1732
+
"cpu": [
1733
+
"ia32"
1734
+
],
1735
+
"dev": true,
1736
+
"optional": true,
1737
+
"os": [
1738
+
"win32"
1739
+
],
1740
+
"engines": {
1741
+
"node": ">=18"
1742
+
}
1743
+
},
1744
+
"node_modules/@esbuild/win32-x64": {
1745
+
"version": "0.25.4",
1746
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
1747
+
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
1748
+
"cpu": [
1749
+
"x64"
1750
+
],
1751
+
"dev": true,
1752
+
"optional": true,
1753
+
"os": [
1754
+
"win32"
1755
+
],
1756
+
"engines": {
1757
+
"node": ">=18"
1758
+
}
1759
+
},
819
1760
"node_modules/@eslint-community/eslint-utils": {
820
-
"version": "4.7.0",
821
-
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
822
-
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
1761
+
"version": "4.9.0",
1762
+
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
1763
+
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
823
1764
"dev": true,
824
-
"license": "MIT",
825
1765
"dependencies": {
826
1766
"eslint-visitor-keys": "^3.4.3"
827
1767
},
···
835
1775
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
836
1776
}
837
1777
},
1778
+
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
1779
+
"version": "3.4.3",
1780
+
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
1781
+
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
1782
+
"dev": true,
1783
+
"engines": {
1784
+
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1785
+
},
1786
+
"funding": {
1787
+
"url": "https://opencollective.com/eslint"
1788
+
}
1789
+
},
838
1790
"node_modules/@eslint-community/regexpp": {
839
-
"version": "4.10.0",
840
-
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
841
-
"integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
1791
+
"version": "4.12.2",
1792
+
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
1793
+
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
842
1794
"dev": true,
843
1795
"engines": {
844
1796
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
845
1797
}
846
1798
},
1799
+
"node_modules/@eslint/config-array": {
1800
+
"version": "0.21.1",
1801
+
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
1802
+
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
1803
+
"dev": true,
1804
+
"dependencies": {
1805
+
"@eslint/object-schema": "^2.1.7",
1806
+
"debug": "^4.3.1",
1807
+
"minimatch": "^3.1.2"
1808
+
},
1809
+
"engines": {
1810
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1811
+
}
1812
+
},
1813
+
"node_modules/@eslint/config-helpers": {
1814
+
"version": "0.4.2",
1815
+
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
1816
+
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
1817
+
"dev": true,
1818
+
"dependencies": {
1819
+
"@eslint/core": "^0.17.0"
1820
+
},
1821
+
"engines": {
1822
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1823
+
}
1824
+
},
1825
+
"node_modules/@eslint/core": {
1826
+
"version": "0.17.0",
1827
+
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
1828
+
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
1829
+
"dev": true,
1830
+
"dependencies": {
1831
+
"@types/json-schema": "^7.0.15"
1832
+
},
1833
+
"engines": {
1834
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1835
+
}
1836
+
},
847
1837
"node_modules/@eslint/eslintrc": {
848
-
"version": "2.1.4",
849
-
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
850
-
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
1838
+
"version": "3.3.1",
1839
+
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
1840
+
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
851
1841
"dev": true,
852
1842
"dependencies": {
853
1843
"ajv": "^6.12.4",
854
1844
"debug": "^4.3.2",
855
-
"espree": "^9.6.0",
856
-
"globals": "^13.19.0",
1845
+
"espree": "^10.0.1",
1846
+
"globals": "^14.0.0",
857
1847
"ignore": "^5.2.0",
858
1848
"import-fresh": "^3.2.1",
859
1849
"js-yaml": "^4.1.0",
···
861
1851
"strip-json-comments": "^3.1.1"
862
1852
},
863
1853
"engines": {
864
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1854
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
865
1855
},
866
1856
"funding": {
867
1857
"url": "https://opencollective.com/eslint"
868
1858
}
869
1859
},
870
1860
"node_modules/@eslint/js": {
871
-
"version": "8.57.0",
872
-
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
873
-
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
1861
+
"version": "9.39.1",
1862
+
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
1863
+
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
1864
+
"dev": true,
1865
+
"engines": {
1866
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1867
+
},
1868
+
"funding": {
1869
+
"url": "https://eslint.org/donate"
1870
+
}
1871
+
},
1872
+
"node_modules/@eslint/object-schema": {
1873
+
"version": "2.1.7",
1874
+
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
1875
+
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
1876
+
"dev": true,
1877
+
"engines": {
1878
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1879
+
}
1880
+
},
1881
+
"node_modules/@eslint/plugin-kit": {
1882
+
"version": "0.4.1",
1883
+
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
1884
+
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
874
1885
"dev": true,
1886
+
"dependencies": {
1887
+
"@eslint/core": "^0.17.0",
1888
+
"levn": "^0.4.1"
1889
+
},
875
1890
"engines": {
876
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1891
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
877
1892
}
878
1893
},
879
1894
"node_modules/@fastify/busboy": {
···
1017
2032
"hono": "^4"
1018
2033
}
1019
2034
},
1020
-
"node_modules/@humanwhocodes/config-array": {
1021
-
"version": "0.11.14",
1022
-
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
1023
-
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
2035
+
"node_modules/@humanfs/core": {
2036
+
"version": "0.19.1",
2037
+
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
2038
+
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
2039
+
"dev": true,
2040
+
"engines": {
2041
+
"node": ">=18.18.0"
2042
+
}
2043
+
},
2044
+
"node_modules/@humanfs/node": {
2045
+
"version": "0.16.7",
2046
+
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
2047
+
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
1024
2048
"dev": true,
1025
2049
"dependencies": {
1026
-
"@humanwhocodes/object-schema": "^2.0.2",
1027
-
"debug": "^4.3.1",
1028
-
"minimatch": "^3.0.5"
2050
+
"@humanfs/core": "^0.19.1",
2051
+
"@humanwhocodes/retry": "^0.4.0"
1029
2052
},
1030
2053
"engines": {
1031
-
"node": ">=10.10.0"
2054
+
"node": ">=18.18.0"
1032
2055
}
1033
2056
},
1034
2057
"node_modules/@humanwhocodes/module-importer": {
···
1044
2067
"url": "https://github.com/sponsors/nzakas"
1045
2068
}
1046
2069
},
1047
-
"node_modules/@humanwhocodes/object-schema": {
1048
-
"version": "2.0.3",
1049
-
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
1050
-
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
1051
-
"dev": true
2070
+
"node_modules/@humanwhocodes/retry": {
2071
+
"version": "0.4.3",
2072
+
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
2073
+
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
2074
+
"dev": true,
2075
+
"engines": {
2076
+
"node": ">=18.18"
2077
+
},
2078
+
"funding": {
2079
+
"type": "github",
2080
+
"url": "https://github.com/sponsors/nzakas"
2081
+
}
1052
2082
},
1053
2083
"node_modules/@img/colour": {
1054
2084
"version": "1.0.0",
···
1697
2727
}
1698
2728
},
1699
2729
"node_modules/@next/bundle-analyzer": {
1700
-
"version": "15.3.2",
1701
-
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.3.2.tgz",
1702
-
"integrity": "sha512-zY5O1PNKNxWEjaFX8gKzm77z2oL0cnj+m5aiqNBgay9LPLCDO13Cf+FJONeNq/nJjeXptwHFT9EMmTecF9U4Iw==",
1703
-
"license": "MIT",
2730
+
"version": "16.0.3",
2731
+
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.0.3.tgz",
2732
+
"integrity": "sha512-6Xo8f8/ZXtASfTPa6TH1aUn+xDg9Pkyl1YHVxu+89cVdLH7MnYjxv3rPOfEJ9BwCZCU2q4Flyw5MwltfD2pGbA==",
1704
2733
"dependencies": {
1705
2734
"webpack-bundle-analyzer": "4.10.1"
1706
2735
}
1707
2736
},
1708
2737
"node_modules/@next/env": {
1709
-
"version": "15.5.3",
1710
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz",
1711
-
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==",
2738
+
"version": "16.0.7",
2739
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
2740
+
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
1712
2741
"license": "MIT"
1713
2742
},
1714
2743
"node_modules/@next/eslint-plugin-next": {
1715
-
"version": "15.5.3",
1716
-
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz",
1717
-
"integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==",
2744
+
"version": "16.0.3",
2745
+
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz",
2746
+
"integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==",
1718
2747
"dev": true,
1719
-
"license": "MIT",
1720
2748
"dependencies": {
1721
2749
"fast-glob": "3.3.1"
1722
2750
}
···
1726
2754
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
1727
2755
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
1728
2756
"dev": true,
1729
-
"license": "MIT",
1730
2757
"dependencies": {
1731
2758
"@nodelib/fs.stat": "^2.0.2",
1732
2759
"@nodelib/fs.walk": "^1.2.3",
···
1743
2770
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1744
2771
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1745
2772
"dev": true,
1746
-
"license": "ISC",
1747
2773
"dependencies": {
1748
2774
"is-glob": "^4.0.1"
1749
2775
},
···
1752
2778
}
1753
2779
},
1754
2780
"node_modules/@next/mdx": {
1755
-
"version": "15.3.2",
1756
-
"resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-15.3.2.tgz",
1757
-
"integrity": "sha512-D6lSSbVzn1EiPwrBKG5QzXClcgdqiNCL8a3/6oROinzgZnYSxbVmnfs0UrqygtGSOmgW7sdJJSEOy555DoAwvw==",
1758
-
"license": "MIT",
2781
+
"version": "16.0.3",
2782
+
"resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.0.3.tgz",
2783
+
"integrity": "sha512-uVl2JSEGAjBV+EVnpt1cZN88SK3lJ2n7Fc+iqTsgVx2g9+Y6ru+P6nuUgXd38OHPUIwzL6k2V1u4iV3kwuTySQ==",
1759
2784
"dependencies": {
1760
2785
"source-map": "^0.7.0"
1761
2786
},
···
1781
2806
}
1782
2807
},
1783
2808
"node_modules/@next/swc-darwin-arm64": {
1784
-
"version": "15.5.3",
1785
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz",
1786
-
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==",
2809
+
"version": "16.0.7",
2810
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
2811
+
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
1787
2812
"cpu": [
1788
2813
"arm64"
1789
2814
],
···
1797
2822
}
1798
2823
},
1799
2824
"node_modules/@next/swc-darwin-x64": {
1800
-
"version": "15.5.3",
1801
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz",
1802
-
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==",
2825
+
"version": "16.0.7",
2826
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
2827
+
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
1803
2828
"cpu": [
1804
2829
"x64"
1805
2830
],
···
1813
2838
}
1814
2839
},
1815
2840
"node_modules/@next/swc-linux-arm64-gnu": {
1816
-
"version": "15.5.3",
1817
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz",
1818
-
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==",
2841
+
"version": "16.0.7",
2842
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
2843
+
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
1819
2844
"cpu": [
1820
2845
"arm64"
1821
2846
],
···
1829
2854
}
1830
2855
},
1831
2856
"node_modules/@next/swc-linux-arm64-musl": {
1832
-
"version": "15.5.3",
1833
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz",
1834
-
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==",
2857
+
"version": "16.0.7",
2858
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
2859
+
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
1835
2860
"cpu": [
1836
2861
"arm64"
1837
2862
],
···
1845
2870
}
1846
2871
},
1847
2872
"node_modules/@next/swc-linux-x64-gnu": {
1848
-
"version": "15.5.3",
1849
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz",
1850
-
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==",
2873
+
"version": "16.0.7",
2874
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
2875
+
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
1851
2876
"cpu": [
1852
2877
"x64"
1853
2878
],
···
1861
2886
}
1862
2887
},
1863
2888
"node_modules/@next/swc-linux-x64-musl": {
1864
-
"version": "15.5.3",
1865
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz",
1866
-
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==",
2889
+
"version": "16.0.7",
2890
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
2891
+
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
1867
2892
"cpu": [
1868
2893
"x64"
1869
2894
],
···
1877
2902
}
1878
2903
},
1879
2904
"node_modules/@next/swc-win32-arm64-msvc": {
1880
-
"version": "15.5.3",
1881
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz",
1882
-
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==",
2905
+
"version": "16.0.7",
2906
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
2907
+
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
1883
2908
"cpu": [
1884
2909
"arm64"
1885
2910
],
···
1893
2918
}
1894
2919
},
1895
2920
"node_modules/@next/swc-win32-x64-msvc": {
1896
-
"version": "15.5.3",
1897
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz",
1898
-
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==",
2921
+
"version": "16.0.7",
2922
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
2923
+
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
1899
2924
"cpu": [
1900
2925
"x64"
1901
2926
],
···
5997
7022
"dev": true,
5998
7023
"license": "MIT"
5999
7024
},
6000
-
"node_modules/@rushstack/eslint-patch": {
6001
-
"version": "1.10.3",
6002
-
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
6003
-
"integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==",
6004
-
"dev": true
6005
-
},
6006
7025
"node_modules/@shikijs/core": {
6007
7026
"version": "3.8.1",
6008
7027
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.8.1.tgz",
···
6597
7616
"@types/unist": "*"
6598
7617
}
6599
7618
},
7619
+
"node_modules/@types/json-schema": {
7620
+
"version": "7.0.15",
7621
+
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
7622
+
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
7623
+
"dev": true
7624
+
},
6600
7625
"node_modules/@types/json5": {
6601
7626
"version": "0.0.29",
6602
7627
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
···
6723
7748
"integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA=="
6724
7749
},
6725
7750
"node_modules/@types/react": {
6726
-
"version": "19.1.3",
6727
-
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz",
6728
-
"integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==",
6729
-
"license": "MIT",
7751
+
"version": "19.2.6",
7752
+
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
7753
+
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
6730
7754
"dependencies": {
6731
-
"csstype": "^3.0.2"
7755
+
"csstype": "^3.2.2"
6732
7756
}
6733
7757
},
6734
7758
"node_modules/@types/react-dom": {
6735
-
"version": "19.1.3",
6736
-
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz",
6737
-
"integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==",
7759
+
"version": "19.2.3",
7760
+
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
7761
+
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
6738
7762
"devOptional": true,
6739
-
"license": "MIT",
6740
7763
"peerDependencies": {
6741
-
"@types/react": "^19.0.0"
7764
+
"@types/react": "^19.2.0"
6742
7765
}
6743
7766
},
6744
7767
"node_modules/@types/shimmer": {
···
6776
7799
}
6777
7800
},
6778
7801
"node_modules/@typescript-eslint/eslint-plugin": {
6779
-
"version": "8.32.0",
6780
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz",
6781
-
"integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==",
7802
+
"version": "8.47.0",
7803
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
7804
+
"integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==",
6782
7805
"dev": true,
6783
-
"license": "MIT",
6784
7806
"dependencies": {
6785
7807
"@eslint-community/regexpp": "^4.10.0",
6786
-
"@typescript-eslint/scope-manager": "8.32.0",
6787
-
"@typescript-eslint/type-utils": "8.32.0",
6788
-
"@typescript-eslint/utils": "8.32.0",
6789
-
"@typescript-eslint/visitor-keys": "8.32.0",
7808
+
"@typescript-eslint/scope-manager": "8.47.0",
7809
+
"@typescript-eslint/type-utils": "8.47.0",
7810
+
"@typescript-eslint/utils": "8.47.0",
7811
+
"@typescript-eslint/visitor-keys": "8.47.0",
6790
7812
"graphemer": "^1.4.0",
6791
-
"ignore": "^5.3.1",
7813
+
"ignore": "^7.0.0",
6792
7814
"natural-compare": "^1.4.0",
6793
7815
"ts-api-utils": "^2.1.0"
6794
7816
},
···
6800
7822
"url": "https://opencollective.com/typescript-eslint"
6801
7823
},
6802
7824
"peerDependencies": {
6803
-
"@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
7825
+
"@typescript-eslint/parser": "^8.47.0",
6804
7826
"eslint": "^8.57.0 || ^9.0.0",
6805
-
"typescript": ">=4.8.4 <5.9.0"
7827
+
"typescript": ">=4.8.4 <6.0.0"
7828
+
}
7829
+
},
7830
+
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
7831
+
"version": "7.0.5",
7832
+
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
7833
+
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
7834
+
"dev": true,
7835
+
"engines": {
7836
+
"node": ">= 4"
6806
7837
}
6807
7838
},
6808
7839
"node_modules/@typescript-eslint/parser": {
6809
-
"version": "8.32.0",
6810
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz",
6811
-
"integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==",
7840
+
"version": "8.47.0",
7841
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz",
7842
+
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
6812
7843
"dev": true,
6813
-
"license": "MIT",
6814
7844
"dependencies": {
6815
-
"@typescript-eslint/scope-manager": "8.32.0",
6816
-
"@typescript-eslint/types": "8.32.0",
6817
-
"@typescript-eslint/typescript-estree": "8.32.0",
6818
-
"@typescript-eslint/visitor-keys": "8.32.0",
7845
+
"@typescript-eslint/scope-manager": "8.47.0",
7846
+
"@typescript-eslint/types": "8.47.0",
7847
+
"@typescript-eslint/typescript-estree": "8.47.0",
7848
+
"@typescript-eslint/visitor-keys": "8.47.0",
6819
7849
"debug": "^4.3.4"
6820
7850
},
6821
7851
"engines": {
···
6827
7857
},
6828
7858
"peerDependencies": {
6829
7859
"eslint": "^8.57.0 || ^9.0.0",
6830
-
"typescript": ">=4.8.4 <5.9.0"
7860
+
"typescript": ">=4.8.4 <6.0.0"
7861
+
}
7862
+
},
7863
+
"node_modules/@typescript-eslint/project-service": {
7864
+
"version": "8.47.0",
7865
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
7866
+
"integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
7867
+
"dev": true,
7868
+
"dependencies": {
7869
+
"@typescript-eslint/tsconfig-utils": "^8.47.0",
7870
+
"@typescript-eslint/types": "^8.47.0",
7871
+
"debug": "^4.3.4"
7872
+
},
7873
+
"engines": {
7874
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
7875
+
},
7876
+
"funding": {
7877
+
"type": "opencollective",
7878
+
"url": "https://opencollective.com/typescript-eslint"
7879
+
},
7880
+
"peerDependencies": {
7881
+
"typescript": ">=4.8.4 <6.0.0"
6831
7882
}
6832
7883
},
6833
7884
"node_modules/@typescript-eslint/scope-manager": {
6834
-
"version": "8.32.0",
6835
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz",
6836
-
"integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==",
7885
+
"version": "8.47.0",
7886
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
7887
+
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
6837
7888
"dev": true,
6838
-
"license": "MIT",
6839
7889
"dependencies": {
6840
-
"@typescript-eslint/types": "8.32.0",
6841
-
"@typescript-eslint/visitor-keys": "8.32.0"
7890
+
"@typescript-eslint/types": "8.47.0",
7891
+
"@typescript-eslint/visitor-keys": "8.47.0"
6842
7892
},
6843
7893
"engines": {
6844
7894
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
···
6848
7898
"url": "https://opencollective.com/typescript-eslint"
6849
7899
}
6850
7900
},
7901
+
"node_modules/@typescript-eslint/tsconfig-utils": {
7902
+
"version": "8.47.0",
7903
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
7904
+
"integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
7905
+
"dev": true,
7906
+
"engines": {
7907
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
7908
+
},
7909
+
"funding": {
7910
+
"type": "opencollective",
7911
+
"url": "https://opencollective.com/typescript-eslint"
7912
+
},
7913
+
"peerDependencies": {
7914
+
"typescript": ">=4.8.4 <6.0.0"
7915
+
}
7916
+
},
6851
7917
"node_modules/@typescript-eslint/type-utils": {
6852
-
"version": "8.32.0",
6853
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz",
6854
-
"integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==",
7918
+
"version": "8.47.0",
7919
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz",
7920
+
"integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==",
6855
7921
"dev": true,
6856
-
"license": "MIT",
6857
7922
"dependencies": {
6858
-
"@typescript-eslint/typescript-estree": "8.32.0",
6859
-
"@typescript-eslint/utils": "8.32.0",
7923
+
"@typescript-eslint/types": "8.47.0",
7924
+
"@typescript-eslint/typescript-estree": "8.47.0",
7925
+
"@typescript-eslint/utils": "8.47.0",
6860
7926
"debug": "^4.3.4",
6861
7927
"ts-api-utils": "^2.1.0"
6862
7928
},
···
6869
7935
},
6870
7936
"peerDependencies": {
6871
7937
"eslint": "^8.57.0 || ^9.0.0",
6872
-
"typescript": ">=4.8.4 <5.9.0"
7938
+
"typescript": ">=4.8.4 <6.0.0"
6873
7939
}
6874
7940
},
6875
7941
"node_modules/@typescript-eslint/types": {
6876
-
"version": "8.32.0",
6877
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz",
6878
-
"integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==",
7942
+
"version": "8.47.0",
7943
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
7944
+
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
6879
7945
"dev": true,
6880
-
"license": "MIT",
6881
7946
"engines": {
6882
7947
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
6883
7948
},
···
6887
7952
}
6888
7953
},
6889
7954
"node_modules/@typescript-eslint/typescript-estree": {
6890
-
"version": "8.32.0",
6891
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz",
6892
-
"integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==",
7955
+
"version": "8.47.0",
7956
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
7957
+
"integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
6893
7958
"dev": true,
6894
-
"license": "MIT",
6895
7959
"dependencies": {
6896
-
"@typescript-eslint/types": "8.32.0",
6897
-
"@typescript-eslint/visitor-keys": "8.32.0",
7960
+
"@typescript-eslint/project-service": "8.47.0",
7961
+
"@typescript-eslint/tsconfig-utils": "8.47.0",
7962
+
"@typescript-eslint/types": "8.47.0",
7963
+
"@typescript-eslint/visitor-keys": "8.47.0",
6898
7964
"debug": "^4.3.4",
6899
7965
"fast-glob": "^3.3.2",
6900
7966
"is-glob": "^4.0.3",
···
6910
7976
"url": "https://opencollective.com/typescript-eslint"
6911
7977
},
6912
7978
"peerDependencies": {
6913
-
"typescript": ">=4.8.4 <5.9.0"
7979
+
"typescript": ">=4.8.4 <6.0.0"
6914
7980
}
6915
7981
},
6916
7982
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
6917
-
"version": "2.0.1",
6918
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
6919
-
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
7983
+
"version": "2.0.2",
7984
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
7985
+
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
6920
7986
"dev": true,
6921
-
"license": "MIT",
6922
7987
"dependencies": {
6923
7988
"balanced-match": "^1.0.0"
6924
7989
}
···
6928
7993
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
6929
7994
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
6930
7995
"dev": true,
6931
-
"license": "ISC",
6932
7996
"dependencies": {
6933
7997
"brace-expansion": "^2.0.1"
6934
7998
},
···
6940
8004
}
6941
8005
},
6942
8006
"node_modules/@typescript-eslint/utils": {
6943
-
"version": "8.32.0",
6944
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz",
6945
-
"integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==",
8007
+
"version": "8.47.0",
8008
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
8009
+
"integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
6946
8010
"dev": true,
6947
-
"license": "MIT",
6948
8011
"dependencies": {
6949
8012
"@eslint-community/eslint-utils": "^4.7.0",
6950
-
"@typescript-eslint/scope-manager": "8.32.0",
6951
-
"@typescript-eslint/types": "8.32.0",
6952
-
"@typescript-eslint/typescript-estree": "8.32.0"
8013
+
"@typescript-eslint/scope-manager": "8.47.0",
8014
+
"@typescript-eslint/types": "8.47.0",
8015
+
"@typescript-eslint/typescript-estree": "8.47.0"
6953
8016
},
6954
8017
"engines": {
6955
8018
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
···
6960
8023
},
6961
8024
"peerDependencies": {
6962
8025
"eslint": "^8.57.0 || ^9.0.0",
6963
-
"typescript": ">=4.8.4 <5.9.0"
8026
+
"typescript": ">=4.8.4 <6.0.0"
6964
8027
}
6965
8028
},
6966
8029
"node_modules/@typescript-eslint/visitor-keys": {
6967
-
"version": "8.32.0",
6968
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz",
6969
-
"integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==",
8030
+
"version": "8.47.0",
8031
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
8032
+
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
6970
8033
"dev": true,
6971
-
"license": "MIT",
6972
8034
"dependencies": {
6973
-
"@typescript-eslint/types": "8.32.0",
6974
-
"eslint-visitor-keys": "^4.2.0"
8035
+
"@typescript-eslint/types": "8.47.0",
8036
+
"eslint-visitor-keys": "^4.2.1"
6975
8037
},
6976
8038
"engines": {
6977
8039
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
···
6979
8041
"funding": {
6980
8042
"type": "opencollective",
6981
8043
"url": "https://opencollective.com/typescript-eslint"
6982
-
}
6983
-
},
6984
-
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
6985
-
"version": "4.2.0",
6986
-
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
6987
-
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
6988
-
"dev": true,
6989
-
"license": "Apache-2.0",
6990
-
"engines": {
6991
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
6992
-
},
6993
-
"funding": {
6994
-
"url": "https://opencollective.com/eslint"
6995
8044
}
6996
8045
},
6997
8046
"node_modules/@ungap/structured-clone": {
···
7167
8216
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
7168
8217
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
7169
8218
"dev": true,
7170
-
"license": "MIT",
7171
8219
"dependencies": {
7172
8220
"fast-deep-equal": "^3.1.3",
7173
8221
"fast-uri": "^3.0.1",
···
7264
8312
"license": "MIT"
7265
8313
},
7266
8314
"node_modules/array-includes": {
7267
-
"version": "3.1.8",
7268
-
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
7269
-
"integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
8315
+
"version": "3.1.9",
8316
+
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
8317
+
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
7270
8318
"dev": true,
7271
8319
"dependencies": {
7272
-
"call-bind": "^1.0.7",
8320
+
"call-bind": "^1.0.8",
8321
+
"call-bound": "^1.0.4",
7273
8322
"define-properties": "^1.2.1",
7274
-
"es-abstract": "^1.23.2",
7275
-
"es-object-atoms": "^1.0.0",
7276
-
"get-intrinsic": "^1.2.4",
7277
-
"is-string": "^1.0.7"
8323
+
"es-abstract": "^1.24.0",
8324
+
"es-object-atoms": "^1.1.1",
8325
+
"get-intrinsic": "^1.3.0",
8326
+
"is-string": "^1.1.1",
8327
+
"math-intrinsics": "^1.1.0"
7278
8328
},
7279
8329
"engines": {
7280
8330
"node": ">= 0.4"
···
7305
8355
}
7306
8356
},
7307
8357
"node_modules/array.prototype.findlastindex": {
7308
-
"version": "1.2.5",
7309
-
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
7310
-
"integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
8358
+
"version": "1.2.6",
8359
+
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
8360
+
"integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
7311
8361
"dev": true,
7312
8362
"dependencies": {
7313
-
"call-bind": "^1.0.7",
8363
+
"call-bind": "^1.0.8",
8364
+
"call-bound": "^1.0.4",
7314
8365
"define-properties": "^1.2.1",
7315
-
"es-abstract": "^1.23.2",
8366
+
"es-abstract": "^1.23.9",
7316
8367
"es-errors": "^1.3.0",
7317
-
"es-object-atoms": "^1.0.0",
7318
-
"es-shim-unscopables": "^1.0.2"
8368
+
"es-object-atoms": "^1.1.1",
8369
+
"es-shim-unscopables": "^1.1.0"
7319
8370
},
7320
8371
"engines": {
7321
8372
"node": ">= 0.4"
···
7325
8376
}
7326
8377
},
7327
8378
"node_modules/array.prototype.flat": {
7328
-
"version": "1.3.2",
7329
-
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
7330
-
"integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==",
8379
+
"version": "1.3.3",
8380
+
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
8381
+
"integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
7331
8382
"dev": true,
7332
8383
"dependencies": {
7333
-
"call-bind": "^1.0.2",
7334
-
"define-properties": "^1.2.0",
7335
-
"es-abstract": "^1.22.1",
7336
-
"es-shim-unscopables": "^1.0.0"
8384
+
"call-bind": "^1.0.8",
8385
+
"define-properties": "^1.2.1",
8386
+
"es-abstract": "^1.23.5",
8387
+
"es-shim-unscopables": "^1.0.2"
7337
8388
},
7338
8389
"engines": {
7339
8390
"node": ">= 0.4"
···
7544
8595
}
7545
8596
]
7546
8597
},
8598
+
"node_modules/baseline-browser-mapping": {
8599
+
"version": "2.8.30",
8600
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
8601
+
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
8602
+
"dev": true,
8603
+
"bin": {
8604
+
"baseline-browser-mapping": "dist/cli.js"
8605
+
}
8606
+
},
7547
8607
"node_modules/bignumber.js": {
7548
8608
"version": "9.3.1",
7549
8609
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
···
7662
8722
"node": ">=8"
7663
8723
}
7664
8724
},
8725
+
"node_modules/browserslist": {
8726
+
"version": "4.28.0",
8727
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
8728
+
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
8729
+
"dev": true,
8730
+
"funding": [
8731
+
{
8732
+
"type": "opencollective",
8733
+
"url": "https://opencollective.com/browserslist"
8734
+
},
8735
+
{
8736
+
"type": "tidelift",
8737
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
8738
+
},
8739
+
{
8740
+
"type": "github",
8741
+
"url": "https://github.com/sponsors/ai"
8742
+
}
8743
+
],
8744
+
"dependencies": {
8745
+
"baseline-browser-mapping": "^2.8.25",
8746
+
"caniuse-lite": "^1.0.30001754",
8747
+
"electron-to-chromium": "^1.5.249",
8748
+
"node-releases": "^2.0.27",
8749
+
"update-browserslist-db": "^1.1.4"
8750
+
},
8751
+
"bin": {
8752
+
"browserslist": "cli.js"
8753
+
},
8754
+
"engines": {
8755
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
8756
+
}
8757
+
},
7665
8758
"node_modules/buffer": {
7666
8759
"version": "6.0.3",
7667
8760
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
···
7765
8858
}
7766
8859
},
7767
8860
"node_modules/caniuse-lite": {
7768
-
"version": "1.0.30001717",
7769
-
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz",
7770
-
"integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==",
8861
+
"version": "1.0.30001756",
8862
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
8863
+
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
7771
8864
"funding": [
7772
8865
{
7773
8866
"type": "opencollective",
···
7781
8874
"type": "github",
7782
8875
"url": "https://github.com/sponsors/ai"
7783
8876
}
7784
-
],
7785
-
"license": "CC-BY-4.0"
8877
+
]
7786
8878
},
7787
8879
"node_modules/canonicalize": {
7788
8880
"version": "1.0.8",
···
8105
9197
"node": ">= 0.6"
8106
9198
}
8107
9199
},
9200
+
"node_modules/convert-source-map": {
9201
+
"version": "2.0.0",
9202
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
9203
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
9204
+
"dev": true
9205
+
},
8108
9206
"node_modules/cookie": {
8109
9207
"version": "0.5.0",
8110
9208
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
···
8156
9254
}
8157
9255
},
8158
9256
"node_modules/cross-spawn": {
8159
-
"version": "7.0.3",
8160
-
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
8161
-
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
9257
+
"version": "7.0.6",
9258
+
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
9259
+
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
8162
9260
"dev": true,
8163
9261
"dependencies": {
8164
9262
"path-key": "^3.1.0",
···
8170
9268
}
8171
9269
},
8172
9270
"node_modules/csstype": {
8173
-
"version": "3.1.3",
8174
-
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
8175
-
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
9271
+
"version": "3.2.3",
9272
+
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
9273
+
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
8176
9274
},
8177
9275
"node_modules/d": {
8178
9276
"version": "1.0.2",
···
8434
9532
"node": "*"
8435
9533
}
8436
9534
},
8437
-
"node_modules/doctrine": {
8438
-
"version": "3.0.0",
8439
-
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
8440
-
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
8441
-
"dev": true,
8442
-
"dependencies": {
8443
-
"esutils": "^2.0.2"
8444
-
},
8445
-
"engines": {
8446
-
"node": ">=6.0.0"
8447
-
}
8448
-
},
8449
9535
"node_modules/dreamopt": {
8450
9536
"version": "0.8.0",
8451
9537
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
···
8478
9564
"drizzle-kit": "bin.cjs"
8479
9565
}
8480
9566
},
9567
+
"node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": {
9568
+
"version": "0.19.12",
9569
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
9570
+
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
9571
+
"cpu": [
9572
+
"ppc64"
9573
+
],
9574
+
"dev": true,
9575
+
"optional": true,
9576
+
"os": [
9577
+
"aix"
9578
+
],
9579
+
"engines": {
9580
+
"node": ">=12"
9581
+
}
9582
+
},
9583
+
"node_modules/drizzle-kit/node_modules/@esbuild/android-arm": {
9584
+
"version": "0.19.12",
9585
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
9586
+
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
9587
+
"cpu": [
9588
+
"arm"
9589
+
],
9590
+
"dev": true,
9591
+
"optional": true,
9592
+
"os": [
9593
+
"android"
9594
+
],
9595
+
"engines": {
9596
+
"node": ">=12"
9597
+
}
9598
+
},
9599
+
"node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": {
9600
+
"version": "0.19.12",
9601
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
9602
+
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
9603
+
"cpu": [
9604
+
"arm64"
9605
+
],
9606
+
"dev": true,
9607
+
"optional": true,
9608
+
"os": [
9609
+
"android"
9610
+
],
9611
+
"engines": {
9612
+
"node": ">=12"
9613
+
}
9614
+
},
9615
+
"node_modules/drizzle-kit/node_modules/@esbuild/android-x64": {
9616
+
"version": "0.19.12",
9617
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
9618
+
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
9619
+
"cpu": [
9620
+
"x64"
9621
+
],
9622
+
"dev": true,
9623
+
"optional": true,
9624
+
"os": [
9625
+
"android"
9626
+
],
9627
+
"engines": {
9628
+
"node": ">=12"
9629
+
}
9630
+
},
9631
+
"node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": {
9632
+
"version": "0.19.12",
9633
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
9634
+
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
9635
+
"cpu": [
9636
+
"arm64"
9637
+
],
9638
+
"dev": true,
9639
+
"optional": true,
9640
+
"os": [
9641
+
"darwin"
9642
+
],
9643
+
"engines": {
9644
+
"node": ">=12"
9645
+
}
9646
+
},
9647
+
"node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": {
9648
+
"version": "0.19.12",
9649
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
9650
+
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
9651
+
"cpu": [
9652
+
"x64"
9653
+
],
9654
+
"dev": true,
9655
+
"optional": true,
9656
+
"os": [
9657
+
"darwin"
9658
+
],
9659
+
"engines": {
9660
+
"node": ">=12"
9661
+
}
9662
+
},
9663
+
"node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": {
9664
+
"version": "0.19.12",
9665
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
9666
+
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
9667
+
"cpu": [
9668
+
"arm64"
9669
+
],
9670
+
"dev": true,
9671
+
"optional": true,
9672
+
"os": [
9673
+
"freebsd"
9674
+
],
9675
+
"engines": {
9676
+
"node": ">=12"
9677
+
}
9678
+
},
9679
+
"node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": {
9680
+
"version": "0.19.12",
9681
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
9682
+
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
9683
+
"cpu": [
9684
+
"x64"
9685
+
],
9686
+
"dev": true,
9687
+
"optional": true,
9688
+
"os": [
9689
+
"freebsd"
9690
+
],
9691
+
"engines": {
9692
+
"node": ">=12"
9693
+
}
9694
+
},
9695
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": {
9696
+
"version": "0.19.12",
9697
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
9698
+
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
9699
+
"cpu": [
9700
+
"arm"
9701
+
],
9702
+
"dev": true,
9703
+
"optional": true,
9704
+
"os": [
9705
+
"linux"
9706
+
],
9707
+
"engines": {
9708
+
"node": ">=12"
9709
+
}
9710
+
},
9711
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": {
9712
+
"version": "0.19.12",
9713
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
9714
+
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
9715
+
"cpu": [
9716
+
"arm64"
9717
+
],
9718
+
"dev": true,
9719
+
"optional": true,
9720
+
"os": [
9721
+
"linux"
9722
+
],
9723
+
"engines": {
9724
+
"node": ">=12"
9725
+
}
9726
+
},
9727
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": {
9728
+
"version": "0.19.12",
9729
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
9730
+
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
9731
+
"cpu": [
9732
+
"ia32"
9733
+
],
9734
+
"dev": true,
9735
+
"optional": true,
9736
+
"os": [
9737
+
"linux"
9738
+
],
9739
+
"engines": {
9740
+
"node": ">=12"
9741
+
}
9742
+
},
9743
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": {
9744
+
"version": "0.19.12",
9745
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
9746
+
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
9747
+
"cpu": [
9748
+
"loong64"
9749
+
],
9750
+
"dev": true,
9751
+
"optional": true,
9752
+
"os": [
9753
+
"linux"
9754
+
],
9755
+
"engines": {
9756
+
"node": ">=12"
9757
+
}
9758
+
},
9759
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": {
9760
+
"version": "0.19.12",
9761
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
9762
+
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
9763
+
"cpu": [
9764
+
"mips64el"
9765
+
],
9766
+
"dev": true,
9767
+
"optional": true,
9768
+
"os": [
9769
+
"linux"
9770
+
],
9771
+
"engines": {
9772
+
"node": ">=12"
9773
+
}
9774
+
},
9775
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": {
9776
+
"version": "0.19.12",
9777
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
9778
+
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
9779
+
"cpu": [
9780
+
"ppc64"
9781
+
],
9782
+
"dev": true,
9783
+
"optional": true,
9784
+
"os": [
9785
+
"linux"
9786
+
],
9787
+
"engines": {
9788
+
"node": ">=12"
9789
+
}
9790
+
},
9791
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": {
9792
+
"version": "0.19.12",
9793
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
9794
+
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
9795
+
"cpu": [
9796
+
"riscv64"
9797
+
],
9798
+
"dev": true,
9799
+
"optional": true,
9800
+
"os": [
9801
+
"linux"
9802
+
],
9803
+
"engines": {
9804
+
"node": ">=12"
9805
+
}
9806
+
},
9807
+
"node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": {
9808
+
"version": "0.19.12",
9809
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
9810
+
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
9811
+
"cpu": [
9812
+
"s390x"
9813
+
],
9814
+
"dev": true,
9815
+
"optional": true,
9816
+
"os": [
9817
+
"linux"
9818
+
],
9819
+
"engines": {
9820
+
"node": ">=12"
9821
+
}
9822
+
},
8481
9823
"node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": {
8482
9824
"version": "0.19.12",
8483
9825
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
···
8495
9837
"node": ">=12"
8496
9838
}
8497
9839
},
9840
+
"node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": {
9841
+
"version": "0.19.12",
9842
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
9843
+
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
9844
+
"cpu": [
9845
+
"x64"
9846
+
],
9847
+
"dev": true,
9848
+
"optional": true,
9849
+
"os": [
9850
+
"netbsd"
9851
+
],
9852
+
"engines": {
9853
+
"node": ">=12"
9854
+
}
9855
+
},
9856
+
"node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": {
9857
+
"version": "0.19.12",
9858
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
9859
+
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
9860
+
"cpu": [
9861
+
"x64"
9862
+
],
9863
+
"dev": true,
9864
+
"optional": true,
9865
+
"os": [
9866
+
"openbsd"
9867
+
],
9868
+
"engines": {
9869
+
"node": ">=12"
9870
+
}
9871
+
},
9872
+
"node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": {
9873
+
"version": "0.19.12",
9874
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
9875
+
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
9876
+
"cpu": [
9877
+
"x64"
9878
+
],
9879
+
"dev": true,
9880
+
"optional": true,
9881
+
"os": [
9882
+
"sunos"
9883
+
],
9884
+
"engines": {
9885
+
"node": ">=12"
9886
+
}
9887
+
},
9888
+
"node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": {
9889
+
"version": "0.19.12",
9890
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
9891
+
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
9892
+
"cpu": [
9893
+
"arm64"
9894
+
],
9895
+
"dev": true,
9896
+
"optional": true,
9897
+
"os": [
9898
+
"win32"
9899
+
],
9900
+
"engines": {
9901
+
"node": ">=12"
9902
+
}
9903
+
},
9904
+
"node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": {
9905
+
"version": "0.19.12",
9906
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
9907
+
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
9908
+
"cpu": [
9909
+
"ia32"
9910
+
],
9911
+
"dev": true,
9912
+
"optional": true,
9913
+
"os": [
9914
+
"win32"
9915
+
],
9916
+
"engines": {
9917
+
"node": ">=12"
9918
+
}
9919
+
},
9920
+
"node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": {
9921
+
"version": "0.19.12",
9922
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
9923
+
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
9924
+
"cpu": [
9925
+
"x64"
9926
+
],
9927
+
"dev": true,
9928
+
"optional": true,
9929
+
"os": [
9930
+
"win32"
9931
+
],
9932
+
"engines": {
9933
+
"node": ">=12"
9934
+
}
9935
+
},
8498
9936
"node_modules/drizzle-kit/node_modules/esbuild": {
8499
9937
"version": "0.19.12",
8500
9938
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
···
8678
10116
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
8679
10117
"license": "MIT"
8680
10118
},
10119
+
"node_modules/electron-to-chromium": {
10120
+
"version": "1.5.258",
10121
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
10122
+
"integrity": "sha512-rHUggNV5jKQ0sSdWwlaRDkFc3/rRJIVnOSe9yR4zrR07m3ZxhP4N27Hlg8VeJGGYgFTxK5NqDmWI4DSH72vIJg==",
10123
+
"dev": true
10124
+
},
8681
10125
"node_modules/emoji-regex": {
8682
10126
"version": "9.2.2",
8683
10127
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
···
8731
10175
}
8732
10176
},
8733
10177
"node_modules/es-abstract": {
8734
-
"version": "1.23.9",
8735
-
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
8736
-
"integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
10178
+
"version": "1.24.0",
10179
+
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
10180
+
"integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
8737
10181
"dev": true,
8738
-
"license": "MIT",
8739
10182
"dependencies": {
8740
10183
"array-buffer-byte-length": "^1.0.2",
8741
10184
"arraybuffer.prototype.slice": "^1.0.4",
8742
10185
"available-typed-arrays": "^1.0.7",
8743
10186
"call-bind": "^1.0.8",
8744
-
"call-bound": "^1.0.3",
10187
+
"call-bound": "^1.0.4",
8745
10188
"data-view-buffer": "^1.0.2",
8746
10189
"data-view-byte-length": "^1.0.2",
8747
10190
"data-view-byte-offset": "^1.0.1",
8748
10191
"es-define-property": "^1.0.1",
8749
10192
"es-errors": "^1.3.0",
8750
-
"es-object-atoms": "^1.0.0",
10193
+
"es-object-atoms": "^1.1.1",
8751
10194
"es-set-tostringtag": "^2.1.0",
8752
10195
"es-to-primitive": "^1.3.0",
8753
10196
"function.prototype.name": "^1.1.8",
8754
-
"get-intrinsic": "^1.2.7",
8755
-
"get-proto": "^1.0.0",
10197
+
"get-intrinsic": "^1.3.0",
10198
+
"get-proto": "^1.0.1",
8756
10199
"get-symbol-description": "^1.1.0",
8757
10200
"globalthis": "^1.0.4",
8758
10201
"gopd": "^1.2.0",
···
8764
10207
"is-array-buffer": "^3.0.5",
8765
10208
"is-callable": "^1.2.7",
8766
10209
"is-data-view": "^1.0.2",
10210
+
"is-negative-zero": "^2.0.3",
8767
10211
"is-regex": "^1.2.1",
10212
+
"is-set": "^2.0.3",
8768
10213
"is-shared-array-buffer": "^1.0.4",
8769
10214
"is-string": "^1.1.1",
8770
10215
"is-typed-array": "^1.1.15",
8771
-
"is-weakref": "^1.1.0",
10216
+
"is-weakref": "^1.1.1",
8772
10217
"math-intrinsics": "^1.1.0",
8773
-
"object-inspect": "^1.13.3",
10218
+
"object-inspect": "^1.13.4",
8774
10219
"object-keys": "^1.1.1",
8775
10220
"object.assign": "^4.1.7",
8776
10221
"own-keys": "^1.0.1",
8777
-
"regexp.prototype.flags": "^1.5.3",
10222
+
"regexp.prototype.flags": "^1.5.4",
8778
10223
"safe-array-concat": "^1.1.3",
8779
10224
"safe-push-apply": "^1.0.0",
8780
10225
"safe-regex-test": "^1.1.0",
8781
10226
"set-proto": "^1.0.0",
10227
+
"stop-iteration-iterator": "^1.1.0",
8782
10228
"string.prototype.trim": "^1.2.10",
8783
10229
"string.prototype.trimend": "^1.0.9",
8784
10230
"string.prototype.trimstart": "^1.0.8",
···
8787
10233
"typed-array-byte-offset": "^1.0.4",
8788
10234
"typed-array-length": "^1.0.7",
8789
10235
"unbox-primitive": "^1.1.0",
8790
-
"which-typed-array": "^1.1.18"
10236
+
"which-typed-array": "^1.1.19"
8791
10237
},
8792
10238
"engines": {
8793
10239
"node": ">= 0.4"
···
8870
10316
}
8871
10317
},
8872
10318
"node_modules/es-shim-unscopables": {
8873
-
"version": "1.0.2",
8874
-
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz",
8875
-
"integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==",
10319
+
"version": "1.1.0",
10320
+
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
10321
+
"integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
8876
10322
"dev": true,
8877
10323
"dependencies": {
8878
-
"hasown": "^2.0.0"
10324
+
"hasown": "^2.0.2"
10325
+
},
10326
+
"engines": {
10327
+
"node": ">= 0.4"
8879
10328
}
8880
10329
},
8881
10330
"node_modules/es-to-primitive": {
···
9031
10480
"esbuild": ">=0.12 <1"
9032
10481
}
9033
10482
},
10483
+
"node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
10484
+
"version": "0.25.4",
10485
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
10486
+
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
10487
+
"cpu": [
10488
+
"arm64"
10489
+
],
10490
+
"dev": true,
10491
+
"optional": true,
10492
+
"os": [
10493
+
"darwin"
10494
+
],
10495
+
"engines": {
10496
+
"node": ">=18"
10497
+
}
10498
+
},
9034
10499
"node_modules/escalade": {
9035
-
"version": "3.1.2",
9036
-
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
9037
-
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
10500
+
"version": "3.2.0",
10501
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
10502
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
9038
10503
"engines": {
9039
10504
"node": ">=6"
9040
10505
}
···
9057
10522
}
9058
10523
},
9059
10524
"node_modules/eslint": {
9060
-
"version": "8.57.0",
9061
-
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
9062
-
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
10525
+
"version": "9.39.1",
10526
+
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
10527
+
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
9063
10528
"dev": true,
9064
10529
"dependencies": {
9065
-
"@eslint-community/eslint-utils": "^4.2.0",
9066
-
"@eslint-community/regexpp": "^4.6.1",
9067
-
"@eslint/eslintrc": "^2.1.4",
9068
-
"@eslint/js": "8.57.0",
9069
-
"@humanwhocodes/config-array": "^0.11.14",
10530
+
"@eslint-community/eslint-utils": "^4.8.0",
10531
+
"@eslint-community/regexpp": "^4.12.1",
10532
+
"@eslint/config-array": "^0.21.1",
10533
+
"@eslint/config-helpers": "^0.4.2",
10534
+
"@eslint/core": "^0.17.0",
10535
+
"@eslint/eslintrc": "^3.3.1",
10536
+
"@eslint/js": "9.39.1",
10537
+
"@eslint/plugin-kit": "^0.4.1",
10538
+
"@humanfs/node": "^0.16.6",
9070
10539
"@humanwhocodes/module-importer": "^1.0.1",
9071
-
"@nodelib/fs.walk": "^1.2.8",
9072
-
"@ungap/structured-clone": "^1.2.0",
10540
+
"@humanwhocodes/retry": "^0.4.2",
10541
+
"@types/estree": "^1.0.6",
9073
10542
"ajv": "^6.12.4",
9074
10543
"chalk": "^4.0.0",
9075
-
"cross-spawn": "^7.0.2",
10544
+
"cross-spawn": "^7.0.6",
9076
10545
"debug": "^4.3.2",
9077
-
"doctrine": "^3.0.0",
9078
10546
"escape-string-regexp": "^4.0.0",
9079
-
"eslint-scope": "^7.2.2",
9080
-
"eslint-visitor-keys": "^3.4.3",
9081
-
"espree": "^9.6.1",
9082
-
"esquery": "^1.4.2",
10547
+
"eslint-scope": "^8.4.0",
10548
+
"eslint-visitor-keys": "^4.2.1",
10549
+
"espree": "^10.4.0",
10550
+
"esquery": "^1.5.0",
9083
10551
"esutils": "^2.0.2",
9084
10552
"fast-deep-equal": "^3.1.3",
9085
-
"file-entry-cache": "^6.0.1",
10553
+
"file-entry-cache": "^8.0.0",
9086
10554
"find-up": "^5.0.0",
9087
10555
"glob-parent": "^6.0.2",
9088
-
"globals": "^13.19.0",
9089
-
"graphemer": "^1.4.0",
9090
10556
"ignore": "^5.2.0",
9091
10557
"imurmurhash": "^0.1.4",
9092
10558
"is-glob": "^4.0.0",
9093
-
"is-path-inside": "^3.0.3",
9094
-
"js-yaml": "^4.1.0",
9095
10559
"json-stable-stringify-without-jsonify": "^1.0.1",
9096
-
"levn": "^0.4.1",
9097
10560
"lodash.merge": "^4.6.2",
9098
10561
"minimatch": "^3.1.2",
9099
10562
"natural-compare": "^1.4.0",
9100
-
"optionator": "^0.9.3",
9101
-
"strip-ansi": "^6.0.1",
9102
-
"text-table": "^0.2.0"
10563
+
"optionator": "^0.9.3"
9103
10564
},
9104
10565
"bin": {
9105
10566
"eslint": "bin/eslint.js"
9106
10567
},
9107
10568
"engines": {
9108
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
10569
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
9109
10570
},
9110
10571
"funding": {
9111
-
"url": "https://opencollective.com/eslint"
10572
+
"url": "https://eslint.org/donate"
10573
+
},
10574
+
"peerDependencies": {
10575
+
"jiti": "*"
10576
+
},
10577
+
"peerDependenciesMeta": {
10578
+
"jiti": {
10579
+
"optional": true
10580
+
}
9112
10581
}
9113
10582
},
9114
10583
"node_modules/eslint-config-next": {
9115
-
"version": "15.5.3",
9116
-
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz",
9117
-
"integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==",
10584
+
"version": "16.0.3",
10585
+
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz",
10586
+
"integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==",
9118
10587
"dev": true,
9119
-
"license": "MIT",
9120
10588
"dependencies": {
9121
-
"@next/eslint-plugin-next": "15.5.3",
9122
-
"@rushstack/eslint-patch": "^1.10.3",
9123
-
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
9124
-
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
10589
+
"@next/eslint-plugin-next": "16.0.3",
9125
10590
"eslint-import-resolver-node": "^0.3.6",
9126
10591
"eslint-import-resolver-typescript": "^3.5.2",
9127
-
"eslint-plugin-import": "^2.31.0",
10592
+
"eslint-plugin-import": "^2.32.0",
9128
10593
"eslint-plugin-jsx-a11y": "^6.10.0",
9129
10594
"eslint-plugin-react": "^7.37.0",
9130
-
"eslint-plugin-react-hooks": "^5.0.0"
10595
+
"eslint-plugin-react-hooks": "^7.0.0",
10596
+
"globals": "16.4.0",
10597
+
"typescript-eslint": "^8.46.0"
9131
10598
},
9132
10599
"peerDependencies": {
9133
-
"eslint": "^7.23.0 || ^8.0.0 || ^9.0.0",
10600
+
"eslint": ">=9.0.0",
9134
10601
"typescript": ">=3.3.1"
9135
10602
},
9136
10603
"peerDependenciesMeta": {
9137
10604
"typescript": {
9138
10605
"optional": true
9139
10606
}
10607
+
}
10608
+
},
10609
+
"node_modules/eslint-config-next/node_modules/globals": {
10610
+
"version": "16.4.0",
10611
+
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
10612
+
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
10613
+
"dev": true,
10614
+
"engines": {
10615
+
"node": ">=18"
10616
+
},
10617
+
"funding": {
10618
+
"url": "https://github.com/sponsors/sindresorhus"
9140
10619
}
9141
10620
},
9142
10621
"node_modules/eslint-import-resolver-node": {
···
9185
10664
}
9186
10665
},
9187
10666
"node_modules/eslint-module-utils": {
9188
-
"version": "2.12.0",
9189
-
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
9190
-
"integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
10667
+
"version": "2.12.1",
10668
+
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
10669
+
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
9191
10670
"dev": true,
9192
-
"license": "MIT",
9193
10671
"dependencies": {
9194
10672
"debug": "^3.2.7"
9195
10673
},
···
9212
10690
}
9213
10691
},
9214
10692
"node_modules/eslint-plugin-import": {
9215
-
"version": "2.31.0",
9216
-
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
9217
-
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
10693
+
"version": "2.32.0",
10694
+
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
10695
+
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
9218
10696
"dev": true,
9219
-
"license": "MIT",
9220
10697
"dependencies": {
9221
10698
"@rtsao/scc": "^1.1.0",
9222
-
"array-includes": "^3.1.8",
9223
-
"array.prototype.findlastindex": "^1.2.5",
9224
-
"array.prototype.flat": "^1.3.2",
9225
-
"array.prototype.flatmap": "^1.3.2",
10699
+
"array-includes": "^3.1.9",
10700
+
"array.prototype.findlastindex": "^1.2.6",
10701
+
"array.prototype.flat": "^1.3.3",
10702
+
"array.prototype.flatmap": "^1.3.3",
9226
10703
"debug": "^3.2.7",
9227
10704
"doctrine": "^2.1.0",
9228
10705
"eslint-import-resolver-node": "^0.3.9",
9229
-
"eslint-module-utils": "^2.12.0",
10706
+
"eslint-module-utils": "^2.12.1",
9230
10707
"hasown": "^2.0.2",
9231
-
"is-core-module": "^2.15.1",
10708
+
"is-core-module": "^2.16.1",
9232
10709
"is-glob": "^4.0.3",
9233
10710
"minimatch": "^3.1.2",
9234
10711
"object.fromentries": "^2.0.8",
9235
10712
"object.groupby": "^1.0.3",
9236
-
"object.values": "^1.2.0",
10713
+
"object.values": "^1.2.1",
9237
10714
"semver": "^6.3.1",
9238
-
"string.prototype.trimend": "^1.0.8",
10715
+
"string.prototype.trimend": "^1.0.9",
9239
10716
"tsconfig-paths": "^3.15.0"
9240
10717
},
9241
10718
"engines": {
···
9339
10816
}
9340
10817
},
9341
10818
"node_modules/eslint-plugin-react-hooks": {
9342
-
"version": "5.2.0",
9343
-
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
9344
-
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
10819
+
"version": "7.0.1",
10820
+
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
10821
+
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
9345
10822
"dev": true,
9346
-
"license": "MIT",
10823
+
"dependencies": {
10824
+
"@babel/core": "^7.24.4",
10825
+
"@babel/parser": "^7.24.4",
10826
+
"hermes-parser": "^0.25.1",
10827
+
"zod": "^3.25.0 || ^4.0.0",
10828
+
"zod-validation-error": "^3.5.0 || ^4.0.0"
10829
+
},
9347
10830
"engines": {
9348
-
"node": ">=10"
10831
+
"node": ">=18"
9349
10832
},
9350
10833
"peerDependencies": {
9351
10834
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
9352
10835
}
9353
10836
},
10837
+
"node_modules/eslint-plugin-react-hooks/node_modules/zod": {
10838
+
"version": "4.1.12",
10839
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
10840
+
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
10841
+
"dev": true,
10842
+
"funding": {
10843
+
"url": "https://github.com/sponsors/colinhacks"
10844
+
}
10845
+
},
10846
+
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
10847
+
"version": "4.0.2",
10848
+
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
10849
+
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
10850
+
"dev": true,
10851
+
"engines": {
10852
+
"node": ">=18.0.0"
10853
+
},
10854
+
"peerDependencies": {
10855
+
"zod": "^3.25.0 || ^4.0.0"
10856
+
}
10857
+
},
9354
10858
"node_modules/eslint-plugin-react/node_modules/doctrine": {
9355
10859
"version": "2.1.0",
9356
10860
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
···
9393
10897
}
9394
10898
},
9395
10899
"node_modules/eslint-scope": {
9396
-
"version": "7.2.2",
9397
-
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
9398
-
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
10900
+
"version": "8.4.0",
10901
+
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
10902
+
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
9399
10903
"dev": true,
9400
10904
"dependencies": {
9401
10905
"esrecurse": "^4.3.0",
9402
10906
"estraverse": "^5.2.0"
9403
10907
},
9404
10908
"engines": {
9405
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
10909
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
9406
10910
},
9407
10911
"funding": {
9408
10912
"url": "https://opencollective.com/eslint"
9409
10913
}
9410
10914
},
9411
10915
"node_modules/eslint-visitor-keys": {
9412
-
"version": "3.4.3",
9413
-
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
9414
-
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
10916
+
"version": "4.2.1",
10917
+
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
10918
+
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
9415
10919
"dev": true,
9416
10920
"engines": {
9417
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
10921
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
9418
10922
},
9419
10923
"funding": {
9420
10924
"url": "https://opencollective.com/eslint"
···
9436
10940
}
9437
10941
},
9438
10942
"node_modules/espree": {
9439
-
"version": "9.6.1",
9440
-
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
9441
-
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
10943
+
"version": "10.4.0",
10944
+
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
10945
+
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
9442
10946
"dev": true,
9443
10947
"dependencies": {
9444
-
"acorn": "^8.9.0",
10948
+
"acorn": "^8.15.0",
9445
10949
"acorn-jsx": "^5.3.2",
9446
-
"eslint-visitor-keys": "^3.4.1"
10950
+
"eslint-visitor-keys": "^4.2.1"
9447
10951
},
9448
10952
"engines": {
9449
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
10953
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
9450
10954
},
9451
10955
"funding": {
9452
10956
"url": "https://opencollective.com/eslint"
···
9802
11306
}
9803
11307
},
9804
11308
"node_modules/fast-uri": {
9805
-
"version": "3.0.5",
9806
-
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz",
9807
-
"integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==",
11309
+
"version": "3.1.0",
11310
+
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
11311
+
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
9808
11312
"dev": true,
9809
11313
"funding": [
9810
11314
{
···
9815
11319
"type": "opencollective",
9816
11320
"url": "https://opencollective.com/fastify"
9817
11321
}
9818
-
],
9819
-
"license": "BSD-3-Clause"
11322
+
]
9820
11323
},
9821
11324
"node_modules/fastq": {
9822
11325
"version": "1.17.1",
···
9864
11367
}
9865
11368
},
9866
11369
"node_modules/file-entry-cache": {
9867
-
"version": "6.0.1",
9868
-
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
9869
-
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
11370
+
"version": "8.0.0",
11371
+
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
11372
+
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
9870
11373
"dev": true,
9871
11374
"dependencies": {
9872
-
"flat-cache": "^3.0.4"
11375
+
"flat-cache": "^4.0.0"
9873
11376
},
9874
11377
"engines": {
9875
-
"node": "^10.12.0 || >=12.0.0"
11378
+
"node": ">=16.0.0"
9876
11379
}
9877
11380
},
9878
11381
"node_modules/fill-range": {
···
9937
11440
}
9938
11441
},
9939
11442
"node_modules/flat-cache": {
9940
-
"version": "3.2.0",
9941
-
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
9942
-
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
11443
+
"version": "4.0.1",
11444
+
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
11445
+
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
9943
11446
"dev": true,
9944
11447
"dependencies": {
9945
11448
"flatted": "^3.2.9",
9946
-
"keyv": "^4.5.3",
9947
-
"rimraf": "^3.0.2"
11449
+
"keyv": "^4.5.4"
9948
11450
},
9949
11451
"engines": {
9950
-
"node": "^10.12.0 || >=12.0.0"
11452
+
"node": ">=16"
9951
11453
}
9952
11454
},
9953
11455
"node_modules/flatted": {
9954
-
"version": "3.3.1",
9955
-
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
9956
-
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
11456
+
"version": "3.3.3",
11457
+
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
11458
+
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
9957
11459
"dev": true
9958
11460
},
9959
11461
"node_modules/follow-redirects": {
···
10155
11657
"node": ">=14"
10156
11658
}
10157
11659
},
11660
+
"node_modules/gensync": {
11661
+
"version": "1.0.0-beta.2",
11662
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
11663
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
11664
+
"dev": true,
11665
+
"engines": {
11666
+
"node": ">=6.9.0"
11667
+
}
11668
+
},
10158
11669
"node_modules/get-caller-file": {
10159
11670
"version": "2.0.5",
10160
11671
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
···
10316
11827
}
10317
11828
},
10318
11829
"node_modules/globals": {
10319
-
"version": "13.24.0",
10320
-
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
10321
-
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
11830
+
"version": "14.0.0",
11831
+
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
11832
+
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
10322
11833
"dev": true,
10323
-
"dependencies": {
10324
-
"type-fest": "^0.20.2"
10325
-
},
10326
11834
"engines": {
10327
-
"node": ">=8"
11835
+
"node": ">=18"
10328
11836
},
10329
11837
"funding": {
10330
11838
"url": "https://github.com/sponsors/sindresorhus"
···
10784
12292
"integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
10785
12293
"dev": true
10786
12294
},
12295
+
"node_modules/hermes-estree": {
12296
+
"version": "0.25.1",
12297
+
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
12298
+
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
12299
+
"dev": true
12300
+
},
12301
+
"node_modules/hermes-parser": {
12302
+
"version": "0.25.1",
12303
+
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
12304
+
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
12305
+
"dev": true,
12306
+
"dependencies": {
12307
+
"hermes-estree": "0.25.1"
12308
+
}
12309
+
},
10787
12310
"node_modules/hono": {
10788
12311
"version": "4.7.11",
10789
12312
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz",
···
10888
12411
}
10889
12412
},
10890
12413
"node_modules/import-fresh": {
10891
-
"version": "3.3.0",
10892
-
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
10893
-
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
12414
+
"version": "3.3.1",
12415
+
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
12416
+
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
10894
12417
"dev": true,
10895
12418
"dependencies": {
10896
12419
"parent-module": "^1.0.0",
···
11375
12898
"url": "https://github.com/sponsors/ljharb"
11376
12899
}
11377
12900
},
12901
+
"node_modules/is-negative-zero": {
12902
+
"version": "2.0.3",
12903
+
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
12904
+
"integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
12905
+
"dev": true,
12906
+
"engines": {
12907
+
"node": ">= 0.4"
12908
+
},
12909
+
"funding": {
12910
+
"url": "https://github.com/sponsors/ljharb"
12911
+
}
12912
+
},
11378
12913
"node_modules/is-number": {
11379
12914
"version": "7.0.0",
11380
12915
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
···
11399
12934
},
11400
12935
"funding": {
11401
12936
"url": "https://github.com/sponsors/ljharb"
11402
-
}
11403
-
},
11404
-
"node_modules/is-path-inside": {
11405
-
"version": "3.0.3",
11406
-
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
11407
-
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
11408
-
"dev": true,
11409
-
"engines": {
11410
-
"node": ">=8"
11411
12937
}
11412
12938
},
11413
12939
"node_modules/is-plain-obj": {
···
11666
13192
"license": "MIT"
11667
13193
},
11668
13194
"node_modules/js-yaml": {
11669
-
"version": "4.1.0",
11670
-
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
11671
-
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
13195
+
"version": "4.1.1",
13196
+
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
13197
+
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
11672
13198
"dev": true,
11673
13199
"dependencies": {
11674
13200
"argparse": "^2.0.1"
11675
13201
},
11676
13202
"bin": {
11677
13203
"js-yaml": "bin/js-yaml.js"
13204
+
}
13205
+
},
13206
+
"node_modules/jsesc": {
13207
+
"version": "3.1.0",
13208
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
13209
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
13210
+
"dev": true,
13211
+
"bin": {
13212
+
"jsesc": "bin/jsesc"
13213
+
},
13214
+
"engines": {
13215
+
"node": ">=6"
11678
13216
}
11679
13217
},
11680
13218
"node_modules/json-bigint": {
···
11713
13251
"version": "1.0.0",
11714
13252
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
11715
13253
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
11716
-
"dev": true,
11717
-
"license": "MIT"
13254
+
"dev": true
11718
13255
},
11719
13256
"node_modules/json-stable-stringify-without-jsonify": {
11720
13257
"version": "1.0.1",
···
11832
13369
"dependencies": {
11833
13370
"json-buffer": "3.0.1"
11834
13371
}
13372
+
},
13373
+
"node_modules/l": {
13374
+
"version": "0.6.0",
13375
+
"resolved": "https://registry.npmjs.org/l/-/l-0.6.0.tgz",
13376
+
"integrity": "sha512-rB5disIyfKRBQ1xcedByHCcAmPWy2NPnjWo5u4mVVIPtathROHyfHjkloqSBT49mLnSRnupkpoIUOFCL7irCVQ==",
13377
+
"license": "MIT"
11835
13378
},
11836
13379
"node_modules/language-subtag-registry": {
11837
13380
"version": "0.3.23",
···
13581
15124
}
13582
15125
},
13583
15126
"node_modules/next": {
13584
-
"version": "15.5.3",
13585
-
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz",
13586
-
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==",
15127
+
"version": "16.0.7",
15128
+
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
15129
+
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
13587
15130
"license": "MIT",
13588
15131
"dependencies": {
13589
-
"@next/env": "15.5.3",
15132
+
"@next/env": "16.0.7",
13590
15133
"@swc/helpers": "0.5.15",
13591
15134
"caniuse-lite": "^1.0.30001579",
13592
15135
"postcss": "8.4.31",
···
13596
15139
"next": "dist/bin/next"
13597
15140
},
13598
15141
"engines": {
13599
-
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
15142
+
"node": ">=20.9.0"
13600
15143
},
13601
15144
"optionalDependencies": {
13602
-
"@next/swc-darwin-arm64": "15.5.3",
13603
-
"@next/swc-darwin-x64": "15.5.3",
13604
-
"@next/swc-linux-arm64-gnu": "15.5.3",
13605
-
"@next/swc-linux-arm64-musl": "15.5.3",
13606
-
"@next/swc-linux-x64-gnu": "15.5.3",
13607
-
"@next/swc-linux-x64-musl": "15.5.3",
13608
-
"@next/swc-win32-arm64-msvc": "15.5.3",
13609
-
"@next/swc-win32-x64-msvc": "15.5.3",
13610
-
"sharp": "^0.34.3"
15145
+
"@next/swc-darwin-arm64": "16.0.7",
15146
+
"@next/swc-darwin-x64": "16.0.7",
15147
+
"@next/swc-linux-arm64-gnu": "16.0.7",
15148
+
"@next/swc-linux-arm64-musl": "16.0.7",
15149
+
"@next/swc-linux-x64-gnu": "16.0.7",
15150
+
"@next/swc-linux-x64-musl": "16.0.7",
15151
+
"@next/swc-win32-arm64-msvc": "16.0.7",
15152
+
"@next/swc-win32-x64-msvc": "16.0.7",
15153
+
"sharp": "^0.34.4"
13611
15154
},
13612
15155
"peerDependencies": {
13613
15156
"@opentelemetry/api": "^1.1.0",
···
13731
15274
"node-gyp-build-optional-packages-optional": "optional.js",
13732
15275
"node-gyp-build-optional-packages-test": "build-test.js"
13733
15276
}
15277
+
},
15278
+
"node_modules/node-releases": {
15279
+
"version": "2.0.27",
15280
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
15281
+
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
15282
+
"dev": true
13734
15283
},
13735
15284
"node_modules/normalize-path": {
13736
15285
"version": "3.0.0",
···
14099
15648
"dev": true,
14100
15649
"engines": {
14101
15650
"node": ">=8"
14102
-
}
14103
-
},
14104
-
"node_modules/path-is-absolute": {
14105
-
"version": "1.0.1",
14106
-
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
14107
-
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
14108
-
"dev": true,
14109
-
"engines": {
14110
-
"node": ">=0.10.0"
14111
15651
}
14112
15652
},
14113
15653
"node_modules/path-key": {
···
14798
16338
}
14799
16339
},
14800
16340
"node_modules/react": {
14801
-
"version": "19.1.1",
14802
-
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
14803
-
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
16341
+
"version": "19.2.1",
16342
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
16343
+
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
14804
16344
"license": "MIT",
14805
16345
"engines": {
14806
16346
"node": ">=0.10.0"
···
14920
16460
}
14921
16461
},
14922
16462
"node_modules/react-dom": {
14923
-
"version": "19.1.1",
14924
-
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
14925
-
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
16463
+
"version": "19.2.1",
16464
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
16465
+
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
14926
16466
"license": "MIT",
14927
16467
"dependencies": {
14928
-
"scheduler": "^0.26.0"
16468
+
"scheduler": "^0.27.0"
14929
16469
},
14930
16470
"peerDependencies": {
14931
-
"react": "^19.1.1"
16471
+
"react": "^19.2.1"
14932
16472
}
14933
16473
},
14934
16474
"node_modules/react-is": {
···
15441
16981
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
15442
16982
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
15443
16983
"dev": true,
15444
-
"license": "MIT",
15445
16984
"engines": {
15446
16985
"node": ">=0.10.0"
15447
16986
}
···
15513
17052
"node": ">=0.10.0"
15514
17053
}
15515
17054
},
15516
-
"node_modules/rimraf": {
15517
-
"version": "3.0.2",
15518
-
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
15519
-
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
15520
-
"deprecated": "Rimraf versions prior to v4 are no longer supported",
15521
-
"dev": true,
15522
-
"dependencies": {
15523
-
"glob": "^7.1.3"
15524
-
},
15525
-
"bin": {
15526
-
"rimraf": "bin.js"
15527
-
},
15528
-
"funding": {
15529
-
"url": "https://github.com/sponsors/isaacs"
15530
-
}
15531
-
},
15532
-
"node_modules/rimraf/node_modules/glob": {
15533
-
"version": "7.2.3",
15534
-
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
15535
-
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
15536
-
"deprecated": "Glob versions prior to v9 are no longer supported",
15537
-
"dev": true,
15538
-
"dependencies": {
15539
-
"fs.realpath": "^1.0.0",
15540
-
"inflight": "^1.0.4",
15541
-
"inherits": "2",
15542
-
"minimatch": "^3.1.1",
15543
-
"once": "^1.3.0",
15544
-
"path-is-absolute": "^1.0.0"
15545
-
},
15546
-
"engines": {
15547
-
"node": "*"
15548
-
},
15549
-
"funding": {
15550
-
"url": "https://github.com/sponsors/isaacs"
15551
-
}
15552
-
},
15553
17055
"node_modules/rollup-plugin-inject": {
15554
17056
"version": "3.0.2",
15555
17057
"resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz",
···
15707
17209
"license": "ISC"
15708
17210
},
15709
17211
"node_modules/scheduler": {
15710
-
"version": "0.26.0",
15711
-
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
15712
-
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
15713
-
"license": "MIT"
17212
+
"version": "0.27.0",
17213
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
17214
+
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
15714
17215
},
15715
17216
"node_modules/scmp": {
15716
17217
"version": "2.1.0",
···
16155
17656
"node": ">= 0.8"
16156
17657
}
16157
17658
},
17659
+
"node_modules/stop-iteration-iterator": {
17660
+
"version": "1.1.0",
17661
+
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
17662
+
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
17663
+
"dev": true,
17664
+
"dependencies": {
17665
+
"es-errors": "^1.3.0",
17666
+
"internal-slot": "^1.1.0"
17667
+
},
17668
+
"engines": {
17669
+
"node": ">= 0.4"
17670
+
}
17671
+
},
16158
17672
"node_modules/stoppable": {
16159
17673
"version": "1.1.0",
16160
17674
"resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
···
16482
17996
"integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==",
16483
17997
"license": "ISC"
16484
17998
},
16485
-
"node_modules/text-table": {
16486
-
"version": "0.2.0",
16487
-
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
16488
-
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
16489
-
"dev": true
16490
-
},
16491
17999
"node_modules/thread-stream": {
16492
18000
"version": "2.7.0",
16493
18001
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz",
···
16643
18151
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
16644
18152
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
16645
18153
"dev": true,
16646
-
"license": "MIT",
16647
18154
"engines": {
16648
18155
"node": ">=18.12"
16649
18156
},
···
16761
18268
"node": ">= 0.8.0"
16762
18269
}
16763
18270
},
16764
-
"node_modules/type-fest": {
16765
-
"version": "0.20.2",
16766
-
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
16767
-
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
16768
-
"dev": true,
16769
-
"engines": {
16770
-
"node": ">=10"
16771
-
},
16772
-
"funding": {
16773
-
"url": "https://github.com/sponsors/sindresorhus"
16774
-
}
16775
-
},
16776
18271
"node_modules/type-is": {
16777
18272
"version": "1.6.18",
16778
18273
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
···
16875
18370
},
16876
18371
"engines": {
16877
18372
"node": ">=14.17"
18373
+
}
18374
+
},
18375
+
"node_modules/typescript-eslint": {
18376
+
"version": "8.47.0",
18377
+
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz",
18378
+
"integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==",
18379
+
"dev": true,
18380
+
"dependencies": {
18381
+
"@typescript-eslint/eslint-plugin": "8.47.0",
18382
+
"@typescript-eslint/parser": "8.47.0",
18383
+
"@typescript-eslint/typescript-estree": "8.47.0",
18384
+
"@typescript-eslint/utils": "8.47.0"
18385
+
},
18386
+
"engines": {
18387
+
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
18388
+
},
18389
+
"funding": {
18390
+
"type": "opencollective",
18391
+
"url": "https://opencollective.com/typescript-eslint"
18392
+
},
18393
+
"peerDependencies": {
18394
+
"eslint": "^8.57.0 || ^9.0.0",
18395
+
"typescript": ">=4.8.4 <6.0.0"
16878
18396
}
16879
18397
},
16880
18398
"node_modules/uc.micro": {
···
17048
18566
"node": ">= 0.8"
17049
18567
}
17050
18568
},
18569
+
"node_modules/update-browserslist-db": {
18570
+
"version": "1.1.4",
18571
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
18572
+
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
18573
+
"dev": true,
18574
+
"funding": [
18575
+
{
18576
+
"type": "opencollective",
18577
+
"url": "https://opencollective.com/browserslist"
18578
+
},
18579
+
{
18580
+
"type": "tidelift",
18581
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
18582
+
},
18583
+
{
18584
+
"type": "github",
18585
+
"url": "https://github.com/sponsors/ai"
18586
+
}
18587
+
],
18588
+
"dependencies": {
18589
+
"escalade": "^3.2.0",
18590
+
"picocolors": "^1.1.1"
18591
+
},
18592
+
"bin": {
18593
+
"update-browserslist-db": "cli.js"
18594
+
},
18595
+
"peerDependencies": {
18596
+
"browserslist": ">= 4.21.0"
18597
+
}
18598
+
},
17051
18599
"node_modules/use-callback-ref": {
17052
18600
"version": "1.3.3",
17053
18601
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
···
17457
19005
}
17458
19006
}
17459
19007
},
19008
+
"node_modules/wrangler/node_modules/@esbuild/android-arm": {
19009
+
"version": "0.17.19",
19010
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
19011
+
"integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==",
19012
+
"cpu": [
19013
+
"arm"
19014
+
],
19015
+
"dev": true,
19016
+
"optional": true,
19017
+
"os": [
19018
+
"android"
19019
+
],
19020
+
"engines": {
19021
+
"node": ">=12"
19022
+
}
19023
+
},
19024
+
"node_modules/wrangler/node_modules/@esbuild/android-arm64": {
19025
+
"version": "0.17.19",
19026
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz",
19027
+
"integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==",
19028
+
"cpu": [
19029
+
"arm64"
19030
+
],
19031
+
"dev": true,
19032
+
"optional": true,
19033
+
"os": [
19034
+
"android"
19035
+
],
19036
+
"engines": {
19037
+
"node": ">=12"
19038
+
}
19039
+
},
19040
+
"node_modules/wrangler/node_modules/@esbuild/android-x64": {
19041
+
"version": "0.17.19",
19042
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz",
19043
+
"integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==",
19044
+
"cpu": [
19045
+
"x64"
19046
+
],
19047
+
"dev": true,
19048
+
"optional": true,
19049
+
"os": [
19050
+
"android"
19051
+
],
19052
+
"engines": {
19053
+
"node": ">=12"
19054
+
}
19055
+
},
19056
+
"node_modules/wrangler/node_modules/@esbuild/darwin-arm64": {
19057
+
"version": "0.17.19",
19058
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz",
19059
+
"integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==",
19060
+
"cpu": [
19061
+
"arm64"
19062
+
],
19063
+
"dev": true,
19064
+
"optional": true,
19065
+
"os": [
19066
+
"darwin"
19067
+
],
19068
+
"engines": {
19069
+
"node": ">=12"
19070
+
}
19071
+
},
19072
+
"node_modules/wrangler/node_modules/@esbuild/darwin-x64": {
19073
+
"version": "0.17.19",
19074
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz",
19075
+
"integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==",
19076
+
"cpu": [
19077
+
"x64"
19078
+
],
19079
+
"dev": true,
19080
+
"optional": true,
19081
+
"os": [
19082
+
"darwin"
19083
+
],
19084
+
"engines": {
19085
+
"node": ">=12"
19086
+
}
19087
+
},
19088
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": {
19089
+
"version": "0.17.19",
19090
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz",
19091
+
"integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==",
19092
+
"cpu": [
19093
+
"arm64"
19094
+
],
19095
+
"dev": true,
19096
+
"optional": true,
19097
+
"os": [
19098
+
"freebsd"
19099
+
],
19100
+
"engines": {
19101
+
"node": ">=12"
19102
+
}
19103
+
},
19104
+
"node_modules/wrangler/node_modules/@esbuild/freebsd-x64": {
19105
+
"version": "0.17.19",
19106
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz",
19107
+
"integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==",
19108
+
"cpu": [
19109
+
"x64"
19110
+
],
19111
+
"dev": true,
19112
+
"optional": true,
19113
+
"os": [
19114
+
"freebsd"
19115
+
],
19116
+
"engines": {
19117
+
"node": ">=12"
19118
+
}
19119
+
},
19120
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm": {
19121
+
"version": "0.17.19",
19122
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz",
19123
+
"integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==",
19124
+
"cpu": [
19125
+
"arm"
19126
+
],
19127
+
"dev": true,
19128
+
"optional": true,
19129
+
"os": [
19130
+
"linux"
19131
+
],
19132
+
"engines": {
19133
+
"node": ">=12"
19134
+
}
19135
+
},
19136
+
"node_modules/wrangler/node_modules/@esbuild/linux-arm64": {
19137
+
"version": "0.17.19",
19138
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz",
19139
+
"integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==",
19140
+
"cpu": [
19141
+
"arm64"
19142
+
],
19143
+
"dev": true,
19144
+
"optional": true,
19145
+
"os": [
19146
+
"linux"
19147
+
],
19148
+
"engines": {
19149
+
"node": ">=12"
19150
+
}
19151
+
},
19152
+
"node_modules/wrangler/node_modules/@esbuild/linux-ia32": {
19153
+
"version": "0.17.19",
19154
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz",
19155
+
"integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==",
19156
+
"cpu": [
19157
+
"ia32"
19158
+
],
19159
+
"dev": true,
19160
+
"optional": true,
19161
+
"os": [
19162
+
"linux"
19163
+
],
19164
+
"engines": {
19165
+
"node": ">=12"
19166
+
}
19167
+
},
19168
+
"node_modules/wrangler/node_modules/@esbuild/linux-loong64": {
19169
+
"version": "0.17.19",
19170
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz",
19171
+
"integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==",
19172
+
"cpu": [
19173
+
"loong64"
19174
+
],
19175
+
"dev": true,
19176
+
"optional": true,
19177
+
"os": [
19178
+
"linux"
19179
+
],
19180
+
"engines": {
19181
+
"node": ">=12"
19182
+
}
19183
+
},
19184
+
"node_modules/wrangler/node_modules/@esbuild/linux-mips64el": {
19185
+
"version": "0.17.19",
19186
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz",
19187
+
"integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==",
19188
+
"cpu": [
19189
+
"mips64el"
19190
+
],
19191
+
"dev": true,
19192
+
"optional": true,
19193
+
"os": [
19194
+
"linux"
19195
+
],
19196
+
"engines": {
19197
+
"node": ">=12"
19198
+
}
19199
+
},
19200
+
"node_modules/wrangler/node_modules/@esbuild/linux-ppc64": {
19201
+
"version": "0.17.19",
19202
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz",
19203
+
"integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==",
19204
+
"cpu": [
19205
+
"ppc64"
19206
+
],
19207
+
"dev": true,
19208
+
"optional": true,
19209
+
"os": [
19210
+
"linux"
19211
+
],
19212
+
"engines": {
19213
+
"node": ">=12"
19214
+
}
19215
+
},
19216
+
"node_modules/wrangler/node_modules/@esbuild/linux-riscv64": {
19217
+
"version": "0.17.19",
19218
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz",
19219
+
"integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==",
19220
+
"cpu": [
19221
+
"riscv64"
19222
+
],
19223
+
"dev": true,
19224
+
"optional": true,
19225
+
"os": [
19226
+
"linux"
19227
+
],
19228
+
"engines": {
19229
+
"node": ">=12"
19230
+
}
19231
+
},
19232
+
"node_modules/wrangler/node_modules/@esbuild/linux-s390x": {
19233
+
"version": "0.17.19",
19234
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz",
19235
+
"integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==",
19236
+
"cpu": [
19237
+
"s390x"
19238
+
],
19239
+
"dev": true,
19240
+
"optional": true,
19241
+
"os": [
19242
+
"linux"
19243
+
],
19244
+
"engines": {
19245
+
"node": ">=12"
19246
+
}
19247
+
},
17460
19248
"node_modules/wrangler/node_modules/@esbuild/linux-x64": {
17461
19249
"version": "0.17.19",
17462
19250
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz",
···
17468
19256
"optional": true,
17469
19257
"os": [
17470
19258
"linux"
19259
+
],
19260
+
"engines": {
19261
+
"node": ">=12"
19262
+
}
19263
+
},
19264
+
"node_modules/wrangler/node_modules/@esbuild/netbsd-x64": {
19265
+
"version": "0.17.19",
19266
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz",
19267
+
"integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==",
19268
+
"cpu": [
19269
+
"x64"
19270
+
],
19271
+
"dev": true,
19272
+
"optional": true,
19273
+
"os": [
19274
+
"netbsd"
19275
+
],
19276
+
"engines": {
19277
+
"node": ">=12"
19278
+
}
19279
+
},
19280
+
"node_modules/wrangler/node_modules/@esbuild/openbsd-x64": {
19281
+
"version": "0.17.19",
19282
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz",
19283
+
"integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==",
19284
+
"cpu": [
19285
+
"x64"
19286
+
],
19287
+
"dev": true,
19288
+
"optional": true,
19289
+
"os": [
19290
+
"openbsd"
19291
+
],
19292
+
"engines": {
19293
+
"node": ">=12"
19294
+
}
19295
+
},
19296
+
"node_modules/wrangler/node_modules/@esbuild/sunos-x64": {
19297
+
"version": "0.17.19",
19298
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz",
19299
+
"integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==",
19300
+
"cpu": [
19301
+
"x64"
19302
+
],
19303
+
"dev": true,
19304
+
"optional": true,
19305
+
"os": [
19306
+
"sunos"
19307
+
],
19308
+
"engines": {
19309
+
"node": ">=12"
19310
+
}
19311
+
},
19312
+
"node_modules/wrangler/node_modules/@esbuild/win32-arm64": {
19313
+
"version": "0.17.19",
19314
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz",
19315
+
"integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==",
19316
+
"cpu": [
19317
+
"arm64"
19318
+
],
19319
+
"dev": true,
19320
+
"optional": true,
19321
+
"os": [
19322
+
"win32"
19323
+
],
19324
+
"engines": {
19325
+
"node": ">=12"
19326
+
}
19327
+
},
19328
+
"node_modules/wrangler/node_modules/@esbuild/win32-ia32": {
19329
+
"version": "0.17.19",
19330
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz",
19331
+
"integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==",
19332
+
"cpu": [
19333
+
"ia32"
19334
+
],
19335
+
"dev": true,
19336
+
"optional": true,
19337
+
"os": [
19338
+
"win32"
19339
+
],
19340
+
"engines": {
19341
+
"node": ">=12"
19342
+
}
19343
+
},
19344
+
"node_modules/wrangler/node_modules/@esbuild/win32-x64": {
19345
+
"version": "0.17.19",
19346
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz",
19347
+
"integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==",
19348
+
"cpu": [
19349
+
"x64"
19350
+
],
19351
+
"dev": true,
19352
+
"optional": true,
19353
+
"os": [
19354
+
"win32"
17471
19355
],
17472
19356
"engines": {
17473
19357
"node": ">=12"
+12
-11
package.json
+12
-11
package.json
···
31
31
"@hono/node-server": "^1.14.3",
32
32
"@mdx-js/loader": "^3.1.0",
33
33
"@mdx-js/react": "^3.1.0",
34
-
"@next/bundle-analyzer": "^15.3.2",
35
-
"@next/mdx": "15.3.2",
34
+
"@next/bundle-analyzer": "16.0.3",
35
+
"@next/mdx": "16.0.3",
36
36
"@radix-ui/react-dialog": "^1.1.15",
37
37
"@radix-ui/react-dropdown-menu": "^2.1.16",
38
38
"@radix-ui/react-popover": "^1.1.15",
···
58
58
"inngest": "^3.40.1",
59
59
"ioredis": "^5.6.1",
60
60
"katex": "^0.16.22",
61
+
"l": "^0.6.0",
61
62
"linkifyjs": "^4.2.0",
62
63
"luxon": "^3.7.2",
63
64
"multiformats": "^13.3.2",
64
-
"next": "^15.5.3",
65
+
"next": "^16.0.7",
65
66
"pg": "^8.16.3",
66
67
"prosemirror-commands": "^1.5.2",
67
68
"prosemirror-inputrules": "^1.4.0",
···
69
70
"prosemirror-model": "^1.21.0",
70
71
"prosemirror-schema-basic": "^1.2.2",
71
72
"prosemirror-state": "^1.4.3",
72
-
"react": "^19.1.1",
73
+
"react": "19.2.1",
73
74
"react-aria-components": "^1.8.0",
74
75
"react-day-picker": "^9.3.0",
75
-
"react-dom": "^19.1.1",
76
+
"react-dom": "19.2.1",
76
77
"react-use-measure": "^2.1.1",
77
78
"redlock": "^5.0.0-beta.2",
78
79
"rehype-parse": "^9.0.0",
···
102
103
"@types/katex": "^0.16.7",
103
104
"@types/luxon": "^3.7.1",
104
105
"@types/node": "^22.15.17",
105
-
"@types/react": "19.1.3",
106
-
"@types/react-dom": "19.1.3",
106
+
"@types/react": "19.2.6",
107
+
"@types/react-dom": "19.2.3",
107
108
"@types/uuid": "^10.0.0",
108
109
"drizzle-kit": "^0.21.2",
109
110
"esbuild": "^0.25.4",
110
-
"eslint": "8.57.0",
111
-
"eslint-config-next": "^15.5.3",
111
+
"eslint": "^9.39.1",
112
+
"eslint-config-next": "16.0.3",
112
113
"postcss": "^8.4.38",
113
114
"prettier": "3.2.5",
114
115
"supabase": "^1.187.3",
···
120
121
"overrides": {
121
122
"ajv": "^8.17.1",
122
123
"whatwg-url": "^14.0.0",
123
-
"@types/react": "19.1.3",
124
-
"@types/react-dom": "19.1.3"
124
+
"@types/react": "19.2.6",
125
+
"@types/react-dom": "19.2.3"
125
126
}
126
127
}
+3
-2
src/hooks/useLocalizedDate.ts
+3
-2
src/hooks/useLocalizedDate.ts
···
28
28
29
29
// On initial page load, use header timezone. After hydration, use system timezone
30
30
const effectiveTimezone = !hasPageLoaded
31
-
? timezone
31
+
? timezone || "UTC"
32
32
: Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+
34
+
console.log("tz", effectiveTimezone);
33
35
34
36
// Apply timezone if available
35
37
if (effectiveTimezone) {
···
43
45
? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US"
44
46
: Intl.DateTimeFormat().resolvedOptions().locale;
45
47
46
-
console.log({ effectiveLocale, effectiveTimezone });
47
48
try {
48
49
return dateTime.toLocaleString(options, { locale: effectiveLocale });
49
50
} catch (error) {
+4
-3
src/hooks/usePreserveScroll.ts
+4
-3
src/hooks/usePreserveScroll.ts
···
6
6
useEffect(() => {
7
7
if (!ref.current || !key) return;
8
8
9
-
window.requestAnimationFrame(() => {
10
-
ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
11
-
});
9
+
if (scrollPositions[key] !== undefined)
10
+
window.requestAnimationFrame(() => {
11
+
ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
12
+
});
12
13
13
14
const listener = () => {
14
15
if (!ref.current?.scrollTop) return;
+287
-27
src/notifications.ts
+287
-27
src/notifications.ts
···
2
2
3
3
import { supabaseServerClient } from "supabase/serverClient";
4
4
import { Tables, TablesInsert } from "supabase/database.types";
5
+
import { AtUri } from "@atproto/syntax";
6
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
5
7
6
8
type NotificationRow = Tables<"notifications">;
7
9
···
11
13
12
14
export type NotificationData =
13
15
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
-
| { type: "subscribe"; subscription_uri: string };
16
+
| { type: "subscribe"; subscription_uri: string }
17
+
| { type: "quote"; bsky_post_uri: string; document_uri: string }
18
+
| { type: "mention"; document_uri: string; mention_type: "did" }
19
+
| { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string }
20
+
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }
21
+
| { type: "comment_mention"; comment_uri: string; mention_type: "did" }
22
+
| { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string }
23
+
| { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string };
15
24
16
25
export type HydratedNotification =
17
26
| HydratedCommentNotification
18
-
| HydratedSubscribeNotification;
27
+
| HydratedSubscribeNotification
28
+
| HydratedQuoteNotification
29
+
| HydratedMentionNotification
30
+
| HydratedCommentMentionNotification;
19
31
export async function hydrateNotifications(
20
32
notifications: NotificationRow[],
21
33
): Promise<Array<HydratedNotification>> {
22
34
// Call all hydrators in parallel
23
-
const [commentNotifications, subscribeNotifications] = await Promise.all([
35
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
24
36
hydrateCommentNotifications(notifications),
25
37
hydrateSubscribeNotifications(notifications),
38
+
hydrateQuoteNotifications(notifications),
39
+
hydrateMentionNotifications(notifications),
40
+
hydrateCommentMentionNotifications(notifications),
26
41
]);
27
42
28
43
// Combine all hydrated notifications
29
-
const allHydrated = [...commentNotifications, ...subscribeNotifications];
44
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
30
45
31
46
// Sort by created_at to maintain order
32
47
allHydrated.sort(
···
70
85
)
71
86
.in("uri", commentUris);
72
87
73
-
return commentNotifications.map((notification) => ({
74
-
id: notification.id,
75
-
recipient: notification.recipient,
76
-
created_at: notification.created_at,
77
-
type: "comment" as const,
78
-
comment_uri: notification.data.comment_uri,
79
-
parentData: notification.data.parent_uri
80
-
? comments?.find((c) => c.uri === notification.data.parent_uri)!
81
-
: undefined,
82
-
commentData: comments?.find(
83
-
(c) => c.uri === notification.data.comment_uri,
84
-
)!,
85
-
}));
88
+
return commentNotifications
89
+
.map((notification) => {
90
+
const commentData = comments?.find((c) => c.uri === notification.data.comment_uri);
91
+
if (!commentData) return null;
92
+
return {
93
+
id: notification.id,
94
+
recipient: notification.recipient,
95
+
created_at: notification.created_at,
96
+
type: "comment" as const,
97
+
comment_uri: notification.data.comment_uri,
98
+
parentData: notification.data.parent_uri
99
+
? comments?.find((c) => c.uri === notification.data.parent_uri)
100
+
: undefined,
101
+
commentData,
102
+
};
103
+
})
104
+
.filter((n) => n !== null);
86
105
}
87
106
88
107
export type HydratedSubscribeNotification = Awaited<
···
110
129
.select("*, identities(bsky_profiles(*)), publications(*)")
111
130
.in("uri", subscriptionUris);
112
131
113
-
return subscribeNotifications.map((notification) => ({
114
-
id: notification.id,
115
-
recipient: notification.recipient,
116
-
created_at: notification.created_at,
117
-
type: "subscribe" as const,
118
-
subscription_uri: notification.data.subscription_uri,
119
-
subscriptionData: subscriptions?.find(
120
-
(s) => s.uri === notification.data.subscription_uri,
121
-
)!,
122
-
}));
132
+
return subscribeNotifications
133
+
.map((notification) => {
134
+
const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri);
135
+
if (!subscriptionData) return null;
136
+
return {
137
+
id: notification.id,
138
+
recipient: notification.recipient,
139
+
created_at: notification.created_at,
140
+
type: "subscribe" as const,
141
+
subscription_uri: notification.data.subscription_uri,
142
+
subscriptionData,
143
+
};
144
+
})
145
+
.filter((n) => n !== null);
146
+
}
147
+
148
+
export type HydratedQuoteNotification = Awaited<
149
+
ReturnType<typeof hydrateQuoteNotifications>
150
+
>[0];
151
+
152
+
async function hydrateQuoteNotifications(notifications: NotificationRow[]) {
153
+
const quoteNotifications = notifications.filter(
154
+
(n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } =>
155
+
(n.data as NotificationData)?.type === "quote",
156
+
);
157
+
158
+
if (quoteNotifications.length === 0) {
159
+
return [];
160
+
}
161
+
162
+
// Fetch bsky post data and document data
163
+
const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri);
164
+
const documentUris = quoteNotifications.map((n) => n.data.document_uri);
165
+
166
+
const { data: bskyPosts } = await supabaseServerClient
167
+
.from("bsky_posts")
168
+
.select("*")
169
+
.in("uri", bskyPostUris);
170
+
171
+
const { data: documents } = await supabaseServerClient
172
+
.from("documents")
173
+
.select("*, documents_in_publications(publications(*))")
174
+
.in("uri", documentUris);
175
+
176
+
return quoteNotifications
177
+
.map((notification) => {
178
+
const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri);
179
+
const document = documents?.find((d) => d.uri === notification.data.document_uri);
180
+
if (!bskyPost || !document) return null;
181
+
return {
182
+
id: notification.id,
183
+
recipient: notification.recipient,
184
+
created_at: notification.created_at,
185
+
type: "quote" as const,
186
+
bsky_post_uri: notification.data.bsky_post_uri,
187
+
document_uri: notification.data.document_uri,
188
+
bskyPost,
189
+
document,
190
+
};
191
+
})
192
+
.filter((n) => n !== null);
193
+
}
194
+
195
+
export type HydratedMentionNotification = Awaited<
196
+
ReturnType<typeof hydrateMentionNotifications>
197
+
>[0];
198
+
199
+
async function hydrateMentionNotifications(notifications: NotificationRow[]) {
200
+
const mentionNotifications = notifications.filter(
201
+
(n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } =>
202
+
(n.data as NotificationData)?.type === "mention",
203
+
);
204
+
205
+
if (mentionNotifications.length === 0) {
206
+
return [];
207
+
}
208
+
209
+
// Fetch document data from the database
210
+
const documentUris = mentionNotifications.map((n) => n.data.document_uri);
211
+
const { data: documents } = await supabaseServerClient
212
+
.from("documents")
213
+
.select("*, documents_in_publications(publications(*))")
214
+
.in("uri", documentUris);
215
+
216
+
// Extract unique DIDs from document URIs to resolve handles
217
+
const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))];
218
+
219
+
// Resolve DIDs to handles in parallel
220
+
const didToHandleMap = new Map<string, string | null>();
221
+
await Promise.all(
222
+
documentCreatorDids.map(async (did) => {
223
+
try {
224
+
const resolved = await idResolver.did.resolve(did);
225
+
const handle = resolved?.alsoKnownAs?.[0]
226
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
227
+
: null;
228
+
didToHandleMap.set(did, handle);
229
+
} catch (error) {
230
+
console.error(`Failed to resolve DID ${did}:`, error);
231
+
didToHandleMap.set(did, null);
232
+
}
233
+
}),
234
+
);
235
+
236
+
// Fetch mentioned publications and documents
237
+
const mentionedPublicationUris = mentionNotifications
238
+
.filter((n) => n.data.mention_type === "publication")
239
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri);
240
+
241
+
const mentionedDocumentUris = mentionNotifications
242
+
.filter((n) => n.data.mention_type === "document")
243
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri);
244
+
245
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
246
+
mentionedPublicationUris.length > 0
247
+
? supabaseServerClient
248
+
.from("publications")
249
+
.select("*")
250
+
.in("uri", mentionedPublicationUris)
251
+
: Promise.resolve({ data: [] }),
252
+
mentionedDocumentUris.length > 0
253
+
? supabaseServerClient
254
+
.from("documents")
255
+
.select("*, documents_in_publications(publications(*))")
256
+
.in("uri", mentionedDocumentUris)
257
+
: Promise.resolve({ data: [] }),
258
+
]);
259
+
260
+
return mentionNotifications
261
+
.map((notification) => {
262
+
const document = documents?.find((d) => d.uri === notification.data.document_uri);
263
+
if (!document) return null;
264
+
265
+
const mentionedUri = notification.data.mention_type !== "did"
266
+
? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri
267
+
: undefined;
268
+
269
+
const documentCreatorDid = new AtUri(notification.data.document_uri).host;
270
+
const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null;
271
+
272
+
return {
273
+
id: notification.id,
274
+
recipient: notification.recipient,
275
+
created_at: notification.created_at,
276
+
type: "mention" as const,
277
+
document_uri: notification.data.document_uri,
278
+
mention_type: notification.data.mention_type,
279
+
mentioned_uri: mentionedUri,
280
+
document,
281
+
documentCreatorHandle,
282
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
283
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
284
+
};
285
+
})
286
+
.filter((n) => n !== null);
287
+
}
288
+
289
+
export type HydratedCommentMentionNotification = Awaited<
290
+
ReturnType<typeof hydrateCommentMentionNotifications>
291
+
>[0];
292
+
293
+
async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) {
294
+
const commentMentionNotifications = notifications.filter(
295
+
(n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } =>
296
+
(n.data as NotificationData)?.type === "comment_mention",
297
+
);
298
+
299
+
if (commentMentionNotifications.length === 0) {
300
+
return [];
301
+
}
302
+
303
+
// Fetch comment data from the database
304
+
const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri);
305
+
const { data: comments } = await supabaseServerClient
306
+
.from("comments_on_documents")
307
+
.select(
308
+
"*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))",
309
+
)
310
+
.in("uri", commentUris);
311
+
312
+
// Extract unique DIDs from comment URIs to resolve handles
313
+
const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))];
314
+
315
+
// Resolve DIDs to handles in parallel
316
+
const didToHandleMap = new Map<string, string | null>();
317
+
await Promise.all(
318
+
commenterDids.map(async (did) => {
319
+
try {
320
+
const resolved = await idResolver.did.resolve(did);
321
+
const handle = resolved?.alsoKnownAs?.[0]
322
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
323
+
: null;
324
+
didToHandleMap.set(did, handle);
325
+
} catch (error) {
326
+
console.error(`Failed to resolve DID ${did}:`, error);
327
+
didToHandleMap.set(did, null);
328
+
}
329
+
}),
330
+
);
331
+
332
+
// Fetch mentioned publications and documents
333
+
const mentionedPublicationUris = commentMentionNotifications
334
+
.filter((n) => n.data.mention_type === "publication")
335
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri);
336
+
337
+
const mentionedDocumentUris = commentMentionNotifications
338
+
.filter((n) => n.data.mention_type === "document")
339
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri);
340
+
341
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
342
+
mentionedPublicationUris.length > 0
343
+
? supabaseServerClient
344
+
.from("publications")
345
+
.select("*")
346
+
.in("uri", mentionedPublicationUris)
347
+
: Promise.resolve({ data: [] }),
348
+
mentionedDocumentUris.length > 0
349
+
? supabaseServerClient
350
+
.from("documents")
351
+
.select("*, documents_in_publications(publications(*))")
352
+
.in("uri", mentionedDocumentUris)
353
+
: Promise.resolve({ data: [] }),
354
+
]);
355
+
356
+
return commentMentionNotifications
357
+
.map((notification) => {
358
+
const commentData = comments?.find((c) => c.uri === notification.data.comment_uri);
359
+
if (!commentData) return null;
360
+
361
+
const mentionedUri = notification.data.mention_type !== "did"
362
+
? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri
363
+
: undefined;
364
+
365
+
const commenterDid = new AtUri(notification.data.comment_uri).host;
366
+
const commenterHandle = didToHandleMap.get(commenterDid) ?? null;
367
+
368
+
return {
369
+
id: notification.id,
370
+
recipient: notification.recipient,
371
+
created_at: notification.created_at,
372
+
type: "comment_mention" as const,
373
+
comment_uri: notification.data.comment_uri,
374
+
mention_type: notification.data.mention_type,
375
+
mentioned_uri: mentionedUri,
376
+
commentData,
377
+
commenterHandle,
378
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
379
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
380
+
};
381
+
})
382
+
.filter((n) => n !== null);
123
383
}
124
384
125
385
export async function pingIdentityToUpdateNotification(did: string) {
+34
-8
src/replicache/mutations.ts
+34
-8
src/replicache/mutations.ts
···
609
609
};
610
610
611
611
const updatePublicationDraft: Mutation<{
612
-
title: string;
613
-
description: string;
612
+
title?: string;
613
+
description?: string;
614
+
tags?: string[];
614
615
}> = async (args, ctx) => {
615
616
await ctx.runOnServer(async (serverCtx) => {
616
617
console.log("updating");
617
-
await serverCtx.supabase
618
-
.from("leaflets_in_publications")
619
-
.update({ description: args.description, title: args.title })
620
-
.eq("leaflet", ctx.permission_token_id);
618
+
const updates: {
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)
629
+
const { data: pubResult } = await serverCtx.supabase
630
+
.from("leaflets_in_publications")
631
+
.update(updates)
632
+
.eq("leaflet", ctx.permission_token_id)
633
+
.select("leaflet");
634
+
635
+
// If no rows were updated in leaflets_in_publications,
636
+
// try leaflets_to_documents (for standalone documents)
637
+
if (!pubResult || pubResult.length === 0) {
638
+
await serverCtx.supabase
639
+
.from("leaflets_to_documents")
640
+
.update(updates)
641
+
.eq("leaflet", ctx.permission_token_id);
642
+
}
643
+
}
621
644
});
622
645
await ctx.runOnClient(async ({ tx }) => {
623
-
await tx.set("publication_title", args.title);
624
-
await tx.set("publication_description", args.description);
646
+
if (args.title !== undefined)
647
+
await tx.set("publication_title", args.title);
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);
625
651
});
626
652
};
627
653
+1
src/utils/codeLanguageStorage.ts
+1
src/utils/codeLanguageStorage.ts
···
1
+
export const LAST_USED_CODE_LANGUAGE_KEY = "lastUsedCodeLanguage";
+116
src/utils/deleteBlock.ts
+116
src/utils/deleteBlock.ts
···
1
+
import { Replicache } from "replicache";
2
+
import { ReplicacheMutators } from "src/replicache";
3
+
import { useUIState } from "src/useUIState";
4
+
import { scanIndex } from "src/replicache/utils";
5
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
+
import { focusBlock } from "src/utils/focusBlock";
7
+
8
+
export async function deleteBlock(
9
+
entities: string[],
10
+
rep: Replicache<ReplicacheMutators>,
11
+
) {
12
+
// get what pagess we need to close as a result of deleting this block
13
+
let pagesToClose = [] as string[];
14
+
for (let entity of entities) {
15
+
let [type] = await rep.query((tx) =>
16
+
scanIndex(tx).eav(entity, "block/type"),
17
+
);
18
+
if (type.data.value === "card") {
19
+
let [childPages] = await rep?.query(
20
+
(tx) => scanIndex(tx).eav(entity, "block/card") || [],
21
+
);
22
+
pagesToClose = [childPages?.data.value];
23
+
}
24
+
if (type.data.value === "mailbox") {
25
+
let [archive] = await rep?.query(
26
+
(tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [],
27
+
);
28
+
let [draft] = await rep?.query(
29
+
(tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [],
30
+
);
31
+
pagesToClose = [archive?.data.value, draft?.data.value];
32
+
}
33
+
}
34
+
35
+
// the next and previous blocks in the block list
36
+
// if the focused thing is a page and not a block, return
37
+
let focusedBlock = useUIState.getState().focusedEntity;
38
+
let parent =
39
+
focusedBlock?.entityType === "page"
40
+
? focusedBlock.entityID
41
+
: focusedBlock?.parent;
42
+
43
+
if (parent) {
44
+
let parentType = await rep?.query((tx) =>
45
+
scanIndex(tx).eav(parent, "page/type"),
46
+
);
47
+
if (parentType[0]?.data.value === "canvas") {
48
+
useUIState
49
+
.getState()
50
+
.setFocusedBlock({ entityType: "page", entityID: parent });
51
+
useUIState.getState().setSelectedBlocks([]);
52
+
} else {
53
+
let siblings =
54
+
(await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
55
+
56
+
let selectedBlocks = useUIState.getState().selectedBlocks;
57
+
let firstSelected = selectedBlocks[0];
58
+
let lastSelected = selectedBlocks[entities.length - 1];
59
+
60
+
let prevBlock =
61
+
siblings?.[
62
+
siblings.findIndex((s) => s.value === firstSelected?.value) - 1
63
+
];
64
+
let prevBlockType = await rep?.query((tx) =>
65
+
scanIndex(tx).eav(prevBlock?.value, "block/type"),
66
+
);
67
+
68
+
let nextBlock =
69
+
siblings?.[
70
+
siblings.findIndex((s) => s.value === lastSelected.value) + 1
71
+
];
72
+
let nextBlockType = await rep?.query((tx) =>
73
+
scanIndex(tx).eav(nextBlock?.value, "block/type"),
74
+
);
75
+
76
+
if (prevBlock) {
77
+
useUIState.getState().setSelectedBlock({
78
+
value: prevBlock.value,
79
+
parent: prevBlock.parent,
80
+
});
81
+
82
+
focusBlock(
83
+
{
84
+
value: prevBlock.value,
85
+
type: prevBlockType?.[0].data.value,
86
+
parent: prevBlock.parent,
87
+
},
88
+
{ type: "end" },
89
+
);
90
+
} else {
91
+
useUIState.getState().setSelectedBlock({
92
+
value: nextBlock.value,
93
+
parent: nextBlock.parent,
94
+
});
95
+
96
+
focusBlock(
97
+
{
98
+
value: nextBlock.value,
99
+
type: nextBlockType?.[0]?.data.value,
100
+
parent: nextBlock.parent,
101
+
},
102
+
{ type: "start" },
103
+
);
104
+
}
105
+
}
106
+
}
107
+
108
+
pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
109
+
await Promise.all(
110
+
entities.map((entity) =>
111
+
rep?.mutate.removeBlock({
112
+
blockEntity: entity,
113
+
}),
114
+
),
115
+
);
116
+
}
+37
src/utils/focusElement.ts
+37
src/utils/focusElement.ts
···
1
+
import { isIOS } from "src/utils/isDevice";
2
+
3
+
export const focusElement = (
4
+
el?: HTMLInputElement | HTMLTextAreaElement | null,
5
+
) => {
6
+
if (!isIOS()) {
7
+
el?.focus();
8
+
return;
9
+
}
10
+
11
+
let fakeInput = document.createElement("input");
12
+
fakeInput.setAttribute("type", "text");
13
+
fakeInput.style.position = "fixed";
14
+
fakeInput.style.height = "0px";
15
+
fakeInput.style.width = "0px";
16
+
fakeInput.style.fontSize = "16px"; // disable auto zoom
17
+
document.body.appendChild(fakeInput);
18
+
fakeInput.focus();
19
+
setTimeout(() => {
20
+
if (!el) return;
21
+
el.style.transform = "translateY(-2000px)";
22
+
el?.focus();
23
+
fakeInput.remove();
24
+
el.value = " ";
25
+
el.setSelectionRange(1, 1);
26
+
requestAnimationFrame(() => {
27
+
if (el) {
28
+
el.style.transform = "";
29
+
}
30
+
});
31
+
setTimeout(() => {
32
+
if (!el) return;
33
+
el.value = "";
34
+
el.setSelectionRange(0, 0);
35
+
}, 50);
36
+
}, 20);
37
+
};
+73
src/utils/focusPage.ts
+73
src/utils/focusPage.ts
···
1
+
import { Replicache } from "replicache";
2
+
import { Fact, ReplicacheMutators } from "src/replicache";
3
+
import { useUIState } from "src/useUIState";
4
+
import { scanIndex } from "src/replicache/utils";
5
+
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
6
+
import { elementId } from "src/utils/elementId";
7
+
import { focusBlock } from "src/utils/focusBlock";
8
+
9
+
export async function focusPage(
10
+
pageID: string,
11
+
rep: Replicache<ReplicacheMutators>,
12
+
focusFirstBlock?: "focusFirstBlock",
13
+
) {
14
+
// if this page is already focused,
15
+
let focusedBlock = useUIState.getState().focusedEntity;
16
+
// else set this page as focused
17
+
useUIState.setState(() => ({
18
+
focusedEntity: {
19
+
entityType: "page",
20
+
entityID: pageID,
21
+
},
22
+
}));
23
+
24
+
setTimeout(async () => {
25
+
//scroll to page
26
+
27
+
scrollIntoViewIfNeeded(
28
+
document.getElementById(elementId.page(pageID).container),
29
+
false,
30
+
"smooth",
31
+
);
32
+
33
+
// if we asked that the function focus the first block, focus the first block
34
+
if (focusFirstBlock === "focusFirstBlock") {
35
+
let firstBlock = await rep.query(async (tx) => {
36
+
let type = await scanIndex(tx).eav(pageID, "page/type");
37
+
let blocks = await scanIndex(tx).eav(
38
+
pageID,
39
+
type[0]?.data.value === "canvas" ? "canvas/block" : "card/block",
40
+
);
41
+
42
+
let firstBlock = blocks[0];
43
+
44
+
if (!firstBlock) {
45
+
return null;
46
+
}
47
+
48
+
let blockType = (
49
+
await tx
50
+
.scan<
51
+
Fact<"block/type">
52
+
>({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` })
53
+
.toArray()
54
+
)[0];
55
+
56
+
if (!blockType) return null;
57
+
58
+
return {
59
+
value: firstBlock.data.value,
60
+
type: blockType.data.value,
61
+
parent: firstBlock.entity,
62
+
position: firstBlock.data.position,
63
+
};
64
+
});
65
+
66
+
if (firstBlock) {
67
+
setTimeout(() => {
68
+
focusBlock(firstBlock, { type: "start" });
69
+
}, 500);
70
+
}
71
+
}
72
+
}, 50);
73
+
}
+3
-3
src/utils/getCurrentDeploymentDomain.ts
+3
-3
src/utils/getCurrentDeploymentDomain.ts
···
1
-
import { headers, type UnsafeUnwrappedHeaders } from "next/headers";
2
-
export function getCurrentDeploymentDomain() {
3
-
const headersList = (headers() as unknown as UnsafeUnwrappedHeaders);
1
+
import { headers } from "next/headers";
2
+
export async function getCurrentDeploymentDomain() {
3
+
const headersList = await headers();
4
4
const hostname = headersList.get("x-forwarded-host");
5
5
let protocol = headersList.get("x-forwarded-proto");
6
6
return `${protocol}://${hostname}/`;
+12
-3
src/utils/getMicroLinkOgImage.ts
+12
-3
src/utils/getMicroLinkOgImage.ts
···
10
10
},
11
11
) {
12
12
const headersList = await headers();
13
-
const hostname = headersList.get("x-forwarded-host");
13
+
let hostname = headersList.get("x-forwarded-host");
14
14
let protocol = headersList.get("x-forwarded-proto");
15
+
if (process.env.NODE_ENV === "development") {
16
+
protocol === "https";
17
+
hostname = "leaflet.pub";
18
+
}
15
19
let full_path = `${protocol}://${hostname}${path}`;
16
-
return getWebpageImage(full_path, options);
20
+
return getWebpageImage(full_path, {
21
+
...options,
22
+
setJavaScriptEnabled: false,
23
+
});
17
24
}
18
25
19
26
export async function getWebpageImage(
20
27
url: string,
21
28
options?: {
29
+
setJavaScriptEnabled?: boolean;
22
30
width?: number;
23
31
height?: number;
24
32
deviceScaleFactor?: number;
···
35
43
},
36
44
body: JSON.stringify({
37
45
url,
46
+
setJavaScriptEnabled: options?.setJavaScriptEnabled,
38
47
scrollPage: true,
39
48
addStyleTag: [
40
49
{
41
-
content: `* {overflow: hidden !important; }`,
50
+
content: `* {scrollbar-width:none; }`,
42
51
},
43
52
],
44
53
gotoOptions: {
+50
src/utils/getPublicationMetadataFromLeafletData.ts
+50
src/utils/getPublicationMetadataFromLeafletData.ts
···
1
+
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
2
+
import { Json } from "supabase/database.types";
3
+
4
+
export function getPublicationMetadataFromLeafletData(
5
+
data?: GetLeafletDataReturnType["result"]["data"],
6
+
) {
7
+
if (!data) return null;
8
+
9
+
let pubData:
10
+
| {
11
+
description: string;
12
+
title: string;
13
+
leaflet: string;
14
+
doc: string | null;
15
+
publications: {
16
+
identity_did: string;
17
+
name: string;
18
+
indexed_at: string;
19
+
record: Json | null;
20
+
uri: string;
21
+
} | null;
22
+
documents: {
23
+
data: Json;
24
+
indexed_at: string;
25
+
uri: string;
26
+
} | null;
27
+
}
28
+
| undefined
29
+
| null =
30
+
data?.leaflets_in_publications?.[0] ||
31
+
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
32
+
(p) => p.leaflets_in_publications?.length,
33
+
)?.leaflets_in_publications?.[0];
34
+
35
+
// If not found, check for standalone documents
36
+
let standaloneDoc =
37
+
data?.leaflets_to_documents?.[0] ||
38
+
data?.permission_token_rights[0].entity_sets?.permission_tokens.find(
39
+
(p) => p.leaflets_to_documents?.length,
40
+
)?.leaflets_to_documents?.[0];
41
+
if (!pubData && standaloneDoc) {
42
+
// Transform standalone document data to match the expected format
43
+
pubData = {
44
+
...standaloneDoc,
45
+
publications: null, // No publication for standalone docs
46
+
doc: standaloneDoc.document,
47
+
};
48
+
}
49
+
return pubData;
50
+
}
-16
src/utils/isBot.ts
-16
src/utils/isBot.ts
···
1
-
import { cookies, headers, type UnsafeUnwrappedHeaders } from "next/headers";
2
-
export function getIsBot() {
3
-
const userAgent =
4
-
(headers() as unknown as UnsafeUnwrappedHeaders).get("user-agent") || "";
5
-
const botPatterns = [
6
-
/bot/i,
7
-
/crawler/i,
8
-
/spider/i,
9
-
/googlebot/i,
10
-
/bingbot/i,
11
-
/yahoo/i,
12
-
// Add more patterns as needed
13
-
];
14
-
15
-
return botPatterns.some((pattern) => pattern.test(userAgent));
16
-
}
+59
src/utils/mentionUtils.ts
+59
src/utils/mentionUtils.ts
···
1
+
import { AtUri } from "@atproto/api";
2
+
3
+
/**
4
+
* Converts a DID to a Bluesky profile URL
5
+
*/
6
+
export function didToBlueskyUrl(did: string): string {
7
+
return `https://bsky.app/profile/${did}`;
8
+
}
9
+
10
+
/**
11
+
* Converts an AT URI (publication or document) to the appropriate URL
12
+
*/
13
+
export function atUriToUrl(atUri: string): string {
14
+
try {
15
+
const uri = new AtUri(atUri);
16
+
17
+
if (uri.collection === "pub.leaflet.publication") {
18
+
// Publication URL: /lish/{did}/{rkey}
19
+
return `/lish/${uri.host}/${uri.rkey}`;
20
+
} else if (uri.collection === "pub.leaflet.document") {
21
+
// Document URL - we need to resolve this via the API
22
+
// For now, create a redirect route that will handle it
23
+
return `/lish/uri/${encodeURIComponent(atUri)}`;
24
+
}
25
+
26
+
return "#";
27
+
} catch (e) {
28
+
console.error("Failed to parse AT URI:", atUri, e);
29
+
return "#";
30
+
}
31
+
}
32
+
33
+
/**
34
+
* Opens a mention link in the appropriate way
35
+
* - DID mentions open in a new tab (external Bluesky)
36
+
* - Publication/document mentions navigate in the same tab
37
+
*/
38
+
export function handleMentionClick(
39
+
e: MouseEvent | React.MouseEvent,
40
+
type: "did" | "at-uri",
41
+
value: string
42
+
) {
43
+
e.preventDefault();
44
+
e.stopPropagation();
45
+
46
+
if (type === "did") {
47
+
// Open Bluesky profile in new tab
48
+
window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer");
49
+
} else {
50
+
// Navigate to publication/document in same tab
51
+
const url = atUriToUrl(value);
52
+
if (url.startsWith("/lish/uri/")) {
53
+
// Redirect route - navigate to it
54
+
window.location.href = url;
55
+
} else {
56
+
window.location.href = url;
57
+
}
58
+
}
59
+
}
+41
src/utils/yjsFragmentToString.ts
+41
src/utils/yjsFragmentToString.ts
···
1
+
import { XmlElement, XmlText, XmlHook } from "yjs";
2
+
3
+
export type Delta = {
4
+
insert: string;
5
+
attributes?: {
6
+
strong?: {};
7
+
code?: {};
8
+
em?: {};
9
+
underline?: {};
10
+
strikethrough?: {};
11
+
highlight?: { color: string };
12
+
link?: { href: string };
13
+
};
14
+
};
15
+
16
+
export function YJSFragmentToString(
17
+
node: XmlElement | XmlText | XmlHook,
18
+
): string {
19
+
if (node.constructor === XmlElement) {
20
+
// Handle hard_break nodes specially
21
+
if (node.nodeName === "hard_break") {
22
+
return "\n";
23
+
}
24
+
// Handle inline mention nodes
25
+
if (node.nodeName === "didMention" || node.nodeName === "atMention") {
26
+
return node.getAttribute("text") || "";
27
+
}
28
+
return node
29
+
.toArray()
30
+
.map((f) => YJSFragmentToString(f))
31
+
.join("");
32
+
}
33
+
if (node.constructor === XmlText) {
34
+
return (node.toDelta() as Delta[])
35
+
.map((d) => {
36
+
return d.insert;
37
+
})
38
+
.join("");
39
+
}
40
+
return "";
41
+
}
+54
supabase/database.types.ts
+54
supabase/database.types.ts
···
580
580
}
581
581
leaflets_in_publications: {
582
582
Row: {
583
+
archived: boolean | null
583
584
description: string
584
585
doc: string | null
585
586
leaflet: string
···
587
588
title: string
588
589
}
589
590
Insert: {
591
+
archived?: boolean | null
590
592
description?: string
591
593
doc?: string | null
592
594
leaflet: string
···
594
596
title?: string
595
597
}
596
598
Update: {
599
+
archived?: boolean | null
597
600
description?: string
598
601
doc?: string | null
599
602
leaflet?: string
···
624
627
},
625
628
]
626
629
}
630
+
leaflets_to_documents: {
631
+
Row: {
632
+
created_at: string
633
+
description: string
634
+
document: string
635
+
leaflet: string
636
+
title: string
637
+
}
638
+
Insert: {
639
+
created_at?: string
640
+
description?: string
641
+
document: string
642
+
leaflet: string
643
+
title?: string
644
+
}
645
+
Update: {
646
+
created_at?: string
647
+
description?: string
648
+
document?: string
649
+
leaflet?: string
650
+
title?: string
651
+
}
652
+
Relationships: [
653
+
{
654
+
foreignKeyName: "leaflets_to_documents_document_fkey"
655
+
columns: ["document"]
656
+
isOneToOne: false
657
+
referencedRelation: "documents"
658
+
referencedColumns: ["uri"]
659
+
},
660
+
{
661
+
foreignKeyName: "leaflets_to_documents_leaflet_fkey"
662
+
columns: ["leaflet"]
663
+
isOneToOne: false
664
+
referencedRelation: "permission_tokens"
665
+
referencedColumns: ["id"]
666
+
},
667
+
]
668
+
}
627
669
notifications: {
628
670
Row: {
629
671
created_at: string
···
688
730
}
689
731
permission_token_on_homepage: {
690
732
Row: {
733
+
archived: boolean | null
691
734
created_at: string
692
735
identity: string
693
736
token: string
694
737
}
695
738
Insert: {
739
+
archived?: boolean | null
696
740
created_at?: string
697
741
identity: string
698
742
token: string
699
743
}
700
744
Update: {
745
+
archived?: boolean | null
701
746
created_at?: string
702
747
identity?: string
703
748
token?: string
···
1112
1157
client_group_id: string
1113
1158
}
1114
1159
Returns: Database["public"]["CompositeTypes"]["pull_result"]
1160
+
}
1161
+
search_tags: {
1162
+
Args: {
1163
+
search_query: string
1164
+
}
1165
+
Returns: {
1166
+
name: string
1167
+
document_count: number
1168
+
}[]
1115
1169
}
1116
1170
}
1117
1171
Enums: {
+63
supabase/migrations/20251118185507_add_leaflets_to_documents.sql
+63
supabase/migrations/20251118185507_add_leaflets_to_documents.sql
···
1
+
create table "public"."leaflets_to_documents" (
2
+
"leaflet" uuid not null,
3
+
"document" text not null,
4
+
"created_at" timestamp with time zone not null default now(),
5
+
"title" text not null default ''::text,
6
+
"description" text not null default ''::text
7
+
);
8
+
9
+
alter table "public"."leaflets_to_documents" enable row level security;
10
+
11
+
CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document);
12
+
13
+
alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey";
14
+
15
+
alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid;
16
+
17
+
alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey";
18
+
19
+
alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
20
+
21
+
alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey";
22
+
23
+
grant delete on table "public"."leaflets_to_documents" to "anon";
24
+
25
+
grant insert on table "public"."leaflets_to_documents" to "anon";
26
+
27
+
grant references on table "public"."leaflets_to_documents" to "anon";
28
+
29
+
grant select on table "public"."leaflets_to_documents" to "anon";
30
+
31
+
grant trigger on table "public"."leaflets_to_documents" to "anon";
32
+
33
+
grant truncate on table "public"."leaflets_to_documents" to "anon";
34
+
35
+
grant update on table "public"."leaflets_to_documents" to "anon";
36
+
37
+
grant delete on table "public"."leaflets_to_documents" to "authenticated";
38
+
39
+
grant insert on table "public"."leaflets_to_documents" to "authenticated";
40
+
41
+
grant references on table "public"."leaflets_to_documents" to "authenticated";
42
+
43
+
grant select on table "public"."leaflets_to_documents" to "authenticated";
44
+
45
+
grant trigger on table "public"."leaflets_to_documents" to "authenticated";
46
+
47
+
grant truncate on table "public"."leaflets_to_documents" to "authenticated";
48
+
49
+
grant update on table "public"."leaflets_to_documents" to "authenticated";
50
+
51
+
grant delete on table "public"."leaflets_to_documents" to "service_role";
52
+
53
+
grant insert on table "public"."leaflets_to_documents" to "service_role";
54
+
55
+
grant references on table "public"."leaflets_to_documents" to "service_role";
56
+
57
+
grant select on table "public"."leaflets_to_documents" to "service_role";
58
+
59
+
grant trigger on table "public"."leaflets_to_documents" to "service_role";
60
+
61
+
grant truncate on table "public"."leaflets_to_documents" to "service_role";
62
+
63
+
grant update on table "public"."leaflets_to_documents" to "service_role";
+1
supabase/migrations/20251119191717_add_archived_to_permission_tokens_on_homepage.sql
+1
supabase/migrations/20251119191717_add_archived_to_permission_tokens_on_homepage.sql
···
1
+
alter table "public"."permission_token_on_homepage" add column "archived" boolean;
+1
supabase/migrations/20251120215250_add_archived_col_to_leaflets_in_publications.sql
+1
supabase/migrations/20251120215250_add_archived_col_to_leaflets_in_publications.sql
···
1
+
alter table "public"."leaflets_in_publications" add column "archived" boolean;
+15
supabase/migrations/20251122220118_add_cascade_on_update_to_pt_relations.sql
+15
supabase/migrations/20251122220118_add_cascade_on_update_to_pt_relations.sql
···
1
+
alter table "public"."permission_token_on_homepage" drop constraint "permission_token_creator_token_fkey";
2
+
3
+
alter table "public"."leaflets_in_publications" drop constraint "leaflets_in_publications_leaflet_fkey";
4
+
5
+
alter table "public"."leaflets_in_publications" drop column "archived";
6
+
7
+
alter table "public"."permission_token_on_homepage" drop column "archived";
8
+
9
+
alter table "public"."permission_token_on_homepage" add constraint "permission_token_on_homepage_token_fkey" FOREIGN KEY (token) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
10
+
11
+
alter table "public"."permission_token_on_homepage" validate constraint "permission_token_on_homepage_token_fkey";
12
+
13
+
alter table "public"."leaflets_in_publications" add constraint "leaflets_in_publications_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
14
+
15
+
alter table "public"."leaflets_in_publications" validate constraint "leaflets_in_publications_leaflet_fkey";
+2
supabase/migrations/20251124214105_add_back_archived_cols.sql
+2
supabase/migrations/20251124214105_add_back_archived_cols.sql
+14
-5
tsconfig.json
+14
-5
tsconfig.json
···
1
1
{
2
2
"compilerOptions": {
3
-
"lib": ["dom", "dom.iterable", "esnext"],
4
-
"types": ["@cloudflare/workers-types"],
3
+
"lib": [
4
+
"dom",
5
+
"dom.iterable",
6
+
"esnext"
7
+
],
8
+
"types": [
9
+
"@cloudflare/workers-types"
10
+
],
5
11
"baseUrl": ".",
6
12
"allowJs": true,
7
13
"skipLibCheck": true,
···
15
21
"moduleResolution": "node",
16
22
"resolveJsonModule": true,
17
23
"isolatedModules": true,
18
-
"jsx": "preserve",
24
+
"jsx": "react-jsx",
19
25
"plugins": [
20
26
{
21
27
"name": "next"
···
30
36
"**/*.js",
31
37
"**/*.ts",
32
38
"**/*.tsx",
33
-
"**/*.mdx"
39
+
"**/*.mdx",
40
+
".next/dev/types/**/*.ts"
34
41
],
35
-
"exclude": ["node_modules"]
42
+
"exclude": [
43
+
"node_modules"
44
+
]
36
45
}