-51
actions/createIdentity.ts
-51
actions/createIdentity.ts
···
1
-
import {
2
-
entities,
3
-
permission_tokens,
4
-
permission_token_rights,
5
-
entity_sets,
6
-
identities,
7
-
} from "drizzle/schema";
8
-
import { v7 } from "uuid";
9
-
import { PgTransaction } from "drizzle-orm/pg-core";
10
-
import { NodePgDatabase } from "drizzle-orm/node-postgres";
11
-
import { Json } from "supabase/database.types";
12
-
13
-
export async function createIdentity(
14
-
db: NodePgDatabase,
15
-
data?: { email?: string; atp_did?: string },
16
-
) {
17
-
return db.transaction(async (tx) => {
18
-
// Create a new entity set
19
-
let [entity_set] = await tx.insert(entity_sets).values({}).returning();
20
-
// Create a root-entity
21
-
let [entity] = await tx
22
-
.insert(entities)
23
-
// And add it to that permission set
24
-
.values({ set: entity_set.id, id: v7() })
25
-
.returning();
26
-
//Create a new permission token
27
-
let [permissionToken] = await tx
28
-
.insert(permission_tokens)
29
-
.values({ root_entity: entity.id })
30
-
.returning();
31
-
//and give it all the permission on that entity set
32
-
let [rights] = await tx
33
-
.insert(permission_token_rights)
34
-
.values({
35
-
token: permissionToken.id,
36
-
entity_set: entity_set.id,
37
-
read: true,
38
-
write: true,
39
-
create_token: true,
40
-
change_entity_set: true,
41
-
})
42
-
.returning();
43
-
let [identity] = await tx
44
-
.insert(identities)
45
-
.values({ home_page: permissionToken.id, ...data })
46
-
.returning();
47
-
return identity as Omit<typeof identity, "interface_state"> & {
48
-
interface_state: Json;
49
-
};
50
-
});
51
-
}
+14
-12
actions/deleteLeaflet.ts
+14
-12
actions/deleteLeaflet.ts
···
53
53
}
54
54
55
55
// Check if there's a standalone published document
56
-
const leafletDoc = tokenData.leaflets_to_documents;
57
-
if (leafletDoc && leafletDoc.document) {
58
-
if (!identity || !identity.atp_did) {
56
+
const leafletDocs = tokenData.leaflets_to_documents || [];
57
+
if (leafletDocs.length > 0) {
58
+
if (!identity) {
59
59
throw new Error(
60
60
"Unauthorized: You must be logged in to delete a published leaflet",
61
61
);
62
62
}
63
-
const docUri = leafletDoc.documents?.uri;
64
-
// Extract the DID from the document URI (format: at://did:plc:xxx/...)
65
-
if (docUri && !docUri.includes(identity.atp_did)) {
66
-
throw new Error(
67
-
"Unauthorized: You must own the published document to delete this leaflet",
68
-
);
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
+
}
69
71
}
70
72
}
71
73
}
···
81
83
.where(eq(permission_tokens.id, permission_token.id));
82
84
83
85
if (!token?.permission_token_rights?.write) return;
84
-
const entitySet = token.permission_token_rights.entity_set;
85
-
if (!entitySet) return;
86
-
await tx.delete(entities).where(eq(entities.set, entitySet));
86
+
await tx
87
+
.delete(entities)
88
+
.where(eq(entities.set, token.permission_token_rights.entity_set));
87
89
await tx
88
90
.delete(permission_tokens)
89
91
.where(eq(permission_tokens.id, permission_token.id));
+7
-3
actions/emailAuth.ts
+7
-3
actions/emailAuth.ts
···
6
6
import { email_auth_tokens, identities } from "drizzle/schema";
7
7
import { and, eq } from "drizzle-orm";
8
8
import { cookies } from "next/headers";
9
-
import { createIdentity } from "./createIdentity";
10
9
import { setAuthToken } from "src/auth";
11
10
import { pool } from "supabase/pool";
11
+
import { supabaseServerClient } from "supabase/serverClient";
12
12
13
13
async function sendAuthCode(email: string, code: string) {
14
14
if (process.env.NODE_ENV === "development") {
···
114
114
.from(identities)
115
115
.where(eq(identities.email, token.email));
116
116
if (!identity) {
117
-
let newIdentity = await createIdentity(db, { email: token.email });
118
-
identityID = newIdentity.id;
117
+
const { data: newIdentity } = await supabaseServerClient
118
+
.from("identities")
119
+
.insert({ email: token.email })
120
+
.select()
121
+
.single();
122
+
identityID = newIdentity!.id;
119
123
} else {
120
124
identityID = identity.id;
121
125
}
+7
-8
actions/login.ts
+7
-8
actions/login.ts
···
4
4
import {
5
5
email_auth_tokens,
6
6
identities,
7
-
entity_sets,
8
-
entities,
9
-
permission_tokens,
10
-
permission_token_rights,
11
7
permission_token_on_homepage,
12
8
poll_votes_on_entity,
13
9
} from "drizzle/schema";
14
10
import { and, eq, isNull } from "drizzle-orm";
15
11
import { cookies } from "next/headers";
16
12
import { redirect } from "next/navigation";
17
-
import { v7 } from "uuid";
18
-
import { createIdentity } from "./createIdentity";
19
13
import { pool } from "supabase/pool";
14
+
import { supabaseServerClient } from "supabase/serverClient";
20
15
21
16
export async function loginWithEmailToken(
22
17
localLeaflets: { token: { id: string }; added_at: string }[],
···
77
72
identity = existingIdentityFromCookie;
78
73
}
79
74
} else {
80
-
// Create a new identity
81
-
identity = await createIdentity(tx, { email: token.email });
75
+
const { data: newIdentity } = await supabaseServerClient
76
+
.from("identities")
77
+
.insert({ email: token.email })
78
+
.select()
79
+
.single();
80
+
identity = newIdentity!;
82
81
}
83
82
}
84
83
-3
actions/publications/moveLeafletToPublication.ts
-3
actions/publications/moveLeafletToPublication.ts
···
11
11
) {
12
12
let identity = await getIdentityData();
13
13
if (!identity || !identity.atp_did) return null;
14
-
15
-
// Verify publication ownership
16
14
let { data: publication } = await supabaseServerClient
17
15
.from("publications")
18
16
.select("*")
···
20
18
.single();
21
19
if (publication?.identity_did !== identity.atp_did) return;
22
20
23
-
// Save as a publication draft
24
21
await supabaseServerClient.from("leaflets_in_publications").insert({
25
22
publication: publication_uri,
26
23
leaflet: leaflet_id,
-26
actions/publications/saveLeafletDraft.ts
-26
actions/publications/saveLeafletDraft.ts
···
1
-
"use server";
2
-
3
-
import { getIdentityData } from "actions/getIdentityData";
4
-
import { supabaseServerClient } from "supabase/serverClient";
5
-
6
-
export async function saveLeafletDraft(
7
-
leaflet_id: string,
8
-
metadata: { title: string; description: string },
9
-
entitiesToDelete: string[],
10
-
) {
11
-
let identity = await getIdentityData();
12
-
if (!identity || !identity.atp_did) return null;
13
-
14
-
// Save as a looseleaf draft in leaflets_to_documents with null document
15
-
await supabaseServerClient.from("leaflets_to_documents").upsert({
16
-
leaflet: leaflet_id,
17
-
document: null,
18
-
title: metadata.title,
19
-
description: metadata.description,
20
-
});
21
-
22
-
await supabaseServerClient
23
-
.from("entities")
24
-
.delete()
25
-
.in("id", entitiesToDelete);
26
-
}
+238
-25
actions/publishToPublication.ts
+238
-25
actions/publishToPublication.ts
···
2
2
3
3
import * as Y from "yjs";
4
4
import * as base64 from "base64-js";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
import { getIdentityData } from "actions/getIdentityData";
7
10
import {
8
11
AtpBaseClient,
···
32
35
import { scanIndexLocal } from "src/replicache/utils";
33
36
import type { Fact } from "src/replicache";
34
37
import type { Attribute } from "src/replicache/attributes";
35
-
import {
36
-
Delta,
37
-
YJSFragmentToString,
38
-
} from "components/Blocks/TextBlock/RenderYJSFragment";
38
+
import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
39
39
import { ids } from "lexicons/api/lexicons";
40
40
import { BlobRef } from "@atproto/lexicon";
41
41
import { AtUri } from "@atproto/syntax";
···
50
50
ColorToRGBA,
51
51
} from "components/ThemeManager/colorToLexicons";
52
52
import { parseColor } from "@react-stately/color";
53
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
54
+
import { v7 } from "uuid";
55
+
56
+
type PublishResult =
57
+
| { success: true; rkey: string; record: PubLeafletDocument.Record }
58
+
| { success: false; error: OAuthSessionError };
53
59
54
60
export async function publishToPublication({
55
61
root_entity,
···
57
63
leaflet_id,
58
64
title,
59
65
description,
66
+
tags,
67
+
cover_image,
60
68
entitiesToDelete,
61
69
}: {
62
70
root_entity: string;
···
64
72
leaflet_id: string;
65
73
title?: string;
66
74
description?: string;
75
+
tags?: string[];
76
+
cover_image?: string | null;
67
77
entitiesToDelete?: string[];
68
-
}) {
69
-
const oauthClient = await createOauthClient();
78
+
}): Promise<PublishResult> {
70
79
let identity = await getIdentityData();
71
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
80
+
if (!identity || !identity.atp_did) {
81
+
return {
82
+
success: false,
83
+
error: {
84
+
type: "oauth_session_expired",
85
+
message: "Not authenticated",
86
+
did: "",
87
+
},
88
+
};
89
+
}
72
90
73
-
let credentialSession = await oauthClient.restore(identity.atp_did);
91
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
92
+
if (!sessionResult.ok) {
93
+
return { success: false, error: sessionResult.error };
94
+
}
95
+
let credentialSession = sessionResult.value;
74
96
let agent = new AtpBaseClient(
75
97
credentialSession.fetchHandler.bind(credentialSession),
76
98
);
···
134
156
theme = await extractThemeFromFacts(facts, root_entity, agent);
135
157
}
136
158
159
+
// Upload cover image if provided
160
+
let coverImageBlob: BlobRef | undefined;
161
+
if (cover_image) {
162
+
let scan = scanIndexLocal(facts);
163
+
let [imageData] = scan.eav(cover_image, "block/image");
164
+
if (imageData) {
165
+
let imageResponse = await fetch(imageData.data.src);
166
+
if (imageResponse.status === 200) {
167
+
let binary = await imageResponse.blob();
168
+
let blob = await agent.com.atproto.repo.uploadBlob(binary, {
169
+
headers: { "Content-Type": binary.type },
170
+
});
171
+
coverImageBlob = blob.data.blob;
172
+
}
173
+
}
174
+
}
175
+
137
176
let record: PubLeafletDocument.Record = {
138
177
publishedAt: new Date().toISOString(),
139
178
...existingRecord,
···
143
182
...(theme && { theme }),
144
183
title: title || "Untitled",
145
184
description: description || "",
185
+
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
186
+
...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded
146
187
pages: pages.map((p) => {
147
188
if (p.type === "canvas") {
148
189
return {
···
210
251
}
211
252
}
212
253
213
-
return { rkey, record: JSON.parse(JSON.stringify(record)) };
254
+
// Create notifications for mentions (only on first publish)
255
+
if (!existingDocUri) {
256
+
await createMentionNotifications(result.uri, record, credentialSession.did!);
257
+
}
258
+
259
+
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
214
260
}
215
261
216
262
async function processBlocksToPages(
···
298
344
if (!b) return [];
299
345
let block: PubLeafletPagesLinearDocument.Block = {
300
346
$type: "pub.leaflet.pages.linearDocument#block",
301
-
alignment,
302
347
block: b,
303
348
};
349
+
if (alignment) block.alignment = alignment;
304
350
return [block];
305
351
} else {
306
352
let block: PubLeafletPagesLinearDocument.Block = {
···
342
388
Y.applyUpdate(doc, update);
343
389
let nodes = doc.getXmlElement("prosemirror").toArray();
344
390
let stringValue = YJSFragmentToString(nodes[0]);
345
-
let facets = YJSFragmentToFacets(nodes[0]);
391
+
let { facets } = YJSFragmentToFacets(nodes[0]);
346
392
return [stringValue, facets] as const;
347
393
};
348
394
if (b.type === "card") {
···
398
444
let [stringValue, facets] = getBlockContent(b.value);
399
445
let block: $Typed<PubLeafletBlocksHeader.Main> = {
400
446
$type: "pub.leaflet.blocks.header",
401
-
level: headingLevel?.data.value || 1,
447
+
level: Math.floor(headingLevel?.data.value || 1),
402
448
plaintext: stringValue,
403
449
facets,
404
450
};
···
431
477
let block: $Typed<PubLeafletBlocksIframe.Main> = {
432
478
$type: "pub.leaflet.blocks.iframe",
433
479
url: url.data.value,
434
-
height: height?.data.value || 600,
480
+
height: Math.floor(height?.data.value || 600),
435
481
};
436
482
return block;
437
483
}
···
445
491
$type: "pub.leaflet.blocks.image",
446
492
image: blobref,
447
493
aspectRatio: {
448
-
height: image.data.height,
449
-
width: image.data.width,
494
+
height: Math.floor(image.data.height),
495
+
width: Math.floor(image.data.width),
450
496
},
451
497
alt: altText ? altText.data.value : undefined,
452
498
};
···
603
649
604
650
function YJSFragmentToFacets(
605
651
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
606
-
): PubLeafletRichtextFacet.Main[] {
652
+
byteOffset: number = 0,
653
+
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
607
654
if (node.constructor === Y.XmlElement) {
608
-
return node
609
-
.toArray()
610
-
.map((f) => YJSFragmentToFacets(f))
611
-
.flat();
655
+
// Handle inline mention nodes
656
+
if (node.nodeName === "didMention") {
657
+
const text = node.getAttribute("text") || "";
658
+
const unicodestring = new UnicodeString(text);
659
+
const facet: PubLeafletRichtextFacet.Main = {
660
+
index: {
661
+
byteStart: byteOffset,
662
+
byteEnd: byteOffset + unicodestring.length,
663
+
},
664
+
features: [
665
+
{
666
+
$type: "pub.leaflet.richtext.facet#didMention",
667
+
did: node.getAttribute("did"),
668
+
},
669
+
],
670
+
};
671
+
return { facets: [facet], byteLength: unicodestring.length };
672
+
}
673
+
674
+
if (node.nodeName === "atMention") {
675
+
const text = node.getAttribute("text") || "";
676
+
const unicodestring = new UnicodeString(text);
677
+
const facet: PubLeafletRichtextFacet.Main = {
678
+
index: {
679
+
byteStart: byteOffset,
680
+
byteEnd: byteOffset + unicodestring.length,
681
+
},
682
+
features: [
683
+
{
684
+
$type: "pub.leaflet.richtext.facet#atMention",
685
+
atURI: node.getAttribute("atURI"),
686
+
},
687
+
],
688
+
};
689
+
return { facets: [facet], byteLength: unicodestring.length };
690
+
}
691
+
692
+
if (node.nodeName === "hard_break") {
693
+
const unicodestring = new UnicodeString("\n");
694
+
return { facets: [], byteLength: unicodestring.length };
695
+
}
696
+
697
+
// For other elements (like paragraph), process children
698
+
let allFacets: PubLeafletRichtextFacet.Main[] = [];
699
+
let currentOffset = byteOffset;
700
+
for (const child of node.toArray()) {
701
+
const result = YJSFragmentToFacets(child, currentOffset);
702
+
allFacets.push(...result.facets);
703
+
currentOffset += result.byteLength;
704
+
}
705
+
return { facets: allFacets, byteLength: currentOffset - byteOffset };
612
706
}
707
+
613
708
if (node.constructor === Y.XmlText) {
614
709
let facets: PubLeafletRichtextFacet.Main[] = [];
615
710
let delta = node.toDelta() as Delta[];
616
-
let byteStart = 0;
711
+
let byteStart = byteOffset;
712
+
let totalLength = 0;
617
713
for (let d of delta) {
618
714
let unicodestring = new UnicodeString(d.insert);
619
715
let facet: PubLeafletRichtextFacet.Main = {
···
646
742
});
647
743
if (facet.features.length > 0) facets.push(facet);
648
744
byteStart += unicodestring.length;
745
+
totalLength += unicodestring.length;
649
746
}
650
-
return facets;
747
+
return { facets, byteLength: totalLength };
651
748
}
652
-
return [];
749
+
return { facets: [], byteLength: 0 };
653
750
}
654
751
655
752
type ExcludeString<T> = T extends string
···
712
809
image: blob.data.blob,
713
810
repeat: backgroundImageRepeat?.data.value ? true : false,
714
811
...(backgroundImageRepeat?.data.value && {
715
-
width: backgroundImageRepeat.data.value,
812
+
width: Math.floor(backgroundImageRepeat.data.value),
716
813
}),
717
814
};
718
815
}
···
725
822
726
823
return undefined;
727
824
}
825
+
826
+
/**
827
+
* Extract mentions from a published document and create notifications
828
+
*/
829
+
async function createMentionNotifications(
830
+
documentUri: string,
831
+
record: PubLeafletDocument.Record,
832
+
authorDid: string,
833
+
) {
834
+
const mentionedDids = new Set<string>();
835
+
const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
836
+
const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
837
+
838
+
// Extract mentions from all text blocks in all pages
839
+
for (const page of record.pages) {
840
+
if (page.$type === "pub.leaflet.pages.linearDocument") {
841
+
const linearPage = page as PubLeafletPagesLinearDocument.Main;
842
+
for (const blockWrapper of linearPage.blocks) {
843
+
const block = blockWrapper.block;
844
+
if (block.$type === "pub.leaflet.blocks.text") {
845
+
const textBlock = block as PubLeafletBlocksText.Main;
846
+
if (textBlock.facets) {
847
+
for (const facet of textBlock.facets) {
848
+
for (const feature of facet.features) {
849
+
// Check for DID mentions
850
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
851
+
if (feature.did !== authorDid) {
852
+
mentionedDids.add(feature.did);
853
+
}
854
+
}
855
+
// Check for AT URI mentions (publications and documents)
856
+
if (PubLeafletRichtextFacet.isAtMention(feature)) {
857
+
const uri = new AtUri(feature.atURI);
858
+
859
+
if (uri.collection === "pub.leaflet.publication") {
860
+
// Get the publication owner's DID
861
+
const { data: publication } = await supabaseServerClient
862
+
.from("publications")
863
+
.select("identity_did")
864
+
.eq("uri", feature.atURI)
865
+
.single();
866
+
867
+
if (publication && publication.identity_did !== authorDid) {
868
+
mentionedPublications.set(publication.identity_did, feature.atURI);
869
+
}
870
+
} else if (uri.collection === "pub.leaflet.document") {
871
+
// Get the document owner's DID
872
+
const { data: document } = await supabaseServerClient
873
+
.from("documents")
874
+
.select("uri, data")
875
+
.eq("uri", feature.atURI)
876
+
.single();
877
+
878
+
if (document) {
879
+
const docRecord = document.data as PubLeafletDocument.Record;
880
+
if (docRecord.author !== authorDid) {
881
+
mentionedDocuments.set(docRecord.author, feature.atURI);
882
+
}
883
+
}
884
+
}
885
+
}
886
+
}
887
+
}
888
+
}
889
+
}
890
+
}
891
+
}
892
+
}
893
+
894
+
// Create notifications for DID mentions
895
+
for (const did of mentionedDids) {
896
+
const notification: Notification = {
897
+
id: v7(),
898
+
recipient: did,
899
+
data: {
900
+
type: "mention",
901
+
document_uri: documentUri,
902
+
mention_type: "did",
903
+
},
904
+
};
905
+
await supabaseServerClient.from("notifications").insert(notification);
906
+
await pingIdentityToUpdateNotification(did);
907
+
}
908
+
909
+
// Create notifications for publication mentions
910
+
for (const [recipientDid, publicationUri] of mentionedPublications) {
911
+
const notification: Notification = {
912
+
id: v7(),
913
+
recipient: recipientDid,
914
+
data: {
915
+
type: "mention",
916
+
document_uri: documentUri,
917
+
mention_type: "publication",
918
+
mentioned_uri: publicationUri,
919
+
},
920
+
};
921
+
await supabaseServerClient.from("notifications").insert(notification);
922
+
await pingIdentityToUpdateNotification(recipientDid);
923
+
}
924
+
925
+
// Create notifications for document mentions
926
+
for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
927
+
const notification: Notification = {
928
+
id: v7(),
929
+
recipient: recipientDid,
930
+
data: {
931
+
type: "mention",
932
+
document_uri: documentUri,
933
+
mention_type: "document",
934
+
mentioned_uri: mentionedDocUri,
935
+
},
936
+
};
937
+
await supabaseServerClient.from("notifications").insert(notification);
938
+
await pingIdentityToUpdateNotification(recipientDid);
939
+
}
940
+
}
+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/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>(
+1
app/(home-pages)/discover/PubListing.tsx
+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";
+1
-3
app/(home-pages)/discover/page.tsx
+1
-3
app/(home-pages)/discover/page.tsx
···
17
17
return (
18
18
<DashboardLayout
19
19
id="discover"
20
-
cardBorderHidden={false}
21
20
currentPage="discover"
22
21
defaultTab="default"
23
22
actions={null}
···
32
31
}
33
32
34
33
const DiscoverContent = async (props: { order: string }) => {
35
-
const orderValue =
36
-
props.order === "popular" ? "popular" : "recentlyUpdated";
34
+
const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated";
37
35
let { publications, nextCursor } = await getPublications(orderValue);
38
36
39
37
return (
+7
-92
app/(home-pages)/home/Actions/CreateNewButton.tsx
+7
-92
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
1
1
"use client";
2
2
3
-
import { Action } from "@vercel/sdk/esm/models/userevent";
4
3
import { createNewLeaflet } from "actions/createNewLeaflet";
5
4
import { ActionButton } from "components/ActionBar/ActionButton";
6
5
import { AddTiny } from "components/Icons/AddTiny";
7
-
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
8
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
9
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
10
-
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
11
-
import { Menu, MenuItem, Separator } from "components/Layout";
8
+
import { Menu, MenuItem } from "components/Menu";
12
9
import { useIsMobile } from "src/hooks/isMobile";
13
-
import { useIdentityData } from "components/IdentityProvider";
14
-
import { PubIcon } from "components/ActionBar/Publications";
15
-
import { PubLeafletPublication } from "lexicons/api";
16
-
import { createPublicationDraft } from "actions/createPublicationDraft";
17
-
import { useRouter } from "next/navigation";
18
-
import Link from "next/link";
19
10
20
11
export const CreateNewLeafletButton = (props: {}) => {
21
12
let isMobile = useIsMobile();
···
27
18
}
28
19
};
29
20
return (
30
-
<div className="flex gap-0 flex-row w-full">
31
-
<ActionButton
32
-
id="new-leaflet-button"
33
-
primary
34
-
icon=<AddTiny className="m-1 shrink-0" />
35
-
label="New"
36
-
className="grow rounded-r-none sm:ml-0! sm:mr-0! ml-1! mr-0!"
37
-
onClick={async () => {
38
-
let id = await createNewLeaflet({
39
-
pageType: "doc",
40
-
redirectUser: false,
41
-
});
42
-
openNewLeaflet(id);
43
-
}}
44
-
/>
45
-
<Separator />
46
-
<CreateNewMoreOptionsButton />
47
-
</div>
48
-
);
49
-
};
50
-
51
-
export const CreateNewMoreOptionsButton = (props: {}) => {
52
-
let { identity } = useIdentityData();
53
-
54
-
let isMobile = useIsMobile();
55
-
let openNewLeaflet = (id: string) => {
56
-
if (isMobile) {
57
-
window.location.href = `/${id}?focusFirstBlock`;
58
-
} else {
59
-
window.open(`/${id}?focusFirstBlock`, "_blank");
60
-
}
61
-
};
62
-
63
-
return (
64
21
<Menu
65
22
asChild
66
23
side={isMobile ? "top" : "right"}
67
24
align={isMobile ? "center" : "start"}
68
-
className="py-2"
69
25
trigger={
70
26
<ActionButton
71
-
id="new-leaflet-more-options"
27
+
id="new-leaflet-button"
72
28
primary
73
-
icon=<ArrowDownTiny className="m-1 shrink-0 sm:-rotate-90 rotate-180" />
74
-
className="shrink-0 rounded-l-none w-[34px]! sm:mr-0! sm:ml-0! mr-1! ml-0!"
29
+
icon=<AddTiny className="m-1 shrink-0" />
30
+
label="New"
75
31
/>
76
32
}
77
33
>
78
34
<MenuItem
79
-
className="leading-snug"
80
35
onSelect={async () => {
81
36
let id = await createNewLeaflet({
82
37
pageType: "doc",
···
85
40
openNewLeaflet(id);
86
41
}}
87
42
>
88
-
<BlockDocPageSmall />
43
+
<BlockDocPageSmall />{" "}
89
44
<div className="flex flex-col">
90
-
<div>Doc</div>
45
+
<div>New Doc</div>
91
46
<div className="text-tertiary text-sm font-normal">
92
47
A good ol' text document
93
48
</div>
94
49
</div>
95
50
</MenuItem>
96
51
<MenuItem
97
-
className="leading-snug"
98
52
onSelect={async () => {
99
53
let id = await createNewLeaflet({
100
54
pageType: "canvas",
···
105
59
>
106
60
<BlockCanvasPageSmall />
107
61
<div className="flex flex-col">
108
-
Canvas
62
+
New Canvas
109
63
<div className="text-tertiary text-sm font-normal">
110
64
A digital whiteboard
111
65
</div>
112
66
</div>
113
67
</MenuItem>
114
-
{identity && identity.atp_did && (
115
-
<>
116
-
<hr className="border-border-light mt-2 mb-1 -mx-1" />
117
-
<div className="mx-2 text-sm text-tertiary font-bold">
118
-
AT Proto Draft
119
-
</div>
120
-
<MenuItem className="leading-snug" onSelect={async () => {}}>
121
-
<LooseLeafSmall />
122
-
<div className="flex flex-col">
123
-
Looseleaf
124
-
<div className="text-tertiary text-sm font-normal">
125
-
A one off post on AT Proto
126
-
</div>
127
-
</div>
128
-
</MenuItem>
129
-
{identity?.publications && identity.publications.length > 0 && (
130
-
<>
131
-
<hr className="border-border-light border-dashed mx-2 my-0.5" />
132
-
{identity?.publications.map((pub) => {
133
-
let router = useRouter();
134
-
return (
135
-
<MenuItem
136
-
onSelect={async () => {
137
-
let newLeaflet = await createPublicationDraft(pub.uri);
138
-
router.push(`/${newLeaflet}`);
139
-
}}
140
-
>
141
-
<PubIcon
142
-
record={pub.record as PubLeafletPublication.Record}
143
-
uri={pub.uri}
144
-
/>
145
-
{pub.name}
146
-
</MenuItem>
147
-
);
148
-
})}
149
-
</>
150
-
)}
151
-
</>
152
-
)}
153
68
</Menu>
154
69
);
155
70
};
+10
-22
app/(home-pages)/home/HomeLayout.tsx
+10
-22
app/(home-pages)/home/HomeLayout.tsx
···
20
20
useDashboardState,
21
21
} from "components/PageLayouts/DashboardLayout";
22
22
import { Actions } from "./Actions/Actions";
23
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
24
23
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
25
24
import { useState } from "react";
26
25
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
29
28
HomeEmptyState,
30
29
PublicationBanner,
31
30
} from "./HomeEmpty/HomeEmpty";
32
-
import { EmptyState } from "components/EmptyState";
33
31
34
32
export type Leaflet = {
35
33
added_at: string;
···
57
55
props.entityID,
58
56
"theme/background-image",
59
57
);
60
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
61
58
62
59
let [searchValue, setSearchValue] = useState("");
63
60
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
···
82
79
return (
83
80
<DashboardLayout
84
81
id="home"
85
-
cardBorderHidden={cardBorderHidden}
86
82
currentPage="home"
87
83
defaultTab="home"
88
84
actions={<Actions />}
···
102
98
<HomeLeafletList
103
99
titles={props.titles}
104
100
initialFacts={props.initialFacts}
105
-
cardBorderHidden={cardBorderHidden}
106
101
searchValue={debouncedSearchValue}
107
102
/>
108
103
),
···
118
113
[root_entity: string]: Fact<Attribute>[];
119
114
};
120
115
searchValue: string;
121
-
cardBorderHidden: boolean;
122
116
}) {
123
117
let { identity } = useIdentityData();
124
118
let { data: initialFacts } = useSWR(
···
136
130
(acc, tok) => {
137
131
let title =
138
132
tok.permission_tokens.leaflets_in_publications[0]?.title ||
139
-
tok.permission_tokens.leaflets_to_documents?.title;
133
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
140
134
if (title) acc[tok.permission_tokens.root_entity] = title;
141
135
return acc;
142
136
},
···
172
166
searchValue={props.searchValue}
173
167
leaflets={leaflets}
174
168
titles={initialFacts?.titles || {}}
175
-
cardBorderHidden={props.cardBorderHidden}
176
169
initialFacts={initialFacts?.facts || {}}
177
170
showPreview
178
171
/>
···
193
186
[root_entity: string]: Fact<Attribute>[];
194
187
};
195
188
searchValue: string;
196
-
cardBorderHidden: boolean;
197
189
showPreview?: boolean;
198
190
}) {
199
191
let { identity } = useIdentityData();
···
212
204
className={`
213
205
leafletList
214
206
w-full
215
-
${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 "} `}
207
+
${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"} `}
216
208
>
217
-
{searchedLeaflets.length === 0 && (
218
-
<EmptyState>
219
-
<div className="italic">Oh no! No results!</div>
220
-
</EmptyState>
221
-
)}
222
209
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
223
210
<ReplicacheProvider
224
211
disablePull
···
233
220
value={{
234
221
...leaflet,
235
222
leaflets_in_publications: leaflet.leaflets_in_publications || [],
236
-
leaflets_to_documents: leaflet.leaflets_to_documents || null,
223
+
leaflets_to_documents: leaflet.leaflets_to_documents || [],
237
224
blocked_by_admin: null,
238
225
custom_domain_routes: [],
239
226
}}
···
244
231
loggedIn={!!identity}
245
232
display={display}
246
233
added_at={added_at}
247
-
cardBorderHidden={props.cardBorderHidden}
248
234
index={index}
249
235
showPreview={props.showPreview}
250
236
isHidden={
···
292
278
({ token: leaflet, archived: archived }) => {
293
279
let published =
294
280
!!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
295
-
!!leaflet.leaflets_to_documents?.document;
281
+
!!leaflet.leaflets_to_documents?.find((l) => l.document);
296
282
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
297
283
let docs = !leaflet.leaflets_in_publications?.length && !archived;
298
-
// If no filters are active, show all
284
+
285
+
// If no filters are active, show everything that is not archived
299
286
if (
300
287
!filter.drafts &&
301
288
!filter.published &&
···
304
291
)
305
292
return archived === false || archived === null || archived == undefined;
306
293
294
+
//if a filter is on, return itemsd of that filter that are also NOT archived
307
295
return (
308
-
(filter.drafts && drafts) ||
309
-
(filter.published && published) ||
310
-
(filter.docs && docs) ||
296
+
(filter.drafts && drafts && !archived) ||
297
+
(filter.published && published && !archived) ||
298
+
(filter.docs && docs && !archived) ||
311
299
(filter.archived && archived)
312
300
);
313
301
},
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
4
4
import { useState, useRef, useEffect } from "react";
5
5
import { SpeedyLink } from "components/SpeedyLink";
6
6
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
8
8
9
export const LeafletListItem = (props: {
9
10
archived?: boolean | null;
10
11
loggedIn: boolean;
11
12
display: "list" | "grid";
12
-
cardBorderHidden: boolean;
13
13
added_at: string;
14
14
title?: string;
15
15
index: number;
16
16
isHidden: boolean;
17
17
showPreview?: boolean;
18
18
}) => {
19
+
const cardBorderHidden = useCardBorderHidden();
19
20
const pubStatus = useLeafletPublicationStatus();
20
21
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
21
22
let previewRef = useRef<HTMLDivElement | null>(null);
···
47
48
ref={previewRef}
48
49
className={`relative flex gap-3 w-full
49
50
${props.isHidden ? "hidden" : "flex"}
50
-
${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
+
${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
52
style={{
52
-
backgroundColor: props.cardBorderHidden
53
+
backgroundColor: cardBorderHidden
53
54
? "transparent"
54
55
: "rgba(var(--bg-page), var(--bg-page-alpha))",
55
56
}}
···
67
68
loggedIn={props.loggedIn}
68
69
/>
69
70
</div>
70
-
{props.cardBorderHidden && (
71
+
{cardBorderHidden && (
71
72
<hr
72
73
className="last:hidden border-border-light"
73
74
style={{
···
87
88
${props.isHidden ? "hidden" : "flex"}
88
89
`}
89
90
style={{
90
-
backgroundColor: props.cardBorderHidden
91
+
backgroundColor: cardBorderHidden
91
92
? "transparent"
92
93
: "rgba(var(--bg-page), var(--bg-page-alpha))",
93
94
}}
+2
-2
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+2
-2
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
···
1
1
"use client";
2
2
3
-
import { Menu, MenuItem } from "components/Layout";
3
+
import { Menu, MenuItem } from "components/Menu";
4
4
import { useState } from "react";
5
5
import { ButtonPrimary, ButtonTertiary } from "components/Buttons";
6
6
import { useToaster } from "components/Toast";
···
108
108
await archivePost(tokenId);
109
109
toaster({
110
110
content: (
111
-
<div className="font-bold flex gap-2">
111
+
<div className="font-bold flex gap-2 items-center">
112
112
Archived {itemType}!
113
113
<ButtonTertiary
114
114
className="underline text-accent-2!"
+18
-7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
+18
-7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
···
18
18
const firstPage = useEntity(root, "root/page")[0];
19
19
const page = firstPage?.data.value || root;
20
20
21
-
const cardBorderHidden = useCardBorderHidden(root);
21
+
const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data
22
+
.value;
22
23
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
23
24
const rootBackgroundRepeat = useEntity(
24
25
root,
···
49
50
50
51
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
52
-
return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass };
53
+
return {
54
+
root,
55
+
page,
56
+
cardBorderHidden,
57
+
contentWrapperStyle,
58
+
contentWrapperClass,
59
+
};
53
60
}
54
61
55
62
export const LeafletListPreview = (props: { isVisible: boolean }) => {
56
-
const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } =
57
-
useLeafletPreviewData();
63
+
const {
64
+
root,
65
+
page,
66
+
cardBorderHidden,
67
+
contentWrapperStyle,
68
+
contentWrapperClass,
69
+
} = useLeafletPreviewData();
58
70
59
71
return (
60
72
<Tooltip
61
-
open={true}
62
-
delayDuration={0}
63
73
side="right"
74
+
asChild
64
75
trigger={
65
-
<div className="w-12 h-full py-1">
76
+
<div className="w-12 h-full py-1 z-10">
66
77
<div className="rounded-md h-full overflow-hidden">
67
78
<ThemeProvider local entityID={root} className="">
68
79
<ThemeBackgroundProvider entityID={root}>
+1
-1
app/(home-pages)/home/page.tsx
+1
-1
app/(home-pages)/home/page.tsx
···
30
30
(acc, tok) => {
31
31
let title =
32
32
tok.permission_tokens.leaflets_in_publications[0]?.title ||
33
-
tok.permission_tokens.leaflets_to_documents?.title;
33
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
34
34
if (title) acc[tok.permission_tokens.root_entity] = title;
35
35
return acc;
36
36
},
+5
-56
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
+5
-56
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
1
"use client";
2
-
import {
3
-
DashboardLayout,
4
-
PublicationDashboardControls,
5
-
} from "components/PageLayouts/DashboardLayout";
6
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
2
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
7
3
import { useState } from "react";
8
4
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
9
5
import { Fact, PermissionToken } from "src/replicache";
···
14
10
import useSWR from "swr";
15
11
import { getHomeDocs } from "../home/storage";
16
12
import { Leaflet, LeafletList } from "../home/HomeLayout";
17
-
import { EmptyState } from "components/EmptyState";
18
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
19
13
20
14
export const LooseleafsLayout = (props: {
21
15
entityID: string | null;
···
35
29
[searchValue],
36
30
);
37
31
38
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
39
32
return (
40
33
<DashboardLayout
41
34
id="looseleafs"
42
-
cardBorderHidden={cardBorderHidden}
43
35
currentPage="looseleafs"
44
-
defaultTab="Drafts"
36
+
defaultTab="home"
45
37
actions={<Actions />}
46
38
tabs={{
47
-
Drafts: {
48
-
controls: (
49
-
<PublicationDashboardControls
50
-
defaultDisplay={"list"}
51
-
hasBackgroundImage={cardBorderHidden}
52
-
searchValue={searchValue}
53
-
setSearchValueAction={setSearchValue}
54
-
/>
55
-
),
56
-
content: <LooseleafDraftList empty={true} />,
57
-
},
58
-
Published: {
39
+
home: {
59
40
controls: null,
60
41
content: (
61
42
<LooseleafList
62
43
titles={props.titles}
63
44
initialFacts={props.initialFacts}
64
-
cardBorderHidden={cardBorderHidden}
65
45
searchValue={debouncedSearchValue}
66
46
/>
67
47
),
···
71
51
);
72
52
};
73
53
74
-
const LooseleafDraftList = (props: { empty: boolean }) => {
75
-
if (props.empty)
76
-
return (
77
-
<EmptyState className="pt-2">
78
-
<div className="italic">You don't have any looseleaf drafts yetโฆ</div>
79
-
<ButtonPrimary className="mx-auto">New Draft</ButtonPrimary>
80
-
</EmptyState>
81
-
);
82
-
return (
83
-
<div className="flex flex-col">
84
-
<ButtonSecondary fullWidth>New Looseleaf Draft</ButtonSecondary>
85
-
This is where the draft would go if we had them lol
86
-
</div>
87
-
);
88
-
};
89
-
90
54
export const LooseleafList = (props: {
91
55
titles: { [root_entity: string]: string };
92
56
initialFacts: {
93
57
[root_entity: string]: Fact<Attribute>[];
94
58
};
95
59
searchValue: string;
96
-
cardBorderHidden: boolean;
97
60
}) => {
98
61
let { identity } = useIdentityData();
99
62
let { data: initialFacts } = useSWR(
···
111
74
(acc, tok) => {
112
75
let title =
113
76
tok.permission_tokens.leaflets_in_publications[0]?.title ||
114
-
tok.permission_tokens.leaflets_to_documents?.title;
77
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
115
78
if (title) acc[tok.permission_tokens.root_entity] = title;
116
79
return acc;
117
80
},
···
127
90
let leaflets: Leaflet[] = identity
128
91
? identity.permission_token_on_homepage
129
92
.filter(
130
-
(ptoh) =>
131
-
ptoh.permission_tokens.leaflets_to_documents &&
132
-
ptoh.permission_tokens.leaflets_to_documents.document,
93
+
(ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0,
133
94
)
134
95
.map((ptoh) => ({
135
96
added_at: ptoh.created_at,
136
97
token: ptoh.permission_tokens as PermissionToken,
137
98
}))
138
99
: [];
139
-
140
-
if (!leaflets || leaflets.length === 0)
141
-
return (
142
-
<EmptyState>
143
-
<div className="italic">You haven't published any looseleafs yet.</div>
144
-
<ButtonPrimary className="mx-auto">
145
-
Start a Looseleaf Draft
146
-
</ButtonPrimary>
147
-
</EmptyState>
148
-
);
149
-
150
100
return (
151
101
<LeafletList
152
102
defaultDisplay="list"
153
103
searchValue={props.searchValue}
154
104
leaflets={leaflets}
155
105
titles={initialFacts?.titles || {}}
156
-
cardBorderHidden={props.cardBorderHidden}
157
106
initialFacts={initialFacts?.facts || {}}
158
107
showPreview
159
108
/>
+1
-1
app/(home-pages)/looseleafs/page.tsx
+1
-1
app/(home-pages)/looseleafs/page.tsx
···
34
34
(acc, tok) => {
35
35
let title =
36
36
tok.permission_tokens.leaflets_in_publications[0]?.title ||
37
-
tok.permission_tokens.leaflets_to_documents?.title;
37
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
38
38
if (title) acc[tok.permission_tokens.root_entity] = title;
39
39
return acc;
40
40
},
+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
+
};
+44
-24
app/(home-pages)/notifications/MentionNotification.tsx
+44
-24
app/(home-pages)/notifications/MentionNotification.tsx
···
1
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
1
+
import { MentionTiny } from "components/Icons/MentionTiny";
2
2
import { ContentLayout, Notification } from "./Notification";
3
-
import { HydratedQuoteNotification } from "src/notifications";
3
+
import { HydratedMentionNotification } from "src/notifications";
4
4
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
-
import { AtUri } from "@atproto/api";
6
-
import { Avatar } from "components/Avatar";
5
+
import { Agent, AtUri } from "@atproto/api";
7
6
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";
7
+
export const MentionNotification = (props: HydratedMentionNotification) => {
12
8
const docRecord = props.document.data as PubLeafletDocument.Record;
13
-
const pubRecord = props.document.documents_in_publications[0]?.publications
9
+
const pubRecord = props.document.documents_in_publications?.[0]?.publications
14
10
?.record as PubLeafletPublication.Record | undefined;
15
11
const docUri = new AtUri(props.document.uri);
16
12
const rkey = docUri.rkey;
17
13
const did = docUri.host;
18
-
const postText = postView.record?.text || "";
19
14
20
15
const href = pubRecord
21
16
? `https://${pubRecord.base_path}/${rkey}`
22
17
: `/p/${did}/${rkey}`;
23
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
+
}
54
+
24
55
return (
25
56
<Notification
26
57
timestamp={props.created_at}
27
58
href={href}
28
-
icon={<QuoteTiny />}
29
-
actionText={<>{displayName} quoted your post</>}
59
+
icon={<MentionTiny />}
60
+
actionText={actionText}
30
61
content={
31
62
<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>
63
+
{docRecord.description && docRecord.description}
44
64
</ContentLayout>
45
65
}
46
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!"
+9
-1
app/(home-pages)/notifications/NotificationList.tsx
+9
-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 "./MentionNotification";
10
+
import { QuoteNotification } from "./QuoteNotification";
11
+
import { MentionNotification } from "./MentionNotification";
12
+
import { CommentMentionNotification } from "./CommentMentionNotification";
11
13
12
14
export function NotificationList({
13
15
notifications,
···
45
47
}
46
48
if (n.type === "quote") {
47
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} />;
48
56
}
49
57
})}
50
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
+
};
-1
app/(home-pages)/notifications/page.tsx
-1
app/(home-pages)/notifications/page.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
···
1
+
"use client";
2
+
3
+
import { PostListing } from "components/PostListing";
4
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
5
+
import type { Cursor } from "./getProfilePosts";
6
+
import { getProfilePosts } from "./getProfilePosts";
7
+
import useSWRInfinite from "swr/infinite";
8
+
import { useEffect, useRef } from "react";
9
+
10
+
export const ProfilePostsContent = (props: {
11
+
did: string;
12
+
posts: Post[];
13
+
nextCursor: Cursor | null;
14
+
}) => {
15
+
const getKey = (
16
+
pageIndex: number,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
21
+
) => {
22
+
// Reached the end
23
+
if (previousPageData && !previousPageData.nextCursor) return null;
24
+
25
+
// First page, we don't have previousPageData
26
+
if (pageIndex === 0) return ["profile-posts", props.did, null] as const;
27
+
28
+
// Add the cursor to the key
29
+
return ["profile-posts", props.did, previousPageData?.nextCursor] as const;
30
+
};
31
+
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
33
+
getKey,
34
+
([_, did, cursor]) => getProfilePosts(did, cursor),
35
+
{
36
+
fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
37
+
revalidateFirstPage: false,
38
+
},
39
+
);
40
+
41
+
const loadMoreRef = useRef<HTMLDivElement>(null);
42
+
43
+
// Set up intersection observer to load more when trigger element is visible
44
+
useEffect(() => {
45
+
const observer = new IntersectionObserver(
46
+
(entries) => {
47
+
if (entries[0].isIntersecting && !isValidating) {
48
+
const hasMore = data && data[data.length - 1]?.nextCursor;
49
+
if (hasMore) {
50
+
setSize(size + 1);
51
+
}
52
+
}
53
+
},
54
+
{ threshold: 0.1 },
55
+
);
56
+
57
+
if (loadMoreRef.current) {
58
+
observer.observe(loadMoreRef.current);
59
+
}
60
+
61
+
return () => observer.disconnect();
62
+
}, [data, size, setSize, isValidating]);
63
+
64
+
const allPosts = data ? data.flatMap((page) => page.posts) : [];
65
+
66
+
if (allPosts.length === 0 && !isValidating) {
67
+
return <div className="text-tertiary text-center py-4">No posts yet</div>;
68
+
}
69
+
70
+
return (
71
+
<div className="flex flex-col gap-3 text-left relative">
72
+
{allPosts.map((post) => (
73
+
<PostListing key={post.documents.uri} {...post} />
74
+
))}
75
+
{/* Trigger element for loading more posts */}
76
+
<div
77
+
ref={loadMoreRef}
78
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
79
+
aria-hidden="true"
80
+
/>
81
+
{isValidating && (
82
+
<div className="text-center text-tertiary py-4">
83
+
Loading more posts...
84
+
</div>
85
+
)}
86
+
</div>
87
+
);
88
+
};
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
1
+
"use client";
2
+
import { Avatar } from "components/Avatar";
3
+
import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api";
4
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
5
+
import type { ProfileData } from "./layout";
6
+
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
7
+
import { colorToString } from "components/ThemeManager/useColorAttribute";
8
+
import { PubIcon } from "components/ActionBar/Publications";
9
+
import { Json } from "supabase/database.types";
10
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
11
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
12
+
import { SpeedyLink } from "components/SpeedyLink";
13
+
import { ReactNode } from "react";
14
+
import * as linkify from "linkifyjs";
15
+
16
+
export const ProfileHeader = (props: {
17
+
profile: ProfileViewDetailed;
18
+
publications: { record: Json; uri: string }[];
19
+
popover?: boolean;
20
+
}) => {
21
+
let profileRecord = props.profile;
22
+
const profileUrl = `/p/${props.profile.handle}`;
23
+
24
+
const avatarElement = (
25
+
<Avatar
26
+
src={profileRecord.avatar}
27
+
displayName={profileRecord.displayName}
28
+
className="mx-auto mt-3 sm:mt-4"
29
+
giant
30
+
/>
31
+
);
32
+
33
+
const displayNameElement = (
34
+
<h3 className=" px-3 sm:px-4 pt-2 leading-tight">
35
+
{profileRecord.displayName
36
+
? profileRecord.displayName
37
+
: `@${props.profile.handle}`}
38
+
</h3>
39
+
);
40
+
41
+
const handleElement = profileRecord.displayName && (
42
+
<div
43
+
className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`}
44
+
>
45
+
@{props.profile.handle}
46
+
</div>
47
+
);
48
+
49
+
return (
50
+
<div
51
+
className={`flex flex-col relative ${props.popover && "text-sm"}`}
52
+
id="profile-header"
53
+
>
54
+
<ProfileLinks handle={props.profile.handle || ""} />
55
+
<div className="flex flex-col">
56
+
<div className="flex flex-col group">
57
+
{props.popover ? (
58
+
<SpeedyLink className={"hover:no-underline!"} href={profileUrl}>
59
+
{avatarElement}
60
+
</SpeedyLink>
61
+
) : (
62
+
avatarElement
63
+
)}
64
+
{props.popover ? (
65
+
<SpeedyLink
66
+
className={" text-primary group-hover:underline"}
67
+
href={profileUrl}
68
+
>
69
+
{displayNameElement}
70
+
</SpeedyLink>
71
+
) : (
72
+
displayNameElement
73
+
)}
74
+
{props.popover && handleElement ? (
75
+
<SpeedyLink className={"group-hover:underline"} href={profileUrl}>
76
+
{handleElement}
77
+
</SpeedyLink>
78
+
) : (
79
+
handleElement
80
+
)}
81
+
</div>
82
+
<pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap">
83
+
{profileRecord.description
84
+
? parseDescription(profileRecord.description)
85
+
: null}
86
+
</pre>
87
+
<div className=" w-full overflow-x-scroll py-3 mb-3 ">
88
+
<div
89
+
className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`}
90
+
>
91
+
{props.publications.map((p) => (
92
+
<PublicationCard
93
+
key={p.uri}
94
+
record={p.record as PubLeafletPublication.Record}
95
+
uri={p.uri}
96
+
/>
97
+
))}
98
+
</div>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
);
103
+
};
104
+
105
+
const ProfileLinks = (props: { handle: string }) => {
106
+
return (
107
+
<div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2">
108
+
<a
109
+
className="text-tertiary hover:text-accent-contrast hover:no-underline!"
110
+
href={`https://bsky.app/profile/${props.handle}`}
111
+
>
112
+
<BlueskyTiny />
113
+
</a>
114
+
</div>
115
+
);
116
+
};
117
+
const PublicationCard = (props: {
118
+
record: PubLeafletPublication.Record;
119
+
uri: string;
120
+
}) => {
121
+
const { record, uri } = props;
122
+
const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme);
123
+
124
+
return (
125
+
<a
126
+
href={`https://${record.base_path}`}
127
+
className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2"
128
+
style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }}
129
+
>
130
+
<div
131
+
className="rounded-md p-2 flex flex-row gap-2"
132
+
style={{
133
+
backgroundColor: record.theme?.showPageBackground
134
+
? `rgb(${colorToString(bgPage, "rgb")})`
135
+
: undefined,
136
+
}}
137
+
>
138
+
<PubIcon record={record} uri={uri} />
139
+
<h4
140
+
className="truncate min-w-0"
141
+
style={{
142
+
color: `rgb(${colorToString(primary, "rgb")})`,
143
+
}}
144
+
>
145
+
{record.name}
146
+
</h4>
147
+
</div>
148
+
</a>
149
+
);
150
+
};
151
+
152
+
function parseDescription(description: string): ReactNode[] {
153
+
// Find all mentions using regex
154
+
const mentionRegex = /@\S+/g;
155
+
const mentions: { start: number; end: number; value: string }[] = [];
156
+
let mentionMatch;
157
+
while ((mentionMatch = mentionRegex.exec(description)) !== null) {
158
+
mentions.push({
159
+
start: mentionMatch.index,
160
+
end: mentionMatch.index + mentionMatch[0].length,
161
+
value: mentionMatch[0],
162
+
});
163
+
}
164
+
165
+
// Find all URLs using linkifyjs
166
+
const links = linkify.find(description).filter((link) => link.type === "url");
167
+
168
+
// Filter out URLs that overlap with mentions (mentions take priority)
169
+
const nonOverlappingLinks = links.filter((link) => {
170
+
return !mentions.some(
171
+
(mention) =>
172
+
(link.start >= mention.start && link.start < mention.end) ||
173
+
(link.end > mention.start && link.end <= mention.end) ||
174
+
(link.start <= mention.start && link.end >= mention.end),
175
+
);
176
+
});
177
+
178
+
// Combine into a single sorted list
179
+
const allMatches: Array<{
180
+
start: number;
181
+
end: number;
182
+
value: string;
183
+
href: string;
184
+
type: "url" | "mention";
185
+
}> = [
186
+
...nonOverlappingLinks.map((link) => ({
187
+
start: link.start,
188
+
end: link.end,
189
+
value: link.value,
190
+
href: link.href,
191
+
type: "url" as const,
192
+
})),
193
+
...mentions.map((mention) => ({
194
+
start: mention.start,
195
+
end: mention.end,
196
+
value: mention.value,
197
+
href: `/p/${mention.value.slice(1)}`,
198
+
type: "mention" as const,
199
+
})),
200
+
].sort((a, b) => a.start - b.start);
201
+
202
+
const parts: ReactNode[] = [];
203
+
let lastIndex = 0;
204
+
let key = 0;
205
+
206
+
for (const match of allMatches) {
207
+
// Add text before this match
208
+
if (match.start > lastIndex) {
209
+
parts.push(description.slice(lastIndex, match.start));
210
+
}
211
+
212
+
if (match.type === "mention") {
213
+
parts.push(
214
+
<SpeedyLink key={key++} href={match.href}>
215
+
{match.value}
216
+
</SpeedyLink>,
217
+
);
218
+
} else {
219
+
// It's a URL
220
+
const urlWithoutProtocol = match.value
221
+
.replace(/^https?:\/\//, "")
222
+
.replace(/\/+$/, "");
223
+
const displayText =
224
+
urlWithoutProtocol.length > 50
225
+
? urlWithoutProtocol.slice(0, 50) + "โฆ"
226
+
: urlWithoutProtocol;
227
+
parts.push(
228
+
<a key={key++} href={match.href} target="_blank" rel="noopener noreferrer">
229
+
{displayText}
230
+
</a>,
231
+
);
232
+
}
233
+
234
+
lastIndex = match.end;
235
+
}
236
+
237
+
// Add remaining text after last match
238
+
if (lastIndex < description.length) {
239
+
parts.push(description.slice(lastIndex));
240
+
}
241
+
242
+
return parts;
243
+
}
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
···
1
+
"use client";
2
+
3
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
+
5
+
export function ProfileLayout(props: { children: React.ReactNode }) {
6
+
let cardBorderHidden = useCardBorderHidden();
7
+
return (
8
+
<div
9
+
id="profile-content"
10
+
className={`
11
+
${
12
+
cardBorderHidden
13
+
? ""
14
+
: "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page"
15
+
}
16
+
max-w-prose mx-auto w-full
17
+
flex flex-col
18
+
text-center
19
+
`}
20
+
>
21
+
{props.children}
22
+
</div>
23
+
);
24
+
}
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
···
1
+
"use client";
2
+
3
+
import { SpeedyLink } from "components/SpeedyLink";
4
+
import { useSelectedLayoutSegment } from "next/navigation";
5
+
import { useState, useEffect } from "react";
6
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
+
8
+
export type ProfileTabType = "posts" | "comments" | "subscriptions";
9
+
10
+
export const ProfileTabs = (props: { didOrHandle: string }) => {
11
+
const cardBorderHidden = useCardBorderHidden();
12
+
const segment = useSelectedLayoutSegment();
13
+
const currentTab = (segment || "posts") as ProfileTabType;
14
+
const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0);
15
+
const [headerHeight, setHeaderHeight] = useState(0);
16
+
useEffect(() => {
17
+
let headerHeight =
18
+
document.getElementById("profile-header")?.clientHeight || 0;
19
+
setHeaderHeight(headerHeight);
20
+
21
+
const profileContent = cardBorderHidden
22
+
? document.getElementById("home-content")
23
+
: document.getElementById("profile-content");
24
+
const handleScroll = () => {
25
+
if (profileContent) {
26
+
setScrollPosWithinTabContent(
27
+
profileContent.scrollTop - headerHeight > 0
28
+
? profileContent.scrollTop - headerHeight
29
+
: 0,
30
+
);
31
+
}
32
+
};
33
+
34
+
if (profileContent) {
35
+
profileContent.addEventListener("scroll", handleScroll);
36
+
return () => profileContent.removeEventListener("scroll", handleScroll);
37
+
}
38
+
}, []);
39
+
40
+
const baseUrl = `/p/${props.didOrHandle}`;
41
+
const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)";
42
+
43
+
return (
44
+
<div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3">
45
+
<div
46
+
style={
47
+
scrollPosWithinTabContent < 20
48
+
? {
49
+
paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
50
+
paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
51
+
}
52
+
: { paddingLeft: "12px", paddingRight: "12px" }
53
+
}
54
+
>
55
+
<div
56
+
className={`
57
+
border rounded-lg
58
+
${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"}
59
+
py-1
60
+
w-full `}
61
+
style={
62
+
scrollPosWithinTabContent < 20
63
+
? {
64
+
backgroundColor: !cardBorderHidden
65
+
? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})`
66
+
: `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`,
67
+
paddingLeft: !cardBorderHidden
68
+
? "4px"
69
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
70
+
paddingRight: !cardBorderHidden
71
+
? "4px"
72
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
73
+
}
74
+
: {
75
+
backgroundColor: `rgb(${bgColor})`,
76
+
paddingLeft: "4px",
77
+
paddingRight: "4px",
78
+
}
79
+
}
80
+
>
81
+
<div className="flex gap-2 justify-between">
82
+
<div className="flex gap-2">
83
+
<TabLink
84
+
href={baseUrl}
85
+
name="Posts"
86
+
selected={currentTab === "posts"}
87
+
/>
88
+
<TabLink
89
+
href={`${baseUrl}/comments`}
90
+
name="Comments"
91
+
selected={currentTab === "comments"}
92
+
/>
93
+
</div>
94
+
<TabLink
95
+
href={`${baseUrl}/subscriptions`}
96
+
name="Subscriptions"
97
+
selected={currentTab === "subscriptions"}
98
+
/>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
</div>
103
+
);
104
+
};
105
+
106
+
const TabLink = (props: { href: string; name: string; selected: boolean }) => {
107
+
return (
108
+
<SpeedyLink
109
+
href={props.href}
110
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${
111
+
props.selected
112
+
? "text-accent-2 bg-accent-1 font-bold -mb-px"
113
+
: "text-tertiary"
114
+
}`}
115
+
>
116
+
{props.name}
117
+
</SpeedyLink>
118
+
);
119
+
};
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef, useMemo } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { AppBskyActorProfile, AtUri } from "@atproto/api";
6
+
import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
7
+
import { ReplyTiny } from "components/Icons/ReplyTiny";
8
+
import { Avatar } from "components/Avatar";
9
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
10
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
11
+
import {
12
+
getProfileComments,
13
+
type ProfileComment,
14
+
type Cursor,
15
+
} from "./getProfileComments";
16
+
import { timeAgo } from "src/utils/timeAgo";
17
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
18
+
19
+
export const ProfileCommentsContent = (props: {
20
+
did: string;
21
+
comments: ProfileComment[];
22
+
nextCursor: Cursor | null;
23
+
}) => {
24
+
const getKey = (
25
+
pageIndex: number,
26
+
previousPageData: {
27
+
comments: ProfileComment[];
28
+
nextCursor: Cursor | null;
29
+
} | null,
30
+
) => {
31
+
// Reached the end
32
+
if (previousPageData && !previousPageData.nextCursor) return null;
33
+
34
+
// First page, we don't have previousPageData
35
+
if (pageIndex === 0) return ["profile-comments", props.did, null] as const;
36
+
37
+
// Add the cursor to the key
38
+
return [
39
+
"profile-comments",
40
+
props.did,
41
+
previousPageData?.nextCursor,
42
+
] as const;
43
+
};
44
+
45
+
const { data, size, setSize, isValidating } = useSWRInfinite(
46
+
getKey,
47
+
([_, did, cursor]) => getProfileComments(did, cursor),
48
+
{
49
+
fallbackData: [
50
+
{ comments: props.comments, nextCursor: props.nextCursor },
51
+
],
52
+
revalidateFirstPage: false,
53
+
},
54
+
);
55
+
56
+
const loadMoreRef = useRef<HTMLDivElement>(null);
57
+
58
+
// Set up intersection observer to load more when trigger element is visible
59
+
useEffect(() => {
60
+
const observer = new IntersectionObserver(
61
+
(entries) => {
62
+
if (entries[0].isIntersecting && !isValidating) {
63
+
const hasMore = data && data[data.length - 1]?.nextCursor;
64
+
if (hasMore) {
65
+
setSize(size + 1);
66
+
}
67
+
}
68
+
},
69
+
{ threshold: 0.1 },
70
+
);
71
+
72
+
if (loadMoreRef.current) {
73
+
observer.observe(loadMoreRef.current);
74
+
}
75
+
76
+
return () => observer.disconnect();
77
+
}, [data, size, setSize, isValidating]);
78
+
79
+
const allComments = data ? data.flatMap((page) => page.comments) : [];
80
+
81
+
if (allComments.length === 0 && !isValidating) {
82
+
return (
83
+
<div className="text-tertiary text-center py-4">No comments yet</div>
84
+
);
85
+
}
86
+
87
+
return (
88
+
<div className="flex flex-col gap-2 text-left relative">
89
+
{allComments.map((comment) => (
90
+
<CommentItem key={comment.uri} comment={comment} />
91
+
))}
92
+
{/* Trigger element for loading more comments */}
93
+
<div
94
+
ref={loadMoreRef}
95
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
96
+
aria-hidden="true"
97
+
/>
98
+
{isValidating && (
99
+
<div className="text-center text-tertiary py-4">
100
+
Loading more comments...
101
+
</div>
102
+
)}
103
+
</div>
104
+
);
105
+
};
106
+
107
+
const CommentItem = ({ comment }: { comment: ProfileComment }) => {
108
+
const record = comment.record as PubLeafletComment.Record;
109
+
const profile = comment.bsky_profiles?.record as
110
+
| AppBskyActorProfile.Record
111
+
| undefined;
112
+
const displayName =
113
+
profile?.displayName || comment.bsky_profiles?.handle || "Unknown";
114
+
115
+
// Get commenter DID from comment URI
116
+
const commenterDid = new AtUri(comment.uri).host;
117
+
118
+
const isReply = !!record.reply;
119
+
120
+
// Get document title
121
+
const docData = comment.document?.data as
122
+
| PubLeafletDocument.Record
123
+
| undefined;
124
+
const postTitle = docData?.title || "Untitled";
125
+
126
+
// Get parent comment info for replies
127
+
const parentRecord = comment.parentComment?.record as
128
+
| PubLeafletComment.Record
129
+
| undefined;
130
+
const parentProfile = comment.parentComment?.bsky_profiles?.record as
131
+
| AppBskyActorProfile.Record
132
+
| undefined;
133
+
const parentDisplayName =
134
+
parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle;
135
+
136
+
// Build direct link to the comment
137
+
const commentLink = useMemo(() => {
138
+
if (!comment.document) return null;
139
+
const docUri = new AtUri(comment.document.uri);
140
+
141
+
// Get base URL using getPublicationURL if publication exists, otherwise build path
142
+
let baseUrl: string;
143
+
if (comment.publication) {
144
+
baseUrl = getPublicationURL(comment.publication);
145
+
const pubUri = new AtUri(comment.publication.uri);
146
+
// If getPublicationURL returns a relative path, append the document rkey
147
+
if (baseUrl.startsWith("/")) {
148
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
149
+
} else {
150
+
// For custom domains, append the document rkey
151
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
152
+
}
153
+
} else {
154
+
baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`;
155
+
}
156
+
157
+
// Build query parameters
158
+
const params = new URLSearchParams();
159
+
params.set("interactionDrawer", "comments");
160
+
if (record.onPage) {
161
+
params.set("page", record.onPage);
162
+
}
163
+
164
+
// Use comment URI as hash for direct reference
165
+
const commentId = encodeURIComponent(comment.uri);
166
+
167
+
return `${baseUrl}?${params.toString()}#${commentId}`;
168
+
}, [comment.document, comment.publication, comment.uri, record.onPage]);
169
+
170
+
// Get avatar source
171
+
const avatarSrc = profile?.avatar?.ref
172
+
? blobRefToSrc(profile.avatar.ref, commenterDid)
173
+
: undefined;
174
+
175
+
return (
176
+
<div id={comment.uri} className="w-full flex flex-col text-left mb-8">
177
+
<div className="flex gap-2 w-full">
178
+
<Avatar src={avatarSrc} displayName={displayName} />
179
+
<div className="flex flex-col w-full min-w-0 grow">
180
+
<div className="flex flex-row gap-2 justify-between">
181
+
<div className="text-tertiary text-sm truncate">
182
+
<span className="font-bold text-secondary">{displayName}</span>{" "}
183
+
{isReply ? "replied" : "commented"} on{" "}
184
+
{commentLink ? (
185
+
<a
186
+
href={commentLink}
187
+
className="italic text-accent-contrast hover:underline"
188
+
>
189
+
{postTitle}
190
+
</a>
191
+
) : (
192
+
<span className="italic text-accent-contrast">{postTitle}</span>
193
+
)}
194
+
</div>
195
+
<div className="text-tertiary text-sm shrink-0">
196
+
{timeAgo(record.createdAt)}
197
+
</div>
198
+
</div>
199
+
{isReply && parentRecord && (
200
+
<div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center">
201
+
<ReplyTiny className="shrink-0 scale-75" />
202
+
{parentDisplayName && (
203
+
<div className="font-bold shrink-0">{parentDisplayName}</div>
204
+
)}
205
+
<div className="grow truncate">{parentRecord.plaintext}</div>
206
+
</div>
207
+
)}
208
+
<pre
209
+
style={{ wordBreak: "break-word" }}
210
+
className="whitespace-pre-wrap text-secondary"
211
+
>
212
+
<BaseTextBlock
213
+
index={[]}
214
+
plaintext={record.plaintext}
215
+
facets={record.facets}
216
+
/>
217
+
</pre>
218
+
</div>
219
+
</div>
220
+
</div>
221
+
);
222
+
};
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { PubLeafletComment } from "lexicons/api";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export type ProfileComment = {
13
+
uri: string;
14
+
record: Json;
15
+
indexed_at: string;
16
+
bsky_profiles: { record: Json; handle: string | null } | null;
17
+
document: {
18
+
uri: string;
19
+
data: Json;
20
+
} | null;
21
+
publication: {
22
+
uri: string;
23
+
record: Json;
24
+
} | null;
25
+
// For replies, include the parent comment info
26
+
parentComment: {
27
+
uri: string;
28
+
record: Json;
29
+
bsky_profiles: { record: Json; handle: string | null } | null;
30
+
} | null;
31
+
};
32
+
33
+
export async function getProfileComments(
34
+
did: string,
35
+
cursor?: Cursor | null,
36
+
): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> {
37
+
const limit = 20;
38
+
39
+
let query = supabaseServerClient
40
+
.from("comments_on_documents")
41
+
.select(
42
+
`*,
43
+
bsky_profiles(record, handle),
44
+
documents(uri, data, documents_in_publications(publications(*)))`,
45
+
)
46
+
.eq("profile", did)
47
+
.order("indexed_at", { ascending: false })
48
+
.order("uri", { ascending: false })
49
+
.limit(limit);
50
+
51
+
if (cursor) {
52
+
query = query.or(
53
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
54
+
);
55
+
}
56
+
57
+
const { data: rawComments } = await query;
58
+
59
+
if (!rawComments || rawComments.length === 0) {
60
+
return { comments: [], nextCursor: null };
61
+
}
62
+
63
+
// Collect parent comment URIs for replies
64
+
const parentUris = rawComments
65
+
.map((c) => (c.record as PubLeafletComment.Record).reply?.parent)
66
+
.filter((uri): uri is string => !!uri);
67
+
68
+
// Fetch parent comments if there are any replies
69
+
let parentCommentsMap = new Map<
70
+
string,
71
+
{
72
+
uri: string;
73
+
record: Json;
74
+
bsky_profiles: { record: Json; handle: string | null } | null;
75
+
}
76
+
>();
77
+
78
+
if (parentUris.length > 0) {
79
+
const { data: parentComments } = await supabaseServerClient
80
+
.from("comments_on_documents")
81
+
.select(`uri, record, bsky_profiles(record, handle)`)
82
+
.in("uri", parentUris);
83
+
84
+
if (parentComments) {
85
+
for (const pc of parentComments) {
86
+
parentCommentsMap.set(pc.uri, {
87
+
uri: pc.uri,
88
+
record: pc.record,
89
+
bsky_profiles: pc.bsky_profiles,
90
+
});
91
+
}
92
+
}
93
+
}
94
+
95
+
// Transform to ProfileComment format
96
+
const comments: ProfileComment[] = rawComments.map((comment) => {
97
+
const record = comment.record as PubLeafletComment.Record;
98
+
const doc = comment.documents;
99
+
const pub = doc?.documents_in_publications?.[0]?.publications;
100
+
101
+
return {
102
+
uri: comment.uri,
103
+
record: comment.record,
104
+
indexed_at: comment.indexed_at,
105
+
bsky_profiles: comment.bsky_profiles,
106
+
document: doc
107
+
? {
108
+
uri: doc.uri,
109
+
data: doc.data,
110
+
}
111
+
: null,
112
+
publication: pub
113
+
? {
114
+
uri: pub.uri,
115
+
record: pub.record,
116
+
}
117
+
: null,
118
+
parentComment: record.reply?.parent
119
+
? parentCommentsMap.get(record.reply.parent) || null
120
+
: null,
121
+
};
122
+
});
123
+
124
+
const nextCursor =
125
+
comments.length === limit
126
+
? {
127
+
indexed_at: comments[comments.length - 1].indexed_at,
128
+
uri: comments[comments.length - 1].uri,
129
+
}
130
+
: null;
131
+
132
+
return { comments, nextCursor };
133
+
}
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfileComments } from "./getProfileComments";
3
+
import { ProfileCommentsContent } from "./CommentsContent";
4
+
5
+
export default async function ProfileCommentsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { comments, nextCursor } = await getProfileComments(did);
20
+
21
+
return (
22
+
<ProfileCommentsContent
23
+
did={did}
24
+
comments={comments}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export async function getProfilePosts(
13
+
did: string,
14
+
cursor?: Cursor | null,
15
+
): Promise<{ posts: Post[]; nextCursor: Cursor | null }> {
16
+
const limit = 20;
17
+
18
+
let query = supabaseServerClient
19
+
.from("documents")
20
+
.select(
21
+
`*,
22
+
comments_on_documents(count),
23
+
document_mentions_in_bsky(count),
24
+
documents_in_publications(publications(*))`,
25
+
)
26
+
.like("uri", `at://${did}/%`)
27
+
.order("indexed_at", { ascending: false })
28
+
.order("uri", { ascending: false })
29
+
.limit(limit);
30
+
31
+
if (cursor) {
32
+
query = query.or(
33
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
34
+
);
35
+
}
36
+
37
+
let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([
38
+
query,
39
+
supabaseServerClient
40
+
.from("publications")
41
+
.select("*")
42
+
.eq("identity_did", did),
43
+
supabaseServerClient
44
+
.from("bsky_profiles")
45
+
.select("handle")
46
+
.eq("did", did)
47
+
.single(),
48
+
]);
49
+
50
+
// Build a map of publications for quick lookup
51
+
let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
52
+
for (let pub of pubs || []) {
53
+
pubMap.set(pub.uri, pub);
54
+
}
55
+
56
+
// Transform data to Post[] format
57
+
let handle = profile?.handle ? `@${profile.handle}` : null;
58
+
let posts: Post[] = [];
59
+
60
+
for (let doc of docs || []) {
61
+
let pubFromDoc = doc.documents_in_publications?.[0]?.publications;
62
+
let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null;
63
+
64
+
let post: Post = {
65
+
author: handle,
66
+
documents: {
67
+
data: doc.data,
68
+
uri: doc.uri,
69
+
indexed_at: doc.indexed_at,
70
+
comments_on_documents: doc.comments_on_documents,
71
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
72
+
},
73
+
};
74
+
75
+
if (pub) {
76
+
post.publication = {
77
+
href: getPublicationURL(pub),
78
+
pubRecord: pub.record,
79
+
uri: pub.uri,
80
+
};
81
+
}
82
+
83
+
posts.push(post);
84
+
}
85
+
86
+
const nextCursor =
87
+
posts.length === limit
88
+
? {
89
+
indexed_at: posts[posts.length - 1].documents.indexed_at,
90
+
uri: posts[posts.length - 1].documents.uri,
91
+
}
92
+
: null;
93
+
94
+
return { posts, nextCursor };
95
+
}
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { ProfileHeader } from "./ProfileHeader";
6
+
import { ProfileTabs } from "./ProfileTabs";
7
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
8
+
import { ProfileLayout } from "./ProfileLayout";
9
+
import { Agent } from "@atproto/api";
10
+
import { get_profile_data } from "app/api/rpc/[command]/get_profile_data";
11
+
import { Metadata } from "next";
12
+
import { cache } from "react";
13
+
14
+
// Cache the profile data call to prevent concurrent OAuth restores
15
+
const getCachedProfileData = cache(async (did: string) => {
16
+
return get_profile_data.handler(
17
+
{ didOrHandle: did },
18
+
{ supabase: supabaseServerClient },
19
+
);
20
+
});
21
+
22
+
export async function generateMetadata(props: {
23
+
params: Promise<{ didOrHandle: string }>;
24
+
}): Promise<Metadata> {
25
+
let params = await props.params;
26
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
27
+
28
+
let did = didOrHandle;
29
+
if (!didOrHandle.startsWith("did:")) {
30
+
let resolved = await idResolver.handle.resolve(didOrHandle);
31
+
if (!resolved) return { title: "Profile - Leaflet" };
32
+
did = resolved;
33
+
}
34
+
35
+
let profileData = await getCachedProfileData(did);
36
+
let { profile } = profileData.result;
37
+
38
+
if (!profile) return { title: "Profile - Leaflet" };
39
+
40
+
const displayName = profile.displayName;
41
+
const handle = profile.handle;
42
+
43
+
const title = displayName
44
+
? `${displayName} (@${handle}) - Leaflet`
45
+
: `@${handle} - Leaflet`;
46
+
47
+
return { title };
48
+
}
49
+
50
+
export default async function ProfilePageLayout(props: {
51
+
params: Promise<{ didOrHandle: string }>;
52
+
children: React.ReactNode;
53
+
}) {
54
+
let params = await props.params;
55
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
56
+
57
+
// Resolve handle to DID if necessary
58
+
let did = didOrHandle;
59
+
60
+
if (!didOrHandle.startsWith("did:")) {
61
+
let resolved = await idResolver.handle.resolve(didOrHandle);
62
+
if (!resolved) {
63
+
return (
64
+
<NotFoundLayout>
65
+
<p className="font-bold">Sorry, can't resolve handle!</p>
66
+
<p>
67
+
This may be a glitch on our end. If the issue persists please{" "}
68
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
69
+
</p>
70
+
</NotFoundLayout>
71
+
);
72
+
}
73
+
did = resolved;
74
+
}
75
+
let profileData = await getCachedProfileData(did);
76
+
let { publications, profile } = profileData.result;
77
+
78
+
if (!profile) return null;
79
+
80
+
return (
81
+
<DashboardLayout
82
+
id="profile"
83
+
defaultTab="default"
84
+
currentPage="profile"
85
+
actions={null}
86
+
tabs={{
87
+
default: {
88
+
controls: null,
89
+
content: (
90
+
<ProfileLayout>
91
+
<ProfileHeader
92
+
profile={profile}
93
+
publications={publications || []}
94
+
/>
95
+
<ProfileTabs didOrHandle={params.didOrHandle} />
96
+
<div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col">
97
+
{props.children}
98
+
</div>
99
+
</ProfileLayout>
100
+
),
101
+
},
102
+
}}
103
+
/>
104
+
);
105
+
}
106
+
107
+
export type ProfileData = {
108
+
did: string;
109
+
handle: string | null;
110
+
indexed_at: string;
111
+
record: Json;
112
+
};
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfilePosts } from "./getProfilePosts";
3
+
import { ProfilePostsContent } from "./PostsContent";
4
+
5
+
export default async function ProfilePostsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { posts, nextCursor } = await getProfilePosts(did);
20
+
21
+
return (
22
+
<ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} />
23
+
);
24
+
}
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { PubListing } from "app/(home-pages)/discover/PubListing";
6
+
import {
7
+
getSubscriptions,
8
+
type PublicationSubscription,
9
+
} from "app/(home-pages)/reader/getSubscriptions";
10
+
import { Cursor } from "app/(home-pages)/reader/getReaderFeed";
11
+
12
+
export const ProfileSubscriptionsContent = (props: {
13
+
did: string;
14
+
subscriptions: PublicationSubscription[];
15
+
nextCursor: Cursor | null;
16
+
}) => {
17
+
const getKey = (
18
+
pageIndex: number,
19
+
previousPageData: {
20
+
subscriptions: PublicationSubscription[];
21
+
nextCursor: Cursor | null;
22
+
} | null,
23
+
) => {
24
+
// Reached the end
25
+
if (previousPageData && !previousPageData.nextCursor) return null;
26
+
27
+
// First page, we don't have previousPageData
28
+
if (pageIndex === 0)
29
+
return ["profile-subscriptions", props.did, null] as const;
30
+
31
+
// Add the cursor to the key
32
+
return [
33
+
"profile-subscriptions",
34
+
props.did,
35
+
previousPageData?.nextCursor,
36
+
] as const;
37
+
};
38
+
39
+
const { data, size, setSize, isValidating } = useSWRInfinite(
40
+
getKey,
41
+
([_, did, cursor]) => getSubscriptions(did, cursor),
42
+
{
43
+
fallbackData: [
44
+
{ subscriptions: props.subscriptions, nextCursor: props.nextCursor },
45
+
],
46
+
revalidateFirstPage: false,
47
+
},
48
+
);
49
+
50
+
const loadMoreRef = useRef<HTMLDivElement>(null);
51
+
52
+
// Set up intersection observer to load more when trigger element is visible
53
+
useEffect(() => {
54
+
const observer = new IntersectionObserver(
55
+
(entries) => {
56
+
if (entries[0].isIntersecting && !isValidating) {
57
+
const hasMore = data && data[data.length - 1]?.nextCursor;
58
+
if (hasMore) {
59
+
setSize(size + 1);
60
+
}
61
+
}
62
+
},
63
+
{ threshold: 0.1 },
64
+
);
65
+
66
+
if (loadMoreRef.current) {
67
+
observer.observe(loadMoreRef.current);
68
+
}
69
+
70
+
return () => observer.disconnect();
71
+
}, [data, size, setSize, isValidating]);
72
+
73
+
const allSubscriptions = data
74
+
? data.flatMap((page) => page.subscriptions)
75
+
: [];
76
+
77
+
if (allSubscriptions.length === 0 && !isValidating) {
78
+
return (
79
+
<div className="text-tertiary text-center py-4">No subscriptions yet</div>
80
+
);
81
+
}
82
+
83
+
return (
84
+
<div className="relative">
85
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3">
86
+
{allSubscriptions.map((sub) => (
87
+
<PubListing key={sub.uri} {...sub} />
88
+
))}
89
+
</div>
90
+
{/* Trigger element for loading more subscriptions */}
91
+
<div
92
+
ref={loadMoreRef}
93
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
94
+
aria-hidden="true"
95
+
/>
96
+
{isValidating && (
97
+
<div className="text-center text-tertiary py-4">
98
+
Loading more subscriptions...
99
+
</div>
100
+
)}
101
+
</div>
102
+
);
103
+
};
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions";
3
+
import { ProfileSubscriptionsContent } from "./SubscriptionsContent";
4
+
5
+
export default async function ProfileSubscriptionsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
const params = await props.params;
9
+
const didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
const resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { subscriptions, nextCursor } = await getSubscriptions(did);
20
+
21
+
return (
22
+
<ProfileSubscriptionsContent
23
+
did={did}
24
+
subscriptions={subscriptions}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+9
-195
app/(home-pages)/reader/ReaderContent.tsx
+9
-195
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";
24
-
import { EmptyState } from "components/EmptyState";
9
+
import { PostListing } from "components/PostListing";
25
10
26
11
export const ReaderContent = (props: {
27
12
posts: Post[];
···
29
14
}) => {
30
15
const getKey = (
31
16
pageIndex: number,
32
-
previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
33
21
) => {
34
22
// Reached the end
35
23
if (previousPageData && !previousPageData.nextCursor) return null;
···
41
29
return ["reader-feed", previousPageData?.nextCursor] as const;
42
30
};
43
31
44
-
const { data, error, size, setSize, isValidating } = useSWRInfinite(
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
45
33
getKey,
46
34
([_, cursor]) => getReaderFeed(cursor),
47
35
{
···
80
68
return (
81
69
<div className="flex flex-col gap-3 relative">
82
70
{allPosts.map((p) => (
83
-
<Post {...p} key={p.documents.uri} />
71
+
<PostListing {...p} key={p.documents.uri} />
84
72
))}
85
73
{/* Trigger element for loading more posts */}
86
74
<div
···
97
85
);
98
86
};
99
87
100
-
const Post = (props: Post) => {
101
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
102
-
103
-
let postRecord = props.documents.data as PubLeafletDocument.Record;
104
-
let postUri = new AtUri(props.documents.uri);
105
-
106
-
let theme = usePubTheme(pubRecord?.theme);
107
-
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
108
-
? blobRefToSrc(
109
-
pubRecord?.theme?.backgroundImage?.image?.ref,
110
-
new AtUri(props.publication.uri).host,
111
-
)
112
-
: null;
113
-
114
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
115
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
116
-
117
-
let showPageBackground = pubRecord.theme?.showPageBackground;
118
-
119
-
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
120
-
let comments =
121
-
pubRecord.preferences?.showComments === false
122
-
? 0
123
-
: props.documents.comments_on_documents?.[0]?.count || 0;
124
-
125
-
return (
126
-
<BaseThemeProvider {...theme} local>
127
-
<div
128
-
style={{
129
-
backgroundImage: `url(${backgroundImage})`,
130
-
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
131
-
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
132
-
}}
133
-
className={`no-underline! flex flex-row gap-2 w-full relative
134
-
bg-bg-leaflet
135
-
border border-border-light rounded-lg
136
-
sm:p-2 p-2 selected-outline
137
-
hover:outline-accent-contrast hover:border-accent-contrast
138
-
`}
139
-
>
140
-
<a
141
-
className="h-full w-full absolute top-0 left-0"
142
-
href={`${props.publication.href}/${postUri.rkey}`}
143
-
/>
144
-
<div
145
-
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
146
-
style={{
147
-
backgroundColor: showPageBackground
148
-
? "rgba(var(--bg-page), var(--bg-page-alpha))"
149
-
: "transparent",
150
-
}}
151
-
>
152
-
<h3 className="text-primary truncate">{postRecord.title}</h3>
153
-
154
-
<p className="text-secondary">{postRecord.description}</p>
155
-
<div className="flex gap-2 justify-between items-end">
156
-
<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">
157
-
<PubInfo
158
-
href={props.publication.href}
159
-
pubRecord={pubRecord}
160
-
uri={props.publication.uri}
161
-
/>
162
-
<Separator classname="h-4 !min-h-0 md:block hidden" />
163
-
<PostInfo
164
-
author={props.author || ""}
165
-
publishedAt={postRecord.publishedAt}
166
-
/>
167
-
</div>
168
-
169
-
<PostInterations
170
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
171
-
quotesCount={quotes}
172
-
commentsCount={comments}
173
-
showComments={pubRecord.preferences?.showComments}
174
-
/>
175
-
</div>
176
-
</div>
177
-
</div>
178
-
</BaseThemeProvider>
179
-
);
180
-
};
181
-
182
-
const PubInfo = (props: {
183
-
href: string;
184
-
pubRecord: PubLeafletPublication.Record;
185
-
uri: string;
186
-
}) => {
187
-
return (
188
-
<a
189
-
href={props.href}
190
-
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0"
191
-
>
192
-
<PubIcon small record={props.pubRecord} uri={props.uri} />
193
-
{props.pubRecord.name}
194
-
</a>
195
-
);
196
-
};
197
-
198
-
const PostInfo = (props: {
199
-
author: string;
200
-
publishedAt: string | undefined;
201
-
}) => {
202
-
const formattedDate = useLocalizedDate(
203
-
props.publishedAt || new Date().toISOString(),
204
-
{
205
-
year: "numeric",
206
-
month: "short",
207
-
day: "numeric",
208
-
},
209
-
);
210
-
211
-
return (
212
-
<div className="flex flex-wrap gap-2 grow items-center shrink-0">
213
-
{props.author}
214
-
{props.publishedAt && (
215
-
<>
216
-
<Separator classname="h-4 !min-h-0" />
217
-
{formattedDate}{" "}
218
-
</>
219
-
)}
220
-
</div>
221
-
);
222
-
};
223
-
224
-
const PostInterations = (props: {
225
-
quotesCount: number;
226
-
commentsCount: number;
227
-
postUrl: string;
228
-
showComments: boolean | undefined;
229
-
}) => {
230
-
let smoker = useSmoker();
231
-
let interactionsAvailable =
232
-
props.quotesCount > 0 ||
233
-
(props.showComments !== false && props.commentsCount > 0);
234
-
235
-
return (
236
-
<div className={`flex gap-2 text-tertiary text-sm items-center`}>
237
-
{props.quotesCount === 0 ? null : (
238
-
<div className={`flex gap-1 items-center `} aria-label="Post quotes">
239
-
<QuoteTiny aria-hidden /> {props.quotesCount}
240
-
</div>
241
-
)}
242
-
{props.showComments === false || props.commentsCount === 0 ? null : (
243
-
<div className={`flex gap-1 items-center`} aria-label="Post comments">
244
-
<CommentTiny aria-hidden /> {props.commentsCount}
245
-
</div>
246
-
)}
247
-
{interactionsAvailable && <Separator classname="h-4 !min-h-0" />}
248
-
<button
249
-
id={`copy-post-link-${props.postUrl}`}
250
-
className="flex gap-1 items-center hover:font-bold relative"
251
-
onClick={(e) => {
252
-
e.stopPropagation();
253
-
e.preventDefault();
254
-
let mouseX = e.clientX;
255
-
let mouseY = e.clientY;
256
-
257
-
if (!props.postUrl) return;
258
-
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
259
-
260
-
smoker({
261
-
text: <strong>Copied Link!</strong>,
262
-
position: {
263
-
y: mouseY,
264
-
x: mouseX,
265
-
},
266
-
});
267
-
}}
268
-
>
269
-
Share
270
-
</button>
271
-
</div>
272
-
);
273
-
};
274
88
export const ReaderEmpty = () => {
275
89
return (
276
-
<EmptyState>
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">
277
91
Nothing to read yetโฆ <br />
278
92
Subscribe to publications and find their posts here!
279
93
<Link href={"/discover"}>
···
281
95
<DiscoverSmall /> Discover Publications
282
96
</ButtonPrimary>
283
97
</Link>
284
-
</EmptyState>
98
+
</div>
285
99
);
286
100
};
+3
-4
app/(home-pages)/reader/SubscriptionsContent.tsx
+3
-4
app/(home-pages)/reader/SubscriptionsContent.tsx
···
8
8
import { useEffect, useRef } from "react";
9
9
import { Cursor } from "./getReaderFeed";
10
10
import Link from "next/link";
11
-
import { EmptyState } from "components/EmptyState";
12
11
13
12
export const SubscriptionsContent = (props: {
14
13
publications: PublicationSubscription[];
···
33
32
34
33
const { data, error, size, setSize, isValidating } = useSWRInfinite(
35
34
getKey,
36
-
([_, cursor]) => getSubscriptions(cursor),
35
+
([_, cursor]) => getSubscriptions(null, cursor),
37
36
{
38
37
fallbackData: [
39
38
{ subscriptions: props.publications, nextCursor: props.nextCursor },
···
94
93
95
94
export const SubscriptionsEmpty = () => {
96
95
return (
97
-
<EmptyState>
96
+
<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">
98
97
You haven't subscribed to any publications yet!
99
98
<Link href={"/discover"}>
100
99
<ButtonPrimary className="mx-auto place-self-center">
101
100
<DiscoverSmall /> Discover Publications
102
101
</ButtonPrimary>
103
102
</Link>
104
-
</EmptyState>
103
+
</div>
105
104
);
106
105
};
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
···
8
8
import { idResolver } from "./idResolver";
9
9
import { Cursor } from "./getReaderFeed";
10
10
11
-
export async function getSubscriptions(cursor?: Cursor | null): Promise<{
11
+
export async function getSubscriptions(
12
+
did?: string | null,
13
+
cursor?: Cursor | null,
14
+
): Promise<{
12
15
nextCursor: null | Cursor;
13
16
subscriptions: PublicationSubscription[];
14
17
}> {
15
-
let auth_res = await getIdentityData();
16
-
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
18
+
// If no DID provided, use logged-in user's DID
19
+
let identity = did;
20
+
if (!identity) {
21
+
const auth_res = await getIdentityData();
22
+
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
23
+
identity = auth_res.atp_did;
24
+
}
25
+
17
26
let query = supabaseServerClient
18
27
.from("publication_subscriptions")
19
28
.select(`*, publications(*, documents_in_publications(*, documents(*)))`)
···
25
34
})
26
35
.limit(1, { referencedTable: "publications.documents_in_publications" })
27
36
.limit(25)
28
-
.eq("identity", auth_res.atp_did);
37
+
.eq("identity", identity);
29
38
30
39
if (cursor) {
31
40
query = query.or(
-1
app/(home-pages)/reader/page.tsx
-1
app/(home-pages)/reader/page.tsx
+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
+
}
+83
app/(home-pages)/tag/[tag]/page.tsx
+83
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
+
import { Metadata } from "next";
7
+
8
+
export async function generateMetadata(props: {
9
+
params: Promise<{ tag: string }>;
10
+
}): Promise<Metadata> {
11
+
const params = await props.params;
12
+
const decodedTag = decodeURIComponent(params.tag);
13
+
return { title: `${decodedTag} - Leaflet` };
14
+
}
15
+
16
+
export default async function TagPage(props: {
17
+
params: Promise<{ tag: string }>;
18
+
}) {
19
+
const params = await props.params;
20
+
const decodedTag = decodeURIComponent(params.tag);
21
+
const { posts } = await getDocumentsByTag(decodedTag);
22
+
23
+
return (
24
+
<DashboardLayout
25
+
id="tag"
26
+
currentPage="tag"
27
+
defaultTab="default"
28
+
actions={null}
29
+
tabs={{
30
+
default: {
31
+
controls: null,
32
+
content: <TagContent tag={decodedTag} posts={posts} />,
33
+
},
34
+
}}
35
+
/>
36
+
);
37
+
}
38
+
39
+
const TagContent = (props: {
40
+
tag: string;
41
+
posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"];
42
+
}) => {
43
+
return (
44
+
<div className="max-w-prose mx-auto w-full grow shrink-0">
45
+
<div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4">
46
+
<TagHeader tag={props.tag} postCount={props.posts.length} />
47
+
</div>
48
+
<div className="pt-6 flex flex-col gap-3">
49
+
{props.posts.length === 0 ? (
50
+
<EmptyState tag={props.tag} />
51
+
) : (
52
+
props.posts.map((post) => (
53
+
<PostListing key={post.documents.uri} {...post} />
54
+
))
55
+
)}
56
+
</div>
57
+
</div>
58
+
);
59
+
};
60
+
61
+
const TagHeader = (props: { tag: string; postCount: number }) => {
62
+
return (
63
+
<div className="flex flex-col leading-tight items-center">
64
+
<div className="flex items-center gap-3 text-xl font-bold text-primary">
65
+
<TagTiny className="scale-150" />
66
+
<h1>{props.tag}</h1>
67
+
</div>
68
+
<div className="text-tertiary text-sm">
69
+
{props.postCount} {props.postCount === 1 ? "post" : "posts"}
70
+
</div>
71
+
</div>
72
+
);
73
+
};
74
+
75
+
const EmptyState = (props: { tag: string }) => {
76
+
return (
77
+
<div className="flex flex-col gap-2 items-center justify-center p-8 text-center">
78
+
<div className="text-tertiary">
79
+
No posts found with the tag "{props.tag}"
80
+
</div>
81
+
</div>
82
+
);
83
+
};
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
···
161
161
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
162
162
style={{
163
163
backgroundColor: isHovered
164
-
? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)"
164
+
? "rgb(var(--accent-light))"
165
165
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
166
}}
167
167
onMouseEnter={handleMouseEnter}
+1
-1
app/[leaflet_id]/actions/HomeButton.tsx
+1
-1
app/[leaflet_id]/actions/HomeButton.tsx
+60
-29
app/[leaflet_id]/actions/PublishButton.tsx
+60
-29
app/[leaflet_id]/actions/PublishButton.tsx
···
13
13
import { PublishSmall } from "components/Icons/PublishSmall";
14
14
import { useIdentityData } from "components/IdentityProvider";
15
15
import { InputWithLabel } from "components/Input";
16
-
import { Menu, MenuItem } from "components/Layout";
16
+
import { Menu, MenuItem } from "components/Menu";
17
17
import {
18
18
useLeafletDomains,
19
19
useLeafletPublicationData,
···
27
27
import { useState, useMemo } from "react";
28
28
import { useIsMobile } from "src/hooks/isMobile";
29
29
import { useReplicache, useEntity } from "src/replicache";
30
+
import { useSubscribe } from "src/replicache/useSubscribe";
30
31
import { Json } from "supabase/database.types";
31
32
import {
32
33
useBlocks,
···
34
35
} from "src/hooks/queries/useBlocks";
35
36
import * as Y from "yjs";
36
37
import * as base64 from "base64-js";
37
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
38
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
38
39
import { BlueskyLogin } from "app/login/LoginForm";
39
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
40
-
import { saveLeafletDraft } from "actions/publications/saveLeafletDraft";
41
41
import { AddTiny } from "components/Icons/AddTiny";
42
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
42
43
43
44
export const PublishButton = (props: { entityID: string }) => {
44
45
let { data: pub } = useLeafletPublicationData();
···
64
65
const UpdateButton = () => {
65
66
let [isLoading, setIsLoading] = useState(false);
66
67
let { data: pub, mutate } = useLeafletPublicationData();
67
-
let { permission_token, rootEntity } = useReplicache();
68
+
let { permission_token, rootEntity, rep } = useReplicache();
68
69
let { identity } = useIdentityData();
69
70
let toaster = useToaster();
70
71
72
+
// Get title and description from Replicache state (same as draft editor)
73
+
// This ensures we use the latest edited values, not stale cached data
74
+
let replicacheTitle = useSubscribe(rep, (tx) =>
75
+
tx.get<string>("publication_title"),
76
+
);
77
+
let replicacheDescription = useSubscribe(rep, (tx) =>
78
+
tx.get<string>("publication_description"),
79
+
);
80
+
81
+
// Use Replicache state if available, otherwise fall back to pub data
82
+
const currentTitle =
83
+
typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || "";
84
+
const currentDescription =
85
+
typeof replicacheDescription === "string"
86
+
? replicacheDescription
87
+
: pub?.description || "";
88
+
89
+
// Get tags from Replicache state (same as draft editor)
90
+
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
91
+
const currentTags = Array.isArray(tags) ? tags : [];
92
+
93
+
// Get cover image from Replicache state
94
+
let coverImage = useSubscribe(rep, (tx) =>
95
+
tx.get<string | null>("publication_cover_image"),
96
+
);
97
+
71
98
return (
72
99
<ActionButton
73
100
primary
···
76
103
onClick={async () => {
77
104
if (!pub) return;
78
105
setIsLoading(true);
79
-
let doc = await publishToPublication({
106
+
let result = await publishToPublication({
80
107
root_entity: rootEntity,
81
108
publication_uri: pub.publications?.uri,
82
109
leaflet_id: permission_token.id,
83
-
title: pub.title,
84
-
description: pub.description,
110
+
title: currentTitle,
111
+
description: currentDescription,
112
+
tags: currentTags,
113
+
cover_image: coverImage,
85
114
});
86
115
setIsLoading(false);
87
116
mutate();
88
117
118
+
if (!result.success) {
119
+
toaster({
120
+
content: isOAuthSessionError(result.error) ? (
121
+
<OAuthErrorMessage error={result.error} />
122
+
) : (
123
+
"Failed to publish"
124
+
),
125
+
type: "error",
126
+
});
127
+
return;
128
+
}
129
+
89
130
// Generate URL based on whether it's in a publication or standalone
90
131
let docUrl = pub.publications
91
-
? `${getPublicationURL(pub.publications)}/${doc?.rkey}`
92
-
: `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`;
132
+
? `${getPublicationURL(pub.publications)}/${result.rkey}`
133
+
: `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`;
93
134
94
135
toaster({
95
136
content: (
96
137
<div>
97
138
{pub.doc ? "Updated! " : "Published! "}
98
-
<SpeedyLink href={docUrl}>link</SpeedyLink>
139
+
<SpeedyLink className="underline" href={docUrl}>
140
+
See Published Post
141
+
</SpeedyLink>
99
142
</div>
100
143
),
101
144
type: "success",
···
109
152
let { identity } = useIdentityData();
110
153
let { permission_token } = useReplicache();
111
154
let query = useSearchParams();
112
-
console.log(query.get("publish"));
113
155
let [open, setOpen] = useState(query.get("publish") !== null);
114
156
115
157
let isMobile = useIsMobile();
···
177
219
<hr className="border-border-light mt-3 mb-2" />
178
220
179
221
<div className="flex gap-2 items-center place-self-end">
180
-
{selectedPub && selectedPub !== "create" && (
222
+
{selectedPub !== "looseleaf" && selectedPub && (
181
223
<SaveAsDraftButton
182
224
selectedPub={selectedPub}
183
225
leafletId={permission_token.id}
···
230
272
if (props.selectedPub === "create") return;
231
273
e.preventDefault();
232
274
setIsLoading(true);
233
-
234
-
// Use different actions for looseleaf vs publication
235
-
if (props.selectedPub === "looseleaf") {
236
-
await saveLeafletDraft(
237
-
props.leafletId,
238
-
props.metadata,
239
-
props.entitiesToDelete,
240
-
);
241
-
} else {
242
-
await moveLeafletToPublication(
243
-
props.leafletId,
244
-
props.selectedPub,
245
-
props.metadata,
246
-
props.entitiesToDelete,
247
-
);
248
-
}
249
-
275
+
await moveLeafletToPublication(
276
+
props.leafletId,
277
+
props.selectedPub,
278
+
props.metadata,
279
+
props.entitiesToDelete,
280
+
);
250
281
await Promise.all([rep?.pull(), mutate()]);
251
282
setIsLoading(false);
252
283
}}
+1
-1
app/[leaflet_id]/page.tsx
+1
-1
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";
+144
-294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
+144
-294
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 { 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({
···
258
290
view.destroy();
259
291
viewRef.current = null;
260
292
};
261
-
}, [handleMentionSelect]);
293
+
}, [openMentionAutocomplete]);
262
294
263
295
return (
264
296
<div className="relative w-full h-full group">
265
-
{editorState && (
266
-
<MentionAutocomplete
267
-
editorState={editorState}
268
-
view={viewRef}
269
-
onSelect={handleMentionSelect}
270
-
onMentionStateChange={(active, range, selectedMention) => {
271
-
setMentionState({ active, range, selectedMention });
272
-
}}
273
-
/>
274
-
)}
297
+
<MentionAutocomplete
298
+
open={mentionOpen}
299
+
onOpenChange={handleMentionOpenChange}
300
+
view={viewRef}
301
+
onSelect={handleMentionSelect}
302
+
coords={mentionCoords}
303
+
placeholder="Search people..."
304
+
/>
275
305
{editorState?.doc.textContent.length === 0 && (
276
306
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
277
307
Write a post to share your writing!
···
279
309
)}
280
310
<div
281
311
ref={mountRef}
282
-
className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm"
312
+
className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm"
283
313
style={{
284
314
wordWrap: "break-word",
285
315
overflowWrap: "break-word",
···
290
320
);
291
321
}
292
322
293
-
function MentionAutocomplete(props: {
294
-
editorState: EditorState;
295
-
view: React.RefObject<EditorView | null>;
296
-
onSelect: (
297
-
mention: { handle: string; did: string },
298
-
range: { from: number; to: number },
299
-
) => void;
300
-
onMentionStateChange: (
301
-
active: boolean,
302
-
range: { from: number; to: number } | null,
303
-
selectedMention: { handle: string; did: string } | null,
304
-
) => void;
305
-
}) {
306
-
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
307
-
const [mentionRange, setMentionRange] = useState<{
308
-
from: number;
309
-
to: number;
310
-
} | null>(null);
311
-
const [mentionCoords, setMentionCoords] = useState<{
312
-
top: number;
313
-
left: number;
314
-
} | null>(null);
315
-
316
-
const { suggestionIndex, setSuggestionIndex, suggestions } =
317
-
useMentionSuggestions(mentionQuery);
318
-
319
-
// Check for mention pattern whenever editor state changes
320
-
useEffect(() => {
321
-
const { $from } = props.editorState.selection;
322
-
const textBefore = $from.parent.textBetween(
323
-
Math.max(0, $from.parentOffset - 50),
324
-
$from.parentOffset,
325
-
null,
326
-
"\ufffc",
327
-
);
328
-
329
-
// Look for @ followed by word characters before cursor
330
-
const match = textBefore.match(/@([\w.]*)$/);
331
-
332
-
if (match && props.view.current) {
333
-
const queryBefore = match[1];
334
-
const from = $from.pos - queryBefore.length - 1;
335
-
336
-
// Get text after cursor to find the rest of the handle
337
-
const textAfter = $from.parent.textBetween(
338
-
$from.parentOffset,
339
-
Math.min($from.parent.content.size, $from.parentOffset + 50),
340
-
null,
341
-
"\ufffc",
342
-
);
343
-
344
-
// Match word characters after cursor until space or end
345
-
const afterMatch = textAfter.match(/^([\w.]*)/);
346
-
const queryAfter = afterMatch ? afterMatch[1] : "";
347
-
348
-
// Combine the full handle
349
-
const query = queryBefore + queryAfter;
350
-
const to = $from.pos + queryAfter.length;
351
-
352
-
setMentionQuery(query);
353
-
setMentionRange({ from, to });
354
-
355
-
// Get coordinates for the autocomplete popup
356
-
const coords = props.view.current.coordsAtPos(from);
357
-
setMentionCoords({
358
-
top: coords.bottom + window.scrollY,
359
-
left: coords.left + window.scrollX,
360
-
});
361
-
setSuggestionIndex(0);
362
-
} else {
363
-
setMentionQuery(null);
364
-
setMentionRange(null);
365
-
setMentionCoords(null);
366
-
}
367
-
}, [props.editorState, props.view, setSuggestionIndex]);
368
-
369
-
// Update parent's mention state
370
-
useEffect(() => {
371
-
const active = mentionQuery !== null && suggestions.length > 0;
372
-
const selectedMention =
373
-
active && suggestions[suggestionIndex]
374
-
? suggestions[suggestionIndex]
375
-
: null;
376
-
props.onMentionStateChange(active, mentionRange, selectedMention);
377
-
}, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
378
-
379
-
// Handle keyboard navigation for arrow keys only
380
-
useEffect(() => {
381
-
if (!mentionQuery || !props.view.current) return;
382
-
383
-
const handleKeyDown = (e: KeyboardEvent) => {
384
-
if (suggestions.length === 0) return;
385
-
386
-
if (e.key === "ArrowUp") {
387
-
e.preventDefault();
388
-
if (suggestionIndex > 0) {
389
-
setSuggestionIndex((i) => i - 1);
390
-
}
391
-
} else if (e.key === "ArrowDown") {
392
-
e.preventDefault();
393
-
if (suggestionIndex < suggestions.length - 1) {
394
-
setSuggestionIndex((i) => i + 1);
395
-
}
396
-
}
397
-
};
398
-
399
-
const dom = props.view.current.dom;
400
-
dom.addEventListener("keydown", handleKeyDown);
401
-
402
-
return () => {
403
-
dom.removeEventListener("keydown", handleKeyDown);
404
-
};
405
-
}, [
406
-
mentionQuery,
407
-
suggestions,
408
-
suggestionIndex,
409
-
props.view,
410
-
setSuggestionIndex,
411
-
]);
412
-
413
-
if (!mentionCoords || suggestions.length === 0) return null;
414
-
415
-
// The styles in this component should match the Menu styles in components/Layout.tsx
416
-
return (
417
-
<Popover.Root open>
418
-
{createPortal(
419
-
<Popover.Anchor
420
-
style={{
421
-
top: mentionCoords.top,
422
-
left: mentionCoords.left,
423
-
position: "absolute",
424
-
}}
425
-
/>,
426
-
document.body,
427
-
)}
428
-
<Popover.Portal>
429
-
<Popover.Content
430
-
side="bottom"
431
-
align="start"
432
-
sideOffset={4}
433
-
collisionPadding={20}
434
-
onOpenAutoFocus={(e) => e.preventDefault()}
435
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`}
436
-
>
437
-
<ul className="list-none p-0 text-sm">
438
-
{suggestions.map((result, index) => {
439
-
return (
440
-
<div
441
-
className={`
442
-
MenuItem
443
-
font-bold z-10 py-1 px-3
444
-
text-left text-secondary
445
-
flex gap-2
446
-
${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""}
447
-
hover:bg-border-light hover:text-secondary
448
-
outline-none
449
-
`}
450
-
key={result.did}
451
-
onClick={() => {
452
-
if (mentionRange) {
453
-
props.onSelect(result, mentionRange);
454
-
setMentionQuery(null);
455
-
setMentionRange(null);
456
-
setMentionCoords(null);
457
-
}
458
-
}}
459
-
onMouseDown={(e) => e.preventDefault()}
460
-
>
461
-
@{result.handle}
462
-
</div>
463
-
);
464
-
})}
465
-
</ul>
466
-
</Popover.Content>
467
-
</Popover.Portal>
468
-
</Popover.Root>
469
-
);
470
-
}
471
-
472
-
function useMentionSuggestions(query: string | null) {
473
-
const [suggestionIndex, setSuggestionIndex] = useState(0);
474
-
const [suggestions, setSuggestions] = useState<
475
-
{ handle: string; did: string }[]
476
-
>([]);
477
-
478
-
useDebouncedEffect(
479
-
async () => {
480
-
if (!query) {
481
-
setSuggestions([]);
482
-
return;
483
-
}
484
-
485
-
const agent = new Agent("https://public.api.bsky.app");
486
-
const result = await agent.searchActorsTypeahead({
487
-
q: query,
488
-
limit: 8,
489
-
});
490
-
setSuggestions(
491
-
result.data.actors.map((actor) => ({
492
-
handle: actor.handle,
493
-
did: actor.did,
494
-
})),
495
-
);
496
-
},
497
-
300,
498
-
[query],
499
-
);
500
-
501
-
useEffect(() => {
502
-
if (suggestionIndex > suggestions.length - 1) {
503
-
setSuggestionIndex(Math.max(0, suggestions.length - 1));
504
-
}
505
-
}, [suggestionIndex, suggestions.length]);
506
-
507
-
return {
508
-
suggestions,
509
-
suggestionIndex,
510
-
setSuggestionIndex,
511
-
};
512
-
}
513
-
514
323
/**
515
324
* Converts a ProseMirror editor state to Bluesky post facets.
516
325
* Extracts mentions, links, and hashtags from the editor state and returns them
···
595
404
596
405
return features;
597
406
}
407
+
408
+
export const addMentionToEditor = (
409
+
mention: Mention,
410
+
range: { from: number; to: number },
411
+
view: EditorView,
412
+
) => {
413
+
console.log("view", view);
414
+
if (!view) return;
415
+
const { from, to } = range;
416
+
const tr = view.state.tr;
417
+
418
+
if (mention.type == "did") {
419
+
// Delete the @ and any query text
420
+
tr.delete(from, to);
421
+
// Insert didMention inline node
422
+
const mentionText = "@" + mention.handle;
423
+
const didMentionNode = schema.nodes.didMention.create({
424
+
did: mention.did,
425
+
text: mentionText,
426
+
});
427
+
tr.insert(from, didMentionNode);
428
+
}
429
+
if (mention.type === "publication" || mention.type === "post") {
430
+
// Delete the @ and any query text
431
+
tr.delete(from, to);
432
+
let name = mention.type == "post" ? mention.title : mention.name;
433
+
// Insert atMention inline node
434
+
const atMentionNode = schema.nodes.atMention.create({
435
+
atURI: mention.uri,
436
+
text: name,
437
+
});
438
+
tr.insert(from, atMentionNode);
439
+
}
440
+
console.log("yo", mention);
441
+
442
+
// Add a space after the mention
443
+
tr.insertText(" ", from + 1);
444
+
445
+
view.dispatch(tr);
446
+
view.focus();
447
+
};
+193
-101
app/[leaflet_id]/publish/PublishPost.tsx
+193
-101
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";
21
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
22
24
import { PubIcon } from "components/ActionBar/Publications";
25
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
23
26
24
27
type Props = {
25
28
title: string;
···
31
34
record?: PubLeafletPublication.Record;
32
35
posts_in_pub?: number;
33
36
entitiesToDelete?: string[];
37
+
hasDraft: boolean;
34
38
};
35
39
36
40
export function PublishPost(props: Props) {
···
38
42
{ state: "default" } | { state: "success"; post_url: string }
39
43
>({ state: "default" });
40
44
return (
41
-
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center">
45
+
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary">
42
46
{publishState.state === "default" ? (
43
47
<PublishPostForm setPublishState={setPublishState} {...props} />
44
48
) : (
···
58
62
setPublishState: (s: { state: "success"; post_url: string }) => void;
59
63
} & Props,
60
64
) => {
65
+
let editorStateRef = useRef<EditorState | null>(null);
66
+
let [charCount, setCharCount] = useState(0);
61
67
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
62
-
let editorStateRef = useRef<EditorState | null>(null);
63
68
let [isLoading, setIsLoading] = useState(false);
64
-
let [charCount, setCharCount] = useState(0);
69
+
let [oauthError, setOauthError] = useState<
70
+
import("src/atproto-oauth").OAuthSessionError | null
71
+
>(null);
65
72
let params = useParams();
66
73
let { rep } = useReplicache();
67
74
75
+
// For publications with drafts, use Replicache; otherwise use local state
76
+
let replicacheTags = useSubscribe(rep, (tx) =>
77
+
tx.get<string[]>("publication_tags"),
78
+
);
79
+
let [localTags, setLocalTags] = useState<string[]>([]);
80
+
81
+
// Get cover image from Replicache
82
+
let replicacheCoverImage = useSubscribe(rep, (tx) =>
83
+
tx.get<string | null>("publication_cover_image"),
84
+
);
85
+
86
+
// Use Replicache tags only when we have a draft
87
+
const hasDraft = props.hasDraft;
88
+
const currentTags = hasDraft
89
+
? Array.isArray(replicacheTags)
90
+
? replicacheTags
91
+
: []
92
+
: localTags;
93
+
94
+
// Update tags via Replicache mutation or local state depending on context
95
+
const handleTagsChange = async (newTags: string[]) => {
96
+
if (hasDraft) {
97
+
await rep?.mutate.updatePublicationDraft({
98
+
tags: newTags,
99
+
});
100
+
} else {
101
+
setLocalTags(newTags);
102
+
}
103
+
};
104
+
68
105
async function submit() {
69
106
if (isLoading) return;
70
107
setIsLoading(true);
108
+
setOauthError(null);
71
109
await rep?.push();
72
-
let doc = await publishToPublication({
110
+
let result = await publishToPublication({
73
111
root_entity: props.root_entity,
74
112
publication_uri: props.publication_uri,
75
113
leaflet_id: props.leaflet_id,
76
114
title: props.title,
77
115
description: props.description,
116
+
tags: currentTags,
117
+
cover_image: replicacheCoverImage,
78
118
entitiesToDelete: props.entitiesToDelete,
79
119
});
80
-
if (!doc) return;
120
+
121
+
if (!result.success) {
122
+
setIsLoading(false);
123
+
if (isOAuthSessionError(result.error)) {
124
+
setOauthError(result.error);
125
+
}
126
+
return;
127
+
}
81
128
82
129
// Generate post URL based on whether it's in a publication or standalone
83
130
let post_url = props.record?.base_path
84
-
? `https://${props.record.base_path}/${doc.rkey}`
85
-
: `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`;
131
+
? `https://${props.record.base_path}/${result.rkey}`
132
+
: `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`;
86
133
87
134
let [text, facets] = editorStateRef.current
88
135
? editorStateToFacetedText(editorStateRef.current)
89
136
: [];
90
-
if (shareOption === "bluesky")
91
-
await publishPostToBsky({
137
+
if (shareOption === "bluesky") {
138
+
let bskyResult = await publishPostToBsky({
92
139
facets: facets || [],
93
140
text: text || "",
94
141
title: props.title,
95
142
url: post_url,
96
143
description: props.description,
97
-
document_record: doc.record,
98
-
rkey: doc.rkey,
144
+
document_record: result.record,
145
+
rkey: result.rkey,
99
146
});
147
+
if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) {
148
+
setIsLoading(false);
149
+
setOauthError(bskyResult.error);
150
+
return;
151
+
}
152
+
}
100
153
setIsLoading(false);
101
154
props.setPublishState({ state: "success", post_url });
102
155
}
···
109
162
submit();
110
163
}}
111
164
>
112
-
<div className="container flex flex-col gap-2 sm:p-3 p-4">
165
+
<div className="container flex flex-col gap-3 sm:p-3 p-4">
113
166
<PublishingTo
114
167
publication_uri={props.publication_uri}
115
168
record={props.record}
116
169
/>
117
-
<hr className="border-border-light my-1" />
118
-
<Radio
119
-
checked={shareOption === "quiet"}
120
-
onChange={(e) => {
121
-
if (e.target === e.currentTarget) {
122
-
setShareOption("quiet");
123
-
}
124
-
}}
125
-
name="share-options"
126
-
id="share-quietly"
127
-
value="Share Quietly"
128
-
>
129
-
<div className="flex flex-col">
130
-
<div className="font-bold">Share Quietly</div>
131
-
<div className="text-sm text-tertiary font-normal">
132
-
No one will be notified about this post
133
-
</div>
134
-
</div>
135
-
</Radio>
136
-
<Radio
137
-
checked={shareOption === "bluesky"}
138
-
onChange={(e) => {
139
-
if (e.target === e.currentTarget) {
140
-
setShareOption("bluesky");
141
-
}
142
-
}}
143
-
name="share-options"
144
-
id="share-bsky"
145
-
value="Share on Bluesky"
146
-
>
147
-
<div className="flex flex-col">
148
-
<div className="font-bold">Share on Bluesky</div>
149
-
<div className="text-sm text-tertiary font-normal">
150
-
Pub subscribers will be updated via a custom Bluesky feed
151
-
</div>
170
+
<hr className="border-border" />
171
+
<ShareOptions
172
+
setShareOption={setShareOption}
173
+
shareOption={shareOption}
174
+
charCount={charCount}
175
+
setCharCount={setCharCount}
176
+
editorStateRef={editorStateRef}
177
+
{...props}
178
+
/>
179
+
<hr className="border-border " />
180
+
<div className="flex flex-col gap-2">
181
+
<h4>Tags</h4>
182
+
<TagSelector
183
+
selectedTags={currentTags}
184
+
setSelectedTags={handleTagsChange}
185
+
/>
186
+
</div>
187
+
<hr className="border-border mb-2" />
188
+
189
+
<div className="flex flex-col gap-2">
190
+
<div className="flex justify-between">
191
+
<Link
192
+
className="hover:no-underline! font-bold"
193
+
href={`/${params.leaflet_id}`}
194
+
>
195
+
Back
196
+
</Link>
197
+
<ButtonPrimary
198
+
type="submit"
199
+
className="place-self-end h-[30px]"
200
+
disabled={charCount > 300}
201
+
>
202
+
{isLoading ? <DotLoader /> : "Publish this Post!"}
203
+
</ButtonPrimary>
152
204
</div>
153
-
</Radio>
205
+
{oauthError && (
206
+
<OAuthErrorMessage
207
+
error={oauthError}
208
+
className="text-right text-sm text-accent-contrast"
209
+
/>
210
+
)}
211
+
</div>
212
+
</div>
213
+
</form>
214
+
</div>
215
+
);
216
+
};
154
217
155
-
<div
156
-
className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`}
157
-
>
158
-
<div className="opaque-container p-3 rounded-lg!">
159
-
<div className="flex gap-2">
160
-
<img
161
-
className="rounded-full w-[42px] h-[42px] shrink-0"
162
-
src={props.profile.avatar}
218
+
const ShareOptions = (props: {
219
+
shareOption: "quiet" | "bluesky";
220
+
setShareOption: (option: typeof props.shareOption) => void;
221
+
charCount: number;
222
+
setCharCount: (c: number) => void;
223
+
editorStateRef: React.MutableRefObject<EditorState | null>;
224
+
title: string;
225
+
profile: ProfileViewDetailed;
226
+
description: string;
227
+
record?: PubLeafletPublication.Record;
228
+
}) => {
229
+
return (
230
+
<div className="flex flex-col gap-2">
231
+
<h4>Notifications</h4>
232
+
<Radio
233
+
checked={props.shareOption === "quiet"}
234
+
onChange={(e) => {
235
+
if (e.target === e.currentTarget) {
236
+
props.setShareOption("quiet");
237
+
}
238
+
}}
239
+
name="share-options"
240
+
id="share-quietly"
241
+
value="Share Quietly"
242
+
>
243
+
<div className="flex flex-col">
244
+
<div className="font-bold">Share Quietly</div>
245
+
<div className="text-sm text-tertiary font-normal">
246
+
No one will be notified about this post
247
+
</div>
248
+
</div>
249
+
</Radio>
250
+
<Radio
251
+
checked={props.shareOption === "bluesky"}
252
+
onChange={(e) => {
253
+
if (e.target === e.currentTarget) {
254
+
props.setShareOption("bluesky");
255
+
}
256
+
}}
257
+
name="share-options"
258
+
id="share-bsky"
259
+
value="Share on Bluesky"
260
+
>
261
+
<div className="flex flex-col">
262
+
<div className="font-bold">Share on Bluesky</div>
263
+
<div className="text-sm text-tertiary font-normal">
264
+
Pub subscribers will be updated via a custom Bluesky feed
265
+
</div>
266
+
</div>
267
+
</Radio>
268
+
<div
269
+
className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`}
270
+
>
271
+
<div className="opaque-container py-2 px-3 text-sm rounded-lg!">
272
+
<div className="flex gap-2">
273
+
<img
274
+
className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0"
275
+
src={props.profile.avatar}
276
+
/>
277
+
<div className="flex flex-col w-full">
278
+
<div className="flex gap-2 ">
279
+
<p className="font-bold">{props.profile.displayName}</p>
280
+
<p className="text-tertiary">@{props.profile.handle}</p>
281
+
</div>
282
+
<div className="flex flex-col">
283
+
<BlueskyPostEditorProsemirror
284
+
editorStateRef={props.editorStateRef}
285
+
onCharCountChange={props.setCharCount}
163
286
/>
164
-
<div className="flex flex-col w-full">
165
-
<div className="flex gap-2 pb-1">
166
-
<p className="font-bold">{props.profile.displayName}</p>
167
-
<p className="text-tertiary">@{props.profile.handle}</p>
168
-
</div>
169
-
<div className="flex flex-col">
170
-
<BlueskyPostEditorProsemirror
171
-
editorStateRef={editorStateRef}
172
-
onCharCountChange={setCharCount}
173
-
/>
174
-
</div>
175
-
<div className="opaque-container overflow-hidden flex flex-col mt-4 w-full">
176
-
<div className="flex flex-col p-2">
177
-
<div className="font-bold">{props.title}</div>
178
-
<div className="text-tertiary">{props.description}</div>
179
-
{props.record && (
180
-
<>
181
-
<hr className="border-border-light mt-2 mb-1" />
182
-
<p className="text-xs text-tertiary">
183
-
{props.record?.base_path}
184
-
</p>
185
-
</>
186
-
)}
187
-
</div>
188
-
</div>
189
-
<div className="text-xs text-secondary italic place-self-end pt-2">
190
-
{charCount}/300
191
-
</div>
287
+
</div>
288
+
<div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full">
289
+
<div className="flex flex-col p-2">
290
+
<div className="font-bold">{props.title}</div>
291
+
<div className="text-tertiary">{props.description}</div>
292
+
<hr className="border-border mt-2 mb-1" />
293
+
<p className="text-xs text-tertiary">
294
+
{props.record?.base_path}
295
+
</p>
192
296
</div>
193
297
</div>
298
+
<div className="text-xs text-secondary italic place-self-end pt-2">
299
+
{props.charCount}/300
300
+
</div>
194
301
</div>
195
302
</div>
196
-
<div className="flex justify-between">
197
-
<Link
198
-
className="hover:no-underline! font-bold"
199
-
href={`/${params.leaflet_id}`}
200
-
>
201
-
Back
202
-
</Link>
203
-
<ButtonPrimary
204
-
type="submit"
205
-
className="place-self-end h-[30px]"
206
-
disabled={charCount > 300}
207
-
>
208
-
{isLoading ? <DotLoader /> : "Publish this Post!"}
209
-
</ButtonPrimary>
210
-
</div>
211
303
</div>
212
-
</form>
304
+
</div>
213
305
</div>
214
306
);
215
307
};
+8
-2
app/[leaflet_id]/publish/page.tsx
+8
-2
app/[leaflet_id]/publish/page.tsx
···
76
76
// Get title and description from either source
77
77
let title =
78
78
data.leaflets_in_publications[0]?.title ||
79
-
data.leaflets_to_documents?.title ||
79
+
data.leaflets_to_documents[0]?.title ||
80
80
decodeURIComponent((await props.searchParams).title || "");
81
81
let description =
82
82
data.leaflets_in_publications[0]?.description ||
83
-
data.leaflets_to_documents?.description ||
83
+
data.leaflets_to_documents[0]?.description ||
84
84
decodeURIComponent((await props.searchParams).description || "");
85
85
86
86
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
···
99
99
// If parsing fails, just use empty array
100
100
}
101
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
+
102
107
return (
103
108
<ReplicacheProvider
104
109
rootEntity={rootEntity}
···
116
121
record={publication?.record as PubLeafletPublication.Record | undefined}
117
122
posts_in_pub={publication?.documents_in_publications[0]?.count}
118
123
entitiesToDelete={entitiesToDelete}
124
+
hasDraft={hasDraft}
119
125
/>
120
126
</ReplicacheProvider>
121
127
);
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
···
9
9
import { TID } from "@atproto/common";
10
10
import { getIdentityData } from "actions/getIdentityData";
11
11
import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
12
-
import { createOauthClient } from "src/atproto-oauth";
12
+
import {
13
+
restoreOAuthSession,
14
+
OAuthSessionError,
15
+
} from "src/atproto-oauth";
13
16
import { supabaseServerClient } from "supabase/serverClient";
14
17
import { Json } from "supabase/database.types";
15
18
import {
16
19
getMicroLinkOgImage,
17
20
getWebpageImage,
18
21
} from "src/utils/getMicroLinkOgImage";
22
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
23
+
24
+
type PublishBskyResult =
25
+
| { success: true }
26
+
| { success: false; error: OAuthSessionError };
19
27
20
28
export async function publishPostToBsky(args: {
21
29
text: string;
···
25
33
document_record: PubLeafletDocument.Record;
26
34
rkey: string;
27
35
facets: AppBskyRichtextFacet.Main[];
28
-
}) {
29
-
const oauthClient = await createOauthClient();
36
+
}): Promise<PublishBskyResult> {
30
37
let identity = await getIdentityData();
31
-
if (!identity || !identity.atp_did) return null;
38
+
if (!identity || !identity.atp_did) {
39
+
return {
40
+
success: false,
41
+
error: {
42
+
type: "oauth_session_expired",
43
+
message: "Not authenticated",
44
+
did: "",
45
+
},
46
+
};
47
+
}
32
48
33
-
let credentialSession = await oauthClient.restore(identity.atp_did);
49
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
50
+
if (!sessionResult.ok) {
51
+
return { success: false, error: sessionResult.error };
52
+
}
53
+
let credentialSession = sessionResult.value;
34
54
let agent = new AtpBaseClient(
35
55
credentialSession.fetchHandler.bind(credentialSession),
36
56
);
37
-
let newPostUrl = args.url;
38
-
let preview_image = await getWebpageImage(newPostUrl, {
39
-
width: 1400,
40
-
height: 733,
41
-
noCache: true,
42
-
});
43
57
44
-
let binary = await preview_image.blob();
45
-
let resized_preview_image = await sharp(await binary.arrayBuffer())
58
+
// Get image binary - prefer cover image, fall back to screenshot
59
+
let imageBinary: Blob | null = null;
60
+
61
+
if (args.document_record.coverImage) {
62
+
let cid =
63
+
(args.document_record.coverImage.ref as unknown as { $link: string })[
64
+
"$link"
65
+
] || args.document_record.coverImage.ref.toString();
66
+
67
+
let coverImageResponse = await fetchAtprotoBlob(identity.atp_did, cid);
68
+
if (coverImageResponse) {
69
+
imageBinary = await coverImageResponse.blob();
70
+
}
71
+
}
72
+
73
+
// Fall back to screenshot if no cover image or fetch failed
74
+
if (!imageBinary) {
75
+
let preview_image = await getWebpageImage(args.url, {
76
+
width: 1400,
77
+
height: 733,
78
+
noCache: true,
79
+
});
80
+
imageBinary = await preview_image.blob();
81
+
}
82
+
83
+
// Resize and upload
84
+
let resizedImage = await sharp(await imageBinary.arrayBuffer())
46
85
.resize({
47
86
width: 1200,
87
+
height: 630,
48
88
fit: "cover",
49
89
})
50
90
.webp({ quality: 85 })
51
91
.toBuffer();
52
92
53
-
let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, {
54
-
headers: { "Content-Type": binary.type },
93
+
let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, {
94
+
headers: { "Content-Type": "image/webp" },
55
95
});
56
96
let bsky = new BskyAgent(credentialSession);
57
97
let post = await bsky.app.bsky.feed.post.create(
···
90
130
data: record as Json,
91
131
})
92
132
.eq("uri", result.uri);
93
-
return true;
133
+
return { success: true };
94
134
}
+29
-11
app/api/atproto_images/route.ts
+29
-11
app/api/atproto_images/route.ts
···
1
1
import { IdResolver } from "@atproto/identity";
2
2
import { NextRequest, NextResponse } from "next/server";
3
+
3
4
let idResolver = new IdResolver();
4
5
5
-
export async function GET(req: NextRequest) {
6
-
const url = new URL(req.url);
7
-
const params = {
8
-
did: url.searchParams.get("did") ?? "",
9
-
cid: url.searchParams.get("cid") ?? "",
10
-
};
11
-
if (!params.did || !params.cid)
12
-
return new NextResponse(null, { status: 404 });
6
+
/**
7
+
* Fetches a blob from an AT Protocol PDS given a DID and CID
8
+
* Returns the Response object or null if the blob couldn't be fetched
9
+
*/
10
+
export async function fetchAtprotoBlob(
11
+
did: string,
12
+
cid: string,
13
+
): Promise<Response | null> {
14
+
if (!did || !cid) return null;
13
15
14
-
let identity = await idResolver.did.resolve(params.did);
16
+
let identity = await idResolver.did.resolve(did);
15
17
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
16
-
if (!service) return new NextResponse(null, { status: 404 });
18
+
if (!service) return null;
19
+
17
20
const response = await fetch(
18
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`,
21
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
19
22
{
20
23
headers: {
21
24
"Accept-Encoding": "gzip, deflate, br, zstd",
22
25
},
23
26
},
24
27
);
28
+
29
+
if (!response.ok) return null;
30
+
31
+
return response;
32
+
}
33
+
34
+
export async function GET(req: NextRequest) {
35
+
const url = new URL(req.url);
36
+
const params = {
37
+
did: url.searchParams.get("did") ?? "",
38
+
cid: url.searchParams.get("cid") ?? "",
39
+
};
40
+
41
+
const response = await fetchAtprotoBlob(params.did, params.cid);
42
+
if (!response) return new NextResponse(null, { status: 404 });
25
43
26
44
// Clone the response to modify headers
27
45
const cachedResponse = new Response(response.body, response);
+41
app/api/bsky/agent.ts
+41
app/api/bsky/agent.ts
···
1
+
import { Agent } from "@atproto/api";
2
+
import { cookies } from "next/headers";
3
+
import { createOauthClient } from "src/atproto-oauth";
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
+
6
+
export async function getAuthenticatedAgent(): Promise<Agent | null> {
7
+
try {
8
+
const cookieStore = await cookies();
9
+
const authToken =
10
+
cookieStore.get("auth_token")?.value ||
11
+
cookieStore.get("external_auth_token")?.value;
12
+
13
+
if (!authToken || authToken === "null") return null;
14
+
15
+
const { data } = await supabaseServerClient
16
+
.from("email_auth_tokens")
17
+
.select("identities(atp_did)")
18
+
.eq("id", authToken)
19
+
.eq("confirmed", true)
20
+
.single();
21
+
22
+
const did = data?.identities?.atp_did;
23
+
if (!did) return null;
24
+
25
+
const oauthClient = await createOauthClient();
26
+
const session = await oauthClient.restore(did);
27
+
return new Agent(session);
28
+
} catch (error) {
29
+
console.error("Failed to get authenticated agent:", error);
30
+
return null;
31
+
}
32
+
}
33
+
34
+
export async function getAgent(): Promise<Agent> {
35
+
const agent = await getAuthenticatedAgent();
36
+
if (agent) return agent;
37
+
38
+
return new Agent({
39
+
service: "https://public.api.bsky.app",
40
+
});
41
+
}
+41
app/api/bsky/quotes/route.ts
+41
app/api/bsky/quotes/route.ts
···
1
+
import { lexToJson } from "@atproto/api";
2
+
import { NextRequest } from "next/server";
3
+
import { getAgent } from "../agent";
4
+
5
+
export const runtime = "nodejs";
6
+
7
+
export async function GET(req: NextRequest) {
8
+
try {
9
+
const searchParams = req.nextUrl.searchParams;
10
+
const uri = searchParams.get("uri");
11
+
const cursor = searchParams.get("cursor");
12
+
const limit = searchParams.get("limit");
13
+
14
+
if (!uri) {
15
+
return Response.json(
16
+
{ error: "uri parameter is required" },
17
+
{ status: 400 },
18
+
);
19
+
}
20
+
21
+
const agent = await getAgent();
22
+
23
+
const response = await agent.app.bsky.feed.getQuotes({
24
+
uri,
25
+
limit: limit ? parseInt(limit, 10) : 50,
26
+
cursor: cursor || undefined,
27
+
});
28
+
29
+
const result = lexToJson(response.data);
30
+
31
+
return Response.json(result, {
32
+
headers: {
33
+
// Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating
34
+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600",
35
+
},
36
+
});
37
+
} catch (error) {
38
+
console.error("Error fetching Bluesky quotes:", error);
39
+
return Response.json({ error: "Failed to fetch quotes" }, { status: 500 });
40
+
}
41
+
}
+41
app/api/bsky/thread/route.ts
+41
app/api/bsky/thread/route.ts
···
1
+
import { lexToJson } from "@atproto/api";
2
+
import { NextRequest } from "next/server";
3
+
import { getAgent } from "../agent";
4
+
5
+
export const runtime = "nodejs";
6
+
7
+
export async function GET(req: NextRequest) {
8
+
try {
9
+
const searchParams = req.nextUrl.searchParams;
10
+
const uri = searchParams.get("uri");
11
+
const depth = searchParams.get("depth");
12
+
const parentHeight = searchParams.get("parentHeight");
13
+
14
+
if (!uri) {
15
+
return Response.json(
16
+
{ error: "uri parameter is required" },
17
+
{ status: 400 },
18
+
);
19
+
}
20
+
21
+
const agent = await getAgent();
22
+
23
+
const response = await agent.getPostThread({
24
+
uri,
25
+
depth: depth ? parseInt(depth, 10) : 6,
26
+
parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80,
27
+
});
28
+
29
+
const thread = lexToJson(response.data.thread);
30
+
31
+
return Response.json(thread, {
32
+
headers: {
33
+
// Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating
34
+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600",
35
+
},
36
+
});
37
+
} catch (error) {
38
+
console.error("Error fetching Bluesky thread:", error);
39
+
return Response.json({ error: "Failed to fetch thread" }, { status: 500 });
40
+
}
41
+
}
+5
-7
app/api/inngest/functions/index_follows.ts
+5
-7
app/api/inngest/functions/index_follows.ts
···
1
1
import { supabaseServerClient } from "supabase/serverClient";
2
2
import { AtpAgent, AtUri } from "@atproto/api";
3
-
import { createIdentity } from "actions/createIdentity";
4
-
import { drizzle } from "drizzle-orm/node-postgres";
5
3
import { inngest } from "../client";
6
-
import { pool } from "supabase/pool";
7
4
8
5
export const index_follows = inngest.createFunction(
9
6
{
···
58
55
.eq("atp_did", event.data.did)
59
56
.single();
60
57
if (!exists) {
61
-
const client = await pool.connect();
62
-
let db = drizzle(client);
63
-
let identity = await createIdentity(db, { atp_did: event.data.did });
64
-
client.release();
58
+
const { data: identity } = await supabaseServerClient
59
+
.from("identities")
60
+
.insert({ atp_did: event.data.did })
61
+
.select()
62
+
.single();
65
63
return identity;
66
64
}
67
65
}),
+1
-1
app/api/link_previews/route.ts
+1
-1
app/api/link_previews/route.ts
+8
-9
app/api/oauth/[route]/route.ts
+8
-9
app/api/oauth/[route]/route.ts
···
1
-
import { createIdentity } from "actions/createIdentity";
2
1
import { subscribeToPublication } from "app/lish/subscribeToPublication";
3
-
import { drizzle } from "drizzle-orm/node-postgres";
4
2
import { cookies } from "next/headers";
5
3
import { redirect } from "next/navigation";
6
4
import { NextRequest, NextResponse } from "next/server";
···
13
11
ActionAfterSignIn,
14
12
parseActionFromSearchParam,
15
13
} from "./afterSignInActions";
16
-
import { pool } from "supabase/pool";
17
14
18
15
type OauthRequestClientState = {
19
16
redirect: string | null;
···
80
77
81
78
return handleAction(s.action, redirectPath);
82
79
}
83
-
const client = await pool.connect();
84
-
const db = drizzle(client);
85
-
identity = await createIdentity(db, { atp_did: session.did });
86
-
client.release();
80
+
const { data } = await supabaseServerClient
81
+
.from("identities")
82
+
.insert({ atp_did: session.did })
83
+
.select()
84
+
.single();
85
+
identity = data;
87
86
}
88
87
let { data: token } = await supabaseServerClient
89
88
.from("email_auth_tokens")
90
89
.insert({
91
-
identity: identity.id,
90
+
identity: identity!.id,
92
91
confirmed: true,
93
92
confirmation_code: "",
94
93
})
···
121
120
else url = new URL(decodeURIComponent(redirectPath), "https://example.com");
122
121
if (action?.action === "subscribe") {
123
122
let result = await subscribeToPublication(action.publication);
124
-
if (result.hasFeed === false)
123
+
if (result.success && result.hasFeed === false)
125
124
url.searchParams.set("showSubscribeSuccess", "true");
126
125
}
127
126
+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
+
}
+1
-1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
+1
-1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
···
5
5
import type { Env } from "./route";
6
6
import { scanIndexLocal } from "src/replicache/utils";
7
7
import * as base64 from "base64-js";
8
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
8
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
9
9
import { applyUpdate, Doc } from "yjs";
10
10
11
11
export const getFactsFromHomeLeaflets = makeRoute({
+69
app/api/rpc/[command]/get_profile_data.ts
+69
app/api/rpc/[command]/get_profile_data.ts
···
1
+
import { z } from "zod";
2
+
import { makeRoute } from "../lib";
3
+
import type { Env } from "./route";
4
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import { Agent } from "@atproto/api";
7
+
import { getIdentityData } from "actions/getIdentityData";
8
+
import { createOauthClient } from "src/atproto-oauth";
9
+
10
+
export type GetProfileDataReturnType = Awaited<
11
+
ReturnType<(typeof get_profile_data)["handler"]>
12
+
>;
13
+
14
+
export const get_profile_data = makeRoute({
15
+
route: "get_profile_data",
16
+
input: z.object({
17
+
didOrHandle: z.string(),
18
+
}),
19
+
handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => {
20
+
// Resolve handle to DID if necessary
21
+
let did = didOrHandle;
22
+
23
+
if (!didOrHandle.startsWith("did:")) {
24
+
const resolved = await idResolver.handle.resolve(didOrHandle);
25
+
if (!resolved) {
26
+
throw new Error("Could not resolve handle to DID");
27
+
}
28
+
did = resolved;
29
+
}
30
+
let agent;
31
+
let authed_identity = await getIdentityData();
32
+
if (authed_identity?.atp_did) {
33
+
try {
34
+
const oauthClient = await createOauthClient();
35
+
let credentialSession = await oauthClient.restore(
36
+
authed_identity.atp_did,
37
+
);
38
+
agent = new Agent(credentialSession);
39
+
} catch (e) {
40
+
agent = new Agent({
41
+
service: "https://public.api.bsky.app",
42
+
});
43
+
}
44
+
} else {
45
+
agent = new Agent({
46
+
service: "https://public.api.bsky.app",
47
+
});
48
+
}
49
+
50
+
let profileReq = agent.app.bsky.actor.getProfile({ actor: did });
51
+
52
+
let publicationsReq = supabase
53
+
.from("publications")
54
+
.select("*")
55
+
.eq("identity_did", did);
56
+
57
+
let [{ data: profile }, { data: publications }] = await Promise.all([
58
+
profileReq,
59
+
publicationsReq,
60
+
]);
61
+
62
+
return {
63
+
result: {
64
+
profile,
65
+
publications: publications || [],
66
+
},
67
+
};
68
+
},
69
+
});
+12
app/api/rpc/[command]/pull.ts
+12
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[];
77
+
cover_image: string | null;
76
78
}[];
77
79
let pub_patch = publication_data?.[0]
78
80
? [
···
85
87
op: "put",
86
88
key: "publication_title",
87
89
value: publication_data[0].title,
90
+
},
91
+
{
92
+
op: "put",
93
+
key: "publication_tags",
94
+
value: publication_data[0].tags || [],
95
+
},
96
+
{
97
+
op: "put",
98
+
key: "publication_cover_image",
99
+
value: publication_data[0].cover_image || null,
88
100
},
89
101
]
90
102
: [];
+6
app/api/rpc/[command]/route.ts
+6
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";
16
+
import { get_profile_data } from "./get_profile_data";
14
17
15
18
let supabase = createClient<Database>(
16
19
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
35
38
get_leaflet_subdomain_status,
36
39
get_leaflet_data,
37
40
get_publication_data,
41
+
search_publication_names,
42
+
search_publication_documents,
43
+
get_profile_data,
38
44
];
39
45
export async function POST(
40
46
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
+
});
+215
app/api/unstable_validate/route.ts
+215
app/api/unstable_validate/route.ts
···
1
+
import { NextRequest, NextResponse } from "next/server";
2
+
import { AtpAgent, AtUri } from "@atproto/api";
3
+
import { DidResolver } from "@atproto/identity";
4
+
import {
5
+
PubLeafletDocument,
6
+
PubLeafletPublication,
7
+
PubLeafletPagesLinearDocument,
8
+
PubLeafletPagesCanvas,
9
+
} from "lexicons/api";
10
+
11
+
const didResolver = new DidResolver({});
12
+
13
+
export async function GET(request: NextRequest) {
14
+
try {
15
+
const atUriString = request.nextUrl.searchParams.get("uri");
16
+
17
+
if (!atUriString) {
18
+
return NextResponse.json(
19
+
{
20
+
success: false,
21
+
error: "Missing uri parameter",
22
+
},
23
+
{ status: 400 },
24
+
);
25
+
}
26
+
27
+
const uri = new AtUri(atUriString);
28
+
29
+
// Only allow document and publication collections
30
+
if (
31
+
uri.collection !== "pub.leaflet.document" &&
32
+
uri.collection !== "pub.leaflet.publication"
33
+
) {
34
+
return NextResponse.json(
35
+
{
36
+
success: false,
37
+
error:
38
+
"Unsupported collection type. Must be pub.leaflet.document or pub.leaflet.publication",
39
+
},
40
+
{ status: 400 },
41
+
);
42
+
}
43
+
44
+
// Resolve DID to get service endpoint
45
+
const did = await didResolver.resolve(uri.host);
46
+
const service = did?.service?.[0];
47
+
48
+
if (!service) {
49
+
return NextResponse.json(
50
+
{
51
+
success: false,
52
+
error: "Could not resolve DID service endpoint",
53
+
},
54
+
{ status: 404 },
55
+
);
56
+
}
57
+
58
+
// Fetch the record from AT Protocol
59
+
const agent = new AtpAgent({ service: service.serviceEndpoint as string });
60
+
61
+
let recordResponse;
62
+
try {
63
+
recordResponse = await agent.com.atproto.repo.getRecord({
64
+
repo: uri.host,
65
+
collection: uri.collection,
66
+
rkey: uri.rkey,
67
+
});
68
+
} catch (e) {
69
+
return NextResponse.json(
70
+
{
71
+
success: false,
72
+
error: "Record not found",
73
+
},
74
+
{ status: 404 },
75
+
);
76
+
}
77
+
78
+
// Validate based on collection type
79
+
if (uri.collection === "pub.leaflet.document") {
80
+
const result = PubLeafletDocument.validateRecord(
81
+
recordResponse.data.value,
82
+
);
83
+
if (result.success) {
84
+
return NextResponse.json({
85
+
success: true,
86
+
collection: uri.collection,
87
+
record: result.value,
88
+
});
89
+
} else {
90
+
// Detailed validation: validate pages and blocks individually
91
+
const record = recordResponse.data.value as {
92
+
pages?: Array<{ $type?: string; blocks?: Array<{ block?: unknown }> }>;
93
+
};
94
+
const pageErrors: Array<{
95
+
pageIndex: number;
96
+
pageType: string;
97
+
error?: unknown;
98
+
blockErrors?: Array<{
99
+
blockIndex: number;
100
+
blockType: string;
101
+
error: unknown;
102
+
block: unknown;
103
+
}>;
104
+
}> = [];
105
+
106
+
if (record.pages && Array.isArray(record.pages)) {
107
+
for (let pageIndex = 0; pageIndex < record.pages.length; pageIndex++) {
108
+
const page = record.pages[pageIndex];
109
+
const pageType = page?.$type || "unknown";
110
+
111
+
// Validate page based on type
112
+
let pageResult;
113
+
if (pageType === "pub.leaflet.pages.linearDocument") {
114
+
pageResult = PubLeafletPagesLinearDocument.validateMain(page);
115
+
} else if (pageType === "pub.leaflet.pages.canvas") {
116
+
pageResult = PubLeafletPagesCanvas.validateMain(page);
117
+
} else {
118
+
pageErrors.push({
119
+
pageIndex,
120
+
pageType,
121
+
error: `Unknown page type: ${pageType}`,
122
+
});
123
+
continue;
124
+
}
125
+
126
+
if (!pageResult.success) {
127
+
// Page has errors, validate individual blocks
128
+
const blockErrors: Array<{
129
+
blockIndex: number;
130
+
blockType: string;
131
+
error: unknown;
132
+
block: unknown;
133
+
}> = [];
134
+
135
+
if (page.blocks && Array.isArray(page.blocks)) {
136
+
for (
137
+
let blockIndex = 0;
138
+
blockIndex < page.blocks.length;
139
+
blockIndex++
140
+
) {
141
+
const blockWrapper = page.blocks[blockIndex];
142
+
const blockType =
143
+
(blockWrapper?.block as { $type?: string })?.$type ||
144
+
"unknown";
145
+
146
+
// Validate block wrapper based on page type
147
+
let blockResult;
148
+
if (pageType === "pub.leaflet.pages.linearDocument") {
149
+
blockResult =
150
+
PubLeafletPagesLinearDocument.validateBlock(blockWrapper);
151
+
} else {
152
+
blockResult =
153
+
PubLeafletPagesCanvas.validateBlock(blockWrapper);
154
+
}
155
+
156
+
if (!blockResult.success) {
157
+
blockErrors.push({
158
+
blockIndex,
159
+
blockType,
160
+
error: blockResult.error,
161
+
block: blockWrapper,
162
+
});
163
+
}
164
+
}
165
+
}
166
+
167
+
pageErrors.push({
168
+
pageIndex,
169
+
pageType,
170
+
error: pageResult.error,
171
+
blockErrors: blockErrors.length > 0 ? blockErrors : undefined,
172
+
});
173
+
}
174
+
}
175
+
}
176
+
177
+
return NextResponse.json({
178
+
success: false,
179
+
collection: uri.collection,
180
+
error: result.error,
181
+
pageErrors: pageErrors.length > 0 ? pageErrors : undefined,
182
+
record: recordResponse.data.value,
183
+
});
184
+
}
185
+
}
186
+
187
+
if (uri.collection === "pub.leaflet.publication") {
188
+
const result = PubLeafletPublication.validateRecord(
189
+
recordResponse.data.value,
190
+
);
191
+
if (result.success) {
192
+
return NextResponse.json({
193
+
success: true,
194
+
collection: uri.collection,
195
+
record: result.value,
196
+
});
197
+
} else {
198
+
return NextResponse.json({
199
+
success: false,
200
+
collection: uri.collection,
201
+
error: result.error,
202
+
});
203
+
}
204
+
}
205
+
} catch (error) {
206
+
console.error("Error validating AT URI:", error);
207
+
return NextResponse.json(
208
+
{
209
+
success: false,
210
+
error: "Invalid URI or internal error",
211
+
},
212
+
{ status: 400 },
213
+
);
214
+
}
215
+
}
+49
-13
app/globals.css
+49
-13
app/globals.css
···
107
107
--highlight-3: 255, 205, 195;
108
108
109
109
--list-marker-width: 36px;
110
-
--page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12));
111
-
--page-width-units: min(624px, calc(100vw - 12px));
110
+
--page-width-unitless: min(
111
+
var(--page-width-setting),
112
+
calc(var(--leaflet-width-unitless) - 12)
113
+
);
114
+
--page-width-units: min(
115
+
calc(var(--page-width-unitless) * 1px),
116
+
calc(100vw - 12px)
117
+
);
112
118
113
119
--gripperSVG: url("/gripperPattern.svg");
114
120
--gripperSVG2: url("/gripperPattern2.svg");
···
125
131
126
132
@media (min-width: 640px) {
127
133
:root {
134
+
/*picks between max width and screen width with 64px of padding*/
128
135
--page-width-unitless: min(
129
-
624,
136
+
var(--page-width-setting),
130
137
calc(var(--leaflet-width-unitless) - 128)
131
138
);
132
-
--page-width-units: min(624px, calc(100vw - 128px));
133
-
}
134
-
}
135
-
136
-
@media (min-width: 1280px) {
137
-
:root {
138
-
--page-width-unitless: min(
139
-
624,
140
-
calc((var(--leaflet-width-unitless) / 2) - 32)
139
+
--page-width-units: min(
140
+
calc(var(--page-width-unitless) * 1px),
141
+
calc(100vw - 128px)
141
142
);
142
-
--page-width-units: min(624px, calc((100vw / 2) - 32px));
143
143
}
144
144
}
145
145
···
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
}
···
265
270
}
266
271
267
272
pre.shiki {
273
+
@apply sm:p-3;
268
274
@apply p-2;
269
275
@apply rounded-md;
270
276
@apply overflow-auto;
277
+
278
+
@media (min-width: 640px) {
279
+
@apply p-3;
280
+
}
271
281
}
272
282
273
283
.highlight:has(+ .highlight) {
···
293
303
294
304
.ProseMirror:focus-within .selection-highlight {
295
305
background-color: transparent;
306
+
}
307
+
308
+
.ProseMirror .atMention.ProseMirror-selectednode,
309
+
.ProseMirror .didMention.ProseMirror-selectednode {
310
+
@apply text-accent-contrast;
311
+
@apply px-0.5;
312
+
@apply -mx-[3px]; /* extra px to account for the border*/
313
+
@apply -my-px; /*to account for the border*/
314
+
@apply rounded-[4px];
315
+
@apply box-decoration-clone;
316
+
background-color: rgba(var(--accent-contrast), 0.2);
317
+
border: 1px solid rgba(var(--accent-contrast), 1);
318
+
}
319
+
320
+
.mention {
321
+
@apply cursor-pointer;
322
+
@apply text-accent-contrast;
323
+
@apply px-0.5;
324
+
@apply -mx-[3px];
325
+
@apply -my-px; /*to account for the border*/
326
+
@apply rounded-[4px];
327
+
@apply box-decoration-clone;
328
+
background-color: rgba(var(--accent-contrast), 0.2);
329
+
border: 1px solid transparent;
296
330
}
297
331
298
332
.multiselected:focus-within .selection-highlight {
···
414
448
outline: none !important;
415
449
cursor: pointer;
416
450
background-color: transparent;
451
+
display: flex;
452
+
gap: 0.5rem;
417
453
418
454
:hover {
419
455
text-decoration: none !important;
+55
-208
app/lish/Subscribe.tsx
+55
-208
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
-
};
26
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
182
27
183
28
export const SubscribeWithBluesky = (props: {
184
-
isPost?: boolean;
185
29
pubName: string;
186
30
pub_uri: string;
187
31
base_url: string;
···
208
52
}
209
53
return (
210
54
<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
55
<div className="flex flex-row gap-2 place-self-center">
217
56
<BlueskySubscribeButton
218
57
pub_uri={props.pub_uri}
···
231
70
);
232
71
};
233
72
234
-
const ManageSubscription = (props: {
235
-
isPost?: boolean;
236
-
pubName: string;
73
+
export const ManageSubscription = (props: {
237
74
pub_uri: string;
238
75
subscribers: { identity: string }[];
239
76
base_url: string;
···
248
85
});
249
86
}, null);
250
87
return (
251
-
<div
252
-
className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`}
88
+
<Popover
89
+
trigger={
90
+
<div className="text-accent-contrast text-sm">Manage Subscription</div>
91
+
}
253
92
>
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>
93
+
<div className="max-w-sm flex flex-col gap-1">
94
+
<h4>Update Options</h4>
267
95
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
-
96
+
{!hasFeed && (
280
97
<a
281
-
href={`${props.base_url}/rss`}
282
-
className="flex"
98
+
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
283
99
target="_blank"
284
-
aria-label="Subscribe to RSS"
100
+
className=" place-self-center"
285
101
>
286
-
<ButtonPrimary fullWidth compact>
287
-
Get RSS
102
+
<ButtonPrimary fullWidth compact className="!px-4">
103
+
View Bluesky Custom Feed
288
104
</ButtonPrimary>
289
105
</a>
106
+
)}
290
107
291
-
<hr className="border-border-light my-1" />
108
+
<a
109
+
href={`${props.base_url}/rss`}
110
+
className="flex"
111
+
target="_blank"
112
+
aria-label="Subscribe to RSS"
113
+
>
114
+
<ButtonPrimary fullWidth compact>
115
+
Get RSS
116
+
</ButtonPrimary>
117
+
</a>
292
118
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>
119
+
<hr className="border-border-light my-1" />
120
+
121
+
<form action={unsubscribe}>
122
+
<button className="font-bold text-accent-contrast w-max place-self-center">
123
+
{unsubscribePending ? <DotLoader /> : "Unsubscribe"}
124
+
</button>
125
+
</form>
126
+
</div>
127
+
</Popover>
301
128
);
302
129
};
303
130
···
307
134
}) => {
308
135
let { identity } = useIdentityData();
309
136
let toaster = useToaster();
137
+
let [oauthError, setOauthError] = useState<
138
+
import("src/atproto-oauth").OAuthSessionError | null
139
+
>(null);
310
140
let [, subscribe, subscribePending] = useActionState(async () => {
141
+
setOauthError(null);
311
142
let result = await subscribeToPublication(
312
143
props.pub_uri,
313
144
window.location.href + "?refreshAuth",
314
145
);
146
+
if (!result.success) {
147
+
if (isOAuthSessionError(result.error)) {
148
+
setOauthError(result.error);
149
+
}
150
+
return;
151
+
}
315
152
if (result.hasFeed === false) {
316
153
props.setSuccessModalOpen(true);
317
154
}
···
346
183
}
347
184
348
185
return (
349
-
<>
186
+
<div className="flex flex-col gap-2 place-self-center">
350
187
<form
351
188
action={subscribe}
352
189
className="place-self-center flex flex-row gap-1"
···
361
198
)}
362
199
</ButtonPrimary>
363
200
</form>
364
-
</>
201
+
{oauthError && (
202
+
<OAuthErrorMessage
203
+
error={oauthError}
204
+
className="text-center text-sm text-accent-1"
205
+
/>
206
+
)}
207
+
</div>
365
208
);
366
209
};
367
210
···
430
273
</Dialog.Root>
431
274
);
432
275
};
276
+
277
+
export const SubscribeOnPost = () => {
278
+
return <div></div>;
279
+
};
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
···
1
+
"use client";
2
+
import { ProfilePopover } from "components/ProfilePopover";
3
+
4
+
export const PublicationAuthor = (props: {
5
+
did: string;
6
+
displayName?: string;
7
+
handle: string;
8
+
}) => {
9
+
return (
10
+
<p className="italic text-tertiary sm:text-base text-sm">
11
+
<ProfilePopover
12
+
didOrHandle={props.did}
13
+
trigger={
14
+
<span className="hover:underline">
15
+
<strong>by {props.displayName}</strong> @{props.handle}
16
+
</span>
17
+
}
18
+
/>
19
+
</p>
20
+
);
21
+
};
+21
-171
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
+21
-171
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
2
-
import { PubLeafletRichtextFacet } from "lexicons/api";
3
-
4
-
type Facet = PubLeafletRichtextFacet.Main;
5
-
export function BaseTextBlock(props: {
6
-
plaintext: string;
7
-
facets?: Facet[];
8
-
index: number[];
9
-
preview?: boolean;
10
-
}) {
11
-
let children = [];
12
-
let richText = new RichText({
13
-
text: props.plaintext,
14
-
facets: props.facets || [],
15
-
});
16
-
let counter = 0;
17
-
for (const segment of richText.segments()) {
18
-
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
19
-
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
20
-
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
21
-
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
22
-
let isStrikethrough = segment.facet?.find(
23
-
PubLeafletRichtextFacet.isStrikethrough,
24
-
);
25
-
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
26
-
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
27
-
let isHighlighted = segment.facet?.find(
28
-
PubLeafletRichtextFacet.isHighlight,
29
-
);
30
-
let className = `
31
-
${isCode ? "inline-code" : ""}
32
-
${id ? "scroll-mt-12 scroll-mb-10" : ""}
33
-
${isBold ? "font-bold" : ""}
34
-
${isItalic ? "italic" : ""}
35
-
${isUnderline ? "underline" : ""}
36
-
${isStrikethrough ? "line-through decoration-tertiary" : ""}
37
-
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
38
-
39
-
// Split text by newlines and insert <br> tags
40
-
const textParts = segment.text.split('\n');
41
-
const renderedText = textParts.flatMap((part, i) =>
42
-
i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part]
43
-
);
44
-
45
-
if (isCode) {
46
-
children.push(
47
-
<code key={counter} className={className} id={id?.id}>
48
-
{renderedText}
49
-
</code>,
50
-
);
51
-
} else if (link) {
52
-
children.push(
53
-
<a
54
-
key={counter}
55
-
href={link.uri}
56
-
className={`text-accent-contrast hover:underline ${className}`}
57
-
target="_blank"
58
-
>
59
-
{renderedText}
60
-
</a>,
61
-
);
62
-
} else {
63
-
children.push(
64
-
<span key={counter} className={className} id={id?.id}>
65
-
{renderedText}
66
-
</span>,
67
-
);
68
-
}
69
-
70
-
counter++;
71
-
}
72
-
return <>{children}</>;
73
-
}
74
-
75
-
type RichTextSegment = {
76
-
text: string;
77
-
facet?: Exclude<Facet["features"], { $type: string }>;
78
-
};
79
-
80
-
export class RichText {
81
-
unicodeText: UnicodeString;
82
-
facets?: Facet[];
83
-
84
-
constructor(props: { text: string; facets: Facet[] }) {
85
-
this.unicodeText = new UnicodeString(props.text);
86
-
this.facets = props.facets;
87
-
if (this.facets) {
88
-
this.facets = this.facets
89
-
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
90
-
.sort((a, b) => a.index.byteStart - b.index.byteStart);
91
-
}
92
-
}
1
+
import { ProfilePopover } from "components/ProfilePopover";
2
+
import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore";
3
+
import { ReactNode } from "react";
93
4
94
-
*segments(): Generator<RichTextSegment, void, void> {
95
-
const facets = this.facets || [];
96
-
if (!facets.length) {
97
-
yield { text: this.unicodeText.utf16 };
98
-
return;
99
-
}
5
+
// Re-export RichText for backwards compatibility
6
+
export { RichText };
100
7
101
-
let textCursor = 0;
102
-
let facetCursor = 0;
103
-
do {
104
-
const currFacet = facets[facetCursor];
105
-
if (textCursor < currFacet.index.byteStart) {
106
-
yield {
107
-
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
108
-
};
109
-
} else if (textCursor > currFacet.index.byteStart) {
110
-
facetCursor++;
111
-
continue;
112
-
}
113
-
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
114
-
const subtext = this.unicodeText.slice(
115
-
currFacet.index.byteStart,
116
-
currFacet.index.byteEnd,
117
-
);
118
-
if (!subtext.trim()) {
119
-
// dont empty string entities
120
-
yield { text: subtext };
121
-
} else {
122
-
yield { text: subtext, facet: currFacet.features };
123
-
}
124
-
}
125
-
textCursor = currFacet.index.byteEnd;
126
-
facetCursor++;
127
-
} while (facetCursor < facets.length);
128
-
if (textCursor < this.unicodeText.length) {
129
-
yield {
130
-
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
131
-
};
132
-
}
133
-
}
8
+
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
9
+
return (
10
+
<ProfilePopover
11
+
didOrHandle={props.did}
12
+
trigger={props.children}
13
+
/>
14
+
);
134
15
}
135
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
136
-
if (facets.length === 0) {
137
-
return [newFacet];
138
-
}
139
16
140
-
const allFacets = [...facets, newFacet];
141
-
142
-
// Collect all boundary positions
143
-
const boundaries = new Set<number>();
144
-
boundaries.add(0);
145
-
boundaries.add(length);
146
-
147
-
for (const facet of allFacets) {
148
-
boundaries.add(facet.index.byteStart);
149
-
boundaries.add(facet.index.byteEnd);
150
-
}
151
-
152
-
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
153
-
const result: Facet[] = [];
154
-
155
-
// Process segments between consecutive boundaries
156
-
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
157
-
const start = sortedBoundaries[i];
158
-
const end = sortedBoundaries[i + 1];
159
-
160
-
// Find facets that are active at the start position
161
-
const activeFacets = allFacets.filter(
162
-
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
163
-
);
164
-
165
-
// Only create facet if there are active facets (features present)
166
-
if (activeFacets.length > 0) {
167
-
const features = activeFacets.flatMap((f) => f.features);
168
-
result.push({
169
-
index: { byteStart: start, byteEnd: end },
170
-
features,
171
-
});
172
-
}
173
-
}
174
-
175
-
return result;
17
+
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
18
+
return (
19
+
<TextBlockCore
20
+
{...props}
21
+
renderers={{
22
+
DidMention: DidMentionWithPopover,
23
+
}}
24
+
/>
25
+
);
176
26
}
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
···
1
+
"use client";
2
+
import { AppBskyFeedDefs } from "@atproto/api";
3
+
import useSWR from "swr";
4
+
import { PageWrapper } from "components/Pages/Page";
5
+
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
+
import { DotLoader } from "components/utils/DotLoader";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { openPage } from "./PostPages";
9
+
import { BskyPostContent } from "./BskyPostContent";
10
+
import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks";
11
+
12
+
// Re-export for backwards compatibility
13
+
export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes };
14
+
15
+
type PostView = AppBskyFeedDefs.PostView;
16
+
17
+
export function BlueskyQuotesPage(props: {
18
+
postUri: string;
19
+
pageId: string;
20
+
pageOptions?: React.ReactNode;
21
+
hasPageBackground: boolean;
22
+
}) {
23
+
const { postUri, pageId, pageOptions } = props;
24
+
const drawer = useDrawerOpen(postUri);
25
+
26
+
const {
27
+
data: quotesData,
28
+
isLoading,
29
+
error,
30
+
} = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri));
31
+
32
+
return (
33
+
<PageWrapper
34
+
pageType="doc"
35
+
fullPageScroll={false}
36
+
id={`post-page-${pageId}`}
37
+
drawerOpen={!!drawer}
38
+
pageOptions={pageOptions}
39
+
>
40
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
41
+
<div className="text-secondary font-bold mb-3 flex items-center gap-2">
42
+
<QuoteTiny />
43
+
Bluesky Quotes
44
+
</div>
45
+
{isLoading ? (
46
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
47
+
<span>loading quotes</span>
48
+
<DotLoader />
49
+
</div>
50
+
) : error ? (
51
+
<div className="text-tertiary italic text-sm text-center py-8">
52
+
Failed to load quotes
53
+
</div>
54
+
) : quotesData && quotesData.posts.length > 0 ? (
55
+
<QuotesContent posts={quotesData.posts} postUri={postUri} />
56
+
) : (
57
+
<div className="text-tertiary italic text-sm text-center py-8">
58
+
No quotes yet
59
+
</div>
60
+
)}
61
+
</div>
62
+
</PageWrapper>
63
+
);
64
+
}
65
+
66
+
function QuotesContent(props: { posts: PostView[]; postUri: string }) {
67
+
const { posts, postUri } = props;
68
+
69
+
return (
70
+
<div className="flex flex-col gap-0">
71
+
{posts.map((post) => (
72
+
<QuotePost
73
+
key={post.uri}
74
+
post={post}
75
+
quotesUri={postUri}
76
+
/>
77
+
))}
78
+
</div>
79
+
);
80
+
}
81
+
82
+
function QuotePost(props: {
83
+
post: PostView;
84
+
quotesUri: string;
85
+
}) {
86
+
const { post, quotesUri } = props;
87
+
const parent = { type: "quotes" as const, uri: quotesUri };
88
+
89
+
return (
90
+
<div
91
+
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
92
+
onClick={() => openPage(parent, { type: "thread", uri: post.uri })}
93
+
>
94
+
<BskyPostContent
95
+
post={post}
96
+
parent={parent}
97
+
linksEnabled={true}
98
+
showEmbed={true}
99
+
showBlueskyLink={true}
100
+
onLinkClick={(e) => e.stopPropagation()}
101
+
onEmbedClick={(e) => e.stopPropagation()}
102
+
/>
103
+
</div>
104
+
);
105
+
}
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
···
1
+
"use client";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
+
import {
4
+
BlueskyEmbed,
5
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
6
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
7
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
8
+
import { CommentTiny } from "components/Icons/CommentTiny";
9
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
10
+
import { Separator } from "components/Layout";
11
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
12
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
13
+
import { OpenPage } from "./PostPages";
14
+
import { ThreadLink, QuotesLink } from "./PostLinks";
15
+
16
+
type PostView = AppBskyFeedDefs.PostView;
17
+
18
+
export function BskyPostContent(props: {
19
+
post: PostView;
20
+
parent?: OpenPage;
21
+
linksEnabled?: boolean;
22
+
avatarSize?: "sm" | "md";
23
+
showEmbed?: boolean;
24
+
showBlueskyLink?: boolean;
25
+
onEmbedClick?: (e: React.MouseEvent) => void;
26
+
onLinkClick?: (e: React.MouseEvent) => void;
27
+
}) {
28
+
const {
29
+
post,
30
+
parent,
31
+
linksEnabled = true,
32
+
avatarSize = "md",
33
+
showEmbed = true,
34
+
showBlueskyLink = true,
35
+
onEmbedClick,
36
+
onLinkClick,
37
+
} = props;
38
+
39
+
const record = post.record as AppBskyFeedPost.Record;
40
+
const postId = post.uri.split("/")[4];
41
+
const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
42
+
43
+
const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10";
44
+
45
+
return (
46
+
<>
47
+
<div className="flex flex-col items-center shrink-0">
48
+
{post.author.avatar ? (
49
+
<img
50
+
src={post.author.avatar}
51
+
alt={`${post.author.displayName}'s avatar`}
52
+
className={`${avatarClass} rounded-full border border-border-light`}
53
+
/>
54
+
) : (
55
+
<div className={`${avatarClass} rounded-full border border-border-light bg-border`} />
56
+
)}
57
+
</div>
58
+
59
+
<div className="flex flex-col grow min-w-0">
60
+
<div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}>
61
+
<div className="font-bold text-secondary">
62
+
{post.author.displayName}
63
+
</div>
64
+
<a
65
+
className="text-xs text-tertiary hover:underline"
66
+
target="_blank"
67
+
href={`https://bsky.app/profile/${post.author.handle}`}
68
+
onClick={onLinkClick}
69
+
>
70
+
@{post.author.handle}
71
+
</a>
72
+
</div>
73
+
74
+
<div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}>
75
+
<div className="text-sm text-secondary">
76
+
<BlueskyRichText record={record} />
77
+
</div>
78
+
{showEmbed && post.embed && (
79
+
<div onClick={onEmbedClick}>
80
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
81
+
</div>
82
+
)}
83
+
</div>
84
+
85
+
<div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}>
86
+
<ClientDate date={record.createdAt} />
87
+
<PostCounts
88
+
post={post}
89
+
parent={parent}
90
+
linksEnabled={linksEnabled}
91
+
showBlueskyLink={showBlueskyLink}
92
+
url={url}
93
+
onLinkClick={onLinkClick}
94
+
/>
95
+
</div>
96
+
</div>
97
+
</>
98
+
);
99
+
}
100
+
101
+
function PostCounts(props: {
102
+
post: PostView;
103
+
parent?: OpenPage;
104
+
linksEnabled: boolean;
105
+
showBlueskyLink: boolean;
106
+
url: string;
107
+
onLinkClick?: (e: React.MouseEvent) => void;
108
+
}) {
109
+
const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props;
110
+
111
+
return (
112
+
<div className="flex gap-2 items-center">
113
+
{post.replyCount != null && post.replyCount > 0 && (
114
+
<>
115
+
<Separator classname="h-3" />
116
+
{linksEnabled ? (
117
+
<ThreadLink
118
+
threadUri={post.uri}
119
+
parent={parent}
120
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
121
+
onClick={onLinkClick}
122
+
>
123
+
{post.replyCount}
124
+
<CommentTiny />
125
+
</ThreadLink>
126
+
) : (
127
+
<div className="flex items-center gap-1 text-tertiary text-xs">
128
+
{post.replyCount}
129
+
<CommentTiny />
130
+
</div>
131
+
)}
132
+
</>
133
+
)}
134
+
{post.quoteCount != null && post.quoteCount > 0 && (
135
+
<>
136
+
<Separator classname="h-3" />
137
+
<QuotesLink
138
+
postUri={post.uri}
139
+
parent={parent}
140
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
141
+
onClick={onLinkClick}
142
+
>
143
+
{post.quoteCount}
144
+
<QuoteTiny />
145
+
</QuotesLink>
146
+
</>
147
+
)}
148
+
{showBlueskyLink && (
149
+
<>
150
+
<Separator classname="h-3" />
151
+
<a
152
+
className="text-tertiary"
153
+
target="_blank"
154
+
href={url}
155
+
onClick={onLinkClick}
156
+
>
157
+
<BlueskyTiny />
158
+
</a>
159
+
</>
160
+
)}
161
+
</div>
162
+
);
163
+
}
164
+
165
+
export const ClientDate = (props: { date?: string }) => {
166
+
const pageLoaded = useHasPageLoaded();
167
+
const formattedDate = useLocalizedDate(
168
+
props.date || new Date().toISOString(),
169
+
{
170
+
month: "short",
171
+
day: "numeric",
172
+
year: "numeric",
173
+
hour: "numeric",
174
+
minute: "numeric",
175
+
hour12: true,
176
+
},
177
+
);
178
+
179
+
if (!pageLoaded) return null;
180
+
181
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
182
+
};
+13
-8
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+13
-8
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
22
22
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
23
23
import { PollData } from "./fetchPollData";
24
24
import { SharedPageProps } from "./PostPages";
25
+
import { useIsMobile } from "src/hooks/isMobile";
25
26
26
27
export function CanvasPage({
27
28
blocks,
···
56
57
<PageWrapper
57
58
pageType="canvas"
58
59
fullPageScroll={fullPageScroll}
59
-
cardBorderHidden={!hasPageBackground}
60
-
id={pageId ? `post-page-${pageId}` : "post-page"}
60
+
id={`post-page-${pageId ?? document_uri}`}
61
61
drawerOpen={
62
62
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
63
63
}
···
202
202
isSubpage: boolean | undefined;
203
203
data: PostPageData;
204
204
profile: ProfileViewDetailed;
205
-
preferences: { showComments?: boolean };
205
+
preferences: {
206
+
showComments?: boolean;
207
+
showMentions?: boolean;
208
+
showPrevNext?: boolean;
209
+
};
206
210
quotesCount: number | undefined;
207
211
commentsCount: number | undefined;
208
212
}) => {
213
+
let isMobile = useIsMobile();
209
214
return (
210
-
<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">
215
+
<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">
211
216
<Interactions
212
217
quotesCount={props.quotesCount || 0}
213
218
commentsCount={props.commentsCount || 0}
214
-
compact
215
219
showComments={props.preferences.showComments}
220
+
showMentions={props.preferences.showMentions}
216
221
pageId={props.pageId}
217
222
/>
218
223
{!props.isSubpage && (
219
224
<>
220
225
<Separator classname="h-5" />
221
226
<Popover
222
-
side="left"
223
-
align="start"
224
-
className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
227
+
side="bottom"
228
+
align="end"
229
+
className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`}
225
230
trigger={<InfoSmall />}
226
231
>
227
232
<PostHeader
+247
-16
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+247
-16
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
1
+
import { AtUri, UnicodeString } from "@atproto/api";
2
2
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
3
3
import { multiBlockSchema } from "components/Blocks/TextBlock/schema";
4
4
import { PubLeafletRichtextFacet } from "lexicons/api";
···
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 { useToaster } from "components/Toast";
42
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
43
+
import { Mention, MentionAutocomplete } from "components/Mention";
44
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
45
+
46
+
const addMentionToEditor = (
47
+
mention: Mention,
48
+
range: { from: number; to: number },
49
+
view: EditorView,
50
+
) => {
51
+
if (!view) return;
52
+
const { from, to } = range;
53
+
const tr = view.state.tr;
54
+
55
+
if (mention.type === "did") {
56
+
// Delete the @ and any query text
57
+
tr.delete(from, to);
58
+
// Insert didMention inline node
59
+
const mentionText = "@" + mention.handle;
60
+
const didMentionNode = multiBlockSchema.nodes.didMention.create({
61
+
did: mention.did,
62
+
text: mentionText,
63
+
});
64
+
tr.insert(from, didMentionNode);
65
+
// Add a space after the mention
66
+
tr.insertText(" ", from + 1);
67
+
}
68
+
if (mention.type === "publication" || mention.type === "post") {
69
+
// Delete the @ and any query text
70
+
tr.delete(from, to);
71
+
let name = mention.type === "post" ? mention.title : mention.name;
72
+
// Insert atMention inline node
73
+
const atMentionNode = multiBlockSchema.nodes.atMention.create({
74
+
atURI: mention.uri,
75
+
text: name,
76
+
});
77
+
tr.insert(from, atMentionNode);
78
+
// Add a space after the mention
79
+
tr.insertText(" ", from + 1);
80
+
}
81
+
82
+
view.dispatch(tr);
83
+
view.focus();
84
+
};
39
85
40
86
export function CommentBox(props: {
41
87
doc_uri: string;
···
50
96
commentBox: { quote },
51
97
} = useInteractionState(props.doc_uri);
52
98
let [loading, setLoading] = useState(false);
99
+
let view = useRef<null | EditorView>(null);
100
+
let toaster = useToaster();
53
101
54
-
const handleSubmit = async () => {
102
+
// Mention autocomplete state
103
+
const [mentionOpen, setMentionOpen] = useState(false);
104
+
const [mentionCoords, setMentionCoords] = useState<{
105
+
top: number;
106
+
left: number;
107
+
} | null>(null);
108
+
// Use a ref for insert position to avoid stale closure issues
109
+
const mentionInsertPosRef = useRef<number | null>(null);
110
+
111
+
// Use a ref for the callback so input rules can access it
112
+
const openMentionAutocompleteRef = useRef<() => void>(() => {});
113
+
openMentionAutocompleteRef.current = () => {
114
+
if (!view.current) return;
115
+
116
+
const pos = view.current.state.selection.from;
117
+
mentionInsertPosRef.current = pos;
118
+
119
+
// Get coordinates for the popup relative to the positioned parent
120
+
const coords = view.current.coordsAtPos(pos - 1);
121
+
122
+
// Find the relative positioned parent container
123
+
const editorEl = view.current.dom;
124
+
const container = editorEl.closest(".relative") as HTMLElement | null;
125
+
126
+
if (container) {
127
+
const containerRect = container.getBoundingClientRect();
128
+
setMentionCoords({
129
+
top: coords.bottom - containerRect.top,
130
+
left: coords.left - containerRect.left,
131
+
});
132
+
} else {
133
+
setMentionCoords({
134
+
top: coords.bottom,
135
+
left: coords.left,
136
+
});
137
+
}
138
+
setMentionOpen(true);
139
+
};
140
+
141
+
const handleMentionSelect = useCallback((mention: Mention) => {
142
+
if (!view.current || mentionInsertPosRef.current === null) return;
143
+
144
+
const from = mentionInsertPosRef.current - 1;
145
+
const to = mentionInsertPosRef.current;
146
+
147
+
addMentionToEditor(mention, { from, to }, view.current);
148
+
view.current.focus();
149
+
}, []);
150
+
151
+
const handleMentionOpenChange = useCallback((open: boolean) => {
152
+
setMentionOpen(open);
153
+
if (!open) {
154
+
setMentionCoords(null);
155
+
mentionInsertPosRef.current = null;
156
+
}
157
+
}, []);
158
+
159
+
// Use a ref for handleSubmit so keyboard shortcuts can access it
160
+
const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
161
+
handleSubmitRef.current = async () => {
55
162
if (loading || !view.current) return;
56
163
57
164
setLoading(true);
58
165
let currentState = view.current.state;
59
166
let [plaintext, facets] = docToFacetedText(currentState.doc);
60
-
let comment = await publishComment({
167
+
let result = await publishComment({
61
168
pageId: props.pageId,
62
169
document: props.doc_uri,
63
170
comment: {
···
74
181
},
75
182
});
76
183
184
+
if (!result.success) {
185
+
setLoading(false);
186
+
toaster({
187
+
content: isOAuthSessionError(result.error) ? (
188
+
<OAuthErrorMessage error={result.error} />
189
+
) : (
190
+
"Failed to post comment"
191
+
),
192
+
type: "error",
193
+
});
194
+
return;
195
+
}
196
+
77
197
let tr = currentState.tr;
78
198
tr = tr.replaceWith(
79
199
0,
···
90
210
localComments: [
91
211
...s.localComments,
92
212
{
93
-
record: comment.record,
94
-
uri: comment.uri,
95
-
bsky_profiles: { record: comment.profile as Json },
213
+
record: result.record,
214
+
uri: result.uri,
215
+
bsky_profiles: {
216
+
record: result.profile as Json,
217
+
did: new AtUri(result.uri).host,
218
+
},
96
219
},
97
220
],
98
221
}));
···
114
237
"Mod-y": redo,
115
238
"Shift-Mod-z": redo,
116
239
"Ctrl-Enter": () => {
117
-
handleSubmit();
240
+
handleSubmitRef.current();
118
241
return true;
119
242
},
120
243
"Meta-Enter": () => {
121
-
handleSubmit();
244
+
handleSubmitRef.current();
122
245
return true;
123
246
},
124
247
}),
···
127
250
type: multiBlockSchema.marks.link,
128
251
shouldAutoLink: () => true,
129
252
defaultProtocol: "https",
253
+
}),
254
+
// Input rules for @ mentions
255
+
inputRules({
256
+
rules: [
257
+
// @ at start of line or after space
258
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
259
+
setTimeout(() => openMentionAutocompleteRef.current(), 0);
260
+
return null;
261
+
}),
262
+
],
130
263
}),
131
264
history(),
132
265
],
133
266
}),
134
267
);
135
-
let view = useRef<null | EditorView>(null);
136
268
useLayoutEffect(() => {
137
269
if (!mountRef.current) return;
138
270
view.current = new EditorView(
···
187
319
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
188
320
if (!direct) return;
189
321
if (node.nodeSize - 2 <= _pos) return;
322
+
323
+
const nodeAt1 = node.nodeAt(_pos - 1);
324
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
325
+
326
+
// Check for link marks
190
327
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);
328
+
nodeAt1?.marks.find(
329
+
(f) => f.type === multiBlockSchema.marks.link,
330
+
) ||
331
+
nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link);
197
332
if (mark) {
198
333
window.open(mark.attrs.href, "_blank");
334
+
return;
335
+
}
336
+
337
+
// Check for didMention inline nodes
338
+
if (nodeAt1?.type === multiBlockSchema.nodes.didMention) {
339
+
window.open(
340
+
didToBlueskyUrl(nodeAt1.attrs.did),
341
+
"_blank",
342
+
"noopener,noreferrer",
343
+
);
344
+
return;
345
+
}
346
+
if (nodeAt2?.type === multiBlockSchema.nodes.didMention) {
347
+
window.open(
348
+
didToBlueskyUrl(nodeAt2.attrs.did),
349
+
"_blank",
350
+
"noopener,noreferrer",
351
+
);
352
+
return;
353
+
}
354
+
355
+
// Check for atMention inline nodes (publications/documents)
356
+
if (nodeAt1?.type === multiBlockSchema.nodes.atMention) {
357
+
window.open(
358
+
atUriToUrl(nodeAt1.attrs.atURI),
359
+
"_blank",
360
+
"noopener,noreferrer",
361
+
);
362
+
return;
363
+
}
364
+
if (nodeAt2?.type === multiBlockSchema.nodes.atMention) {
365
+
window.open(
366
+
atUriToUrl(nodeAt2.attrs.atURI),
367
+
"_blank",
368
+
"noopener,noreferrer",
369
+
);
370
+
return;
199
371
}
200
372
},
201
373
dispatchTransaction(tr) {
···
236
408
<div className="w-full relative group">
237
409
<pre
238
410
ref={mountRef}
411
+
onFocus={() => {
412
+
// Close mention dropdown when editor gains focus (reset stale state)
413
+
handleMentionOpenChange(false);
414
+
}}
415
+
onBlur={(e) => {
416
+
// Close mention dropdown when editor loses focus
417
+
// But not if focus moved to the mention autocomplete
418
+
const relatedTarget = e.relatedTarget as HTMLElement | null;
419
+
if (!relatedTarget?.closest(".dropdownMenu")) {
420
+
handleMentionOpenChange(false);
421
+
}
422
+
}}
239
423
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
240
424
/>
241
425
<IOSBS view={view} />
426
+
<MentionAutocomplete
427
+
open={mentionOpen}
428
+
onOpenChange={handleMentionOpenChange}
429
+
view={view}
430
+
onSelect={handleMentionSelect}
431
+
coords={mentionCoords}
432
+
/>
242
433
</div>
243
434
<div className="flex justify-between pt-1">
244
435
<div className="flex gap-1">
···
261
452
view={view}
262
453
/>
263
454
</div>
264
-
<ButtonPrimary compact onClick={handleSubmit}>
455
+
<ButtonPrimary compact onClick={() => handleSubmitRef.current()}>
265
456
{loading ? <DotLoader /> : <ShareSmall />}
266
457
</ButtonPrimary>
267
458
</div>
···
328
519
facets.push(facet);
329
520
}
330
521
}
522
+
523
+
fullText += text;
524
+
byteOffset += unicodeString.length;
525
+
} else if (node.type.name === "didMention") {
526
+
// Handle DID mention nodes
527
+
const text = node.attrs.text || "";
528
+
const unicodeString = new UnicodeString(text);
529
+
530
+
facets.push({
531
+
index: {
532
+
byteStart: byteOffset,
533
+
byteEnd: byteOffset + unicodeString.length,
534
+
},
535
+
features: [
536
+
{
537
+
$type: "pub.leaflet.richtext.facet#didMention",
538
+
did: node.attrs.did,
539
+
},
540
+
],
541
+
});
542
+
543
+
fullText += text;
544
+
byteOffset += unicodeString.length;
545
+
} else if (node.type.name === "atMention") {
546
+
// Handle AT-URI mention nodes (publications and documents)
547
+
const text = node.attrs.text || "";
548
+
const unicodeString = new UnicodeString(text);
549
+
550
+
facets.push({
551
+
index: {
552
+
byteStart: byteOffset,
553
+
byteEnd: byteOffset + unicodeString.length,
554
+
},
555
+
features: [
556
+
{
557
+
$type: "pub.leaflet.richtext.facet#atMention",
558
+
atURI: node.attrs.atURI,
559
+
},
560
+
],
561
+
});
331
562
332
563
fullText += text;
333
564
byteOffset += unicodeString.length;
+123
-6
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
+123
-6
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
3
3
import { AtpBaseClient, PubLeafletComment } from "lexicons/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
5
import { PubLeafletRichtextFacet } from "lexicons/api";
6
-
import { createOauthClient } from "src/atproto-oauth";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
7
10
import { TID } from "@atproto/common";
8
11
import { AtUri, lexToJson, Un$Typed } from "@atproto/api";
9
12
import { supabaseServerClient } from "supabase/serverClient";
10
13
import { Json } from "supabase/database.types";
11
14
import {
12
15
Notification,
16
+
NotificationData,
13
17
pingIdentityToUpdateNotification,
14
18
} from "src/notifications";
15
19
import { v7 } from "uuid";
16
20
21
+
type PublishCommentResult =
22
+
| { success: true; record: Json; profile: any; uri: string }
23
+
| { success: false; error: OAuthSessionError };
24
+
17
25
export async function publishComment(args: {
18
26
document: string;
19
27
pageId?: string;
···
23
31
replyTo?: string;
24
32
attachment: PubLeafletComment.Record["attachment"];
25
33
};
26
-
}) {
27
-
const oauthClient = await createOauthClient();
34
+
}): Promise<PublishCommentResult> {
28
35
let identity = await getIdentityData();
29
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
36
+
if (!identity || !identity.atp_did) {
37
+
return {
38
+
success: false,
39
+
error: {
40
+
type: "oauth_session_expired",
41
+
message: "Not authenticated",
42
+
did: "",
43
+
},
44
+
};
45
+
}
30
46
31
-
let credentialSession = await oauthClient.restore(identity.atp_did);
47
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
48
+
if (!sessionResult.ok) {
49
+
return { success: false, error: sessionResult.error };
50
+
}
51
+
let credentialSession = sessionResult.value;
32
52
let agent = new AtpBaseClient(
33
53
credentialSession.fetchHandler.bind(credentialSession),
34
54
);
···
84
104
parent_uri: args.comment.replyTo,
85
105
},
86
106
});
107
+
}
108
+
109
+
// Create mention notifications from comment facets
110
+
const mentionNotifications = createCommentMentionNotifications(
111
+
args.comment.facets,
112
+
uri.toString(),
113
+
credentialSession.did!,
114
+
);
115
+
notifications.push(...mentionNotifications);
116
+
117
+
// Insert all notifications and ping recipients
118
+
if (notifications.length > 0) {
87
119
// SOMEDAY: move this out the action with inngest or workflows
88
120
await supabaseServerClient.from("notifications").insert(notifications);
89
-
await pingIdentityToUpdateNotification(recipient);
121
+
122
+
// Ping all unique recipients
123
+
const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))];
124
+
await Promise.all(
125
+
uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)),
126
+
);
90
127
}
91
128
92
129
return {
130
+
success: true,
93
131
record: data?.[0].record as Json,
94
132
profile: lexToJson(profile.value),
95
133
uri: uri.toString(),
96
134
};
97
135
}
136
+
137
+
/**
138
+
* Creates mention notifications from comment facets
139
+
* Handles didMention (people) and atMention (publications/documents)
140
+
*/
141
+
function createCommentMentionNotifications(
142
+
facets: PubLeafletRichtextFacet.Main[],
143
+
commentUri: string,
144
+
commenterDid: string,
145
+
): Notification[] {
146
+
const notifications: Notification[] = [];
147
+
const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications
148
+
149
+
for (const facet of facets) {
150
+
for (const feature of facet.features) {
151
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
152
+
// DID mention - notify the mentioned person directly
153
+
const recipientDid = feature.did;
154
+
155
+
// Don't notify yourself
156
+
if (recipientDid === commenterDid) continue;
157
+
// Avoid duplicate notifications to the same person
158
+
if (notifiedRecipients.has(recipientDid)) continue;
159
+
notifiedRecipients.add(recipientDid);
160
+
161
+
notifications.push({
162
+
id: v7(),
163
+
recipient: recipientDid,
164
+
data: {
165
+
type: "comment_mention",
166
+
comment_uri: commentUri,
167
+
mention_type: "did",
168
+
},
169
+
});
170
+
} else if (PubLeafletRichtextFacet.isAtMention(feature)) {
171
+
// AT-URI mention - notify the owner of the publication/document
172
+
try {
173
+
const mentionedUri = new AtUri(feature.atURI);
174
+
const recipientDid = mentionedUri.host;
175
+
176
+
// Don't notify yourself
177
+
if (recipientDid === commenterDid) continue;
178
+
// Avoid duplicate notifications to the same person for the same mentioned item
179
+
const dedupeKey = `${recipientDid}:${feature.atURI}`;
180
+
if (notifiedRecipients.has(dedupeKey)) continue;
181
+
notifiedRecipients.add(dedupeKey);
182
+
183
+
if (mentionedUri.collection === "pub.leaflet.publication") {
184
+
notifications.push({
185
+
id: v7(),
186
+
recipient: recipientDid,
187
+
data: {
188
+
type: "comment_mention",
189
+
comment_uri: commentUri,
190
+
mention_type: "publication",
191
+
mentioned_uri: feature.atURI,
192
+
},
193
+
});
194
+
} else if (mentionedUri.collection === "pub.leaflet.document") {
195
+
notifications.push({
196
+
id: v7(),
197
+
recipient: recipientDid,
198
+
data: {
199
+
type: "comment_mention",
200
+
comment_uri: commentUri,
201
+
mention_type: "document",
202
+
mentioned_uri: feature.atURI,
203
+
},
204
+
});
205
+
}
206
+
} catch (error) {
207
+
console.error("Failed to parse AT-URI for mention:", feature.atURI, error);
208
+
}
209
+
}
210
+
}
211
+
}
212
+
213
+
return notifications;
214
+
}
+19
-100
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+19
-100
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
18
18
import { QuoteContent } from "../Quotes";
19
19
import { timeAgo } from "src/utils/timeAgo";
20
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
+
import { ProfilePopover } from "components/ProfilePopover";
21
22
22
23
export type Comment = {
23
24
record: Json;
24
25
uri: string;
25
-
bsky_profiles: { record: Json } | null;
26
+
bsky_profiles: { record: Json; did: string } | null;
26
27
};
27
28
export function Comments(props: {
28
29
document_uri: string;
···
50
51
}, []);
51
52
52
53
return (
53
-
<div id={"commentsDrawer"} className="flex flex-col gap-2 relative">
54
+
<div
55
+
id={"commentsDrawer"}
56
+
className="flex flex-col gap-2 relative text-sm text-secondary"
57
+
>
54
58
<div className="w-full flex justify-between text-secondary font-bold">
55
59
Comments
56
60
<button
···
109
113
document: string;
110
114
comment: Comment;
111
115
comments: Comment[];
112
-
profile?: AppBskyActorProfile.Record;
116
+
profile: AppBskyActorProfile.Record;
113
117
record: PubLeafletComment.Record;
114
118
pageId?: string;
115
119
}) => {
120
+
const did = props.comment.bsky_profiles?.did;
121
+
116
122
return (
117
-
<div className="comment">
123
+
<div id={props.comment.uri} className="comment">
118
124
<div className="flex gap-2">
119
-
{props.profile && (
120
-
<ProfilePopover profile={props.profile} comment={props.comment.uri} />
125
+
{did && (
126
+
<ProfilePopover
127
+
didOrHandle={did}
128
+
trigger={
129
+
<div className="text-sm text-tertiary font-bold hover:underline">
130
+
{props.profile.displayName}
131
+
</div>
132
+
}
133
+
/>
121
134
)}
122
-
<DatePopover date={props.record.createdAt} />
123
135
</div>
124
136
{props.record.attachment &&
125
137
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
291
303
</Popover>
292
304
);
293
305
};
294
-
295
-
const ProfilePopover = (props: {
296
-
profile: AppBskyActorProfile.Record;
297
-
comment: string;
298
-
}) => {
299
-
let commenterId = new AtUri(props.comment).host;
300
-
301
-
return (
302
-
<>
303
-
<a
304
-
className="font-bold text-tertiary text-sm hover:underline"
305
-
href={`https://bsky.app/profile/${commenterId}`}
306
-
>
307
-
{props.profile.displayName}
308
-
</a>
309
-
{/*<Media mobile={false}>
310
-
<Popover
311
-
align="start"
312
-
trigger={
313
-
<div
314
-
onMouseOver={() => {
315
-
setHovering(true);
316
-
hoverTimeout.current = window.setTimeout(() => {
317
-
setLoadProfile(true);
318
-
}, 500);
319
-
}}
320
-
onMouseOut={() => {
321
-
setHovering(false);
322
-
clearTimeout(hoverTimeout.current);
323
-
}}
324
-
className="font-bold text-tertiary text-sm hover:underline"
325
-
>
326
-
{props.profile.displayName}
327
-
</div>
328
-
}
329
-
className="max-w-sm"
330
-
>
331
-
{profile && (
332
-
<>
333
-
<div className="profilePopover text-sm flex gap-2">
334
-
<div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" />
335
-
<div className="flex flex-col">
336
-
<div className="flex justify-between">
337
-
<div className="profileHeader flex gap-2 items-center">
338
-
<div className="font-bold">celine</div>
339
-
<a className="text-tertiary" href="/">
340
-
@{profile.handle}
341
-
</a>
342
-
</div>
343
-
</div>
344
-
345
-
<div className="profileBio text-secondary ">
346
-
{profile.description}
347
-
</div>
348
-
<div className="flex flex-row gap-2 items-center pt-2 font-bold">
349
-
{!profile.viewer?.following ? (
350
-
<div className="text-tertiary bg-border-light rounded-md px-1 py-0">
351
-
Following
352
-
</div>
353
-
) : (
354
-
<ButtonPrimary compact className="text-sm">
355
-
Follow <BlueskyTiny />
356
-
</ButtonPrimary>
357
-
)}
358
-
{profile.viewer?.followedBy && (
359
-
<div className="text-tertiary">Follows You</div>
360
-
)}
361
-
</div>
362
-
</div>
363
-
</div>
364
-
365
-
<hr className="my-2 border-border-light" />
366
-
<div className="flex gap-2 leading-tight items-center text-tertiary text-sm">
367
-
<div className="flex flex-col w-6 justify-center">
368
-
{profile.viewer?.knownFollowers?.followers.map((follower) => {
369
-
return (
370
-
<div
371
-
className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page"
372
-
key={follower.did}
373
-
/>
374
-
);
375
-
})}
376
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
377
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
378
-
</div>
379
-
</div>
380
-
</>
381
-
)}
382
-
</Popover>
383
-
</Media>*/}
384
-
</>
385
-
);
386
-
};
+6
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+6
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
9
9
import { decodeQuotePosition } from "../quotePosition";
10
10
11
11
export const InteractionDrawer = (props: {
12
+
showPageBackground: boolean | undefined;
12
13
document_uri: string;
13
14
quotesAndMentions: { uri: string; link?: string }[];
14
15
comments: Comment[];
···
38
39
<div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]">
39
40
<div
40
41
id="interaction-drawer"
41
-
className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] "
42
+
className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`}
42
43
>
43
44
{drawer.drawer === "quotes" ? (
44
45
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
···
58
59
export const useDrawerOpen = (uri: string) => {
59
60
let params = useSearchParams();
60
61
let interactionDrawerSearchParam = params.get("interactionDrawer");
62
+
let pageParam = params.get("page");
61
63
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
62
64
if (open === false || (open === undefined && !interactionDrawerSearchParam))
63
65
return null;
64
66
drawer =
65
67
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
66
-
return { drawer, pageId };
68
+
// Use pageId from state, or fall back to page search param
69
+
const resolvedPageId = pageId ?? pageParam ?? undefined;
70
+
return { drawer, pageId: resolvedPageId };
67
71
};
+229
-30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+229
-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;
111
+
showMentions?: boolean;
105
112
pageId?: string;
106
113
}) => {
107
114
const data = useContext(PostPageContext);
108
115
const document_uri = data?.uri;
116
+
let { identity } = useIdentityData();
109
117
if (!document_uri)
110
118
throw new Error("document_uri not available in PostPageContext");
111
119
···
117
125
}
118
126
};
119
127
128
+
const tags = (data?.data as any)?.tags as string[] | undefined;
129
+
const tagCount = tags?.length || 0;
130
+
120
131
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>
132
+
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
133
+
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
134
+
135
+
{props.quotesCount === 0 || props.showMentions === false ? null : (
136
+
<button
137
+
className="flex w-fit gap-2 items-center"
138
+
onClick={() => {
139
+
if (!drawerOpen || drawer !== "quotes")
140
+
openInteractionDrawer("quotes", document_uri, props.pageId);
141
+
else setInteractionState(document_uri, { drawerOpen: false });
142
+
}}
143
+
onMouseEnter={handleQuotePrefetch}
144
+
onTouchStart={handleQuotePrefetch}
145
+
aria-label="Post quotes"
146
+
>
147
+
<QuoteTiny aria-hidden /> {props.quotesCount}
148
+
</button>
149
+
)}
142
150
{props.showComments === false ? null : (
143
151
<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"}`}
152
+
className="flex gap-2 items-center w-fit"
145
153
onClick={() => {
146
154
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
147
155
openInteractionDrawer("comments", document_uri, props.pageId);
···
149
157
}}
150
158
aria-label="Post comments"
151
159
>
152
-
<CommentTiny aria-hidden /> {props.commentsCount}{" "}
153
-
{!props.compact && (
154
-
<span
155
-
aria-hidden
156
-
>{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span>
157
-
)}
160
+
<CommentTiny aria-hidden /> {props.commentsCount}
158
161
</button>
159
162
)}
160
163
</div>
161
164
);
162
165
};
163
166
167
+
export const ExpandedInteractions = (props: {
168
+
quotesCount: number;
169
+
commentsCount: number;
170
+
className?: string;
171
+
showComments?: boolean;
172
+
showMentions?: boolean;
173
+
pageId?: string;
174
+
}) => {
175
+
const data = useContext(PostPageContext);
176
+
let { identity } = useIdentityData();
177
+
178
+
const document_uri = data?.uri;
179
+
if (!document_uri)
180
+
throw new Error("document_uri not available in PostPageContext");
181
+
182
+
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
183
+
184
+
const handleQuotePrefetch = () => {
185
+
if (data?.quotesAndMentions) {
186
+
prefetchQuotesData(data.quotesAndMentions);
187
+
}
188
+
};
189
+
let publication = data?.documents_in_publications[0]?.publications;
190
+
191
+
const tags = (data?.data as any)?.tags as string[] | undefined;
192
+
const tagCount = tags?.length || 0;
193
+
194
+
let noInteractions = !props.showComments && !props.showMentions;
195
+
196
+
let subscribed =
197
+
identity?.atp_did &&
198
+
publication?.publication_subscriptions &&
199
+
publication?.publication_subscriptions.find(
200
+
(s) => s.identity === identity.atp_did,
201
+
);
202
+
203
+
let isAuthor =
204
+
identity &&
205
+
identity.atp_did ===
206
+
data.documents_in_publications[0]?.publications?.identity_did &&
207
+
data.leaflets_in_publications[0];
208
+
209
+
return (
210
+
<div
211
+
className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`}
212
+
>
213
+
{!subscribed && !isAuthor && publication && publication.record && (
214
+
<div className="text-center flex flex-col accent-container rounded-md mb-3">
215
+
<div className="flex flex-col py-4">
216
+
<div className="leading-snug flex flex-col pb-2 text-sm">
217
+
<div className="font-bold">Subscribe to {publication.name}</div>{" "}
218
+
to get updates in Reader, RSS, or via Bluesky Feed
219
+
</div>
220
+
<SubscribeWithBluesky
221
+
pubName={publication.name}
222
+
pub_uri={publication.uri}
223
+
base_url={getPublicationURL(publication)}
224
+
subscribers={publication?.publication_subscriptions}
225
+
/>
226
+
</div>
227
+
</div>
228
+
)}
229
+
{tagCount > 0 && (
230
+
<>
231
+
<hr className="border-border-light mb-3" />
232
+
233
+
<TagList tags={tags} className="mb-3" />
234
+
</>
235
+
)}
236
+
237
+
<hr className="border-border-light mb-3 " />
238
+
239
+
<div className="flex gap-2 justify-between">
240
+
{noInteractions ? (
241
+
<div />
242
+
) : (
243
+
<>
244
+
<div className="flex gap-2">
245
+
{props.quotesCount === 0 ||
246
+
props.showMentions === false ? null : (
247
+
<button
248
+
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
249
+
onClick={() => {
250
+
if (!drawerOpen || drawer !== "quotes")
251
+
openInteractionDrawer(
252
+
"quotes",
253
+
document_uri,
254
+
props.pageId,
255
+
);
256
+
else
257
+
setInteractionState(document_uri, { drawerOpen: false });
258
+
}}
259
+
onMouseEnter={handleQuotePrefetch}
260
+
onTouchStart={handleQuotePrefetch}
261
+
aria-label="Post quotes"
262
+
>
263
+
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
264
+
<span
265
+
aria-hidden
266
+
>{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span>
267
+
</button>
268
+
)}
269
+
{props.showComments === false ? null : (
270
+
<button
271
+
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
272
+
onClick={() => {
273
+
if (
274
+
!drawerOpen ||
275
+
drawer !== "comments" ||
276
+
pageId !== props.pageId
277
+
)
278
+
openInteractionDrawer(
279
+
"comments",
280
+
document_uri,
281
+
props.pageId,
282
+
);
283
+
else
284
+
setInteractionState(document_uri, { drawerOpen: false });
285
+
}}
286
+
aria-label="Post comments"
287
+
>
288
+
<CommentTiny aria-hidden />{" "}
289
+
{props.commentsCount > 0 ? (
290
+
<span aria-hidden>
291
+
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
292
+
</span>
293
+
) : (
294
+
"Comment"
295
+
)}
296
+
</button>
297
+
)}
298
+
</div>
299
+
</>
300
+
)}
301
+
302
+
<EditButton document={data} />
303
+
{subscribed && publication && (
304
+
<ManageSubscription
305
+
base_url={getPublicationURL(publication)}
306
+
pub_uri={publication.uri}
307
+
subscribers={publication.publication_subscriptions}
308
+
/>
309
+
)}
310
+
</div>
311
+
</div>
312
+
);
313
+
};
314
+
315
+
const TagPopover = (props: {
316
+
tagCount: number;
317
+
tags: string[] | undefined;
318
+
}) => {
319
+
return (
320
+
<Popover
321
+
className="p-2! max-w-xs"
322
+
trigger={
323
+
<div className="tags flex gap-1 items-center ">
324
+
<TagTiny /> {props.tagCount}
325
+
</div>
326
+
}
327
+
>
328
+
<TagList tags={props.tags} className="text-secondary!" />
329
+
</Popover>
330
+
);
331
+
};
332
+
333
+
const TagList = (props: { className?: string; tags: string[] | undefined }) => {
334
+
if (!props.tags) return;
335
+
return (
336
+
<div className="flex gap-1 flex-wrap">
337
+
{props.tags.map((tag, index) => (
338
+
<Tag name={tag} key={index} className={props.className} />
339
+
))}
340
+
</div>
341
+
);
342
+
};
164
343
export function getQuoteCount(document: PostPageData, pageId?: string) {
165
344
if (!document) return;
166
345
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
···
198
377
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
199
378
).length;
200
379
}
380
+
381
+
const EditButton = (props: { document: PostPageData }) => {
382
+
let { identity } = useIdentityData();
383
+
if (!props.document) return;
384
+
if (
385
+
identity &&
386
+
identity.atp_did ===
387
+
props.document.documents_in_publications[0]?.publications?.identity_did &&
388
+
props.document.leaflets_in_publications[0]
389
+
)
390
+
return (
391
+
<a
392
+
href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`}
393
+
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"
394
+
>
395
+
<EditTiny /> Edit Post
396
+
</a>
397
+
);
398
+
return;
399
+
};
+58
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+58
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
4
4
import { useIsMobile } from "src/hooks/isMobile";
5
5
import { setInteractionState } from "./Interactions";
6
6
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
7
-
import { AtUri } from "@atproto/api";
7
+
import { AtUri, AppBskyFeedPost } from "@atproto/api";
8
8
import { PostPageContext } from "../PostPageContext";
9
9
import {
10
10
PubLeafletBlocksText,
···
22
22
import { openPage } from "../PostPages";
23
23
import useSWR, { mutate } from "swr";
24
24
import { DotLoader } from "components/utils/DotLoader";
25
+
import { CommentTiny } from "components/Icons/CommentTiny";
26
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
+
import { ThreadLink, QuotesLink } from "../PostLinks";
25
28
26
29
// Helper to get SWR key for quotes
27
30
export function getQuotesSWRKey(uris: string[]) {
···
129
132
130
133
<div className="h-5 w-1 ml-5 border-l border-border-light" />
131
134
<BskyPost
135
+
uri={pv.uri}
132
136
rkey={new AtUri(pv.uri).rkey}
133
137
content={pv.record.text as string}
134
138
user={pv.author.displayName || pv.author.handle}
135
139
profile={pv.author}
136
140
handle={pv.author.handle}
141
+
replyCount={pv.replyCount}
142
+
quoteCount={pv.quoteCount}
137
143
/>
138
144
</div>
139
145
);
···
150
156
return (
151
157
<BskyPost
152
158
key={`mention-${index}`}
159
+
uri={pv.uri}
153
160
rkey={new AtUri(pv.uri).rkey}
154
161
content={pv.record.text as string}
155
162
user={pv.author.displayName || pv.author.handle}
156
163
profile={pv.author}
157
164
handle={pv.author.handle}
165
+
replyCount={pv.replyCount}
166
+
quoteCount={pv.quoteCount}
158
167
/>
159
168
);
160
169
})}
···
174
183
}) => {
175
184
let isMobile = useIsMobile();
176
185
const data = useContext(PostPageContext);
186
+
const document_uri = data?.uri;
177
187
178
188
let record = data?.data as PubLeafletDocument.Record;
179
189
let page: PubLeafletPagesLinearDocument.Main | undefined = (
···
201
211
className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer"
202
212
onClick={(e) => {
203
213
if (props.position.pageId)
204
-
flushSync(() => openPage(undefined, props.position.pageId!));
214
+
flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! }));
205
215
let scrollMargin = isMobile
206
216
? 16
207
217
: e.currentTarget.getBoundingClientRect().top;
208
-
let scrollContainer = window.document.getElementById("post-page");
218
+
let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`;
219
+
let scrollContainer = window.document.getElementById(scrollContainerId);
209
220
let el = window.document.getElementById(
210
221
props.position.start.block.join("."),
211
222
);
···
239
250
};
240
251
241
252
export const BskyPost = (props: {
253
+
uri: string;
242
254
rkey: string;
243
255
content: string;
244
256
user: string;
245
257
handle: string;
246
258
profile: ProfileViewBasic;
259
+
replyCount?: number;
260
+
quoteCount?: number;
247
261
}) => {
262
+
const handleOpenThread = () => {
263
+
openPage(undefined, { type: "thread", uri: props.uri });
264
+
};
265
+
248
266
return (
249
-
<a
250
-
target="_blank"
251
-
href={`https://bsky.app/profile/${props.handle}/post/${props.rkey}`}
252
-
className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal"
267
+
<div
268
+
onClick={handleOpenThread}
269
+
className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded"
253
270
>
254
271
{props.profile.avatar && (
255
272
<img
256
-
className="rounded-full w-6 h-6"
273
+
className="rounded-full w-6 h-6 shrink-0"
257
274
src={props.profile.avatar}
258
275
alt={props.profile.displayName}
259
276
/>
260
277
)}
261
-
<div className="flex flex-col">
262
-
<div className="flex items-center gap-2">
278
+
<div className="flex flex-col min-w-0">
279
+
<div className="flex items-center gap-2 flex-wrap">
263
280
<div className="font-bold">{props.user}</div>
264
-
<div className="text-tertiary">@{props.handle}</div>
281
+
<a
282
+
className="text-tertiary hover:underline"
283
+
href={`https://bsky.app/profile/${props.handle}`}
284
+
target="_blank"
285
+
onClick={(e) => e.stopPropagation()}
286
+
>
287
+
@{props.handle}
288
+
</a>
265
289
</div>
266
290
<div className="text-primary">{props.content}</div>
291
+
<div className="flex gap-2 items-center mt-1">
292
+
{props.replyCount != null && props.replyCount > 0 && (
293
+
<ThreadLink
294
+
threadUri={props.uri}
295
+
onClick={(e) => e.stopPropagation()}
296
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
297
+
>
298
+
<CommentTiny />
299
+
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
300
+
</ThreadLink>
301
+
)}
302
+
{props.quoteCount != null && props.quoteCount > 0 && (
303
+
<QuotesLink
304
+
postUri={props.uri}
305
+
onClick={(e) => e.stopPropagation()}
306
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
307
+
>
308
+
<QuoteTiny />
309
+
{props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"}
310
+
</QuotesLink>
311
+
)}
312
+
</div>
267
313
</div>
268
-
</a>
314
+
</div>
269
315
);
270
316
};
271
317
+11
-43
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+11
-43
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
-
Interactions,
17
17
} from "./Interactions/Interactions";
18
18
import { PostContent } from "./PostContent";
19
19
import { PostHeader } from "./PostHeader/PostHeader";
···
24
24
import { decodeQuotePosition } from "./quotePosition";
25
25
import { PollData } from "./fetchPollData";
26
26
import { SharedPageProps } from "./PostPages";
27
+
import { PostPrevNextButtons } from "./PostPrevNextButtons";
27
28
28
29
export function LinearDocumentPage({
29
30
blocks,
···
47
48
fullPageScroll,
48
49
hasPageBackground,
49
50
} = props;
50
-
let { identity } = useIdentityData();
51
51
let drawer = useDrawerOpen(document_uri);
52
52
53
53
if (!document) return null;
···
56
56
57
57
const isSubpage = !!pageId;
58
58
59
+
console.log("prev/next?: " + preferences.showPrevNext);
60
+
59
61
return (
60
62
<>
61
63
<PageWrapper
62
64
pageType="doc"
63
65
fullPageScroll={fullPageScroll}
64
-
cardBorderHidden={!hasPageBackground}
65
-
id={pageId ? `post-page-${pageId}` : "post-page"}
66
+
id={`post-page-${pageId ?? document_uri}`}
66
67
drawerOpen={
67
68
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
68
69
}
···
84
85
did={did}
85
86
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
87
/>
87
-
<Interactions
88
+
<PostPrevNextButtons
89
+
showPrevNext={preferences.showPrevNext && !isSubpage}
90
+
/>
91
+
<ExpandedInteractions
88
92
pageId={pageId}
89
93
showComments={preferences.showComments}
94
+
showMentions={preferences.showMentions}
90
95
commentsCount={getCommentCount(document, pageId) || 0}
91
96
quotesCount={getQuoteCount(document, pageId) || 0}
92
97
/>
93
-
{!isSubpage && (
94
-
<>
95
-
<hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" />
96
-
<div className="sm:px-4 px-3">
97
-
{identity &&
98
-
identity.atp_did ===
99
-
document.documents_in_publications[0]?.publications
100
-
?.identity_did &&
101
-
document.leaflets_in_publications[0] ? (
102
-
<a
103
-
href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`}
104
-
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"
105
-
>
106
-
<EditTiny /> Edit Post
107
-
</a>
108
-
) : (
109
-
document.documents_in_publications[0]?.publications && (
110
-
<SubscribeWithBluesky
111
-
isPost
112
-
base_url={getPublicationURL(
113
-
document.documents_in_publications[0].publications,
114
-
)}
115
-
pub_uri={
116
-
document.documents_in_publications[0].publications.uri
117
-
}
118
-
subscribers={
119
-
document.documents_in_publications[0].publications
120
-
.publication_subscriptions
121
-
}
122
-
pubName={
123
-
document.documents_in_publications[0].publications.name
124
-
}
125
-
/>
126
-
)
127
-
)}
128
-
</div>
129
-
</>
130
-
)}
98
+
{!hasPageBackground && <div className={`spacer h-8 w-full`} />}
131
99
</PageWrapper>
132
100
</>
133
101
);
+12
-9
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+12
-9
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 (
···
173
173
let uri = b.block.postRef.uri;
174
174
let post = bskyPostData.find((p) => p.uri === uri);
175
175
if (!post) return <div>no prefetched post rip</div>;
176
-
return <PubBlueskyPostBlock post={post} className={className} />;
176
+
return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />;
177
177
}
178
178
case PubLeafletBlocksIframe.isMain(b.block): {
179
179
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
-
// };
+64
-32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+64
-32
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";
21
+
import { ProfilePopover } from "components/ProfilePopover";
19
22
20
23
export function PostHeader(props: {
21
24
data: PostPageData;
22
25
profile: ProfileViewDetailed;
23
-
preferences: { showComments?: boolean };
26
+
preferences: { showComments?: boolean; showMentions?: boolean };
24
27
}) {
25
28
let { identity } = useIdentityData();
26
29
let document = props.data;
···
40
43
41
44
if (!document?.data) return;
42
45
return (
43
-
<div
44
-
className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2"
45
-
id="post-header"
46
-
>
47
-
<div className="pubHeader flex flex-col pb-5">
48
-
<div className="flex justify-between w-full">
46
+
<PostHeaderLayout
47
+
pubLink={
48
+
<>
49
49
{pub && (
50
50
<SpeedyLink
51
51
className="font-bold hover:no-underline text-accent-contrast"
···
65
65
<EditTiny className="shrink-0" />
66
66
</a>
67
67
)}
68
-
</div>
69
-
<h2 className="">{record.title}</h2>
70
-
{record.description ? (
71
-
<p className="italic text-secondary">{record.description}</p>
72
-
) : null}
73
-
74
-
<div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap">
75
-
{profile ? (
76
-
<>
77
-
<a
78
-
className="text-tertiary"
79
-
href={`https://bsky.app/profile/${profile.handle}`}
80
-
>
81
-
by {profile.displayName || profile.handle}
82
-
</a>
83
-
</>
84
-
) : null}
85
-
{record.publishedAt ? (
86
-
<>
87
-
|<p>{formattedDate}</p>
88
-
</>
89
-
) : null}
90
-
|{" "}
68
+
</>
69
+
}
70
+
postTitle={record.title}
71
+
postDescription={record.description}
72
+
postInfo={
73
+
<>
74
+
<div className="flex flex-row gap-2 items-center">
75
+
{profile ? (
76
+
<ProfilePopover
77
+
didOrHandle={profile.did}
78
+
trigger={
79
+
<span className="text-tertiary hover:underline">
80
+
{profile.displayName || profile.handle}
81
+
</span>
82
+
}
83
+
/>
84
+
) : null}
85
+
{record.publishedAt ? (
86
+
<>
87
+
<Separator classname="h-4!" />
88
+
<p>{formattedDate}</p>
89
+
</>
90
+
) : null}
91
+
</div>
91
92
<Interactions
92
93
showComments={props.preferences.showComments}
93
-
compact
94
+
showMentions={props.preferences.showMentions}
94
95
quotesCount={getQuoteCount(document) || 0}
95
96
commentsCount={getCommentCount(document) || 0}
96
97
/>
98
+
</>
99
+
}
100
+
/>
101
+
);
102
+
}
103
+
104
+
export const PostHeaderLayout = (props: {
105
+
pubLink: React.ReactNode;
106
+
postTitle: React.ReactNode | undefined;
107
+
postDescription: React.ReactNode | undefined;
108
+
postInfo: React.ReactNode;
109
+
}) => {
110
+
return (
111
+
<div
112
+
className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
113
+
id="post-header"
114
+
>
115
+
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
116
+
{props.pubLink}
117
+
</div>
118
+
<h2
119
+
className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`}
120
+
>
121
+
{props.postTitle ? props.postTitle : "Untitled"}
122
+
</h2>
123
+
{props.postDescription ? (
124
+
<div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
125
+
{props.postDescription}
97
126
</div>
127
+
) : null}
128
+
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
129
+
{props.postInfo}
98
130
</div>
99
131
</div>
100
132
);
101
-
}
133
+
};
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
···
1
+
"use client";
2
+
import { AppBskyFeedDefs } from "@atproto/api";
3
+
import { preload } from "swr";
4
+
import { openPage, OpenPage } from "./PostPages";
5
+
6
+
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
7
+
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
8
+
type BlockedPost = AppBskyFeedDefs.BlockedPost;
9
+
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
10
+
11
+
type PostView = AppBskyFeedDefs.PostView;
12
+
13
+
export interface QuotesResponse {
14
+
uri: string;
15
+
cid?: string;
16
+
cursor?: string;
17
+
posts: PostView[];
18
+
}
19
+
20
+
// Thread fetching
21
+
export const getThreadKey = (uri: string) => `thread:${uri}`;
22
+
23
+
export async function fetchThread(uri: string): Promise<ThreadType> {
24
+
const params = new URLSearchParams({ uri });
25
+
const response = await fetch(`/api/bsky/thread?${params.toString()}`);
26
+
27
+
if (!response.ok) {
28
+
throw new Error("Failed to fetch thread");
29
+
}
30
+
31
+
return response.json();
32
+
}
33
+
34
+
export const prefetchThread = (uri: string) => {
35
+
preload(getThreadKey(uri), () => fetchThread(uri));
36
+
};
37
+
38
+
// Quotes fetching
39
+
export const getQuotesKey = (uri: string) => `quotes:${uri}`;
40
+
41
+
export async function fetchQuotes(uri: string): Promise<QuotesResponse> {
42
+
const params = new URLSearchParams({ uri });
43
+
const response = await fetch(`/api/bsky/quotes?${params.toString()}`);
44
+
45
+
if (!response.ok) {
46
+
throw new Error("Failed to fetch quotes");
47
+
}
48
+
49
+
return response.json();
50
+
}
51
+
52
+
export const prefetchQuotes = (uri: string) => {
53
+
preload(getQuotesKey(uri), () => fetchQuotes(uri));
54
+
};
55
+
56
+
// Link component for opening thread pages with prefetching
57
+
export function ThreadLink(props: {
58
+
threadUri: string;
59
+
parent?: OpenPage;
60
+
children: React.ReactNode;
61
+
className?: string;
62
+
onClick?: (e: React.MouseEvent) => void;
63
+
}) {
64
+
const { threadUri, parent, children, className, onClick } = props;
65
+
66
+
const handleClick = (e: React.MouseEvent) => {
67
+
onClick?.(e);
68
+
if (e.defaultPrevented) return;
69
+
openPage(parent, { type: "thread", uri: threadUri });
70
+
};
71
+
72
+
const handlePrefetch = () => {
73
+
prefetchThread(threadUri);
74
+
};
75
+
76
+
return (
77
+
<button
78
+
className={className}
79
+
onClick={handleClick}
80
+
onMouseEnter={handlePrefetch}
81
+
onPointerDown={handlePrefetch}
82
+
>
83
+
{children}
84
+
</button>
85
+
);
86
+
}
87
+
88
+
// Link component for opening quotes pages with prefetching
89
+
export function QuotesLink(props: {
90
+
postUri: string;
91
+
parent?: OpenPage;
92
+
children: React.ReactNode;
93
+
className?: string;
94
+
onClick?: (e: React.MouseEvent) => void;
95
+
}) {
96
+
const { postUri, parent, children, className, onClick } = props;
97
+
98
+
const handleClick = (e: React.MouseEvent) => {
99
+
onClick?.(e);
100
+
if (e.defaultPrevented) return;
101
+
openPage(parent, { type: "quotes", uri: postUri });
102
+
};
103
+
104
+
const handlePrefetch = () => {
105
+
prefetchQuotes(postUri);
106
+
};
107
+
108
+
return (
109
+
<button
110
+
className={className}
111
+
onClick={handleClick}
112
+
onMouseEnter={handlePrefetch}
113
+
onPointerDown={handlePrefetch}
114
+
>
115
+
{children}
116
+
</button>
117
+
);
118
+
}
+130
-28
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+130
-28
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
19
19
import { Fragment, useEffect } from "react";
20
20
import { flushSync } from "react-dom";
21
21
import { scrollIntoView } from "src/utils/scrollIntoView";
22
-
import { useParams } from "next/navigation";
22
+
import { useParams, useSearchParams } from "next/navigation";
23
23
import { decodeQuotePosition } from "./quotePosition";
24
24
import { PollData } from "./fetchPollData";
25
25
import { LinearDocumentPage } from "./LinearDocumentPage";
26
26
import { CanvasPage } from "./CanvasPage";
27
+
import { ThreadPage as ThreadPageComponent } from "./ThreadPage";
28
+
import { BlueskyQuotesPage } from "./BlueskyQuotesPage";
29
+
30
+
// Page types
31
+
export type DocPage = { type: "doc"; id: string };
32
+
export type ThreadPage = { type: "thread"; uri: string };
33
+
export type QuotesPage = { type: "quotes"; uri: string };
34
+
export type OpenPage = DocPage | ThreadPage | QuotesPage;
35
+
36
+
// Get a stable key for a page
37
+
const getPageKey = (page: OpenPage): string => {
38
+
if (page.type === "doc") return page.id;
39
+
if (page.type === "quotes") return `quotes:${page.uri}`;
40
+
return `thread:${page.uri}`;
41
+
};
27
42
28
43
const usePostPageUIState = create(() => ({
29
-
pages: [] as string[],
44
+
pages: [] as OpenPage[],
30
45
initialized: false,
31
46
}));
32
47
33
-
export const useOpenPages = () => {
48
+
export const useOpenPages = (): OpenPage[] => {
34
49
const { quote } = useParams();
35
50
const state = usePostPageUIState((s) => s);
51
+
const searchParams = useSearchParams();
52
+
const pageParam = searchParams.get("page");
36
53
37
-
if (!state.initialized && quote) {
38
-
const decodedQuote = decodeQuotePosition(quote as string);
39
-
if (decodedQuote?.pageId) {
40
-
return [decodedQuote.pageId];
54
+
if (!state.initialized) {
55
+
// Check for page search param first (for comment links)
56
+
if (pageParam) {
57
+
return [{ type: "doc", id: pageParam }];
58
+
}
59
+
// Then check for quote param
60
+
if (quote) {
61
+
const decodedQuote = decodeQuotePosition(quote as string);
62
+
if (decodedQuote?.pageId) {
63
+
return [{ type: "doc", id: decodedQuote.pageId }];
64
+
}
41
65
}
42
66
}
43
67
···
46
70
47
71
export const useInitializeOpenPages = () => {
48
72
const { quote } = useParams();
73
+
const searchParams = useSearchParams();
74
+
const pageParam = searchParams.get("page");
49
75
50
76
useEffect(() => {
51
77
const state = usePostPageUIState.getState();
52
78
if (!state.initialized) {
79
+
// Check for page search param first (for comment links)
80
+
if (pageParam) {
81
+
usePostPageUIState.setState({
82
+
pages: [{ type: "doc", id: pageParam }],
83
+
initialized: true,
84
+
});
85
+
return;
86
+
}
87
+
// Then check for quote param
53
88
if (quote) {
54
89
const decodedQuote = decodeQuotePosition(quote as string);
55
90
if (decodedQuote?.pageId) {
56
91
usePostPageUIState.setState({
57
-
pages: [decodedQuote.pageId],
92
+
pages: [{ type: "doc", id: decodedQuote.pageId }],
58
93
initialized: true,
59
94
});
60
95
return;
···
63
98
// Mark as initialized even if no pageId found
64
99
usePostPageUIState.setState({ initialized: true });
65
100
}
66
-
}, [quote]);
101
+
}, [quote, pageParam]);
67
102
};
68
103
69
104
export const openPage = (
70
-
parent: string | undefined,
71
-
page: string,
105
+
parent: OpenPage | undefined,
106
+
page: OpenPage,
72
107
options?: { scrollIntoView?: boolean },
73
108
) => {
109
+
const pageKey = getPageKey(page);
110
+
const parentKey = parent ? getPageKey(parent) : undefined;
111
+
74
112
flushSync(() => {
75
113
usePostPageUIState.setState((state) => {
76
-
let parentPosition = state.pages.findIndex((s) => s == parent);
114
+
let parentPosition = state.pages.findIndex(
115
+
(s) => getPageKey(s) === parentKey,
116
+
);
77
117
return {
78
118
pages:
79
119
parentPosition === -1
···
85
125
});
86
126
87
127
if (options?.scrollIntoView !== false) {
88
-
scrollIntoView(`post-page-${page}`);
128
+
scrollIntoView(`post-page-${pageKey}`);
89
129
}
90
130
};
91
131
92
-
export const closePage = (page: string) =>
132
+
export const closePage = (page: OpenPage) => {
133
+
const pageKey = getPageKey(page);
93
134
usePostPageUIState.setState((state) => {
94
-
let parentPosition = state.pages.findIndex((s) => s == page);
135
+
let parentPosition = state.pages.findIndex(
136
+
(s) => getPageKey(s) === pageKey,
137
+
);
95
138
return {
96
139
pages: state.pages.slice(0, parentPosition),
97
140
initialized: true,
98
141
};
99
142
});
143
+
};
100
144
101
145
// Shared props type for both page components
102
146
export type SharedPageProps = {
103
147
document: PostPageData;
104
148
did: string;
105
149
profile: ProfileViewDetailed;
106
-
preferences: { showComments?: boolean };
150
+
preferences: {
151
+
showComments?: boolean;
152
+
showMentions?: boolean;
153
+
showPrevNext?: boolean;
154
+
};
107
155
pubRecord?: PubLeafletPublication.Record;
108
156
theme?: PubLeafletPublication.Theme | null;
109
157
prerenderedCodeBlocks?: Map<string, string>;
···
162
210
did: string;
163
211
prerenderedCodeBlocks?: Map<string, string>;
164
212
bskyPostData: AppBskyFeedDefs.PostView[];
165
-
preferences: { showComments?: boolean };
213
+
preferences: {
214
+
showComments?: boolean;
215
+
showMentions?: boolean;
216
+
showPrevNext?: boolean;
217
+
};
166
218
pollData: PollData[];
167
219
}) {
168
220
let drawer = useDrawerOpen(document_uri);
···
217
269
218
270
{drawer && !drawer.pageId && (
219
271
<InteractionDrawer
272
+
showPageBackground={pubRecord?.theme?.showPageBackground}
220
273
document_uri={document.uri}
221
274
comments={
222
275
pubRecord?.preferences?.showComments === false
223
276
? []
224
277
: document.comments_on_documents
225
278
}
226
-
quotesAndMentions={quotesAndMentions}
279
+
quotesAndMentions={
280
+
pubRecord?.preferences?.showMentions === false
281
+
? []
282
+
: quotesAndMentions
283
+
}
227
284
did={did}
228
285
/>
229
286
)}
230
287
231
-
{openPageIds.map((pageId) => {
288
+
{openPageIds.map((openPage) => {
289
+
const pageKey = getPageKey(openPage);
290
+
291
+
// Handle thread pages
292
+
if (openPage.type === "thread") {
293
+
return (
294
+
<Fragment key={pageKey}>
295
+
<SandwichSpacer />
296
+
<ThreadPageComponent
297
+
threadUri={openPage.uri}
298
+
pageId={pageKey}
299
+
hasPageBackground={hasPageBackground}
300
+
pageOptions={
301
+
<PageOptions
302
+
onClick={() => closePage(openPage)}
303
+
hasPageBackground={hasPageBackground}
304
+
/>
305
+
}
306
+
/>
307
+
</Fragment>
308
+
);
309
+
}
310
+
311
+
// Handle quotes pages
312
+
if (openPage.type === "quotes") {
313
+
return (
314
+
<Fragment key={pageKey}>
315
+
<SandwichSpacer />
316
+
<BlueskyQuotesPage
317
+
postUri={openPage.uri}
318
+
pageId={pageKey}
319
+
hasPageBackground={hasPageBackground}
320
+
pageOptions={
321
+
<PageOptions
322
+
onClick={() => closePage(openPage)}
323
+
hasPageBackground={hasPageBackground}
324
+
/>
325
+
}
326
+
/>
327
+
</Fragment>
328
+
);
329
+
}
330
+
331
+
// Handle document pages
232
332
let page = record.pages.find(
233
333
(p) =>
234
334
(
235
335
p as
236
336
| PubLeafletPagesLinearDocument.Main
237
337
| PubLeafletPagesCanvas.Main
238
-
).id === pageId,
338
+
).id === openPage.id,
239
339
) as
240
340
| PubLeafletPagesLinearDocument.Main
241
341
| PubLeafletPagesCanvas.Main
···
244
344
if (!page) return null;
245
345
246
346
return (
247
-
<Fragment key={pageId}>
347
+
<Fragment key={pageKey}>
248
348
<SandwichSpacer />
249
349
<PageRenderer
250
350
page={page}
···
253
353
pageId={page.id}
254
354
pageOptions={
255
355
<PageOptions
256
-
onClick={() => closePage(page.id!)}
356
+
onClick={() => closePage(openPage)}
257
357
hasPageBackground={hasPageBackground}
258
358
/>
259
359
}
260
360
/>
261
361
{drawer && drawer.pageId === page.id && (
262
362
<InteractionDrawer
363
+
showPageBackground={pubRecord?.theme?.showPageBackground}
263
364
pageId={page.id}
264
365
document_uri={document.uri}
265
366
comments={
···
267
368
? []
268
369
: document.comments_on_documents
269
370
}
270
-
quotesAndMentions={quotesAndMentions}
371
+
quotesAndMentions={
372
+
pubRecord?.preferences?.showMentions === false
373
+
? []
374
+
: quotesAndMentions
375
+
}
271
376
did={did}
272
377
/>
273
378
)}
···
287
392
return (
288
393
<div
289
394
className={`pageOptions w-fit z-10
290
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
395
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
291
396
flex sm:flex-col flex-row-reverse gap-1 items-start`}
292
397
>
293
-
<PageOptionButton
294
-
cardBorderHidden={!props.hasPageBackground}
295
-
onClick={props.onClick}
296
-
>
398
+
<PageOptionButton onClick={props.onClick}>
297
399
<CloseTiny />
298
400
</PageOptionButton>
299
401
</div>
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
···
1
+
"use client";
2
+
import { PubLeafletDocument } from "lexicons/api";
3
+
import { usePublicationData } from "../dashboard/PublicationSWRProvider";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
import { AtUri } from "@atproto/api";
6
+
import { useParams } from "next/navigation";
7
+
import { getPostPageData } from "./getPostPageData";
8
+
import { PostPageContext } from "./PostPageContext";
9
+
import { useContext } from "react";
10
+
import { SpeedyLink } from "components/SpeedyLink";
11
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
12
+
13
+
export const PostPrevNextButtons = (props: {
14
+
showPrevNext: boolean | undefined;
15
+
}) => {
16
+
let postData = useContext(PostPageContext);
17
+
let pub = postData?.documents_in_publications[0]?.publications;
18
+
19
+
if (!props.showPrevNext || !pub || !postData) return;
20
+
21
+
function getPostLink(uri: string) {
22
+
return pub && uri
23
+
? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}`
24
+
: "leaflet.pub/not-found";
25
+
}
26
+
let prevPost = postData?.prevNext?.prev;
27
+
let nextPost = postData?.prevNext?.next;
28
+
29
+
return (
30
+
<div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
31
+
{/*<hr className="border-border-light" />*/}
32
+
<div className="flex justify-between w-full gap-8 ">
33
+
{nextPost ? (
34
+
<SpeedyLink
35
+
href={getPostLink(nextPost.uri)}
36
+
className="flex gap-1 items-center truncate min-w-0 basis-1/2"
37
+
>
38
+
<ArrowRightTiny className="rotate-180 shrink-0" />
39
+
<div className="min-w-0 truncate">{nextPost.title}</div>
40
+
</SpeedyLink>
41
+
) : (
42
+
<div />
43
+
)}
44
+
{prevPost ? (
45
+
<SpeedyLink
46
+
href={getPostLink(prevPost.uri)}
47
+
className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end"
48
+
>
49
+
<div className="min-w-0 truncate">{prevPost.title}</div>
50
+
<ArrowRightTiny className="shrink-0" />
51
+
</SpeedyLink>
52
+
) : (
53
+
<div />
54
+
)}
55
+
</div>
56
+
</div>
57
+
);
58
+
};
+48
-18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
+48
-18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
1
1
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
-
import { useEntitySetContext } from "components/EntitySetProvider";
3
-
import { useEffect, useState } from "react";
4
-
import { useEntity } from "src/replicache";
5
-
import { useUIState } from "src/useUIState";
6
-
import { elementId } from "src/utils/elementId";
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
9
3
import { Separator } from "components/Layout";
10
4
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
11
5
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
12
6
import { CommentTiny } from "components/Icons/CommentTiny";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { ThreadLink, QuotesLink } from "./PostLinks";
13
9
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
14
10
import {
15
11
BlueskyEmbed,
16
12
PostNotAvailable,
17
13
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
18
14
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
15
+
import { openPage } from "./PostPages";
19
16
20
17
export const PubBlueskyPostBlock = (props: {
21
18
post: PostView;
22
19
className: string;
20
+
pageId?: string;
23
21
}) => {
24
22
let post = props.post;
23
+
24
+
const handleOpenThread = () => {
25
+
openPage(
26
+
props.pageId ? { type: "doc", id: props.pageId } : undefined,
27
+
{ type: "thread", uri: post.uri },
28
+
);
29
+
};
30
+
25
31
switch (true) {
26
32
case AppBskyFeedDefs.isBlockedPost(post) ||
27
33
AppBskyFeedDefs.isBlockedAuthor(post) ||
···
34
40
35
41
case AppBskyFeedDefs.validatePostView(post).success:
36
42
let record = post.record as AppBskyFeedDefs.PostView["record"];
37
-
let facets = record.facets;
38
43
39
44
// silliness to get the text and timestamp from the record with proper types
40
-
let text: string | null = null;
41
45
let timestamp: string | undefined = undefined;
42
46
if (AppBskyFeedPost.isRecord(record)) {
43
-
text = (record as AppBskyFeedPost.Record).text;
44
47
timestamp = (record as AppBskyFeedPost.Record).createdAt;
45
48
}
46
49
47
50
//getting the url to the post
48
51
let postId = post.uri.split("/")[4];
49
52
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
53
+
54
+
const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined;
50
55
51
56
return (
52
57
<div
58
+
onClick={handleOpenThread}
53
59
className={`
54
60
${props.className}
55
61
block-border
56
62
mb-2
57
63
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
64
+
cursor-pointer hover:border-accent-contrast
58
65
`}
59
66
>
60
67
{post.author && record && (
···
75
82
className="text-xs text-tertiary hover:underline"
76
83
target="_blank"
77
84
href={`https://bsky.app/profile/${post.author?.handle}`}
85
+
onClick={(e) => e.stopPropagation()}
78
86
>
79
87
@{post.author?.handle}
80
88
</a>
···
90
98
</pre>
91
99
</div>
92
100
{post.embed && (
93
-
<BlueskyEmbed embed={post.embed} postUrl={url} />
101
+
<div onClick={(e) => e.stopPropagation()}>
102
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
103
+
</div>
94
104
)}
95
105
</div>
96
106
</>
···
98
108
<div className="w-full flex gap-2 items-center justify-between">
99
109
<ClientDate date={timestamp} />
100
110
<div className="flex gap-2 items-center">
101
-
{post.replyCount && post.replyCount > 0 && (
111
+
{post.replyCount != null && post.replyCount > 0 && (
102
112
<>
103
-
<a
104
-
className="flex items-center gap-1 hover:no-underline"
105
-
target="_blank"
106
-
href={url}
113
+
<ThreadLink
114
+
threadUri={post.uri}
115
+
parent={parent}
116
+
className="flex items-center gap-1 hover:text-accent-contrast"
117
+
onClick={(e) => e.stopPropagation()}
107
118
>
108
119
{post.replyCount}
109
120
<CommentTiny />
110
-
</a>
121
+
</ThreadLink>
122
+
<Separator classname="h-4" />
123
+
</>
124
+
)}
125
+
{post.quoteCount != null && post.quoteCount > 0 && (
126
+
<>
127
+
<QuotesLink
128
+
postUri={post.uri}
129
+
parent={parent}
130
+
className="flex items-center gap-1 hover:text-accent-contrast"
131
+
onClick={(e) => e.stopPropagation()}
132
+
>
133
+
{post.quoteCount}
134
+
<QuoteTiny />
135
+
</QuotesLink>
111
136
<Separator classname="h-4" />
112
137
</>
113
138
)}
114
139
115
-
<a className="" target="_blank" href={url}>
140
+
<a
141
+
className=""
142
+
target="_blank"
143
+
href={url}
144
+
onClick={(e) => e.stopPropagation()}
145
+
>
116
146
<BlueskyTiny />
117
147
</a>
118
148
</div>
+17
-8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
+17
-8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
40
40
}) {
41
41
//switch to use actually state
42
42
let openPages = useOpenPages();
43
-
let isOpen = openPages.includes(props.pageId);
43
+
let isOpen = openPages.some(
44
+
(p) => p.type === "doc" && p.id === props.pageId,
45
+
);
44
46
return (
45
47
<div
46
48
className={`w-full cursor-pointer
···
57
59
e.preventDefault();
58
60
e.stopPropagation();
59
61
60
-
openPage(props.parentPageId, props.pageId);
62
+
openPage(
63
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
64
+
{ type: "doc", id: props.pageId },
65
+
);
61
66
}}
62
67
>
63
68
{props.isCanvas ? (
···
213
218
onClick={(e) => {
214
219
e.preventDefault();
215
220
e.stopPropagation();
216
-
openPage(props.parentPageId, props.pageId, {
217
-
scrollIntoView: false,
218
-
});
221
+
openPage(
222
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
223
+
{ type: "doc", id: props.pageId },
224
+
{ scrollIntoView: false },
225
+
);
219
226
if (!drawerOpen || drawer !== "quotes")
220
227
openInteractionDrawer("quotes", document_uri, props.pageId);
221
228
else setInteractionState(document_uri, { drawerOpen: false });
···
231
238
onClick={(e) => {
232
239
e.preventDefault();
233
240
e.stopPropagation();
234
-
openPage(props.parentPageId, props.pageId, {
235
-
scrollIntoView: false,
236
-
});
241
+
openPage(
242
+
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
243
+
{ type: "doc", id: props.pageId },
244
+
{ scrollIntoView: false },
245
+
);
237
246
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
238
247
openInteractionDrawer("comments", document_uri, props.pageId);
239
248
else setInteractionState(document_uri, { drawerOpen: false });
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
···
186
186
<BlueskyLinkTiny className="shrink-0" />
187
187
Bluesky
188
188
</a>
189
-
<Separator classname="h-4" />
189
+
<Separator classname="h-4!" />
190
190
<button
191
191
id="copy-quote-link"
192
192
className="flex gap-1 items-center hover:font-bold px-1"
···
211
211
</button>
212
212
{pubRecord?.preferences?.showComments !== false && identity?.atp_did && (
213
213
<>
214
-
<Separator classname="h-4" />
214
+
<Separator classname="h-4! " />
215
+
215
216
<button
216
217
className="flex gap-1 items-center hover:font-bold px-1"
217
218
onClick={() => {
+11
-7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+11
-7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
···
12
12
PubLeafletPagesLinearDocument,
13
13
} from "lexicons/api";
14
14
import { blobRefToSrc } from "src/utils/blobRefToSrc";
15
-
import { BaseTextBlock } from "./BaseTextBlock";
15
+
import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore";
16
16
import { StaticMathBlock } from "./StaticMathBlock";
17
17
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
18
+
19
+
function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
20
+
return <TextBlockCore {...props} />;
21
+
}
18
22
19
23
export function StaticPostContent({
20
24
blocks,
···
47
51
case PubLeafletBlocksBlockquote.isMain(b.block): {
48
52
return (
49
53
<blockquote className={` blockquote `}>
50
-
<BaseTextBlock
54
+
<StaticBaseTextBlock
51
55
facets={b.block.facets}
52
56
plaintext={b.block.plaintext}
53
57
index={[]}
···
116
120
case PubLeafletBlocksText.isMain(b.block):
117
121
return (
118
122
<p>
119
-
<BaseTextBlock
123
+
<StaticBaseTextBlock
120
124
facets={b.block.facets}
121
125
plaintext={b.block.plaintext}
122
126
index={[]}
···
127
131
if (b.block.level === 1)
128
132
return (
129
133
<h1>
130
-
<BaseTextBlock {...b.block} index={[]} />
134
+
<StaticBaseTextBlock {...b.block} index={[]} />
131
135
</h1>
132
136
);
133
137
if (b.block.level === 2)
134
138
return (
135
139
<h2>
136
-
<BaseTextBlock {...b.block} index={[]} />
140
+
<StaticBaseTextBlock {...b.block} index={[]} />
137
141
</h2>
138
142
);
139
143
if (b.block.level === 3)
140
144
return (
141
145
<h3>
142
-
<BaseTextBlock {...b.block} index={[]} />
146
+
<StaticBaseTextBlock {...b.block} index={[]} />
143
147
</h3>
144
148
);
145
149
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
146
150
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
147
151
return (
148
152
<h6>
149
-
<BaseTextBlock {...b.block} index={[]} />
153
+
<StaticBaseTextBlock {...b.block} index={[]} />
150
154
</h6>
151
155
);
152
156
}
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
···
1
+
import { UnicodeString } from "@atproto/api";
2
+
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { AtMentionLink } from "components/AtMentionLink";
4
+
import { ReactNode } from "react";
5
+
6
+
type Facet = PubLeafletRichtextFacet.Main;
7
+
8
+
export type FacetRenderers = {
9
+
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
+
};
11
+
12
+
export type TextBlockCoreProps = {
13
+
plaintext: string;
14
+
facets?: Facet[];
15
+
index: number[];
16
+
preview?: boolean;
17
+
renderers?: FacetRenderers;
18
+
};
19
+
20
+
export function TextBlockCore(props: TextBlockCoreProps) {
21
+
let children = [];
22
+
let richText = new RichText({
23
+
text: props.plaintext,
24
+
facets: props.facets || [],
25
+
});
26
+
let counter = 0;
27
+
for (const segment of richText.segments()) {
28
+
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
29
+
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
30
+
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
31
+
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
32
+
let isStrikethrough = segment.facet?.find(
33
+
PubLeafletRichtextFacet.isStrikethrough,
34
+
);
35
+
let isDidMention = segment.facet?.find(
36
+
PubLeafletRichtextFacet.isDidMention,
37
+
);
38
+
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
+
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
+
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
41
+
let isHighlighted = segment.facet?.find(
42
+
PubLeafletRichtextFacet.isHighlight,
43
+
);
44
+
let className = `
45
+
${isCode ? "inline-code" : ""}
46
+
${id ? "scroll-mt-12 scroll-mb-10" : ""}
47
+
${isBold ? "font-bold" : ""}
48
+
${isItalic ? "italic" : ""}
49
+
${isUnderline ? "underline" : ""}
50
+
${isStrikethrough ? "line-through decoration-tertiary" : ""}
51
+
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
52
+
53
+
// Split text by newlines and insert <br> tags
54
+
const textParts = segment.text.split("\n");
55
+
const renderedText = textParts.flatMap((part, i) =>
56
+
i < textParts.length - 1
57
+
? [part, <br key={`br-${counter}-${i}`} />]
58
+
: [part],
59
+
);
60
+
61
+
if (isCode) {
62
+
children.push(
63
+
<code key={counter} className={className} id={id?.id}>
64
+
{renderedText}
65
+
</code>,
66
+
);
67
+
} else if (isDidMention) {
68
+
const DidMentionRenderer = props.renderers?.DidMention;
69
+
if (DidMentionRenderer) {
70
+
children.push(
71
+
<DidMentionRenderer key={counter} did={isDidMention.did}>
72
+
<span className="mention">{renderedText}</span>
73
+
</DidMentionRenderer>,
74
+
);
75
+
} else {
76
+
// Default: render as a simple link
77
+
children.push(
78
+
<a
79
+
key={counter}
80
+
href={`https://leaflet.pub/p/${isDidMention.did}`}
81
+
target="_blank"
82
+
className="no-underline"
83
+
>
84
+
<span className="mention">{renderedText}</span>
85
+
</a>,
86
+
);
87
+
}
88
+
} else if (isAtMention) {
89
+
children.push(
90
+
<AtMentionLink
91
+
key={counter}
92
+
atURI={isAtMention.atURI}
93
+
className={className}
94
+
>
95
+
{renderedText}
96
+
</AtMentionLink>,
97
+
);
98
+
} else if (link) {
99
+
children.push(
100
+
<a
101
+
key={counter}
102
+
href={link.uri.trim()}
103
+
className={`text-accent-contrast hover:underline ${className}`}
104
+
target="_blank"
105
+
>
106
+
{renderedText}
107
+
</a>,
108
+
);
109
+
} else {
110
+
children.push(
111
+
<span key={counter} className={className} id={id?.id}>
112
+
{renderedText}
113
+
</span>,
114
+
);
115
+
}
116
+
117
+
counter++;
118
+
}
119
+
return <>{children}</>;
120
+
}
121
+
122
+
type RichTextSegment = {
123
+
text: string;
124
+
facet?: Exclude<Facet["features"], { $type: string }>;
125
+
};
126
+
127
+
export class RichText {
128
+
unicodeText: UnicodeString;
129
+
facets?: Facet[];
130
+
131
+
constructor(props: { text: string; facets: Facet[] }) {
132
+
this.unicodeText = new UnicodeString(props.text);
133
+
this.facets = props.facets;
134
+
if (this.facets) {
135
+
this.facets = this.facets
136
+
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
137
+
.sort((a, b) => a.index.byteStart - b.index.byteStart);
138
+
}
139
+
}
140
+
141
+
*segments(): Generator<RichTextSegment, void, void> {
142
+
const facets = this.facets || [];
143
+
if (!facets.length) {
144
+
yield { text: this.unicodeText.utf16 };
145
+
return;
146
+
}
147
+
148
+
let textCursor = 0;
149
+
let facetCursor = 0;
150
+
do {
151
+
const currFacet = facets[facetCursor];
152
+
if (textCursor < currFacet.index.byteStart) {
153
+
yield {
154
+
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
155
+
};
156
+
} else if (textCursor > currFacet.index.byteStart) {
157
+
facetCursor++;
158
+
continue;
159
+
}
160
+
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
161
+
const subtext = this.unicodeText.slice(
162
+
currFacet.index.byteStart,
163
+
currFacet.index.byteEnd,
164
+
);
165
+
if (!subtext.trim()) {
166
+
// dont empty string entities
167
+
yield { text: subtext };
168
+
} else {
169
+
yield { text: subtext, facet: currFacet.features };
170
+
}
171
+
}
172
+
textCursor = currFacet.index.byteEnd;
173
+
facetCursor++;
174
+
} while (facetCursor < facets.length);
175
+
if (textCursor < this.unicodeText.length) {
176
+
yield {
177
+
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
178
+
};
179
+
}
180
+
}
181
+
}
+324
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+324
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
1
+
"use client";
2
+
import { useEffect, useRef } from "react";
3
+
import { AppBskyFeedDefs } from "@atproto/api";
4
+
import useSWR from "swr";
5
+
import { PageWrapper } from "components/Pages/Page";
6
+
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
7
+
import { DotLoader } from "components/utils/DotLoader";
8
+
import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
9
+
import { openPage } from "./PostPages";
10
+
import { useThreadState } from "src/useThreadState";
11
+
import { BskyPostContent, ClientDate } from "./BskyPostContent";
12
+
import {
13
+
ThreadLink,
14
+
getThreadKey,
15
+
fetchThread,
16
+
prefetchThread,
17
+
} from "./PostLinks";
18
+
19
+
// Re-export for backwards compatibility
20
+
export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate };
21
+
22
+
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
23
+
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
24
+
type BlockedPost = AppBskyFeedDefs.BlockedPost;
25
+
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
26
+
27
+
export function ThreadPage(props: {
28
+
threadUri: string;
29
+
pageId: string;
30
+
pageOptions?: React.ReactNode;
31
+
hasPageBackground: boolean;
32
+
}) {
33
+
const { threadUri, pageId, pageOptions } = props;
34
+
const drawer = useDrawerOpen(threadUri);
35
+
36
+
const {
37
+
data: thread,
38
+
isLoading,
39
+
error,
40
+
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
41
+
fetchThread(threadUri),
42
+
);
43
+
44
+
return (
45
+
<PageWrapper
46
+
pageType="doc"
47
+
fullPageScroll={false}
48
+
id={`post-page-${pageId}`}
49
+
drawerOpen={!!drawer}
50
+
pageOptions={pageOptions}
51
+
>
52
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
53
+
{isLoading ? (
54
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
55
+
<span>loading thread</span>
56
+
<DotLoader />
57
+
</div>
58
+
) : error ? (
59
+
<div className="text-tertiary italic text-sm text-center py-8">
60
+
Failed to load thread
61
+
</div>
62
+
) : thread ? (
63
+
<ThreadContent thread={thread} threadUri={threadUri} />
64
+
) : null}
65
+
</div>
66
+
</PageWrapper>
67
+
);
68
+
}
69
+
70
+
function ThreadContent(props: { thread: ThreadType; threadUri: string }) {
71
+
const { thread, threadUri } = props;
72
+
const mainPostRef = useRef<HTMLDivElement>(null);
73
+
74
+
// Scroll the main post into view when the thread loads
75
+
useEffect(() => {
76
+
if (mainPostRef.current) {
77
+
mainPostRef.current.scrollIntoView({
78
+
behavior: "instant",
79
+
block: "start",
80
+
});
81
+
}
82
+
}, []);
83
+
84
+
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
85
+
return <PostNotAvailable />;
86
+
}
87
+
88
+
if (AppBskyFeedDefs.isBlockedPost(thread)) {
89
+
return (
90
+
<div className="text-tertiary italic text-sm text-center py-8">
91
+
This post is blocked
92
+
</div>
93
+
);
94
+
}
95
+
96
+
if (!AppBskyFeedDefs.isThreadViewPost(thread)) {
97
+
return <PostNotAvailable />;
98
+
}
99
+
100
+
// Collect all parent posts in order (oldest first)
101
+
const parents: ThreadViewPost[] = [];
102
+
let currentParent = thread.parent;
103
+
while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) {
104
+
parents.unshift(currentParent);
105
+
currentParent = currentParent.parent;
106
+
}
107
+
108
+
return (
109
+
<div className="flex flex-col gap-0">
110
+
{/* Parent posts */}
111
+
{parents.map((parent, index) => (
112
+
<div key={parent.post.uri} className="flex flex-col">
113
+
<ThreadPost
114
+
post={parent}
115
+
isMainPost={false}
116
+
showReplyLine={index < parents.length - 1 || true}
117
+
threadUri={threadUri}
118
+
/>
119
+
</div>
120
+
))}
121
+
122
+
{/* Main post */}
123
+
<div ref={mainPostRef}>
124
+
<ThreadPost
125
+
post={thread}
126
+
isMainPost={true}
127
+
showReplyLine={false}
128
+
threadUri={threadUri}
129
+
/>
130
+
</div>
131
+
132
+
{/* Replies */}
133
+
{thread.replies && thread.replies.length > 0 && (
134
+
<div className="flex flex-col mt-2 pt-2 border-t border-border-light">
135
+
<div className="text-tertiary text-xs font-bold mb-2 px-2">
136
+
Replies
137
+
</div>
138
+
<Replies
139
+
replies={thread.replies as any[]}
140
+
threadUri={threadUri}
141
+
depth={0}
142
+
parentAuthorDid={thread.post.author.did}
143
+
/>
144
+
</div>
145
+
)}
146
+
</div>
147
+
);
148
+
}
149
+
150
+
function ThreadPost(props: {
151
+
post: ThreadViewPost;
152
+
isMainPost: boolean;
153
+
showReplyLine: boolean;
154
+
threadUri: string;
155
+
}) {
156
+
const { post, isMainPost, showReplyLine, threadUri } = props;
157
+
const postView = post.post;
158
+
const parent = { type: "thread" as const, uri: threadUri };
159
+
160
+
return (
161
+
<div className="flex gap-2 relative">
162
+
{/* Reply line connector */}
163
+
{showReplyLine && (
164
+
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
165
+
)}
166
+
167
+
<BskyPostContent
168
+
post={postView}
169
+
parent={parent}
170
+
linksEnabled={!isMainPost}
171
+
showBlueskyLink={true}
172
+
showEmbed={true}
173
+
/>
174
+
</div>
175
+
);
176
+
}
177
+
178
+
function Replies(props: {
179
+
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
180
+
threadUri: string;
181
+
depth: number;
182
+
parentAuthorDid?: string;
183
+
}) {
184
+
const { replies, threadUri, depth, parentAuthorDid } = props;
185
+
const collapsedThreads = useThreadState((s) => s.collapsedThreads);
186
+
const toggleCollapsed = useThreadState((s) => s.toggleCollapsed);
187
+
188
+
// Sort replies so that replies from the parent author come first
189
+
const sortedReplies = parentAuthorDid
190
+
? [...replies].sort((a, b) => {
191
+
const aIsAuthor =
192
+
AppBskyFeedDefs.isThreadViewPost(a) &&
193
+
a.post.author.did === parentAuthorDid;
194
+
const bIsAuthor =
195
+
AppBskyFeedDefs.isThreadViewPost(b) &&
196
+
b.post.author.did === parentAuthorDid;
197
+
if (aIsAuthor && !bIsAuthor) return -1;
198
+
if (!aIsAuthor && bIsAuthor) return 1;
199
+
return 0;
200
+
})
201
+
: replies;
202
+
203
+
return (
204
+
<div className="flex flex-col gap-0">
205
+
{sortedReplies.map((reply, index) => {
206
+
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
207
+
return (
208
+
<div
209
+
key={`not-found-${index}`}
210
+
className="text-tertiary italic text-xs py-2 px-2"
211
+
>
212
+
Post not found
213
+
</div>
214
+
);
215
+
}
216
+
217
+
if (AppBskyFeedDefs.isBlockedPost(reply)) {
218
+
return (
219
+
<div
220
+
key={`blocked-${index}`}
221
+
className="text-tertiary italic text-xs py-2 px-2"
222
+
>
223
+
Post blocked
224
+
</div>
225
+
);
226
+
}
227
+
228
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) {
229
+
return null;
230
+
}
231
+
232
+
const hasReplies = reply.replies && reply.replies.length > 0;
233
+
const isCollapsed = collapsedThreads.has(reply.post.uri);
234
+
const replyCount = reply.replies?.length ?? 0;
235
+
236
+
return (
237
+
<div key={reply.post.uri} className="flex flex-col">
238
+
<ReplyPost
239
+
post={reply}
240
+
showReplyLine={hasReplies || index < replies.length - 1}
241
+
isLast={index === replies.length - 1 && !hasReplies}
242
+
threadUri={threadUri}
243
+
/>
244
+
{hasReplies && depth < 3 && (
245
+
<div className="ml-2 flex">
246
+
{/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */}
247
+
<button
248
+
onClick={(e) => {
249
+
e.stopPropagation();
250
+
toggleCollapsed(reply.post.uri);
251
+
}}
252
+
className="group w-8 flex justify-center cursor-pointer shrink-0"
253
+
aria-label={
254
+
isCollapsed ? "Expand replies" : "Collapse replies"
255
+
}
256
+
>
257
+
<div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" />
258
+
</button>
259
+
{isCollapsed ? (
260
+
<button
261
+
onClick={(e) => {
262
+
e.stopPropagation();
263
+
toggleCollapsed(reply.post.uri);
264
+
}}
265
+
className="text-xs text-accent-contrast hover:underline py-1 pl-1"
266
+
>
267
+
Show {replyCount} {replyCount === 1 ? "reply" : "replies"}
268
+
</button>
269
+
) : (
270
+
<div className="grow">
271
+
<Replies
272
+
replies={reply.replies as any[]}
273
+
threadUri={threadUri}
274
+
depth={depth + 1}
275
+
parentAuthorDid={reply.post.author.did}
276
+
/>
277
+
</div>
278
+
)}
279
+
</div>
280
+
)}
281
+
{hasReplies && depth >= 3 && (
282
+
<ThreadLink
283
+
threadUri={reply.post.uri}
284
+
parent={{ type: "thread", uri: threadUri }}
285
+
className="ml-12 text-xs text-accent-contrast hover:underline py-1"
286
+
>
287
+
View more replies
288
+
</ThreadLink>
289
+
)}
290
+
</div>
291
+
);
292
+
})}
293
+
</div>
294
+
);
295
+
}
296
+
297
+
function ReplyPost(props: {
298
+
post: ThreadViewPost;
299
+
showReplyLine: boolean;
300
+
isLast: boolean;
301
+
threadUri: string;
302
+
}) {
303
+
const { post, threadUri } = props;
304
+
const postView = post.post;
305
+
const parent = { type: "thread" as const, uri: threadUri };
306
+
307
+
return (
308
+
<div
309
+
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
310
+
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
311
+
>
312
+
<BskyPostContent
313
+
post={postView}
314
+
parent={parent}
315
+
linksEnabled={true}
316
+
avatarSize="sm"
317
+
showEmbed={false}
318
+
showBlueskyLink={false}
319
+
onLinkClick={(e) => e.stopPropagation()}
320
+
onEmbedClick={(e) => e.stopPropagation()}
321
+
/>
322
+
</div>
323
+
);
324
+
}
+58
-1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+58
-1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
10
10
data,
11
11
uri,
12
12
comments_on_documents(*, bsky_profiles(*)),
13
-
documents_in_publications(publications(*, publication_subscriptions(*))),
13
+
documents_in_publications(publications(*,
14
+
documents_in_publications(documents(uri, data)),
15
+
publication_subscriptions(*))
16
+
),
14
17
document_mentions_in_bsky(*),
15
18
leaflets_in_publications(*)
16
19
`,
···
51
54
?.record as PubLeafletPublication.Record
52
55
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
53
56
57
+
// Calculate prev/next documents from the fetched publication documents
58
+
let prevNext:
59
+
| {
60
+
prev?: { uri: string; title: string };
61
+
next?: { uri: string; title: string };
62
+
}
63
+
| undefined;
64
+
65
+
const currentPublishedAt = (document.data as PubLeafletDocument.Record)
66
+
?.publishedAt;
67
+
const allDocs =
68
+
document.documents_in_publications[0]?.publications
69
+
?.documents_in_publications;
70
+
71
+
if (currentPublishedAt && allDocs) {
72
+
// Filter and sort documents by publishedAt
73
+
const sortedDocs = allDocs
74
+
.map((dip) => ({
75
+
uri: dip?.documents?.uri,
76
+
title: (dip?.documents?.data as PubLeafletDocument.Record).title,
77
+
publishedAt: (dip?.documents?.data as PubLeafletDocument.Record)
78
+
.publishedAt,
79
+
}))
80
+
.filter((doc) => doc.publishedAt) // Only include docs with publishedAt
81
+
.sort(
82
+
(a, b) =>
83
+
new Date(a.publishedAt!).getTime() -
84
+
new Date(b.publishedAt!).getTime(),
85
+
);
86
+
87
+
// Find current document index
88
+
const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri);
89
+
90
+
if (currentIndex !== -1) {
91
+
prevNext = {
92
+
prev:
93
+
currentIndex > 0
94
+
? {
95
+
uri: sortedDocs[currentIndex - 1].uri || "",
96
+
title: sortedDocs[currentIndex - 1].title,
97
+
}
98
+
: undefined,
99
+
next:
100
+
currentIndex < sortedDocs.length - 1
101
+
? {
102
+
uri: sortedDocs[currentIndex + 1].uri || "",
103
+
title: sortedDocs[currentIndex + 1].title,
104
+
}
105
+
: undefined,
106
+
};
107
+
}
108
+
}
109
+
54
110
return {
55
111
...document,
56
112
quotesAndMentions,
57
113
theme,
114
+
prevNext,
58
115
};
59
116
}
60
117
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { ids } from "lexicons/api/lexicons";
5
+
import { PubLeafletDocument } from "lexicons/api";
6
+
import { jsonToLex } from "@atproto/lexicon";
7
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
8
3
-
export const runtime = "edge";
4
9
export const revalidate = 60;
5
10
6
11
export default async function OpenGraphImage(props: {
7
12
params: Promise<{ publication: string; did: string; rkey: string }>;
8
13
}) {
9
14
let params = await props.params;
15
+
let did = decodeURIComponent(params.did);
16
+
17
+
// Try to get the document's cover image
18
+
let { data: document } = await supabaseServerClient
19
+
.from("documents")
20
+
.select("data")
21
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
22
+
.single();
23
+
24
+
if (document) {
25
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
26
+
if (docRecord.coverImage) {
27
+
try {
28
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
29
+
let cid =
30
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
31
+
docRecord.coverImage.ref.toString();
32
+
33
+
let imageResponse = await fetchAtprotoBlob(did, cid);
34
+
if (imageResponse) {
35
+
let imageBlob = await imageResponse.blob();
36
+
37
+
// Return the image with appropriate headers
38
+
return new Response(imageBlob, {
39
+
headers: {
40
+
"Content-Type": imageBlob.type || "image/jpeg",
41
+
"Cache-Control": "public, max-age=3600",
42
+
},
43
+
});
44
+
}
45
+
} catch (e) {
46
+
// Fall through to screenshot if cover image fetch fails
47
+
console.error("Failed to fetch cover image:", e);
48
+
}
49
+
}
50
+
}
51
+
52
+
// Fall back to screenshot
10
53
return getMicroLinkOgImage(
11
54
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
12
55
);
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
···
1
1
"use server";
2
2
3
-
import { createOauthClient } from "src/atproto-oauth";
3
+
import {
4
+
restoreOAuthSession,
5
+
OAuthSessionError,
6
+
} from "src/atproto-oauth";
4
7
import { getIdentityData } from "actions/getIdentityData";
5
8
import { AtpBaseClient, AtUri } from "@atproto/api";
6
9
import { PubLeafletPollVote } from "lexicons/api";
···
12
15
pollUri: string,
13
16
pollCid: string,
14
17
selectedOption: string,
15
-
): Promise<{ success: boolean; error?: string }> {
18
+
): Promise<
19
+
{ success: true } | { success: false; error: string | OAuthSessionError }
20
+
> {
16
21
try {
17
22
const identity = await getIdentityData();
18
23
···
20
25
return { success: false, error: "Not authenticated" };
21
26
}
22
27
23
-
const oauthClient = await createOauthClient();
24
-
const session = await oauthClient.restore(identity.atp_did);
28
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
29
+
if (!sessionResult.ok) {
30
+
return { success: false, error: sessionResult.error };
31
+
}
32
+
const session = sessionResult.value;
25
33
let agent = new AtpBaseClient(session.fetchHandler.bind(session));
26
34
27
35
const voteRecord: PubLeafletPollVote.Record = {
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
···
1
1
"use client";
2
2
3
3
import { NewDraftActionButton } from "./NewDraftButton";
4
-
import { PublicationSettingsButton } from "./PublicationSettings";
4
+
import { PublicationSettingsButton } from "./settings/PublicationSettings";
5
5
import { ActionButton } from "components/ActionBar/ActionButton";
6
6
import { ShareSmall } from "components/Icons/ShareSmall";
7
-
import { Menu } from "components/Layout";
8
-
import { MenuItem } from "components/Layout";
7
+
import { Menu, MenuItem } from "components/Menu";
9
8
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
9
import { usePublicationData } from "./PublicationSWRProvider";
11
10
import { useSmoker } from "components/Toast";
+20
-34
app/lish/[did]/[publication]/dashboard/DraftList.tsx
+20
-34
app/lish/[did]/[publication]/dashboard/DraftList.tsx
···
4
4
import React from "react";
5
5
import { usePublicationData } from "./PublicationSWRProvider";
6
6
import { LeafletList } from "app/(home-pages)/home/HomeLayout";
7
-
import { EmptyState } from "components/EmptyState";
8
7
9
8
export function DraftList(props: {
10
9
searchValue: string;
···
13
12
let { data: pub_data } = usePublicationData();
14
13
if (!pub_data?.publication) return null;
15
14
let { leaflets_in_publications, ...publication } = pub_data.publication;
16
-
let filteredLeaflets = leaflets_in_publications
17
-
.filter((l) => !l.documents)
18
-
.filter((l) => !l.archived)
19
-
.map((l) => {
20
-
return {
21
-
archived: l.archived,
22
-
added_at: "",
23
-
token: {
24
-
...l.permission_tokens!,
25
-
leaflets_in_publications: [
26
-
{
27
-
...l,
28
-
publications: {
29
-
...publication,
30
-
},
31
-
},
32
-
],
33
-
},
34
-
};
35
-
});
36
-
37
-
38
-
39
-
if (!filteredLeaflets || filteredLeaflets.length === 0)
40
-
return (
41
-
<EmptyState>
42
-
No drafts yet!
43
-
<NewDraftSecondaryButton publication={pub_data?.publication?.uri} />
44
-
</EmptyState>
45
-
);
46
-
47
15
return (
48
16
<div className="flex flex-col gap-4">
49
17
<NewDraftSecondaryButton
···
55
23
searchValue={props.searchValue}
56
24
showPreview={false}
57
25
defaultDisplay="list"
58
-
cardBorderHidden={!props.showPageBackground}
59
-
leaflets={filteredLeaflets}
26
+
leaflets={leaflets_in_publications
27
+
.filter((l) => !l.documents)
28
+
.filter((l) => !l.archived)
29
+
.map((l) => {
30
+
return {
31
+
archived: l.archived,
32
+
added_at: "",
33
+
token: {
34
+
...l.permission_tokens!,
35
+
leaflets_in_publications: [
36
+
{
37
+
...l,
38
+
publications: {
39
+
...publication,
40
+
},
41
+
},
42
+
],
43
+
},
44
+
};
45
+
})}
60
46
initialFacts={pub_data.leaflet_data.facts || {}}
61
47
titles={{
62
48
...leaflets_in_publications.reduce(
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
-132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
-132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
···
1
-
"use client";
2
-
3
-
import { ActionButton } from "components/ActionBar/ActionButton";
4
-
import { Popover } from "components/Popover";
5
-
import { SettingsSmall } from "components/Icons/SettingsSmall";
6
-
import { EditPubForm } from "app/lish/createPub/UpdatePubForm";
7
-
import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter";
8
-
import { useIsMobile } from "src/hooks/isMobile";
9
-
import { useState } from "react";
10
-
import { GoBackSmall } from "components/Icons/GoBackSmall";
11
-
import { theme } from "tailwind.config";
12
-
import { ButtonPrimary } from "components/Buttons";
13
-
import { DotLoader } from "components/utils/DotLoader";
14
-
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
15
-
16
-
export function PublicationSettingsButton(props: { publication: string }) {
17
-
let isMobile = useIsMobile();
18
-
let [state, setState] = useState<"menu" | "general" | "theme">("menu");
19
-
let [loading, setLoading] = useState(false);
20
-
21
-
return (
22
-
<Popover
23
-
asChild
24
-
onOpenChange={() => setState("menu")}
25
-
side={isMobile ? "top" : "right"}
26
-
align={isMobile ? "center" : "start"}
27
-
className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`}
28
-
arrowFill={theme.colors["border-light"]}
29
-
trigger={
30
-
<ActionButton
31
-
id="pub-settings-button"
32
-
icon=<SettingsSmall />
33
-
label="Settings"
34
-
/>
35
-
}
36
-
>
37
-
{state === "general" ? (
38
-
<EditPubForm
39
-
backToMenuAction={() => setState("menu")}
40
-
loading={loading}
41
-
setLoadingAction={setLoading}
42
-
/>
43
-
) : state === "theme" ? (
44
-
<PubThemeSetter
45
-
backToMenu={() => setState("menu")}
46
-
loading={loading}
47
-
setLoading={setLoading}
48
-
/>
49
-
) : (
50
-
<PubSettingsMenu
51
-
state={state}
52
-
setState={setState}
53
-
loading={loading}
54
-
setLoading={setLoading}
55
-
/>
56
-
)}
57
-
</Popover>
58
-
);
59
-
}
60
-
61
-
const PubSettingsMenu = (props: {
62
-
state: "menu" | "general" | "theme";
63
-
setState: (s: typeof props.state) => void;
64
-
loading: boolean;
65
-
setLoading: (l: boolean) => void;
66
-
}) => {
67
-
let menuItemClassName =
68
-
"menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!";
69
-
70
-
return (
71
-
<div className="flex flex-col gap-0.5">
72
-
<PubSettingsHeader
73
-
loading={props.loading}
74
-
setLoadingAction={props.setLoading}
75
-
state={"menu"}
76
-
/>
77
-
<button
78
-
className={menuItemClassName}
79
-
type="button"
80
-
onClick={() => {
81
-
props.setState("general");
82
-
}}
83
-
>
84
-
Publication Settings
85
-
<ArrowRightTiny />
86
-
</button>
87
-
<button
88
-
className={menuItemClassName}
89
-
type="button"
90
-
onClick={() => props.setState("theme")}
91
-
>
92
-
Publication Theme
93
-
<ArrowRightTiny />
94
-
</button>
95
-
</div>
96
-
);
97
-
};
98
-
99
-
export const PubSettingsHeader = (props: {
100
-
state: "menu" | "general" | "theme";
101
-
backToMenuAction?: () => void;
102
-
loading: boolean;
103
-
setLoadingAction: (l: boolean) => void;
104
-
}) => {
105
-
return (
106
-
<div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1">
107
-
{props.state === "menu"
108
-
? "Settings"
109
-
: props.state === "general"
110
-
? "General"
111
-
: props.state === "theme"
112
-
? "Publication Theme"
113
-
: ""}
114
-
{props.state !== "menu" && (
115
-
<div className="flex gap-2">
116
-
<button
117
-
type="button"
118
-
onClick={() => {
119
-
props.backToMenuAction && props.backToMenuAction();
120
-
}}
121
-
>
122
-
<GoBackSmall className="text-accent-contrast" />
123
-
</button>
124
-
125
-
<ButtonPrimary compact type="submit">
126
-
{props.loading ? <DotLoader /> : "Update"}
127
-
</ButtonPrimary>
128
-
</div>
129
-
)}
130
-
</div>
131
-
);
132
-
};
+2
-1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
+2
-1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
···
4
4
import { ButtonPrimary } from "components/Buttons";
5
5
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
6
import { useSmoker } from "components/Toast";
7
-
import { Menu, MenuItem, Separator } from "components/Layout";
7
+
import { Menu, MenuItem } from "components/Menu";
8
+
import { Separator } from "components/Layout";
8
9
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
9
10
import { Checkbox } from "components/Checkbox";
10
11
import { useEffect, useState } from "react";
+27
-32
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+27
-32
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";
7
7
import { Fragment, useState } from "react";
8
8
import { useParams } from "next/navigation";
9
9
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
-
import { Menu, MenuItem } from "components/Layout";
10
+
import { Menu, MenuItem } from "components/Menu";
11
11
import { deletePost } from "./deletePost";
12
12
import { ButtonPrimary } from "components/Buttons";
13
13
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
···
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";
21
22
import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions";
22
23
import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
23
-
import { EmptyState } from "components/EmptyState";
24
24
25
25
export function PublishedPostsList(props: {
26
26
searchValue: string;
···
29
29
let { data } = usePublicationData();
30
30
let params = useParams();
31
31
let { publication } = data!;
32
+
let pubRecord = publication?.record as PubLeafletPublication.Record;
33
+
32
34
if (!publication) return null;
33
35
if (publication.documents_in_publications.length === 0)
34
-
return <EmptyState>Nothing's been published yet...</EmptyState>;
36
+
return (
37
+
<div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3">
38
+
Nothing's been published yet...
39
+
</div>
40
+
);
35
41
return (
36
42
<div className="publishedList w-full flex flex-col gap-2 pb-4">
37
43
{publication.documents_in_publications
···
52
58
(l) => doc.documents && l.doc === doc.documents.uri,
53
59
);
54
60
let uri = new AtUri(doc.documents.uri);
55
-
let record = doc.documents.data as PubLeafletDocument.Record;
61
+
let postRecord = doc.documents.data as PubLeafletDocument.Record;
56
62
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
57
63
let comments = doc.documents.comments_on_documents[0]?.count || 0;
64
+
let tags = (postRecord?.tags as string[] | undefined) || [];
58
65
59
66
let postLink = data?.publication
60
67
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
···
78
85
href={`${getPublicationURL(publication)}/${uri.rkey}`}
79
86
>
80
87
<h3 className="text-primary grow leading-snug">
81
-
{record.title}
88
+
{postRecord.title}
82
89
</h3>
83
90
</a>
84
91
<div className="flex justify-start align-top flex-row gap-1">
···
107
114
: null,
108
115
},
109
116
],
110
-
leaflets_to_documents: null,
117
+
leaflets_to_documents: [],
111
118
blocked_by_admin: null,
112
119
custom_domain_routes: [],
113
120
}}
···
119
126
</div>
120
127
</div>
121
128
122
-
{record.description ? (
129
+
{postRecord.description ? (
123
130
<p className="italic text-secondary">
124
-
{record.description}
131
+
{postRecord.description}
125
132
</p>
126
133
) : null}
127
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3">
128
-
{record.publishedAt ? (
129
-
<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} />
130
137
) : null}
131
-
{(comments > 0 || quotes > 0) && record.publishedAt
132
-
? " | "
133
-
: ""}
134
-
{quotes > 0 && (
135
-
<SpeedyLink
136
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
137
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
138
-
>
139
-
<QuoteTiny /> {quotes}
140
-
</SpeedyLink>
141
-
)}
142
-
{comments > 0 && quotes > 0 ? " " : ""}
143
-
{comments > 0 && (
144
-
<SpeedyLink
145
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
146
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
147
-
>
148
-
<CommentTiny /> {comments}
149
-
</SpeedyLink>
150
-
)}
138
+
<InteractionPreview
139
+
quotesCount={quotes}
140
+
commentsCount={comments}
141
+
tags={tags}
142
+
showComments={pubRecord?.preferences?.showComments}
143
+
showMentions={pubRecord?.preferences?.showMentions}
144
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
145
+
/>
151
146
</div>
152
147
</div>
153
148
</div>
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
···
2
2
3
3
import { AtpBaseClient } from "lexicons/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
import { AtUri } from "@atproto/syntax";
7
10
import { supabaseServerClient } from "supabase/serverClient";
8
11
import { revalidatePath } from "next/cache";
9
12
10
-
export async function deletePost(document_uri: string) {
13
+
export async function deletePost(
14
+
document_uri: string
15
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
11
16
let identity = await getIdentityData();
12
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
17
+
if (!identity || !identity.atp_did) {
18
+
return {
19
+
success: false,
20
+
error: {
21
+
type: "oauth_session_expired",
22
+
message: "Not authenticated",
23
+
did: "",
24
+
},
25
+
};
26
+
}
13
27
14
-
const oauthClient = await createOauthClient();
15
-
let credentialSession = await oauthClient.restore(identity.atp_did);
28
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
29
+
if (!sessionResult.ok) {
30
+
return { success: false, error: sessionResult.error };
31
+
}
32
+
let credentialSession = sessionResult.value;
16
33
let agent = new AtpBaseClient(
17
34
credentialSession.fetchHandler.bind(credentialSession),
18
35
);
19
36
let uri = new AtUri(document_uri);
20
-
if (uri.host !== identity.atp_did) return;
37
+
if (uri.host !== identity.atp_did) {
38
+
return { success: true };
39
+
}
21
40
22
41
await Promise.all([
23
42
agent.pub.leaflet.document.delete({
···
31
50
.eq("doc", document_uri),
32
51
]);
33
52
34
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
53
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
54
+
return { success: true };
35
55
}
36
56
37
-
export async function unpublishPost(document_uri: string) {
57
+
export async function unpublishPost(
58
+
document_uri: string
59
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
38
60
let identity = await getIdentityData();
39
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
61
+
if (!identity || !identity.atp_did) {
62
+
return {
63
+
success: false,
64
+
error: {
65
+
type: "oauth_session_expired",
66
+
message: "Not authenticated",
67
+
did: "",
68
+
},
69
+
};
70
+
}
40
71
41
-
const oauthClient = await createOauthClient();
42
-
let credentialSession = await oauthClient.restore(identity.atp_did);
72
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
73
+
if (!sessionResult.ok) {
74
+
return { success: false, error: sessionResult.error };
75
+
}
76
+
let credentialSession = sessionResult.value;
43
77
let agent = new AtpBaseClient(
44
78
credentialSession.fetchHandler.bind(credentialSession),
45
79
);
46
80
let uri = new AtUri(document_uri);
47
-
if (uri.host !== identity.atp_did) return;
81
+
if (uri.host !== identity.atp_did) {
82
+
return { success: true };
83
+
}
48
84
49
85
await Promise.all([
50
86
agent.pub.leaflet.document.delete({
···
53
89
}),
54
90
supabaseServerClient.from("documents").delete().eq("uri", document_uri),
55
91
]);
56
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
92
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
93
+
return { success: true };
57
94
}
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
···
1
+
import { PubLeafletPublication } from "lexicons/api";
2
+
import { usePublicationData } from "../PublicationSWRProvider";
3
+
import { PubSettingsHeader } from "./PublicationSettings";
4
+
import { useState } from "react";
5
+
import { Toggle } from "components/Toggle";
6
+
import { updatePublication } from "app/lish/createPub/updatePublication";
7
+
import { useToaster } from "components/Toast";
8
+
import { mutate } from "swr";
9
+
10
+
export const PostOptions = (props: {
11
+
backToMenu: () => void;
12
+
loading: boolean;
13
+
setLoading: (l: boolean) => void;
14
+
}) => {
15
+
let { data } = usePublicationData();
16
+
17
+
let { publication: pubData } = data || {};
18
+
let record = pubData?.record as PubLeafletPublication.Record;
19
+
20
+
let [showComments, setShowComments] = useState(
21
+
record?.preferences?.showComments === undefined
22
+
? true
23
+
: record.preferences.showComments,
24
+
);
25
+
let [showMentions, setShowMentions] = useState(
26
+
record?.preferences?.showMentions === undefined
27
+
? true
28
+
: record.preferences.showMentions,
29
+
);
30
+
let [showPrevNext, setShowPrevNext] = useState(
31
+
record?.preferences?.showPrevNext === undefined
32
+
? true
33
+
: record.preferences.showPrevNext,
34
+
);
35
+
36
+
let toast = useToaster();
37
+
return (
38
+
<form
39
+
onSubmit={async (e) => {
40
+
if (!pubData) return;
41
+
e.preventDefault();
42
+
props.setLoading(true);
43
+
let data = await updatePublication({
44
+
name: record.name,
45
+
uri: pubData.uri,
46
+
preferences: {
47
+
showInDiscover:
48
+
record?.preferences?.showInDiscover === undefined
49
+
? true
50
+
: record.preferences.showInDiscover,
51
+
showComments: showComments,
52
+
showMentions: showMentions,
53
+
showPrevNext: showPrevNext,
54
+
},
55
+
});
56
+
toast({ type: "success", content: <strong>Posts Updated!</strong> });
57
+
console.log(record.preferences?.showPrevNext);
58
+
props.setLoading(false);
59
+
mutate("publication-data");
60
+
}}
61
+
className="text-primary flex flex-col"
62
+
>
63
+
<PubSettingsHeader
64
+
loading={props.loading}
65
+
setLoadingAction={props.setLoading}
66
+
backToMenuAction={props.backToMenu}
67
+
state={"post-options"}
68
+
>
69
+
Post Options
70
+
</PubSettingsHeader>
71
+
<h4 className="mb-1">Layout</h4>
72
+
<Toggle
73
+
toggle={showPrevNext}
74
+
onToggle={() => {
75
+
setShowPrevNext(!showPrevNext);
76
+
}}
77
+
>
78
+
<div className="font-bold">Show Prev/Next Buttons</div>
79
+
</Toggle>
80
+
<hr className="my-2 border-border-light" />
81
+
<h4 className="mb-1">Interactions</h4>
82
+
<div className="flex flex-col gap-2">
83
+
<Toggle
84
+
toggle={showComments}
85
+
onToggle={() => {
86
+
setShowComments(!showComments);
87
+
}}
88
+
>
89
+
<div className="font-bold">Show Comments</div>
90
+
</Toggle>
91
+
92
+
<Toggle
93
+
toggle={showMentions}
94
+
onToggle={() => {
95
+
setShowMentions(!showMentions);
96
+
}}
97
+
>
98
+
<div className="flex flex-col justify-start">
99
+
<div className="font-bold">Show Mentions</div>
100
+
<div className="text-tertiary text-sm leading-tight">
101
+
Display a list of posts on Bluesky that mention your post
102
+
</div>
103
+
</div>
104
+
</Toggle>
105
+
</div>
106
+
</form>
107
+
);
108
+
};
+146
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
+146
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
···
1
+
"use client";
2
+
3
+
import { ActionButton } from "components/ActionBar/ActionButton";
4
+
import { Popover } from "components/Popover";
5
+
import { SettingsSmall } from "components/Icons/SettingsSmall";
6
+
import { EditPubForm } from "app/lish/createPub/UpdatePubForm";
7
+
import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter";
8
+
import { useIsMobile } from "src/hooks/isMobile";
9
+
import { useState } from "react";
10
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
11
+
import { theme } from "tailwind.config";
12
+
import { ButtonPrimary } from "components/Buttons";
13
+
import { DotLoader } from "components/utils/DotLoader";
14
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
15
+
import { PostOptions } from "./PostOptions";
16
+
17
+
type menuState = "menu" | "general" | "theme" | "post-options";
18
+
19
+
export function PublicationSettingsButton(props: { publication: string }) {
20
+
let isMobile = useIsMobile();
21
+
let [state, setState] = useState<menuState>("menu");
22
+
let [loading, setLoading] = useState(false);
23
+
24
+
return (
25
+
<Popover
26
+
asChild
27
+
onOpenChange={() => setState("menu")}
28
+
side={isMobile ? "top" : "right"}
29
+
align={isMobile ? "center" : "start"}
30
+
className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`}
31
+
arrowFill={theme.colors["border-light"]}
32
+
trigger={
33
+
<ActionButton
34
+
id="pub-settings-button"
35
+
icon=<SettingsSmall />
36
+
label="Settings"
37
+
/>
38
+
}
39
+
>
40
+
{state === "general" ? (
41
+
<EditPubForm
42
+
backToMenuAction={() => setState("menu")}
43
+
loading={loading}
44
+
setLoadingAction={setLoading}
45
+
/>
46
+
) : state === "theme" ? (
47
+
<PubThemeSetter
48
+
backToMenu={() => setState("menu")}
49
+
loading={loading}
50
+
setLoading={setLoading}
51
+
/>
52
+
) : state === "post-options" ? (
53
+
<PostOptions
54
+
backToMenu={() => setState("menu")}
55
+
loading={loading}
56
+
setLoading={setLoading}
57
+
/>
58
+
) : (
59
+
<PubSettingsMenu
60
+
state={state}
61
+
setState={setState}
62
+
loading={loading}
63
+
setLoading={setLoading}
64
+
/>
65
+
)}
66
+
</Popover>
67
+
);
68
+
}
69
+
70
+
const PubSettingsMenu = (props: {
71
+
state: menuState;
72
+
setState: (s: menuState) => void;
73
+
loading: boolean;
74
+
setLoading: (l: boolean) => void;
75
+
}) => {
76
+
let menuItemClassName =
77
+
"menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!";
78
+
79
+
return (
80
+
<div className="flex flex-col gap-0.5">
81
+
<PubSettingsHeader
82
+
loading={props.loading}
83
+
setLoadingAction={props.setLoading}
84
+
state={"menu"}
85
+
>
86
+
Settings
87
+
</PubSettingsHeader>
88
+
<button
89
+
className={menuItemClassName}
90
+
type="button"
91
+
onClick={() => {
92
+
props.setState("general");
93
+
}}
94
+
>
95
+
General Settings
96
+
<ArrowRightTiny />
97
+
</button>
98
+
<button
99
+
className={menuItemClassName}
100
+
type="button"
101
+
onClick={() => props.setState("theme")}
102
+
>
103
+
Theme and Layout
104
+
<ArrowRightTiny />
105
+
</button>
106
+
<button
107
+
className={menuItemClassName}
108
+
type="button"
109
+
onClick={() => props.setState("post-options")}
110
+
>
111
+
Post Options
112
+
<ArrowRightTiny />
113
+
</button>
114
+
</div>
115
+
);
116
+
};
117
+
118
+
export const PubSettingsHeader = (props: {
119
+
state: menuState;
120
+
backToMenuAction?: () => void;
121
+
loading: boolean;
122
+
setLoadingAction: (l: boolean) => void;
123
+
children: React.ReactNode;
124
+
}) => {
125
+
return (
126
+
<div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1">
127
+
{props.children}
128
+
{props.state !== "menu" && (
129
+
<div className="flex gap-2">
130
+
<button
131
+
type="button"
132
+
onClick={() => {
133
+
props.backToMenuAction && props.backToMenuAction();
134
+
}}
135
+
>
136
+
<GoBackSmall className="text-accent-contrast" />
137
+
</button>
138
+
139
+
<ButtonPrimary compact type="submit">
140
+
{props.loading ? <DotLoader /> : "Update"}
141
+
</ButtonPrimary>
142
+
</div>
143
+
)}
144
+
</div>
145
+
);
146
+
};
+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
+
}
-68
app/lish/[did]/[publication]/icon.ts
-68
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(props: {
18
-
params: Promise<{ did: string; publication: string }>;
19
-
}) {
20
-
const params = await props.params;
21
-
try {
22
-
let did = decodeURIComponent(params.did);
23
-
let uri;
24
-
if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
25
-
uri = AtUri.make(
26
-
did,
27
-
"pub.leaflet.publication",
28
-
params.publication,
29
-
).toString();
30
-
}
31
-
let { data: publication } = await supabaseServerClient
32
-
.from("publications")
33
-
.select(
34
-
`*,
35
-
publication_subscriptions(*),
36
-
documents_in_publications(documents(*))
37
-
`,
38
-
)
39
-
.eq("identity_did", did)
40
-
.or(`name.eq."${params.publication}", uri.eq."${uri}"`)
41
-
.single();
42
-
43
-
let record = publication?.record as PubLeafletPublication.Record | null;
44
-
if (!record?.icon) return redirect("/icon.png");
45
-
46
-
let identity = await idResolver.did.resolve(did);
47
-
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
48
-
if (!service) return null;
49
-
let cid = (record.icon.ref as unknown as { $link: string })["$link"];
50
-
const response = await fetch(
51
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
52
-
);
53
-
let blob = await response.blob();
54
-
let resizedImage = await sharp(await blob.arrayBuffer())
55
-
.resize({ width: 32, height: 32 })
56
-
.toBuffer();
57
-
return new Response(new Uint8Array(resizedImage), {
58
-
headers: {
59
-
"Content-Type": "image/png",
60
-
"CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400",
61
-
"Cache-Control":
62
-
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
63
-
},
64
-
});
65
-
} catch (e) {
66
-
return redirect("/icon.png");
67
-
}
68
-
}
+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,
+16
-26
app/lish/[did]/[publication]/page.tsx
+16
-26
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";
18
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
+
import { PublicationAuthor } from "./PublicationAuthor";
19
21
20
22
export default async function Publication(props: {
21
23
params: Promise<{ publication: string; did: string }>;
···
90
92
{record?.description}{" "}
91
93
</p>
92
94
{profile && (
93
-
<p className="italic text-tertiary sm:text-base text-sm">
94
-
<strong className="">by {profile.displayName}</strong>{" "}
95
-
<a
96
-
className="text-tertiary"
97
-
href={`https://bsky.app/profile/${profile.handle}`}
98
-
>
99
-
@{profile.handle}
100
-
</a>
101
-
</p>
95
+
<PublicationAuthor
96
+
did={profile.did}
97
+
displayName={profile.displayName}
98
+
handle={profile.handle}
99
+
/>
102
100
)}
103
101
<div className="sm:pt-4 pt-4">
104
102
<SubscribeWithBluesky
···
134
132
record?.preferences?.showComments === false
135
133
? 0
136
134
: doc.documents.comments_on_documents[0].count || 0;
135
+
let tags = (doc_record?.tags as string[] | undefined) || [];
137
136
138
137
return (
139
138
<React.Fragment key={doc.documents?.uri}>
···
162
161
)}{" "}
163
162
</p>
164
163
{comments > 0 || quotes > 0 ? "| " : ""}
165
-
{quotes > 0 && (
166
-
<SpeedyLink
167
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
168
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
169
-
>
170
-
<QuoteTiny /> {quotes}
171
-
</SpeedyLink>
172
-
)}
173
-
{comments > 0 &&
174
-
record?.preferences?.showComments !== false && (
175
-
<SpeedyLink
176
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
177
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
178
-
>
179
-
<CommentTiny /> {comments}
180
-
</SpeedyLink>
181
-
)}
164
+
<InteractionPreview
165
+
quotesCount={quotes}
166
+
commentsCount={comments}
167
+
tags={tags}
168
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
169
+
showComments={record?.preferences?.showComments}
170
+
showMentions={record?.preferences?.showMentions}
171
+
/>
182
172
</div>
183
173
</div>
184
174
<hr className="last:hidden border-border-light" />
+22
-6
app/lish/addFeed.tsx
+22
-6
app/lish/addFeed.tsx
···
2
2
3
3
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
const leafletFeedURI =
7
10
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
8
11
9
-
export async function addFeed() {
10
-
const oauthClient = await createOauthClient();
12
+
export async function addFeed(): Promise<
13
+
{ success: true } | { success: false; error: OAuthSessionError }
14
+
> {
11
15
let identity = await getIdentityData();
12
16
if (!identity || !identity.atp_did) {
13
-
throw new Error("Invalid identity data");
17
+
return {
18
+
success: false,
19
+
error: {
20
+
type: "oauth_session_expired",
21
+
message: "Not authenticated",
22
+
did: "",
23
+
},
24
+
};
14
25
}
15
26
16
-
let credentialSession = await oauthClient.restore(identity.atp_did);
27
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
28
+
if (!sessionResult.ok) {
29
+
return { success: false, error: sessionResult.error };
30
+
}
31
+
let credentialSession = sessionResult.value;
17
32
let bsky = new BskyAgent(credentialSession);
18
33
let prefs = await bsky.app.bsky.actor.getPreferences();
19
34
let savedFeeds = prefs.data.preferences.find(
···
23
38
let hasFeed = !!savedFeeds.items.find(
24
39
(feed) => feed.value === leafletFeedURI,
25
40
);
26
-
if (hasFeed) return;
41
+
if (hasFeed) return { success: true };
27
42
28
43
await bsky.addSavedFeeds([
29
44
{
···
32
47
type: "feed",
33
48
},
34
49
]);
50
+
return { success: true };
35
51
}
+42
-13
app/lish/createPub/CreatePubForm.tsx
+42
-13
app/lish/createPub/CreatePubForm.tsx
···
13
13
import { string } from "zod";
14
14
import { DotLoader } from "components/utils/DotLoader";
15
15
import { Checkbox } from "components/Checkbox";
16
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
16
17
17
18
type DomainState =
18
19
| { status: "empty" }
···
32
33
let [domainState, setDomainState] = useState<DomainState>({
33
34
status: "empty",
34
35
});
36
+
let [oauthError, setOauthError] = useState<
37
+
import("src/atproto-oauth").OAuthSessionError | null
38
+
>(null);
35
39
let fileInputRef = useRef<HTMLInputElement>(null);
36
40
37
41
let router = useRouter();
···
43
47
e.preventDefault();
44
48
if (!subdomainValidator.safeParse(domainValue).success) return;
45
49
setFormState("loading");
46
-
let data = await createPublication({
50
+
setOauthError(null);
51
+
let result = await createPublication({
47
52
name: nameValue,
48
53
description: descriptionValue,
49
54
iconFile: logoFile,
50
55
subdomain: domainValue,
51
-
preferences: { showInDiscover, showComments: true },
56
+
preferences: {
57
+
showInDiscover,
58
+
showComments: true,
59
+
showMentions: true,
60
+
showPrevNext: false,
61
+
},
52
62
});
63
+
64
+
if (!result.success) {
65
+
setFormState("normal");
66
+
if (result.error && isOAuthSessionError(result.error)) {
67
+
setOauthError(result.error);
68
+
}
69
+
return;
70
+
}
71
+
53
72
// Show a spinner while this is happening! Maybe a progress bar?
54
73
setTimeout(() => {
55
74
setFormState("normal");
56
-
if (data?.publication)
57
-
router.push(`${getBasePublicationURL(data.publication)}/dashboard`);
75
+
if (result.publication)
76
+
router.push(
77
+
`${getBasePublicationURL(result.publication)}/dashboard`,
78
+
);
58
79
}, 500);
59
80
}}
60
81
>
···
139
160
</Checkbox>
140
161
<hr className="border-border-light" />
141
162
142
-
<div className="flex w-full justify-end">
143
-
<ButtonPrimary
144
-
type="submit"
145
-
disabled={
146
-
!nameValue || !domainValue || domainState.status !== "valid"
147
-
}
148
-
>
149
-
{formState === "loading" ? <DotLoader /> : "Create Publication!"}
150
-
</ButtonPrimary>
163
+
<div className="flex flex-col gap-2">
164
+
<div className="flex w-full justify-end">
165
+
<ButtonPrimary
166
+
type="submit"
167
+
disabled={
168
+
!nameValue || !domainValue || domainState.status !== "valid"
169
+
}
170
+
>
171
+
{formState === "loading" ? <DotLoader /> : "Create Publication!"}
172
+
</ButtonPrimary>
173
+
</div>
174
+
{oauthError && (
175
+
<OAuthErrorMessage
176
+
error={oauthError}
177
+
className="text-right text-sm text-accent-1"
178
+
/>
179
+
)}
151
180
</div>
152
181
</form>
153
182
);
+23
-16
app/lish/createPub/UpdatePubForm.tsx
+23
-16
app/lish/createPub/UpdatePubForm.tsx
···
20
20
import Link from "next/link";
21
21
import { Checkbox } from "components/Checkbox";
22
22
import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
23
-
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings";
23
+
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings";
24
+
import { Toggle } from "components/Toggle";
24
25
25
26
export const EditPubForm = (props: {
26
27
backToMenuAction: () => void;
···
43
44
? true
44
45
: record.preferences.showComments,
45
46
);
47
+
let showMentions =
48
+
record?.preferences?.showMentions === undefined
49
+
? true
50
+
: record.preferences.showMentions;
51
+
let showPrevNext =
52
+
record?.preferences?.showPrevNext === undefined
53
+
? true
54
+
: record.preferences.showPrevNext;
55
+
46
56
let [descriptionValue, setDescriptionValue] = useState(
47
57
record?.description || "",
48
58
);
···
74
84
preferences: {
75
85
showInDiscover: showInDiscover,
76
86
showComments: showComments,
87
+
showMentions: showMentions,
88
+
showPrevNext: showPrevNext,
77
89
},
78
90
});
79
91
toast({ type: "success", content: "Updated!" });
···
86
98
setLoadingAction={props.setLoadingAction}
87
99
backToMenuAction={props.backToMenuAction}
88
100
state={"theme"}
89
-
/>
101
+
>
102
+
General Settings
103
+
</PubSettingsHeader>
90
104
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
91
-
<div className="flex items-center justify-between gap-2 ">
105
+
<div className="flex items-center justify-between gap-2 mt-2 ">
92
106
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
93
107
Logo <span className="font-normal">(optional)</span>
94
108
</p>
···
158
172
<CustomDomainForm />
159
173
<hr className="border-border-light" />
160
174
161
-
<Checkbox
162
-
checked={showInDiscover}
163
-
onChange={(e) => setShowInDiscover(e.target.checked)}
175
+
<Toggle
176
+
toggle={showInDiscover}
177
+
onToggle={() => setShowInDiscover(!showInDiscover)}
164
178
>
165
-
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
179
+
<div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
166
180
<p className="font-bold">
167
181
Show In{" "}
168
182
<a href="/discover" target="_blank">
···
177
191
page. You can change this at any time!
178
192
</p>
179
193
</div>
180
-
</Checkbox>
194
+
</Toggle>
181
195
182
-
<Checkbox
183
-
checked={showComments}
184
-
onChange={(e) => setShowComments(e.target.checked)}
185
-
>
186
-
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
187
-
<p className="font-bold">Show comments on posts</p>
188
-
</div>
189
-
</Checkbox>
196
+
190
197
</div>
191
198
</form>
192
199
);
+24
-5
app/lish/createPub/createPublication.ts
+24
-5
app/lish/createPub/createPublication.ts
···
1
1
"use server";
2
2
import { TID } from "@atproto/common";
3
3
import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
4
-
import { createOauthClient } from "src/atproto-oauth";
4
+
import {
5
+
restoreOAuthSession,
6
+
OAuthSessionError,
7
+
} from "src/atproto-oauth";
5
8
import { getIdentityData } from "actions/getIdentityData";
6
9
import { supabaseServerClient } from "supabase/serverClient";
7
10
import { Un$Typed } from "@atproto/api";
···
18
21
.min(3)
19
22
.max(63)
20
23
.regex(/^[a-z0-9-]+$/);
24
+
type CreatePublicationResult =
25
+
| { success: true; publication: any }
26
+
| { success: false; error?: OAuthSessionError };
27
+
21
28
export async function createPublication({
22
29
name,
23
30
description,
···
30
37
iconFile: File | null;
31
38
subdomain: string;
32
39
preferences: Omit<PubLeafletPublication.Preferences, "$type">;
33
-
}) {
40
+
}): Promise<CreatePublicationResult> {
34
41
let isSubdomainValid = subdomainValidator.safeParse(subdomain);
35
42
if (!isSubdomainValid.success) {
36
43
return { success: false };
37
44
}
38
-
const oauthClient = await createOauthClient();
39
45
let identity = await getIdentityData();
40
-
if (!identity || !identity.atp_did) return;
46
+
if (!identity || !identity.atp_did) {
47
+
return {
48
+
success: false,
49
+
error: {
50
+
type: "oauth_session_expired",
51
+
message: "Not authenticated",
52
+
did: "",
53
+
},
54
+
};
55
+
}
41
56
42
57
let domain = `${subdomain}.leaflet.pub`;
43
58
44
-
let credentialSession = await oauthClient.restore(identity.atp_did);
59
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
60
+
if (!sessionResult.ok) {
61
+
return { success: false, error: sessionResult.error };
62
+
}
63
+
let credentialSession = sessionResult.value;
45
64
let agent = new AtpBaseClient(
46
65
credentialSession.fetchHandler.bind(credentialSession),
47
66
);
+66
-18
app/lish/createPub/updatePublication.ts
+66
-18
app/lish/createPub/updatePublication.ts
···
5
5
PubLeafletPublication,
6
6
PubLeafletThemeColor,
7
7
} from "lexicons/api";
8
-
import { createOauthClient } from "src/atproto-oauth";
8
+
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
9
9
import { getIdentityData } from "actions/getIdentityData";
10
10
import { supabaseServerClient } from "supabase/serverClient";
11
11
import { Json } from "supabase/database.types";
12
12
import { AtUri } from "@atproto/syntax";
13
13
import { $Typed } from "@atproto/api";
14
+
15
+
type UpdatePublicationResult =
16
+
| { success: true; publication: any }
17
+
| { success: false; error?: OAuthSessionError };
14
18
15
19
export async function updatePublication({
16
20
uri,
···
21
25
}: {
22
26
uri: string;
23
27
name: string;
24
-
description: string;
25
-
iconFile: File | null;
28
+
description?: string;
29
+
iconFile?: File | null;
26
30
preferences?: Omit<PubLeafletPublication.Preferences, "$type">;
27
-
}) {
28
-
const oauthClient = await createOauthClient();
31
+
}): Promise<UpdatePublicationResult> {
29
32
let identity = await getIdentityData();
30
-
if (!identity || !identity.atp_did) return;
33
+
if (!identity || !identity.atp_did) {
34
+
return {
35
+
success: false,
36
+
error: {
37
+
type: "oauth_session_expired",
38
+
message: "Not authenticated",
39
+
did: "",
40
+
},
41
+
};
42
+
}
31
43
32
-
let credentialSession = await oauthClient.restore(identity.atp_did);
44
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
45
+
if (!sessionResult.ok) {
46
+
return { success: false, error: sessionResult.error };
47
+
}
48
+
let credentialSession = sessionResult.value;
33
49
let agent = new AtpBaseClient(
34
50
credentialSession.fetchHandler.bind(credentialSession),
35
51
);
···
38
54
.select("*")
39
55
.eq("uri", uri)
40
56
.single();
41
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
57
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
58
+
return { success: false };
59
+
}
42
60
let aturi = new AtUri(existingPub.uri);
43
61
44
62
let record: PubLeafletPublication.Record = {
···
94
112
}: {
95
113
uri: string;
96
114
base_path: string;
97
-
}) {
98
-
const oauthClient = await createOauthClient();
115
+
}): Promise<UpdatePublicationResult> {
99
116
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
117
+
if (!identity || !identity.atp_did) {
118
+
return {
119
+
success: false,
120
+
error: {
121
+
type: "oauth_session_expired",
122
+
message: "Not authenticated",
123
+
did: "",
124
+
},
125
+
};
126
+
}
101
127
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
128
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
129
+
if (!sessionResult.ok) {
130
+
return { success: false, error: sessionResult.error };
131
+
}
132
+
let credentialSession = sessionResult.value;
103
133
let agent = new AtpBaseClient(
104
134
credentialSession.fetchHandler.bind(credentialSession),
105
135
);
···
108
138
.select("*")
109
139
.eq("uri", uri)
110
140
.single();
111
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
141
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
142
+
return { success: false };
143
+
}
112
144
let aturi = new AtUri(existingPub.uri);
113
145
114
146
let record: PubLeafletPublication.Record = {
···
149
181
backgroundImage?: File | null;
150
182
backgroundRepeat?: number | null;
151
183
backgroundColor: Color;
184
+
pageWidth?: number;
152
185
primary: Color;
153
186
pageBackground: Color;
154
187
showPageBackground: boolean;
155
188
accentBackground: Color;
156
189
accentText: Color;
157
190
};
158
-
}) {
159
-
const oauthClient = await createOauthClient();
191
+
}): Promise<UpdatePublicationResult> {
160
192
let identity = await getIdentityData();
161
-
if (!identity || !identity.atp_did) return;
193
+
if (!identity || !identity.atp_did) {
194
+
return {
195
+
success: false,
196
+
error: {
197
+
type: "oauth_session_expired",
198
+
message: "Not authenticated",
199
+
did: "",
200
+
},
201
+
};
202
+
}
162
203
163
-
let credentialSession = await oauthClient.restore(identity.atp_did);
204
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
205
+
if (!sessionResult.ok) {
206
+
return { success: false, error: sessionResult.error };
207
+
}
208
+
let credentialSession = sessionResult.value;
164
209
let agent = new AtpBaseClient(
165
210
credentialSession.fetchHandler.bind(credentialSession),
166
211
);
···
169
214
.select("*")
170
215
.eq("uri", uri)
171
216
.single();
172
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
217
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
218
+
return { success: false };
219
+
}
173
220
let aturi = new AtUri(existingPub.uri);
174
221
175
222
let oldRecord = existingPub.record as PubLeafletPublication.Record;
···
197
244
...theme.backgroundColor,
198
245
}
199
246
: undefined,
247
+
pageWidth: theme.pageWidth,
200
248
primary: {
201
249
...theme.primary,
202
250
},
+40
-9
app/lish/subscribeToPublication.ts
+40
-9
app/lish/subscribeToPublication.ts
···
3
3
import { AtpBaseClient } from "lexicons/api";
4
4
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5
5
import { getIdentityData } from "actions/getIdentityData";
6
-
import { createOauthClient } from "src/atproto-oauth";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
7
10
import { TID } from "@atproto/common";
8
11
import { supabaseServerClient } from "supabase/serverClient";
9
12
import { revalidatePath } from "next/cache";
···
21
24
let leafletFeedURI =
22
25
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
23
26
let idResolver = new IdResolver();
27
+
28
+
type SubscribeResult =
29
+
| { success: true; hasFeed: boolean }
30
+
| { success: false; error: OAuthSessionError };
31
+
24
32
export async function subscribeToPublication(
25
33
publication: string,
26
34
redirectRoute?: string,
27
-
) {
28
-
const oauthClient = await createOauthClient();
35
+
): Promise<SubscribeResult | never> {
29
36
let identity = await getIdentityData();
30
37
if (!identity || !identity.atp_did) {
31
38
return redirect(
···
33
40
);
34
41
}
35
42
36
-
let credentialSession = await oauthClient.restore(identity.atp_did);
43
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
44
+
if (!sessionResult.ok) {
45
+
return { success: false, error: sessionResult.error };
46
+
}
47
+
let credentialSession = sessionResult.value;
37
48
let agent = new AtpBaseClient(
38
49
credentialSession.fetchHandler.bind(credentialSession),
39
50
);
···
90
101
) as AppBskyActorDefs.SavedFeedsPrefV2;
91
102
revalidatePath("/lish/[did]/[publication]", "layout");
92
103
return {
104
+
success: true,
93
105
hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
94
106
};
95
107
}
96
108
97
-
export async function unsubscribeToPublication(publication: string) {
98
-
const oauthClient = await createOauthClient();
109
+
type UnsubscribeResult =
110
+
| { success: true }
111
+
| { success: false; error: OAuthSessionError };
112
+
113
+
export async function unsubscribeToPublication(
114
+
publication: string
115
+
): Promise<UnsubscribeResult> {
99
116
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
117
+
if (!identity || !identity.atp_did) {
118
+
return {
119
+
success: false,
120
+
error: {
121
+
type: "oauth_session_expired",
122
+
message: "Not authenticated",
123
+
did: "",
124
+
},
125
+
};
126
+
}
101
127
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
128
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
129
+
if (!sessionResult.ok) {
130
+
return { success: false, error: sessionResult.error };
131
+
}
132
+
let credentialSession = sessionResult.value;
103
133
let agent = new AtpBaseClient(
104
134
credentialSession.fetchHandler.bind(credentialSession),
105
135
);
···
109
139
.eq("identity", identity.atp_did)
110
140
.eq("publication", publication)
111
141
.single();
112
-
if (!existingSubscription) return;
142
+
if (!existingSubscription) return { success: true };
113
143
await agent.pub.leaflet.graph.subscription.delete({
114
144
repo: credentialSession.did!,
115
145
rkey: new AtUri(existingSubscription.uri).rkey,
···
120
150
.eq("identity", identity.atp_did)
121
151
.eq("publication", publication);
122
152
revalidatePath("/lish/[did]/[publication]", "layout");
153
+
return { success: true };
123
154
}
+99
app/lish/uri/[uri]/route.ts
+99
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", {
36
+
status: 404,
37
+
});
38
+
}
39
+
40
+
// Redirect to the publication's hosted domain (temporary redirect since base_path can change)
41
+
return NextResponse.redirect(basePath, 307);
42
+
} else if (uri.collection === "pub.leaflet.document") {
43
+
// Document link - need to find the publication it belongs to
44
+
const { data: docInPub } = await supabaseServerClient
45
+
.from("documents_in_publications")
46
+
.select("publication, publications!inner(record)")
47
+
.eq("document", atUriString)
48
+
.single();
49
+
50
+
if (docInPub?.publication && docInPub.publications) {
51
+
// Document is in a publication - redirect to domain/rkey
52
+
const record = docInPub.publications
53
+
.record as PubLeafletPublication.Record;
54
+
const basePath = record.base_path;
55
+
56
+
if (!basePath) {
57
+
return new NextResponse("Publication has no base_path", {
58
+
status: 404,
59
+
});
60
+
}
61
+
62
+
// Ensure basePath ends without trailing slash
63
+
const cleanBasePath = basePath.endsWith("/")
64
+
? basePath.slice(0, -1)
65
+
: basePath;
66
+
67
+
// Redirect to the document on the publication's domain (temporary redirect since base_path can change)
68
+
return NextResponse.redirect(
69
+
`https://${cleanBasePath}/${uri.rkey}`,
70
+
307,
71
+
);
72
+
}
73
+
74
+
// If not in a publication, check if it's a standalone document
75
+
const { data: doc } = await supabaseServerClient
76
+
.from("documents")
77
+
.select("uri")
78
+
.eq("uri", atUriString)
79
+
.single();
80
+
81
+
if (doc) {
82
+
// Standalone document - redirect to /p/did/rkey (temporary redirect)
83
+
return NextResponse.redirect(
84
+
new URL(`/p/${uri.host}/${uri.rkey}`, request.url),
85
+
307,
86
+
);
87
+
}
88
+
89
+
// Document not found
90
+
return new NextResponse("Document not found", { status: 404 });
91
+
}
92
+
93
+
// Unsupported collection type
94
+
return new NextResponse("Unsupported URI type", { status: 400 });
95
+
} catch (error) {
96
+
console.error("Error resolving AT URI:", error);
97
+
return new NextResponse("Invalid URI", { status: 400 });
98
+
}
99
+
}
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { ids } from "lexicons/api/lexicons";
5
+
import { PubLeafletDocument } from "lexicons/api";
6
+
import { jsonToLex } from "@atproto/lexicon";
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
9
3
-
export const runtime = "edge";
4
10
export const revalidate = 60;
5
11
6
12
export default async function OpenGraphImage(props: {
7
13
params: Promise<{ rkey: string; didOrHandle: string }>;
8
14
}) {
9
15
let params = await props.params;
10
-
return getMicroLinkOgImage(
11
-
`/p/${params.didOrHandle}/${params.rkey}/`,
12
-
);
16
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
17
+
18
+
// Resolve handle to DID if needed
19
+
let did = didOrHandle;
20
+
if (!didOrHandle.startsWith("did:")) {
21
+
try {
22
+
let resolved = await idResolver.handle.resolve(didOrHandle);
23
+
if (resolved) did = resolved;
24
+
} catch (e) {
25
+
// Fall back to screenshot if handle resolution fails
26
+
}
27
+
}
28
+
29
+
if (did) {
30
+
// Try to get the document's cover image
31
+
let { data: document } = await supabaseServerClient
32
+
.from("documents")
33
+
.select("data")
34
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
35
+
.single();
36
+
37
+
if (document) {
38
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
39
+
if (docRecord.coverImage) {
40
+
try {
41
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
42
+
let cid =
43
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
44
+
docRecord.coverImage.ref.toString();
45
+
46
+
let imageResponse = await fetchAtprotoBlob(did, cid);
47
+
if (imageResponse) {
48
+
let imageBlob = await imageResponse.blob();
49
+
50
+
// Return the image with appropriate headers
51
+
return new Response(imageBlob, {
52
+
headers: {
53
+
"Content-Type": imageBlob.type || "image/jpeg",
54
+
"Cache-Control": "public, max-age=3600",
55
+
},
56
+
});
57
+
}
58
+
} catch (e) {
59
+
// Fall through to screenshot if cover image fetch fails
60
+
console.error("Failed to fetch cover image:", e);
61
+
}
62
+
}
63
+
}
64
+
}
65
+
66
+
// Fall back to screenshot
67
+
return getMicroLinkOgImage(`/p/${params.didOrHandle}/${params.rkey}/`);
13
68
}
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
···
5
5
import { Metadata } from "next";
6
6
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
7
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
8
9
9
10
export async function generateMetadata(props: {
10
11
params: Promise<{ didOrHandle: string; rkey: string }>;
···
34
35
let docRecord = document.data as PubLeafletDocument.Record;
35
36
36
37
// For documents in publications, include publication name
37
-
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
+
let publicationName =
39
+
document.documents_in_publications[0]?.publications?.name;
38
40
39
41
return {
40
42
icons: {
···
63
65
let resolved = await idResolver.handle.resolve(didOrHandle);
64
66
if (!resolved) {
65
67
return (
66
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
-
<p>Sorry, can't resolve handle.</p>
68
+
<NotFoundLayout>
69
+
<p className="font-bold">Sorry, we can't find this handle!</p>
68
70
<p>
69
71
This may be a glitch on our end. If the issue persists please{" "}
70
72
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
73
</p>
72
-
</div>
74
+
</NotFoundLayout>
73
75
);
74
76
}
75
77
did = resolved;
76
78
} catch (e) {
77
79
return (
78
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
-
<p>Sorry, can't resolve handle.</p>
80
+
<NotFoundLayout>
81
+
<p className="font-bold">Sorry, we can't find this leaflet!</p>
80
82
<p>
81
83
This may be a glitch on our end. If the issue persists please{" "}
82
84
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
85
</p>
84
-
</div>
86
+
</NotFoundLayout>
85
87
);
86
88
}
87
89
}
+8
-32
appview/index.ts
+8
-32
appview/index.ts
···
20
20
} from "@atproto/api";
21
21
import { AtUri } from "@atproto/syntax";
22
22
import { writeFile, readFile } from "fs/promises";
23
-
import { createIdentity } from "actions/createIdentity";
24
-
import { drizzle } from "drizzle-orm/node-postgres";
25
23
import { inngest } from "app/api/inngest/client";
26
-
import { Client } from "pg";
27
24
28
25
const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor";
29
26
···
135
132
if (evt.event === "create" || evt.event === "update") {
136
133
let record = PubLeafletPublication.validateRecord(evt.record);
137
134
if (!record.success) return;
138
-
let { error } = await supabase.from("publications").upsert({
135
+
await supabase
136
+
.from("identities")
137
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
138
+
await supabase.from("publications").upsert({
139
139
uri: evt.uri.toString(),
140
140
identity_did: evt.did,
141
141
name: record.value.name,
142
142
record: record.value as Json,
143
143
});
144
-
145
-
if (error && error.code === "23503") {
146
-
console.log("creating identity");
147
-
let client = new Client({ connectionString: process.env.DB_URL });
148
-
let db = drizzle(client);
149
-
await createIdentity(db, { atp_did: evt.did });
150
-
client.end();
151
-
await supabase.from("publications").upsert({
152
-
uri: evt.uri.toString(),
153
-
identity_did: evt.did,
154
-
name: record.value.name,
155
-
record: record.value as Json,
156
-
});
157
-
}
158
144
}
159
145
if (evt.event === "delete") {
160
146
await supabase
···
222
208
if (evt.event === "create" || evt.event === "update") {
223
209
let record = PubLeafletGraphSubscription.validateRecord(evt.record);
224
210
if (!record.success) return;
225
-
let { error } = await supabase.from("publication_subscriptions").upsert({
211
+
await supabase
212
+
.from("identities")
213
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
214
+
await supabase.from("publication_subscriptions").upsert({
226
215
uri: evt.uri.toString(),
227
216
identity: evt.did,
228
217
publication: record.value.publication,
229
218
record: record.value as Json,
230
219
});
231
-
if (error && error.code === "23503") {
232
-
console.log("creating identity");
233
-
let client = new Client({ connectionString: process.env.DB_URL });
234
-
let db = drizzle(client);
235
-
await createIdentity(db, { atp_did: evt.did });
236
-
client.end();
237
-
await supabase.from("publication_subscriptions").upsert({
238
-
uri: evt.uri.toString(),
239
-
identity: evt.did,
240
-
publication: record.value.publication,
241
-
record: record.value as Json,
242
-
});
243
-
}
244
220
}
245
221
if (evt.event === "delete") {
246
222
await supabase
+12
-14
components/ActionBar/ActionButton.tsx
+12
-14
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
···
11
11
_props: ButtonProps & {
12
12
id?: string;
13
13
icon: React.ReactNode;
14
-
label?: React.ReactNode;
14
+
label: React.ReactNode;
15
15
primary?: boolean;
16
16
secondary?: boolean;
17
17
nav?: boolean;
···
69
69
`}
70
70
>
71
71
<div className="shrink-0">{icon}</div>
72
-
{label && (
73
-
<div
74
-
className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
75
-
>
76
-
<div className="truncate text-left pt-[1px]">{label}</div>
77
-
{subtext && (
78
-
<div className="text-xs text-tertiary font-normal text-left">
79
-
{subtext}
80
-
</div>
81
-
)}
82
-
</div>
83
-
)}
72
+
<div
73
+
className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
74
+
>
75
+
<div className="truncate text-left pt-[1px]">{label}</div>
76
+
{subtext && (
77
+
<div className="text-xs text-tertiary font-normal text-left">
78
+
{subtext}
79
+
</div>
80
+
)}
81
+
</div>
84
82
</button>
85
83
);
86
84
};
+3
-4
components/ActionBar/Publications.tsx
+3
-4
components/ActionBar/Publications.tsx
···
23
23
currentPubUri: string | undefined;
24
24
}) => {
25
25
let { identity } = useIdentityData();
26
-
let hasLooseleafs = identity?.permission_token_on_homepage.find(
26
+
let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
27
27
(f) =>
28
28
f.permission_tokens.leaflets_to_documents &&
29
-
f.permission_tokens.leaflets_to_documents.document,
29
+
f.permission_tokens.leaflets_to_documents[0]?.document,
30
30
);
31
-
console.log(hasLooseleafs);
32
31
33
32
// don't show pub list button if not logged in or no pub list
34
33
// we show a "start a pub" banner instead
···
194
193
195
194
return props.record.icon ? (
196
195
<div
197
-
className={`${iconSizeClassName} ${props.className} relative overflow-hidden`}
196
+
className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`}
198
197
>
199
198
<img
200
199
src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+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-4 h-4 rounded-full mr-1 mt-[3px] 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={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
+
>
42
+
{icon}
43
+
{children}
44
+
</a>
45
+
);
46
+
}
+4
-1
components/Avatar.tsx
+4
-1
components/Avatar.tsx
···
3
3
export const Avatar = (props: {
4
4
src: string | undefined;
5
5
displayName: string | undefined;
6
+
className?: string;
6
7
tiny?: boolean;
8
+
large?: boolean;
9
+
giant?: boolean;
7
10
}) => {
8
11
if (props.src)
9
12
return (
10
13
<img
11
-
className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`}
14
+
className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`}
12
15
src={props.src}
13
16
alt={
14
17
props.displayName
+26
components/Blocks/Block.tsx
+26
components/Blocks/Block.tsx
···
383
383
);
384
384
};
385
385
386
+
export const BlockLayout = (props: {
387
+
isSelected?: boolean;
388
+
children: React.ReactNode;
389
+
className?: string;
390
+
hasBackground?: "accent" | "page";
391
+
borderOnHover?: boolean;
392
+
}) => {
393
+
return (
394
+
<div
395
+
className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden
396
+
${props.isSelected ? "block-border-selected " : "block-border"}
397
+
${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`}
398
+
style={{
399
+
backgroundColor:
400
+
props.hasBackground === "accent"
401
+
? "var(--accent-light)"
402
+
: props.hasBackground === "page"
403
+
? "rgb(var(--bg-page))"
404
+
: "transparent",
405
+
}}
406
+
>
407
+
{props.children}
408
+
</div>
409
+
);
410
+
};
411
+
386
412
export const ListMarker = (
387
413
props: Block & {
388
414
previousBlock?: Block | null;
+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
}}
+3
-3
components/Blocks/BlockCommands.tsx
+3
-3
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";
+59
-31
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
+59
-31
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
23
23
return (
24
24
<div className="flex flex-wrap rounded-md w-full overflow-hidden">
25
25
{imageEmbed.images.map(
26
-
(image: { fullsize: string; alt?: string }, i: number) => (
27
-
<img
28
-
key={i}
29
-
src={image.fullsize}
30
-
alt={image.alt || "Post image"}
31
-
className={`
32
-
overflow-hidden w-full object-cover
33
-
${imageEmbed.images.length === 1 && "h-auto max-h-[800px]"}
34
-
${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"}
35
-
${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"}
26
+
(
27
+
image: {
28
+
fullsize: string;
29
+
alt?: string;
30
+
aspectRatio?: { width: number; height: number };
31
+
},
32
+
i: number,
33
+
) => {
34
+
const isSingle = imageEmbed.images.length === 1;
35
+
const aspectRatio = image.aspectRatio
36
+
? image.aspectRatio.width / image.aspectRatio.height
37
+
: undefined;
38
+
39
+
return (
40
+
<img
41
+
key={i}
42
+
src={image.fullsize}
43
+
alt={image.alt || "Post image"}
44
+
style={
45
+
isSingle && aspectRatio
46
+
? { aspectRatio: String(aspectRatio) }
47
+
: undefined
48
+
}
49
+
className={`
50
+
overflow-hidden w-full object-cover
51
+
${isSingle && "max-h-[800px]"}
52
+
${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"}
53
+
${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"}
36
54
${
37
55
imageEmbed.images.length === 4
38
56
? "basis-1/2 aspect-3/2"
39
-
: `basis-1/${imageEmbed.images.length} `
57
+
: `basis-1/${imageEmbed.images.length}`
40
58
}
41
-
`}
42
-
/>
43
-
),
59
+
`}
60
+
/>
61
+
);
62
+
},
44
63
)}
45
64
</div>
46
65
);
···
49
68
let isGif = externalEmbed.external.uri.includes(".gif");
50
69
if (isGif) {
51
70
return (
52
-
<div className="flex flex-col border border-border-light rounded-md overflow-hidden">
71
+
<div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video">
53
72
<img
54
73
src={externalEmbed.external.uri}
55
74
alt={externalEmbed.external.title}
56
-
className="object-cover"
75
+
className="w-full h-full object-cover"
57
76
/>
58
77
</div>
59
78
);
···
66
85
>
67
86
{externalEmbed.external.thumb === undefined ? null : (
68
87
<>
69
-
<img
70
-
src={externalEmbed.external.thumb}
71
-
alt={externalEmbed.external.title}
72
-
className="object-cover"
73
-
/>
74
-
75
-
<hr className="border-border-light " />
88
+
<div className="w-full aspect-[1.91/1] overflow-hidden">
89
+
<img
90
+
src={externalEmbed.external.thumb}
91
+
alt={externalEmbed.external.title}
92
+
className="w-full h-full object-cover"
93
+
/>
94
+
</div>
95
+
<hr className="border-border-light" />
76
96
</>
77
97
)}
78
98
<div className="p-2 flex flex-col gap-1">
···
91
111
);
92
112
case AppBskyEmbedVideo.isView(props.embed):
93
113
let videoEmbed = props.embed;
114
+
const videoAspectRatio = videoEmbed.aspectRatio
115
+
? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height
116
+
: 16 / 9;
94
117
return (
95
-
<div className="rounded-md overflow-hidden relative">
118
+
<div
119
+
className="rounded-md overflow-hidden relative w-full"
120
+
style={{ aspectRatio: String(videoAspectRatio) }}
121
+
>
96
122
<img
97
123
src={videoEmbed.thumbnail}
98
124
alt={
99
125
"Thumbnail from embedded video. Go to Bluesky to see the full post."
100
126
}
101
-
className={`overflow-hidden w-full object-cover`}
127
+
className="absolute inset-0 w-full h-full object-cover"
102
128
/>
103
-
<div className="overlay absolute top-0 right-0 left-0 bottom-0 bg-primary opacity-65" />
129
+
<div className="overlay absolute inset-0 bg-primary opacity-65" />
104
130
<div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md">
105
131
<SeePostOnBluesky postUrl={props.postUrl} />
106
132
</div>
···
122
148
}
123
149
return (
124
150
<div
125
-
className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`}
151
+
className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`}
126
152
>
127
-
<div className="bskyAuthor w-full flex items-center gap-1">
153
+
<div className="bskyAuthor w-full flex items-center ">
128
154
{record.author.avatar && (
129
155
<img
130
156
src={record.author?.avatar}
131
157
alt={`${record.author?.displayName}'s avatar`}
132
-
className="shink-0 w-6 h-6 rounded-full border border-border-light"
158
+
className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]"
133
159
/>
134
160
)}
135
-
<div className=" font-bold text-secondary">
161
+
<div className=" font-bold text-secondary mr-1">
136
162
{record.author?.displayName}
137
163
</div>
138
164
<a
···
145
171
</div>
146
172
147
173
<div className="flex flex-col gap-2 ">
148
-
{text && <pre className="whitespace-pre-wrap">{text}</pre>}
174
+
{text && (
175
+
<pre className="whitespace-pre-wrap text-secondary">{text}</pre>
176
+
)}
149
177
{record.embeds !== undefined
150
178
? record.embeds.map((embed, index) => (
151
179
<BlueskyEmbed embed={embed} key={index} />
+9
-12
components/Blocks/BlueskyPostBlock/index.tsx
+9
-12
components/Blocks/BlueskyPostBlock/index.tsx
···
2
2
import { useEffect, useState } from "react";
3
3
import { useEntity } from "src/replicache";
4
4
import { useUIState } from "src/useUIState";
5
-
import { BlockProps } from "../Block";
5
+
import { BlockProps, BlockLayout } from "../Block";
6
6
import { elementId } from "src/utils/elementId";
7
7
import { focusBlock } from "src/utils/focusBlock";
8
8
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
···
56
56
AppBskyFeedDefs.isBlockedAuthor(post) ||
57
57
AppBskyFeedDefs.isNotFoundPost(post):
58
58
return (
59
-
<div
60
-
className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`}
61
-
>
59
+
<BlockLayout isSelected={!!isSelected} className="w-full">
62
60
<PostNotAvailable />
63
-
</div>
61
+
</BlockLayout>
64
62
);
65
63
66
64
case AppBskyFeedDefs.isThreadViewPost(post):
···
81
79
let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
82
80
83
81
return (
84
-
<div
85
-
className={`
86
-
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
87
-
${isSelected ? "block-border-selected " : "block-border"}
88
-
`}
82
+
<BlockLayout
83
+
isSelected={!!isSelected}
84
+
hasBackground="page"
85
+
className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary"
89
86
>
90
87
{post.post.author && record && (
91
88
<>
···
130
127
<div className="w-full flex gap-2 items-center justify-between">
131
128
{timestamp && <PostDate timestamp={timestamp} />}
132
129
<div className="flex gap-2 items-center">
133
-
{post.post.replyCount && post.post.replyCount > 0 && (
130
+
{post.post.replyCount != null && post.post.replyCount > 0 && (
134
131
<>
135
132
<a
136
133
className="flex items-center gap-1 hover:no-underline"
···
149
146
</a>
150
147
</div>
151
148
</div>
152
-
</div>
149
+
</BlockLayout>
153
150
);
154
151
}
155
152
};
+103
-103
components/Blocks/ButtonBlock.tsx
+103
-103
components/Blocks/ButtonBlock.tsx
···
3
3
import { useCallback, useEffect, useState } from "react";
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
7
import { v7 } from "uuid";
8
8
import { useSmoker } from "components/Toast";
9
9
···
106
106
};
107
107
108
108
return (
109
-
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full">
109
+
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full ">
110
110
<ButtonPrimary className="mx-auto">
111
111
{text !== "" ? text : "Button"}
112
112
</ButtonPrimary>
113
-
114
-
<form
115
-
className={`
116
-
buttonBlockSettingsBorder
117
-
w-full bg-bg-page
118
-
text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0
119
-
flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
120
-
${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"}
121
-
`}
122
-
onSubmit={(e) => {
123
-
e.preventDefault();
124
-
let rect = document
125
-
.getElementById("button-block-settings")
126
-
?.getBoundingClientRect();
127
-
if (!textValue) {
128
-
smoker({
129
-
error: true,
130
-
text: "missing button text!",
131
-
position: {
132
-
y: rect ? rect.top : 0,
133
-
x: rect ? rect.left + 12 : 0,
134
-
},
135
-
});
136
-
return;
137
-
}
138
-
if (!urlValue) {
139
-
smoker({
140
-
error: true,
141
-
text: "missing url!",
142
-
position: {
143
-
y: rect ? rect.top : 0,
144
-
x: rect ? rect.left + 12 : 0,
145
-
},
146
-
});
147
-
return;
148
-
}
149
-
if (!isUrl(urlValue)) {
150
-
smoker({
151
-
error: true,
152
-
text: "invalid url!",
153
-
position: {
154
-
y: rect ? rect.top : 0,
155
-
x: rect ? rect.left + 12 : 0,
156
-
},
157
-
});
158
-
return;
159
-
}
160
-
submit();
161
-
}}
113
+
<BlockLayout
114
+
isSelected={!!isSelected}
115
+
borderOnHover
116
+
hasBackground="accent"
117
+
className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!"
162
118
>
163
-
<div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
164
-
<div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
165
-
<BlockButtonSmall
166
-
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
167
-
/>
168
-
<Separator />
169
-
<Input
170
-
type="text"
171
-
autoFocus
172
-
className="w-full grow border-none outline-hidden bg-transparent"
173
-
placeholder="button text"
174
-
value={textValue}
175
-
disabled={isLocked}
176
-
onChange={(e) => setTextValue(e.target.value)}
177
-
onKeyDown={(e) => {
178
-
if (
179
-
e.key === "Backspace" &&
180
-
!e.currentTarget.value &&
181
-
urlValue !== ""
182
-
)
183
-
e.preventDefault();
184
-
}}
185
-
/>
186
-
</div>
187
-
<div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
188
-
<LinkSmall
189
-
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
190
-
/>
191
-
<Separator />
192
-
<Input
193
-
type="text"
194
-
id="button-block-url-input"
195
-
className="w-full grow border-none outline-hidden bg-transparent"
196
-
placeholder="www.example.com"
197
-
value={urlValue}
198
-
disabled={isLocked}
199
-
onChange={(e) => setUrlValue(e.target.value)}
200
-
onKeyDown={(e) => {
201
-
if (e.key === "Backspace" && !e.currentTarget.value)
202
-
e.preventDefault();
203
-
}}
204
-
/>
119
+
<form
120
+
className={`w-full`}
121
+
onSubmit={(e) => {
122
+
e.preventDefault();
123
+
let rect = document
124
+
.getElementById("button-block-settings")
125
+
?.getBoundingClientRect();
126
+
if (!textValue) {
127
+
smoker({
128
+
error: true,
129
+
text: "missing button text!",
130
+
position: {
131
+
y: rect ? rect.top : 0,
132
+
x: rect ? rect.left + 12 : 0,
133
+
},
134
+
});
135
+
return;
136
+
}
137
+
if (!urlValue) {
138
+
smoker({
139
+
error: true,
140
+
text: "missing url!",
141
+
position: {
142
+
y: rect ? rect.top : 0,
143
+
x: rect ? rect.left + 12 : 0,
144
+
},
145
+
});
146
+
return;
147
+
}
148
+
if (!isUrl(urlValue)) {
149
+
smoker({
150
+
error: true,
151
+
text: "invalid url!",
152
+
position: {
153
+
y: rect ? rect.top : 0,
154
+
x: rect ? rect.left + 12 : 0,
155
+
},
156
+
});
157
+
return;
158
+
}
159
+
submit();
160
+
}}
161
+
>
162
+
<div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
163
+
<div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
164
+
<BlockButtonSmall
165
+
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
166
+
/>
167
+
<Separator />
168
+
<Input
169
+
type="text"
170
+
autoFocus
171
+
className="w-full grow border-none outline-hidden bg-transparent"
172
+
placeholder="button text"
173
+
value={textValue}
174
+
disabled={isLocked}
175
+
onChange={(e) => setTextValue(e.target.value)}
176
+
onKeyDown={(e) => {
177
+
if (
178
+
e.key === "Backspace" &&
179
+
!e.currentTarget.value &&
180
+
urlValue !== ""
181
+
)
182
+
e.preventDefault();
183
+
}}
184
+
/>
185
+
</div>
186
+
<div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
187
+
<LinkSmall
188
+
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
189
+
/>
190
+
<Separator />
191
+
<Input
192
+
type="text"
193
+
id="button-block-url-input"
194
+
className="w-full grow border-none outline-hidden bg-transparent"
195
+
placeholder="www.example.com"
196
+
value={urlValue}
197
+
disabled={isLocked}
198
+
onChange={(e) => setUrlValue(e.target.value)}
199
+
onKeyDown={(e) => {
200
+
if (e.key === "Backspace" && !e.currentTarget.value)
201
+
e.preventDefault();
202
+
}}
203
+
/>
204
+
</div>
205
+
<button
206
+
id="button-block-settings"
207
+
type="submit"
208
+
className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`}
209
+
>
210
+
<div className="sm:hidden block">Save</div>
211
+
<CheckTiny />
212
+
</button>
205
213
</div>
206
-
<button
207
-
id="button-block-settings"
208
-
type="submit"
209
-
className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`}
210
-
>
211
-
<div className="sm:hidden block">Save</div>
212
-
<CheckTiny />
213
-
</button>
214
-
</div>
215
-
</form>
214
+
</form>
215
+
</BlockLayout>
216
216
</div>
217
217
);
218
218
};
+17
-6
components/Blocks/CodeBlock.tsx
+17
-6
components/Blocks/CodeBlock.tsx
···
6
6
} from "shiki";
7
7
import { useEntity, useReplicache } from "src/replicache";
8
8
import "katex/dist/katex.min.css";
9
-
import { BlockProps } from "./Block";
9
+
import { BlockLayout, BlockProps } from "./Block";
10
10
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
11
11
import { useUIState } from "src/useUIState";
12
12
import { BaseTextareaBlock } from "./BaseTextareaBlock";
···
119
119
</select>
120
120
</div>
121
121
)}
122
-
<div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline">
122
+
123
+
<BlockLayout
124
+
isSelected={focusedBlock}
125
+
hasBackground="accent"
126
+
borderOnHover
127
+
className="p-0! min-h-[48px]"
128
+
>
123
129
{focusedBlock && permissions.write ? (
124
130
<BaseTextareaBlock
131
+
placeholder="write some codeโฆ"
125
132
data-editable-block
126
133
data-entityid={props.entityID}
127
134
id={elementId.block(props.entityID).input}
···
131
138
spellCheck={false}
132
139
autoCapitalize="none"
133
140
autoCorrect="off"
134
-
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2"
141
+
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3"
135
142
value={content?.data.value}
136
143
onChange={async (e) => {
137
144
// Update the entity with the new value
···
146
153
<pre
147
154
onClick={onClick}
148
155
onMouseDown={(e) => e.stopPropagation()}
149
-
className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full"
156
+
className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full"
150
157
>
151
-
{content?.data.value}
158
+
{content?.data.value === "" || content?.data.value === undefined ? (
159
+
<div className="text-tertiary italic">write some codeโฆ</div>
160
+
) : (
161
+
content?.data.value
162
+
)}
152
163
</pre>
153
164
) : (
154
165
<div
···
159
170
dangerouslySetInnerHTML={{ __html: html || "" }}
160
171
/>
161
172
)}
162
-
</div>
173
+
</BlockLayout>
163
174
</div>
164
175
);
165
176
}
+5
-5
components/Blocks/DateTimeBlock.tsx
+5
-5
components/Blocks/DateTimeBlock.tsx
···
1
1
import { useEntity, useReplicache } from "src/replicache";
2
-
import { BlockProps } from "./Block";
2
+
import { BlockProps, BlockLayout } from "./Block";
3
3
import { ChevronProps, DayPicker } from "react-day-picker";
4
4
import { Popover } from "components/Popover";
5
5
import { useEffect, useMemo, useState } from "react";
···
121
121
disabled={isLocked || !permissions.write}
122
122
className="w-64 z-10 px-2!"
123
123
trigger={
124
-
<div
125
-
className={`flex flex-row gap-2 group/date w-64 z-1
126
-
${isSelected ? "block-border-selected border-transparent!" : "border border-transparent"}
124
+
<BlockLayout
125
+
isSelected={!!isSelected}
126
+
className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent!
127
127
${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"}
128
128
`}
129
129
>
···
163
163
</div>
164
164
)}
165
165
</FadeIn>
166
-
</div>
166
+
</BlockLayout>
167
167
}
168
168
>
169
169
<div className="flex flex-col gap-3 ">
+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
-
}
+13
-16
components/Blocks/EmbedBlock.tsx
+13
-16
components/Blocks/EmbedBlock.tsx
···
3
3
import { useCallback, useEffect, useState } from "react";
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
7
import { v7 } from "uuid";
8
8
import { useSmoker } from "components/Toast";
9
9
import { Separator } from "components/Layout";
···
84
84
<div
85
85
className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
86
86
>
87
-
{/*
88
-
the iframe!
89
-
can also add 'allow' and 'referrerpolicy' attributes later if needed
90
-
*/}
91
-
<iframe
92
-
className={`
93
-
flex flex-col relative w-full overflow-hidden group/embedBlock
94
-
${isSelected ? "block-border-selected " : "block-border"}
95
-
`}
96
-
width="100%"
97
-
height={height + (heightHandle.dragDelta?.y || 0)}
98
-
src={url?.data.value}
99
-
allow="fullscreen"
100
-
loading="lazy"
101
-
></iframe>
87
+
<BlockLayout
88
+
isSelected={!!isSelected}
89
+
className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!"
90
+
>
91
+
<iframe
92
+
width="100%"
93
+
height={height + (heightHandle.dragDelta?.y || 0)}
94
+
src={url?.data.value}
95
+
allow="fullscreen"
96
+
loading="lazy"
97
+
></iframe>
98
+
</BlockLayout>
102
99
{/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
103
100
<a
104
101
href={url?.data.value}
+45
-43
components/Blocks/ExternalLinkBlock.tsx
+45
-43
components/Blocks/ExternalLinkBlock.tsx
···
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
6
import { addLinkBlock } from "src/utils/addLinkBlock";
7
-
import { BlockProps } from "./Block";
7
+
import { BlockProps, BlockLayout } from "./Block";
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";
···
63
64
}
64
65
65
66
return (
66
-
<a
67
-
href={url?.data.value}
68
-
target="_blank"
69
-
className={`
70
-
externalLinkBlock flex relative group/linkBlock
71
-
h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline
72
-
hover:border-accent-contrast shadow-sm
73
-
${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"}
74
-
75
-
`}
67
+
<BlockLayout
68
+
isSelected={!!isSelected}
69
+
hasBackground="page"
70
+
borderOnHover
71
+
className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!"
76
72
>
77
-
<div className="pt-2 pb-2 px-3 grow min-w-0">
78
-
<div className="flex flex-col w-full min-w-0 h-full grow ">
79
-
<div
80
-
className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`}
81
-
style={{
82
-
overflow: "hidden",
83
-
textOverflow: "ellipsis",
84
-
wordBreak: "break-all",
85
-
}}
86
-
>
87
-
{title?.data.value}
88
-
</div>
73
+
<a
74
+
href={url?.data.value}
75
+
target="_blank"
76
+
className="flex w-full h-full text-primary hover:no-underline no-underline"
77
+
>
78
+
<div className="pt-2 pb-2 px-3 grow min-w-0">
79
+
<div className="flex flex-col w-full min-w-0 h-full grow ">
80
+
<div
81
+
className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`}
82
+
style={{
83
+
overflow: "hidden",
84
+
textOverflow: "ellipsis",
85
+
wordBreak: "break-all",
86
+
}}
87
+
>
88
+
{title?.data.value}
89
+
</div>
89
90
90
-
<div
91
-
className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`}
92
-
>
93
-
{description?.data.value}
94
-
</div>
95
-
<div
96
-
style={{ wordBreak: "break-word" }} // better than tailwind break-all!
97
-
className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`}
98
-
>
99
-
{url?.data.value}
91
+
<div
92
+
className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`}
93
+
>
94
+
{description?.data.value}
95
+
</div>
96
+
<div
97
+
style={{ wordBreak: "break-word" }} // better than tailwind break-all!
98
+
className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`}
99
+
>
100
+
{url?.data.value}
101
+
</div>
100
102
</div>
101
103
</div>
102
-
</div>
103
104
104
-
<div
105
-
className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`}
106
-
style={{
107
-
backgroundImage: `url(${previewImage?.data.src})`,
108
-
backgroundPosition: "center",
109
-
}}
110
-
/>
111
-
</a>
105
+
<div
106
+
className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`}
107
+
style={{
108
+
backgroundImage: `url(${previewImage?.data.src})`,
109
+
backgroundPosition: "center",
110
+
}}
111
+
/>
112
+
</a>
113
+
</BlockLayout>
112
114
);
113
115
};
114
116
+68
-24
components/Blocks/ImageBlock.tsx
+68
-24
components/Blocks/ImageBlock.tsx
···
1
1
"use client";
2
2
3
3
import { useEntity, useReplicache } from "src/replicache";
4
-
import { BlockProps } from "./Block";
4
+
import { BlockProps, BlockLayout } from "./Block";
5
5
import { useUIState } from "src/useUIState";
6
6
import Image from "next/image";
7
7
import { v7 } from "uuid";
···
17
17
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
18
18
import { set } from "colorjs.io/fn";
19
19
import { ImageAltSmall } from "components/Icons/ImageAlt";
20
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
21
+
import { useSubscribe } from "src/replicache/useSubscribe";
22
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
20
23
21
24
export function ImageBlock(props: BlockProps & { preview?: boolean }) {
22
25
let { rep } = useReplicache();
···
61
64
factID: v7(),
62
65
permission_set: entity_set.set,
63
66
type: "text",
64
-
position: generateKeyBetween(
65
-
props.position,
66
-
props.nextPosition,
67
-
),
67
+
position: generateKeyBetween(props.position, props.nextPosition),
68
68
newEntityID: entity,
69
69
});
70
70
}
···
82
82
if (!image) {
83
83
if (!entity_set.permissions.write) return null;
84
84
return (
85
-
<div className="grow w-full">
85
+
<BlockLayout
86
+
hasBackground="accent"
87
+
isSelected={!!isSelected && !isLocked}
88
+
borderOnHover
89
+
className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg"
90
+
>
86
91
<label
87
92
className={`
88
-
group/image-block
89
-
w-full h-[104px] hover:cursor-pointer p-2
90
-
text-tertiary hover:text-accent-contrast hover:font-bold
93
+
94
+
w-full h-full hover:cursor-pointer
91
95
flex flex-col items-center justify-center
92
-
hover:border-2 border-dashed hover:border-accent-contrast rounded-lg
93
-
${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"}
94
96
${props.pageType === "canvas" && "bg-bg-page"}`}
95
97
onMouseDown={(e) => e.preventDefault()}
96
98
onDragOver={(e) => {
···
104
106
const files = e.dataTransfer.files;
105
107
if (files && files.length > 0) {
106
108
const file = files[0];
107
-
if (file.type.startsWith('image/')) {
109
+
if (file.type.startsWith("image/")) {
108
110
await handleImageUpload(file);
109
111
}
110
112
}
···
128
130
}}
129
131
/>
130
132
</label>
131
-
</div>
133
+
</BlockLayout>
132
134
);
133
135
}
134
136
135
-
let className = isFullBleed
137
+
let imageClassName = isFullBleed
136
138
? ""
137
139
: isSelected
138
140
? "block-border-selected border-transparent! "
···
140
142
141
143
let isLocalUpload = localImages.get(image.data.src);
142
144
145
+
let blockClassName = `
146
+
relative group/image border-transparent! p-0! w-fit!
147
+
${isFullBleed && "-mx-3 sm:-mx-4"}
148
+
${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""}
149
+
${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""}
150
+
`;
151
+
143
152
return (
144
-
<div
145
-
className={`relative group/image
146
-
${className}
147
-
${isFullBleed && "-mx-3 sm:-mx-4"}
148
-
${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""}
149
-
${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `}
150
-
>
151
-
{isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null}
153
+
<BlockLayout isSelected={!!isSelected} className={blockClassName}>
152
154
{isLocalUpload || image.data.local ? (
153
155
<img
154
156
loading="lazy"
···
166
168
}
167
169
height={image?.data.height}
168
170
width={image?.data.width}
169
-
className={className}
171
+
className={imageClassName}
170
172
/>
171
173
)}
172
174
{altText !== undefined && !props.preview ? (
173
175
<ImageAlt entityID={props.value} />
174
176
) : null}
175
-
</div>
177
+
{!props.preview ? <CoverImageButton entityID={props.value} /> : null}
178
+
</BlockLayout>
176
179
);
177
180
}
178
181
···
188
191
altEditorOpen: false,
189
192
setAltEditorOpen: (s: boolean) => {},
190
193
});
194
+
195
+
const CoverImageButton = (props: { entityID: string }) => {
196
+
let { rep } = useReplicache();
197
+
let entity_set = useEntitySetContext();
198
+
let { data: pubData } = useLeafletPublicationData();
199
+
let coverImage = useSubscribe(rep, (tx) =>
200
+
tx.get<string | null>("publication_cover_image"),
201
+
);
202
+
let isFocused = useUIState(
203
+
(s) => s.focusedEntity?.entityID === props.entityID,
204
+
);
205
+
206
+
// Only show if focused, in a publication, has write permissions, and no cover image is set
207
+
if (
208
+
!isFocused ||
209
+
!pubData?.publications ||
210
+
!entity_set.permissions.write ||
211
+
coverImage
212
+
)
213
+
return null;
214
+
215
+
return (
216
+
<div className="absolute top-2 left-2">
217
+
<button
218
+
className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors"
219
+
onClick={async (e) => {
220
+
e.preventDefault();
221
+
e.stopPropagation();
222
+
await rep?.mutate.updatePublicationDraft({
223
+
cover_image: props.entityID,
224
+
});
225
+
}}
226
+
>
227
+
<span className="w-4 h-4 flex items-center justify-center">
228
+
<ImageCoverImage />
229
+
</span>
230
+
Set as Cover
231
+
</button>
232
+
</div>
233
+
);
234
+
};
191
235
192
236
const ImageAlt = (props: { entityID: string }) => {
193
237
let { rep } = useReplicache();
+81
-95
components/Blocks/MailboxBlock.tsx
+81
-95
components/Blocks/MailboxBlock.tsx
···
1
1
import { ButtonPrimary } from "components/Buttons";
2
2
import { Popover } from "components/Popover";
3
-
import { Menu, MenuItem, Separator } from "components/Layout";
3
+
import { MenuItem } from "components/Menu";
4
+
import { Separator } from "components/Layout";
4
5
import { useUIState } from "src/useUIState";
5
6
import { useState } from "react";
6
7
import { useSmoker, useToaster } from "components/Toast";
7
-
import { BlockProps } from "./Block";
8
+
import { BlockProps, BlockLayout } from "./Block";
8
9
import { useEntity, useReplicache } from "src/replicache";
9
10
import { useEntitySetContext } from "components/EntitySetProvider";
10
11
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
11
12
import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
12
-
import { focusPage } from "components/Pages";
13
+
import { focusPage } from "src/utils/focusPage";
13
14
import { v7 } from "uuid";
14
15
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
15
16
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
···
45
46
46
47
return (
47
48
<div className={`mailboxContent relative w-full flex flex-col gap-1`}>
48
-
<div
49
-
className={`flex flex-col gap-2 items-center justify-center w-full
50
-
${isSelected ? "block-border-selected " : "block-border"} `}
51
-
style={{
52
-
backgroundColor:
53
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
54
-
}}
49
+
<BlockLayout
50
+
isSelected={!!isSelected}
51
+
hasBackground={"accent"}
52
+
className="flex gap-2 items-center justify-center"
55
53
>
56
-
<div className="flex gap-2 p-4">
57
-
<ButtonPrimary
58
-
onClick={async () => {
59
-
let entity;
60
-
if (draft) {
61
-
entity = draft.data.value;
62
-
} else {
63
-
entity = v7();
64
-
await rep?.mutate.createDraft({
65
-
mailboxEntity: props.entityID,
66
-
permission_set: entity_set.set,
67
-
newEntity: entity,
68
-
firstBlockEntity: v7(),
69
-
firstBlockFactID: v7(),
70
-
});
71
-
}
72
-
useUIState.getState().openPage(props.parent, entity);
73
-
if (rep) focusPage(entity, rep, "focusFirstBlock");
74
-
return;
75
-
}}
76
-
>
77
-
{draft ? "Edit Draft" : "Write a Post"}
78
-
</ButtonPrimary>
79
-
<MailboxInfo />
80
-
</div>
81
-
</div>
54
+
<ButtonPrimary
55
+
onClick={async () => {
56
+
let entity;
57
+
if (draft) {
58
+
entity = draft.data.value;
59
+
} else {
60
+
entity = v7();
61
+
await rep?.mutate.createDraft({
62
+
mailboxEntity: props.entityID,
63
+
permission_set: entity_set.set,
64
+
newEntity: entity,
65
+
firstBlockEntity: v7(),
66
+
firstBlockFactID: v7(),
67
+
});
68
+
}
69
+
useUIState.getState().openPage(props.parent, entity);
70
+
if (rep) focusPage(entity, rep, "focusFirstBlock");
71
+
return;
72
+
}}
73
+
>
74
+
{draft ? "Edit Draft" : "Write a Post"}
75
+
</ButtonPrimary>
76
+
<MailboxInfo />
77
+
</BlockLayout>
82
78
<div className="flex gap-3 items-center justify-between">
83
79
{
84
80
<>
···
134
130
let { rep } = useReplicache();
135
131
return (
136
132
<div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}>
137
-
<div
138
-
className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${
139
-
isSelected
140
-
? "border-border outline-border"
141
-
: "border-border-light outline-transparent"
142
-
}`}
143
-
style={{
144
-
backgroundColor:
145
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
146
-
}}
133
+
<BlockLayout
134
+
isSelected={!!isSelected}
135
+
hasBackground={"accent"}
136
+
className="`h-full flex flex-col gap-2 items-center justify-center"
147
137
>
148
-
<div className="flex flex-col w-full gap-2 p-4">
149
-
{!isSubscribed?.confirmed ? (
150
-
<>
151
-
<SubscribeForm
152
-
entityID={props.entityID}
153
-
role={"reader"}
154
-
parent={props.parent}
155
-
/>
156
-
</>
157
-
) : (
158
-
<div className="flex flex-col gap-2 items-center place-self-center">
159
-
<div className=" font-bold text-secondary ">
160
-
You're Subscribed!
161
-
</div>
162
-
<div className="flex flex-col gap-1 items-center place-self-center">
163
-
{archive ? (
164
-
<ButtonPrimary
165
-
onMouseDown={(e) => {
166
-
e.preventDefault();
167
-
if (rep) {
168
-
useUIState
169
-
.getState()
170
-
.openPage(props.parent, archive.data.value);
171
-
focusPage(archive.data.value, rep);
172
-
}
173
-
}}
174
-
>
175
-
See All Posts
176
-
</ButtonPrimary>
177
-
) : (
178
-
<div className="text-tertiary">
179
-
Nothing has been posted yet
180
-
</div>
181
-
)}
182
-
<button
183
-
className="text-accent-contrast hover:underline text-sm"
184
-
onClick={(e) => {
185
-
let rect = e.currentTarget.getBoundingClientRect();
186
-
unsubscribe(isSubscribed);
187
-
smoke({
188
-
text: "unsubscribed!",
189
-
position: { x: rect.left, y: rect.top - 8 },
190
-
});
138
+
{!isSubscribed?.confirmed ? (
139
+
<>
140
+
<SubscribeForm
141
+
entityID={props.entityID}
142
+
role={"reader"}
143
+
parent={props.parent}
144
+
/>
145
+
</>
146
+
) : (
147
+
<div className="flex flex-col gap-2 items-center place-self-center">
148
+
<div className=" font-bold text-secondary ">
149
+
You're Subscribed!
150
+
</div>
151
+
<div className="flex flex-col gap-1 items-center place-self-center">
152
+
{archive ? (
153
+
<ButtonPrimary
154
+
onMouseDown={(e) => {
155
+
e.preventDefault();
156
+
if (rep) {
157
+
useUIState
158
+
.getState()
159
+
.openPage(props.parent, archive.data.value);
160
+
focusPage(archive.data.value, rep);
161
+
}
191
162
}}
192
163
>
193
-
unsubscribe
194
-
</button>
195
-
</div>
164
+
See All Posts
165
+
</ButtonPrimary>
166
+
) : (
167
+
<div className="text-tertiary">Nothing has been posted yet</div>
168
+
)}
169
+
<button
170
+
className="text-accent-contrast hover:underline text-sm"
171
+
onClick={(e) => {
172
+
let rect = e.currentTarget.getBoundingClientRect();
173
+
unsubscribe(isSubscribed);
174
+
smoke({
175
+
text: "unsubscribed!",
176
+
position: { x: rect.left, y: rect.top - 8 },
177
+
});
178
+
}}
179
+
>
180
+
unsubscribe
181
+
</button>
196
182
</div>
197
-
)}
198
-
</div>
199
-
</div>
183
+
</div>
184
+
)}
185
+
</BlockLayout>
200
186
</div>
201
187
);
202
188
};
+33
-23
components/Blocks/MathBlock.tsx
+33
-23
components/Blocks/MathBlock.tsx
···
1
1
import { useEntity, useReplicache } from "src/replicache";
2
2
import "katex/dist/katex.min.css";
3
-
import { BlockProps } from "./Block";
3
+
import { BlockLayout, BlockProps } from "./Block";
4
4
import Katex from "katex";
5
5
import { useMemo } from "react";
6
6
import { useUIState } from "src/useUIState";
···
32
32
}
33
33
}, [content?.data.value]);
34
34
return focusedBlock ? (
35
-
<BaseTextareaBlock
36
-
id={elementId.block(props.entityID).input}
37
-
block={props}
38
-
spellCheck={false}
39
-
autoCapitalize="none"
40
-
autoCorrect="off"
41
-
className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline"
42
-
placeholder="write some Tex here..."
43
-
value={content?.data.value}
44
-
onChange={async (e) => {
45
-
// Update the entity with the new value
46
-
await rep?.mutate.assertFact({
47
-
attribute: "block/math",
48
-
entity: props.entityID,
49
-
data: { type: "string", value: e.target.value },
50
-
});
51
-
}}
52
-
/>
35
+
<BlockLayout
36
+
isSelected={focusedBlock}
37
+
hasBackground="accent"
38
+
className="min-h-[48px]"
39
+
>
40
+
<BaseTextareaBlock
41
+
id={elementId.block(props.entityID).input}
42
+
block={props}
43
+
spellCheck={false}
44
+
autoCapitalize="none"
45
+
autoCorrect="off"
46
+
className="h-full w-full whitespace-nowrap overflow-auto!"
47
+
placeholder="write some Tex here..."
48
+
value={content?.data.value}
49
+
onChange={async (e) => {
50
+
// Update the entity with the new value
51
+
await rep?.mutate.assertFact({
52
+
attribute: "block/math",
53
+
entity: props.entityID,
54
+
data: { type: "string", value: e.target.value },
55
+
});
56
+
}}
57
+
/>
58
+
</BlockLayout>
53
59
) : html && content?.data.value ? (
54
60
<div
55
-
className="text-lg min-h-[66px] w-full border border-transparent"
61
+
className="text-lg min-h-[48px] w-full border border-transparent"
56
62
dangerouslySetInnerHTML={{ __html: html }}
57
63
/>
58
64
) : (
59
-
<div className="text-tertiary italic rounded-md p-2 w-full min-h-16">
60
-
write some Tex here...
61
-
</div>
65
+
<BlockLayout
66
+
isSelected={focusedBlock}
67
+
hasBackground="accent"
68
+
className="min-h-[48px]"
69
+
>
70
+
<div className="text-tertiary italic w-full ">write some Tex here...</div>
71
+
</BlockLayout>
62
72
);
63
73
}
+27
-23
components/Blocks/PageLinkBlock.tsx
+27
-23
components/Blocks/PageLinkBlock.tsx
···
1
1
"use client";
2
-
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
2
+
import { BlockProps, ListMarker, Block, BlockLayout } 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";
···
29
29
30
30
return (
31
31
<CardThemeProvider entityID={page?.data.value}>
32
-
<div
33
-
className={`w-full cursor-pointer
32
+
<BlockLayout
33
+
hasBackground="page"
34
+
isSelected={!!isSelected}
35
+
className={`cursor-pointer
34
36
pageLinkBlockWrapper relative group/pageLinkBlock
35
-
bg-bg-page shadow-sm
36
-
flex overflow-clip
37
-
${isSelected ? "block-border-selected " : "block-border"}
38
-
${isOpen && "border-tertiary!"}
37
+
flex overflow-clip p-0!
38
+
${isOpen && "border-accent-contrast! outline-accent-contrast!"}
39
39
`}
40
-
onClick={(e) => {
41
-
if (!page) return;
42
-
if (e.isDefaultPrevented()) return;
43
-
if (e.shiftKey) return;
44
-
e.preventDefault();
45
-
e.stopPropagation();
46
-
useUIState.getState().openPage(props.parent, page.data.value);
47
-
if (rep) focusPage(page.data.value, rep);
48
-
}}
49
40
>
50
-
{type === "canvas" && page ? (
51
-
<CanvasLinkBlock entityID={page?.data.value} />
52
-
) : (
53
-
<DocLinkBlock {...props} />
54
-
)}
55
-
</div>
41
+
<div
42
+
className="w-full h-full"
43
+
onClick={(e) => {
44
+
if (!page) return;
45
+
if (e.isDefaultPrevented()) return;
46
+
if (e.shiftKey) return;
47
+
e.preventDefault();
48
+
e.stopPropagation();
49
+
useUIState.getState().openPage(props.parent, page.data.value);
50
+
if (rep) focusPage(page.data.value, rep);
51
+
}}
52
+
>
53
+
{type === "canvas" && page ? (
54
+
<CanvasLinkBlock entityID={page?.data.value} />
55
+
) : (
56
+
<DocLinkBlock {...props} />
57
+
)}
58
+
</div>
59
+
</BlockLayout>
56
60
</CardThemeProvider>
57
61
);
58
62
}
+498
components/Blocks/PollBlock/index.tsx
+498
components/Blocks/PollBlock/index.tsx
···
1
+
import { useUIState } from "src/useUIState";
2
+
import { BlockProps, BlockLayout } 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
+
<BlockLayout
65
+
isSelected={!!isSelected}
66
+
hasBackground={"accent"}
67
+
className="poll flex flex-col gap-2 w-full"
68
+
>
69
+
{pollState === "editing" ? (
70
+
<EditPoll
71
+
totalVotes={totalVotes}
72
+
votes={votes.map((v) => v.poll_votes_on_entity)}
73
+
entityID={props.entityID}
74
+
close={() => {
75
+
if (hasVoted) setPollState("results");
76
+
else setPollState("voting");
77
+
}}
78
+
/>
79
+
) : pollState === "results" ? (
80
+
<PollResults
81
+
entityID={props.entityID}
82
+
pollState={pollState}
83
+
setPollState={setPollState}
84
+
hasVoted={!!hasVoted}
85
+
/>
86
+
) : (
87
+
<PollVote
88
+
entityID={props.entityID}
89
+
onSubmit={() => setPollState("results")}
90
+
pollState={pollState}
91
+
setPollState={setPollState}
92
+
hasVoted={!!hasVoted}
93
+
/>
94
+
)}
95
+
</BlockLayout>
96
+
);
97
+
};
98
+
99
+
const PollVote = (props: {
100
+
entityID: string;
101
+
onSubmit: () => void;
102
+
pollState: "editing" | "voting" | "results";
103
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
104
+
hasVoted: boolean;
105
+
}) => {
106
+
let { data, mutate } = usePollData();
107
+
let { permissions } = useEntitySetContext();
108
+
109
+
let pollOptions = useEntity(props.entityID, "poll/options");
110
+
let currentVotes = data?.voter_token
111
+
? data.polls
112
+
.filter(
113
+
(p) =>
114
+
p.poll_votes_on_entity.poll_entity === props.entityID &&
115
+
p.poll_votes_on_entity.voter_token === data.voter_token,
116
+
)
117
+
.map((v) => v.poll_votes_on_entity.option_entity)
118
+
: [];
119
+
let [selectedPollOptions, setSelectedPollOptions] =
120
+
useState<string[]>(currentVotes);
121
+
122
+
return (
123
+
<>
124
+
{pollOptions.map((option, index) => (
125
+
<PollVoteButton
126
+
key={option.data.value}
127
+
selected={selectedPollOptions.includes(option.data.value)}
128
+
toggleSelected={() =>
129
+
setSelectedPollOptions((s) =>
130
+
s.includes(option.data.value)
131
+
? s.filter((s) => s !== option.data.value)
132
+
: [...s, option.data.value],
133
+
)
134
+
}
135
+
entityID={option.data.value}
136
+
/>
137
+
))}
138
+
<div className="flex justify-between items-center">
139
+
<div className="flex justify-end gap-2">
140
+
{permissions.write && (
141
+
<button
142
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
143
+
onClick={() => {
144
+
props.setPollState("editing");
145
+
}}
146
+
>
147
+
Edit Options
148
+
</button>
149
+
)}
150
+
151
+
{permissions.write && <Separator classname="h-6" />}
152
+
<PollStateToggle
153
+
setPollState={props.setPollState}
154
+
pollState={props.pollState}
155
+
hasVoted={props.hasVoted}
156
+
/>
157
+
</div>
158
+
<ButtonPrimary
159
+
className="place-self-end"
160
+
onClick={async () => {
161
+
await voteOnPoll(props.entityID, selectedPollOptions);
162
+
mutate((oldState) => {
163
+
if (!oldState || !oldState.voter_token) return;
164
+
return {
165
+
...oldState,
166
+
polls: [
167
+
...oldState.polls.filter(
168
+
(p) =>
169
+
!(
170
+
p.poll_votes_on_entity.voter_token ===
171
+
oldState.voter_token &&
172
+
p.poll_votes_on_entity.poll_entity == props.entityID
173
+
),
174
+
),
175
+
...selectedPollOptions.map((option_entity) => ({
176
+
poll_votes_on_entity: {
177
+
option_entity,
178
+
entities: { set: "" },
179
+
poll_entity: props.entityID,
180
+
voter_token: oldState.voter_token!,
181
+
},
182
+
})),
183
+
],
184
+
};
185
+
});
186
+
props.onSubmit();
187
+
}}
188
+
disabled={
189
+
selectedPollOptions.length === 0 ||
190
+
(selectedPollOptions.length === currentVotes.length &&
191
+
selectedPollOptions.every((s) => currentVotes.includes(s)))
192
+
}
193
+
>
194
+
Vote!
195
+
</ButtonPrimary>
196
+
</div>
197
+
</>
198
+
);
199
+
};
200
+
const PollVoteButton = (props: {
201
+
entityID: string;
202
+
selected: boolean;
203
+
toggleSelected: () => void;
204
+
}) => {
205
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
206
+
if (!optionName) return null;
207
+
if (props.selected)
208
+
return (
209
+
<div className="flex gap-2 items-center">
210
+
<ButtonPrimary
211
+
className={`pollOption grow max-w-full flex`}
212
+
onClick={() => {
213
+
props.toggleSelected();
214
+
}}
215
+
>
216
+
{optionName}
217
+
</ButtonPrimary>
218
+
</div>
219
+
);
220
+
return (
221
+
<div className="flex gap-2 items-center">
222
+
<ButtonSecondary
223
+
className={`pollOption grow max-w-full flex`}
224
+
onClick={() => {
225
+
props.toggleSelected();
226
+
}}
227
+
>
228
+
{optionName}
229
+
</ButtonSecondary>
230
+
</div>
231
+
);
232
+
};
233
+
234
+
const PollResults = (props: {
235
+
entityID: string;
236
+
pollState: "editing" | "voting" | "results";
237
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
238
+
hasVoted: boolean;
239
+
}) => {
240
+
let { data } = usePollData();
241
+
let { permissions } = useEntitySetContext();
242
+
let pollOptions = useEntity(props.entityID, "poll/options");
243
+
let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
244
+
let votesByOptions = pollData?.votesByOption || {};
245
+
let highestVotes = Math.max(...Object.values(votesByOptions));
246
+
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
247
+
(winningEntities, [entity, votes]) => {
248
+
if (votes === highestVotes) winningEntities.push(entity);
249
+
return winningEntities;
250
+
},
251
+
[],
252
+
);
253
+
return (
254
+
<>
255
+
{pollOptions.map((p) => (
256
+
<PollResult
257
+
key={p.id}
258
+
winner={winningOptionEntities.includes(p.data.value)}
259
+
entityID={p.data.value}
260
+
totalVotes={pollData?.unique_votes || 0}
261
+
votes={pollData?.votesByOption[p.data.value] || 0}
262
+
/>
263
+
))}
264
+
<div className="flex gap-2">
265
+
{permissions.write && (
266
+
<button
267
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
268
+
onClick={() => {
269
+
props.setPollState("editing");
270
+
}}
271
+
>
272
+
Edit Options
273
+
</button>
274
+
)}
275
+
276
+
{permissions.write && <Separator classname="h-6" />}
277
+
<PollStateToggle
278
+
setPollState={props.setPollState}
279
+
pollState={props.pollState}
280
+
hasVoted={props.hasVoted}
281
+
/>
282
+
</div>
283
+
</>
284
+
);
285
+
};
286
+
287
+
const PollResult = (props: {
288
+
entityID: string;
289
+
votes: number;
290
+
totalVotes: number;
291
+
winner: boolean;
292
+
}) => {
293
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
294
+
return (
295
+
<div
296
+
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
297
+
>
298
+
<div
299
+
style={{
300
+
WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
301
+
paintOrder: "stroke fill",
302
+
}}
303
+
className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
304
+
>
305
+
<div className="grow max-w-full truncate">{optionName}</div>
306
+
<div>{props.votes}</div>
307
+
</div>
308
+
<div
309
+
className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
310
+
>
311
+
<div
312
+
className={`bg-accent-contrast rounded-[2px] m-0.5`}
313
+
style={{
314
+
maskImage: "var(--hatchSVG)",
315
+
maskRepeat: "repeat repeat",
316
+
317
+
...(props.votes === 0
318
+
? { width: "4px" }
319
+
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
320
+
}}
321
+
/>
322
+
<div />
323
+
</div>
324
+
</div>
325
+
);
326
+
};
327
+
328
+
const EditPoll = (props: {
329
+
votes: { option_entity: string }[];
330
+
totalVotes: number;
331
+
entityID: string;
332
+
close: () => void;
333
+
}) => {
334
+
let pollOptions = useEntity(props.entityID, "poll/options");
335
+
let { rep } = useReplicache();
336
+
let permission_set = useEntitySetContext();
337
+
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
338
+
[k: string]: string;
339
+
}>({});
340
+
return (
341
+
<>
342
+
{props.totalVotes > 0 && (
343
+
<div className="text-sm italic text-tertiary">
344
+
You can't edit options people already voted for!
345
+
</div>
346
+
)}
347
+
348
+
{pollOptions.length === 0 && (
349
+
<div className="text-center italic text-tertiary text-sm">
350
+
no options yet...
351
+
</div>
352
+
)}
353
+
{pollOptions.map((p) => (
354
+
<EditPollOption
355
+
key={p.id}
356
+
entityID={p.data.value}
357
+
pollEntity={props.entityID}
358
+
disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
359
+
localNameState={localPollOptionNames[p.data.value]}
360
+
setLocalNameState={setLocalPollOptionNames}
361
+
/>
362
+
))}
363
+
364
+
<button
365
+
className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
366
+
onClick={async () => {
367
+
let pollOptionEntity = v7();
368
+
await rep?.mutate.addPollOption({
369
+
pollEntity: props.entityID,
370
+
pollOptionEntity,
371
+
pollOptionName: "",
372
+
permission_set: permission_set.set,
373
+
factID: v7(),
374
+
});
375
+
376
+
focusElement(
377
+
document.getElementById(
378
+
elementId.block(props.entityID).pollInput(pollOptionEntity),
379
+
) as HTMLInputElement | null,
380
+
);
381
+
}}
382
+
>
383
+
Add an Option
384
+
</button>
385
+
386
+
<hr className="border-border" />
387
+
<ButtonPrimary
388
+
className="place-self-end"
389
+
onClick={async () => {
390
+
// remove any poll options that have no name
391
+
// look through the localPollOptionNames object and remove any options that have no name
392
+
let emptyOptions = Object.entries(localPollOptionNames).filter(
393
+
([optionEntity, optionName]) => optionName === "",
394
+
);
395
+
await Promise.all(
396
+
emptyOptions.map(
397
+
async ([entity]) =>
398
+
await rep?.mutate.removePollOption({
399
+
optionEntity: entity,
400
+
}),
401
+
),
402
+
);
403
+
404
+
await rep?.mutate.assertFact(
405
+
Object.entries(localPollOptionNames)
406
+
.filter(([, name]) => !!name)
407
+
.map(([entity, name]) => ({
408
+
entity,
409
+
attribute: "poll-option/name",
410
+
data: { type: "string", value: name },
411
+
})),
412
+
);
413
+
props.close();
414
+
}}
415
+
>
416
+
Save <CheckTiny />
417
+
</ButtonPrimary>
418
+
</>
419
+
);
420
+
};
421
+
422
+
const EditPollOption = (props: {
423
+
entityID: string;
424
+
pollEntity: string;
425
+
localNameState: string | undefined;
426
+
setLocalNameState: (
427
+
s: (s: { [k: string]: string }) => { [k: string]: string },
428
+
) => void;
429
+
disabled: boolean;
430
+
}) => {
431
+
let { rep } = useReplicache();
432
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
433
+
useEffect(() => {
434
+
props.setLocalNameState((s) => ({
435
+
...s,
436
+
[props.entityID]: optionName || "",
437
+
}));
438
+
}, [optionName, props.setLocalNameState, props.entityID]);
439
+
440
+
return (
441
+
<div className="flex gap-2 items-center">
442
+
<Input
443
+
id={elementId.block(props.pollEntity).pollInput(props.entityID)}
444
+
type="text"
445
+
className="pollOptionInput w-full input-with-border"
446
+
placeholder="Option here..."
447
+
disabled={props.disabled}
448
+
value={
449
+
props.localNameState === undefined ? optionName : props.localNameState
450
+
}
451
+
onChange={(e) => {
452
+
props.setLocalNameState((s) => ({
453
+
...s,
454
+
[props.entityID]: e.target.value,
455
+
}));
456
+
}}
457
+
onKeyDown={(e) => {
458
+
if (e.key === "Backspace" && !e.currentTarget.value) {
459
+
e.preventDefault();
460
+
rep?.mutate.removePollOption({ optionEntity: props.entityID });
461
+
}
462
+
}}
463
+
/>
464
+
465
+
<button
466
+
tabIndex={-1}
467
+
disabled={props.disabled}
468
+
className="text-accent-contrast disabled:text-border"
469
+
onMouseDown={async () => {
470
+
await rep?.mutate.removePollOption({ optionEntity: props.entityID });
471
+
}}
472
+
>
473
+
<CloseTiny />
474
+
</button>
475
+
</div>
476
+
);
477
+
};
478
+
479
+
const PollStateToggle = (props: {
480
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
481
+
hasVoted: boolean;
482
+
pollState: "editing" | "voting" | "results";
483
+
}) => {
484
+
return (
485
+
<button
486
+
className="text-sm text-accent-contrast "
487
+
onClick={() => {
488
+
props.setPollState(props.pollState === "voting" ? "results" : "voting");
489
+
}}
490
+
>
491
+
{props.pollState === "voting"
492
+
? "See Results"
493
+
: props.hasVoted
494
+
? "Change Vote"
495
+
: "Back to Poll"}
496
+
</button>
497
+
);
498
+
};
+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
-
};
+8
-10
components/Blocks/PublicationPollBlock.tsx
+8
-10
components/Blocks/PublicationPollBlock.tsx
···
1
1
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "./Block";
2
+
import { BlockLayout, 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";
···
52
53
}, [publicationData, props.entityID]);
53
54
54
55
return (
55
-
<div
56
-
className={`poll flex flex-col gap-2 p-3 w-full
57
-
${isSelected ? "block-border-selected " : "block-border"}`}
58
-
style={{
59
-
backgroundColor:
60
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
61
-
}}
56
+
<BlockLayout
57
+
className="poll flex flex-col gap-2"
58
+
hasBackground={"accent"}
59
+
isSelected={!!isSelected}
62
60
>
63
61
<EditPollForPublication
64
62
entityID={props.entityID}
65
63
isPublished={isPublished}
66
64
/>
67
-
</div>
65
+
</BlockLayout>
68
66
);
69
67
};
70
68
+6
-8
components/Blocks/RSVPBlock/index.tsx
+6
-8
components/Blocks/RSVPBlock/index.tsx
···
1
1
"use client";
2
2
import { Database } from "supabase/database.types";
3
-
import { BlockProps } from "components/Blocks/Block";
3
+
import { BlockProps, BlockLayout } from "components/Blocks/Block";
4
4
import { useState } from "react";
5
5
import { submitRSVP } from "actions/phone_rsvp_to_event";
6
6
import { useRSVPData } from "components/PageSWRDataProvider";
···
29
29
s.selectedBlocks.find((b) => b.value === props.entityID),
30
30
);
31
31
return (
32
-
<div
33
-
className={`rsvp relative flex flex-col gap-1 border p-3 w-full rounded-lg place-items-center justify-center ${isSelected ? "block-border-selected " : "block-border"}`}
34
-
style={{
35
-
backgroundColor:
36
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
37
-
}}
32
+
<BlockLayout
33
+
isSelected={!!isSelected}
34
+
hasBackground={"accent"}
35
+
className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center"
38
36
>
39
37
<RSVPForm entityID={props.entityID} />
40
-
</div>
38
+
</BlockLayout>
41
39
);
42
40
}
43
41
+42
-37
components/Blocks/TextBlock/RenderYJSFragment.tsx
+42
-37
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";
9
+
import { ProfilePopover } from "components/ProfilePopover";
6
10
7
11
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
8
12
export function RenderYJSFragment({
···
60
64
);
61
65
}
62
66
63
-
if (node.constructor === XmlElement && node.nodeName === "hard_break") {
67
+
if (
68
+
node.constructor === XmlElement &&
69
+
node.nodeName === "hard_break"
70
+
) {
64
71
return <br key={index} />;
65
72
}
66
73
74
+
// Handle didMention inline nodes
75
+
if (
76
+
node.constructor === XmlElement &&
77
+
node.nodeName === "didMention"
78
+
) {
79
+
const did = node.getAttribute("did") || "";
80
+
const text = node.getAttribute("text") || "";
81
+
return (
82
+
<a
83
+
href={didToBlueskyUrl(did)}
84
+
target="_blank"
85
+
rel="noopener noreferrer"
86
+
key={index}
87
+
className="mention"
88
+
>
89
+
{text}
90
+
</a>
91
+
);
92
+
}
93
+
94
+
// Handle atMention inline nodes
95
+
if (
96
+
node.constructor === XmlElement &&
97
+
node.nodeName === "atMention"
98
+
) {
99
+
const atURI = node.getAttribute("atURI") || "";
100
+
const text = node.getAttribute("text") || "";
101
+
return (
102
+
<AtMentionLink key={index} atURI={atURI}>
103
+
{text}
104
+
</AtMentionLink>
105
+
);
106
+
}
107
+
67
108
return null;
68
109
})
69
110
)}
···
101
142
}
102
143
};
103
144
104
-
export type Delta = {
105
-
insert: string;
106
-
attributes?: {
107
-
strong?: {};
108
-
code?: {};
109
-
em?: {};
110
-
underline?: {};
111
-
strikethrough?: {};
112
-
highlight?: { color: string };
113
-
link?: { href: string };
114
-
};
115
-
};
116
-
117
145
function attributesToStyle(d: Delta) {
118
146
let props = {
119
147
style: {},
···
143
171
144
172
return props;
145
173
}
146
-
147
-
export function YJSFragmentToString(
148
-
node: XmlElement | XmlText | XmlHook,
149
-
): string {
150
-
if (node.constructor === XmlElement) {
151
-
// Handle hard_break nodes specially
152
-
if (node.nodeName === "hard_break") {
153
-
return "\n";
154
-
}
155
-
return node
156
-
.toArray()
157
-
.map((f) => YJSFragmentToString(f))
158
-
.join("");
159
-
}
160
-
if (node.constructor === XmlText) {
161
-
return (node.toDelta() as Delta[])
162
-
.map((d) => {
163
-
return d.insert;
164
-
})
165
-
.join("");
166
-
}
167
-
return "";
168
-
}
+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
};
+20
components/Blocks/TextBlock/inputRules.ts
+20
components/Blocks/TextBlock/inputRules.ts
···
15
15
export const inputrules = (
16
16
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17
17
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18
+
openMentionAutocomplete?: () => void,
18
19
) =>
19
20
inputRules({
20
21
//Strikethrough
···
189
190
data: { type: "number", value: headingLevel },
190
191
});
191
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
192
212
}),
193
213
],
194
214
});
+5
-8
components/Blocks/TextBlock/keymap.ts
+5
-8
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
145
// Insert a hard break
+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,
+101
-1
components/Blocks/TextBlock/schema.ts
+101
-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
···
122
123
parseDOM: [{ tag: "br" }],
123
124
toDOM: () => ["br"] as const,
124
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 mention";
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:
172
+
"inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top",
173
+
alt: "",
174
+
width: "16",
175
+
height: "16",
176
+
loading: "lazy",
177
+
},
178
+
],
179
+
node.attrs.text,
180
+
];
181
+
}
182
+
183
+
return [
184
+
"span",
185
+
{
186
+
class: className,
187
+
"data-at-uri": node.attrs.atURI,
188
+
},
189
+
node.attrs.text,
190
+
];
191
+
},
192
+
} as NodeSpec,
193
+
didMention: {
194
+
attrs: {
195
+
did: {},
196
+
text: { default: "" },
197
+
},
198
+
group: "inline",
199
+
inline: true,
200
+
atom: true,
201
+
selectable: true,
202
+
draggable: true,
203
+
parseDOM: [
204
+
{
205
+
tag: "span.didMention",
206
+
getAttrs(dom: HTMLElement) {
207
+
return {
208
+
did: dom.getAttribute("data-did"),
209
+
text: dom.textContent || "",
210
+
};
211
+
},
212
+
},
213
+
],
214
+
toDOM(node) {
215
+
return [
216
+
"span",
217
+
{
218
+
class: "didMention mention",
219
+
"data-did": node.attrs.did,
220
+
},
221
+
node.attrs.text,
222
+
];
223
+
},
224
+
} as NodeSpec,
125
225
},
126
226
};
127
227
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";
+11
-5
components/Buttons.tsx
+11
-5
components/Buttons.tsx
···
38
38
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
39
39
bg-accent-1 disabled:bg-border-light
40
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
41
+
outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
42
42
text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border
43
43
flex gap-2 items-center justify-center shrink-0
44
44
${className}
···
77
77
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
78
78
bg-bg-page disabled:bg-border-light
79
79
border border-accent-contrast rounded-md
80
-
outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
80
+
outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
81
81
text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border
82
82
flex gap-2 items-center justify-center shrink-0
83
83
${props.className}
···
116
116
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
117
117
bg-transparent hover:bg-[var(--accent-light)]
118
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
119
+
outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
120
120
text-base font-bold text-accent-contrast disabled:text-border
121
121
flex gap-2 items-center justify-center shrink-0
122
122
${props.className}
···
165
165
side={props.side ? props.side : undefined}
166
166
sideOffset={6}
167
167
alignOffset={12}
168
-
className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm"
168
+
className="z-10 rounded-md py-1 px-[6px] font-bold text-secondary text-sm"
169
+
style={{
170
+
backgroundColor:
171
+
"color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)",
172
+
}}
169
173
>
170
174
{props.tooltipContent}
171
175
<RadixTooltip.Arrow
···
175
179
viewBox="0 0 16 8"
176
180
>
177
181
<PopoverArrow
178
-
arrowFill={theme.colors["border"]}
182
+
arrowFill={
183
+
"color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)"
184
+
}
179
185
arrowStroke="transparent"
180
186
/>
181
187
</RadixTooltip.Arrow>
+6
-3
components/Canvas.tsx
+6
-3
components/Canvas.tsx
···
170
170
171
171
let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172
172
let showComments = pubRecord.preferences?.showComments;
173
+
let showMentions = pubRecord.preferences?.showMentions;
173
174
174
175
return (
175
176
<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">
···
178
179
<CommentTiny className="text-border" /> โ
179
180
</div>
180
181
)}
181
-
<div className="flex gap-1 text-tertiary items-center">
182
-
<QuoteTiny className="text-border" /> โ
183
-
</div>
182
+
{showComments && (
183
+
<div className="flex gap-1 text-tertiary items-center">
184
+
<QuoteTiny className="text-border" /> โ
185
+
</div>
186
+
)}
184
187
185
188
{!props.isSubpage && (
186
189
<>
-17
components/EmptyState.tsx
-17
components/EmptyState.tsx
···
1
-
export const EmptyState = (props: {
2
-
children: React.ReactNode;
3
-
className?: string;
4
-
}) => {
5
-
return (
6
-
<div
7
-
className={`
8
-
flex flex-col gap-2 justify-between
9
-
container bg-[rgba(var(--bg-page),.7)]
10
-
sm:p-4 p-3 mt-2
11
-
text-center text-tertiary
12
-
${props.className}`}
13
-
>
14
-
{props.children}
15
-
</div>
16
-
);
17
-
};
+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
+
};
+14
components/Icons/ImageCoverImage.tsx
+14
components/Icons/ImageCoverImage.tsx
···
1
+
export const ImageCoverImage = () => (
2
+
<svg
3
+
width="24"
4
+
height="24"
5
+
viewBox="0 0 24 24"
6
+
fill="none"
7
+
xmlns="http://www.w3.org/2000/svg"
8
+
>
9
+
<path
10
+
d="M20.1631 2.56445C21.8887 2.56481 23.2881 3.96378 23.2881 5.68945V18.3105C23.288 20.0361 21.8886 21.4362 20.1631 21.4365H3.83789C2.11225 21.4365 0.713286 20.0371 0.712891 18.3115V5.68945C0.712891 3.96356 2.112 2.56445 3.83789 2.56445H20.1631ZM1.96289 18.3115C1.96329 19.3467 2.8026 20.1865 3.83789 20.1865H20.1631C21.1982 20.1862 22.038 19.3457 22.0381 18.3105V15.8066H1.96289V18.3115ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5936 15.2383 18.0078C15.2383 18.422 14.9025 18.7578 14.4883 18.7578H3.81543C3.40138 18.7576 3.06543 18.4219 3.06543 18.0078C3.06546 17.5937 3.4014 17.258 3.81543 17.2578H14.4883ZM19.9775 10.9688C19.5515 11.5175 18.8232 11.7343 18.166 11.5088L16.3213 10.876C16.2238 10.8425 16.1167 10.8506 16.0254 10.8984L15.0215 11.4238C14.4872 11.7037 13.8413 11.6645 13.3447 11.3223L12.6826 10.8652L11.3467 12.2539L11.6924 12.4844C11.979 12.6758 12.0572 13.0635 11.8662 13.3506C11.6751 13.6377 11.2873 13.7151 11 13.5244L10.0312 12.8799L8.81152 12.0654L8.03027 12.8691C7.5506 13.3622 6.78589 13.4381 6.21875 13.0488C6.17033 13.0156 6.10738 13.0112 6.05469 13.0371L4.79883 13.6572C4.25797 13.9241 3.61321 13.8697 3.125 13.5156L2.26172 12.8887L1.96289 13.1572V14.5566H22.0381V10.1299L21.1738 9.42383L19.9775 10.9688ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0361L3.85938 12.5039C3.97199 12.5854 4.12044 12.5977 4.24512 12.5361L5.50098 11.917C5.95929 11.6908 6.50439 11.7294 6.92578 12.0186C6.99106 12.0633 7.07957 12.0548 7.13477 11.998L7.75488 11.3604L5.58984 9.91504L4.71094 10.7012ZM3.83789 3.81445C2.80236 3.81445 1.96289 4.65392 1.96289 5.68945V11.4805L4.8291 8.91895C5.18774 8.59891 5.70727 8.54436 6.12207 8.77344L6.20312 8.82324L10.2891 11.5498L16.3809 5.22754L16.46 5.15234C16.8692 4.80225 17.4773 4.78945 17.9023 5.13672L22.0381 8.51562V5.68945C22.0381 4.65414 21.1983 3.81481 20.1631 3.81445H3.83789ZM13.5625 9.95312L14.0547 10.293C14.1692 10.3717 14.3182 10.3809 14.4414 10.3164L15.4453 9.79102C15.841 9.58378 16.3051 9.54827 16.7275 9.69336L18.5723 10.3271C18.7238 10.3788 18.8921 10.3286 18.9902 10.2021L20.2061 8.63281L17.2002 6.17676L13.5625 9.95312ZM8.86328 4.8291C9.84255 4.82937 10.6366 5.62324 10.6367 6.60254C10.6365 7.58178 9.8425 8.37571 8.86328 8.37598C7.88394 8.37585 7.09004 7.58186 7.08984 6.60254C7.08997 5.62315 7.88389 4.82923 8.86328 4.8291ZM8.86328 5.8291C8.43618 5.82923 8.08997 6.17544 8.08984 6.60254C8.09004 7.02958 8.43622 7.37585 8.86328 7.37598C9.29022 7.37571 9.63652 7.02949 9.63672 6.60254C9.63659 6.17552 9.29026 5.82937 8.86328 5.8291Z"
11
+
fill="currentColor"
12
+
/>
13
+
</svg>
14
+
);
+1
components/Icons/ReplyTiny.tsx
+1
components/Icons/ReplyTiny.tsx
+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
+
};
+1
-34
components/Input.tsx
+1
-34
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: {
···
56
57
}}
57
58
/>
58
59
);
59
-
};
60
-
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
60
};
94
61
95
62
export const InputWithLabel = (
+116
components/InteractionsPreview.tsx
+116
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
+
showMentions: boolean | undefined;
18
+
19
+
share?: boolean;
20
+
}) => {
21
+
let smoker = useSmoker();
22
+
let interactionsAvailable =
23
+
(props.quotesCount > 0 && props.showMentions !== false) ||
24
+
(props.showComments !== false && props.commentsCount > 0);
25
+
26
+
const tagsCount = props.tags?.length || 0;
27
+
28
+
return (
29
+
<div
30
+
className={`flex gap-2 text-tertiary text-sm items-center self-start`}
31
+
>
32
+
{tagsCount === 0 ? null : (
33
+
<>
34
+
<TagPopover tags={props.tags!} />
35
+
{interactionsAvailable || props.share ? (
36
+
<Separator classname="h-4!" />
37
+
) : null}
38
+
</>
39
+
)}
40
+
41
+
{props.showMentions === false || props.quotesCount === 0 ? null : (
42
+
<SpeedyLink
43
+
aria-label="Post quotes"
44
+
href={`${props.postUrl}?interactionDrawer=quotes`}
45
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
46
+
>
47
+
<QuoteTiny /> {props.quotesCount}
48
+
</SpeedyLink>
49
+
)}
50
+
{props.showComments === false || props.commentsCount === 0 ? null : (
51
+
<SpeedyLink
52
+
aria-label="Post comments"
53
+
href={`${props.postUrl}?interactionDrawer=comments`}
54
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
55
+
>
56
+
<CommentTiny /> {props.commentsCount}
57
+
</SpeedyLink>
58
+
)}
59
+
{interactionsAvailable && props.share ? (
60
+
<Separator classname="h-4! !min-h-0" />
61
+
) : null}
62
+
{props.share && (
63
+
<>
64
+
<button
65
+
id={`copy-post-link-${props.postUrl}`}
66
+
className="flex gap-1 items-center hover:text-accent-contrast relative"
67
+
onClick={(e) => {
68
+
e.stopPropagation();
69
+
e.preventDefault();
70
+
let mouseX = e.clientX;
71
+
let mouseY = e.clientY;
72
+
73
+
if (!props.postUrl) return;
74
+
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
75
+
76
+
smoker({
77
+
text: <strong>Copied Link!</strong>,
78
+
position: {
79
+
y: mouseY,
80
+
x: mouseX,
81
+
},
82
+
});
83
+
}}
84
+
>
85
+
Share
86
+
</button>
87
+
</>
88
+
)}
89
+
</div>
90
+
);
91
+
};
92
+
93
+
const TagPopover = (props: { tags: string[] }) => {
94
+
return (
95
+
<Popover
96
+
className="p-2! max-w-xs"
97
+
trigger={
98
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast">
99
+
<TagTiny /> {props.tags.length}
100
+
</div>
101
+
}
102
+
>
103
+
<TagList tags={props.tags} className="text-secondary!" />
104
+
</Popover>
105
+
);
106
+
};
107
+
108
+
const TagList = (props: { tags: string[]; className?: string }) => {
109
+
return (
110
+
<div className="flex gap-1 flex-wrap">
111
+
{props.tags.map((tag, index) => (
112
+
<Tag name={tag} key={index} className={props.className} />
113
+
))}
114
+
</div>
115
+
);
116
+
};
-94
components/Layout.tsx
-94
components/Layout.tsx
···
1
-
"use client";
2
-
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
-
import { theme } from "tailwind.config";
4
-
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
-
import { PopoverArrow } from "./Icons/PopoverArrow";
6
-
import { PopoverOpenContext } from "./Popover";
7
-
import { useState } from "react";
8
-
9
1
export const Separator = (props: { classname?: string }) => {
10
2
return <div className={`h-full border-r border-border ${props.classname}`} />;
11
-
};
12
-
13
-
export const Menu = (props: {
14
-
open?: boolean;
15
-
trigger: React.ReactNode;
16
-
children: React.ReactNode;
17
-
align?: "start" | "end" | "center" | undefined;
18
-
alignOffset?: number;
19
-
side?: "top" | "bottom" | "right" | "left" | undefined;
20
-
background?: string;
21
-
border?: string;
22
-
className?: string;
23
-
onOpenChange?: (o: boolean) => void;
24
-
asChild?: boolean;
25
-
}) => {
26
-
let [open, setOpen] = useState(props.open || false);
27
-
return (
28
-
<DropdownMenu.Root
29
-
onOpenChange={(o) => {
30
-
setOpen(o);
31
-
props.onOpenChange?.(o);
32
-
}}
33
-
open={props.open}
34
-
>
35
-
<PopoverOpenContext value={open}>
36
-
<DropdownMenu.Trigger asChild={props.asChild}>
37
-
{props.trigger}
38
-
</DropdownMenu.Trigger>
39
-
<DropdownMenu.Portal>
40
-
<NestedCardThemeProvider>
41
-
<DropdownMenu.Content
42
-
side={props.side ? props.side : "bottom"}
43
-
align={props.align ? props.align : "center"}
44
-
alignOffset={props.alignOffset ? props.alignOffset : undefined}
45
-
sideOffset={4}
46
-
collisionPadding={16}
47
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`}
48
-
>
49
-
{props.children}
50
-
<DropdownMenu.Arrow
51
-
asChild
52
-
width={16}
53
-
height={8}
54
-
viewBox="0 0 16 8"
55
-
>
56
-
<PopoverArrow
57
-
arrowFill={
58
-
props.background
59
-
? props.background
60
-
: theme.colors["bg-page"]
61
-
}
62
-
arrowStroke={
63
-
props.border ? props.border : theme.colors["border"]
64
-
}
65
-
/>
66
-
</DropdownMenu.Arrow>
67
-
</DropdownMenu.Content>
68
-
</NestedCardThemeProvider>
69
-
</DropdownMenu.Portal>
70
-
</PopoverOpenContext>
71
-
</DropdownMenu.Root>
72
-
);
73
-
};
74
-
75
-
export const MenuItem = (props: {
76
-
children?: React.ReactNode;
77
-
className?: string;
78
-
onSelect: (e: Event) => void;
79
-
id?: string;
80
-
}) => {
81
-
return (
82
-
<DropdownMenu.Item
83
-
id={props.id}
84
-
onSelect={(event) => {
85
-
props.onSelect(event);
86
-
}}
87
-
className={`
88
-
menuItem
89
-
z-10 py-1! px-2!
90
-
flex gap-2
91
-
${props.className}
92
-
`}
93
-
>
94
-
{props.children}
95
-
</DropdownMenu.Item>
96
-
);
97
3
};
98
4
99
5
export const ShortcutKey = (props: { children: React.ReactNode }) => {
+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
+
}
+97
components/Menu.tsx
+97
components/Menu.tsx
···
1
+
"use client";
2
+
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
+
import { theme } from "tailwind.config";
4
+
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
+
import { PopoverArrow } from "./Icons/PopoverArrow";
6
+
import { PopoverOpenContext } from "./Popover/PopoverContext";
7
+
import { useState } from "react";
8
+
9
+
export const Menu = (props: {
10
+
open?: boolean;
11
+
trigger: React.ReactNode;
12
+
children: React.ReactNode;
13
+
align?: "start" | "end" | "center" | undefined;
14
+
alignOffset?: number;
15
+
side?: "top" | "bottom" | "right" | "left" | undefined;
16
+
background?: string;
17
+
border?: string;
18
+
className?: string;
19
+
onOpenChange?: (o: boolean) => void;
20
+
asChild?: boolean;
21
+
}) => {
22
+
let [open, setOpen] = useState(props.open || false);
23
+
24
+
return (
25
+
<DropdownMenu.Root
26
+
onOpenChange={(o) => {
27
+
setOpen(o);
28
+
props.onOpenChange?.(o);
29
+
}}
30
+
open={props.open}
31
+
>
32
+
<PopoverOpenContext value={open}>
33
+
<DropdownMenu.Trigger asChild={props.asChild}>
34
+
{props.trigger}
35
+
</DropdownMenu.Trigger>
36
+
<DropdownMenu.Portal>
37
+
<NestedCardThemeProvider>
38
+
<DropdownMenu.Content
39
+
side={props.side ? props.side : "bottom"}
40
+
align={props.align ? props.align : "center"}
41
+
alignOffset={props.alignOffset ? props.alignOffset : undefined}
42
+
sideOffset={4}
43
+
collisionPadding={16}
44
+
className={`
45
+
dropdownMenu z-20 p-1
46
+
flex flex-col gap-0.5
47
+
bg-bg-page
48
+
border border-border rounded-md shadow-md
49
+
${props.className}`}
50
+
>
51
+
{props.children}
52
+
<DropdownMenu.Arrow
53
+
asChild
54
+
width={16}
55
+
height={8}
56
+
viewBox="0 0 16 8"
57
+
>
58
+
<PopoverArrow
59
+
arrowFill={
60
+
props.background
61
+
? props.background
62
+
: theme.colors["bg-page"]
63
+
}
64
+
arrowStroke={
65
+
props.border ? props.border : theme.colors["border"]
66
+
}
67
+
/>
68
+
</DropdownMenu.Arrow>
69
+
</DropdownMenu.Content>
70
+
</NestedCardThemeProvider>
71
+
</DropdownMenu.Portal>
72
+
</PopoverOpenContext>
73
+
</DropdownMenu.Root>
74
+
);
75
+
};
76
+
77
+
export const MenuItem = (props: {
78
+
children?: React.ReactNode;
79
+
className?: string;
80
+
onSelect: (e: Event) => void;
81
+
id?: string;
82
+
}) => {
83
+
return (
84
+
<DropdownMenu.Item
85
+
id={props.id}
86
+
onSelect={(event) => {
87
+
props.onSelect(event);
88
+
}}
89
+
className={`
90
+
menuItem
91
+
${props.className}
92
+
`}
93
+
>
94
+
{props.children}
95
+
</DropdownMenu.Item>
96
+
);
97
+
};
+33
components/OAuthError.tsx
+33
components/OAuthError.tsx
···
1
+
"use client";
2
+
3
+
import { OAuthSessionError } from "src/atproto-oauth";
4
+
5
+
export function OAuthErrorMessage({
6
+
error,
7
+
className,
8
+
}: {
9
+
error: OAuthSessionError;
10
+
className?: string;
11
+
}) {
12
+
const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(window.location.href)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`;
13
+
14
+
return (
15
+
<div className={className}>
16
+
<span>Your session has expired or is invalid. </span>
17
+
<a href={signInUrl} className="underline font-bold whitespace-nowrap">
18
+
Sign in again
19
+
</a>
20
+
</div>
21
+
);
22
+
}
23
+
24
+
export function isOAuthSessionError(
25
+
error: unknown,
26
+
): error is OAuthSessionError {
27
+
return (
28
+
typeof error === "object" &&
29
+
error !== null &&
30
+
"type" in error &&
31
+
(error as OAuthSessionError).type === "oauth_session_expired"
32
+
);
33
+
}
+7
-8
components/PageHeader.tsx
+7
-8
components/PageHeader.tsx
···
1
1
"use client";
2
2
import { useState, useEffect } from "react";
3
+
import { useCardBorderHidden } from "./Pages/useCardBorderHidden";
3
4
4
-
export const Header = (props: {
5
-
children: React.ReactNode;
6
-
cardBorderHidden: boolean;
7
-
}) => {
5
+
export const Header = (props: { children: React.ReactNode }) => {
6
+
let cardBorderHidden = useCardBorderHidden();
8
7
let [scrollPos, setScrollPos] = useState(0);
9
8
10
9
useEffect(() => {
···
22
21
}
23
22
}, []);
24
23
25
-
let headerBGColor = props.cardBorderHidden
24
+
let headerBGColor = !cardBorderHidden
26
25
? "var(--bg-leaflet)"
27
26
: "var(--bg-page)";
28
27
···
54
53
style={
55
54
scrollPos < 20
56
55
? {
57
-
backgroundColor: props.cardBorderHidden
56
+
backgroundColor: !cardBorderHidden
58
57
? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`
59
58
: `rgba(${headerBGColor}, ${scrollPos / 20})`,
60
-
paddingLeft: props.cardBorderHidden
59
+
paddingLeft: !cardBorderHidden
61
60
? "4px"
62
61
: `calc(${scrollPos / 20}*4px)`,
63
-
paddingRight: props.cardBorderHidden
62
+
paddingRight: !cardBorderHidden
64
63
? "8px"
65
64
: `calc(${scrollPos / 20}*8px)`,
66
65
}
+4
-21
components/PageLayouts/DashboardLayout.tsx
+4
-21
components/PageLayouts/DashboardLayout.tsx
···
25
25
import Link from "next/link";
26
26
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27
27
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
+
import { Tab } from "components/Tab";
28
29
29
30
export type DashboardState = {
30
31
display?: "grid" | "list";
···
133
134
},
134
135
>(props: {
135
136
id: string;
136
-
cardBorderHidden: boolean;
137
137
tabs: T;
138
138
defaultTab: keyof T;
139
139
currentPage: navPages;
···
180
180
</div>
181
181
</MediaContents>
182
182
<div
183
-
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `}
183
+
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `}
184
184
ref={ref}
185
185
id="home-content"
186
186
>
187
187
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
188
188
<>
189
-
<Header cardBorderHidden={props.cardBorderHidden}>
189
+
<Header>
190
190
{headerState === "default" ? (
191
191
<>
192
192
{Object.keys(props.tabs).length > 1 && (
···
355
355
);
356
356
};
357
357
358
-
function Tab(props: {
359
-
name: string;
360
-
selected: boolean;
361
-
onSelect: () => void;
362
-
href?: string;
363
-
}) {
364
-
return (
365
-
<div
366
-
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
367
-
onClick={() => props.onSelect()}
368
-
>
369
-
{props.name}
370
-
{props.href && <ExternalLinkTiny />}
371
-
</div>
372
-
);
373
-
}
374
-
375
358
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
376
359
let { filter } = useDashboardState();
377
360
let setState = useSetDashboardState();
···
469
452
type="text"
470
453
id="pubName"
471
454
size={1}
472
-
placeholder="searchโฆ"
455
+
placeholder="search..."
473
456
value={props.searchValue}
474
457
onChange={(e) => {
475
458
props.setSearchValue(e.currentTarget.value);
+4
-8
components/PageSWRDataProvider.tsx
+4
-8
components/PageSWRDataProvider.tsx
···
90
90
const publishedInPublication = data.leaflets_in_publications?.find(
91
91
(l) => l.doc,
92
92
);
93
-
const publishedStandalone =
94
-
data.leaflets_to_documents && data.leaflets_to_documents.documents
95
-
? data.leaflets_to_documents
96
-
: null;
93
+
const publishedStandalone = data.leaflets_to_documents?.find(
94
+
(l) => !!l.documents,
95
+
);
97
96
98
97
const documentUri =
99
98
publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
100
99
101
100
// Compute the full post URL for sharing
102
101
let postShareLink: string | undefined;
103
-
if (
104
-
publishedInPublication?.publications &&
105
-
publishedInPublication.documents
106
-
) {
102
+
if (publishedInPublication?.publications && publishedInPublication.documents) {
107
103
// Published in a publication - use publication URL + document rkey
108
104
const docUri = new AtUri(publishedInPublication.documents.uri);
109
105
postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+4
-6
components/Pages/Page.tsx
+4
-6
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";
···
34
34
return focusedPageID === props.entityID;
35
35
});
36
36
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
38
37
39
38
let drawerOpen = useDrawerOpen(props.entityID);
40
39
return (
···
49
48
}}
50
49
id={elementId.page(props.entityID).container}
51
50
drawerOpen={!!drawerOpen}
52
-
cardBorderHidden={!!cardBorderHidden}
53
51
isFocused={isFocused}
54
52
fullPageScroll={props.fullPageScroll}
55
53
pageType={pageType}
···
77
75
id: string;
78
76
children: React.ReactNode;
79
77
pageOptions?: React.ReactNode;
80
-
cardBorderHidden: boolean;
81
78
fullPageScroll: boolean;
82
79
isFocused?: boolean;
83
80
onClickAction?: (e: React.MouseEvent) => void;
84
81
pageType: "canvas" | "doc";
85
82
drawerOpen: boolean | undefined;
86
83
}) => {
84
+
const cardBorderHidden = useCardBorderHidden();
87
85
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
88
86
return (
89
87
// this div wraps the contents AND the page options.
···
106
104
shrink-0 snap-center
107
105
overflow-y-scroll
108
106
${
109
-
!props.cardBorderHidden &&
107
+
!cardBorderHidden &&
110
108
`h-full border
111
109
bg-[rgba(var(--bg-page),var(--bg-page-alpha))]
112
110
${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"}
113
111
${props.isFocused ? "shadow-md border-border" : "border-border-light"}`
114
112
}
115
-
${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
113
+
${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
116
114
${props.fullPageScroll && "max-w-full "}
117
115
${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"}
118
116
${
+9
-31
components/Pages/PageOptions.tsx
+9
-31
components/Pages/PageOptions.tsx
···
7
7
import { useReplicache } from "src/replicache";
8
8
9
9
import { Media } from "../Media";
10
-
import { MenuItem, Menu } from "../Layout";
10
+
import { MenuItem, Menu } from "../Menu";
11
11
import { PageThemeSetter } from "../ThemeManager/PageThemeSetter";
12
12
import { PageShareMenu } from "./PageShareMenu";
13
13
import { useUndoState } from "src/undoManager";
···
21
21
export const PageOptionButton = ({
22
22
children,
23
23
secondary,
24
-
cardBorderHidden,
25
24
className,
26
25
disabled,
27
26
...props
28
27
}: {
29
28
children: React.ReactNode;
30
29
secondary?: boolean;
31
-
cardBorderHidden: boolean | undefined;
32
30
className?: string;
33
31
disabled?: boolean;
34
32
} & Omit<JSX.IntrinsicElements["button"], "content">) => {
33
+
const cardBorderHidden = useCardBorderHidden();
35
34
return (
36
35
<button
37
36
className={`
···
58
57
first: boolean | undefined;
59
58
isFocused: boolean;
60
59
}) => {
61
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
62
-
63
60
return (
64
61
<div
65
62
className={`pageOptions w-fit z-10
66
63
${props.isFocused ? "block" : "sm:hidden block"}
67
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
64
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
68
65
flex sm:flex-col flex-row-reverse gap-1 items-start`}
69
66
>
70
67
{!props.first && (
71
68
<PageOptionButton
72
-
cardBorderHidden={cardBorderHidden}
73
69
secondary
74
70
onClick={() => {
75
71
useUIState.getState().closePage(props.entityID);
···
78
74
<CloseTiny />
79
75
</PageOptionButton>
80
76
)}
81
-
<OptionsMenu
82
-
entityID={props.entityID}
83
-
first={!!props.first}
84
-
cardBorderHidden={cardBorderHidden}
85
-
/>
86
-
<UndoButtons cardBorderHidden={cardBorderHidden} />
77
+
<OptionsMenu entityID={props.entityID} first={!!props.first} />
78
+
<UndoButtons />
87
79
</div>
88
80
);
89
81
};
90
82
91
-
export const UndoButtons = (props: {
92
-
cardBorderHidden: boolean | undefined;
93
-
}) => {
83
+
export const UndoButtons = () => {
94
84
let undoState = useUndoState();
95
85
let { undoManager } = useReplicache();
96
86
return (
97
87
<Media mobile>
98
88
{undoState.canUndo && (
99
89
<div className="gap-1 flex sm:flex-col">
100
-
<PageOptionButton
101
-
secondary
102
-
cardBorderHidden={props.cardBorderHidden}
103
-
onClick={() => undoManager.undo()}
104
-
>
90
+
<PageOptionButton secondary onClick={() => undoManager.undo()}>
105
91
<UndoTiny />
106
92
</PageOptionButton>
107
93
108
94
<PageOptionButton
109
95
secondary
110
-
cardBorderHidden={props.cardBorderHidden}
111
96
onClick={() => undoManager.undo()}
112
97
disabled={!undoState.canRedo}
113
98
>
···
119
104
);
120
105
};
121
106
122
-
export const OptionsMenu = (props: {
123
-
entityID: string;
124
-
first: boolean;
125
-
cardBorderHidden: boolean | undefined;
126
-
}) => {
107
+
export const OptionsMenu = (props: { entityID: string; first: boolean }) => {
127
108
let [state, setState] = useState<"normal" | "theme" | "share">("normal");
128
109
let { permissions } = useEntitySetContext();
129
110
if (!permissions.write) return null;
···
138
119
if (!open) setState("normal");
139
120
}}
140
121
trigger={
141
-
<PageOptionButton
142
-
cardBorderHidden={props.cardBorderHidden}
143
-
className="!w-8 !h-5 sm:!w-5 sm:!h-8"
144
-
>
122
+
<PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8">
145
123
<MoreOptionsTiny className="sm:rotate-90" />
146
124
</PageOptionButton>
147
125
}
+158
-84
components/Pages/PublicationMetadata.tsx
+158
-84
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
37
if (!pub) return null;
···
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
-
{pub.publications && (
40
-
<Link
41
-
href={
42
-
identity?.atp_did === pub.publications?.identity_did
43
-
? `${getBasePublicationURL(pub.publications)}/dashboard`
44
-
: getPublicationURL(pub.publications)
45
-
}
46
-
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
47
-
>
48
-
{pub.publications?.name}
49
-
</Link>
50
-
)}
51
-
<div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md ">
52
-
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>
53
66
</div>
54
-
</div>
55
-
<TextField
56
-
className="text-xl font-bold outline-hidden bg-transparent"
57
-
value={title}
58
-
onChange={async (newTitle) => {
59
-
await rep?.mutate.updatePublicationDraft({
60
-
title: newTitle,
61
-
description,
62
-
});
63
-
}}
64
-
placeholder="Untitled"
65
-
/>
66
-
<TextField
67
-
placeholder="add an optional description..."
68
-
className="italic text-secondary outline-hidden bg-transparent"
69
-
value={description}
70
-
onChange={async (newDescription) => {
71
-
await rep?.mutate.updatePublicationDraft({
72
-
title,
73
-
description: newDescription,
74
-
});
75
-
}}
76
-
/>
77
-
{pub.doc ? (
78
-
<div className="flex flex-row items-center gap-2 pt-3">
79
-
<p className="text-sm text-tertiary">
80
-
Published {publishedAt && timeAgo(publishedAt)}
81
-
</p>
82
-
<Separator classname="h-4" />
83
-
<Link
84
-
target="_blank"
85
-
className="text-sm"
86
-
href={
87
-
pub.publications
88
-
? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
89
-
: `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}`
90
-
}
91
-
>
92
-
View Post
93
-
</Link>
94
-
</div>
95
-
) : (
96
-
<p className="text-sm text-tertiary pt-2">Draft</p>
97
-
)}
98
-
</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
+
{pubRecord?.preferences?.showMentions && (
125
+
<div className="flex gap-1 items-center">
126
+
<QuoteTiny />โ
127
+
</div>
128
+
)}
129
+
{pubRecord?.preferences?.showComments && (
130
+
<div className="flex gap-1 items-center">
131
+
<CommentTiny />โ
132
+
</div>
133
+
)}
134
+
</div>
135
+
</>
136
+
}
137
+
/>
99
138
);
100
139
};
101
140
···
178
217
if (!pub) return null;
179
218
180
219
return (
181
-
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
182
-
<div className="text-accent-contrast font-bold hover:no-underline">
183
-
{pub.publications?.name}
184
-
</div>
220
+
<PostHeaderLayout
221
+
pubLink={
222
+
<div className="text-accent-contrast font-bold hover:no-underline">
223
+
{pub.publications?.name}
224
+
</div>
225
+
}
226
+
postTitle={pub.title}
227
+
postDescription={pub.description}
228
+
postInfo={
229
+
pub.doc ? (
230
+
<p>Published {publishedAt && timeAgo(publishedAt)}</p>
231
+
) : (
232
+
<p>Draft</p>
233
+
)
234
+
}
235
+
/>
236
+
);
237
+
};
185
238
186
-
<div
187
-
className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`}
188
-
>
189
-
{pub.title ? pub.title : "Untitled"}
190
-
</div>
191
-
<div className="italic text-secondary outline-hidden bg-transparent">
192
-
{pub.description}
193
-
</div>
239
+
const AddTags = () => {
240
+
let { data: pub } = useLeafletPublicationData();
241
+
let { rep } = useReplicache();
242
+
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
194
243
195
-
{pub.doc ? (
196
-
<div className="flex flex-row items-center gap-2 pt-3">
197
-
<p className="text-sm text-tertiary">
198
-
Published {publishedAt && timeAgo(publishedAt)}
199
-
</p>
244
+
// Get tags from Replicache local state or published document
245
+
let replicacheTags = useSubscribe(rep, (tx) =>
246
+
tx.get<string[]>("publication_tags"),
247
+
);
248
+
249
+
// Determine which tags to use - prioritize Replicache state
250
+
let tags: string[] = [];
251
+
if (Array.isArray(replicacheTags)) {
252
+
tags = replicacheTags;
253
+
} else if (record?.tags && Array.isArray(record.tags)) {
254
+
tags = record.tags as string[];
255
+
}
256
+
257
+
// Update tags in replicache local state
258
+
const handleTagsChange = async (newTags: string[]) => {
259
+
// Store tags in replicache for next publish/update
260
+
await rep?.mutate.updatePublicationDraft({
261
+
tags: newTags,
262
+
});
263
+
};
264
+
265
+
return (
266
+
<Popover
267
+
className="p-2! w-full min-w-xs"
268
+
trigger={
269
+
<div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary">
270
+
<TagTiny />{" "}
271
+
{tags.length > 0
272
+
? `${tags.length} Tag${tags.length === 1 ? "" : "s"}`
273
+
: "Add Tags"}
200
274
</div>
201
-
) : (
202
-
<p className="text-sm text-tertiary pt-2">Draft</p>
203
-
)}
204
-
</div>
275
+
}
276
+
>
277
+
<TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
278
+
</Popover>
205
279
);
206
280
};
+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
-18
components/Pages/useCardBorderHidden.ts
+3
-18
components/Pages/useCardBorderHidden.ts
···
1
-
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
2
-
import { PubLeafletPublication } from "lexicons/api";
3
-
import { useEntity, useReplicache } from "src/replicache";
1
+
import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider";
4
2
5
-
export function useCardBorderHidden(entityID: string | null) {
6
-
let { rootEntity } = useReplicache();
7
-
let { data: pub } = useLeafletPublicationData();
8
-
let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
9
-
10
-
let cardBorderHidden =
11
-
useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden;
12
-
if (!cardBorderHidden && !rootCardBorderHidden) {
13
-
if (pub?.publications?.record) {
14
-
let record = pub.publications.record as PubLeafletPublication.Record;
15
-
return !record.theme?.showPageBackground;
16
-
}
17
-
return false;
18
-
}
19
-
return (cardBorderHidden || rootCardBorderHidden)?.data.value;
3
+
export function useCardBorderHidden(entityID?: string | null) {
4
+
return useCardBorderHiddenContext();
20
5
}
+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
-
};
+145
components/PostListing.tsx
+145
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
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
17
+
18
+
export const PostListing = (props: Post) => {
19
+
let pubRecord = props.publication?.pubRecord as
20
+
| PubLeafletPublication.Record
21
+
| undefined;
22
+
23
+
let postRecord = props.documents.data as PubLeafletDocument.Record;
24
+
let postUri = new AtUri(props.documents.uri);
25
+
let uri = props.publication ? props.publication?.uri : props.documents.uri;
26
+
27
+
// For standalone documents (no publication), pass isStandalone to get correct defaults
28
+
let isStandalone = !pubRecord;
29
+
let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone);
30
+
let themeRecord = pubRecord?.theme || postRecord?.theme;
31
+
let backgroundImage =
32
+
themeRecord?.backgroundImage?.image?.ref && uri
33
+
? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host)
34
+
: null;
35
+
36
+
let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat;
37
+
let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
38
+
39
+
let showPageBackground = pubRecord
40
+
? pubRecord?.theme?.showPageBackground
41
+
: postRecord.theme?.showPageBackground ?? true;
42
+
43
+
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
44
+
let comments =
45
+
pubRecord?.preferences?.showComments === false
46
+
? 0
47
+
: props.documents.comments_on_documents?.[0]?.count || 0;
48
+
let tags = (postRecord?.tags as string[] | undefined) || [];
49
+
50
+
// For standalone posts, link directly to the document
51
+
let postHref = props.publication
52
+
? `${props.publication.href}/${postUri.rkey}`
53
+
: `/p/${postUri.host}/${postUri.rkey}`;
54
+
55
+
return (
56
+
<BaseThemeProvider {...theme} local>
57
+
<div
58
+
style={{
59
+
backgroundImage: backgroundImage
60
+
? `url(${backgroundImage})`
61
+
: undefined,
62
+
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
63
+
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
64
+
}}
65
+
className={`no-underline! flex flex-row gap-2 w-full relative
66
+
bg-bg-leaflet
67
+
border border-border-light rounded-lg
68
+
sm:p-2 p-2 selected-outline
69
+
hover:outline-accent-contrast hover:border-accent-contrast
70
+
`}
71
+
>
72
+
<Link className="h-full w-full absolute top-0 left-0" href={postHref} />
73
+
<div
74
+
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
75
+
style={{
76
+
backgroundColor: showPageBackground
77
+
? "rgba(var(--bg-page), var(--bg-page-alpha))"
78
+
: "transparent",
79
+
}}
80
+
>
81
+
<h3 className="text-primary truncate">{postRecord.title}</h3>
82
+
83
+
<p className="text-secondary italic">{postRecord.description}</p>
84
+
<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">
85
+
{props.publication && pubRecord && (
86
+
<PubInfo
87
+
href={props.publication.href}
88
+
pubRecord={pubRecord}
89
+
uri={props.publication.uri}
90
+
/>
91
+
)}
92
+
<div className="flex flex-row justify-between gap-2 items-center w-full">
93
+
<PostInfo publishedAt={postRecord.publishedAt} />
94
+
<InteractionPreview
95
+
postUrl={postHref}
96
+
quotesCount={quotes}
97
+
commentsCount={comments}
98
+
tags={tags}
99
+
showComments={pubRecord?.preferences?.showComments}
100
+
showMentions={pubRecord?.preferences?.showMentions}
101
+
share
102
+
/>
103
+
</div>
104
+
</div>
105
+
</div>
106
+
</div>
107
+
</BaseThemeProvider>
108
+
);
109
+
};
110
+
111
+
const PubInfo = (props: {
112
+
href: string;
113
+
pubRecord: PubLeafletPublication.Record;
114
+
uri: string;
115
+
}) => {
116
+
return (
117
+
<div className="flex flex-col md:w-auto shrink-0 w-full">
118
+
<hr className="md:hidden block border-border-light mb-2" />
119
+
<Link
120
+
href={props.href}
121
+
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0"
122
+
>
123
+
<PubIcon small record={props.pubRecord} uri={props.uri} />
124
+
{props.pubRecord.name}
125
+
</Link>
126
+
</div>
127
+
);
128
+
};
129
+
130
+
const PostInfo = (props: { publishedAt: string | undefined }) => {
131
+
let localizedDate = useLocalizedDate(props.publishedAt || "", {
132
+
year: "numeric",
133
+
month: "short",
134
+
day: "numeric",
135
+
});
136
+
return (
137
+
<div className="flex gap-2 items-center shrink-0 self-start">
138
+
{props.publishedAt && (
139
+
<>
140
+
<div className="shrink-0">{localizedDate}</div>
141
+
</>
142
+
)}
143
+
</div>
144
+
);
145
+
};
+98
components/ProfilePopover.tsx
+98
components/ProfilePopover.tsx
···
1
+
"use client";
2
+
import { Popover } from "./Popover";
3
+
import useSWR from "swr";
4
+
import { callRPC } from "app/api/rpc/client";
5
+
import { useRef, useState } from "react";
6
+
import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader";
7
+
import { SpeedyLink } from "./SpeedyLink";
8
+
import { Tooltip } from "./Tooltip";
9
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
10
+
11
+
export const ProfilePopover = (props: {
12
+
trigger: React.ReactNode;
13
+
didOrHandle: string;
14
+
}) => {
15
+
const [isOpen, setIsOpen] = useState(false);
16
+
let [isHovered, setIsHovered] = useState(false);
17
+
const hoverTimeout = useRef<null | number>(null);
18
+
19
+
const { data, isLoading } = useSWR(
20
+
isHovered ? ["profile-data", props.didOrHandle] : null,
21
+
async () => {
22
+
const response = await callRPC("get_profile_data", {
23
+
didOrHandle: props.didOrHandle,
24
+
});
25
+
return response.result;
26
+
},
27
+
);
28
+
29
+
return (
30
+
<Tooltip
31
+
className="max-w-sm p-0! text-center"
32
+
asChild
33
+
trigger={
34
+
<a
35
+
className="no-underline"
36
+
href={`https://leaflet.pub/p/${props.didOrHandle}`}
37
+
target="_blank"
38
+
onPointerEnter={(e) => {
39
+
if (hoverTimeout.current) {
40
+
window.clearTimeout(hoverTimeout.current);
41
+
}
42
+
hoverTimeout.current = window.setTimeout(async () => {
43
+
setIsHovered(true);
44
+
}, 150);
45
+
}}
46
+
onPointerLeave={() => {
47
+
if (isHovered) return;
48
+
if (hoverTimeout.current) {
49
+
window.clearTimeout(hoverTimeout.current);
50
+
hoverTimeout.current = null;
51
+
}
52
+
setIsHovered(false);
53
+
}}
54
+
>
55
+
{props.trigger}
56
+
</a>
57
+
}
58
+
onOpenChange={setIsOpen}
59
+
>
60
+
{isLoading ? (
61
+
<div className="text-secondary p-4">Loading...</div>
62
+
) : data ? (
63
+
<div>
64
+
<ProfileHeader
65
+
profile={data.profile}
66
+
publications={data.publications}
67
+
popover
68
+
/>
69
+
<KnownFollowers viewer={data.profile.viewer} did={data.profile.did} />
70
+
</div>
71
+
) : (
72
+
<div className="text-secondary py-2 px-4">Profile not found</div>
73
+
)}
74
+
</Tooltip>
75
+
);
76
+
};
77
+
78
+
let KnownFollowers = (props: {
79
+
viewer: ProfileViewDetailed["viewer"];
80
+
did: string;
81
+
}) => {
82
+
if (!props.viewer?.knownFollowers) return null;
83
+
let count = props.viewer.knownFollowers.count;
84
+
return (
85
+
<>
86
+
<hr className="border-border" />
87
+
Followed by{" "}
88
+
<a
89
+
className="hover:underline"
90
+
href={`https://bsky.social/profile/${props.did}/known-followers`}
91
+
target="_blank"
92
+
>
93
+
{props.viewer?.knownFollowers?.followers[0]?.displayName}{" "}
94
+
{count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""}
95
+
</a>
96
+
</>
97
+
);
98
+
};
+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
-
}
+18
components/Tab.tsx
+18
components/Tab.tsx
···
1
+
import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
2
+
3
+
export const Tab = (props: {
4
+
name: string;
5
+
selected: boolean;
6
+
onSelect: () => void;
7
+
href?: string;
8
+
}) => {
9
+
return (
10
+
<div
11
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
12
+
onClick={() => props.onSelect()}
13
+
>
14
+
{props.name}
15
+
{props.href && <ExternalLinkTiny />}
16
+
</div>
17
+
);
18
+
};
+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
+
};
+4
-5
components/ThemeManager/PageThemeSetter.tsx
+4
-5
components/ThemeManager/PageThemeSetter.tsx
···
3
3
import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter";
4
4
5
5
import {
6
-
PageBackgroundPicker,
6
+
SubpageBackgroundPicker,
7
7
PageThemePickers,
8
8
} from "./Pickers/PageThemePickers";
9
9
import { useMemo, useState } from "react";
···
54
54
className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]"
55
55
style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }}
56
56
>
57
-
<PageBackgroundPicker
57
+
<SubpageBackgroundPicker
58
58
entityID={props.entityID}
59
59
openPicker={openPicker}
60
-
setOpenPicker={(pickers) => setOpenPicker(pickers)}
61
-
setValue={set("theme/card-background")}
60
+
setOpenPicker={setOpenPicker}
62
61
/>
63
62
</div>
64
63
···
147
146
<div
148
147
className={
149
148
pageBorderHidden
150
-
? "py-2 px-0 border border-transparent"
149
+
? "relative py-2 px-0 border border-transparent"
151
150
: `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent`
152
151
}
153
152
style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
+6
components/ThemeManager/Pickers/ColorPicker.tsx
···
21
21
22
22
export const ColorPicker = (props: {
23
23
label?: string;
24
+
helpText?: string;
24
25
value: Color | undefined;
25
26
alpha?: boolean;
26
27
image?: boolean;
···
116
117
<div className="w-full flex flex-col gap-2 px-1 pb-2">
117
118
{
118
119
<>
120
+
{props.helpText && (
121
+
<div className="text-sm leading-tight text-tertiary pl-7 -mt-2.5">
122
+
{props.helpText}
123
+
</div>
124
+
)}
119
125
<ColorArea
120
126
className="w-full h-[128px] rounded-md"
121
127
colorSpace="hsb"
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
···
73
73
});
74
74
}}
75
75
>
76
-
<div className="flex flex-col gap-2 w-full">
76
+
<div className="flex flex-col w-full">
77
77
<div className="flex gap-2">
78
78
<div
79
79
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
122
122
}}
123
123
>
124
124
<Slider.Track
125
-
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
125
+
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
126
126
></Slider.Track>
127
127
<Slider.Thumb
128
128
className={`
129
129
flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
130
-
${repeat ? "bg-[#595959]" : " bg-[#C3C3C3] "}
131
-
${repeat && "shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"} `}
130
+
${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "}
131
+
`}
132
132
aria-label="Volume"
133
133
/>
134
134
</Slider.Root>
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
···
1
-
"use client";
2
-
3
-
import {
4
-
ColorPicker as SpectrumColorPicker,
5
-
parseColor,
6
-
Color,
7
-
ColorArea,
8
-
ColorThumb,
9
-
ColorSlider,
10
-
Input,
11
-
ColorField,
12
-
SliderTrack,
13
-
ColorSwatch,
14
-
} from "react-aria-components";
15
-
import { pickers, setColorAttribute } from "../ThemeSetter";
16
-
import { thumbStyle } from "./ColorPicker";
17
-
import { ImageInput, ImageSettings } from "./ImagePicker";
18
-
import { useEntity, useReplicache } from "src/replicache";
19
-
import { useColorAttribute } from "components/ThemeManager/useColorAttribute";
20
-
import { Separator } from "components/Layout";
21
-
import { onMouseDown } from "src/utils/iosInputMouseDown";
22
-
import { BlockImageSmall } from "components/Icons/BlockImageSmall";
23
-
import { DeleteSmall } from "components/Icons/DeleteSmall";
24
-
25
-
export const LeafletBGPicker = (props: {
26
-
entityID: string;
27
-
openPicker: pickers;
28
-
thisPicker: pickers;
29
-
setOpenPicker: (thisPicker: pickers) => void;
30
-
closePicker: () => void;
31
-
setValue: (c: Color) => void;
32
-
}) => {
33
-
let bgImage = useEntity(props.entityID, "theme/background-image");
34
-
let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat");
35
-
let bgColor = useColorAttribute(props.entityID, "theme/page-background");
36
-
let open = props.openPicker == props.thisPicker;
37
-
let { rep } = useReplicache();
38
-
39
-
return (
40
-
<>
41
-
<div className="bgPickerLabel flex justify-between place-items-center ">
42
-
<div className="bgPickerColorLabel flex gap-2 items-center">
43
-
<button
44
-
onClick={() => {
45
-
if (props.openPicker === props.thisPicker) {
46
-
props.setOpenPicker("null");
47
-
} else {
48
-
props.setOpenPicker(props.thisPicker);
49
-
}
50
-
}}
51
-
className="flex gap-2 items-center"
52
-
>
53
-
<ColorSwatch
54
-
color={bgColor}
55
-
className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`}
56
-
style={{
57
-
backgroundImage: bgImage?.data.src
58
-
? `url(${bgImage.data.src})`
59
-
: undefined,
60
-
backgroundSize: "cover",
61
-
}}
62
-
/>
63
-
<strong className={` "text-[#595959]`}>{"Background"}</strong>
64
-
</button>
65
-
66
-
<div className="flex">
67
-
{bgImage ? (
68
-
<div className={`"text-[#969696]`}>Image</div>
69
-
) : (
70
-
<>
71
-
<ColorField className="w-fit gap-1" value={bgColor}>
72
-
<Input
73
-
onMouseDown={onMouseDown}
74
-
onFocus={(e) => {
75
-
e.currentTarget.setSelectionRange(
76
-
1,
77
-
e.currentTarget.value.length,
78
-
);
79
-
}}
80
-
onPaste={(e) => {
81
-
console.log(e);
82
-
}}
83
-
onKeyDown={(e) => {
84
-
if (e.key === "Enter") {
85
-
e.currentTarget.blur();
86
-
} else return;
87
-
}}
88
-
onBlur={(e) => {
89
-
props.setValue(parseColor(e.currentTarget.value));
90
-
}}
91
-
className={`w-[72px] bg-transparent outline-nonetext-[#595959]`}
92
-
/>
93
-
</ColorField>
94
-
</>
95
-
)}
96
-
</div>
97
-
</div>
98
-
<div className="flex gap-1 justify-end grow text-[#969696]">
99
-
{bgImage && (
100
-
<button
101
-
onClick={() => {
102
-
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
103
-
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
104
-
}}
105
-
>
106
-
<DeleteSmall />
107
-
</button>
108
-
)}
109
-
<label>
110
-
<BlockImageSmall />
111
-
<div className="hidden">
112
-
<ImageInput
113
-
{...props}
114
-
onChange={() => {
115
-
props.setOpenPicker(props.thisPicker);
116
-
}}
117
-
/>
118
-
</div>
119
-
</label>
120
-
</div>
121
-
</div>
122
-
{open && (
123
-
<div className="bgImageAndColorPicker w-full flex flex-col gap-2 ">
124
-
<SpectrumColorPicker
125
-
value={bgColor}
126
-
onChange={setColorAttribute(
127
-
rep,
128
-
props.entityID,
129
-
)("theme/page-background")}
130
-
>
131
-
{bgImage ? (
132
-
<ImageSettings
133
-
entityID={props.entityID}
134
-
setValue={props.setValue}
135
-
/>
136
-
) : (
137
-
<>
138
-
<ColorArea
139
-
className="w-full h-[128px] rounded-md"
140
-
colorSpace="hsb"
141
-
xChannel="saturation"
142
-
yChannel="brightness"
143
-
>
144
-
<ColorThumb className={thumbStyle} />
145
-
</ColorArea>
146
-
<ColorSlider
147
-
colorSpace="hsb"
148
-
className="w-full "
149
-
channel="hue"
150
-
>
151
-
<SliderTrack className="h-2 w-full rounded-md">
152
-
<ColorThumb className={`${thumbStyle} mt-[4px]`} />
153
-
</SliderTrack>
154
-
</ColorSlider>
155
-
</>
156
-
)}
157
-
</SpectrumColorPicker>
158
-
</div>
159
-
)}
160
-
</>
161
-
);
162
-
};
+353
-43
components/ThemeManager/Pickers/PageThemePickers.tsx
+353
-43
components/ThemeManager/Pickers/PageThemePickers.tsx
···
51
51
<hr className="border-border-light w-full" />
52
52
</>
53
53
)}
54
-
<PageTextPicker
54
+
<TextPickers
55
55
value={primaryValue}
56
56
setValue={set("theme/primary")}
57
57
openPicker={props.openPicker}
···
61
61
);
62
62
};
63
63
64
-
export const PageBackgroundPicker = (props: {
64
+
// Page background picker for subpages - shows Page/Containers color with optional background image
65
+
export const SubpageBackgroundPicker = (props: {
65
66
entityID: string;
66
-
setValue: (c: Color) => void;
67
67
openPicker: pickers;
68
68
setOpenPicker: (p: pickers) => void;
69
-
home?: boolean;
70
69
}) => {
70
+
let { rep, rootEntity } = useReplicache();
71
+
let set = useMemo(() => {
72
+
return setColorAttribute(rep, props.entityID);
73
+
}, [rep, props.entityID]);
74
+
71
75
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
72
76
let pageBGImage = useEntity(props.entityID, "theme/card-background-image");
73
-
let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
77
+
let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
78
+
let entityPageBorderHidden = useEntity(
79
+
props.entityID,
80
+
"theme/card-border-hidden",
81
+
);
82
+
let pageBorderHidden =
83
+
(entityPageBorderHidden || rootPageBorderHidden)?.data.value || false;
84
+
let hasPageBackground = !pageBorderHidden;
85
+
86
+
// Label is "Page" when page background is visible, "Containers" when hidden
87
+
let label = hasPageBackground ? "Page" : "Containers";
88
+
89
+
// If root page border is hidden, only show color picker (no image support)
90
+
if (!hasPageBackground) {
91
+
return (
92
+
<ColorPicker
93
+
label={label}
94
+
helpText={"Affects menus, tooltips and some block backgrounds"}
95
+
value={pageValue}
96
+
setValue={set("theme/card-background")}
97
+
thisPicker="page"
98
+
openPicker={props.openPicker}
99
+
setOpenPicker={props.setOpenPicker}
100
+
closePicker={() => props.setOpenPicker("null")}
101
+
alpha
102
+
/>
103
+
);
104
+
}
74
105
75
106
return (
76
107
<>
77
-
{pageBGImage && pageBGImage !== null && (
78
-
<PageBackgroundImagePicker
79
-
disabled={pageBorderHidden?.data.value}
108
+
{pageBGImage && (
109
+
<SubpageBackgroundImagePicker
80
110
entityID={props.entityID}
81
-
thisPicker={"page-background-image"}
82
111
openPicker={props.openPicker}
83
112
setOpenPicker={props.setOpenPicker}
84
-
closePicker={() => props.setOpenPicker("null")}
85
-
setValue={props.setValue}
86
-
home={props.home}
113
+
setValue={set("theme/card-background")}
87
114
/>
88
115
)}
89
116
<div className="relative">
90
-
<PageBackgroundColorPicker
91
-
label={pageBorderHidden?.data.value ? "Menus" : "Page"}
117
+
<ColorPicker
118
+
label={label}
92
119
value={pageValue}
93
-
setValue={props.setValue}
94
-
thisPicker={"page"}
120
+
setValue={set("theme/card-background")}
121
+
thisPicker="page"
95
122
openPicker={props.openPicker}
96
123
setOpenPicker={props.setOpenPicker}
124
+
closePicker={() => props.setOpenPicker("null")}
97
125
alpha
98
126
/>
99
-
{(pageBGImage === null ||
100
-
(!pageBGImage && !pageBorderHidden?.data.value && !props.home)) && (
101
-
<label
102
-
className={`
103
-
hover:cursor-pointer text-[#969696] shrink-0
104
-
absolute top-0 right-0
105
-
`}
106
-
>
127
+
{!pageBGImage && (
128
+
<label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
107
129
<BlockImageSmall />
108
130
<div className="hidden">
109
131
<ImageInput
···
119
141
);
120
142
};
121
143
144
+
const SubpageBackgroundImagePicker = (props: {
145
+
entityID: string;
146
+
openPicker: pickers;
147
+
setOpenPicker: (p: pickers) => void;
148
+
setValue: (c: Color) => void;
149
+
}) => {
150
+
let { rep } = useReplicache();
151
+
let bgImage = useEntity(props.entityID, "theme/card-background-image");
152
+
let bgRepeat = useEntity(
153
+
props.entityID,
154
+
"theme/card-background-image-repeat",
155
+
);
156
+
let bgColor = useColorAttribute(props.entityID, "theme/card-background");
157
+
let bgAlpha =
158
+
useEntity(props.entityID, "theme/card-background-image-opacity")?.data
159
+
.value || 1;
160
+
let alphaColor = useMemo(() => {
161
+
return parseColor(`rgba(0,0,0,${bgAlpha})`);
162
+
}, [bgAlpha]);
163
+
let open = props.openPicker === "page-background-image";
164
+
165
+
return (
166
+
<>
167
+
<div className="bgPickerColorLabel flex gap-2 items-center">
168
+
<button
169
+
onClick={() => {
170
+
props.setOpenPicker(open ? "null" : "page-background-image");
171
+
}}
172
+
className="flex gap-2 items-center grow"
173
+
>
174
+
<ColorSwatch
175
+
color={bgColor}
176
+
className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
177
+
style={{
178
+
backgroundImage: bgImage?.data.src
179
+
? `url(${bgImage.data.src})`
180
+
: undefined,
181
+
backgroundPosition: "center",
182
+
backgroundSize: "cover",
183
+
}}
184
+
/>
185
+
<strong className="text-[#595959]">Page</strong>
186
+
<div className="italic text-[#8C8C8C]">image</div>
187
+
</button>
188
+
189
+
<SpectrumColorPicker
190
+
value={alphaColor}
191
+
onChange={(c) => {
192
+
let alpha = c.getChannelValue("alpha");
193
+
rep?.mutate.assertFact({
194
+
entity: props.entityID,
195
+
attribute: "theme/card-background-image-opacity",
196
+
data: { type: "number", value: alpha },
197
+
});
198
+
}}
199
+
>
200
+
<Separator classname="h-4! my-1 border-[#C3C3C3]!" />
201
+
<ColorField className="w-fit pl-[6px]" channel="alpha">
202
+
<Input
203
+
onMouseDown={onMouseDown}
204
+
onFocus={(e) => {
205
+
e.currentTarget.setSelectionRange(
206
+
0,
207
+
e.currentTarget.value.length - 1,
208
+
);
209
+
}}
210
+
onKeyDown={(e) => {
211
+
if (e.key === "Enter") {
212
+
e.currentTarget.blur();
213
+
} else return;
214
+
}}
215
+
className="w-[48px] bg-transparent outline-hidden"
216
+
/>
217
+
</ColorField>
218
+
</SpectrumColorPicker>
219
+
220
+
<div className="flex gap-1 text-[#8C8C8C]">
221
+
<button
222
+
onClick={() => {
223
+
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
224
+
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
225
+
}}
226
+
>
227
+
<DeleteSmall />
228
+
</button>
229
+
<label className="hover:cursor-pointer">
230
+
<BlockImageSmall />
231
+
<div className="hidden">
232
+
<ImageInput
233
+
entityID={props.entityID}
234
+
onChange={() => props.setOpenPicker("page-background-image")}
235
+
card
236
+
/>
237
+
</div>
238
+
</label>
239
+
</div>
240
+
</div>
241
+
{open && (
242
+
<div className="pageImagePicker flex flex-col gap-2">
243
+
<ImageSettings
244
+
entityID={props.entityID}
245
+
card
246
+
setValue={props.setValue}
247
+
/>
248
+
<div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2">
249
+
<hr className="border-[#DBDBDB]" />
250
+
<SpectrumColorPicker
251
+
value={alphaColor}
252
+
onChange={(c) => {
253
+
let alpha = c.getChannelValue("alpha");
254
+
rep?.mutate.assertFact({
255
+
entity: props.entityID,
256
+
attribute: "theme/card-background-image-opacity",
257
+
data: { type: "number", value: alpha },
258
+
});
259
+
}}
260
+
>
261
+
<ColorSlider
262
+
colorSpace="hsb"
263
+
className="w-full mt-1 rounded-full"
264
+
style={{
265
+
backgroundImage: `url(/transparent-bg.png)`,
266
+
backgroundRepeat: "repeat",
267
+
backgroundSize: "8px",
268
+
}}
269
+
channel="alpha"
270
+
>
271
+
<SliderTrack className="h-2 w-full rounded-md">
272
+
<ColorThumb className={`${thumbStyle} mt-[4px]`} />
273
+
</SliderTrack>
274
+
</ColorSlider>
275
+
</SpectrumColorPicker>
276
+
</div>
277
+
</div>
278
+
)}
279
+
</>
280
+
);
281
+
};
282
+
283
+
// Unified background picker for leaflets - matches structure of BackgroundPicker for publications
284
+
export const LeafletBackgroundPicker = (props: {
285
+
entityID: string;
286
+
openPicker: pickers;
287
+
setOpenPicker: (p: pickers) => void;
288
+
}) => {
289
+
let { rep } = useReplicache();
290
+
let set = useMemo(() => {
291
+
return setColorAttribute(rep, props.entityID);
292
+
}, [rep, props.entityID]);
293
+
294
+
let leafletBgValue = useColorAttribute(
295
+
props.entityID,
296
+
"theme/page-background",
297
+
);
298
+
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
299
+
let leafletBGImage = useEntity(props.entityID, "theme/background-image");
300
+
let leafletBGRepeat = useEntity(
301
+
props.entityID,
302
+
"theme/background-image-repeat",
303
+
);
304
+
let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
305
+
let hasPageBackground = !pageBorderHidden?.data.value;
306
+
307
+
// When page background is hidden and no background image, only show the Background picker
308
+
let showPagePicker = hasPageBackground || !!leafletBGImage;
309
+
310
+
return (
311
+
<>
312
+
{/* Background color/image picker */}
313
+
{leafletBGImage ? (
314
+
<LeafletBackgroundImagePicker
315
+
entityID={props.entityID}
316
+
openPicker={props.openPicker}
317
+
setOpenPicker={props.setOpenPicker}
318
+
/>
319
+
) : (
320
+
<div className="relative">
321
+
<ColorPicker
322
+
label="Background"
323
+
value={leafletBgValue}
324
+
setValue={set("theme/page-background")}
325
+
thisPicker="leaflet"
326
+
openPicker={props.openPicker}
327
+
setOpenPicker={props.setOpenPicker}
328
+
closePicker={() => props.setOpenPicker("null")}
329
+
/>
330
+
<label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
331
+
<BlockImageSmall />
332
+
<div className="hidden">
333
+
<ImageInput
334
+
entityID={props.entityID}
335
+
onChange={() => props.setOpenPicker("leaflet")}
336
+
/>
337
+
</div>
338
+
</label>
339
+
</div>
340
+
)}
341
+
342
+
{/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */}
343
+
{showPagePicker && (
344
+
<ColorPicker
345
+
label={hasPageBackground ? "Page" : "Containers"}
346
+
helpText={
347
+
hasPageBackground
348
+
? undefined
349
+
: "Affects menus, tooltips and some block backgrounds"
350
+
}
351
+
value={pageValue}
352
+
setValue={set("theme/card-background")}
353
+
thisPicker="page"
354
+
openPicker={props.openPicker}
355
+
setOpenPicker={props.setOpenPicker}
356
+
closePicker={() => props.setOpenPicker("null")}
357
+
alpha
358
+
/>
359
+
)}
360
+
361
+
<hr className="border-[#CCCCCC]" />
362
+
363
+
{/* Page Background toggle */}
364
+
<PageBorderHider
365
+
entityID={props.entityID}
366
+
openPicker={props.openPicker}
367
+
setOpenPicker={props.setOpenPicker}
368
+
/>
369
+
</>
370
+
);
371
+
};
372
+
373
+
const LeafletBackgroundImagePicker = (props: {
374
+
entityID: string;
375
+
openPicker: pickers;
376
+
setOpenPicker: (p: pickers) => void;
377
+
}) => {
378
+
let { rep } = useReplicache();
379
+
let bgImage = useEntity(props.entityID, "theme/background-image");
380
+
let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat");
381
+
let bgColor = useColorAttribute(props.entityID, "theme/page-background");
382
+
let open = props.openPicker === "leaflet";
383
+
384
+
return (
385
+
<>
386
+
<div className="bgPickerColorLabel flex gap-2 items-center">
387
+
<button
388
+
onClick={() => {
389
+
props.setOpenPicker(open ? "null" : "leaflet");
390
+
}}
391
+
className="flex gap-2 items-center grow"
392
+
>
393
+
<ColorSwatch
394
+
color={bgColor}
395
+
className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
396
+
style={{
397
+
backgroundImage: bgImage?.data.src
398
+
? `url(${bgImage.data.src})`
399
+
: undefined,
400
+
backgroundPosition: "center",
401
+
backgroundSize: "cover",
402
+
}}
403
+
/>
404
+
<strong className="text-[#595959]">Background</strong>
405
+
<div className="italic text-[#8C8C8C]">image</div>
406
+
</button>
407
+
<div className="flex gap-1 text-[#8C8C8C]">
408
+
<button
409
+
onClick={() => {
410
+
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
411
+
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
412
+
}}
413
+
>
414
+
<DeleteSmall />
415
+
</button>
416
+
<label className="hover:cursor-pointer">
417
+
<BlockImageSmall />
418
+
<div className="hidden">
419
+
<ImageInput
420
+
entityID={props.entityID}
421
+
onChange={() => props.setOpenPicker("leaflet")}
422
+
/>
423
+
</div>
424
+
</label>
425
+
</div>
426
+
</div>
427
+
{open && (
428
+
<div className="pageImagePicker flex flex-col gap-2">
429
+
<ImageSettings entityID={props.entityID} setValue={() => {}} />
430
+
</div>
431
+
)}
432
+
</>
433
+
);
434
+
};
435
+
122
436
export const PageBackgroundColorPicker = (props: {
123
437
disabled?: boolean;
124
438
label: string;
···
128
442
setValue: (c: Color) => void;
129
443
value: Color;
130
444
alpha?: boolean;
445
+
helpText?: string;
131
446
}) => {
132
447
return (
133
448
<ColorPicker
134
449
disabled={props.disabled}
135
450
label={props.label}
451
+
helpText={props.helpText}
136
452
value={props.value}
137
453
setValue={props.setValue}
138
454
thisPicker={"page"}
···
347
663
);
348
664
};
349
665
350
-
export const PageTextPicker = (props: {
666
+
export const TextPickers = (props: {
351
667
openPicker: pickers;
352
668
setOpenPicker: (thisPicker: pickers) => void;
353
669
value: Color;
···
394
710
395
711
return (
396
712
<>
397
-
<div className="flex gap-2 items-center">
398
-
<Toggle
399
-
toggleOn={!pageBorderHidden}
400
-
setToggleOn={() => {
401
-
handleToggle();
402
-
}}
403
-
disabledColor1="#8C8C8C"
404
-
disabledColor2="#DBDBDB"
405
-
/>
406
-
<button
407
-
className="flex gap-2 items-center"
408
-
onClick={() => {
409
-
handleToggle();
410
-
}}
411
-
>
713
+
<Toggle
714
+
toggle={!pageBorderHidden}
715
+
onToggle={() => {
716
+
handleToggle();
717
+
}}
718
+
disabledColor1="#8C8C8C"
719
+
disabledColor2="#DBDBDB"
720
+
>
721
+
<div className="flex gap-2">
412
722
<div className="font-bold">Page Background</div>
413
723
<div className="italic text-[#8C8C8C]">
414
-
{pageBorderHidden ? "hidden" : ""}
724
+
{pageBorderHidden ? "none" : ""}
415
725
</div>
416
-
</button>
417
-
</div>
726
+
</div>
727
+
</Toggle>
418
728
</>
419
729
);
420
730
};
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
···
1
+
import * as Slider from "@radix-ui/react-slider";
2
+
import { Input } from "components/Input";
3
+
import { Radio } from "components/Checkbox";
4
+
import { useEntity, useReplicache } from "src/replicache";
5
+
import { pickers } from "../ThemeSetter";
6
+
import { useState, useEffect } from "react";
7
+
8
+
export const PageWidthSetter = (props: {
9
+
entityID: string;
10
+
openPicker: pickers;
11
+
thisPicker: pickers;
12
+
setOpenPicker: (thisPicker: pickers) => void;
13
+
closePicker: () => void;
14
+
}) => {
15
+
let { rep } = useReplicache();
16
+
17
+
let defaultPreset = 624;
18
+
let widePreset = 768;
19
+
let pageWidth = useEntity(props.entityID, "theme/page-width")?.data.value;
20
+
let currentValue = pageWidth || defaultPreset;
21
+
let [interimValue, setInterimValue] = useState<number>(currentValue);
22
+
let [selectedPreset, setSelectedPreset] = useState<
23
+
"default" | "wide" | "custom"
24
+
>(
25
+
currentValue === defaultPreset
26
+
? "default"
27
+
: currentValue === widePreset
28
+
? "wide"
29
+
: "custom",
30
+
);
31
+
let min = 320;
32
+
let max = 1200;
33
+
34
+
let open = props.openPicker == props.thisPicker;
35
+
36
+
// Update interim value when current value changes
37
+
useEffect(() => {
38
+
setInterimValue(currentValue);
39
+
}, [currentValue]);
40
+
41
+
const setPageWidth = (value: number) => {
42
+
rep?.mutate.assertFact({
43
+
entity: props.entityID,
44
+
attribute: "theme/page-width",
45
+
data: {
46
+
type: "number",
47
+
value: value,
48
+
},
49
+
});
50
+
};
51
+
52
+
return (
53
+
<div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md">
54
+
<div className="flex flex-col gap-2">
55
+
<div className="flex gap-2 items-center">
56
+
<button
57
+
className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 items-start text-left"
58
+
onClick={() => {
59
+
if (props.openPicker === props.thisPicker) {
60
+
props.setOpenPicker("null");
61
+
} else {
62
+
props.setOpenPicker(props.thisPicker);
63
+
}
64
+
}}
65
+
>
66
+
Max Page Width
67
+
<span className="flex font-normal text-[#969696]">
68
+
{currentValue}px
69
+
</span>
70
+
</button>
71
+
</div>
72
+
{open && (
73
+
<div className="flex flex-col gap-1 px-3">
74
+
<label htmlFor="default" className="w-full">
75
+
<Radio
76
+
radioCheckedClassName="text-[#595959]!"
77
+
radioEmptyClassName="text-[#969696]!"
78
+
type="radio"
79
+
id="default"
80
+
name="page-width-options"
81
+
value="default"
82
+
checked={selectedPreset === "default"}
83
+
onChange={(e) => {
84
+
if (!e.currentTarget.checked) return;
85
+
setSelectedPreset("default");
86
+
setPageWidth(defaultPreset);
87
+
}}
88
+
>
89
+
<div
90
+
className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`}
91
+
>
92
+
default (624px)
93
+
</div>
94
+
</Radio>
95
+
</label>
96
+
<label htmlFor="wide" className="w-full">
97
+
<Radio
98
+
radioCheckedClassName="text-[#595959]!"
99
+
radioEmptyClassName="text-[#969696]!"
100
+
type="radio"
101
+
id="wide"
102
+
name="page-width-options"
103
+
value="wide"
104
+
checked={selectedPreset === "wide"}
105
+
onChange={(e) => {
106
+
if (!e.currentTarget.checked) return;
107
+
setSelectedPreset("wide");
108
+
setPageWidth(widePreset);
109
+
}}
110
+
>
111
+
<div
112
+
className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`}
113
+
>
114
+
wide (756px)
115
+
</div>
116
+
</Radio>
117
+
</label>
118
+
<label htmlFor="custom" className="pb-3 w-full">
119
+
<Radio
120
+
type="radio"
121
+
id="custom"
122
+
name="page-width-options"
123
+
value="custom"
124
+
radioCheckedClassName="text-[#595959]!"
125
+
radioEmptyClassName="text-[#969696]!"
126
+
checked={selectedPreset === "custom"}
127
+
onChange={(e) => {
128
+
if (!e.currentTarget.checked) return;
129
+
setSelectedPreset("custom");
130
+
if (selectedPreset !== "custom") {
131
+
setPageWidth(currentValue);
132
+
setInterimValue(currentValue);
133
+
}
134
+
}}
135
+
>
136
+
<div className="flex flex-col w-full">
137
+
<div className="flex gap-2">
138
+
<div
139
+
className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`}
140
+
>
141
+
custom
142
+
</div>
143
+
<div
144
+
className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`}
145
+
>
146
+
<Input
147
+
type="number"
148
+
className="w-10 text-right appearance-none bg-transparent"
149
+
max={max}
150
+
min={min}
151
+
value={interimValue}
152
+
onChange={(e) => {
153
+
setInterimValue(parseInt(e.currentTarget.value));
154
+
}}
155
+
onKeyDown={(e) => {
156
+
if (e.key === "Enter" || e.key === "Escape") {
157
+
e.preventDefault();
158
+
let clampedValue = interimValue;
159
+
if (!isNaN(interimValue)) {
160
+
clampedValue = Math.max(
161
+
min,
162
+
Math.min(max, interimValue),
163
+
);
164
+
setInterimValue(clampedValue);
165
+
}
166
+
setPageWidth(clampedValue);
167
+
}
168
+
}}
169
+
onBlur={() => {
170
+
let clampedValue = interimValue;
171
+
if (!isNaN(interimValue)) {
172
+
clampedValue = Math.max(
173
+
min,
174
+
Math.min(max, interimValue),
175
+
);
176
+
setInterimValue(clampedValue);
177
+
}
178
+
setPageWidth(clampedValue);
179
+
}}
180
+
/>
181
+
px
182
+
</div>
183
+
</div>
184
+
<Slider.Root
185
+
className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`}
186
+
value={[interimValue]}
187
+
max={max}
188
+
min={min}
189
+
step={16}
190
+
onValueChange={(value) => {
191
+
setInterimValue(value[0]);
192
+
}}
193
+
onValueCommit={(value) => {
194
+
setPageWidth(value[0]);
195
+
}}
196
+
>
197
+
<Slider.Track
198
+
className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
199
+
/>
200
+
<Slider.Thumb
201
+
className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
202
+
${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"}
203
+
`}
204
+
aria-label="Max Page Width"
205
+
/>
206
+
</Slider.Root>
207
+
</div>
208
+
</Radio>
209
+
</label>
210
+
</div>
211
+
)}
212
+
</div>
213
+
</div>
214
+
);
215
+
};
+30
-24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
+30
-24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
···
24
24
hasPageBackground: boolean;
25
25
setHasPageBackground: (s: boolean) => void;
26
26
}) => {
27
+
// When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker
28
+
let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage;
29
+
27
30
return (
28
31
<>
29
32
{props.bgImage && props.bgImage !== null ? (
···
83
86
)}
84
87
</div>
85
88
)}
86
-
<PageBackgroundColorPicker
87
-
label={"Containers"}
88
-
value={props.pageBackground}
89
-
setValue={props.setPageBackground}
90
-
thisPicker={"page"}
91
-
openPicker={props.openPicker}
92
-
setOpenPicker={props.setOpenPicker}
93
-
alpha={props.hasPageBackground ? true : false}
94
-
/>
89
+
{!showLeafletBgPicker && (
90
+
// When there's a background image and page background hidden, label should say "Containers"
91
+
<PageBackgroundColorPicker
92
+
label={props.hasPageBackground ? "Page" : "Containers"}
93
+
helpText={
94
+
props.hasPageBackground
95
+
? undefined
96
+
: "Affects menus, tooltips and some block backgrounds"
97
+
}
98
+
value={props.pageBackground}
99
+
setValue={props.setPageBackground}
100
+
thisPicker={"page"}
101
+
openPicker={props.openPicker}
102
+
setOpenPicker={props.setOpenPicker}
103
+
alpha={props.hasPageBackground ? true : false}
104
+
/>
105
+
)}
95
106
<hr className="border-border-light" />
96
107
<div className="flex gap-2 items-center">
97
108
<Toggle
98
-
toggleOn={props.hasPageBackground}
99
-
setToggleOn={() => {
109
+
toggle={props.hasPageBackground}
110
+
onToggle={() => {
100
111
props.setHasPageBackground(!props.hasPageBackground);
101
112
props.hasPageBackground &&
102
113
props.openPicker === "page" &&
···
104
115
}}
105
116
disabledColor1="#8C8C8C"
106
117
disabledColor2="#DBDBDB"
107
-
/>
108
-
<button
109
-
className="flex gap-2 items-center"
110
-
onClick={() => {
111
-
props.setHasPageBackground(!props.hasPageBackground);
112
-
props.hasPageBackground && props.setOpenPicker("null");
113
-
}}
114
118
>
115
-
<div className="font-bold">Page Background</div>
116
-
<div className="italic text-[#8C8C8C]">
117
-
{props.hasPageBackground ? "" : "hidden"}
119
+
<div className="flex gap-2">
120
+
<div className="font-bold">Page Background</div>
121
+
<div className="italic text-[#8C8C8C]">
122
+
{props.hasPageBackground ? "" : "none"}
123
+
</div>
118
124
</div>
119
-
</button>
125
+
</Toggle>
120
126
</div>
121
127
</>
122
128
);
···
250
256
props.setBgImage({ ...props.bgImage, repeat: 500 });
251
257
}}
252
258
>
253
-
<div className="flex flex-col gap-2 w-full">
259
+
<div className="flex flex-col w-full">
254
260
<div className="flex gap-2">
255
261
<div
256
262
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
289
295
}}
290
296
>
291
297
<Slider.Track
292
-
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
298
+
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
293
299
></Slider.Track>
294
300
<Slider.Thumb
295
301
className={`
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
···
1
+
import * as Slider from "@radix-ui/react-slider";
2
+
import { Input } from "components/Input";
3
+
import { Radio } from "components/Checkbox";
4
+
import { useState, useEffect } from "react";
5
+
import { pickers } from "../ThemeSetter";
6
+
7
+
export const PubPageWidthSetter = (props: {
8
+
pageWidth: number | undefined;
9
+
setPageWidth: (value: number) => void;
10
+
thisPicker: pickers;
11
+
openPicker: pickers;
12
+
setOpenPicker: (p: pickers) => void;
13
+
}) => {
14
+
let defaultPreset = 624;
15
+
let widePreset = 768;
16
+
17
+
let currentValue = props.pageWidth || defaultPreset;
18
+
let [interimValue, setInterimValue] = useState<number>(currentValue);
19
+
let [selectedPreset, setSelectedPreset] = useState<
20
+
"default" | "wide" | "custom"
21
+
>(
22
+
currentValue === defaultPreset
23
+
? "default"
24
+
: currentValue === widePreset
25
+
? "wide"
26
+
: "custom",
27
+
);
28
+
let min = 320;
29
+
let max = 1200;
30
+
31
+
// Update interim value when current value changes
32
+
useEffect(() => {
33
+
setInterimValue(currentValue);
34
+
}, [currentValue]);
35
+
36
+
const setPageWidth = (value: number) => {
37
+
props.setPageWidth(value);
38
+
};
39
+
40
+
let open = props.openPicker == props.thisPicker;
41
+
42
+
return (
43
+
<div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white">
44
+
<button
45
+
type="button"
46
+
className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center"
47
+
onClick={() => {
48
+
if (!open) {
49
+
props.setOpenPicker(props.thisPicker);
50
+
} else {
51
+
props.setOpenPicker("null");
52
+
}
53
+
}}
54
+
>
55
+
Max Page Width
56
+
<div className="flex font-normal text-[#969696]">{currentValue}px</div>
57
+
</button>
58
+
59
+
{open && (
60
+
<div className="flex flex-col gap-1 px-3">
61
+
<label htmlFor="pub-default" className="w-full">
62
+
<Radio
63
+
radioCheckedClassName="text-[#595959]!"
64
+
radioEmptyClassName="text-[#969696]!"
65
+
type="radio"
66
+
id="pub-default"
67
+
name="pub-page-width-options"
68
+
value="default"
69
+
checked={selectedPreset === "default"}
70
+
onChange={(e) => {
71
+
if (!e.currentTarget.checked) return;
72
+
setSelectedPreset("default");
73
+
setPageWidth(defaultPreset);
74
+
}}
75
+
>
76
+
<div
77
+
className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`}
78
+
>
79
+
default (624px)
80
+
</div>
81
+
</Radio>
82
+
</label>
83
+
<label htmlFor="pub-wide" className="w-full">
84
+
<Radio
85
+
radioCheckedClassName="text-[#595959]!"
86
+
radioEmptyClassName="text-[#969696]!"
87
+
type="radio"
88
+
id="pub-wide"
89
+
name="pub-page-width-options"
90
+
value="wide"
91
+
checked={selectedPreset === "wide"}
92
+
onChange={(e) => {
93
+
if (!e.currentTarget.checked) return;
94
+
setSelectedPreset("wide");
95
+
setPageWidth(widePreset);
96
+
}}
97
+
>
98
+
<div
99
+
className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`}
100
+
>
101
+
wide (756px)
102
+
</div>
103
+
</Radio>
104
+
</label>
105
+
<label htmlFor="pub-custom" className="pb-3 w-full">
106
+
<Radio
107
+
type="radio"
108
+
id="pub-custom"
109
+
name="pub-page-width-options"
110
+
value="custom"
111
+
radioCheckedClassName="text-[#595959]!"
112
+
radioEmptyClassName="text-[#969696]!"
113
+
checked={selectedPreset === "custom"}
114
+
onChange={(e) => {
115
+
if (!e.currentTarget.checked) return;
116
+
setSelectedPreset("custom");
117
+
if (selectedPreset !== "custom") {
118
+
setPageWidth(currentValue);
119
+
setInterimValue(currentValue);
120
+
}
121
+
}}
122
+
>
123
+
<div className="flex flex-col w-full">
124
+
<div className="flex gap-2">
125
+
<div
126
+
className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`}
127
+
>
128
+
custom
129
+
</div>
130
+
<div
131
+
className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`}
132
+
>
133
+
<Input
134
+
type="number"
135
+
className="w-10 text-right appearance-none bg-transparent"
136
+
max={max}
137
+
min={min}
138
+
value={interimValue}
139
+
onChange={(e) => {
140
+
setInterimValue(parseInt(e.currentTarget.value));
141
+
}}
142
+
onKeyDown={(e) => {
143
+
if (e.key === "Enter" || e.key === "Escape") {
144
+
e.preventDefault();
145
+
let clampedValue = interimValue;
146
+
if (!isNaN(interimValue)) {
147
+
clampedValue = Math.max(
148
+
min,
149
+
Math.min(max, interimValue),
150
+
);
151
+
setInterimValue(clampedValue);
152
+
}
153
+
setPageWidth(clampedValue);
154
+
}
155
+
}}
156
+
onBlur={() => {
157
+
let clampedValue = interimValue;
158
+
if (!isNaN(interimValue)) {
159
+
clampedValue = Math.max(
160
+
min,
161
+
Math.min(max, interimValue),
162
+
);
163
+
setInterimValue(clampedValue);
164
+
}
165
+
setPageWidth(clampedValue);
166
+
}}
167
+
/>
168
+
px
169
+
</div>
170
+
</div>
171
+
<Slider.Root
172
+
className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`}
173
+
value={[interimValue]}
174
+
max={max}
175
+
min={min}
176
+
step={16}
177
+
onValueChange={(value) => {
178
+
setInterimValue(value[0]);
179
+
}}
180
+
onValueCommit={(value) => {
181
+
setPageWidth(value[0]);
182
+
}}
183
+
>
184
+
<Slider.Track
185
+
className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
186
+
/>
187
+
<Slider.Thumb
188
+
className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
189
+
${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"}
190
+
`}
191
+
aria-label="Max Page Width"
192
+
/>
193
+
</Slider.Root>
194
+
</div>
195
+
</Radio>
196
+
</label>
197
+
</div>
198
+
)}
199
+
</div>
200
+
);
201
+
};
+2
-2
components/ThemeManager/PubPickers/PubTextPickers.tsx
+2
-2
components/ThemeManager/PubPickers/PubTextPickers.tsx
···
1
1
import { pickers } from "../ThemeSetter";
2
-
import { PageTextPicker } from "../Pickers/PageThemePickers";
2
+
import { TextPickers } from "../Pickers/PageThemePickers";
3
3
import { Color } from "react-aria-components";
4
4
5
5
export const PagePickers = (props: {
···
20
20
: "transparent",
21
21
}}
22
22
>
23
-
<PageTextPicker
23
+
<TextPickers
24
24
value={props.primary}
25
25
setValue={props.setPrimary}
26
26
openPicker={props.openPicker}
+41
-8
components/ThemeManager/PubThemeSetter.tsx
+41
-8
components/ThemeManager/PubThemeSetter.tsx
···
15
15
import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
16
16
import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17
17
import { Separator } from "components/Layout";
18
-
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings";
18
+
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings";
19
19
import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
20
+
import { useToaster } from "components/Toast";
21
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
22
+
import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter";
20
23
21
24
export type ImageState = {
22
25
src: string;
···
54
57
}
55
58
: null,
56
59
);
57
-
60
+
let [pageWidth, setPageWidth] = useState<number>(
61
+
record?.theme?.pageWidth || 624,
62
+
);
58
63
let pubBGImage = image?.src || null;
59
64
let leafletBGRepeat = image?.repeat || null;
65
+
let toaster = useToaster();
60
66
61
67
return (
62
-
<BaseThemeProvider local {...localPubTheme}>
68
+
<BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}>
63
69
<form
64
70
onSubmit={async (e) => {
65
71
e.preventDefault();
···
75
81
: ColorToRGB(localPubTheme.bgLeaflet),
76
82
backgroundRepeat: image?.repeat,
77
83
backgroundImage: image ? image.file : null,
84
+
pageWidth: pageWidth,
78
85
primary: ColorToRGB(localPubTheme.primary),
79
86
accentBackground: ColorToRGB(localPubTheme.accent1),
80
87
accentText: ColorToRGB(localPubTheme.accent2),
81
88
},
82
89
});
90
+
91
+
if (!result.success) {
92
+
props.setLoading(false);
93
+
if (result.error && isOAuthSessionError(result.error)) {
94
+
toaster({
95
+
content: <OAuthErrorMessage error={result.error} />,
96
+
type: "error",
97
+
});
98
+
} else {
99
+
toaster({
100
+
content: "Failed to update theme",
101
+
type: "error",
102
+
});
103
+
}
104
+
return;
105
+
}
106
+
83
107
mutate((pub) => {
84
-
if (result?.publication && pub?.publication)
108
+
if (result.publication && pub?.publication)
85
109
return {
86
110
...pub,
87
111
publication: { ...pub.publication, ...result.publication },
···
96
120
setLoadingAction={props.setLoading}
97
121
backToMenuAction={props.backToMenu}
98
122
state={"theme"}
99
-
/>
123
+
>
124
+
Theme and Layout
125
+
</PubSettingsHeader>
100
126
</form>
101
127
102
-
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 ">
103
-
<div className="themeBGLeaflet flex">
128
+
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 mt-2 ">
129
+
<PubPageWidthSetter
130
+
pageWidth={pageWidth}
131
+
setPageWidth={setPageWidth}
132
+
thisPicker="page-width"
133
+
openPicker={openPicker}
134
+
setOpenPicker={setOpenPicker}
135
+
/>
136
+
<div className="themeBGLeaflet flex flex-col">
104
137
<div
105
-
className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
138
+
className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
106
139
>
107
140
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
108
141
<BackgroundPicker
+20
-7
components/ThemeManager/PublicationThemeProvider.tsx
+20
-7
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
-
import { BaseThemeProvider } from "./ThemeProvider";
7
+
import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
8
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
9
9
import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
10
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
···
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
}}
···
100
102
pub_creator: string;
101
103
isStandalone?: boolean;
102
104
}) {
103
-
let colors = usePubTheme(props.theme, props.isStandalone);
105
+
let theme = usePubTheme(props.theme, props.isStandalone);
106
+
let cardBorderHidden = !theme.showPageBackground;
107
+
let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref;
108
+
104
109
return (
105
-
<BaseThemeProvider local={props.local} {...colors}>
106
-
{props.children}
107
-
</BaseThemeProvider>
110
+
<CardBorderHiddenContext.Provider value={cardBorderHidden}>
111
+
<BaseThemeProvider
112
+
local={props.local}
113
+
{...theme}
114
+
hasBackgroundImage={hasBackgroundImage}
115
+
>
116
+
{props.children}
117
+
</BaseThemeProvider>
118
+
</CardBorderHiddenContext.Provider>
108
119
);
109
120
}
110
121
···
122
133
bgPage = bgLeaflet;
123
134
}
124
135
let showPageBackground = theme?.showPageBackground;
136
+
let pageWidth = theme?.pageWidth;
125
137
126
138
let primary = useColor(theme, "primary");
127
139
···
142
154
highlight2,
143
155
highlight3,
144
156
showPageBackground,
157
+
pageWidth,
145
158
};
146
159
};
147
160
+52
-63
components/ThemeManager/ThemeProvider.tsx
+52
-63
components/ThemeManager/ThemeProvider.tsx
···
1
1
"use client";
2
2
3
-
import {
4
-
createContext,
5
-
CSSProperties,
6
-
useContext,
7
-
useEffect,
8
-
useMemo,
9
-
useState,
10
-
} from "react";
3
+
import { createContext, CSSProperties, useContext, useEffect } from "react";
4
+
5
+
// Context for cardBorderHidden
6
+
export const CardBorderHiddenContext = createContext<boolean>(false);
7
+
8
+
export function useCardBorderHiddenContext() {
9
+
return useContext(CardBorderHiddenContext);
10
+
}
11
11
import {
12
12
colorToString,
13
13
useColorAttribute,
14
14
useColorAttributeNullable,
15
15
} from "./useColorAttribute";
16
16
import { Color as AriaColor, parseColor } from "react-aria-components";
17
-
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
18
17
19
18
import { useEntity } from "src/replicache";
20
19
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···
23
22
PublicationThemeProvider,
24
23
} from "./PublicationThemeProvider";
25
24
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
-
};
25
+
import { getColorContrast } from "./themeUtils";
54
26
55
27
// define a function to set an Aria Color to a CSS Variable in RGB
56
28
function setCSSVariableToColor(
···
88
60
}) {
89
61
let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
90
62
let bgPage = useColorAttribute(props.entityID, "theme/card-background");
91
-
let showPageBackground = !useEntity(
63
+
let cardBorderHiddenValue = useEntity(
92
64
props.entityID,
93
65
"theme/card-border-hidden",
94
66
)?.data.value;
67
+
let showPageBackground = !cardBorderHiddenValue;
68
+
let backgroundImage = useEntity(props.entityID, "theme/background-image");
69
+
let hasBackgroundImage = !!backgroundImage;
95
70
let primary = useColorAttribute(props.entityID, "theme/primary");
96
71
97
72
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
101
76
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
102
77
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
103
78
79
+
let pageWidth = useEntity(props.entityID, "theme/page-width");
80
+
104
81
return (
105
-
<BaseThemeProvider
106
-
local={props.local}
107
-
bgLeaflet={bgLeaflet}
108
-
bgPage={bgPage}
109
-
primary={primary}
110
-
highlight2={highlight2}
111
-
highlight3={highlight3}
112
-
highlight1={highlight1?.data.value}
113
-
accent1={accent1}
114
-
accent2={accent2}
115
-
showPageBackground={showPageBackground}
116
-
>
117
-
{props.children}
118
-
</BaseThemeProvider>
82
+
<CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
83
+
<BaseThemeProvider
84
+
local={props.local}
85
+
bgLeaflet={bgLeaflet}
86
+
bgPage={bgPage}
87
+
primary={primary}
88
+
highlight2={highlight2}
89
+
highlight3={highlight3}
90
+
highlight1={highlight1?.data.value}
91
+
accent1={accent1}
92
+
accent2={accent2}
93
+
showPageBackground={showPageBackground}
94
+
pageWidth={pageWidth?.data.value}
95
+
hasBackgroundImage={hasBackgroundImage}
96
+
>
97
+
{props.children}
98
+
</BaseThemeProvider>
99
+
</CardBorderHiddenContext.Provider>
119
100
);
120
101
}
121
102
···
123
104
export const BaseThemeProvider = ({
124
105
local,
125
106
bgLeaflet,
126
-
bgPage,
107
+
bgPage: bgPageProp,
127
108
primary,
128
109
accent1,
129
110
accent2,
···
131
112
highlight2,
132
113
highlight3,
133
114
showPageBackground,
115
+
pageWidth,
116
+
hasBackgroundImage,
134
117
children,
135
118
}: {
136
119
local?: boolean;
137
120
showPageBackground?: boolean;
121
+
hasBackgroundImage?: boolean;
138
122
bgLeaflet: AriaColor;
139
123
bgPage: AriaColor;
140
124
primary: AriaColor;
···
143
127
highlight1?: string;
144
128
highlight2: AriaColor;
145
129
highlight3: AriaColor;
130
+
pageWidth?: number;
146
131
children: React.ReactNode;
147
132
}) => {
133
+
// When showPageBackground is false and there's no background image,
134
+
// pageBg should inherit from leafletBg
135
+
const bgPage =
136
+
!showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp;
148
137
// set accent contrast to the accent color that has the highest contrast with the page background
149
138
let accentContrast;
150
139
···
220
209
el?.style.setProperty(
221
210
"--accent-1-is-contrast",
222
211
accentContrast === accent1 ? "1" : "0",
212
+
);
213
+
214
+
// Set page width CSS variable
215
+
el?.style.setProperty(
216
+
"--page-width-setting",
217
+
(pageWidth || 624).toString(),
223
218
);
224
219
}, [
225
220
local,
···
232
227
accent1,
233
228
accent2,
234
229
accentContrast,
230
+
pageWidth,
235
231
]);
236
232
return (
237
233
<div
···
251
247
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
252
248
"--highlight-2": colorToString(highlight2, "rgb"),
253
249
"--highlight-3": colorToString(highlight3, "rgb"),
250
+
"--page-width-setting": pageWidth || 624,
251
+
"--page-width-unitless": pageWidth || 624,
252
+
"--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`,
254
253
} as CSSProperties
255
254
}
256
255
>
···
367
366
</div>
368
367
);
369
368
};
370
-
371
-
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
372
-
export function getColorContrast(color1: string, color2: string) {
373
-
ColorSpace.register(sRGB);
374
-
375
-
let parsedColor1 = parse(`rgb(${color1})`);
376
-
let parsedColor2 = parse(`rgb(${color2})`);
377
-
378
-
return contrastLstar(parsedColor1, parsedColor2);
379
-
}
+21
-35
components/ThemeManager/ThemeSetter.tsx
+21
-35
components/ThemeManager/ThemeSetter.tsx
···
1
1
"use client";
2
2
import { Popover } from "components/Popover";
3
-
import { theme } from "../../tailwind.config";
4
3
5
4
import { Color } from "react-aria-components";
6
5
7
-
import { LeafletBGPicker } from "./Pickers/LeafletBGPicker";
8
6
import {
9
-
PageBackgroundPicker,
10
-
PageBorderHider,
7
+
LeafletBackgroundPicker,
11
8
PageThemePickers,
12
9
} from "./Pickers/PageThemePickers";
10
+
import { PageWidthSetter } from "./Pickers/PageWidthSetter";
13
11
import { useMemo, useState } from "react";
14
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
15
13
import { Replicache } from "replicache";
···
35
33
| "highlight-1"
36
34
| "highlight-2"
37
35
| "highlight-3"
38
-
| "page-background-image";
36
+
| "page-background-image"
37
+
| "page-width";
39
38
40
39
export function setColorAttribute(
41
40
rep: Replicache<ReplicacheMutators> | null,
···
75
74
return (
76
75
<>
77
76
<Popover
78
-
className="w-80 bg-white"
77
+
className="w-80 bg-white py-3!"
79
78
arrowFill="#FFFFFF"
80
79
asChild
81
80
side={isMobile ? "top" : "right"}
···
114
113
if (pub?.publications) return null;
115
114
return (
116
115
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar">
116
+
{!props.home && (
117
+
<PageWidthSetter
118
+
entityID={props.entityID}
119
+
thisPicker={"page-width"}
120
+
openPicker={openPicker}
121
+
setOpenPicker={setOpenPicker}
122
+
closePicker={() => setOpenPicker("null")}
123
+
/>
124
+
)}
117
125
<div className="themeBGLeaflet flex">
118
126
<div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}>
119
127
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md">
120
-
<LeafletBGPicker
121
-
entityID={props.entityID}
122
-
thisPicker={"leaflet"}
123
-
openPicker={openPicker}
124
-
setOpenPicker={setOpenPicker}
125
-
closePicker={() => setOpenPicker("null")}
126
-
setValue={set("theme/page-background")}
127
-
/>
128
-
<PageBackgroundPicker
129
-
entityID={props.entityID}
130
-
setValue={set("theme/card-background")}
131
-
openPicker={openPicker}
132
-
setOpenPicker={setOpenPicker}
133
-
home={props.home}
134
-
/>
135
-
<hr className=" border-[#CCCCCC]" />
136
-
<PageBorderHider
128
+
<LeafletBackgroundPicker
137
129
entityID={props.entityID}
138
130
openPicker={openPicker}
139
131
setOpenPicker={setOpenPicker}
···
173
165
setOpenPicker={(pickers) => setOpenPicker(pickers)}
174
166
/>
175
167
<SectionArrow
176
-
fill={theme.colors["accent-2"]}
177
-
stroke={theme.colors["accent-1"]}
168
+
fill="rgb(var(--accent-2))"
169
+
stroke="rgb(var(--accent-1))"
178
170
className="ml-2"
179
171
/>
180
172
</div>
···
209
201
return (
210
202
<div className="flex gap-2 items-start mt-0.5">
211
203
<Toggle
212
-
toggleOn={!!checked?.data.value}
213
-
setToggleOn={() => {
204
+
toggle={!!checked?.data.value}
205
+
onToggle={() => {
214
206
handleToggle();
215
207
}}
216
208
disabledColor1="#8C8C8C"
217
209
disabledColor2="#DBDBDB"
218
-
/>
219
-
<button
220
-
className="flex gap-2 items-center -mt-0.5"
221
-
onClick={() => {
222
-
handleToggle();
223
-
}}
224
210
>
225
-
<div className="flex flex-col gap-0 items-start">
211
+
<div className="flex flex-col gap-0 items-start ">
226
212
<div className="font-bold">Show Leaflet Watermark</div>
227
213
<div className="text-sm text-[#969696]">Help us spread the word!</div>
228
214
</div>
229
-
</button>
215
+
</Toggle>
230
216
</div>
231
217
);
232
218
}
+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,
+32
-20
components/Toggle.tsx
+32
-20
components/Toggle.tsx
···
1
1
import { theme } from "tailwind.config";
2
2
3
3
export const Toggle = (props: {
4
-
toggleOn: boolean;
5
-
setToggleOn: (s: boolean) => void;
4
+
toggle: boolean;
5
+
onToggle: () => void;
6
6
disabledColor1?: string;
7
7
disabledColor2?: string;
8
+
children: React.ReactNode;
8
9
}) => {
9
10
return (
10
11
<button
11
-
className="toggle selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border"
12
-
style={{
13
-
border: props.toggleOn
14
-
? "1px solid " + theme.colors["accent-2"]
15
-
: "1px solid " + props.disabledColor2 || theme.colors["border-light"],
16
-
justifyContent: props.toggleOn ? "flex-end" : "flex-start",
17
-
background: props.toggleOn
18
-
? theme.colors["accent-1"]
19
-
: props.disabledColor1 || theme.colors["tertiary"],
12
+
type="button"
13
+
className="toggle flex gap-2 items-start justify-start text-left"
14
+
onClick={() => {
15
+
props.onToggle();
20
16
}}
21
-
onClick={() => props.setToggleOn(!props.toggleOn)}
22
17
>
23
-
<div
24
-
className="h-[14px] w-[10px] m-0.5 rounded-[2px]"
25
-
style={{
26
-
background: props.toggleOn
27
-
? theme.colors["accent-2"]
28
-
: props.disabledColor2 || theme.colors["border-light"],
29
-
}}
30
-
/>
18
+
<div className="h-6 flex place-items-center">
19
+
<div
20
+
className="selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border"
21
+
style={{
22
+
border: props.toggle
23
+
? "1px solid " + theme.colors["accent-2"]
24
+
: "1px solid " + props.disabledColor2 ||
25
+
theme.colors["border-light"],
26
+
justifyContent: props.toggle ? "flex-end" : "flex-start",
27
+
background: props.toggle
28
+
? theme.colors["accent-1"]
29
+
: props.disabledColor1 || theme.colors["tertiary"],
30
+
}}
31
+
>
32
+
<div
33
+
className="h-[14px] w-[10px] m-0.5 rounded-[2px]"
34
+
style={{
35
+
background: props.toggle
36
+
? theme.colors["accent-2"]
37
+
: props.disabledColor2 || theme.colors["border-light"],
38
+
}}
39
+
/>
40
+
</div>
41
+
</div>
42
+
{props.children}
31
43
</button>
32
44
);
33
45
};
+7
-15
components/Toolbar/BlockToolbar.tsx
+7
-15
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
-
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
8
+
import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } 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: (
···
44
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
45
<ImageFullBleedButton />
46
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
+
<ImageCoverButton />
47
48
{focusedEntityType?.data.value !== "canvas" && (
48
49
<Separator classname="h-6" />
49
50
)}
···
66
67
67
68
const MoveBlockButtons = () => {
68
69
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
70
return (
81
71
<>
82
72
<ToolbarButton
83
73
hiddenOnCanvas
84
74
onClick={async () => {
85
-
let [sortedBlocks, siblings] = await getSortedSelection();
75
+
if (!rep) return;
76
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
86
77
if (sortedBlocks.length > 1) return;
87
78
let block = sortedBlocks[0];
88
79
let previousBlock =
···
139
130
<ToolbarButton
140
131
hiddenOnCanvas
141
132
onClick={async () => {
142
-
let [sortedBlocks, siblings] = await getSortedSelection();
133
+
if (!rep) return;
134
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
143
135
if (sortedBlocks.length > 1) return;
144
136
let block = sortedBlocks[0];
145
137
let nextBlock = siblings
+37
components/Toolbar/ImageToolbar.tsx
+37
components/Toolbar/ImageToolbar.tsx
···
4
4
import { useUIState } from "src/useUIState";
5
5
import { Props } from "components/Icons/Props";
6
6
import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt";
7
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
8
+
import { useSubscribe } from "src/replicache/useSubscribe";
9
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
7
10
8
11
export const ImageFullBleedButton = (props: {}) => {
9
12
let { rep } = useReplicache();
···
76
79
) : (
77
80
<ImageRemoveAltSmall />
78
81
)}
82
+
</ToolbarButton>
83
+
);
84
+
};
85
+
86
+
export const ImageCoverButton = () => {
87
+
let { rep } = useReplicache();
88
+
let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null;
89
+
let hasSrc = useEntity(focusedBlock, "block/image")?.data;
90
+
let { data: pubData } = useLeafletPublicationData();
91
+
let coverImage = useSubscribe(rep, (tx) =>
92
+
tx.get<string | null>("publication_cover_image"),
93
+
);
94
+
95
+
// Only show if in a publication and has an image
96
+
if (!pubData?.publications || !hasSrc) return null;
97
+
98
+
let isCoverImage = coverImage === focusedBlock;
99
+
100
+
return (
101
+
<ToolbarButton
102
+
active={isCoverImage}
103
+
onClick={async (e) => {
104
+
e.preventDefault();
105
+
if (rep && focusedBlock) {
106
+
await rep.mutate.updatePublicationDraft({
107
+
cover_image: isCoverImage ? null : focusedBlock,
108
+
});
109
+
}
110
+
}}
111
+
tooltipContent={
112
+
<div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div>
113
+
}
114
+
>
115
+
<ImageCoverImage />
79
116
</ToolbarButton>
80
117
);
81
118
};
+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/Tooltip.tsx
+1
-1
components/Tooltip.tsx
+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";
+49
lexicons/api/lexicons.ts
+49
lexicons/api/lexicons.ts
···
1440
1440
type: 'ref',
1441
1441
ref: 'lex:pub.leaflet.publication#theme',
1442
1442
},
1443
+
tags: {
1444
+
type: 'array',
1445
+
items: {
1446
+
type: 'string',
1447
+
maxLength: 50,
1448
+
},
1449
+
},
1450
+
coverImage: {
1451
+
type: 'blob',
1452
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
1453
+
maxSize: 1000000,
1454
+
},
1443
1455
pages: {
1444
1456
type: 'array',
1445
1457
items: {
···
1794
1806
type: 'boolean',
1795
1807
default: true,
1796
1808
},
1809
+
showMentions: {
1810
+
type: 'boolean',
1811
+
default: true,
1812
+
},
1813
+
showPrevNext: {
1814
+
type: 'boolean',
1815
+
default: true,
1816
+
},
1797
1817
},
1798
1818
},
1799
1819
theme: {
···
1810
1830
type: 'ref',
1811
1831
ref: 'lex:pub.leaflet.theme.backgroundImage',
1812
1832
},
1833
+
pageWidth: {
1834
+
type: 'integer',
1835
+
minimum: 0,
1836
+
maximum: 1600,
1837
+
},
1813
1838
primary: {
1814
1839
type: 'union',
1815
1840
refs: [
···
1865
1890
type: 'union',
1866
1891
refs: [
1867
1892
'lex:pub.leaflet.richtext.facet#link',
1893
+
'lex:pub.leaflet.richtext.facet#didMention',
1894
+
'lex:pub.leaflet.richtext.facet#atMention',
1868
1895
'lex:pub.leaflet.richtext.facet#code',
1869
1896
'lex:pub.leaflet.richtext.facet#highlight',
1870
1897
'lex:pub.leaflet.richtext.facet#underline',
···
1901
1928
properties: {
1902
1929
uri: {
1903
1930
type: 'string',
1931
+
},
1932
+
},
1933
+
},
1934
+
didMention: {
1935
+
type: 'object',
1936
+
description: 'Facet feature for mentioning a did.',
1937
+
required: ['did'],
1938
+
properties: {
1939
+
did: {
1940
+
type: 'string',
1941
+
format: 'did',
1942
+
},
1943
+
},
1944
+
},
1945
+
atMention: {
1946
+
type: 'object',
1947
+
description: 'Facet feature for mentioning an AT URI.',
1948
+
required: ['atURI'],
1949
+
properties: {
1950
+
atURI: {
1951
+
type: 'string',
1952
+
format: 'uri',
1904
1953
},
1905
1954
},
1906
1955
},
+2
lexicons/api/types/pub/leaflet/document.ts
+2
lexicons/api/types/pub/leaflet/document.ts
+3
lexicons/api/types/pub/leaflet/publication.ts
+3
lexicons/api/types/pub/leaflet/publication.ts
···
37
37
$type?: 'pub.leaflet.publication#preferences'
38
38
showInDiscover: boolean
39
39
showComments: boolean
40
+
showMentions: boolean
41
+
showPrevNext: boolean
40
42
}
41
43
42
44
const hashPreferences = 'preferences'
···
56
58
| $Typed<PubLeafletThemeColor.Rgb>
57
59
| { $type: string }
58
60
backgroundImage?: PubLeafletThemeBackgroundImage.Main
61
+
pageWidth?: number
59
62
primary?:
60
63
| $Typed<PubLeafletThemeColor.Rgba>
61
64
| $Typed<PubLeafletThemeColor.Rgb>
+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. */
+2
lexicons/build.ts
+2
lexicons/build.ts
···
9
9
import * as path from "path";
10
10
import { PubLeafletRichTextFacet } from "./src/facet";
11
11
import { PubLeafletComment } from "./src/comment";
12
+
import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions";
12
13
13
14
const outdir = path.join("lexicons", "pub", "leaflet");
14
15
···
21
22
PubLeafletDocument,
22
23
PubLeafletComment,
23
24
PubLeafletRichTextFacet,
25
+
PubLeafletAuthFullPermissions,
24
26
PageLexicons.PubLeafletPagesLinearDocument,
25
27
PageLexicons.PubLeafletPagesCanvasDocument,
26
28
...ThemeLexicons,
+44
lexicons/fix-extensions.ts
+44
lexicons/fix-extensions.ts
···
1
+
import * as fs from "fs";
2
+
import * as path from "path";
3
+
4
+
/**
5
+
* Recursively processes all files in a directory and removes .js extensions from imports
6
+
*/
7
+
function fixExtensionsInDirectory(dir: string): void {
8
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
9
+
10
+
for (const entry of entries) {
11
+
const fullPath = path.join(dir, entry.name);
12
+
13
+
if (entry.isDirectory()) {
14
+
fixExtensionsInDirectory(fullPath);
15
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
16
+
fixExtensionsInFile(fullPath);
17
+
}
18
+
}
19
+
}
20
+
21
+
/**
22
+
* Removes .js extensions from import/export statements in a file
23
+
*/
24
+
function fixExtensionsInFile(filePath: string): void {
25
+
const content = fs.readFileSync(filePath, "utf-8");
26
+
const fixedContent = content.replace(/\.js'/g, "'");
27
+
28
+
if (content !== fixedContent) {
29
+
fs.writeFileSync(filePath, fixedContent, "utf-8");
30
+
console.log(`Fixed: ${filePath}`);
31
+
}
32
+
}
33
+
34
+
// Get the directory to process from command line arguments
35
+
const targetDir = process.argv[2] || "./lexicons/api";
36
+
37
+
if (!fs.existsSync(targetDir)) {
38
+
console.error(`Directory not found: ${targetDir}`);
39
+
process.exit(1);
40
+
}
41
+
42
+
console.log(`Fixing extensions in: ${targetDir}`);
43
+
fixExtensionsInDirectory(targetDir);
44
+
console.log("Done!");
+30
lexicons/pub/leaflet/authFullPermissions.json
+30
lexicons/pub/leaflet/authFullPermissions.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "pub.leaflet.authFullPermissions",
4
+
"defs": {
5
+
"main": {
6
+
"type": "permission-set",
7
+
"title": "Full Leaflet Permissions",
8
+
"detail": "Manage creating and updating leaflet documents and publications and all interactions on them.",
9
+
"permissions": [
10
+
{
11
+
"type": "permission",
12
+
"resource": "repo",
13
+
"action": [
14
+
"create",
15
+
"update",
16
+
"delete"
17
+
],
18
+
"collection": [
19
+
"pub.leaflet.document",
20
+
"pub.leaflet.publication",
21
+
"pub.leaflet.comment",
22
+
"pub.leaflet.poll.definition",
23
+
"pub.leaflet.poll.vote",
24
+
"pub.leaflet.graph.subscription"
25
+
]
26
+
}
27
+
]
28
+
}
29
+
}
30
+
}
+16
lexicons/pub/leaflet/document.json
+16
lexicons/pub/leaflet/document.json
···
46
46
"type": "ref",
47
47
"ref": "pub.leaflet.publication#theme"
48
48
},
49
+
"tags": {
50
+
"type": "array",
51
+
"items": {
52
+
"type": "string",
53
+
"maxLength": 50
54
+
}
55
+
},
56
+
"coverImage": {
57
+
"type": "blob",
58
+
"accept": [
59
+
"image/png",
60
+
"image/jpeg",
61
+
"image/webp"
62
+
],
63
+
"maxSize": 1000000
64
+
},
49
65
"pages": {
50
66
"type": "array",
51
67
"items": {
+13
lexicons/pub/leaflet/publication.json
+13
lexicons/pub/leaflet/publication.json
···
51
51
"showComments": {
52
52
"type": "boolean",
53
53
"default": true
54
+
},
55
+
"showMentions": {
56
+
"type": "boolean",
57
+
"default": true
58
+
},
59
+
"showPrevNext": {
60
+
"type": "boolean",
61
+
"default": true
54
62
}
55
63
}
56
64
},
···
67
75
"backgroundImage": {
68
76
"type": "ref",
69
77
"ref": "pub.leaflet.theme.backgroundImage"
78
+
},
79
+
"pageWidth": {
80
+
"type": "integer",
81
+
"minimum": 0,
82
+
"maximum": 1600
70
83
},
71
84
"primary": {
72
85
"type": "union",
+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
},
+36
lexicons/src/authFullPermissions.ts
+36
lexicons/src/authFullPermissions.ts
···
1
+
import { LexiconDoc } from "@atproto/lexicon";
2
+
import { PubLeafletDocument } from "./document";
3
+
import {
4
+
PubLeafletPublication,
5
+
PubLeafletPublicationSubscription,
6
+
} from "./publication";
7
+
import { PubLeafletComment } from "./comment";
8
+
import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls";
9
+
10
+
export const PubLeafletAuthFullPermissions: LexiconDoc = {
11
+
lexicon: 1,
12
+
id: "pub.leaflet.authFullPermissions",
13
+
defs: {
14
+
main: {
15
+
type: "permission-set",
16
+
title: "Full Leaflet Permissions",
17
+
detail:
18
+
"Manage creating and updating leaflet documents and publications and all interactions on them.",
19
+
permissions: [
20
+
{
21
+
type: "permission",
22
+
resource: "repo",
23
+
action: ["create", "update", "delete"],
24
+
collection: [
25
+
PubLeafletDocument.id,
26
+
PubLeafletPublication.id,
27
+
PubLeafletComment.id,
28
+
PubLeafletPollDefinition.id,
29
+
PubLeafletPollVote.id,
30
+
PubLeafletPublicationSubscription.id,
31
+
],
32
+
},
33
+
],
34
+
},
35
+
},
36
+
};
+6
lexicons/src/document.ts
+6
lexicons/src/document.ts
···
23
23
publication: { type: "string", format: "at-uri" },
24
24
author: { type: "string", format: "at-identifier" },
25
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
+
tags: { type: "array", items: { type: "string", maxLength: 50 } },
27
+
coverImage: {
28
+
type: "blob",
29
+
accept: ["image/png", "image/jpeg", "image/webp"],
30
+
maxSize: 1000000,
31
+
},
26
32
pages: {
27
33
type: "array",
28
34
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.",
+7
lexicons/src/publication.ts
+7
lexicons/src/publication.ts
···
27
27
properties: {
28
28
showInDiscover: { type: "boolean", default: true },
29
29
showComments: { type: "boolean", default: true },
30
+
showMentions: { type: "boolean", default: true },
31
+
showPrevNext: { type: "boolean", default: false },
30
32
},
31
33
},
32
34
theme: {
···
36
38
backgroundImage: {
37
39
type: "ref",
38
40
ref: PubLeafletThemeBackgroundImage.id,
41
+
},
42
+
pageWidth: {
43
+
type: "integer",
44
+
minimum: 0,
45
+
maximum: 1600,
39
46
},
40
47
primary: ColorUnion,
41
48
pageBackground: ColorUnion,
+68
-49
package-lock.json
+68
-49
package-lock.json
···
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": "16.0.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.2.0",
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.2.0",
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",
···
2734
2735
}
2735
2736
},
2736
2737
"node_modules/@next/env": {
2737
-
"version": "16.0.3",
2738
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
2739
-
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="
2738
+
"version": "16.0.7",
2739
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
2740
+
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
2741
+
"license": "MIT"
2740
2742
},
2741
2743
"node_modules/@next/eslint-plugin-next": {
2742
2744
"version": "16.0.3",
···
2804
2806
}
2805
2807
},
2806
2808
"node_modules/@next/swc-darwin-arm64": {
2807
-
"version": "16.0.3",
2808
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
2809
-
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
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==",
2810
2812
"cpu": [
2811
2813
"arm64"
2812
2814
],
2815
+
"license": "MIT",
2813
2816
"optional": true,
2814
2817
"os": [
2815
2818
"darwin"
···
2819
2822
}
2820
2823
},
2821
2824
"node_modules/@next/swc-darwin-x64": {
2822
-
"version": "16.0.3",
2823
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
2824
-
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
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==",
2825
2828
"cpu": [
2826
2829
"x64"
2827
2830
],
2831
+
"license": "MIT",
2828
2832
"optional": true,
2829
2833
"os": [
2830
2834
"darwin"
···
2834
2838
}
2835
2839
},
2836
2840
"node_modules/@next/swc-linux-arm64-gnu": {
2837
-
"version": "16.0.3",
2838
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
2839
-
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
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==",
2840
2844
"cpu": [
2841
2845
"arm64"
2842
2846
],
2847
+
"license": "MIT",
2843
2848
"optional": true,
2844
2849
"os": [
2845
2850
"linux"
···
2849
2854
}
2850
2855
},
2851
2856
"node_modules/@next/swc-linux-arm64-musl": {
2852
-
"version": "16.0.3",
2853
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
2854
-
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
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==",
2855
2860
"cpu": [
2856
2861
"arm64"
2857
2862
],
2863
+
"license": "MIT",
2858
2864
"optional": true,
2859
2865
"os": [
2860
2866
"linux"
···
2864
2870
}
2865
2871
},
2866
2872
"node_modules/@next/swc-linux-x64-gnu": {
2867
-
"version": "16.0.3",
2868
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
2869
-
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
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==",
2870
2876
"cpu": [
2871
2877
"x64"
2872
2878
],
2879
+
"license": "MIT",
2873
2880
"optional": true,
2874
2881
"os": [
2875
2882
"linux"
···
2879
2886
}
2880
2887
},
2881
2888
"node_modules/@next/swc-linux-x64-musl": {
2882
-
"version": "16.0.3",
2883
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
2884
-
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
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==",
2885
2892
"cpu": [
2886
2893
"x64"
2887
2894
],
2895
+
"license": "MIT",
2888
2896
"optional": true,
2889
2897
"os": [
2890
2898
"linux"
···
2894
2902
}
2895
2903
},
2896
2904
"node_modules/@next/swc-win32-arm64-msvc": {
2897
-
"version": "16.0.3",
2898
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
2899
-
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
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==",
2900
2908
"cpu": [
2901
2909
"arm64"
2902
2910
],
2911
+
"license": "MIT",
2903
2912
"optional": true,
2904
2913
"os": [
2905
2914
"win32"
···
2909
2918
}
2910
2919
},
2911
2920
"node_modules/@next/swc-win32-x64-msvc": {
2912
-
"version": "16.0.3",
2913
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
2914
-
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
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==",
2915
2924
"cpu": [
2916
2925
"x64"
2917
2926
],
2927
+
"license": "MIT",
2918
2928
"optional": true,
2919
2929
"os": [
2920
2930
"win32"
···
13360
13370
"json-buffer": "3.0.1"
13361
13371
}
13362
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"
13378
+
},
13363
13379
"node_modules/language-subtag-registry": {
13364
13380
"version": "0.3.23",
13365
13381
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
···
15108
15124
}
15109
15125
},
15110
15126
"node_modules/next": {
15111
-
"version": "16.0.3",
15112
-
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
15113
-
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
15127
+
"version": "16.0.7",
15128
+
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
15129
+
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
15130
+
"license": "MIT",
15114
15131
"dependencies": {
15115
-
"@next/env": "16.0.3",
15132
+
"@next/env": "16.0.7",
15116
15133
"@swc/helpers": "0.5.15",
15117
15134
"caniuse-lite": "^1.0.30001579",
15118
15135
"postcss": "8.4.31",
···
15125
15142
"node": ">=20.9.0"
15126
15143
},
15127
15144
"optionalDependencies": {
15128
-
"@next/swc-darwin-arm64": "16.0.3",
15129
-
"@next/swc-darwin-x64": "16.0.3",
15130
-
"@next/swc-linux-arm64-gnu": "16.0.3",
15131
-
"@next/swc-linux-arm64-musl": "16.0.3",
15132
-
"@next/swc-linux-x64-gnu": "16.0.3",
15133
-
"@next/swc-linux-x64-musl": "16.0.3",
15134
-
"@next/swc-win32-arm64-msvc": "16.0.3",
15135
-
"@next/swc-win32-x64-msvc": "16.0.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",
15136
15153
"sharp": "^0.34.4"
15137
15154
},
15138
15155
"peerDependencies": {
···
16321
16338
}
16322
16339
},
16323
16340
"node_modules/react": {
16324
-
"version": "19.2.0",
16325
-
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
16326
-
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
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==",
16344
+
"license": "MIT",
16327
16345
"engines": {
16328
16346
"node": ">=0.10.0"
16329
16347
}
···
16442
16460
}
16443
16461
},
16444
16462
"node_modules/react-dom": {
16445
-
"version": "19.2.0",
16446
-
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
16447
-
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
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==",
16466
+
"license": "MIT",
16448
16467
"dependencies": {
16449
16468
"scheduler": "^0.27.0"
16450
16469
},
16451
16470
"peerDependencies": {
16452
-
"react": "^19.2.0"
16471
+
"react": "^19.2.1"
16453
16472
}
16454
16473
},
16455
16474
"node_modules/react-is": {
+5
-4
package.json
+5
-4
package.json
···
7
7
"dev": "TZ=UTC next dev --turbo",
8
8
"publish-lexicons": "tsx lexicons/publish.ts",
9
9
"generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
10
-
"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;",
10
+
"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api",
11
11
"wrangler-dev": "wrangler dev",
12
12
"build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node",
13
13
"build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
···
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": "16.0.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.2.0",
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.2.0",
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",
+27
src/atproto-oauth.ts
+27
src/atproto-oauth.ts
···
3
3
NodeSavedSession,
4
4
NodeSavedState,
5
5
RuntimeLock,
6
+
OAuthSession,
6
7
} from "@atproto/oauth-client-node";
7
8
import { JoseKey } from "@atproto/jwk-jose";
8
9
import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata";
···
10
11
11
12
import Client from "ioredis";
12
13
import Redlock from "redlock";
14
+
import { Result, Ok, Err } from "./result";
13
15
export async function createOauthClient() {
14
16
let keyset =
15
17
process.env.NODE_ENV === "production"
···
90
92
.eq("key", key);
91
93
},
92
94
};
95
+
96
+
export type OAuthSessionError = {
97
+
type: "oauth_session_expired";
98
+
message: string;
99
+
did: string;
100
+
};
101
+
102
+
export async function restoreOAuthSession(
103
+
did: string
104
+
): Promise<Result<OAuthSession, OAuthSessionError>> {
105
+
try {
106
+
const oauthClient = await createOauthClient();
107
+
const session = await oauthClient.restore(did);
108
+
return Ok(session);
109
+
} catch (error) {
110
+
return Err({
111
+
type: "oauth_session_expired",
112
+
message:
113
+
error instanceof Error
114
+
? error.message
115
+
: "OAuth session expired or invalid",
116
+
did,
117
+
});
118
+
}
119
+
}
+1
-1
src/hooks/useLocalizedDate.ts
+1
-1
src/hooks/useLocalizedDate.ts
+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;
+254
-37
src/notifications.ts
+254
-37
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
···
12
14
export type NotificationData =
13
15
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
16
| { type: "subscribe"; subscription_uri: string }
15
-
| { type: "quote"; bsky_post_uri: string; document_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 };
16
24
17
25
export type HydratedNotification =
18
26
| HydratedCommentNotification
19
27
| HydratedSubscribeNotification
20
-
| HydratedQuoteNotification;
28
+
| HydratedQuoteNotification
29
+
| HydratedMentionNotification
30
+
| HydratedCommentMentionNotification;
21
31
export async function hydrateNotifications(
22
32
notifications: NotificationRow[],
23
33
): Promise<Array<HydratedNotification>> {
24
34
// Call all hydrators in parallel
25
-
const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
35
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
26
36
hydrateCommentNotifications(notifications),
27
37
hydrateSubscribeNotifications(notifications),
28
38
hydrateQuoteNotifications(notifications),
39
+
hydrateMentionNotifications(notifications),
40
+
hydrateCommentMentionNotifications(notifications),
29
41
]);
30
42
31
43
// Combine all hydrated notifications
32
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
44
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
33
45
34
46
// Sort by created_at to maintain order
35
47
allHydrated.sort(
···
73
85
)
74
86
.in("uri", commentUris);
75
87
76
-
return commentNotifications.map((notification) => ({
77
-
id: notification.id,
78
-
recipient: notification.recipient,
79
-
created_at: notification.created_at,
80
-
type: "comment" as const,
81
-
comment_uri: notification.data.comment_uri,
82
-
parentData: notification.data.parent_uri
83
-
? comments?.find((c) => c.uri === notification.data.parent_uri)!
84
-
: undefined,
85
-
commentData: comments?.find(
86
-
(c) => c.uri === notification.data.comment_uri,
87
-
)!,
88
-
}));
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);
89
105
}
90
106
91
107
export type HydratedSubscribeNotification = Awaited<
···
113
129
.select("*, identities(bsky_profiles(*)), publications(*)")
114
130
.in("uri", subscriptionUris);
115
131
116
-
return subscribeNotifications.map((notification) => ({
117
-
id: notification.id,
118
-
recipient: notification.recipient,
119
-
created_at: notification.created_at,
120
-
type: "subscribe" as const,
121
-
subscription_uri: notification.data.subscription_uri,
122
-
subscriptionData: subscriptions?.find(
123
-
(s) => s.uri === notification.data.subscription_uri,
124
-
)!,
125
-
}));
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);
126
146
}
127
147
128
148
export type HydratedQuoteNotification = Awaited<
···
153
173
.select("*, documents_in_publications(publications(*))")
154
174
.in("uri", documentUris);
155
175
156
-
return quoteNotifications.map((notification) => ({
157
-
id: notification.id,
158
-
recipient: notification.recipient,
159
-
created_at: notification.created_at,
160
-
type: "quote" as const,
161
-
bsky_post_uri: notification.data.bsky_post_uri,
162
-
document_uri: notification.data.document_uri,
163
-
bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!,
164
-
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
165
-
}));
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);
166
383
}
167
384
168
385
export async function pingIdentityToUpdateNotification(did: string) {
+4
src/replicache/attributes.ts
+4
src/replicache/attributes.ts
+64
-9
src/replicache/mutations.ts
+64
-9
src/replicache/mutations.ts
···
319
319
await supabase.storage
320
320
.from("minilink-user-assets")
321
321
.remove([paths[paths.length - 1]]);
322
+
323
+
// Clear cover image if this block is the cover image
324
+
// First try leaflets_in_publications
325
+
const { data: pubResult } = await supabase
326
+
.from("leaflets_in_publications")
327
+
.update({ cover_image: null })
328
+
.eq("leaflet", ctx.permission_token_id)
329
+
.eq("cover_image", block.blockEntity)
330
+
.select("leaflet");
331
+
332
+
// If no rows updated, try leaflets_to_documents
333
+
if (!pubResult || pubResult.length === 0) {
334
+
await supabase
335
+
.from("leaflets_to_documents")
336
+
.update({ cover_image: null })
337
+
.eq("leaflet", ctx.permission_token_id)
338
+
.eq("cover_image", block.blockEntity);
339
+
}
322
340
}
323
341
});
324
-
await ctx.runOnClient(async () => {
342
+
await ctx.runOnClient(async ({ tx }) => {
325
343
let cache = await caches.open("minilink-user-assets");
326
344
if (image) {
327
345
await cache.delete(image.data.src + "?local");
346
+
347
+
// Clear cover image in client state if this block was the cover image
348
+
let currentCoverImage = await tx.get("publication_cover_image");
349
+
if (currentCoverImage === block.blockEntity) {
350
+
await tx.set("publication_cover_image", null);
351
+
}
328
352
}
329
353
});
330
354
await ctx.deleteEntity(block.blockEntity);
···
609
633
};
610
634
611
635
const updatePublicationDraft: Mutation<{
612
-
title: string;
613
-
description: string;
636
+
title?: string;
637
+
description?: string;
638
+
tags?: string[];
639
+
cover_image?: string | null;
614
640
}> = async (args, ctx) => {
615
641
await ctx.runOnServer(async (serverCtx) => {
616
642
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);
643
+
const updates: {
644
+
description?: string;
645
+
title?: string;
646
+
tags?: string[];
647
+
cover_image?: string | null;
648
+
} = {};
649
+
if (args.description !== undefined) updates.description = args.description;
650
+
if (args.title !== undefined) updates.title = args.title;
651
+
if (args.tags !== undefined) updates.tags = args.tags;
652
+
if (args.cover_image !== undefined) updates.cover_image = args.cover_image;
653
+
654
+
if (Object.keys(updates).length > 0) {
655
+
// First try to update leaflets_in_publications (for publications)
656
+
const { data: pubResult } = await serverCtx.supabase
657
+
.from("leaflets_in_publications")
658
+
.update(updates)
659
+
.eq("leaflet", ctx.permission_token_id)
660
+
.select("leaflet");
661
+
662
+
// If no rows were updated in leaflets_in_publications,
663
+
// try leaflets_to_documents (for standalone documents)
664
+
if (!pubResult || pubResult.length === 0) {
665
+
await serverCtx.supabase
666
+
.from("leaflets_to_documents")
667
+
.update(updates)
668
+
.eq("leaflet", ctx.permission_token_id);
669
+
}
670
+
}
621
671
});
622
672
await ctx.runOnClient(async ({ tx }) => {
623
-
await tx.set("publication_title", args.title);
624
-
await tx.set("publication_description", args.description);
673
+
if (args.title !== undefined)
674
+
await tx.set("publication_title", args.title);
675
+
if (args.description !== undefined)
676
+
await tx.set("publication_description", args.description);
677
+
if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
678
+
if (args.cover_image !== undefined)
679
+
await tx.set("publication_cover_image", args.cover_image);
625
680
});
626
681
};
627
682
+8
src/result.ts
+8
src/result.ts
···
1
+
// Result type - a discriminated union for handling success/error cases
2
+
export type Result<T, E> =
3
+
| { ok: true; value: T }
4
+
| { ok: false; error: E };
5
+
6
+
// Constructors
7
+
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
8
+
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
+28
src/useThreadState.ts
+28
src/useThreadState.ts
···
1
+
import { create } from "zustand";
2
+
import { combine } from "zustand/middleware";
3
+
4
+
export const useThreadState = create(
5
+
combine(
6
+
{
7
+
// Set of collapsed thread URIs
8
+
collapsedThreads: new Set<string>(),
9
+
},
10
+
(set) => ({
11
+
toggleCollapsed: (uri: string) => {
12
+
set((state) => {
13
+
const newCollapsed = new Set(state.collapsedThreads);
14
+
if (newCollapsed.has(uri)) {
15
+
newCollapsed.delete(uri);
16
+
} else {
17
+
newCollapsed.add(uri);
18
+
}
19
+
return { collapsedThreads: newCollapsed };
20
+
});
21
+
},
22
+
isCollapsed: (uri: string) => {
23
+
// This is a selector helper, but we'll use the state directly
24
+
return false;
25
+
},
26
+
}),
27
+
),
28
+
);
+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
+
}
+6
-1
src/utils/getMicroLinkOgImage.ts
+6
-1
src/utils/getMicroLinkOgImage.ts
···
17
17
hostname = "leaflet.pub";
18
18
}
19
19
let full_path = `${protocol}://${hostname}${path}`;
20
-
return getWebpageImage(full_path, options);
20
+
return getWebpageImage(full_path, {
21
+
...options,
22
+
setJavaScriptEnabled: false,
23
+
});
21
24
}
22
25
23
26
export async function getWebpageImage(
24
27
url: string,
25
28
options?: {
29
+
setJavaScriptEnabled?: boolean;
26
30
width?: number;
27
31
height?: number;
28
32
deviceScaleFactor?: number;
···
39
43
},
40
44
body: JSON.stringify({
41
45
url,
46
+
setJavaScriptEnabled: options?.setJavaScriptEnabled,
42
47
scrollPage: true,
43
48
addStyleTag: [
44
49
{
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
···
32
32
(p) => p.leaflets_in_publications?.length,
33
33
)?.leaflets_in_publications?.[0];
34
34
35
-
// If not found, check for standalone documents (looseleafs)
36
-
let standaloneDoc = data?.leaflets_to_documents;
37
-
38
-
// Only use standaloneDoc if it exists and has meaningful data
39
-
// (either published with a document, or saved as draft with a title)
40
-
if (
41
-
!pubData &&
42
-
standaloneDoc &&
43
-
(standaloneDoc.document || standaloneDoc.title)
44
-
) {
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) {
45
42
// Transform standalone document data to match the expected format
46
43
pubData = {
47
44
...standaloneDoc,
48
45
publications: null, // No publication for standalone docs
49
46
doc: standaloneDoc.document,
50
-
leaflet: data.id,
51
47
};
52
48
}
53
-
54
-
// Also check nested permission tokens for looseleafs
55
-
if (!pubData) {
56
-
let nestedStandaloneDoc =
57
-
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
58
-
(p) =>
59
-
p.leaflets_to_documents &&
60
-
(p.leaflets_to_documents.document || p.leaflets_to_documents.title),
61
-
)?.leaflets_to_documents;
62
-
63
-
if (nestedStandaloneDoc) {
64
-
pubData = {
65
-
...nestedStandaloneDoc,
66
-
publications: null,
67
-
doc: nestedStandaloneDoc.document,
68
-
leaflet: data.id,
69
-
};
70
-
}
71
-
}
72
-
73
49
return pubData;
74
50
}
+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
+
}
+24
-5
supabase/database.types.ts
+24
-5
supabase/database.types.ts
···
556
556
atp_did?: string | null
557
557
created_at?: string
558
558
email?: string | null
559
-
home_page: string
559
+
home_page?: string
560
560
id?: string
561
561
interface_state?: Json | null
562
562
}
···
581
581
leaflets_in_publications: {
582
582
Row: {
583
583
archived: boolean | null
584
+
cover_image: string | null
584
585
description: string
585
586
doc: string | null
586
587
leaflet: string
···
589
590
}
590
591
Insert: {
591
592
archived?: boolean | null
593
+
cover_image?: string | null
592
594
description?: string
593
595
doc?: string | null
594
596
leaflet: string
···
597
599
}
598
600
Update: {
599
601
archived?: boolean | null
602
+
cover_image?: string | null
600
603
description?: string
601
604
doc?: string | null
602
605
leaflet?: string
···
629
632
}
630
633
leaflets_to_documents: {
631
634
Row: {
635
+
cover_image: string | null
632
636
created_at: string
633
637
description: string
634
-
document: string | null
638
+
document: string
635
639
leaflet: string
636
640
title: string
637
641
}
638
642
Insert: {
643
+
cover_image?: string | null
639
644
created_at?: string
640
645
description?: string
641
-
document?: string | null
646
+
document: string
642
647
leaflet: string
643
648
title?: string
644
649
}
645
650
Update: {
651
+
cover_image?: string | null
646
652
created_at?: string
647
653
description?: string
648
-
document?: string | null
654
+
document?: string
649
655
leaflet?: string
650
656
title?: string
651
657
}
···
660
666
{
661
667
foreignKeyName: "leaflets_to_documents_leaflet_fkey"
662
668
columns: ["leaflet"]
663
-
isOneToOne: true
669
+
isOneToOne: false
664
670
referencedRelation: "permission_tokens"
665
671
referencedColumns: ["id"]
666
672
},
···
1112
1118
[_ in never]: never
1113
1119
}
1114
1120
Functions: {
1121
+
create_identity_homepage: {
1122
+
Args: Record<PropertyKey, never>
1123
+
Returns: string
1124
+
}
1115
1125
get_facts: {
1116
1126
Args: {
1117
1127
root: string
···
1157
1167
client_group_id: string
1158
1168
}
1159
1169
Returns: Database["public"]["CompositeTypes"]["pull_result"]
1170
+
}
1171
+
search_tags: {
1172
+
Args: {
1173
+
search_query: string
1174
+
}
1175
+
Returns: {
1176
+
name: string
1177
+
document_count: number
1178
+
}[]
1160
1179
}
1161
1180
}
1162
1181
Enums: {
+2
supabase/migrations/20251223000000_add_cover_image_column.sql
+2
supabase/migrations/20251223000000_add_cover_image_column.sql
+34
supabase/migrations/20260106183631_add_homepage_default_to_identities.sql
+34
supabase/migrations/20260106183631_add_homepage_default_to_identities.sql
···
1
+
-- Function to create homepage infrastructure for new identities
2
+
-- Replicates the logic from createIdentity TypeScript function
3
+
-- Returns the permission token ID to be used as home_page
4
+
CREATE OR REPLACE FUNCTION create_identity_homepage()
5
+
RETURNS uuid AS $$
6
+
DECLARE
7
+
new_entity_set_id uuid;
8
+
new_entity_id uuid;
9
+
new_permission_token_id uuid;
10
+
BEGIN
11
+
-- Create a new entity set
12
+
INSERT INTO entity_sets DEFAULT VALUES
13
+
RETURNING id INTO new_entity_set_id;
14
+
15
+
-- Create a root entity and add it to that entity set
16
+
new_entity_id := gen_random_uuid();
17
+
INSERT INTO entities (id, set)
18
+
VALUES (new_entity_id, new_entity_set_id);
19
+
20
+
-- Create a new permission token
21
+
INSERT INTO permission_tokens (root_entity)
22
+
VALUES (new_entity_id)
23
+
RETURNING id INTO new_permission_token_id;
24
+
25
+
-- Give the token full permissions on that entity set
26
+
INSERT INTO permission_token_rights (token, entity_set, read, write, create_token, change_entity_set)
27
+
VALUES (new_permission_token_id, new_entity_set_id, true, true, true, true);
28
+
29
+
RETURN new_permission_token_id;
30
+
END;
31
+
$$ LANGUAGE plpgsql;
32
+
33
+
-- Set the function as the default value for home_page column
34
+
ALTER TABLE identities ALTER COLUMN home_page SET DEFAULT create_identity_homepage();
+161
supabase/migrations/20260106190000_add_site_standard_tables.sql
+161
supabase/migrations/20260106190000_add_site_standard_tables.sql
···
1
+
-- site_standard_publications table (modeled off publications)
2
+
create table "public"."site_standard_publications" (
3
+
"uri" text not null,
4
+
"data" jsonb not null,
5
+
"indexed_at" timestamp with time zone not null default now(),
6
+
"identity_did" text not null
7
+
);
8
+
alter table "public"."site_standard_publications" enable row level security;
9
+
10
+
-- site_standard_documents table (modeled off documents)
11
+
create table "public"."site_standard_documents" (
12
+
"uri" text not null,
13
+
"data" jsonb not null,
14
+
"indexed_at" timestamp with time zone not null default now(),
15
+
"identity_did" text not null
16
+
);
17
+
alter table "public"."site_standard_documents" enable row level security;
18
+
19
+
-- site_standard_documents_in_publications relation table (modeled off documents_in_publications)
20
+
create table "public"."site_standard_documents_in_publications" (
21
+
"publication" text not null,
22
+
"document" text not null,
23
+
"indexed_at" timestamp with time zone not null default now()
24
+
);
25
+
alter table "public"."site_standard_documents_in_publications" enable row level security;
26
+
27
+
-- Primary key indexes
28
+
CREATE UNIQUE INDEX site_standard_publications_pkey ON public.site_standard_publications USING btree (uri);
29
+
CREATE UNIQUE INDEX site_standard_documents_pkey ON public.site_standard_documents USING btree (uri);
30
+
CREATE UNIQUE INDEX site_standard_documents_in_publications_pkey ON public.site_standard_documents_in_publications USING btree (publication, document);
31
+
32
+
-- Add primary key constraints
33
+
alter table "public"."site_standard_publications" add constraint "site_standard_publications_pkey" PRIMARY KEY using index "site_standard_publications_pkey";
34
+
alter table "public"."site_standard_documents" add constraint "site_standard_documents_pkey" PRIMARY KEY using index "site_standard_documents_pkey";
35
+
alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_pkey" PRIMARY KEY using index "site_standard_documents_in_publications_pkey";
36
+
37
+
-- Foreign key constraints for identity relations
38
+
alter table "public"."site_standard_publications" add constraint "site_standard_publications_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid;
39
+
alter table "public"."site_standard_publications" validate constraint "site_standard_publications_identity_did_fkey";
40
+
alter table "public"."site_standard_documents" add constraint "site_standard_documents_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid;
41
+
alter table "public"."site_standard_documents" validate constraint "site_standard_documents_identity_did_fkey";
42
+
43
+
-- Foreign key constraints for relation table
44
+
alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_document_fkey" FOREIGN KEY (document) REFERENCES site_standard_documents(uri) ON DELETE CASCADE not valid;
45
+
alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_document_fkey";
46
+
alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid;
47
+
alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_publication_fkey";
48
+
49
+
-- Grants for site_standard_publications
50
+
grant delete on table "public"."site_standard_publications" to "anon";
51
+
grant insert on table "public"."site_standard_publications" to "anon";
52
+
grant references on table "public"."site_standard_publications" to "anon";
53
+
grant select on table "public"."site_standard_publications" to "anon";
54
+
grant trigger on table "public"."site_standard_publications" to "anon";
55
+
grant truncate on table "public"."site_standard_publications" to "anon";
56
+
grant update on table "public"."site_standard_publications" to "anon";
57
+
grant delete on table "public"."site_standard_publications" to "authenticated";
58
+
grant insert on table "public"."site_standard_publications" to "authenticated";
59
+
grant references on table "public"."site_standard_publications" to "authenticated";
60
+
grant select on table "public"."site_standard_publications" to "authenticated";
61
+
grant trigger on table "public"."site_standard_publications" to "authenticated";
62
+
grant truncate on table "public"."site_standard_publications" to "authenticated";
63
+
grant update on table "public"."site_standard_publications" to "authenticated";
64
+
grant delete on table "public"."site_standard_publications" to "service_role";
65
+
grant insert on table "public"."site_standard_publications" to "service_role";
66
+
grant references on table "public"."site_standard_publications" to "service_role";
67
+
grant select on table "public"."site_standard_publications" to "service_role";
68
+
grant trigger on table "public"."site_standard_publications" to "service_role";
69
+
grant truncate on table "public"."site_standard_publications" to "service_role";
70
+
grant update on table "public"."site_standard_publications" to "service_role";
71
+
72
+
-- Grants for site_standard_documents
73
+
grant delete on table "public"."site_standard_documents" to "anon";
74
+
grant insert on table "public"."site_standard_documents" to "anon";
75
+
grant references on table "public"."site_standard_documents" to "anon";
76
+
grant select on table "public"."site_standard_documents" to "anon";
77
+
grant trigger on table "public"."site_standard_documents" to "anon";
78
+
grant truncate on table "public"."site_standard_documents" to "anon";
79
+
grant update on table "public"."site_standard_documents" to "anon";
80
+
grant delete on table "public"."site_standard_documents" to "authenticated";
81
+
grant insert on table "public"."site_standard_documents" to "authenticated";
82
+
grant references on table "public"."site_standard_documents" to "authenticated";
83
+
grant select on table "public"."site_standard_documents" to "authenticated";
84
+
grant trigger on table "public"."site_standard_documents" to "authenticated";
85
+
grant truncate on table "public"."site_standard_documents" to "authenticated";
86
+
grant update on table "public"."site_standard_documents" to "authenticated";
87
+
grant delete on table "public"."site_standard_documents" to "service_role";
88
+
grant insert on table "public"."site_standard_documents" to "service_role";
89
+
grant references on table "public"."site_standard_documents" to "service_role";
90
+
grant select on table "public"."site_standard_documents" to "service_role";
91
+
grant trigger on table "public"."site_standard_documents" to "service_role";
92
+
grant truncate on table "public"."site_standard_documents" to "service_role";
93
+
grant update on table "public"."site_standard_documents" to "service_role";
94
+
95
+
-- Grants for site_standard_documents_in_publications
96
+
grant delete on table "public"."site_standard_documents_in_publications" to "anon";
97
+
grant insert on table "public"."site_standard_documents_in_publications" to "anon";
98
+
grant references on table "public"."site_standard_documents_in_publications" to "anon";
99
+
grant select on table "public"."site_standard_documents_in_publications" to "anon";
100
+
grant trigger on table "public"."site_standard_documents_in_publications" to "anon";
101
+
grant truncate on table "public"."site_standard_documents_in_publications" to "anon";
102
+
grant update on table "public"."site_standard_documents_in_publications" to "anon";
103
+
grant delete on table "public"."site_standard_documents_in_publications" to "authenticated";
104
+
grant insert on table "public"."site_standard_documents_in_publications" to "authenticated";
105
+
grant references on table "public"."site_standard_documents_in_publications" to "authenticated";
106
+
grant select on table "public"."site_standard_documents_in_publications" to "authenticated";
107
+
grant trigger on table "public"."site_standard_documents_in_publications" to "authenticated";
108
+
grant truncate on table "public"."site_standard_documents_in_publications" to "authenticated";
109
+
grant update on table "public"."site_standard_documents_in_publications" to "authenticated";
110
+
grant delete on table "public"."site_standard_documents_in_publications" to "service_role";
111
+
grant insert on table "public"."site_standard_documents_in_publications" to "service_role";
112
+
grant references on table "public"."site_standard_documents_in_publications" to "service_role";
113
+
grant select on table "public"."site_standard_documents_in_publications" to "service_role";
114
+
grant trigger on table "public"."site_standard_documents_in_publications" to "service_role";
115
+
grant truncate on table "public"."site_standard_documents_in_publications" to "service_role";
116
+
grant update on table "public"."site_standard_documents_in_publications" to "service_role";
117
+
118
+
-- site_standard_subscriptions table (modeled off publication_subscriptions)
119
+
create table "public"."site_standard_subscriptions" (
120
+
"publication" text not null,
121
+
"identity" text not null,
122
+
"created_at" timestamp with time zone not null default now(),
123
+
"record" jsonb not null,
124
+
"uri" text not null
125
+
);
126
+
alter table "public"."site_standard_subscriptions" enable row level security;
127
+
128
+
-- Primary key and unique indexes
129
+
CREATE UNIQUE INDEX site_standard_subscriptions_pkey ON public.site_standard_subscriptions USING btree (publication, identity);
130
+
CREATE UNIQUE INDEX site_standard_subscriptions_uri_key ON public.site_standard_subscriptions USING btree (uri);
131
+
132
+
-- Add constraints
133
+
alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_pkey" PRIMARY KEY using index "site_standard_subscriptions_pkey";
134
+
alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_uri_key" UNIQUE using index "site_standard_subscriptions_uri_key";
135
+
alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid;
136
+
alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_publication_fkey";
137
+
alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid;
138
+
alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_identity_fkey";
139
+
140
+
-- Grants for site_standard_subscriptions
141
+
grant delete on table "public"."site_standard_subscriptions" to "anon";
142
+
grant insert on table "public"."site_standard_subscriptions" to "anon";
143
+
grant references on table "public"."site_standard_subscriptions" to "anon";
144
+
grant select on table "public"."site_standard_subscriptions" to "anon";
145
+
grant trigger on table "public"."site_standard_subscriptions" to "anon";
146
+
grant truncate on table "public"."site_standard_subscriptions" to "anon";
147
+
grant update on table "public"."site_standard_subscriptions" to "anon";
148
+
grant delete on table "public"."site_standard_subscriptions" to "authenticated";
149
+
grant insert on table "public"."site_standard_subscriptions" to "authenticated";
150
+
grant references on table "public"."site_standard_subscriptions" to "authenticated";
151
+
grant select on table "public"."site_standard_subscriptions" to "authenticated";
152
+
grant trigger on table "public"."site_standard_subscriptions" to "authenticated";
153
+
grant truncate on table "public"."site_standard_subscriptions" to "authenticated";
154
+
grant update on table "public"."site_standard_subscriptions" to "authenticated";
155
+
grant delete on table "public"."site_standard_subscriptions" to "service_role";
156
+
grant insert on table "public"."site_standard_subscriptions" to "service_role";
157
+
grant references on table "public"."site_standard_subscriptions" to "service_role";
158
+
grant select on table "public"."site_standard_subscriptions" to "service_role";
159
+
grant trigger on table "public"."site_standard_subscriptions" to "service_role";
160
+
grant truncate on table "public"."site_standard_subscriptions" to "service_role";
161
+
grant update on table "public"."site_standard_subscriptions" to "service_role";