-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
-
}
···
+7
-3
actions/emailAuth.ts
+7
-3
actions/emailAuth.ts
···
6
import { email_auth_tokens, identities } from "drizzle/schema";
7
import { and, eq } from "drizzle-orm";
8
import { cookies } from "next/headers";
9
-
import { createIdentity } from "./createIdentity";
10
import { setAuthToken } from "src/auth";
11
import { pool } from "supabase/pool";
12
13
async function sendAuthCode(email: string, code: string) {
14
if (process.env.NODE_ENV === "development") {
···
114
.from(identities)
115
.where(eq(identities.email, token.email));
116
if (!identity) {
117
-
let newIdentity = await createIdentity(db, { email: token.email });
118
-
identityID = newIdentity.id;
119
} else {
120
identityID = identity.id;
121
}
···
6
import { email_auth_tokens, identities } from "drizzle/schema";
7
import { and, eq } from "drizzle-orm";
8
import { cookies } from "next/headers";
9
import { setAuthToken } from "src/auth";
10
import { pool } from "supabase/pool";
11
+
import { supabaseServerClient } from "supabase/serverClient";
12
13
async function sendAuthCode(email: string, code: string) {
14
if (process.env.NODE_ENV === "development") {
···
114
.from(identities)
115
.where(eq(identities.email, token.email));
116
if (!identity) {
117
+
const { data: newIdentity } = await supabaseServerClient
118
+
.from("identities")
119
+
.insert({ email: token.email })
120
+
.select()
121
+
.single();
122
+
identityID = newIdentity!.id;
123
} else {
124
identityID = identity.id;
125
}
+7
-8
actions/login.ts
+7
-8
actions/login.ts
···
4
import {
5
email_auth_tokens,
6
identities,
7
-
entity_sets,
8
-
entities,
9
-
permission_tokens,
10
-
permission_token_rights,
11
permission_token_on_homepage,
12
poll_votes_on_entity,
13
} from "drizzle/schema";
14
import { and, eq, isNull } from "drizzle-orm";
15
import { cookies } from "next/headers";
16
import { redirect } from "next/navigation";
17
-
import { v7 } from "uuid";
18
-
import { createIdentity } from "./createIdentity";
19
import { pool } from "supabase/pool";
20
21
export async function loginWithEmailToken(
22
localLeaflets: { token: { id: string }; added_at: string }[],
···
77
identity = existingIdentityFromCookie;
78
}
79
} else {
80
-
// Create a new identity
81
-
identity = await createIdentity(tx, { email: token.email });
82
}
83
}
84
···
4
import {
5
email_auth_tokens,
6
identities,
7
permission_token_on_homepage,
8
poll_votes_on_entity,
9
} from "drizzle/schema";
10
import { and, eq, isNull } from "drizzle-orm";
11
import { cookies } from "next/headers";
12
import { redirect } from "next/navigation";
13
import { pool } from "supabase/pool";
14
+
import { supabaseServerClient } from "supabase/serverClient";
15
16
export async function loginWithEmailToken(
17
localLeaflets: { token: { id: string }; added_at: string }[],
···
72
identity = existingIdentityFromCookie;
73
}
74
} else {
75
+
const { data: newIdentity } = await supabaseServerClient
76
+
.from("identities")
77
+
.insert({ email: token.email })
78
+
.select()
79
+
.single();
80
+
identity = newIdentity!;
81
}
82
}
83
+61
-10
actions/publishToPublication.ts
+61
-10
actions/publishToPublication.ts
···
2
3
import * as Y from "yjs";
4
import * as base64 from "base64-js";
5
-
import { createOauthClient } from "src/atproto-oauth";
6
import { getIdentityData } from "actions/getIdentityData";
7
import {
8
AtpBaseClient,
···
47
ColorToRGBA,
48
} from "components/ThemeManager/colorToLexicons";
49
import { parseColor } from "@react-stately/color";
50
-
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
51
import { v7 } from "uuid";
52
53
export async function publishToPublication({
54
root_entity,
55
publication_uri,
···
57
title,
58
description,
59
tags,
60
entitiesToDelete,
61
}: {
62
root_entity: string;
···
65
title?: string;
66
description?: string;
67
tags?: string[];
68
entitiesToDelete?: string[];
69
-
}) {
70
-
const oauthClient = await createOauthClient();
71
let identity = await getIdentityData();
72
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
73
74
-
let credentialSession = await oauthClient.restore(identity.atp_did);
75
let agent = new AtpBaseClient(
76
credentialSession.fetchHandler.bind(credentialSession),
77
);
···
135
theme = await extractThemeFromFacts(facts, root_entity, agent);
136
}
137
138
let record: PubLeafletDocument.Record = {
139
publishedAt: new Date().toISOString(),
140
...existingRecord,
···
145
title: title || "Untitled",
146
description: description || "",
147
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
148
pages: pages.map((p) => {
149
if (p.type === "canvas") {
150
return {
···
214
215
// Create notifications for mentions (only on first publish)
216
if (!existingDocUri) {
217
-
await createMentionNotifications(result.uri, record, credentialSession.did!);
218
}
219
220
-
return { rkey, record: JSON.parse(JSON.stringify(record)) };
221
}
222
223
async function processBlocksToPages(
···
424
425
if (b.type == "text") {
426
let [stringValue, facets] = getBlockContent(b.value);
427
let block: $Typed<PubLeafletBlocksText.Main> = {
428
$type: ids.PubLeafletBlocksText,
429
plaintext: stringValue,
430
facets,
431
};
432
return block;
433
}
···
739
root_entity,
740
"theme/background-image-repeat",
741
)?.[0];
742
743
let theme: PubLeafletPublication.Theme = {
744
showPageBackground: showPageBackground ?? true,
745
};
746
747
if (pageBackground)
748
theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
749
if (cardBackground)
···
826
.single();
827
828
if (publication && publication.identity_did !== authorDid) {
829
-
mentionedPublications.set(publication.identity_did, feature.atURI);
830
}
831
} else if (uri.collection === "pub.leaflet.document") {
832
// Get the document owner's DID
···
837
.single();
838
839
if (document) {
840
-
const docRecord = document.data as PubLeafletDocument.Record;
841
if (docRecord.author !== authorDid) {
842
mentionedDocuments.set(docRecord.author, feature.atURI);
843
}
···
2
3
import * as Y from "yjs";
4
import * as base64 from "base64-js";
5
+
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
6
import { getIdentityData } from "actions/getIdentityData";
7
import {
8
AtpBaseClient,
···
47
ColorToRGBA,
48
} from "components/ThemeManager/colorToLexicons";
49
import { parseColor } from "@react-stately/color";
50
+
import {
51
+
Notification,
52
+
pingIdentityToUpdateNotification,
53
+
} 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 };
59
+
60
export async function publishToPublication({
61
root_entity,
62
publication_uri,
···
64
title,
65
description,
66
tags,
67
+
cover_image,
68
entitiesToDelete,
69
}: {
70
root_entity: string;
···
73
title?: string;
74
description?: string;
75
tags?: string[];
76
+
cover_image?: string | null;
77
entitiesToDelete?: string[];
78
+
}): Promise<PublishResult> {
79
let identity = await getIdentityData();
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
+
}
90
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;
96
let agent = new AtpBaseClient(
97
credentialSession.fetchHandler.bind(credentialSession),
98
);
···
156
theme = await extractThemeFromFacts(facts, root_entity, agent);
157
}
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
+
176
let record: PubLeafletDocument.Record = {
177
publishedAt: new Date().toISOString(),
178
...existingRecord,
···
183
title: title || "Untitled",
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
187
pages: pages.map((p) => {
188
if (p.type === "canvas") {
189
return {
···
253
254
// Create notifications for mentions (only on first publish)
255
if (!existingDocUri) {
256
+
await createMentionNotifications(
257
+
result.uri,
258
+
record,
259
+
credentialSession.did!,
260
+
);
261
}
262
263
+
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
264
}
265
266
async function processBlocksToPages(
···
467
468
if (b.type == "text") {
469
let [stringValue, facets] = getBlockContent(b.value);
470
+
let [textSize] = scan.eav(b.value, "block/text-size");
471
let block: $Typed<PubLeafletBlocksText.Main> = {
472
$type: ids.PubLeafletBlocksText,
473
plaintext: stringValue,
474
facets,
475
+
...(textSize && { textSize: textSize.data.value }),
476
};
477
return block;
478
}
···
784
root_entity,
785
"theme/background-image-repeat",
786
)?.[0];
787
+
let pageWidth = scan.eav(root_entity, "theme/page-width")?.[0];
788
789
let theme: PubLeafletPublication.Theme = {
790
showPageBackground: showPageBackground ?? true,
791
};
792
793
+
if (pageWidth) theme.pageWidth = pageWidth.data.value;
794
if (pageBackground)
795
theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
796
if (cardBackground)
···
873
.single();
874
875
if (publication && publication.identity_did !== authorDid) {
876
+
mentionedPublications.set(
877
+
publication.identity_did,
878
+
feature.atURI,
879
+
);
880
}
881
} else if (uri.collection === "pub.leaflet.document") {
882
// Get the document owner's DID
···
887
.single();
888
889
if (document) {
890
+
const docRecord =
891
+
document.data as PubLeafletDocument.Record;
892
if (docRecord.author !== authorDid) {
893
mentionedDocuments.set(docRecord.author, feature.atURI);
894
}
+1
-1
app/(home-pages)/home/Actions/CreateNewButton.tsx
+1
-1
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
5
import { AddTiny } from "components/Icons/AddTiny";
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
8
-
import { Menu, MenuItem } from "components/Layout";
9
import { useIsMobile } from "src/hooks/isMobile";
10
11
export const CreateNewLeafletButton = (props: {}) => {
···
5
import { AddTiny } from "components/Icons/AddTiny";
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
8
+
import { Menu, MenuItem } from "components/Menu";
9
import { useIsMobile } from "src/hooks/isMobile";
10
11
export const CreateNewLeafletButton = (props: {}) => {
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+1
-1
app/(home-pages)/notifications/CommentNotication.tsx
+1
-1
app/(home-pages)/notifications/CommentNotication.tsx
+1
-1
app/(home-pages)/notifications/Notification.tsx
+1
-1
app/(home-pages)/notifications/Notification.tsx
···
1
"use client";
2
import { Avatar } from "components/Avatar";
3
-
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
4
import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api";
5
import { timeAgo } from "src/utils/timeAgo";
6
import { useReplicache, useEntity } from "src/replicache";
···
1
"use client";
2
import { Avatar } from "components/Avatar";
3
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock";
4
import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api";
5
import { timeAgo } from "src/utils/timeAgo";
6
import { useReplicache, useEntity } from "src/replicache";
+1
-1
app/(home-pages)/notifications/ReplyNotification.tsx
+1
-1
app/(home-pages)/notifications/ReplyNotification.tsx
+5
-2
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+5
-2
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
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,
···
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">
181
<div className="text-tertiary text-sm truncate">
182
<span className="font-bold text-secondary">{displayName}</span>{" "}
183
{isReply ? "replied" : "commented"} on{" "}
···
191
) : (
192
<span className="italic text-accent-contrast">{postTitle}</span>
193
)}
194
</div>
195
</div>
196
{isReply && parentRecord && (
···
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]/Blocks/BaseTextBlock";
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
11
import {
12
getProfileComments,
···
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{" "}
···
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 && (
+11
-8
app/(home-pages)/p/[didOrHandle]/layout.tsx
+11
-8
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
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
13
export async function generateMetadata(props: {
14
params: Promise<{ didOrHandle: string }>;
···
23
did = resolved;
24
}
25
26
-
let profileData = await get_profile_data.handler(
27
-
{ didOrHandle: did },
28
-
{ supabase: supabaseServerClient },
29
-
);
30
let { profile } = profileData.result;
31
32
if (!profile) return { title: "Profile - Leaflet" };
···
66
}
67
did = resolved;
68
}
69
-
let profileData = await get_profile_data.handler(
70
-
{ didOrHandle: did },
71
-
{ supabase: supabaseServerClient },
72
-
);
73
let { publications, profile } = profileData.result;
74
75
if (!profile) return null;
···
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 }>;
···
32
did = resolved;
33
}
34
35
+
let profileData = await getCachedProfileData(did);
36
let { profile } = profileData.result;
37
38
if (!profile) return { title: "Profile - Leaflet" };
···
72
}
73
did = resolved;
74
}
75
+
let profileData = await getCachedProfileData(did);
76
let { publications, profile } = profileData.result;
77
78
if (!profile) return null;
+25
-1
app/[leaflet_id]/actions/HelpButton.tsx
+25
-1
app/[leaflet_id]/actions/HelpButton.tsx
···
58
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]}
59
/>
60
<KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} />
61
62
<Label>Block Shortcuts</Label>
63
{/* shift + up/down arrows (or click + drag): select multiple blocks */}
···
161
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
162
style={{
163
backgroundColor: isHovered
164
-
? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)"
165
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
}}
167
onMouseEnter={handleMouseEnter}
···
58
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]}
59
/>
60
<KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} />
61
+
<KeyboardShortcut
62
+
name="Make Title"
63
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "1"]}
64
+
/>
65
+
<KeyboardShortcut
66
+
name="Make Heading"
67
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "2"]}
68
+
/>
69
+
<KeyboardShortcut
70
+
name="Make Subheading"
71
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "3"]}
72
+
/>
73
+
<KeyboardShortcut
74
+
name="Regular Text"
75
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "0"]}
76
+
/>
77
+
<KeyboardShortcut
78
+
name="Large Text"
79
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "+"]}
80
+
/>
81
+
<KeyboardShortcut
82
+
name="Small Text"
83
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "-"]}
84
+
/>
85
86
<Label>Block Shortcuts</Label>
87
{/* shift + up/down arrows (or click + drag): select multiple blocks */}
···
185
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
186
style={{
187
backgroundColor: isHovered
188
+
? "rgb(var(--accent-light))"
189
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
190
}}
191
onMouseEnter={handleMouseEnter}
+45
-7
app/[leaflet_id]/actions/PublishButton.tsx
+45
-7
app/[leaflet_id]/actions/PublishButton.tsx
···
13
import { PublishSmall } from "components/Icons/PublishSmall";
14
import { useIdentityData } from "components/IdentityProvider";
15
import { InputWithLabel } from "components/Input";
16
-
import { Menu, MenuItem } from "components/Layout";
17
import {
18
useLeafletDomains,
19
useLeafletPublicationData,
···
39
import { BlueskyLogin } from "app/login/LoginForm";
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41
import { AddTiny } from "components/Icons/AddTiny";
42
43
export const PublishButton = (props: { entityID: string }) => {
44
let { data: pub } = useLeafletPublicationData();
···
68
let { identity } = useIdentityData();
69
let toaster = useToaster();
70
71
// Get tags from Replicache state (same as draft editor)
72
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73
const currentTags = Array.isArray(tags) ? tags : [];
74
75
return (
76
<ActionButton
77
primary
···
80
onClick={async () => {
81
if (!pub) return;
82
setIsLoading(true);
83
-
let doc = await publishToPublication({
84
root_entity: rootEntity,
85
publication_uri: pub.publications?.uri,
86
leaflet_id: permission_token.id,
87
-
title: pub.title,
88
-
description: pub.description,
89
tags: currentTags,
90
});
91
setIsLoading(false);
92
mutate();
93
94
// Generate URL based on whether it's in a publication or standalone
95
let docUrl = pub.publications
96
-
? `${getPublicationURL(pub.publications)}/${doc?.rkey}`
97
-
: `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`;
98
99
toaster({
100
content: (
101
<div>
102
{pub.doc ? "Updated! " : "Published! "}
103
-
<SpeedyLink href={docUrl}>link</SpeedyLink>
104
</div>
105
),
106
type: "success",
···
13
import { PublishSmall } from "components/Icons/PublishSmall";
14
import { useIdentityData } from "components/IdentityProvider";
15
import { InputWithLabel } from "components/Input";
16
+
import { Menu, MenuItem } from "components/Menu";
17
import {
18
useLeafletDomains,
19
useLeafletPublicationData,
···
39
import { BlueskyLogin } from "app/login/LoginForm";
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41
import { AddTiny } from "components/Icons/AddTiny";
42
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
43
44
export const PublishButton = (props: { entityID: string }) => {
45
let { data: pub } = useLeafletPublicationData();
···
69
let { identity } = useIdentityData();
70
let toaster = useToaster();
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
+
98
return (
99
<ActionButton
100
primary
···
103
onClick={async () => {
104
if (!pub) return;
105
setIsLoading(true);
106
+
let result = await publishToPublication({
107
root_entity: rootEntity,
108
publication_uri: pub.publications?.uri,
109
leaflet_id: permission_token.id,
110
+
title: currentTitle,
111
+
description: currentDescription,
112
tags: currentTags,
113
+
cover_image: coverImage,
114
});
115
setIsLoading(false);
116
mutate();
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
+
130
// Generate URL based on whether it's in a publication or standalone
131
let docUrl = pub.publications
132
+
? `${getPublicationURL(pub.publications)}/${result.rkey}`
133
+
: `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`;
134
135
toaster({
136
content: (
137
<div>
138
{pub.doc ? "Updated! " : "Published! "}
139
+
<SpeedyLink className="underline" href={docUrl}>
140
+
See Published Post
141
+
</SpeedyLink>
142
</div>
143
),
144
type: "success",
+58
-22
app/[leaflet_id]/publish/PublishPost.tsx
+58
-22
app/[leaflet_id]/publish/PublishPost.tsx
···
22
import { TagSelector } from "../../../components/Tags";
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
24
import { PubIcon } from "components/ActionBar/Publications";
25
26
type Props = {
27
title: string;
···
65
let [charCount, setCharCount] = useState(0);
66
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
67
let [isLoading, setIsLoading] = useState(false);
68
let params = useParams();
69
let { rep } = useReplicache();
70
···
73
tx.get<string[]>("publication_tags"),
74
);
75
let [localTags, setLocalTags] = useState<string[]>([]);
76
77
// Use Replicache tags only when we have a draft
78
const hasDraft = props.hasDraft;
···
96
async function submit() {
97
if (isLoading) return;
98
setIsLoading(true);
99
await rep?.push();
100
-
let doc = await publishToPublication({
101
root_entity: props.root_entity,
102
publication_uri: props.publication_uri,
103
leaflet_id: props.leaflet_id,
104
title: props.title,
105
description: props.description,
106
tags: currentTags,
107
entitiesToDelete: props.entitiesToDelete,
108
});
109
-
if (!doc) return;
110
111
// Generate post URL based on whether it's in a publication or standalone
112
let post_url = props.record?.base_path
113
-
? `https://${props.record.base_path}/${doc.rkey}`
114
-
: `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`;
115
116
let [text, facets] = editorStateRef.current
117
? editorStateToFacetedText(editorStateRef.current)
118
: [];
119
-
if (shareOption === "bluesky")
120
-
await publishPostToBsky({
121
facets: facets || [],
122
text: text || "",
123
title: props.title,
124
url: post_url,
125
description: props.description,
126
-
document_record: doc.record,
127
-
rkey: doc.rkey,
128
});
129
setIsLoading(false);
130
props.setPublishState({ state: "success", post_url });
131
}
···
162
</div>
163
<hr className="border-border mb-2" />
164
165
-
<div className="flex justify-between">
166
-
<Link
167
-
className="hover:no-underline! font-bold"
168
-
href={`/${params.leaflet_id}`}
169
-
>
170
-
Back
171
-
</Link>
172
-
<ButtonPrimary
173
-
type="submit"
174
-
className="place-self-end h-[30px]"
175
-
disabled={charCount > 300}
176
-
>
177
-
{isLoading ? <DotLoader /> : "Publish this Post!"}
178
-
</ButtonPrimary>
179
</div>
180
</div>
181
</form>
···
22
import { TagSelector } from "../../../components/Tags";
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
24
import { PubIcon } from "components/ActionBar/Publications";
25
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
26
27
type Props = {
28
title: string;
···
66
let [charCount, setCharCount] = useState(0);
67
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
68
let [isLoading, setIsLoading] = useState(false);
69
+
let [oauthError, setOauthError] = useState<
70
+
import("src/atproto-oauth").OAuthSessionError | null
71
+
>(null);
72
let params = useParams();
73
let { rep } = useReplicache();
74
···
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;
···
105
async function submit() {
106
if (isLoading) return;
107
setIsLoading(true);
108
+
setOauthError(null);
109
await rep?.push();
110
+
let result = await publishToPublication({
111
root_entity: props.root_entity,
112
publication_uri: props.publication_uri,
113
leaflet_id: props.leaflet_id,
114
title: props.title,
115
description: props.description,
116
tags: currentTags,
117
+
cover_image: replicacheCoverImage,
118
entitiesToDelete: props.entitiesToDelete,
119
});
120
+
121
+
if (!result.success) {
122
+
setIsLoading(false);
123
+
if (isOAuthSessionError(result.error)) {
124
+
setOauthError(result.error);
125
+
}
126
+
return;
127
+
}
128
129
// Generate post URL based on whether it's in a publication or standalone
130
let post_url = props.record?.base_path
131
+
? `https://${props.record.base_path}/${result.rkey}`
132
+
: `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`;
133
134
let [text, facets] = editorStateRef.current
135
? editorStateToFacetedText(editorStateRef.current)
136
: [];
137
+
if (shareOption === "bluesky") {
138
+
let bskyResult = await publishPostToBsky({
139
facets: facets || [],
140
text: text || "",
141
title: props.title,
142
url: post_url,
143
description: props.description,
144
+
document_record: result.record,
145
+
rkey: result.rkey,
146
});
147
+
if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) {
148
+
setIsLoading(false);
149
+
setOauthError(bskyResult.error);
150
+
return;
151
+
}
152
+
}
153
setIsLoading(false);
154
props.setPublishState({ state: "success", post_url });
155
}
···
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 ? (
203
+
<DotLoader className="h-[23px]" />
204
+
) : (
205
+
"Publish this Post!"
206
+
)}
207
+
</ButtonPrimary>
208
+
</div>
209
+
{oauthError && (
210
+
<OAuthErrorMessage
211
+
error={oauthError}
212
+
className="text-right text-sm text-accent-contrast"
213
+
/>
214
+
)}
215
</div>
216
</div>
217
</form>
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
···
9
import { TID } from "@atproto/common";
10
import { getIdentityData } from "actions/getIdentityData";
11
import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
12
-
import { createOauthClient } from "src/atproto-oauth";
13
import { supabaseServerClient } from "supabase/serverClient";
14
import { Json } from "supabase/database.types";
15
import {
16
getMicroLinkOgImage,
17
getWebpageImage,
18
} from "src/utils/getMicroLinkOgImage";
19
20
export async function publishPostToBsky(args: {
21
text: string;
···
25
document_record: PubLeafletDocument.Record;
26
rkey: string;
27
facets: AppBskyRichtextFacet.Main[];
28
-
}) {
29
-
const oauthClient = await createOauthClient();
30
let identity = await getIdentityData();
31
-
if (!identity || !identity.atp_did) return null;
32
33
-
let credentialSession = await oauthClient.restore(identity.atp_did);
34
let agent = new AtpBaseClient(
35
credentialSession.fetchHandler.bind(credentialSession),
36
);
37
-
let newPostUrl = args.url;
38
-
let preview_image = await getWebpageImage(newPostUrl, {
39
-
width: 1400,
40
-
height: 733,
41
-
noCache: true,
42
-
});
43
44
-
let binary = await preview_image.blob();
45
-
let resized_preview_image = await sharp(await binary.arrayBuffer())
46
.resize({
47
width: 1200,
48
fit: "cover",
49
})
50
.webp({ quality: 85 })
51
.toBuffer();
52
53
-
let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, {
54
-
headers: { "Content-Type": binary.type },
55
});
56
let bsky = new BskyAgent(credentialSession);
57
let post = await bsky.app.bsky.feed.post.create(
···
90
data: record as Json,
91
})
92
.eq("uri", result.uri);
93
-
return true;
94
}
···
9
import { TID } from "@atproto/common";
10
import { getIdentityData } from "actions/getIdentityData";
11
import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
12
+
import {
13
+
restoreOAuthSession,
14
+
OAuthSessionError,
15
+
} from "src/atproto-oauth";
16
import { supabaseServerClient } from "supabase/serverClient";
17
import { Json } from "supabase/database.types";
18
import {
19
getMicroLinkOgImage,
20
getWebpageImage,
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 };
27
28
export async function publishPostToBsky(args: {
29
text: string;
···
33
document_record: PubLeafletDocument.Record;
34
rkey: string;
35
facets: AppBskyRichtextFacet.Main[];
36
+
}): Promise<PublishBskyResult> {
37
let identity = await getIdentityData();
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
+
}
48
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;
54
let agent = new AtpBaseClient(
55
credentialSession.fetchHandler.bind(credentialSession),
56
);
57
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())
85
.resize({
86
width: 1200,
87
+
height: 630,
88
fit: "cover",
89
})
90
.webp({ quality: 85 })
91
.toBuffer();
92
93
+
let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, {
94
+
headers: { "Content-Type": "image/webp" },
95
});
96
let bsky = new BskyAgent(credentialSession);
97
let post = await bsky.app.bsky.feed.post.create(
···
130
data: record as Json,
131
})
132
.eq("uri", result.uri);
133
+
return { success: true };
134
}
+29
-11
app/api/atproto_images/route.ts
+29
-11
app/api/atproto_images/route.ts
···
1
import { IdResolver } from "@atproto/identity";
2
import { NextRequest, NextResponse } from "next/server";
3
let idResolver = new IdResolver();
4
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 });
13
14
-
let identity = await idResolver.did.resolve(params.did);
15
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
16
-
if (!service) return new NextResponse(null, { status: 404 });
17
const response = await fetch(
18
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`,
19
{
20
headers: {
21
"Accept-Encoding": "gzip, deflate, br, zstd",
22
},
23
},
24
);
25
26
// Clone the response to modify headers
27
const cachedResponse = new Response(response.body, response);
···
1
import { IdResolver } from "@atproto/identity";
2
import { NextRequest, NextResponse } from "next/server";
3
+
4
let idResolver = new IdResolver();
5
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;
15
16
+
let identity = await idResolver.did.resolve(did);
17
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
18
+
if (!service) return null;
19
+
20
const response = await fetch(
21
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
22
{
23
headers: {
24
"Accept-Encoding": "gzip, deflate, br, zstd",
25
},
26
},
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 });
43
44
// Clone the response to modify headers
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
+
}
+3
-40
app/api/bsky/thread/route.ts
+3
-40
app/api/bsky/thread/route.ts
···
1
-
import { Agent, lexToJson } from "@atproto/api";
2
-
import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
3
-
import { cookies } from "next/headers";
4
import { NextRequest } from "next/server";
5
-
import { createOauthClient } from "src/atproto-oauth";
6
-
import { supabaseServerClient } from "supabase/serverClient";
7
8
export const runtime = "nodejs";
9
10
-
async function getAuthenticatedAgent(): Promise<Agent | null> {
11
-
try {
12
-
const cookieStore = await cookies();
13
-
const authToken =
14
-
cookieStore.get("auth_token")?.value ||
15
-
cookieStore.get("external_auth_token")?.value;
16
-
17
-
if (!authToken || authToken === "null") return null;
18
-
19
-
const { data } = await supabaseServerClient
20
-
.from("email_auth_tokens")
21
-
.select("identities(atp_did)")
22
-
.eq("id", authToken)
23
-
.eq("confirmed", true)
24
-
.single();
25
-
26
-
const did = data?.identities?.atp_did;
27
-
if (!did) return null;
28
-
29
-
const oauthClient = await createOauthClient();
30
-
const session = await oauthClient.restore(did);
31
-
return new Agent(session);
32
-
} catch (error) {
33
-
console.error("Failed to get authenticated agent:", error);
34
-
return null;
35
-
}
36
-
}
37
-
38
export async function GET(req: NextRequest) {
39
try {
40
const searchParams = req.nextUrl.searchParams;
···
49
);
50
}
51
52
-
// Try to use authenticated agent if user is logged in, otherwise fall back to public API
53
-
let agent = await getAuthenticatedAgent();
54
-
if (!agent) {
55
-
agent = new Agent({
56
-
service: "https://public.api.bsky.app",
57
-
});
58
-
}
59
60
const response = await agent.getPostThread({
61
uri,
···
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;
···
18
);
19
}
20
21
+
const agent = await getAgent();
22
23
const response = await agent.getPostThread({
24
uri,
+5
-7
app/api/inngest/functions/index_follows.ts
+5
-7
app/api/inngest/functions/index_follows.ts
···
1
import { supabaseServerClient } from "supabase/serverClient";
2
import { AtpAgent, AtUri } from "@atproto/api";
3
-
import { createIdentity } from "actions/createIdentity";
4
-
import { drizzle } from "drizzle-orm/node-postgres";
5
import { inngest } from "../client";
6
-
import { pool } from "supabase/pool";
7
8
export const index_follows = inngest.createFunction(
9
{
···
58
.eq("atp_did", event.data.did)
59
.single();
60
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();
65
return identity;
66
}
67
}),
···
1
import { supabaseServerClient } from "supabase/serverClient";
2
import { AtpAgent, AtUri } from "@atproto/api";
3
import { inngest } from "../client";
4
5
export const index_follows = inngest.createFunction(
6
{
···
55
.eq("atp_did", event.data.did)
56
.single();
57
if (!exists) {
58
+
const { data: identity } = await supabaseServerClient
59
+
.from("identities")
60
+
.insert({ atp_did: event.data.did })
61
+
.select()
62
+
.single();
63
return identity;
64
}
65
}),
+8
-9
app/api/oauth/[route]/route.ts
+8
-9
app/api/oauth/[route]/route.ts
···
1
-
import { createIdentity } from "actions/createIdentity";
2
import { subscribeToPublication } from "app/lish/subscribeToPublication";
3
-
import { drizzle } from "drizzle-orm/node-postgres";
4
import { cookies } from "next/headers";
5
import { redirect } from "next/navigation";
6
import { NextRequest, NextResponse } from "next/server";
···
13
ActionAfterSignIn,
14
parseActionFromSearchParam,
15
} from "./afterSignInActions";
16
-
import { pool } from "supabase/pool";
17
18
type OauthRequestClientState = {
19
redirect: string | null;
···
80
81
return handleAction(s.action, redirectPath);
82
}
83
-
const client = await pool.connect();
84
-
const db = drizzle(client);
85
-
identity = await createIdentity(db, { atp_did: session.did });
86
-
client.release();
87
}
88
let { data: token } = await supabaseServerClient
89
.from("email_auth_tokens")
90
.insert({
91
-
identity: identity.id,
92
confirmed: true,
93
confirmation_code: "",
94
})
···
121
else url = new URL(decodeURIComponent(redirectPath), "https://example.com");
122
if (action?.action === "subscribe") {
123
let result = await subscribeToPublication(action.publication);
124
-
if (result.hasFeed === false)
125
url.searchParams.set("showSubscribeSuccess", "true");
126
}
127
···
1
import { subscribeToPublication } from "app/lish/subscribeToPublication";
2
import { cookies } from "next/headers";
3
import { redirect } from "next/navigation";
4
import { NextRequest, NextResponse } from "next/server";
···
11
ActionAfterSignIn,
12
parseActionFromSearchParam,
13
} from "./afterSignInActions";
14
15
type OauthRequestClientState = {
16
redirect: string | null;
···
77
78
return handleAction(s.action, redirectPath);
79
}
80
+
const { data } = await supabaseServerClient
81
+
.from("identities")
82
+
.insert({ atp_did: session.did })
83
+
.select()
84
+
.single();
85
+
identity = data;
86
}
87
let { data: token } = await supabaseServerClient
88
.from("email_auth_tokens")
89
.insert({
90
+
identity: identity!.id,
91
confirmed: true,
92
confirmation_code: "",
93
})
···
120
else url = new URL(decodeURIComponent(redirectPath), "https://example.com");
121
if (action?.action === "subscribe") {
122
let result = await subscribeToPublication(action.publication);
123
+
if (result.success && result.hasFeed === false)
124
url.searchParams.set("showSubscribeSuccess", "true");
125
}
126
+6
app/api/rpc/[command]/pull.ts
+6
app/api/rpc/[command]/pull.ts
···
74
description: string;
75
title: string;
76
tags: string[];
77
+
cover_image: string | null;
78
}[];
79
let pub_patch = publication_data?.[0]
80
? [
···
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,
100
},
101
]
102
: [];
+18
-13
app/globals.css
+18
-13
app/globals.css
···
107
--highlight-3: 255, 205, 195;
108
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));
112
113
--gripperSVG: url("/gripperPattern.svg");
114
--gripperSVG2: url("/gripperPattern2.svg");
···
125
126
@media (min-width: 640px) {
127
:root {
128
--page-width-unitless: min(
129
-
624,
130
calc(var(--leaflet-width-unitless) - 128)
131
);
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)
141
);
142
-
--page-width-units: min(624px, calc((100vw / 2) - 32px));
143
}
144
}
145
···
270
}
271
272
pre.shiki {
273
@apply p-2;
274
@apply rounded-md;
275
@apply overflow-auto;
276
}
277
278
.highlight:has(+ .highlight) {
···
107
--highlight-3: 255, 205, 195;
108
109
--list-marker-width: 36px;
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
+
);
118
119
--gripperSVG: url("/gripperPattern.svg");
120
--gripperSVG2: url("/gripperPattern2.svg");
···
131
132
@media (min-width: 640px) {
133
:root {
134
+
/*picks between max width and screen width with 64px of padding*/
135
--page-width-unitless: min(
136
+
var(--page-width-setting),
137
calc(var(--leaflet-width-unitless) - 128)
138
);
139
+
--page-width-units: min(
140
+
calc(var(--page-width-unitless) * 1px),
141
+
calc(100vw - 128px)
142
);
143
}
144
}
145
···
270
}
271
272
pre.shiki {
273
+
@apply sm:p-3;
274
@apply p-2;
275
@apply rounded-md;
276
@apply overflow-auto;
277
+
278
+
@media (min-width: 640px) {
279
+
@apply p-3;
280
+
}
281
}
282
283
.highlight:has(+ .highlight) {
+20
-3
app/lish/Subscribe.tsx
+20
-3
app/lish/Subscribe.tsx
···
23
import { useSearchParams } from "next/navigation";
24
import LoginForm from "app/login/LoginForm";
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
27
export const SubscribeWithBluesky = (props: {
28
pubName: string;
···
105
)}
106
107
<a
108
-
href={`https://${props.base_url}/rss`}
109
className="flex"
110
target="_blank"
111
aria-label="Subscribe to RSS"
···
133
}) => {
134
let { identity } = useIdentityData();
135
let toaster = useToaster();
136
let [, subscribe, subscribePending] = useActionState(async () => {
137
let result = await subscribeToPublication(
138
props.pub_uri,
139
window.location.href + "?refreshAuth",
140
);
141
if (result.hasFeed === false) {
142
props.setSuccessModalOpen(true);
143
}
···
172
}
173
174
return (
175
-
<>
176
<form
177
action={subscribe}
178
className="place-self-center flex flex-row gap-1"
···
187
)}
188
</ButtonPrimary>
189
</form>
190
-
</>
191
);
192
};
193
···
23
import { useSearchParams } from "next/navigation";
24
import LoginForm from "app/login/LoginForm";
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
27
28
export const SubscribeWithBluesky = (props: {
29
pubName: string;
···
106
)}
107
108
<a
109
+
href={`${props.base_url}/rss`}
110
className="flex"
111
target="_blank"
112
aria-label="Subscribe to RSS"
···
134
}) => {
135
let { identity } = useIdentityData();
136
let toaster = useToaster();
137
+
let [oauthError, setOauthError] = useState<
138
+
import("src/atproto-oauth").OAuthSessionError | null
139
+
>(null);
140
let [, subscribe, subscribePending] = useActionState(async () => {
141
+
setOauthError(null);
142
let result = await subscribeToPublication(
143
props.pub_uri,
144
window.location.href + "?refreshAuth",
145
);
146
+
if (!result.success) {
147
+
if (isOAuthSessionError(result.error)) {
148
+
setOauthError(result.error);
149
+
}
150
+
return;
151
+
}
152
if (result.hasFeed === false) {
153
props.setSuccessModalOpen(true);
154
}
···
183
}
184
185
return (
186
+
<div className="flex flex-col gap-2 place-self-center">
187
<form
188
action={subscribe}
189
className="place-self-center flex flex-row gap-1"
···
198
)}
199
</ButtonPrimary>
200
</form>
201
+
{oauthError && (
202
+
<OAuthErrorMessage
203
+
error={oauthError}
204
+
className="text-center text-sm text-accent-1"
205
+
/>
206
+
)}
207
+
</div>
208
);
209
};
210
-203
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
-203
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
2
-
import { PubLeafletRichtextFacet } from "lexicons/api";
3
-
import { didToBlueskyUrl } from "src/utils/mentionUtils";
4
-
import { AtMentionLink } from "components/AtMentionLink";
5
-
import { ProfilePopover } from "components/ProfilePopover";
6
-
7
-
type Facet = PubLeafletRichtextFacet.Main;
8
-
export function BaseTextBlock(props: {
9
-
plaintext: string;
10
-
facets?: Facet[];
11
-
index: number[];
12
-
preview?: boolean;
13
-
}) {
14
-
let children = [];
15
-
let richText = new RichText({
16
-
text: props.plaintext,
17
-
facets: props.facets || [],
18
-
});
19
-
let counter = 0;
20
-
for (const segment of richText.segments()) {
21
-
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
22
-
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
23
-
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
24
-
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
25
-
let isStrikethrough = segment.facet?.find(
26
-
PubLeafletRichtextFacet.isStrikethrough,
27
-
);
28
-
let isDidMention = segment.facet?.find(
29
-
PubLeafletRichtextFacet.isDidMention,
30
-
);
31
-
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
32
-
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
33
-
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
34
-
let isHighlighted = segment.facet?.find(
35
-
PubLeafletRichtextFacet.isHighlight,
36
-
);
37
-
let className = `
38
-
${isCode ? "inline-code" : ""}
39
-
${id ? "scroll-mt-12 scroll-mb-10" : ""}
40
-
${isBold ? "font-bold" : ""}
41
-
${isItalic ? "italic" : ""}
42
-
${isUnderline ? "underline" : ""}
43
-
${isStrikethrough ? "line-through decoration-tertiary" : ""}
44
-
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
45
-
46
-
// Split text by newlines and insert <br> tags
47
-
const textParts = segment.text.split("\n");
48
-
const renderedText = textParts.flatMap((part, i) =>
49
-
i < textParts.length - 1
50
-
? [part, <br key={`br-${counter}-${i}`} />]
51
-
: [part],
52
-
);
53
-
54
-
if (isCode) {
55
-
children.push(
56
-
<code key={counter} className={className} id={id?.id}>
57
-
{renderedText}
58
-
</code>,
59
-
);
60
-
} else if (isDidMention) {
61
-
children.push(
62
-
<ProfilePopover
63
-
key={counter}
64
-
didOrHandle={isDidMention.did}
65
-
trigger={<span className="mention">{renderedText}</span>}
66
-
/>,
67
-
);
68
-
} else if (isAtMention) {
69
-
children.push(
70
-
<AtMentionLink
71
-
key={counter}
72
-
atURI={isAtMention.atURI}
73
-
className={className}
74
-
>
75
-
{renderedText}
76
-
</AtMentionLink>,
77
-
);
78
-
} else if (link) {
79
-
children.push(
80
-
<a
81
-
key={counter}
82
-
href={link.uri}
83
-
className={`text-accent-contrast hover:underline ${className}`}
84
-
target="_blank"
85
-
>
86
-
{renderedText}
87
-
</a>,
88
-
);
89
-
} else {
90
-
children.push(
91
-
<span key={counter} className={className} id={id?.id}>
92
-
{renderedText}
93
-
</span>,
94
-
);
95
-
}
96
-
97
-
counter++;
98
-
}
99
-
return <>{children}</>;
100
-
}
101
-
102
-
type RichTextSegment = {
103
-
text: string;
104
-
facet?: Exclude<Facet["features"], { $type: string }>;
105
-
};
106
-
107
-
export class RichText {
108
-
unicodeText: UnicodeString;
109
-
facets?: Facet[];
110
-
111
-
constructor(props: { text: string; facets: Facet[] }) {
112
-
this.unicodeText = new UnicodeString(props.text);
113
-
this.facets = props.facets;
114
-
if (this.facets) {
115
-
this.facets = this.facets
116
-
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
117
-
.sort((a, b) => a.index.byteStart - b.index.byteStart);
118
-
}
119
-
}
120
-
121
-
*segments(): Generator<RichTextSegment, void, void> {
122
-
const facets = this.facets || [];
123
-
if (!facets.length) {
124
-
yield { text: this.unicodeText.utf16 };
125
-
return;
126
-
}
127
-
128
-
let textCursor = 0;
129
-
let facetCursor = 0;
130
-
do {
131
-
const currFacet = facets[facetCursor];
132
-
if (textCursor < currFacet.index.byteStart) {
133
-
yield {
134
-
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
135
-
};
136
-
} else if (textCursor > currFacet.index.byteStart) {
137
-
facetCursor++;
138
-
continue;
139
-
}
140
-
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
141
-
const subtext = this.unicodeText.slice(
142
-
currFacet.index.byteStart,
143
-
currFacet.index.byteEnd,
144
-
);
145
-
if (!subtext.trim()) {
146
-
// dont empty string entities
147
-
yield { text: subtext };
148
-
} else {
149
-
yield { text: subtext, facet: currFacet.features };
150
-
}
151
-
}
152
-
textCursor = currFacet.index.byteEnd;
153
-
facetCursor++;
154
-
} while (facetCursor < facets.length);
155
-
if (textCursor < this.unicodeText.length) {
156
-
yield {
157
-
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
158
-
};
159
-
}
160
-
}
161
-
}
162
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
163
-
if (facets.length === 0) {
164
-
return [newFacet];
165
-
}
166
-
167
-
const allFacets = [...facets, newFacet];
168
-
169
-
// Collect all boundary positions
170
-
const boundaries = new Set<number>();
171
-
boundaries.add(0);
172
-
boundaries.add(length);
173
-
174
-
for (const facet of allFacets) {
175
-
boundaries.add(facet.index.byteStart);
176
-
boundaries.add(facet.index.byteEnd);
177
-
}
178
-
179
-
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
180
-
const result: Facet[] = [];
181
-
182
-
// Process segments between consecutive boundaries
183
-
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
184
-
const start = sortedBoundaries[i];
185
-
const end = sortedBoundaries[i + 1];
186
-
187
-
// Find facets that are active at the start position
188
-
const activeFacets = allFacets.filter(
189
-
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
190
-
);
191
-
192
-
// Only create facet if there are active facets (features present)
193
-
if (activeFacets.length > 0) {
194
-
const features = activeFacets.flatMap((f) => f.features);
195
-
result.push({
196
-
index: { byteStart: start, byteEnd: end },
197
-
features,
198
-
});
199
-
}
200
-
}
201
-
202
-
return result;
203
-
}
···
+25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
+25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
···
···
1
+
import { ProfilePopover } from "components/ProfilePopover";
2
+
import {
3
+
TextBlockCore,
4
+
TextBlockCoreProps,
5
+
RichText,
6
+
} from "../Blocks/TextBlockCore";
7
+
import { ReactNode } from "react";
8
+
9
+
// Re-export RichText for backwards compatibility
10
+
export { RichText };
11
+
12
+
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
13
+
return <ProfilePopover didOrHandle={props.did} trigger={props.children} />;
14
+
}
15
+
16
+
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
17
+
return (
18
+
<TextBlockCore
19
+
{...props}
20
+
renderers={{
21
+
DidMention: DidMentionWithPopover,
22
+
}}
23
+
/>
24
+
);
25
+
}
+28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
+28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
···
···
1
+
"use client";
2
+
3
+
import { PubLeafletBlocksCode } from "lexicons/api";
4
+
import { useLayoutEffect, useState } from "react";
5
+
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
6
+
7
+
export function PubCodeBlock({
8
+
block,
9
+
prerenderedCode,
10
+
}: {
11
+
block: PubLeafletBlocksCode.Main;
12
+
prerenderedCode?: string;
13
+
}) {
14
+
const [html, setHTML] = useState<string | null>(prerenderedCode || null);
15
+
16
+
useLayoutEffect(() => {
17
+
const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext";
18
+
const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light";
19
+
20
+
codeToHtml(block.plaintext, { lang, theme }).then(setHTML);
21
+
}, [block]);
22
+
return (
23
+
<div
24
+
className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline"
25
+
dangerouslySetInnerHTML={{ __html: html || "" }}
26
+
/>
27
+
);
28
+
}
+174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
+174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
···
···
1
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
+
import { Separator } from "components/Layout";
4
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
+
import { CommentTiny } from "components/Icons/CommentTiny";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { ThreadLink, QuotesLink } from "../PostLinks";
9
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
10
+
import {
11
+
BlueskyEmbed,
12
+
PostNotAvailable,
13
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
14
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
15
+
import { openPage } from "../PostPages";
16
+
17
+
export const PubBlueskyPostBlock = (props: {
18
+
post: PostView;
19
+
className: string;
20
+
pageId?: string;
21
+
}) => {
22
+
let post = props.post;
23
+
24
+
const handleOpenThread = () => {
25
+
openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, {
26
+
type: "thread",
27
+
uri: post.uri,
28
+
});
29
+
};
30
+
31
+
switch (true) {
32
+
case AppBskyFeedDefs.isBlockedPost(post) ||
33
+
AppBskyFeedDefs.isBlockedAuthor(post) ||
34
+
AppBskyFeedDefs.isNotFoundPost(post):
35
+
return (
36
+
<div className={`w-full`}>
37
+
<PostNotAvailable />
38
+
</div>
39
+
);
40
+
41
+
case AppBskyFeedDefs.validatePostView(post).success:
42
+
let record = post.record as AppBskyFeedDefs.PostView["record"];
43
+
44
+
// silliness to get the text and timestamp from the record with proper types
45
+
let timestamp: string | undefined = undefined;
46
+
if (AppBskyFeedPost.isRecord(record)) {
47
+
timestamp = (record as AppBskyFeedPost.Record).createdAt;
48
+
}
49
+
50
+
//getting the url to the post
51
+
let postId = post.uri.split("/")[4];
52
+
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
53
+
54
+
const parent = props.pageId
55
+
? { type: "doc" as const, id: props.pageId }
56
+
: undefined;
57
+
58
+
return (
59
+
<div
60
+
onClick={handleOpenThread}
61
+
className={`
62
+
${props.className}
63
+
block-border
64
+
mb-2
65
+
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
66
+
cursor-pointer hover:border-accent-contrast
67
+
`}
68
+
>
69
+
{post.author && record && (
70
+
<>
71
+
<div className="bskyAuthor w-full flex items-center gap-2">
72
+
{post.author.avatar && (
73
+
<img
74
+
src={post.author?.avatar}
75
+
alt={`${post.author?.displayName}'s avatar`}
76
+
className="shink-0 w-8 h-8 rounded-full border border-border-light"
77
+
/>
78
+
)}
79
+
<div className="grow flex flex-col gap-0.5 leading-tight">
80
+
<div className=" font-bold text-secondary">
81
+
{post.author?.displayName}
82
+
</div>
83
+
<a
84
+
className="text-xs text-tertiary hover:underline"
85
+
target="_blank"
86
+
href={`https://bsky.app/profile/${post.author?.handle}`}
87
+
onClick={(e) => e.stopPropagation()}
88
+
>
89
+
@{post.author?.handle}
90
+
</a>
91
+
</div>
92
+
</div>
93
+
94
+
<div className="flex flex-col gap-2 ">
95
+
<div>
96
+
<pre className="whitespace-pre-wrap">
97
+
{BlueskyRichText({
98
+
record: record as AppBskyFeedPost.Record | null,
99
+
})}
100
+
</pre>
101
+
</div>
102
+
{post.embed && (
103
+
<div onClick={(e) => e.stopPropagation()}>
104
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
105
+
</div>
106
+
)}
107
+
</div>
108
+
</>
109
+
)}
110
+
<div className="w-full flex gap-2 items-center justify-between">
111
+
<ClientDate date={timestamp} />
112
+
<div className="flex gap-2 items-center">
113
+
{post.replyCount != null && post.replyCount > 0 && (
114
+
<>
115
+
<ThreadLink
116
+
threadUri={post.uri}
117
+
parent={parent}
118
+
className="flex items-center gap-1 hover:text-accent-contrast"
119
+
onClick={(e) => e.stopPropagation()}
120
+
>
121
+
{post.replyCount}
122
+
<CommentTiny />
123
+
</ThreadLink>
124
+
<Separator classname="h-4" />
125
+
</>
126
+
)}
127
+
{post.quoteCount != null && post.quoteCount > 0 && (
128
+
<>
129
+
<QuotesLink
130
+
postUri={post.uri}
131
+
parent={parent}
132
+
className="flex items-center gap-1 hover:text-accent-contrast"
133
+
onClick={(e) => e.stopPropagation()}
134
+
>
135
+
{post.quoteCount}
136
+
<QuoteTiny />
137
+
</QuotesLink>
138
+
<Separator classname="h-4" />
139
+
</>
140
+
)}
141
+
142
+
<a
143
+
className=""
144
+
target="_blank"
145
+
href={url}
146
+
onClick={(e) => e.stopPropagation()}
147
+
>
148
+
<BlueskyTiny />
149
+
</a>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
);
154
+
}
155
+
};
156
+
157
+
const ClientDate = (props: { date?: string }) => {
158
+
let pageLoaded = useHasPageLoaded();
159
+
const formattedDate = useLocalizedDate(
160
+
props.date || new Date().toISOString(),
161
+
{
162
+
month: "short",
163
+
day: "numeric",
164
+
year: "numeric",
165
+
hour: "numeric",
166
+
minute: "numeric",
167
+
hour12: true,
168
+
},
169
+
);
170
+
171
+
if (!pageLoaded) return null;
172
+
173
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
174
+
};
+344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
+344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
···
···
1
+
"use client";
2
+
3
+
import { useEntity, useReplicache } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { CSSProperties, useContext, useRef } from "react";
6
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
+
import { PostContent, Block } from "../PostContent";
8
+
import {
9
+
PubLeafletBlocksHeader,
10
+
PubLeafletBlocksText,
11
+
PubLeafletComment,
12
+
PubLeafletPagesLinearDocument,
13
+
PubLeafletPagesCanvas,
14
+
PubLeafletPublication,
15
+
} from "lexicons/api";
16
+
import { AppBskyFeedDefs } from "@atproto/api";
17
+
import { TextBlock } from "./TextBlock";
18
+
import { PostPageContext } from "../PostPageContext";
19
+
import { openPage, useOpenPages } from "../PostPages";
20
+
import {
21
+
openInteractionDrawer,
22
+
setInteractionState,
23
+
useInteractionState,
24
+
} from "../Interactions/Interactions";
25
+
import { CommentTiny } from "components/Icons/CommentTiny";
26
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
+
import { CanvasBackgroundPattern } from "components/Canvas";
28
+
29
+
export function PublishedPageLinkBlock(props: {
30
+
blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[];
31
+
parentPageId: string | undefined;
32
+
pageId: string;
33
+
did: string;
34
+
preview?: boolean;
35
+
className?: string;
36
+
prerenderedCodeBlocks?: Map<string, string>;
37
+
bskyPostData: AppBskyFeedDefs.PostView[];
38
+
isCanvas?: boolean;
39
+
pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
40
+
}) {
41
+
//switch to use actually state
42
+
let openPages = useOpenPages();
43
+
let isOpen = openPages.some((p) => p.type === "doc" && p.id === props.pageId);
44
+
return (
45
+
<div
46
+
className={`w-full cursor-pointer
47
+
pageLinkBlockWrapper relative group/pageLinkBlock
48
+
bg-bg-page shadow-sm
49
+
flex overflow-clip
50
+
block-border
51
+
${isOpen && "!border-tertiary"}
52
+
${props.className}
53
+
`}
54
+
onClick={(e) => {
55
+
if (e.isDefaultPrevented()) return;
56
+
if (e.shiftKey) return;
57
+
e.preventDefault();
58
+
e.stopPropagation();
59
+
60
+
openPage(
61
+
props.parentPageId
62
+
? { type: "doc", id: props.parentPageId }
63
+
: undefined,
64
+
{ type: "doc", id: props.pageId },
65
+
);
66
+
}}
67
+
>
68
+
{props.isCanvas ? (
69
+
<CanvasLinkBlock
70
+
blocks={props.blocks as PubLeafletPagesCanvas.Block[]}
71
+
did={props.did}
72
+
pageId={props.pageId}
73
+
bskyPostData={props.bskyPostData}
74
+
pages={props.pages || []}
75
+
/>
76
+
) : (
77
+
<DocLinkBlock
78
+
{...props}
79
+
blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]}
80
+
/>
81
+
)}
82
+
</div>
83
+
);
84
+
}
85
+
export function DocLinkBlock(props: {
86
+
blocks: PubLeafletPagesLinearDocument.Block[];
87
+
pageId: string;
88
+
parentPageId?: string;
89
+
did: string;
90
+
preview?: boolean;
91
+
className?: string;
92
+
prerenderedCodeBlocks?: Map<string, string>;
93
+
bskyPostData: AppBskyFeedDefs.PostView[];
94
+
}) {
95
+
let [title, description] = props.blocks
96
+
.map((b) => b.block)
97
+
.filter(
98
+
(b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b),
99
+
);
100
+
101
+
return (
102
+
<div
103
+
style={{ "--list-marker-width": "20px" } as CSSProperties}
104
+
className={`
105
+
w-full h-[104px]
106
+
`}
107
+
>
108
+
<>
109
+
<div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full">
110
+
<div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col ">
111
+
<div className="grow">
112
+
{title && (
113
+
<div
114
+
className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
115
+
>
116
+
<TextBlock
117
+
facets={title.facets}
118
+
plaintext={title.plaintext}
119
+
index={[]}
120
+
preview
121
+
/>
122
+
</div>
123
+
)}
124
+
{description && (
125
+
<div
126
+
className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
127
+
>
128
+
<TextBlock
129
+
facets={description.facets}
130
+
plaintext={description.plaintext}
131
+
index={[]}
132
+
preview
133
+
/>
134
+
</div>
135
+
)}
136
+
</div>
137
+
138
+
<Interactions
139
+
pageId={props.pageId}
140
+
parentPageId={props.parentPageId}
141
+
/>
142
+
</div>
143
+
{!props.preview && (
144
+
<PagePreview blocks={props.blocks} did={props.did} />
145
+
)}
146
+
</div>
147
+
</>
148
+
</div>
149
+
);
150
+
}
151
+
152
+
export function PagePreview(props: {
153
+
did: string;
154
+
blocks: PubLeafletPagesLinearDocument.Block[];
155
+
}) {
156
+
let previewRef = useRef<HTMLDivElement | null>(null);
157
+
let { rootEntity } = useReplicache();
158
+
let data = useContext(PostPageContext);
159
+
let theme = data?.theme;
160
+
let pageWidth = `var(--page-width-unitless)`;
161
+
let cardBorderHidden = !theme?.showPageBackground;
162
+
return (
163
+
<div
164
+
ref={previewRef}
165
+
className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`}
166
+
>
167
+
<div
168
+
className="absolute top-0 left-0 origin-top-left pointer-events-none "
169
+
style={{
170
+
width: `calc(1px * ${pageWidth})`,
171
+
height: `calc(100vh - 64px)`,
172
+
transform: `scale(calc((120 / ${pageWidth} )))`,
173
+
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))",
174
+
}}
175
+
>
176
+
{!cardBorderHidden && (
177
+
<div
178
+
className={`pageLinkBlockBackground
179
+
absolute top-0 left-0 right-0 bottom-0
180
+
pointer-events-none
181
+
`}
182
+
/>
183
+
)}
184
+
<PostContent
185
+
pollData={[]}
186
+
pages={[]}
187
+
did={props.did}
188
+
blocks={props.blocks}
189
+
preview
190
+
bskyPostData={[]}
191
+
/>
192
+
</div>
193
+
</div>
194
+
);
195
+
}
196
+
197
+
const Interactions = (props: { pageId: string; parentPageId?: string }) => {
198
+
const data = useContext(PostPageContext);
199
+
const document_uri = data?.uri;
200
+
if (!document_uri)
201
+
throw new Error("document_uri not available in PostPageContext");
202
+
let comments = data.comments_on_documents.filter(
203
+
(c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId,
204
+
).length;
205
+
let quotes = data.document_mentions_in_bsky.filter((q) =>
206
+
q.link.includes(props.pageId),
207
+
).length;
208
+
209
+
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
210
+
211
+
return (
212
+
<div
213
+
className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`}
214
+
>
215
+
{quotes > 0 && (
216
+
<button
217
+
className={`flex gap-1 items-center`}
218
+
onClick={(e) => {
219
+
e.preventDefault();
220
+
e.stopPropagation();
221
+
openPage(
222
+
props.parentPageId
223
+
? { type: "doc", id: props.parentPageId }
224
+
: undefined,
225
+
{ type: "doc", id: props.pageId },
226
+
{ scrollIntoView: false },
227
+
);
228
+
if (!drawerOpen || drawer !== "quotes")
229
+
openInteractionDrawer("quotes", document_uri, props.pageId);
230
+
else setInteractionState(document_uri, { drawerOpen: false });
231
+
}}
232
+
>
233
+
<span className="sr-only">Page quotes</span>
234
+
<QuoteTiny aria-hidden /> {quotes}{" "}
235
+
</button>
236
+
)}
237
+
{comments > 0 && (
238
+
<button
239
+
className={`flex gap-1 items-center`}
240
+
onClick={(e) => {
241
+
e.preventDefault();
242
+
e.stopPropagation();
243
+
openPage(
244
+
props.parentPageId
245
+
? { type: "doc", id: props.parentPageId }
246
+
: undefined,
247
+
{ type: "doc", id: props.pageId },
248
+
{ scrollIntoView: false },
249
+
);
250
+
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
251
+
openInteractionDrawer("comments", document_uri, props.pageId);
252
+
else setInteractionState(document_uri, { drawerOpen: false });
253
+
}}
254
+
>
255
+
<span className="sr-only">Page comments</span>
256
+
<CommentTiny aria-hidden /> {comments}{" "}
257
+
</button>
258
+
)}
259
+
</div>
260
+
);
261
+
};
262
+
263
+
const CanvasLinkBlock = (props: {
264
+
blocks: PubLeafletPagesCanvas.Block[];
265
+
did: string;
266
+
pageId: string;
267
+
bskyPostData: AppBskyFeedDefs.PostView[];
268
+
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
269
+
}) => {
270
+
let pageWidth = `var(--page-width-unitless)`;
271
+
let height =
272
+
props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0;
273
+
274
+
return (
275
+
<div
276
+
style={{ contain: "size layout paint" }}
277
+
className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`}
278
+
>
279
+
<div
280
+
className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`}
281
+
style={{
282
+
width: `calc(1px * ${pageWidth})`,
283
+
height: "calc(1150px * 2)",
284
+
transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`,
285
+
}}
286
+
>
287
+
<div
288
+
style={{
289
+
minHeight: height + 512,
290
+
contain: "size layout paint",
291
+
}}
292
+
className="relative h-full w-[1272px]"
293
+
>
294
+
<div className="w-full h-full pointer-events-none">
295
+
<CanvasBackgroundPattern pattern="grid" />
296
+
</div>
297
+
{props.blocks
298
+
.sort((a, b) => {
299
+
if (a.y === b.y) {
300
+
return a.x - b.x;
301
+
}
302
+
return a.y - b.y;
303
+
})
304
+
.map((canvasBlock, index) => {
305
+
let { x, y, width, rotation } = canvasBlock;
306
+
let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`;
307
+
308
+
// Wrap the block in a LinearDocument.Block structure for compatibility
309
+
let linearBlock: PubLeafletPagesLinearDocument.Block = {
310
+
$type: "pub.leaflet.pages.linearDocument#block",
311
+
block: canvasBlock.block,
312
+
};
313
+
314
+
return (
315
+
<div
316
+
key={index}
317
+
className="absolute rounded-lg flex items-stretch origin-center p-3"
318
+
style={{
319
+
top: 0,
320
+
left: 0,
321
+
width,
322
+
transform,
323
+
}}
324
+
>
325
+
<div className="contents">
326
+
<Block
327
+
pollData={[]}
328
+
pageId={props.pageId}
329
+
pages={props.pages}
330
+
bskyPostData={props.bskyPostData}
331
+
block={linearBlock}
332
+
did={props.did}
333
+
index={[index]}
334
+
preview={true}
335
+
/>
336
+
</div>
337
+
</div>
338
+
);
339
+
})}
340
+
</div>
341
+
</div>
342
+
</div>
343
+
);
344
+
};
+346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
+346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
···
···
1
+
"use client";
2
+
3
+
import {
4
+
PubLeafletBlocksPoll,
5
+
PubLeafletPollDefinition,
6
+
PubLeafletPollVote,
7
+
} from "lexicons/api";
8
+
import { useState, useEffect } from "react";
9
+
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
10
+
import { useIdentityData } from "components/IdentityProvider";
11
+
import { AtpAgent } from "@atproto/api";
12
+
import { voteOnPublishedPoll } from "../voteOnPublishedPoll";
13
+
import { PollData } from "../fetchPollData";
14
+
import { Popover } from "components/Popover";
15
+
import LoginForm from "app/login/LoginForm";
16
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
+
import { getVoterIdentities, VoterIdentity } from "../getVoterIdentities";
18
+
import { Json } from "supabase/database.types";
19
+
import { InfoSmall } from "components/Icons/InfoSmall";
20
+
21
+
// Helper function to extract the first option from a vote record
22
+
const getVoteOption = (voteRecord: any): string | null => {
23
+
try {
24
+
const record = voteRecord as PubLeafletPollVote.Record;
25
+
return record.option && record.option.length > 0 ? record.option[0] : null;
26
+
} catch {
27
+
return null;
28
+
}
29
+
};
30
+
31
+
export const PublishedPollBlock = (props: {
32
+
block: PubLeafletBlocksPoll.Main;
33
+
pollData: PollData;
34
+
className?: string;
35
+
}) => {
36
+
const { identity } = useIdentityData();
37
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
38
+
const [isVoting, setIsVoting] = useState(false);
39
+
const [showResults, setShowResults] = useState(false);
40
+
const [optimisticVote, setOptimisticVote] = useState<{
41
+
option: string;
42
+
voter_did: string;
43
+
} | null>(null);
44
+
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
45
+
let [isClient, setIsClient] = useState(false);
46
+
useEffect(() => {
47
+
setIsClient(true);
48
+
}, []);
49
+
50
+
const handleVote = async () => {
51
+
if (!selectedOption || !identity?.atp_did) return;
52
+
53
+
setIsVoting(true);
54
+
55
+
// Optimistically add the vote
56
+
setOptimisticVote({
57
+
option: selectedOption,
58
+
voter_did: identity.atp_did,
59
+
});
60
+
setShowResults(true);
61
+
62
+
try {
63
+
const result = await voteOnPublishedPoll(
64
+
props.block.pollRef.uri,
65
+
props.block.pollRef.cid,
66
+
selectedOption,
67
+
);
68
+
69
+
if (!result.success) {
70
+
console.error("Failed to vote:", result.error);
71
+
// Revert optimistic update on failure
72
+
setOptimisticVote(null);
73
+
setShowResults(false);
74
+
}
75
+
} catch (error) {
76
+
console.error("Failed to vote:", error);
77
+
// Revert optimistic update on failure
78
+
setOptimisticVote(null);
79
+
setShowResults(false);
80
+
} finally {
81
+
setIsVoting(false);
82
+
}
83
+
};
84
+
85
+
const hasVoted =
86
+
!!identity?.atp_did &&
87
+
(!!props.pollData?.atp_poll_votes.find(
88
+
(v) => v.voter_did === identity?.atp_did,
89
+
) ||
90
+
!!optimisticVote);
91
+
let isCreator =
92
+
identity?.atp_did && props.pollData.uri.includes(identity?.atp_did);
93
+
const displayResults = showResults || hasVoted;
94
+
95
+
return (
96
+
<div
97
+
className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`}
98
+
style={{
99
+
backgroundColor:
100
+
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
101
+
}}
102
+
>
103
+
{displayResults ? (
104
+
<>
105
+
<PollResults
106
+
pollData={props.pollData}
107
+
hasVoted={hasVoted}
108
+
setShowResults={setShowResults}
109
+
optimisticVote={optimisticVote}
110
+
/>
111
+
{!hasVoted && (
112
+
<div className="flex justify-start">
113
+
<button
114
+
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
115
+
onClick={() => setShowResults(false)}
116
+
>
117
+
Back to Voting
118
+
</button>
119
+
</div>
120
+
)}
121
+
</>
122
+
) : (
123
+
<>
124
+
{pollRecord.options.map((option, index) => (
125
+
<PollOptionButton
126
+
key={index}
127
+
option={option}
128
+
optionIndex={index.toString()}
129
+
selected={selectedOption === index.toString()}
130
+
onSelect={() => setSelectedOption(index.toString())}
131
+
disabled={!identity?.atp_did}
132
+
/>
133
+
))}
134
+
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2">
135
+
<div className="text-sm text-tertiary">All votes are public</div>
136
+
<div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center">
137
+
<button
138
+
className="w-fit font-bold text-accent-contrast"
139
+
onClick={() => setShowResults(!showResults)}
140
+
>
141
+
See Results
142
+
</button>
143
+
{identity?.atp_did ? (
144
+
<ButtonPrimary
145
+
className="place-self-end"
146
+
onClick={handleVote}
147
+
disabled={!selectedOption || isVoting}
148
+
>
149
+
{isVoting ? "Voting..." : "Vote!"}
150
+
</ButtonPrimary>
151
+
) : (
152
+
<Popover
153
+
asChild
154
+
trigger={
155
+
<ButtonPrimary className="place-self-center">
156
+
<BlueskyTiny /> Login to vote
157
+
</ButtonPrimary>
158
+
}
159
+
>
160
+
{isClient && (
161
+
<LoginForm
162
+
text="Log in to vote on this poll!"
163
+
noEmail
164
+
redirectRoute={window?.location.href + "?refreshAuth"}
165
+
/>
166
+
)}
167
+
</Popover>
168
+
)}
169
+
</div>
170
+
</div>
171
+
</>
172
+
)}
173
+
</div>
174
+
);
175
+
};
176
+
177
+
const PollOptionButton = (props: {
178
+
option: PubLeafletPollDefinition.Option;
179
+
optionIndex: string;
180
+
selected: boolean;
181
+
onSelect: () => void;
182
+
disabled?: boolean;
183
+
}) => {
184
+
const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary;
185
+
186
+
return (
187
+
<div className="flex gap-2 items-center">
188
+
<ButtonComponent
189
+
className="pollOption grow max-w-full flex"
190
+
onClick={props.onSelect}
191
+
disabled={props.disabled}
192
+
>
193
+
{props.option.text}
194
+
</ButtonComponent>
195
+
</div>
196
+
);
197
+
};
198
+
199
+
const PollResults = (props: {
200
+
pollData: PollData;
201
+
hasVoted: boolean;
202
+
setShowResults: (show: boolean) => void;
203
+
optimisticVote: { option: string; voter_did: string } | null;
204
+
}) => {
205
+
// Merge optimistic vote with actual votes
206
+
const allVotes = props.optimisticVote
207
+
? [
208
+
...props.pollData.atp_poll_votes,
209
+
{
210
+
voter_did: props.optimisticVote.voter_did,
211
+
record: {
212
+
$type: "pub.leaflet.poll.vote",
213
+
option: [props.optimisticVote.option],
214
+
},
215
+
},
216
+
]
217
+
: props.pollData.atp_poll_votes;
218
+
219
+
const totalVotes = allVotes.length || 0;
220
+
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
221
+
let optionsWithCount = pollRecord.options.map((o, index) => ({
222
+
...o,
223
+
votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()),
224
+
}));
225
+
226
+
const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length));
227
+
return (
228
+
<>
229
+
{pollRecord.options.map((option, index) => {
230
+
const voteRecords = allVotes.filter(
231
+
(v) => getVoteOption(v.record) === index.toString(),
232
+
);
233
+
const isWinner = totalVotes > 0 && voteRecords.length === highestVotes;
234
+
235
+
return (
236
+
<PollResult
237
+
key={index}
238
+
option={option}
239
+
votes={voteRecords.length}
240
+
voteRecords={voteRecords}
241
+
totalVotes={totalVotes}
242
+
winner={isWinner}
243
+
/>
244
+
);
245
+
})}
246
+
</>
247
+
);
248
+
};
249
+
250
+
const VoterListPopover = (props: {
251
+
votes: number;
252
+
voteRecords: { voter_did: string; record: Json }[];
253
+
}) => {
254
+
const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]);
255
+
const [isLoading, setIsLoading] = useState(false);
256
+
const [hasFetched, setHasFetched] = useState(false);
257
+
258
+
const handleOpenChange = async () => {
259
+
if (!hasFetched && props.voteRecords.length > 0) {
260
+
setIsLoading(true);
261
+
setHasFetched(true);
262
+
try {
263
+
const dids = props.voteRecords.map((v) => v.voter_did);
264
+
const identities = await getVoterIdentities(dids);
265
+
setVoterIdentities(identities);
266
+
} catch (error) {
267
+
console.error("Failed to fetch voter identities:", error);
268
+
} finally {
269
+
setIsLoading(false);
270
+
}
271
+
}
272
+
};
273
+
274
+
return (
275
+
<Popover
276
+
trigger={
277
+
<button
278
+
className="hover:underline cursor-pointer"
279
+
disabled={props.votes === 0}
280
+
>
281
+
{props.votes}
282
+
</button>
283
+
}
284
+
onOpenChange={handleOpenChange}
285
+
className="w-64 max-h-80"
286
+
>
287
+
{isLoading ? (
288
+
<div className="flex justify-center py-4">
289
+
<div className="text-sm text-secondary">Loading...</div>
290
+
</div>
291
+
) : (
292
+
<div className="flex flex-col gap-1 text-sm py-0.5">
293
+
{voterIdentities.map((voter) => (
294
+
<a
295
+
key={voter.did}
296
+
href={`https://bsky.app/profile/${voter.handle || voter.did}`}
297
+
target="_blank"
298
+
rel="noopener noreferrer"
299
+
className=""
300
+
>
301
+
@{voter.handle || voter.did}
302
+
</a>
303
+
))}
304
+
</div>
305
+
)}
306
+
</Popover>
307
+
);
308
+
};
309
+
310
+
const PollResult = (props: {
311
+
option: PubLeafletPollDefinition.Option;
312
+
votes: number;
313
+
voteRecords: { voter_did: string; record: Json }[];
314
+
totalVotes: number;
315
+
winner: boolean;
316
+
}) => {
317
+
return (
318
+
<div
319
+
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
320
+
>
321
+
<div
322
+
style={{
323
+
WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`,
324
+
paintOrder: "stroke fill",
325
+
}}
326
+
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
327
+
>
328
+
<div className="grow max-w-full truncate">{props.option.text}</div>
329
+
<VoterListPopover votes={props.votes} voteRecords={props.voteRecords} />
330
+
</div>
331
+
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
332
+
<div
333
+
className="bg-accent-contrast rounded-[2px] m-0.5"
334
+
style={{
335
+
maskImage: "var(--hatchSVG)",
336
+
maskRepeat: "repeat repeat",
337
+
...(props.votes === 0
338
+
? { width: "4px" }
339
+
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
340
+
}}
341
+
/>
342
+
<div />
343
+
</div>
344
+
</div>
345
+
);
346
+
};
+20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
+20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
···
···
1
+
import { PubLeafletBlocksMath } from "lexicons/api";
2
+
import Katex from "katex";
3
+
import "katex/dist/katex.min.css";
4
+
5
+
export const StaticMathBlock = ({
6
+
block,
7
+
}: {
8
+
block: PubLeafletBlocksMath.Main;
9
+
}) => {
10
+
const html = Katex.renderToString(block.tex, {
11
+
displayMode: true,
12
+
output: "html",
13
+
throwOnError: false,
14
+
});
15
+
return (
16
+
<div className="math-block my-2">
17
+
<div dangerouslySetInnerHTML={{ __html: html }} />
18
+
</div>
19
+
);
20
+
};
+95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
+95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
···
···
1
+
"use client";
2
+
import { UnicodeString } from "@atproto/api";
3
+
import { PubLeafletRichtextFacet } from "lexicons/api";
4
+
import { useMemo } from "react";
5
+
import { useHighlight } from "../useHighlight";
6
+
import { BaseTextBlock } from "./BaseTextBlock";
7
+
8
+
type Facet = PubLeafletRichtextFacet.Main;
9
+
export function TextBlock(props: {
10
+
plaintext: string;
11
+
facets?: Facet[];
12
+
index: number[];
13
+
preview?: boolean;
14
+
pageId?: string;
15
+
}) {
16
+
let children = [];
17
+
let highlights = useHighlight(props.index, props.pageId);
18
+
let facets = useMemo(() => {
19
+
if (props.preview) return props.facets;
20
+
let facets = [...(props.facets || [])];
21
+
for (let highlight of highlights) {
22
+
const fragmentId = props.pageId
23
+
? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}`
24
+
: `${props.index.join(".")}_${highlight.startOffset || 0}`;
25
+
facets = addFacet(
26
+
facets,
27
+
{
28
+
index: {
29
+
byteStart: highlight.startOffset
30
+
? new UnicodeString(
31
+
props.plaintext.slice(0, highlight.startOffset),
32
+
).length
33
+
: 0,
34
+
byteEnd: new UnicodeString(
35
+
props.plaintext.slice(0, highlight.endOffset ?? undefined),
36
+
).length,
37
+
},
38
+
features: [
39
+
{ $type: "pub.leaflet.richtext.facet#highlight" },
40
+
{
41
+
$type: "pub.leaflet.richtext.facet#id",
42
+
id: fragmentId,
43
+
},
44
+
],
45
+
},
46
+
new UnicodeString(props.plaintext).length,
47
+
);
48
+
}
49
+
return facets;
50
+
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
51
+
return <BaseTextBlock {...props} facets={facets} />;
52
+
}
53
+
54
+
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
55
+
if (facets.length === 0) {
56
+
return [newFacet];
57
+
}
58
+
59
+
const allFacets = [...facets, newFacet];
60
+
61
+
// Collect all boundary positions
62
+
const boundaries = new Set<number>();
63
+
boundaries.add(0);
64
+
boundaries.add(length);
65
+
66
+
for (const facet of allFacets) {
67
+
boundaries.add(facet.index.byteStart);
68
+
boundaries.add(facet.index.byteEnd);
69
+
}
70
+
71
+
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
72
+
const result: Facet[] = [];
73
+
74
+
// Process segments between consecutive boundaries
75
+
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
76
+
const start = sortedBoundaries[i];
77
+
const end = sortedBoundaries[i + 1];
78
+
79
+
// Find facets that are active at the start position
80
+
const activeFacets = allFacets.filter(
81
+
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
82
+
);
83
+
84
+
// Only create facet if there are active facets (features present)
85
+
if (activeFacets.length > 0) {
86
+
const features = activeFacets.flatMap((f) => f.features);
87
+
result.push({
88
+
index: { byteStart: start, byteEnd: end },
89
+
features,
90
+
});
91
+
}
92
+
}
93
+
94
+
return result;
95
+
}
+181
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
+181
app/lish/[did]/[publication]/[rkey]/Blocks/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
+
}
+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
+
};
+7
-2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+7
-2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
57
<PageWrapper
58
pageType="canvas"
59
fullPageScroll={fullPageScroll}
60
-
id={pageId ? `post-page-${pageId}` : "post-page"}
61
drawerOpen={
62
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
63
}
···
202
isSubpage: boolean | undefined;
203
data: PostPageData;
204
profile: ProfileViewDetailed;
205
-
preferences: { showComments?: boolean };
206
quotesCount: number | undefined;
207
commentsCount: number | undefined;
208
}) => {
···
213
quotesCount={props.quotesCount || 0}
214
commentsCount={props.commentsCount || 0}
215
showComments={props.preferences.showComments}
216
pageId={props.pageId}
217
/>
218
{!props.isSubpage && (
···
57
<PageWrapper
58
pageType="canvas"
59
fullPageScroll={fullPageScroll}
60
+
id={`post-page-${pageId ?? document_uri}`}
61
drawerOpen={
62
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
63
}
···
202
isSubpage: boolean | undefined;
203
data: PostPageData;
204
profile: ProfileViewDetailed;
205
+
preferences: {
206
+
showComments?: boolean;
207
+
showMentions?: boolean;
208
+
showPrevNext?: boolean;
209
+
};
210
quotesCount: number | undefined;
211
commentsCount: number | undefined;
212
}) => {
···
217
quotesCount={props.quotesCount || 0}
218
commentsCount={props.commentsCount || 0}
219
showComments={props.preferences.showComments}
220
+
showMentions={props.preferences.showMentions}
221
pageId={props.pageId}
222
/>
223
{!props.isSubpage && (
+21
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+21
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
38
import { CloseTiny } from "components/Icons/CloseTiny";
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
40
import { betterIsUrl } from "src/utils/isURL";
41
import { Mention, MentionAutocomplete } from "components/Mention";
42
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
43
···
95
} = useInteractionState(props.doc_uri);
96
let [loading, setLoading] = useState(false);
97
let view = useRef<null | EditorView>(null);
98
99
// Mention autocomplete state
100
const [mentionOpen, setMentionOpen] = useState(false);
···
161
setLoading(true);
162
let currentState = view.current.state;
163
let [plaintext, facets] = docToFacetedText(currentState.doc);
164
-
let comment = await publishComment({
165
pageId: props.pageId,
166
document: props.doc_uri,
167
comment: {
···
178
},
179
});
180
181
let tr = currentState.tr;
182
tr = tr.replaceWith(
183
0,
···
194
localComments: [
195
...s.localComments,
196
{
197
-
record: comment.record,
198
-
uri: comment.uri,
199
bsky_profiles: {
200
-
record: comment.profile as Json,
201
-
did: new AtUri(comment.uri).host,
202
},
203
},
204
],
···
38
import { CloseTiny } from "components/Icons/CloseTiny";
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
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
···
97
} = useInteractionState(props.doc_uri);
98
let [loading, setLoading] = useState(false);
99
let view = useRef<null | EditorView>(null);
100
+
let toaster = useToaster();
101
102
// Mention autocomplete state
103
const [mentionOpen, setMentionOpen] = useState(false);
···
164
setLoading(true);
165
let currentState = view.current.state;
166
let [plaintext, facets] = docToFacetedText(currentState.doc);
167
+
let result = await publishComment({
168
pageId: props.pageId,
169
document: props.doc_uri,
170
comment: {
···
181
},
182
});
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
+
197
let tr = currentState.tr;
198
tr = tr.replaceWith(
199
0,
···
210
localComments: [
211
...s.localComments,
212
{
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
},
219
},
220
],
+25
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
+25
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
3
import { AtpBaseClient, PubLeafletComment } from "lexicons/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
import { PubLeafletRichtextFacet } from "lexicons/api";
6
-
import { createOauthClient } from "src/atproto-oauth";
7
import { TID } from "@atproto/common";
8
import { AtUri, lexToJson, Un$Typed } from "@atproto/api";
9
import { supabaseServerClient } from "supabase/serverClient";
···
15
} from "src/notifications";
16
import { v7 } from "uuid";
17
18
export async function publishComment(args: {
19
document: string;
20
pageId?: string;
···
24
replyTo?: string;
25
attachment: PubLeafletComment.Record["attachment"];
26
};
27
-
}) {
28
-
const oauthClient = await createOauthClient();
29
let identity = await getIdentityData();
30
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
31
32
-
let credentialSession = await oauthClient.restore(identity.atp_did);
33
let agent = new AtpBaseClient(
34
credentialSession.fetchHandler.bind(credentialSession),
35
);
···
108
}
109
110
return {
111
record: data?.[0].record as Json,
112
profile: lexToJson(profile.value),
113
uri: uri.toString(),
···
3
import { AtpBaseClient, PubLeafletComment } from "lexicons/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
import { PubLeafletRichtextFacet } from "lexicons/api";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
10
import { TID } from "@atproto/common";
11
import { AtUri, lexToJson, Un$Typed } from "@atproto/api";
12
import { supabaseServerClient } from "supabase/serverClient";
···
18
} from "src/notifications";
19
import { v7 } from "uuid";
20
21
+
type PublishCommentResult =
22
+
| { success: true; record: Json; profile: any; uri: string }
23
+
| { success: false; error: OAuthSessionError };
24
+
25
export async function publishComment(args: {
26
document: string;
27
pageId?: string;
···
31
replyTo?: string;
32
attachment: PubLeafletComment.Record["attachment"];
33
};
34
+
}): Promise<PublishCommentResult> {
35
let identity = await getIdentityData();
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
+
}
46
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;
52
let agent = new AtpBaseClient(
53
credentialSession.fetchHandler.bind(credentialSession),
54
);
···
127
}
128
129
return {
130
+
success: true,
131
record: data?.[0].record as Json,
132
profile: lexToJson(profile.value),
133
uri: uri.toString(),
+5
-2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+5
-2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
5
import { CommentBox } from "./CommentBox";
6
import { Json } from "supabase/database.types";
7
import { PubLeafletComment } from "lexicons/api";
8
-
import { BaseTextBlock } from "../../BaseTextBlock";
9
import { useMemo, useState } from "react";
10
import { CommentTiny } from "components/Icons/CommentTiny";
11
import { Separator } from "components/Layout";
···
51
}, []);
52
53
return (
54
-
<div id={"commentsDrawer"} className="flex flex-col gap-2 relative">
55
<div className="w-full flex justify-between text-secondary font-bold">
56
Comments
57
<button
···
5
import { CommentBox } from "./CommentBox";
6
import { Json } from "supabase/database.types";
7
import { PubLeafletComment } from "lexicons/api";
8
+
import { BaseTextBlock } from "../../Blocks/BaseTextBlock";
9
import { useMemo, useState } from "react";
10
import { CommentTiny } from "components/Icons/CommentTiny";
11
import { Separator } from "components/Layout";
···
51
}, []);
52
53
return (
54
+
<div
55
+
id={"commentsDrawer"}
56
+
className="flex flex-col gap-2 relative text-sm text-secondary"
57
+
>
58
<div className="w-full flex justify-between text-secondary font-bold">
59
Comments
60
<button
+2
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+2
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
9
import { decodeQuotePosition } from "../quotePosition";
10
11
export const InteractionDrawer = (props: {
12
document_uri: string;
13
quotesAndMentions: { uri: string; link?: string }[];
14
comments: Comment[];
···
38
<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
<div
40
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
>
43
{drawer.drawer === "quotes" ? (
44
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
···
9
import { decodeQuotePosition } from "../quotePosition";
10
11
export const InteractionDrawer = (props: {
12
+
showPageBackground: boolean | undefined;
13
document_uri: string;
14
quotesAndMentions: { uri: string; link?: string }[];
15
comments: Comment[];
···
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))]">
40
<div
41
id="interaction-drawer"
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"}`}
43
>
44
{drawer.drawer === "quotes" ? (
45
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
+68
-44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+68
-44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
108
commentsCount: number;
109
className?: string;
110
showComments?: boolean;
111
pageId?: string;
112
}) => {
113
const data = useContext(PostPageContext);
···
131
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
132
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
133
134
-
{props.quotesCount > 0 && (
135
<button
136
className="flex w-fit gap-2 items-center"
137
onClick={() => {
···
168
commentsCount: number;
169
className?: string;
170
showComments?: boolean;
171
pageId?: string;
172
}) => {
173
const data = useContext(PostPageContext);
···
189
const tags = (data?.data as any)?.tags as string[] | undefined;
190
const tagCount = tags?.length || 0;
191
192
let subscribed =
193
identity?.atp_did &&
194
publication?.publication_subscriptions &&
···
229
<TagList tags={tags} className="mb-3" />
230
</>
231
)}
232
<hr className="border-border-light mb-3 " />
233
<div className="flex gap-2 justify-between">
234
-
<div className="flex gap-2">
235
-
{props.quotesCount > 0 && (
236
-
<button
237
-
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
238
-
onClick={() => {
239
-
if (!drawerOpen || drawer !== "quotes")
240
-
openInteractionDrawer("quotes", document_uri, props.pageId);
241
-
else setInteractionState(document_uri, { drawerOpen: false });
242
-
}}
243
-
onMouseEnter={handleQuotePrefetch}
244
-
onTouchStart={handleQuotePrefetch}
245
-
aria-label="Post quotes"
246
-
>
247
-
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
248
-
<span
249
-
aria-hidden
250
-
>{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span>
251
-
</button>
252
-
)}
253
-
{props.showComments === false ? null : (
254
-
<button
255
-
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
256
-
onClick={() => {
257
-
if (
258
-
!drawerOpen ||
259
-
drawer !== "comments" ||
260
-
pageId !== props.pageId
261
-
)
262
-
openInteractionDrawer("comments", document_uri, props.pageId);
263
-
else setInteractionState(document_uri, { drawerOpen: false });
264
-
}}
265
-
aria-label="Post comments"
266
-
>
267
-
<CommentTiny aria-hidden />{" "}
268
-
{props.commentsCount > 0 ? (
269
-
<span aria-hidden>
270
-
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
271
-
</span>
272
-
) : (
273
-
"Comment"
274
)}
275
-
</button>
276
-
)}
277
-
</div>
278
<EditButton document={data} />
279
{subscribed && publication && (
280
<ManageSubscription
···
108
commentsCount: number;
109
className?: string;
110
showComments?: boolean;
111
+
showMentions?: boolean;
112
pageId?: string;
113
}) => {
114
const data = useContext(PostPageContext);
···
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={() => {
···
169
commentsCount: number;
170
className?: string;
171
showComments?: boolean;
172
+
showMentions?: boolean;
173
pageId?: string;
174
}) => {
175
const data = useContext(PostPageContext);
···
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 &&
···
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
+30
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+30
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
23
import useSWR, { mutate } from "swr";
24
import { DotLoader } from "components/utils/DotLoader";
25
import { CommentTiny } from "components/Icons/CommentTiny";
26
-
import { ThreadLink } from "../ThreadPage";
27
28
// Helper to get SWR key for quotes
29
export function getQuotesSWRKey(uris: string[]) {
···
138
profile={pv.author}
139
handle={pv.author.handle}
140
replyCount={pv.replyCount}
141
/>
142
</div>
143
);
···
161
profile={pv.author}
162
handle={pv.author.handle}
163
replyCount={pv.replyCount}
164
/>
165
);
166
})}
···
180
}) => {
181
let isMobile = useIsMobile();
182
const data = useContext(PostPageContext);
183
184
let record = data?.data as PubLeafletDocument.Record;
185
let page: PubLeafletPagesLinearDocument.Main | undefined = (
···
211
let scrollMargin = isMobile
212
? 16
213
: e.currentTarget.getBoundingClientRect().top;
214
-
let scrollContainer = window.document.getElementById("post-page");
215
let el = window.document.getElementById(
216
props.position.start.block.join("."),
217
);
···
252
handle: string;
253
profile: ProfileViewBasic;
254
replyCount?: number;
255
}) => {
256
const handleOpenThread = () => {
257
openPage(undefined, { type: "thread", uri: props.uri });
···
282
</a>
283
</div>
284
<div className="text-primary">{props.content}</div>
285
-
{props.replyCount != null && props.replyCount > 0 && (
286
-
<ThreadLink
287
-
threadUri={props.uri}
288
-
onClick={(e) => e.stopPropagation()}
289
-
className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast"
290
-
>
291
-
<CommentTiny />
292
-
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
293
-
</ThreadLink>
294
-
)}
295
</div>
296
</div>
297
);
···
23
import useSWR, { mutate } from "swr";
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";
28
29
// Helper to get SWR key for quotes
30
export function getQuotesSWRKey(uris: string[]) {
···
139
profile={pv.author}
140
handle={pv.author.handle}
141
replyCount={pv.replyCount}
142
+
quoteCount={pv.quoteCount}
143
/>
144
</div>
145
);
···
163
profile={pv.author}
164
handle={pv.author.handle}
165
replyCount={pv.replyCount}
166
+
quoteCount={pv.quoteCount}
167
/>
168
);
169
})}
···
183
}) => {
184
let isMobile = useIsMobile();
185
const data = useContext(PostPageContext);
186
+
const document_uri = data?.uri;
187
188
let record = data?.data as PubLeafletDocument.Record;
189
let page: PubLeafletPagesLinearDocument.Main | undefined = (
···
215
let scrollMargin = isMobile
216
? 16
217
: e.currentTarget.getBoundingClientRect().top;
218
+
let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`;
219
+
let scrollContainer = window.document.getElementById(scrollContainerId);
220
let el = window.document.getElementById(
221
props.position.start.block.join("."),
222
);
···
257
handle: string;
258
profile: ProfileViewBasic;
259
replyCount?: number;
260
+
quoteCount?: number;
261
}) => {
262
const handleOpenThread = () => {
263
openPage(undefined, { type: "thread", uri: props.uri });
···
288
</a>
289
</div>
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>
313
</div>
314
</div>
315
);
+8
-3
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+8
-3
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
14
ExpandedInteractions,
15
getCommentCount,
16
getQuoteCount,
17
-
Interactions,
18
} from "./Interactions/Interactions";
19
import { PostContent } from "./PostContent";
20
import { PostHeader } from "./PostHeader/PostHeader";
···
25
import { decodeQuotePosition } from "./quotePosition";
26
import { PollData } from "./fetchPollData";
27
import { SharedPageProps } from "./PostPages";
28
29
export function LinearDocumentPage({
30
blocks,
···
56
57
const isSubpage = !!pageId;
58
59
return (
60
<>
61
<PageWrapper
62
pageType="doc"
63
fullPageScroll={fullPageScroll}
64
-
id={pageId ? `post-page-${pageId}` : "post-page"}
65
drawerOpen={
66
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
67
}
···
83
did={did}
84
prerenderedCodeBlocks={prerenderedCodeBlocks}
85
/>
86
-
87
<ExpandedInteractions
88
pageId={pageId}
89
showComments={preferences.showComments}
90
commentsCount={getCommentCount(document, pageId) || 0}
91
quotesCount={getQuoteCount(document, pageId) || 0}
92
/>
···
14
ExpandedInteractions,
15
getCommentCount,
16
getQuoteCount,
17
} from "./Interactions/Interactions";
18
import { PostContent } from "./PostContent";
19
import { PostHeader } from "./PostHeader/PostHeader";
···
24
import { decodeQuotePosition } from "./quotePosition";
25
import { PollData } from "./fetchPollData";
26
import { SharedPageProps } from "./PostPages";
27
+
import { PostPrevNextButtons } from "./PostPrevNextButtons";
28
29
export function LinearDocumentPage({
30
blocks,
···
56
57
const isSubpage = !!pageId;
58
59
+
console.log("prev/next?: " + preferences.showPrevNext);
60
+
61
return (
62
<>
63
<PageWrapper
64
pageType="doc"
65
fullPageScroll={fullPageScroll}
66
+
id={`post-page-${pageId ?? document_uri}`}
67
drawerOpen={
68
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
69
}
···
85
did={did}
86
prerenderedCodeBlocks={prerenderedCodeBlocks}
87
/>
88
+
<PostPrevNextButtons
89
+
showPrevNext={preferences.showPrevNext && !isSubpage}
90
+
/>
91
<ExpandedInteractions
92
pageId={pageId}
93
showComments={preferences.showComments}
94
+
showMentions={preferences.showMentions}
95
commentsCount={getCommentCount(document, pageId) || 0}
96
quotesCount={getQuoteCount(document, pageId) || 0}
97
/>
+18
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+18
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
20
} from "lexicons/api";
21
22
import { blobRefToSrc } from "src/utils/blobRefToSrc";
23
-
import { TextBlock } from "./TextBlock";
24
import { Popover } from "components/Popover";
25
import { theme } from "tailwind.config";
26
import { ImageAltSmall } from "components/Icons/ImageAlt";
27
-
import { StaticMathBlock } from "./StaticMathBlock";
28
-
import { PubCodeBlock } from "./PubCodeBlock";
29
import { AppBskyFeedDefs } from "@atproto/api";
30
-
import { PubBlueskyPostBlock } from "./PublishBskyPostBlock";
31
import { openPage } from "./PostPages";
32
import { PageLinkBlock } from "components/Blocks/PageLinkBlock";
33
-
import { PublishedPageLinkBlock } from "./PublishedPageBlock";
34
-
import { PublishedPollBlock } from "./PublishedPollBlock";
35
import { PollData } from "./fetchPollData";
36
import { ButtonPrimary } from "components/Buttons";
37
···
173
let uri = b.block.postRef.uri;
174
let post = bskyPostData.find((p) => p.uri === uri);
175
if (!post) return <div>no prefetched post rip</div>;
176
-
return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />;
177
}
178
case PubLeafletBlocksIframe.isMain(b.block): {
179
return (
···
339
}
340
case PubLeafletBlocksText.isMain(b.block):
341
return (
342
-
<p className={`textBlock ${className}`} {...blockProps}>
343
<TextBlock
344
facets={b.block.facets}
345
plaintext={b.block.plaintext}
···
349
/>
350
</p>
351
);
352
case PubLeafletBlocksHeader.isMain(b.block): {
353
if (b.block.level === 1)
354
return (
···
20
} from "lexicons/api";
21
22
import { blobRefToSrc } from "src/utils/blobRefToSrc";
23
+
import { TextBlock } from "./Blocks/TextBlock";
24
import { Popover } from "components/Popover";
25
import { theme } from "tailwind.config";
26
import { ImageAltSmall } from "components/Icons/ImageAlt";
27
+
import { StaticMathBlock } from "./Blocks/StaticMathBlock";
28
+
import { PubCodeBlock } from "./Blocks/PubCodeBlock";
29
import { AppBskyFeedDefs } from "@atproto/api";
30
+
import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock";
31
import { openPage } from "./PostPages";
32
import { PageLinkBlock } from "components/Blocks/PageLinkBlock";
33
+
import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock";
34
+
import { PublishedPollBlock } from "./Blocks/PublishedPollBlock";
35
import { PollData } from "./fetchPollData";
36
import { ButtonPrimary } from "components/Buttons";
37
···
173
let uri = b.block.postRef.uri;
174
let post = bskyPostData.find((p) => p.uri === uri);
175
if (!post) return <div>no prefetched post rip</div>;
176
+
return (
177
+
<PubBlueskyPostBlock
178
+
post={post}
179
+
className={className}
180
+
pageId={pageId}
181
+
/>
182
+
);
183
}
184
case PubLeafletBlocksIframe.isMain(b.block): {
185
return (
···
345
}
346
case PubLeafletBlocksText.isMain(b.block):
347
return (
348
+
<p
349
+
className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`}
350
+
{...blockProps}
351
+
>
352
<TextBlock
353
facets={b.block.facets}
354
plaintext={b.block.plaintext}
···
358
/>
359
</p>
360
);
361
+
362
case PubLeafletBlocksHeader.isMain(b.block): {
363
if (b.block.level === 1)
364
return (
+3
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+3
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
23
export function PostHeader(props: {
24
data: PostPageData;
25
profile: ProfileViewDetailed;
26
-
preferences: { showComments?: boolean };
27
}) {
28
let { identity } = useIdentityData();
29
let document = props.data;
···
91
</div>
92
<Interactions
93
showComments={props.preferences.showComments}
94
quotesCount={getQuoteCount(document) || 0}
95
commentsCount={getCommentCount(document) || 0}
96
/>
···
108
}) => {
109
return (
110
<div
111
-
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
112
id="post-header"
113
>
114
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
···
23
export function PostHeader(props: {
24
data: PostPageData;
25
profile: ProfileViewDetailed;
26
+
preferences: { showComments?: boolean; showMentions?: boolean };
27
}) {
28
let { identity } = useIdentityData();
29
let document = props.data;
···
91
</div>
92
<Interactions
93
showComments={props.preferences.showComments}
94
+
showMentions={props.preferences.showMentions}
95
quotesCount={getQuoteCount(document) || 0}
96
commentsCount={getCommentCount(document) || 0}
97
/>
···
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">
+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
+
}
+47
-6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+47
-6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
25
import { LinearDocumentPage } from "./LinearDocumentPage";
26
import { CanvasPage } from "./CanvasPage";
27
import { ThreadPage as ThreadPageComponent } from "./ThreadPage";
28
29
// Page types
30
export type DocPage = { type: "doc"; id: string };
31
export type ThreadPage = { type: "thread"; uri: string };
32
-
export type OpenPage = DocPage | ThreadPage;
33
34
// Get a stable key for a page
35
const getPageKey = (page: OpenPage): string => {
36
if (page.type === "doc") return page.id;
37
return `thread:${page.uri}`;
38
};
39
···
144
document: PostPageData;
145
did: string;
146
profile: ProfileViewDetailed;
147
-
preferences: { showComments?: boolean };
148
pubRecord?: PubLeafletPublication.Record;
149
theme?: PubLeafletPublication.Theme | null;
150
prerenderedCodeBlocks?: Map<string, string>;
···
203
did: string;
204
prerenderedCodeBlocks?: Map<string, string>;
205
bskyPostData: AppBskyFeedDefs.PostView[];
206
-
preferences: { showComments?: boolean };
207
pollData: PollData[];
208
}) {
209
let drawer = useDrawerOpen(document_uri);
···
258
259
{drawer && !drawer.pageId && (
260
<InteractionDrawer
261
document_uri={document.uri}
262
comments={
263
pubRecord?.preferences?.showComments === false
264
? []
265
: document.comments_on_documents
266
}
267
-
quotesAndMentions={quotesAndMentions}
268
did={did}
269
/>
270
)}
···
292
);
293
}
294
295
// Handle document pages
296
let page = record.pages.find(
297
(p) =>
···
324
/>
325
{drawer && drawer.pageId === page.id && (
326
<InteractionDrawer
327
pageId={page.id}
328
document_uri={document.uri}
329
comments={
···
331
? []
332
: document.comments_on_documents
333
}
334
-
quotesAndMentions={quotesAndMentions}
335
did={did}
336
/>
337
)}
···
351
return (
352
<div
353
className={`pageOptions w-fit z-10
354
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
355
flex sm:flex-col flex-row-reverse gap-1 items-start`}
356
>
357
<PageOptionButton onClick={props.onClick}>
···
25
import { LinearDocumentPage } from "./LinearDocumentPage";
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
};
42
···
147
document: PostPageData;
148
did: string;
149
profile: ProfileViewDetailed;
150
+
preferences: {
151
+
showComments?: boolean;
152
+
showMentions?: boolean;
153
+
showPrevNext?: boolean;
154
+
};
155
pubRecord?: PubLeafletPublication.Record;
156
theme?: PubLeafletPublication.Theme | null;
157
prerenderedCodeBlocks?: Map<string, string>;
···
210
did: string;
211
prerenderedCodeBlocks?: Map<string, string>;
212
bskyPostData: AppBskyFeedDefs.PostView[];
213
+
preferences: {
214
+
showComments?: boolean;
215
+
showMentions?: boolean;
216
+
showPrevNext?: boolean;
217
+
};
218
pollData: PollData[];
219
}) {
220
let drawer = useDrawerOpen(document_uri);
···
269
270
{drawer && !drawer.pageId && (
271
<InteractionDrawer
272
+
showPageBackground={pubRecord?.theme?.showPageBackground}
273
document_uri={document.uri}
274
comments={
275
pubRecord?.preferences?.showComments === false
276
? []
277
: document.comments_on_documents
278
}
279
+
quotesAndMentions={
280
+
pubRecord?.preferences?.showMentions === false
281
+
? []
282
+
: quotesAndMentions
283
+
}
284
did={did}
285
/>
286
)}
···
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
332
let page = record.pages.find(
333
(p) =>
···
360
/>
361
{drawer && drawer.pageId === page.id && (
362
<InteractionDrawer
363
+
showPageBackground={pubRecord?.theme?.showPageBackground}
364
pageId={page.id}
365
document_uri={document.uri}
366
comments={
···
368
? []
369
: document.comments_on_documents
370
}
371
+
quotesAndMentions={
372
+
pubRecord?.preferences?.showMentions === false
373
+
? []
374
+
: quotesAndMentions
375
+
}
376
did={did}
377
/>
378
)}
···
392
return (
393
<div
394
className={`pageOptions w-fit z-10
395
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
396
flex sm:flex-col flex-row-reverse gap-1 items-start`}
397
>
398
<PageOptionButton onClick={props.onClick}>
+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
+
};
-28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
-28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
···
1
-
"use client";
2
-
3
-
import { PubLeafletBlocksCode } from "lexicons/api";
4
-
import { useLayoutEffect, useState } from "react";
5
-
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
6
-
7
-
export function PubCodeBlock({
8
-
block,
9
-
prerenderedCode,
10
-
}: {
11
-
block: PubLeafletBlocksCode.Main;
12
-
prerenderedCode?: string;
13
-
}) {
14
-
const [html, setHTML] = useState<string | null>(prerenderedCode || null);
15
-
16
-
useLayoutEffect(() => {
17
-
const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext";
18
-
const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light";
19
-
20
-
codeToHtml(block.plaintext, { lang, theme }).then(setHTML);
21
-
}, [block]);
22
-
return (
23
-
<div
24
-
className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline"
25
-
dangerouslySetInnerHTML={{ __html: html || "" }}
26
-
/>
27
-
);
28
-
}
···
-157
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
-157
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
1
-
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
-
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
-
import { Separator } from "components/Layout";
4
-
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
-
import { CommentTiny } from "components/Icons/CommentTiny";
7
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
8
-
import {
9
-
BlueskyEmbed,
10
-
PostNotAvailable,
11
-
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
12
-
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
13
-
import { openPage } from "./PostPages";
14
-
import { ThreadLink } from "./ThreadPage";
15
-
16
-
export const PubBlueskyPostBlock = (props: {
17
-
post: PostView;
18
-
className: string;
19
-
pageId?: string;
20
-
}) => {
21
-
let post = props.post;
22
-
23
-
const handleOpenThread = () => {
24
-
openPage(
25
-
props.pageId ? { type: "doc", id: props.pageId } : undefined,
26
-
{ type: "thread", uri: post.uri },
27
-
);
28
-
};
29
-
30
-
switch (true) {
31
-
case AppBskyFeedDefs.isBlockedPost(post) ||
32
-
AppBskyFeedDefs.isBlockedAuthor(post) ||
33
-
AppBskyFeedDefs.isNotFoundPost(post):
34
-
return (
35
-
<div className={`w-full`}>
36
-
<PostNotAvailable />
37
-
</div>
38
-
);
39
-
40
-
case AppBskyFeedDefs.validatePostView(post).success:
41
-
let record = post.record as AppBskyFeedDefs.PostView["record"];
42
-
43
-
// silliness to get the text and timestamp from the record with proper types
44
-
let timestamp: string | undefined = undefined;
45
-
if (AppBskyFeedPost.isRecord(record)) {
46
-
timestamp = (record as AppBskyFeedPost.Record).createdAt;
47
-
}
48
-
49
-
//getting the url to the post
50
-
let postId = post.uri.split("/")[4];
51
-
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
52
-
53
-
const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined;
54
-
55
-
return (
56
-
<div
57
-
onClick={handleOpenThread}
58
-
className={`
59
-
${props.className}
60
-
block-border
61
-
mb-2
62
-
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
63
-
cursor-pointer hover:border-accent-contrast
64
-
`}
65
-
>
66
-
{post.author && record && (
67
-
<>
68
-
<div className="bskyAuthor w-full flex items-center gap-2">
69
-
{post.author.avatar && (
70
-
<img
71
-
src={post.author?.avatar}
72
-
alt={`${post.author?.displayName}'s avatar`}
73
-
className="shink-0 w-8 h-8 rounded-full border border-border-light"
74
-
/>
75
-
)}
76
-
<div className="grow flex flex-col gap-0.5 leading-tight">
77
-
<div className=" font-bold text-secondary">
78
-
{post.author?.displayName}
79
-
</div>
80
-
<a
81
-
className="text-xs text-tertiary hover:underline"
82
-
target="_blank"
83
-
href={`https://bsky.app/profile/${post.author?.handle}`}
84
-
onClick={(e) => e.stopPropagation()}
85
-
>
86
-
@{post.author?.handle}
87
-
</a>
88
-
</div>
89
-
</div>
90
-
91
-
<div className="flex flex-col gap-2 ">
92
-
<div>
93
-
<pre className="whitespace-pre-wrap">
94
-
{BlueskyRichText({
95
-
record: record as AppBskyFeedPost.Record | null,
96
-
})}
97
-
</pre>
98
-
</div>
99
-
{post.embed && (
100
-
<div onClick={(e) => e.stopPropagation()}>
101
-
<BlueskyEmbed embed={post.embed} postUrl={url} />
102
-
</div>
103
-
)}
104
-
</div>
105
-
</>
106
-
)}
107
-
<div className="w-full flex gap-2 items-center justify-between">
108
-
<ClientDate date={timestamp} />
109
-
<div className="flex gap-2 items-center">
110
-
{post.replyCount != null && post.replyCount > 0 && (
111
-
<>
112
-
<ThreadLink
113
-
threadUri={post.uri}
114
-
parent={parent}
115
-
className="flex items-center gap-1 hover:text-accent-contrast"
116
-
onClick={(e) => e.stopPropagation()}
117
-
>
118
-
{post.replyCount}
119
-
<CommentTiny />
120
-
</ThreadLink>
121
-
<Separator classname="h-4" />
122
-
</>
123
-
)}
124
-
125
-
<a
126
-
className=""
127
-
target="_blank"
128
-
href={url}
129
-
onClick={(e) => e.stopPropagation()}
130
-
>
131
-
<BlueskyTiny />
132
-
</a>
133
-
</div>
134
-
</div>
135
-
</div>
136
-
);
137
-
}
138
-
};
139
-
140
-
const ClientDate = (props: { date?: string }) => {
141
-
let pageLoaded = useHasPageLoaded();
142
-
const formattedDate = useLocalizedDate(
143
-
props.date || new Date().toISOString(),
144
-
{
145
-
month: "short",
146
-
day: "numeric",
147
-
year: "numeric",
148
-
hour: "numeric",
149
-
minute: "numeric",
150
-
hour12: true,
151
-
},
152
-
);
153
-
154
-
if (!pageLoaded) return null;
155
-
156
-
return <div className="text-xs text-tertiary">{formattedDate}</div>;
157
-
};
···
-340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
-340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
1
-
"use client";
2
-
3
-
import { useEntity, useReplicache } from "src/replicache";
4
-
import { useUIState } from "src/useUIState";
5
-
import { CSSProperties, useContext, useRef } from "react";
6
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
-
import { PostContent, Block } from "./PostContent";
8
-
import {
9
-
PubLeafletBlocksHeader,
10
-
PubLeafletBlocksText,
11
-
PubLeafletComment,
12
-
PubLeafletPagesLinearDocument,
13
-
PubLeafletPagesCanvas,
14
-
PubLeafletPublication,
15
-
} from "lexicons/api";
16
-
import { AppBskyFeedDefs } from "@atproto/api";
17
-
import { TextBlock } from "./TextBlock";
18
-
import { PostPageContext } from "./PostPageContext";
19
-
import { openPage, useOpenPages } from "./PostPages";
20
-
import {
21
-
openInteractionDrawer,
22
-
setInteractionState,
23
-
useInteractionState,
24
-
} from "./Interactions/Interactions";
25
-
import { CommentTiny } from "components/Icons/CommentTiny";
26
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
-
import { CanvasBackgroundPattern } from "components/Canvas";
28
-
29
-
export function PublishedPageLinkBlock(props: {
30
-
blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[];
31
-
parentPageId: string | undefined;
32
-
pageId: string;
33
-
did: string;
34
-
preview?: boolean;
35
-
className?: string;
36
-
prerenderedCodeBlocks?: Map<string, string>;
37
-
bskyPostData: AppBskyFeedDefs.PostView[];
38
-
isCanvas?: boolean;
39
-
pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
40
-
}) {
41
-
//switch to use actually state
42
-
let openPages = useOpenPages();
43
-
let isOpen = openPages.some(
44
-
(p) => p.type === "doc" && p.id === props.pageId,
45
-
);
46
-
return (
47
-
<div
48
-
className={`w-full cursor-pointer
49
-
pageLinkBlockWrapper relative group/pageLinkBlock
50
-
bg-bg-page shadow-sm
51
-
flex overflow-clip
52
-
block-border
53
-
${isOpen && "!border-tertiary"}
54
-
${props.className}
55
-
`}
56
-
onClick={(e) => {
57
-
if (e.isDefaultPrevented()) return;
58
-
if (e.shiftKey) return;
59
-
e.preventDefault();
60
-
e.stopPropagation();
61
-
62
-
openPage(
63
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
64
-
{ type: "doc", id: props.pageId },
65
-
);
66
-
}}
67
-
>
68
-
{props.isCanvas ? (
69
-
<CanvasLinkBlock
70
-
blocks={props.blocks as PubLeafletPagesCanvas.Block[]}
71
-
did={props.did}
72
-
pageId={props.pageId}
73
-
bskyPostData={props.bskyPostData}
74
-
pages={props.pages || []}
75
-
/>
76
-
) : (
77
-
<DocLinkBlock
78
-
{...props}
79
-
blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]}
80
-
/>
81
-
)}
82
-
</div>
83
-
);
84
-
}
85
-
export function DocLinkBlock(props: {
86
-
blocks: PubLeafletPagesLinearDocument.Block[];
87
-
pageId: string;
88
-
parentPageId?: string;
89
-
did: string;
90
-
preview?: boolean;
91
-
className?: string;
92
-
prerenderedCodeBlocks?: Map<string, string>;
93
-
bskyPostData: AppBskyFeedDefs.PostView[];
94
-
}) {
95
-
let [title, description] = props.blocks
96
-
.map((b) => b.block)
97
-
.filter(
98
-
(b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b),
99
-
);
100
-
101
-
return (
102
-
<div
103
-
style={{ "--list-marker-width": "20px" } as CSSProperties}
104
-
className={`
105
-
w-full h-[104px]
106
-
`}
107
-
>
108
-
<>
109
-
<div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full">
110
-
<div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col ">
111
-
<div className="grow">
112
-
{title && (
113
-
<div
114
-
className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
115
-
>
116
-
<TextBlock
117
-
facets={title.facets}
118
-
plaintext={title.plaintext}
119
-
index={[]}
120
-
preview
121
-
/>
122
-
</div>
123
-
)}
124
-
{description && (
125
-
<div
126
-
className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
127
-
>
128
-
<TextBlock
129
-
facets={description.facets}
130
-
plaintext={description.plaintext}
131
-
index={[]}
132
-
preview
133
-
/>
134
-
</div>
135
-
)}
136
-
</div>
137
-
138
-
<Interactions
139
-
pageId={props.pageId}
140
-
parentPageId={props.parentPageId}
141
-
/>
142
-
</div>
143
-
{!props.preview && (
144
-
<PagePreview blocks={props.blocks} did={props.did} />
145
-
)}
146
-
</div>
147
-
</>
148
-
</div>
149
-
);
150
-
}
151
-
152
-
export function PagePreview(props: {
153
-
did: string;
154
-
blocks: PubLeafletPagesLinearDocument.Block[];
155
-
}) {
156
-
let previewRef = useRef<HTMLDivElement | null>(null);
157
-
let { rootEntity } = useReplicache();
158
-
let data = useContext(PostPageContext);
159
-
let theme = data?.theme;
160
-
let pageWidth = `var(--page-width-unitless)`;
161
-
let cardBorderHidden = !theme?.showPageBackground;
162
-
return (
163
-
<div
164
-
ref={previewRef}
165
-
className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`}
166
-
>
167
-
<div
168
-
className="absolute top-0 left-0 origin-top-left pointer-events-none "
169
-
style={{
170
-
width: `calc(1px * ${pageWidth})`,
171
-
height: `calc(100vh - 64px)`,
172
-
transform: `scale(calc((120 / ${pageWidth} )))`,
173
-
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))",
174
-
}}
175
-
>
176
-
{!cardBorderHidden && (
177
-
<div
178
-
className={`pageLinkBlockBackground
179
-
absolute top-0 left-0 right-0 bottom-0
180
-
pointer-events-none
181
-
`}
182
-
/>
183
-
)}
184
-
<PostContent
185
-
pollData={[]}
186
-
pages={[]}
187
-
did={props.did}
188
-
blocks={props.blocks}
189
-
preview
190
-
bskyPostData={[]}
191
-
/>
192
-
</div>
193
-
</div>
194
-
);
195
-
}
196
-
197
-
const Interactions = (props: { pageId: string; parentPageId?: string }) => {
198
-
const data = useContext(PostPageContext);
199
-
const document_uri = data?.uri;
200
-
if (!document_uri)
201
-
throw new Error("document_uri not available in PostPageContext");
202
-
let comments = data.comments_on_documents.filter(
203
-
(c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId,
204
-
).length;
205
-
let quotes = data.document_mentions_in_bsky.filter((q) =>
206
-
q.link.includes(props.pageId),
207
-
).length;
208
-
209
-
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
210
-
211
-
return (
212
-
<div
213
-
className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`}
214
-
>
215
-
{quotes > 0 && (
216
-
<button
217
-
className={`flex gap-1 items-center`}
218
-
onClick={(e) => {
219
-
e.preventDefault();
220
-
e.stopPropagation();
221
-
openPage(
222
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
223
-
{ type: "doc", id: props.pageId },
224
-
{ scrollIntoView: false },
225
-
);
226
-
if (!drawerOpen || drawer !== "quotes")
227
-
openInteractionDrawer("quotes", document_uri, props.pageId);
228
-
else setInteractionState(document_uri, { drawerOpen: false });
229
-
}}
230
-
>
231
-
<span className="sr-only">Page quotes</span>
232
-
<QuoteTiny aria-hidden /> {quotes}{" "}
233
-
</button>
234
-
)}
235
-
{comments > 0 && (
236
-
<button
237
-
className={`flex gap-1 items-center`}
238
-
onClick={(e) => {
239
-
e.preventDefault();
240
-
e.stopPropagation();
241
-
openPage(
242
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
243
-
{ type: "doc", id: props.pageId },
244
-
{ scrollIntoView: false },
245
-
);
246
-
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
247
-
openInteractionDrawer("comments", document_uri, props.pageId);
248
-
else setInteractionState(document_uri, { drawerOpen: false });
249
-
}}
250
-
>
251
-
<span className="sr-only">Page comments</span>
252
-
<CommentTiny aria-hidden /> {comments}{" "}
253
-
</button>
254
-
)}
255
-
</div>
256
-
);
257
-
};
258
-
259
-
const CanvasLinkBlock = (props: {
260
-
blocks: PubLeafletPagesCanvas.Block[];
261
-
did: string;
262
-
pageId: string;
263
-
bskyPostData: AppBskyFeedDefs.PostView[];
264
-
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
265
-
}) => {
266
-
let pageWidth = `var(--page-width-unitless)`;
267
-
let height =
268
-
props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0;
269
-
270
-
return (
271
-
<div
272
-
style={{ contain: "size layout paint" }}
273
-
className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`}
274
-
>
275
-
<div
276
-
className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`}
277
-
style={{
278
-
width: `calc(1px * ${pageWidth})`,
279
-
height: "calc(1150px * 2)",
280
-
transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`,
281
-
}}
282
-
>
283
-
<div
284
-
style={{
285
-
minHeight: height + 512,
286
-
contain: "size layout paint",
287
-
}}
288
-
className="relative h-full w-[1272px]"
289
-
>
290
-
<div className="w-full h-full pointer-events-none">
291
-
<CanvasBackgroundPattern pattern="grid" />
292
-
</div>
293
-
{props.blocks
294
-
.sort((a, b) => {
295
-
if (a.y === b.y) {
296
-
return a.x - b.x;
297
-
}
298
-
return a.y - b.y;
299
-
})
300
-
.map((canvasBlock, index) => {
301
-
let { x, y, width, rotation } = canvasBlock;
302
-
let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`;
303
-
304
-
// Wrap the block in a LinearDocument.Block structure for compatibility
305
-
let linearBlock: PubLeafletPagesLinearDocument.Block = {
306
-
$type: "pub.leaflet.pages.linearDocument#block",
307
-
block: canvasBlock.block,
308
-
};
309
-
310
-
return (
311
-
<div
312
-
key={index}
313
-
className="absolute rounded-lg flex items-stretch origin-center p-3"
314
-
style={{
315
-
top: 0,
316
-
left: 0,
317
-
width,
318
-
transform,
319
-
}}
320
-
>
321
-
<div className="contents">
322
-
<Block
323
-
pollData={[]}
324
-
pageId={props.pageId}
325
-
pages={props.pages}
326
-
bskyPostData={props.bskyPostData}
327
-
block={linearBlock}
328
-
did={props.did}
329
-
index={[index]}
330
-
preview={true}
331
-
/>
332
-
</div>
333
-
</div>
334
-
);
335
-
})}
336
-
</div>
337
-
</div>
338
-
</div>
339
-
);
340
-
};
···
-346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
-346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
···
1
-
"use client";
2
-
3
-
import {
4
-
PubLeafletBlocksPoll,
5
-
PubLeafletPollDefinition,
6
-
PubLeafletPollVote,
7
-
} from "lexicons/api";
8
-
import { useState, useEffect } from "react";
9
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
10
-
import { useIdentityData } from "components/IdentityProvider";
11
-
import { AtpAgent } from "@atproto/api";
12
-
import { voteOnPublishedPoll } from "./voteOnPublishedPoll";
13
-
import { PollData } from "./fetchPollData";
14
-
import { Popover } from "components/Popover";
15
-
import LoginForm from "app/login/LoginForm";
16
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
-
import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities";
18
-
import { Json } from "supabase/database.types";
19
-
import { InfoSmall } from "components/Icons/InfoSmall";
20
-
21
-
// Helper function to extract the first option from a vote record
22
-
const getVoteOption = (voteRecord: any): string | null => {
23
-
try {
24
-
const record = voteRecord as PubLeafletPollVote.Record;
25
-
return record.option && record.option.length > 0 ? record.option[0] : null;
26
-
} catch {
27
-
return null;
28
-
}
29
-
};
30
-
31
-
export const PublishedPollBlock = (props: {
32
-
block: PubLeafletBlocksPoll.Main;
33
-
pollData: PollData;
34
-
className?: string;
35
-
}) => {
36
-
const { identity } = useIdentityData();
37
-
const [selectedOption, setSelectedOption] = useState<string | null>(null);
38
-
const [isVoting, setIsVoting] = useState(false);
39
-
const [showResults, setShowResults] = useState(false);
40
-
const [optimisticVote, setOptimisticVote] = useState<{
41
-
option: string;
42
-
voter_did: string;
43
-
} | null>(null);
44
-
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
45
-
let [isClient, setIsClient] = useState(false);
46
-
useEffect(() => {
47
-
setIsClient(true);
48
-
}, []);
49
-
50
-
const handleVote = async () => {
51
-
if (!selectedOption || !identity?.atp_did) return;
52
-
53
-
setIsVoting(true);
54
-
55
-
// Optimistically add the vote
56
-
setOptimisticVote({
57
-
option: selectedOption,
58
-
voter_did: identity.atp_did,
59
-
});
60
-
setShowResults(true);
61
-
62
-
try {
63
-
const result = await voteOnPublishedPoll(
64
-
props.block.pollRef.uri,
65
-
props.block.pollRef.cid,
66
-
selectedOption,
67
-
);
68
-
69
-
if (!result.success) {
70
-
console.error("Failed to vote:", result.error);
71
-
// Revert optimistic update on failure
72
-
setOptimisticVote(null);
73
-
setShowResults(false);
74
-
}
75
-
} catch (error) {
76
-
console.error("Failed to vote:", error);
77
-
// Revert optimistic update on failure
78
-
setOptimisticVote(null);
79
-
setShowResults(false);
80
-
} finally {
81
-
setIsVoting(false);
82
-
}
83
-
};
84
-
85
-
const hasVoted =
86
-
!!identity?.atp_did &&
87
-
(!!props.pollData?.atp_poll_votes.find(
88
-
(v) => v.voter_did === identity?.atp_did,
89
-
) ||
90
-
!!optimisticVote);
91
-
let isCreator =
92
-
identity?.atp_did && props.pollData.uri.includes(identity?.atp_did);
93
-
const displayResults = showResults || hasVoted;
94
-
95
-
return (
96
-
<div
97
-
className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`}
98
-
style={{
99
-
backgroundColor:
100
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
101
-
}}
102
-
>
103
-
{displayResults ? (
104
-
<>
105
-
<PollResults
106
-
pollData={props.pollData}
107
-
hasVoted={hasVoted}
108
-
setShowResults={setShowResults}
109
-
optimisticVote={optimisticVote}
110
-
/>
111
-
{!hasVoted && (
112
-
<div className="flex justify-start">
113
-
<button
114
-
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
115
-
onClick={() => setShowResults(false)}
116
-
>
117
-
Back to Voting
118
-
</button>
119
-
</div>
120
-
)}
121
-
</>
122
-
) : (
123
-
<>
124
-
{pollRecord.options.map((option, index) => (
125
-
<PollOptionButton
126
-
key={index}
127
-
option={option}
128
-
optionIndex={index.toString()}
129
-
selected={selectedOption === index.toString()}
130
-
onSelect={() => setSelectedOption(index.toString())}
131
-
disabled={!identity?.atp_did}
132
-
/>
133
-
))}
134
-
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2">
135
-
<div className="text-sm text-tertiary">All votes are public</div>
136
-
<div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center">
137
-
<button
138
-
className="w-fit font-bold text-accent-contrast"
139
-
onClick={() => setShowResults(!showResults)}
140
-
>
141
-
See Results
142
-
</button>
143
-
{identity?.atp_did ? (
144
-
<ButtonPrimary
145
-
className="place-self-end"
146
-
onClick={handleVote}
147
-
disabled={!selectedOption || isVoting}
148
-
>
149
-
{isVoting ? "Voting..." : "Vote!"}
150
-
</ButtonPrimary>
151
-
) : (
152
-
<Popover
153
-
asChild
154
-
trigger={
155
-
<ButtonPrimary className="place-self-center">
156
-
<BlueskyTiny /> Login to vote
157
-
</ButtonPrimary>
158
-
}
159
-
>
160
-
{isClient && (
161
-
<LoginForm
162
-
text="Log in to vote on this poll!"
163
-
noEmail
164
-
redirectRoute={window?.location.href + "?refreshAuth"}
165
-
/>
166
-
)}
167
-
</Popover>
168
-
)}
169
-
</div>
170
-
</div>
171
-
</>
172
-
)}
173
-
</div>
174
-
);
175
-
};
176
-
177
-
const PollOptionButton = (props: {
178
-
option: PubLeafletPollDefinition.Option;
179
-
optionIndex: string;
180
-
selected: boolean;
181
-
onSelect: () => void;
182
-
disabled?: boolean;
183
-
}) => {
184
-
const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary;
185
-
186
-
return (
187
-
<div className="flex gap-2 items-center">
188
-
<ButtonComponent
189
-
className="pollOption grow max-w-full flex"
190
-
onClick={props.onSelect}
191
-
disabled={props.disabled}
192
-
>
193
-
{props.option.text}
194
-
</ButtonComponent>
195
-
</div>
196
-
);
197
-
};
198
-
199
-
const PollResults = (props: {
200
-
pollData: PollData;
201
-
hasVoted: boolean;
202
-
setShowResults: (show: boolean) => void;
203
-
optimisticVote: { option: string; voter_did: string } | null;
204
-
}) => {
205
-
// Merge optimistic vote with actual votes
206
-
const allVotes = props.optimisticVote
207
-
? [
208
-
...props.pollData.atp_poll_votes,
209
-
{
210
-
voter_did: props.optimisticVote.voter_did,
211
-
record: {
212
-
$type: "pub.leaflet.poll.vote",
213
-
option: [props.optimisticVote.option],
214
-
},
215
-
},
216
-
]
217
-
: props.pollData.atp_poll_votes;
218
-
219
-
const totalVotes = allVotes.length || 0;
220
-
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
221
-
let optionsWithCount = pollRecord.options.map((o, index) => ({
222
-
...o,
223
-
votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()),
224
-
}));
225
-
226
-
const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length));
227
-
return (
228
-
<>
229
-
{pollRecord.options.map((option, index) => {
230
-
const voteRecords = allVotes.filter(
231
-
(v) => getVoteOption(v.record) === index.toString(),
232
-
);
233
-
const isWinner = totalVotes > 0 && voteRecords.length === highestVotes;
234
-
235
-
return (
236
-
<PollResult
237
-
key={index}
238
-
option={option}
239
-
votes={voteRecords.length}
240
-
voteRecords={voteRecords}
241
-
totalVotes={totalVotes}
242
-
winner={isWinner}
243
-
/>
244
-
);
245
-
})}
246
-
</>
247
-
);
248
-
};
249
-
250
-
const VoterListPopover = (props: {
251
-
votes: number;
252
-
voteRecords: { voter_did: string; record: Json }[];
253
-
}) => {
254
-
const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]);
255
-
const [isLoading, setIsLoading] = useState(false);
256
-
const [hasFetched, setHasFetched] = useState(false);
257
-
258
-
const handleOpenChange = async () => {
259
-
if (!hasFetched && props.voteRecords.length > 0) {
260
-
setIsLoading(true);
261
-
setHasFetched(true);
262
-
try {
263
-
const dids = props.voteRecords.map((v) => v.voter_did);
264
-
const identities = await getVoterIdentities(dids);
265
-
setVoterIdentities(identities);
266
-
} catch (error) {
267
-
console.error("Failed to fetch voter identities:", error);
268
-
} finally {
269
-
setIsLoading(false);
270
-
}
271
-
}
272
-
};
273
-
274
-
return (
275
-
<Popover
276
-
trigger={
277
-
<button
278
-
className="hover:underline cursor-pointer"
279
-
disabled={props.votes === 0}
280
-
>
281
-
{props.votes}
282
-
</button>
283
-
}
284
-
onOpenChange={handleOpenChange}
285
-
className="w-64 max-h-80"
286
-
>
287
-
{isLoading ? (
288
-
<div className="flex justify-center py-4">
289
-
<div className="text-sm text-secondary">Loading...</div>
290
-
</div>
291
-
) : (
292
-
<div className="flex flex-col gap-1 text-sm py-0.5">
293
-
{voterIdentities.map((voter) => (
294
-
<a
295
-
key={voter.did}
296
-
href={`https://bsky.app/profile/${voter.handle || voter.did}`}
297
-
target="_blank"
298
-
rel="noopener noreferrer"
299
-
className=""
300
-
>
301
-
@{voter.handle || voter.did}
302
-
</a>
303
-
))}
304
-
</div>
305
-
)}
306
-
</Popover>
307
-
);
308
-
};
309
-
310
-
const PollResult = (props: {
311
-
option: PubLeafletPollDefinition.Option;
312
-
votes: number;
313
-
voteRecords: { voter_did: string; record: Json }[];
314
-
totalVotes: number;
315
-
winner: boolean;
316
-
}) => {
317
-
return (
318
-
<div
319
-
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
320
-
>
321
-
<div
322
-
style={{
323
-
WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`,
324
-
paintOrder: "stroke fill",
325
-
}}
326
-
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
327
-
>
328
-
<div className="grow max-w-full truncate">{props.option.text}</div>
329
-
<VoterListPopover votes={props.votes} voteRecords={props.voteRecords} />
330
-
</div>
331
-
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
332
-
<div
333
-
className="bg-accent-contrast rounded-[2px] m-0.5"
334
-
style={{
335
-
maskImage: "var(--hatchSVG)",
336
-
maskRepeat: "repeat repeat",
337
-
...(props.votes === 0
338
-
? { width: "4px" }
339
-
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
340
-
}}
341
-
/>
342
-
<div />
343
-
</div>
344
-
</div>
345
-
);
346
-
};
···
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
···
186
<BlueskyLinkTiny className="shrink-0" />
187
Bluesky
188
</a>
189
-
<Separator classname="h-4" />
190
<button
191
id="copy-quote-link"
192
className="flex gap-1 items-center hover:font-bold px-1"
···
211
</button>
212
{pubRecord?.preferences?.showComments !== false && identity?.atp_did && (
213
<>
214
-
<Separator classname="h-4" />
215
<button
216
className="flex gap-1 items-center hover:font-bold px-1"
217
onClick={() => {
···
186
<BlueskyLinkTiny className="shrink-0" />
187
Bluesky
188
</a>
189
+
<Separator classname="h-4!" />
190
<button
191
id="copy-quote-link"
192
className="flex gap-1 items-center hover:font-bold px-1"
···
211
</button>
212
{pubRecord?.preferences?.showComments !== false && identity?.atp_did && (
213
<>
214
+
<Separator classname="h-4! " />
215
+
216
<button
217
className="flex gap-1 items-center hover:font-bold px-1"
218
onClick={() => {
-20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
-20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
···
1
-
import { PubLeafletBlocksMath } from "lexicons/api";
2
-
import Katex from "katex";
3
-
import "katex/dist/katex.min.css";
4
-
5
-
export const StaticMathBlock = ({
6
-
block,
7
-
}: {
8
-
block: PubLeafletBlocksMath.Main;
9
-
}) => {
10
-
const html = Katex.renderToString(block.tex, {
11
-
displayMode: true,
12
-
output: "html",
13
-
throwOnError: false,
14
-
});
15
-
return (
16
-
<div className="math-block my-2">
17
-
<div dangerouslySetInnerHTML={{ __html: html }} />
18
-
</div>
19
-
);
20
-
};
···
+12
-8
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+12
-8
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
···
12
PubLeafletPagesLinearDocument,
13
} from "lexicons/api";
14
import { blobRefToSrc } from "src/utils/blobRefToSrc";
15
-
import { BaseTextBlock } from "./BaseTextBlock";
16
-
import { StaticMathBlock } from "./StaticMathBlock";
17
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
18
19
export function StaticPostContent({
20
blocks,
···
47
case PubLeafletBlocksBlockquote.isMain(b.block): {
48
return (
49
<blockquote className={` blockquote `}>
50
-
<BaseTextBlock
51
facets={b.block.facets}
52
plaintext={b.block.plaintext}
53
index={[]}
···
116
case PubLeafletBlocksText.isMain(b.block):
117
return (
118
<p>
119
-
<BaseTextBlock
120
facets={b.block.facets}
121
plaintext={b.block.plaintext}
122
index={[]}
···
127
if (b.block.level === 1)
128
return (
129
<h1>
130
-
<BaseTextBlock {...b.block} index={[]} />
131
</h1>
132
);
133
if (b.block.level === 2)
134
return (
135
<h2>
136
-
<BaseTextBlock {...b.block} index={[]} />
137
</h2>
138
);
139
if (b.block.level === 3)
140
return (
141
<h3>
142
-
<BaseTextBlock {...b.block} index={[]} />
143
</h3>
144
);
145
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
146
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
147
return (
148
<h6>
149
-
<BaseTextBlock {...b.block} index={[]} />
150
</h6>
151
);
152
}
···
12
PubLeafletPagesLinearDocument,
13
} from "lexicons/api";
14
import { blobRefToSrc } from "src/utils/blobRefToSrc";
15
+
import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore";
16
+
import { StaticMathBlock } from "./Blocks/StaticMathBlock";
17
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
18
+
19
+
function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
20
+
return <TextBlockCore {...props} />;
21
+
}
22
23
export function StaticPostContent({
24
blocks,
···
51
case PubLeafletBlocksBlockquote.isMain(b.block): {
52
return (
53
<blockquote className={` blockquote `}>
54
+
<StaticBaseTextBlock
55
facets={b.block.facets}
56
plaintext={b.block.plaintext}
57
index={[]}
···
120
case PubLeafletBlocksText.isMain(b.block):
121
return (
122
<p>
123
+
<StaticBaseTextBlock
124
facets={b.block.facets}
125
plaintext={b.block.plaintext}
126
index={[]}
···
131
if (b.block.level === 1)
132
return (
133
<h1>
134
+
<StaticBaseTextBlock {...b.block} index={[]} />
135
</h1>
136
);
137
if (b.block.level === 2)
138
return (
139
<h2>
140
+
<StaticBaseTextBlock {...b.block} index={[]} />
141
</h2>
142
);
143
if (b.block.level === 3)
144
return (
145
<h3>
146
+
<StaticBaseTextBlock {...b.block} index={[]} />
147
</h3>
148
);
149
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
150
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
151
return (
152
<h6>
153
+
<StaticBaseTextBlock {...b.block} index={[]} />
154
</h6>
155
);
156
}
-95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
-95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
···
1
-
"use client";
2
-
import { UnicodeString } from "@atproto/api";
3
-
import { PubLeafletRichtextFacet } from "lexicons/api";
4
-
import { useMemo } from "react";
5
-
import { useHighlight } from "./useHighlight";
6
-
import { BaseTextBlock } from "./BaseTextBlock";
7
-
8
-
type Facet = PubLeafletRichtextFacet.Main;
9
-
export function TextBlock(props: {
10
-
plaintext: string;
11
-
facets?: Facet[];
12
-
index: number[];
13
-
preview?: boolean;
14
-
pageId?: string;
15
-
}) {
16
-
let children = [];
17
-
let highlights = useHighlight(props.index, props.pageId);
18
-
let facets = useMemo(() => {
19
-
if (props.preview) return props.facets;
20
-
let facets = [...(props.facets || [])];
21
-
for (let highlight of highlights) {
22
-
const fragmentId = props.pageId
23
-
? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}`
24
-
: `${props.index.join(".")}_${highlight.startOffset || 0}`;
25
-
facets = addFacet(
26
-
facets,
27
-
{
28
-
index: {
29
-
byteStart: highlight.startOffset
30
-
? new UnicodeString(
31
-
props.plaintext.slice(0, highlight.startOffset),
32
-
).length
33
-
: 0,
34
-
byteEnd: new UnicodeString(
35
-
props.plaintext.slice(0, highlight.endOffset ?? undefined),
36
-
).length,
37
-
},
38
-
features: [
39
-
{ $type: "pub.leaflet.richtext.facet#highlight" },
40
-
{
41
-
$type: "pub.leaflet.richtext.facet#id",
42
-
id: fragmentId,
43
-
},
44
-
],
45
-
},
46
-
new UnicodeString(props.plaintext).length,
47
-
);
48
-
}
49
-
return facets;
50
-
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
51
-
return <BaseTextBlock {...props} facets={facets} />;
52
-
}
53
-
54
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
55
-
if (facets.length === 0) {
56
-
return [newFacet];
57
-
}
58
-
59
-
const allFacets = [...facets, newFacet];
60
-
61
-
// Collect all boundary positions
62
-
const boundaries = new Set<number>();
63
-
boundaries.add(0);
64
-
boundaries.add(length);
65
-
66
-
for (const facet of allFacets) {
67
-
boundaries.add(facet.index.byteStart);
68
-
boundaries.add(facet.index.byteEnd);
69
-
}
70
-
71
-
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
72
-
const result: Facet[] = [];
73
-
74
-
// Process segments between consecutive boundaries
75
-
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
76
-
const start = sortedBoundaries[i];
77
-
const end = sortedBoundaries[i + 1];
78
-
79
-
// Find facets that are active at the start position
80
-
const activeFacets = allFacets.filter(
81
-
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
82
-
);
83
-
84
-
// Only create facet if there are active facets (features present)
85
-
if (activeFacets.length > 0) {
86
-
const features = activeFacets.flatMap((f) => f.features);
87
-
result.push({
88
-
index: { byteStart: start, byteEnd: end },
89
-
features,
90
-
});
91
-
}
92
-
}
93
-
94
-
return result;
95
-
}
···
+110
-224
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+110
-224
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
1
"use client";
2
-
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
-
import useSWR, { preload } from "swr";
4
import { PageWrapper } from "components/Pages/Page";
5
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
import { DotLoader } from "components/utils/DotLoader";
7
import {
8
-
BlueskyEmbed,
9
-
PostNotAvailable,
10
-
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
11
-
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
12
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
13
-
import { CommentTiny } from "components/Icons/CommentTiny";
14
-
import { Separator } from "components/Layout";
15
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16
-
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
17
-
import { openPage, OpenPage } from "./PostPages";
18
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
19
20
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
21
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
22
type BlockedPost = AppBskyFeedDefs.BlockedPost;
23
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
24
25
-
// SWR key for thread data
26
-
export const getThreadKey = (uri: string) => `thread:${uri}`;
27
-
28
-
// Fetch thread from API route
29
-
export async function fetchThread(uri: string): Promise<ThreadType> {
30
-
const params = new URLSearchParams({ uri });
31
-
const response = await fetch(`/api/bsky/thread?${params.toString()}`);
32
-
33
-
if (!response.ok) {
34
-
throw new Error("Failed to fetch thread");
35
-
}
36
-
37
-
return response.json();
38
-
}
39
-
40
-
// Prefetch thread data
41
-
export const prefetchThread = (uri: string) => {
42
-
preload(getThreadKey(uri), () => fetchThread(uri));
43
-
};
44
-
45
-
// Link component for opening thread pages with prefetching
46
-
export function ThreadLink(props: {
47
-
threadUri: string;
48
-
parent?: OpenPage;
49
-
children: React.ReactNode;
50
-
className?: string;
51
-
onClick?: (e: React.MouseEvent) => void;
52
-
}) {
53
-
const { threadUri, parent, children, className, onClick } = props;
54
-
55
-
const handleClick = (e: React.MouseEvent) => {
56
-
onClick?.(e);
57
-
if (e.defaultPrevented) return;
58
-
openPage(parent, { type: "thread", uri: threadUri });
59
-
};
60
-
61
-
const handlePrefetch = () => {
62
-
prefetchThread(threadUri);
63
-
};
64
-
65
-
return (
66
-
<button
67
-
className={className}
68
-
onClick={handleClick}
69
-
onMouseEnter={handlePrefetch}
70
-
onPointerDown={handlePrefetch}
71
-
>
72
-
{children}
73
-
</button>
74
-
);
75
-
}
76
-
77
export function ThreadPage(props: {
78
threadUri: string;
79
pageId: string;
···
90
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
91
fetchThread(threadUri),
92
);
93
-
let cardBorderHidden = useCardBorderHidden(null);
94
95
return (
96
<PageWrapper
···
120
121
function ThreadContent(props: { thread: ThreadType; threadUri: string }) {
122
const { thread, threadUri } = props;
123
124
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
125
return <PostNotAvailable />;
···
160
))}
161
162
{/* Main post */}
163
-
<ThreadPost
164
-
post={thread}
165
-
isMainPost={true}
166
-
showReplyLine={false}
167
-
threadUri={threadUri}
168
-
/>
169
170
{/* Replies */}
171
{thread.replies && thread.replies.length > 0 && (
···
177
replies={thread.replies as any[]}
178
threadUri={threadUri}
179
depth={0}
180
/>
181
</div>
182
)}
···
192
}) {
193
const { post, isMainPost, showReplyLine, threadUri } = props;
194
const postView = post.post;
195
-
const record = postView.record as AppBskyFeedPost.Record;
196
-
197
-
const postId = postView.uri.split("/")[4];
198
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
199
200
return (
201
<div className="flex gap-2 relative">
···
204
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
205
)}
206
207
-
<div className="flex flex-col items-center shrink-0">
208
-
{postView.author.avatar ? (
209
-
<img
210
-
src={postView.author.avatar}
211
-
alt={`${postView.author.displayName}'s avatar`}
212
-
className="w-10 h-10 rounded-full border border-border-light"
213
-
/>
214
-
) : (
215
-
<div className="w-10 h-10 rounded-full border border-border-light bg-border" />
216
-
)}
217
-
</div>
218
-
219
-
<div
220
-
className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`}
221
-
>
222
-
<div className="flex items-center gap-2 leading-tight">
223
-
<div className="font-bold text-secondary">
224
-
{postView.author.displayName}
225
-
</div>
226
-
<a
227
-
className="text-xs text-tertiary hover:underline"
228
-
target="_blank"
229
-
href={`https://bsky.app/profile/${postView.author.handle}`}
230
-
>
231
-
@{postView.author.handle}
232
-
</a>
233
-
</div>
234
-
235
-
<div className="flex flex-col gap-2 mt-1">
236
-
<div className="text-sm text-secondary">
237
-
<BlueskyRichText record={record} />
238
-
</div>
239
-
{postView.embed && (
240
-
<BlueskyEmbed embed={postView.embed} postUrl={url} />
241
-
)}
242
-
</div>
243
-
244
-
<div className="flex gap-2 items-center justify-between mt-2">
245
-
<ClientDate date={record.createdAt} />
246
-
<div className="flex gap-2 items-center">
247
-
{postView.replyCount != null && postView.replyCount > 0 && (
248
-
<>
249
-
{isMainPost ? (
250
-
<div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs">
251
-
{postView.replyCount}
252
-
<CommentTiny />
253
-
</div>
254
-
) : (
255
-
<ThreadLink
256
-
threadUri={postView.uri}
257
-
parent={{ type: "thread", uri: threadUri }}
258
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
259
-
>
260
-
{postView.replyCount}
261
-
<CommentTiny />
262
-
</ThreadLink>
263
-
)}
264
-
<Separator classname="h-4" />
265
-
</>
266
-
)}
267
-
<a className="text-tertiary" target="_blank" href={url}>
268
-
<BlueskyTiny />
269
-
</a>
270
-
</div>
271
-
</div>
272
-
</div>
273
</div>
274
);
275
}
···
278
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
279
threadUri: string;
280
depth: number;
281
}) {
282
-
const { replies, threadUri, depth } = props;
283
284
return (
285
<div className="flex flex-col gap-0">
286
-
{replies.map((reply, index) => {
287
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
288
return (
289
<div
···
311
}
312
313
const hasReplies = reply.replies && reply.replies.length > 0;
314
315
return (
316
<div key={reply.post.uri} className="flex flex-col">
···
321
threadUri={threadUri}
322
/>
323
{hasReplies && depth < 3 && (
324
-
<div className="ml-5 pl-5 border-l border-border-light">
325
-
<Replies
326
-
replies={reply.replies as any[]}
327
-
threadUri={threadUri}
328
-
depth={depth + 1}
329
-
/>
330
</div>
331
)}
332
{hasReplies && depth >= 3 && (
···
351
isLast: boolean;
352
threadUri: string;
353
}) {
354
-
const { post, showReplyLine, isLast, threadUri } = props;
355
const postView = post.post;
356
-
const record = postView.record as AppBskyFeedPost.Record;
357
-
358
-
const postId = postView.uri.split("/")[4];
359
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
360
-
361
const parent = { type: "thread" as const, uri: threadUri };
362
363
return (
···
365
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
366
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
367
>
368
-
<div className="flex flex-col items-center shrink-0">
369
-
{postView.author.avatar ? (
370
-
<img
371
-
src={postView.author.avatar}
372
-
alt={`${postView.author.displayName}'s avatar`}
373
-
className="w-8 h-8 rounded-full border border-border-light"
374
-
/>
375
-
) : (
376
-
<div className="w-8 h-8 rounded-full border border-border-light bg-border" />
377
-
)}
378
-
</div>
379
-
380
-
<div className="flex flex-col grow min-w-0">
381
-
<div className="flex items-center gap-2 leading-tight text-sm">
382
-
<div className="font-bold text-secondary">
383
-
{postView.author.displayName}
384
-
</div>
385
-
<a
386
-
className="text-xs text-tertiary hover:underline"
387
-
target="_blank"
388
-
href={`https://bsky.app/profile/${postView.author.handle}`}
389
-
onClick={(e) => e.stopPropagation()}
390
-
>
391
-
@{postView.author.handle}
392
-
</a>
393
-
</div>
394
-
395
-
<div className="text-sm text-secondary mt-0.5">
396
-
<BlueskyRichText record={record} />
397
-
</div>
398
-
399
-
<div className="flex gap-2 items-center mt-1">
400
-
<ClientDate date={record.createdAt} />
401
-
{postView.replyCount != null && postView.replyCount > 0 && (
402
-
<>
403
-
<Separator classname="h-3" />
404
-
<ThreadLink
405
-
threadUri={postView.uri}
406
-
parent={parent}
407
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
408
-
onClick={(e) => e.stopPropagation()}
409
-
>
410
-
{postView.replyCount}
411
-
<CommentTiny />
412
-
</ThreadLink>
413
-
</>
414
-
)}
415
-
</div>
416
-
</div>
417
</div>
418
);
419
}
420
-
421
-
const ClientDate = (props: { date?: string }) => {
422
-
const pageLoaded = useHasPageLoaded();
423
-
const formattedDate = useLocalizedDate(
424
-
props.date || new Date().toISOString(),
425
-
{
426
-
month: "short",
427
-
day: "numeric",
428
-
year: "numeric",
429
-
hour: "numeric",
430
-
minute: "numeric",
431
-
hour12: true,
432
-
},
433
-
);
434
-
435
-
if (!pageLoaded) return null;
436
-
437
-
return <div className="text-xs text-tertiary">{formattedDate}</div>;
438
-
};
···
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;
···
40
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
41
fetchThread(threadUri),
42
);
43
44
return (
45
<PageWrapper
···
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 />;
···
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 && (
···
139
replies={thread.replies as any[]}
140
threadUri={threadUri}
141
depth={0}
142
+
parentAuthorDid={thread.post.author.did}
143
/>
144
</div>
145
)}
···
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">
···
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
}
···
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
···
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">
···
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 && (
···
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 (
···
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
data,
11
uri,
12
comments_on_documents(*, bsky_profiles(*)),
13
-
documents_in_publications(publications(*, publication_subscriptions(*))),
14
document_mentions_in_bsky(*),
15
leaflets_in_publications(*)
16
`,
···
51
?.record as PubLeafletPublication.Record
52
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
53
54
return {
55
...document,
56
quotesAndMentions,
57
theme,
58
};
59
}
60
···
10
data,
11
uri,
12
comments_on_documents(*, bsky_profiles(*)),
13
+
documents_in_publications(publications(*,
14
+
documents_in_publications(documents(uri, data)),
15
+
publication_subscriptions(*))
16
+
),
17
document_mentions_in_bsky(*),
18
leaflets_in_publications(*)
19
`,
···
54
?.record as PubLeafletPublication.Record
55
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
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
+
110
return {
111
...document,
112
quotesAndMentions,
113
theme,
114
+
prevNext,
115
};
116
}
117
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
···
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
3
-
export const runtime = "edge";
4
export const revalidate = 60;
5
6
export default async function OpenGraphImage(props: {
7
params: Promise<{ publication: string; did: string; rkey: string }>;
8
}) {
9
let params = await props.params;
10
return getMicroLinkOgImage(
11
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
12
);
···
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";
8
9
export const revalidate = 60;
10
11
export default async function OpenGraphImage(props: {
12
params: Promise<{ publication: string; did: string; rkey: string }>;
13
}) {
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
53
return getMicroLinkOgImage(
54
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
55
);
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
···
1
"use server";
2
3
-
import { createOauthClient } from "src/atproto-oauth";
4
import { getIdentityData } from "actions/getIdentityData";
5
import { AtpBaseClient, AtUri } from "@atproto/api";
6
import { PubLeafletPollVote } from "lexicons/api";
···
12
pollUri: string,
13
pollCid: string,
14
selectedOption: string,
15
-
): Promise<{ success: boolean; error?: string }> {
16
try {
17
const identity = await getIdentityData();
18
···
20
return { success: false, error: "Not authenticated" };
21
}
22
23
-
const oauthClient = await createOauthClient();
24
-
const session = await oauthClient.restore(identity.atp_did);
25
let agent = new AtpBaseClient(session.fetchHandler.bind(session));
26
27
const voteRecord: PubLeafletPollVote.Record = {
···
1
"use server";
2
3
+
import {
4
+
restoreOAuthSession,
5
+
OAuthSessionError,
6
+
} from "src/atproto-oauth";
7
import { getIdentityData } from "actions/getIdentityData";
8
import { AtpBaseClient, AtUri } from "@atproto/api";
9
import { PubLeafletPollVote } from "lexicons/api";
···
15
pollUri: string,
16
pollCid: string,
17
selectedOption: string,
18
+
): Promise<
19
+
{ success: true } | { success: false; error: string | OAuthSessionError }
20
+
> {
21
try {
22
const identity = await getIdentityData();
23
···
25
return { success: false, error: "Not authenticated" };
26
}
27
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;
33
let agent = new AtpBaseClient(session.fetchHandler.bind(session));
34
35
const voteRecord: PubLeafletPollVote.Record = {
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
···
1
"use client";
2
3
import { NewDraftActionButton } from "./NewDraftButton";
4
-
import { PublicationSettingsButton } from "./PublicationSettings";
5
import { ActionButton } from "components/ActionBar/ActionButton";
6
import { ShareSmall } from "components/Icons/ShareSmall";
7
-
import { Menu } from "components/Layout";
8
-
import { MenuItem } from "components/Layout";
9
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
import { usePublicationData } from "./PublicationSWRProvider";
11
import { useSmoker } from "components/Toast";
···
1
"use client";
2
3
import { NewDraftActionButton } from "./NewDraftButton";
4
+
import { PublicationSettingsButton } from "./settings/PublicationSettings";
5
import { ActionButton } from "components/ActionBar/ActionButton";
6
import { ShareSmall } from "components/Icons/ShareSmall";
7
+
import { Menu, MenuItem } from "components/Menu";
8
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
9
import { usePublicationData } from "./PublicationSWRProvider";
10
import { useSmoker } from "components/Toast";
-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
import { ButtonPrimary } from "components/Buttons";
5
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
import { useSmoker } from "components/Toast";
7
-
import { Menu, MenuItem, Separator } from "components/Layout";
8
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
9
import { Checkbox } from "components/Checkbox";
10
import { useEffect, useState } from "react";
···
4
import { ButtonPrimary } from "components/Buttons";
5
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
import { useSmoker } from "components/Toast";
7
+
import { Menu, MenuItem } from "components/Menu";
8
+
import { Separator } from "components/Layout";
9
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
10
import { Checkbox } from "components/Checkbox";
11
import { useEffect, useState } from "react";
+2
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+2
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
···
7
import { Fragment, useState } from "react";
8
import { useParams } from "next/navigation";
9
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
-
import { Menu, MenuItem } from "components/Layout";
11
import { deletePost } from "./deletePost";
12
import { ButtonPrimary } from "components/Buttons";
13
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
···
140
commentsCount={comments}
141
tags={tags}
142
showComments={pubRecord?.preferences?.showComments}
143
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
144
/>
145
</div>
···
7
import { Fragment, useState } from "react";
8
import { useParams } from "next/navigation";
9
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
+
import { Menu, MenuItem } from "components/Menu";
11
import { deletePost } from "./deletePost";
12
import { ButtonPrimary } from "components/Buttons";
13
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
···
140
commentsCount={comments}
141
tags={tags}
142
showComments={pubRecord?.preferences?.showComments}
143
+
showMentions={pubRecord?.preferences?.showMentions}
144
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
145
/>
146
</div>
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
···
2
3
import { AtpBaseClient } from "lexicons/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
6
import { AtUri } from "@atproto/syntax";
7
import { supabaseServerClient } from "supabase/serverClient";
8
import { revalidatePath } from "next/cache";
9
10
-
export async function deletePost(document_uri: string) {
11
let identity = await getIdentityData();
12
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
13
14
-
const oauthClient = await createOauthClient();
15
-
let credentialSession = await oauthClient.restore(identity.atp_did);
16
let agent = new AtpBaseClient(
17
credentialSession.fetchHandler.bind(credentialSession),
18
);
19
let uri = new AtUri(document_uri);
20
-
if (uri.host !== identity.atp_did) return;
21
22
await Promise.all([
23
agent.pub.leaflet.document.delete({
···
31
.eq("doc", document_uri),
32
]);
33
34
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
35
}
36
37
-
export async function unpublishPost(document_uri: string) {
38
let identity = await getIdentityData();
39
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
40
41
-
const oauthClient = await createOauthClient();
42
-
let credentialSession = await oauthClient.restore(identity.atp_did);
43
let agent = new AtpBaseClient(
44
credentialSession.fetchHandler.bind(credentialSession),
45
);
46
let uri = new AtUri(document_uri);
47
-
if (uri.host !== identity.atp_did) return;
48
49
await Promise.all([
50
agent.pub.leaflet.document.delete({
···
53
}),
54
supabaseServerClient.from("documents").delete().eq("uri", document_uri),
55
]);
56
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
57
}
···
2
3
import { AtpBaseClient } from "lexicons/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
9
import { AtUri } from "@atproto/syntax";
10
import { supabaseServerClient } from "supabase/serverClient";
11
import { revalidatePath } from "next/cache";
12
13
+
export async function deletePost(
14
+
document_uri: string
15
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
16
let identity = await getIdentityData();
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
+
}
27
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;
33
let agent = new AtpBaseClient(
34
credentialSession.fetchHandler.bind(credentialSession),
35
);
36
let uri = new AtUri(document_uri);
37
+
if (uri.host !== identity.atp_did) {
38
+
return { success: true };
39
+
}
40
41
await Promise.all([
42
agent.pub.leaflet.document.delete({
···
50
.eq("doc", document_uri),
51
]);
52
53
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
54
+
return { success: true };
55
}
56
57
+
export async function unpublishPost(
58
+
document_uri: string
59
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
60
let identity = await getIdentityData();
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
+
}
71
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;
77
let agent = new AtpBaseClient(
78
credentialSession.fetchHandler.bind(credentialSession),
79
);
80
let uri = new AtUri(document_uri);
81
+
if (uri.host !== identity.atp_did) {
82
+
return { success: true };
83
+
}
84
85
await Promise.all([
86
agent.pub.leaflet.document.delete({
···
89
}),
90
supabaseServerClient.from("documents").delete().eq("uri", document_uri),
91
]);
92
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
93
+
return { success: true };
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
+
};
+8
-2
app/lish/[did]/[publication]/page.tsx
+8
-2
app/lish/[did]/[publication]/page.tsx
···
18
import { LocalizedDate } from "./LocalizedDate";
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
import { PublicationAuthor } from "./PublicationAuthor";
21
22
export default async function Publication(props: {
23
params: Promise<{ publication: string; did: string }>;
···
147
</p>
148
</SpeedyLink>
149
150
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2">
151
<p className="text-sm text-tertiary ">
152
{doc_record.publishedAt && (
153
<LocalizedDate
···
160
/>
161
)}{" "}
162
</p>
163
-
{comments > 0 || quotes > 0 ? "| " : ""}
164
<InteractionPreview
165
quotesCount={quotes}
166
commentsCount={comments}
167
tags={tags}
168
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
169
showComments={record?.preferences?.showComments}
170
/>
171
</div>
172
</div>
···
18
import { LocalizedDate } from "./LocalizedDate";
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
import { PublicationAuthor } from "./PublicationAuthor";
21
+
import { Separator } from "components/Layout";
22
23
export default async function Publication(props: {
24
params: Promise<{ publication: string; did: string }>;
···
148
</p>
149
</SpeedyLink>
150
151
+
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center">
152
<p className="text-sm text-tertiary ">
153
{doc_record.publishedAt && (
154
<LocalizedDate
···
161
/>
162
)}{" "}
163
</p>
164
+
{comments > 0 || quotes > 0 || tags.length > 0 ? (
165
+
<Separator classname="h-4! mx-1" />
166
+
) : (
167
+
""
168
+
)}
169
<InteractionPreview
170
quotesCount={quotes}
171
commentsCount={comments}
172
tags={tags}
173
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
174
showComments={record?.preferences?.showComments}
175
+
showMentions={record?.preferences?.showMentions}
176
/>
177
</div>
178
</div>
+22
-6
app/lish/addFeed.tsx
+22
-6
app/lish/addFeed.tsx
···
2
3
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
6
const leafletFeedURI =
7
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
8
9
-
export async function addFeed() {
10
-
const oauthClient = await createOauthClient();
11
let identity = await getIdentityData();
12
if (!identity || !identity.atp_did) {
13
-
throw new Error("Invalid identity data");
14
}
15
16
-
let credentialSession = await oauthClient.restore(identity.atp_did);
17
let bsky = new BskyAgent(credentialSession);
18
let prefs = await bsky.app.bsky.actor.getPreferences();
19
let savedFeeds = prefs.data.preferences.find(
···
23
let hasFeed = !!savedFeeds.items.find(
24
(feed) => feed.value === leafletFeedURI,
25
);
26
-
if (hasFeed) return;
27
28
await bsky.addSavedFeeds([
29
{
···
32
type: "feed",
33
},
34
]);
35
}
···
2
3
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
4
import { getIdentityData } from "actions/getIdentityData";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
9
const leafletFeedURI =
10
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
11
12
+
export async function addFeed(): Promise<
13
+
{ success: true } | { success: false; error: OAuthSessionError }
14
+
> {
15
let identity = await getIdentityData();
16
if (!identity || !identity.atp_did) {
17
+
return {
18
+
success: false,
19
+
error: {
20
+
type: "oauth_session_expired",
21
+
message: "Not authenticated",
22
+
did: "",
23
+
},
24
+
};
25
}
26
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;
32
let bsky = new BskyAgent(credentialSession);
33
let prefs = await bsky.app.bsky.actor.getPreferences();
34
let savedFeeds = prefs.data.preferences.find(
···
38
let hasFeed = !!savedFeeds.items.find(
39
(feed) => feed.value === leafletFeedURI,
40
);
41
+
if (hasFeed) return { success: true };
42
43
await bsky.addSavedFeeds([
44
{
···
47
type: "feed",
48
},
49
]);
50
+
return { success: true };
51
}
+42
-13
app/lish/createPub/CreatePubForm.tsx
+42
-13
app/lish/createPub/CreatePubForm.tsx
···
13
import { string } from "zod";
14
import { DotLoader } from "components/utils/DotLoader";
15
import { Checkbox } from "components/Checkbox";
16
17
type DomainState =
18
| { status: "empty" }
···
32
let [domainState, setDomainState] = useState<DomainState>({
33
status: "empty",
34
});
35
let fileInputRef = useRef<HTMLInputElement>(null);
36
37
let router = useRouter();
···
43
e.preventDefault();
44
if (!subdomainValidator.safeParse(domainValue).success) return;
45
setFormState("loading");
46
-
let data = await createPublication({
47
name: nameValue,
48
description: descriptionValue,
49
iconFile: logoFile,
50
subdomain: domainValue,
51
-
preferences: { showInDiscover, showComments: true },
52
});
53
// Show a spinner while this is happening! Maybe a progress bar?
54
setTimeout(() => {
55
setFormState("normal");
56
-
if (data?.publication)
57
-
router.push(`${getBasePublicationURL(data.publication)}/dashboard`);
58
}, 500);
59
}}
60
>
···
139
</Checkbox>
140
<hr className="border-border-light" />
141
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>
151
</div>
152
</form>
153
);
···
13
import { string } from "zod";
14
import { DotLoader } from "components/utils/DotLoader";
15
import { Checkbox } from "components/Checkbox";
16
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
17
18
type DomainState =
19
| { status: "empty" }
···
33
let [domainState, setDomainState] = useState<DomainState>({
34
status: "empty",
35
});
36
+
let [oauthError, setOauthError] = useState<
37
+
import("src/atproto-oauth").OAuthSessionError | null
38
+
>(null);
39
let fileInputRef = useRef<HTMLInputElement>(null);
40
41
let router = useRouter();
···
47
e.preventDefault();
48
if (!subdomainValidator.safeParse(domainValue).success) return;
49
setFormState("loading");
50
+
setOauthError(null);
51
+
let result = await createPublication({
52
name: nameValue,
53
description: descriptionValue,
54
iconFile: logoFile,
55
subdomain: domainValue,
56
+
preferences: {
57
+
showInDiscover,
58
+
showComments: true,
59
+
showMentions: true,
60
+
showPrevNext: false,
61
+
},
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
+
72
// Show a spinner while this is happening! Maybe a progress bar?
73
setTimeout(() => {
74
setFormState("normal");
75
+
if (result.publication)
76
+
router.push(
77
+
`${getBasePublicationURL(result.publication)}/dashboard`,
78
+
);
79
}, 500);
80
}}
81
>
···
160
</Checkbox>
161
<hr className="border-border-light" />
162
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
+
)}
180
</div>
181
</form>
182
);
+23
-16
app/lish/createPub/UpdatePubForm.tsx
+23
-16
app/lish/createPub/UpdatePubForm.tsx
···
20
import Link from "next/link";
21
import { Checkbox } from "components/Checkbox";
22
import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
23
-
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings";
24
25
export const EditPubForm = (props: {
26
backToMenuAction: () => void;
···
43
? true
44
: record.preferences.showComments,
45
);
46
let [descriptionValue, setDescriptionValue] = useState(
47
record?.description || "",
48
);
···
74
preferences: {
75
showInDiscover: showInDiscover,
76
showComments: showComments,
77
},
78
});
79
toast({ type: "success", content: "Updated!" });
···
86
setLoadingAction={props.setLoadingAction}
87
backToMenuAction={props.backToMenuAction}
88
state={"theme"}
89
-
/>
90
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
91
-
<div className="flex items-center justify-between gap-2 ">
92
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
93
Logo <span className="font-normal">(optional)</span>
94
</p>
···
158
<CustomDomainForm />
159
<hr className="border-border-light" />
160
161
-
<Checkbox
162
-
checked={showInDiscover}
163
-
onChange={(e) => setShowInDiscover(e.target.checked)}
164
>
165
-
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
166
<p className="font-bold">
167
Show In{" "}
168
<a href="/discover" target="_blank">
···
177
page. You can change this at any time!
178
</p>
179
</div>
180
-
</Checkbox>
181
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>
190
</div>
191
</form>
192
);
···
20
import Link from "next/link";
21
import { Checkbox } from "components/Checkbox";
22
import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
23
+
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings";
24
+
import { Toggle } from "components/Toggle";
25
26
export const EditPubForm = (props: {
27
backToMenuAction: () => void;
···
44
? true
45
: record.preferences.showComments,
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
+
56
let [descriptionValue, setDescriptionValue] = useState(
57
record?.description || "",
58
);
···
84
preferences: {
85
showInDiscover: showInDiscover,
86
showComments: showComments,
87
+
showMentions: showMentions,
88
+
showPrevNext: showPrevNext,
89
},
90
});
91
toast({ type: "success", content: "Updated!" });
···
98
setLoadingAction={props.setLoadingAction}
99
backToMenuAction={props.backToMenuAction}
100
state={"theme"}
101
+
>
102
+
General Settings
103
+
</PubSettingsHeader>
104
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
105
+
<div className="flex items-center justify-between gap-2 mt-2 ">
106
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
107
Logo <span className="font-normal">(optional)</span>
108
</p>
···
172
<CustomDomainForm />
173
<hr className="border-border-light" />
174
175
+
<Toggle
176
+
toggle={showInDiscover}
177
+
onToggle={() => setShowInDiscover(!showInDiscover)}
178
>
179
+
<div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
180
<p className="font-bold">
181
Show In{" "}
182
<a href="/discover" target="_blank">
···
191
page. You can change this at any time!
192
</p>
193
</div>
194
+
</Toggle>
195
196
+
197
</div>
198
</form>
199
);
+24
-5
app/lish/createPub/createPublication.ts
+24
-5
app/lish/createPub/createPublication.ts
···
1
"use server";
2
import { TID } from "@atproto/common";
3
import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
4
-
import { createOauthClient } from "src/atproto-oauth";
5
import { getIdentityData } from "actions/getIdentityData";
6
import { supabaseServerClient } from "supabase/serverClient";
7
import { Un$Typed } from "@atproto/api";
···
18
.min(3)
19
.max(63)
20
.regex(/^[a-z0-9-]+$/);
21
export async function createPublication({
22
name,
23
description,
···
30
iconFile: File | null;
31
subdomain: string;
32
preferences: Omit<PubLeafletPublication.Preferences, "$type">;
33
-
}) {
34
let isSubdomainValid = subdomainValidator.safeParse(subdomain);
35
if (!isSubdomainValid.success) {
36
return { success: false };
37
}
38
-
const oauthClient = await createOauthClient();
39
let identity = await getIdentityData();
40
-
if (!identity || !identity.atp_did) return;
41
42
let domain = `${subdomain}.leaflet.pub`;
43
44
-
let credentialSession = await oauthClient.restore(identity.atp_did);
45
let agent = new AtpBaseClient(
46
credentialSession.fetchHandler.bind(credentialSession),
47
);
···
1
"use server";
2
import { TID } from "@atproto/common";
3
import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
4
+
import {
5
+
restoreOAuthSession,
6
+
OAuthSessionError,
7
+
} from "src/atproto-oauth";
8
import { getIdentityData } from "actions/getIdentityData";
9
import { supabaseServerClient } from "supabase/serverClient";
10
import { Un$Typed } from "@atproto/api";
···
21
.min(3)
22
.max(63)
23
.regex(/^[a-z0-9-]+$/);
24
+
type CreatePublicationResult =
25
+
| { success: true; publication: any }
26
+
| { success: false; error?: OAuthSessionError };
27
+
28
export async function createPublication({
29
name,
30
description,
···
37
iconFile: File | null;
38
subdomain: string;
39
preferences: Omit<PubLeafletPublication.Preferences, "$type">;
40
+
}): Promise<CreatePublicationResult> {
41
let isSubdomainValid = subdomainValidator.safeParse(subdomain);
42
if (!isSubdomainValid.success) {
43
return { success: false };
44
}
45
let identity = await getIdentityData();
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
+
}
56
57
let domain = `${subdomain}.leaflet.pub`;
58
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;
64
let agent = new AtpBaseClient(
65
credentialSession.fetchHandler.bind(credentialSession),
66
);
+66
-18
app/lish/createPub/updatePublication.ts
+66
-18
app/lish/createPub/updatePublication.ts
···
5
PubLeafletPublication,
6
PubLeafletThemeColor,
7
} from "lexicons/api";
8
-
import { createOauthClient } from "src/atproto-oauth";
9
import { getIdentityData } from "actions/getIdentityData";
10
import { supabaseServerClient } from "supabase/serverClient";
11
import { Json } from "supabase/database.types";
12
import { AtUri } from "@atproto/syntax";
13
import { $Typed } from "@atproto/api";
14
15
export async function updatePublication({
16
uri,
···
21
}: {
22
uri: string;
23
name: string;
24
-
description: string;
25
-
iconFile: File | null;
26
preferences?: Omit<PubLeafletPublication.Preferences, "$type">;
27
-
}) {
28
-
const oauthClient = await createOauthClient();
29
let identity = await getIdentityData();
30
-
if (!identity || !identity.atp_did) return;
31
32
-
let credentialSession = await oauthClient.restore(identity.atp_did);
33
let agent = new AtpBaseClient(
34
credentialSession.fetchHandler.bind(credentialSession),
35
);
···
38
.select("*")
39
.eq("uri", uri)
40
.single();
41
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
42
let aturi = new AtUri(existingPub.uri);
43
44
let record: PubLeafletPublication.Record = {
···
94
}: {
95
uri: string;
96
base_path: string;
97
-
}) {
98
-
const oauthClient = await createOauthClient();
99
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
101
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
103
let agent = new AtpBaseClient(
104
credentialSession.fetchHandler.bind(credentialSession),
105
);
···
108
.select("*")
109
.eq("uri", uri)
110
.single();
111
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
112
let aturi = new AtUri(existingPub.uri);
113
114
let record: PubLeafletPublication.Record = {
···
149
backgroundImage?: File | null;
150
backgroundRepeat?: number | null;
151
backgroundColor: Color;
152
primary: Color;
153
pageBackground: Color;
154
showPageBackground: boolean;
155
accentBackground: Color;
156
accentText: Color;
157
};
158
-
}) {
159
-
const oauthClient = await createOauthClient();
160
let identity = await getIdentityData();
161
-
if (!identity || !identity.atp_did) return;
162
163
-
let credentialSession = await oauthClient.restore(identity.atp_did);
164
let agent = new AtpBaseClient(
165
credentialSession.fetchHandler.bind(credentialSession),
166
);
···
169
.select("*")
170
.eq("uri", uri)
171
.single();
172
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
173
let aturi = new AtUri(existingPub.uri);
174
175
let oldRecord = existingPub.record as PubLeafletPublication.Record;
···
197
...theme.backgroundColor,
198
}
199
: undefined,
200
primary: {
201
...theme.primary,
202
},
···
5
PubLeafletPublication,
6
PubLeafletThemeColor,
7
} from "lexicons/api";
8
+
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
9
import { getIdentityData } from "actions/getIdentityData";
10
import { supabaseServerClient } from "supabase/serverClient";
11
import { Json } from "supabase/database.types";
12
import { AtUri } from "@atproto/syntax";
13
import { $Typed } from "@atproto/api";
14
+
15
+
type UpdatePublicationResult =
16
+
| { success: true; publication: any }
17
+
| { success: false; error?: OAuthSessionError };
18
19
export async function updatePublication({
20
uri,
···
25
}: {
26
uri: string;
27
name: string;
28
+
description?: string;
29
+
iconFile?: File | null;
30
preferences?: Omit<PubLeafletPublication.Preferences, "$type">;
31
+
}): Promise<UpdatePublicationResult> {
32
let identity = await getIdentityData();
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
+
}
43
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;
49
let agent = new AtpBaseClient(
50
credentialSession.fetchHandler.bind(credentialSession),
51
);
···
54
.select("*")
55
.eq("uri", uri)
56
.single();
57
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
58
+
return { success: false };
59
+
}
60
let aturi = new AtUri(existingPub.uri);
61
62
let record: PubLeafletPublication.Record = {
···
112
}: {
113
uri: string;
114
base_path: string;
115
+
}): Promise<UpdatePublicationResult> {
116
let identity = await getIdentityData();
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
+
}
127
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;
133
let agent = new AtpBaseClient(
134
credentialSession.fetchHandler.bind(credentialSession),
135
);
···
138
.select("*")
139
.eq("uri", uri)
140
.single();
141
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
142
+
return { success: false };
143
+
}
144
let aturi = new AtUri(existingPub.uri);
145
146
let record: PubLeafletPublication.Record = {
···
181
backgroundImage?: File | null;
182
backgroundRepeat?: number | null;
183
backgroundColor: Color;
184
+
pageWidth?: number;
185
primary: Color;
186
pageBackground: Color;
187
showPageBackground: boolean;
188
accentBackground: Color;
189
accentText: Color;
190
};
191
+
}): Promise<UpdatePublicationResult> {
192
let identity = await getIdentityData();
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
+
}
203
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;
209
let agent = new AtpBaseClient(
210
credentialSession.fetchHandler.bind(credentialSession),
211
);
···
214
.select("*")
215
.eq("uri", uri)
216
.single();
217
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
218
+
return { success: false };
219
+
}
220
let aturi = new AtUri(existingPub.uri);
221
222
let oldRecord = existingPub.record as PubLeafletPublication.Record;
···
244
...theme.backgroundColor,
245
}
246
: undefined,
247
+
pageWidth: theme.pageWidth,
248
primary: {
249
...theme.primary,
250
},
+40
-9
app/lish/subscribeToPublication.ts
+40
-9
app/lish/subscribeToPublication.ts
···
3
import { AtpBaseClient } from "lexicons/api";
4
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5
import { getIdentityData } from "actions/getIdentityData";
6
-
import { createOauthClient } from "src/atproto-oauth";
7
import { TID } from "@atproto/common";
8
import { supabaseServerClient } from "supabase/serverClient";
9
import { revalidatePath } from "next/cache";
···
21
let leafletFeedURI =
22
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
23
let idResolver = new IdResolver();
24
export async function subscribeToPublication(
25
publication: string,
26
redirectRoute?: string,
27
-
) {
28
-
const oauthClient = await createOauthClient();
29
let identity = await getIdentityData();
30
if (!identity || !identity.atp_did) {
31
return redirect(
···
33
);
34
}
35
36
-
let credentialSession = await oauthClient.restore(identity.atp_did);
37
let agent = new AtpBaseClient(
38
credentialSession.fetchHandler.bind(credentialSession),
39
);
···
90
) as AppBskyActorDefs.SavedFeedsPrefV2;
91
revalidatePath("/lish/[did]/[publication]", "layout");
92
return {
93
hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
94
};
95
}
96
97
-
export async function unsubscribeToPublication(publication: string) {
98
-
const oauthClient = await createOauthClient();
99
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
101
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
103
let agent = new AtpBaseClient(
104
credentialSession.fetchHandler.bind(credentialSession),
105
);
···
109
.eq("identity", identity.atp_did)
110
.eq("publication", publication)
111
.single();
112
-
if (!existingSubscription) return;
113
await agent.pub.leaflet.graph.subscription.delete({
114
repo: credentialSession.did!,
115
rkey: new AtUri(existingSubscription.uri).rkey,
···
120
.eq("identity", identity.atp_did)
121
.eq("publication", publication);
122
revalidatePath("/lish/[did]/[publication]", "layout");
123
}
···
3
import { AtpBaseClient } from "lexicons/api";
4
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5
import { getIdentityData } from "actions/getIdentityData";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
10
import { TID } from "@atproto/common";
11
import { supabaseServerClient } from "supabase/serverClient";
12
import { revalidatePath } from "next/cache";
···
24
let leafletFeedURI =
25
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
26
let idResolver = new IdResolver();
27
+
28
+
type SubscribeResult =
29
+
| { success: true; hasFeed: boolean }
30
+
| { success: false; error: OAuthSessionError };
31
+
32
export async function subscribeToPublication(
33
publication: string,
34
redirectRoute?: string,
35
+
): Promise<SubscribeResult | never> {
36
let identity = await getIdentityData();
37
if (!identity || !identity.atp_did) {
38
return redirect(
···
40
);
41
}
42
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;
48
let agent = new AtpBaseClient(
49
credentialSession.fetchHandler.bind(credentialSession),
50
);
···
101
) as AppBskyActorDefs.SavedFeedsPrefV2;
102
revalidatePath("/lish/[did]/[publication]", "layout");
103
return {
104
+
success: true,
105
hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
106
};
107
}
108
109
+
type UnsubscribeResult =
110
+
| { success: true }
111
+
| { success: false; error: OAuthSessionError };
112
+
113
+
export async function unsubscribeToPublication(
114
+
publication: string
115
+
): Promise<UnsubscribeResult> {
116
let identity = await getIdentityData();
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
+
}
127
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;
133
let agent = new AtpBaseClient(
134
credentialSession.fetchHandler.bind(credentialSession),
135
);
···
139
.eq("identity", identity.atp_did)
140
.eq("publication", publication)
141
.single();
142
+
if (!existingSubscription) return { success: true };
143
await agent.pub.leaflet.graph.subscription.delete({
144
repo: credentialSession.did!,
145
rkey: new AtUri(existingSubscription.uri).rkey,
···
150
.eq("identity", identity.atp_did)
151
.eq("publication", publication);
152
revalidatePath("/lish/[did]/[publication]", "layout");
153
+
return { success: true };
154
}
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
3
-
export const runtime = "edge";
4
export const revalidate = 60;
5
6
export default async function OpenGraphImage(props: {
7
params: Promise<{ rkey: string; didOrHandle: string }>;
8
}) {
9
let params = await props.params;
10
-
return getMicroLinkOgImage(
11
-
`/p/${params.didOrHandle}/${params.rkey}/`,
12
-
);
13
}
···
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";
9
10
export const revalidate = 60;
11
12
export default async function OpenGraphImage(props: {
13
params: Promise<{ rkey: string; didOrHandle: string }>;
14
}) {
15
let params = await props.params;
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}/`);
68
}
+8
-32
appview/index.ts
+8
-32
appview/index.ts
···
20
} from "@atproto/api";
21
import { AtUri } from "@atproto/syntax";
22
import { writeFile, readFile } from "fs/promises";
23
-
import { createIdentity } from "actions/createIdentity";
24
-
import { drizzle } from "drizzle-orm/node-postgres";
25
import { inngest } from "app/api/inngest/client";
26
-
import { Client } from "pg";
27
28
const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor";
29
···
135
if (evt.event === "create" || evt.event === "update") {
136
let record = PubLeafletPublication.validateRecord(evt.record);
137
if (!record.success) return;
138
-
let { error } = await supabase.from("publications").upsert({
139
uri: evt.uri.toString(),
140
identity_did: evt.did,
141
name: record.value.name,
142
record: record.value as Json,
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
}
159
if (evt.event === "delete") {
160
await supabase
···
222
if (evt.event === "create" || evt.event === "update") {
223
let record = PubLeafletGraphSubscription.validateRecord(evt.record);
224
if (!record.success) return;
225
-
let { error } = await supabase.from("publication_subscriptions").upsert({
226
uri: evt.uri.toString(),
227
identity: evt.did,
228
publication: record.value.publication,
229
record: record.value as Json,
230
});
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
}
245
if (evt.event === "delete") {
246
await supabase
···
20
} from "@atproto/api";
21
import { AtUri } from "@atproto/syntax";
22
import { writeFile, readFile } from "fs/promises";
23
import { inngest } from "app/api/inngest/client";
24
25
const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor";
26
···
132
if (evt.event === "create" || evt.event === "update") {
133
let record = PubLeafletPublication.validateRecord(evt.record);
134
if (!record.success) return;
135
+
await supabase
136
+
.from("identities")
137
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
138
+
await supabase.from("publications").upsert({
139
uri: evt.uri.toString(),
140
identity_did: evt.did,
141
name: record.value.name,
142
record: record.value as Json,
143
});
144
}
145
if (evt.event === "delete") {
146
await supabase
···
208
if (evt.event === "create" || evt.event === "update") {
209
let record = PubLeafletGraphSubscription.validateRecord(evt.record);
210
if (!record.success) return;
211
+
await supabase
212
+
.from("identities")
213
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
214
+
await supabase.from("publication_subscriptions").upsert({
215
uri: evt.uri.toString(),
216
identity: evt.did,
217
publication: record.value.publication,
218
record: record.value as Json,
219
});
220
}
221
if (evt.event === "delete") {
222
await supabase
+2
-2
components/ActionBar/ActionButton.tsx
+2
-2
components/ActionBar/ActionButton.tsx
···
70
>
71
<div className="shrink-0">{icon}</div>
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}
···
70
>
71
<div className="shrink-0">{icon}</div>
72
<div
73
+
className={`flex flex-col pr-1 ${subtext && "leading-snug"} max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
74
>
75
+
<div className="truncate text-left">{label}</div>
76
{subtext && (
77
<div className="text-xs text-tertiary font-normal text-left">
78
{subtext}
+27
-1
components/Blocks/Block.tsx
+27
-1
components/Blocks/Block.tsx
···
10
import { useHandleDrop } from "./useHandleDrop";
11
import { useEntitySetContext } from "components/EntitySetProvider";
12
13
-
import { TextBlock } from "components/Blocks/TextBlock";
14
import { ImageBlock } from "./ImageBlock";
15
import { PageLinkBlock } from "./PageLinkBlock";
16
import { ExternalLinkBlock } from "./ExternalLinkBlock";
···
381
)}
382
</>
383
);
384
};
385
386
export const ListMarker = (
···
10
import { useHandleDrop } from "./useHandleDrop";
11
import { useEntitySetContext } from "components/EntitySetProvider";
12
13
+
import { TextBlock } from "./TextBlock/index";
14
import { ImageBlock } from "./ImageBlock";
15
import { PageLinkBlock } from "./PageLinkBlock";
16
import { ExternalLinkBlock } from "./ExternalLinkBlock";
···
381
)}
382
</>
383
);
384
+
};
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
412
export const ListMarker = (
+7
-5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
+7
-5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
148
}
149
return (
150
<div
151
-
className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`}
152
>
153
-
<div className="bskyAuthor w-full flex items-center gap-1">
154
{record.author.avatar && (
155
<img
156
src={record.author?.avatar}
157
alt={`${record.author?.displayName}'s avatar`}
158
-
className="shink-0 w-6 h-6 rounded-full border border-border-light"
159
/>
160
)}
161
-
<div className=" font-bold text-secondary">
162
{record.author?.displayName}
163
</div>
164
<a
···
171
</div>
172
173
<div className="flex flex-col gap-2 ">
174
-
{text && <pre className="whitespace-pre-wrap">{text}</pre>}
175
{record.embeds !== undefined
176
? record.embeds.map((embed, index) => (
177
<BlueskyEmbed embed={embed} key={index} />
···
148
}
149
return (
150
<div
151
+
className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`}
152
>
153
+
<div className="bskyAuthor w-full flex items-center ">
154
{record.author.avatar && (
155
<img
156
src={record.author?.avatar}
157
alt={`${record.author?.displayName}'s avatar`}
158
+
className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]"
159
/>
160
)}
161
+
<div className=" font-bold text-secondary mr-1">
162
{record.author?.displayName}
163
</div>
164
<a
···
171
</div>
172
173
<div className="flex flex-col gap-2 ">
174
+
{text && (
175
+
<pre className="whitespace-pre-wrap text-secondary">{text}</pre>
176
+
)}
177
{record.embeds !== undefined
178
? record.embeds.map((embed, index) => (
179
<BlueskyEmbed embed={embed} key={index} />
+8
-11
components/Blocks/BlueskyPostBlock/index.tsx
+8
-11
components/Blocks/BlueskyPostBlock/index.tsx
···
2
import { useEffect, useState } from "react";
3
import { useEntity } from "src/replicache";
4
import { useUIState } from "src/useUIState";
5
-
import { BlockProps } from "../Block";
6
import { elementId } from "src/utils/elementId";
7
import { focusBlock } from "src/utils/focusBlock";
8
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
···
56
AppBskyFeedDefs.isBlockedAuthor(post) ||
57
AppBskyFeedDefs.isNotFoundPost(post):
58
return (
59
-
<div
60
-
className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`}
61
-
>
62
<PostNotAvailable />
63
-
</div>
64
);
65
66
case AppBskyFeedDefs.isThreadViewPost(post):
···
81
let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
82
83
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
-
`}
89
>
90
{post.post.author && record && (
91
<>
···
149
</a>
150
</div>
151
</div>
152
-
</div>
153
);
154
}
155
};
···
2
import { useEffect, useState } from "react";
3
import { useEntity } from "src/replicache";
4
import { useUIState } from "src/useUIState";
5
+
import { BlockProps, BlockLayout } from "../Block";
6
import { elementId } from "src/utils/elementId";
7
import { focusBlock } from "src/utils/focusBlock";
8
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
···
56
AppBskyFeedDefs.isBlockedAuthor(post) ||
57
AppBskyFeedDefs.isNotFoundPost(post):
58
return (
59
+
<BlockLayout isSelected={!!isSelected} className="w-full">
60
<PostNotAvailable />
61
+
</BlockLayout>
62
);
63
64
case AppBskyFeedDefs.isThreadViewPost(post):
···
79
let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
80
81
return (
82
+
<BlockLayout
83
+
isSelected={!!isSelected}
84
+
hasBackground="page"
85
+
className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary"
86
>
87
{post.post.author && record && (
88
<>
···
146
</a>
147
</div>
148
</div>
149
+
</BlockLayout>
150
);
151
}
152
};
+103
-103
components/Blocks/ButtonBlock.tsx
+103
-103
components/Blocks/ButtonBlock.tsx
···
3
import { useCallback, useEffect, useState } from "react";
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
7
import { v7 } from "uuid";
8
import { useSmoker } from "components/Toast";
9
···
106
};
107
108
return (
109
-
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full">
110
<ButtonPrimary className="mx-auto">
111
{text !== "" ? text : "Button"}
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
-
}}
162
>
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
-
/>
205
</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>
216
</div>
217
);
218
};
···
3
import { useCallback, useEffect, useState } from "react";
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
import { v7 } from "uuid";
8
import { useSmoker } from "components/Toast";
9
···
106
};
107
108
return (
109
+
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full ">
110
<ButtonPrimary className="mx-auto">
111
{text !== "" ? text : "Button"}
112
</ButtonPrimary>
113
+
<BlockLayout
114
+
isSelected={!!isSelected}
115
+
borderOnHover
116
+
hasBackground="accent"
117
+
className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!"
118
>
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>
213
</div>
214
+
</form>
215
+
</BlockLayout>
216
</div>
217
);
218
};
+17
-6
components/Blocks/CodeBlock.tsx
+17
-6
components/Blocks/CodeBlock.tsx
···
6
} from "shiki";
7
import { useEntity, useReplicache } from "src/replicache";
8
import "katex/dist/katex.min.css";
9
-
import { BlockProps } from "./Block";
10
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
11
import { useUIState } from "src/useUIState";
12
import { BaseTextareaBlock } from "./BaseTextareaBlock";
···
119
</select>
120
</div>
121
)}
122
-
<div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline">
123
{focusedBlock && permissions.write ? (
124
<BaseTextareaBlock
125
data-editable-block
126
data-entityid={props.entityID}
127
id={elementId.block(props.entityID).input}
···
131
spellCheck={false}
132
autoCapitalize="none"
133
autoCorrect="off"
134
-
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2"
135
value={content?.data.value}
136
onChange={async (e) => {
137
// Update the entity with the new value
···
146
<pre
147
onClick={onClick}
148
onMouseDown={(e) => e.stopPropagation()}
149
-
className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full"
150
>
151
-
{content?.data.value}
152
</pre>
153
) : (
154
<div
···
159
dangerouslySetInnerHTML={{ __html: html || "" }}
160
/>
161
)}
162
-
</div>
163
</div>
164
);
165
}
···
6
} from "shiki";
7
import { useEntity, useReplicache } from "src/replicache";
8
import "katex/dist/katex.min.css";
9
+
import { BlockLayout, BlockProps } from "./Block";
10
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
11
import { useUIState } from "src/useUIState";
12
import { BaseTextareaBlock } from "./BaseTextareaBlock";
···
119
</select>
120
</div>
121
)}
122
+
123
+
<BlockLayout
124
+
isSelected={focusedBlock}
125
+
hasBackground="accent"
126
+
borderOnHover
127
+
className="p-0! min-h-[48px]"
128
+
>
129
{focusedBlock && permissions.write ? (
130
<BaseTextareaBlock
131
+
placeholder="write some codeโฆ"
132
data-editable-block
133
data-entityid={props.entityID}
134
id={elementId.block(props.entityID).input}
···
138
spellCheck={false}
139
autoCapitalize="none"
140
autoCorrect="off"
141
+
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3"
142
value={content?.data.value}
143
onChange={async (e) => {
144
// Update the entity with the new value
···
153
<pre
154
onClick={onClick}
155
onMouseDown={(e) => e.stopPropagation()}
156
+
className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full"
157
>
158
+
{content?.data.value === "" || content?.data.value === undefined ? (
159
+
<div className="text-tertiary italic">write some codeโฆ</div>
160
+
) : (
161
+
content?.data.value
162
+
)}
163
</pre>
164
) : (
165
<div
···
170
dangerouslySetInnerHTML={{ __html: html || "" }}
171
/>
172
)}
173
+
</BlockLayout>
174
</div>
175
);
176
}
+5
-5
components/Blocks/DateTimeBlock.tsx
+5
-5
components/Blocks/DateTimeBlock.tsx
···
1
import { useEntity, useReplicache } from "src/replicache";
2
-
import { BlockProps } from "./Block";
3
import { ChevronProps, DayPicker } from "react-day-picker";
4
import { Popover } from "components/Popover";
5
import { useEffect, useMemo, useState } from "react";
···
121
disabled={isLocked || !permissions.write}
122
className="w-64 z-10 px-2!"
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"}
127
${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"}
128
`}
129
>
···
163
</div>
164
)}
165
</FadeIn>
166
-
</div>
167
}
168
>
169
<div className="flex flex-col gap-3 ">
···
1
import { useEntity, useReplicache } from "src/replicache";
2
+
import { BlockProps, BlockLayout } from "./Block";
3
import { ChevronProps, DayPicker } from "react-day-picker";
4
import { Popover } from "components/Popover";
5
import { useEffect, useMemo, useState } from "react";
···
121
disabled={isLocked || !permissions.write}
122
className="w-64 z-10 px-2!"
123
trigger={
124
+
<BlockLayout
125
+
isSelected={!!isSelected}
126
+
className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent!
127
${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"}
128
`}
129
>
···
163
</div>
164
)}
165
</FadeIn>
166
+
</BlockLayout>
167
}
168
>
169
<div className="flex flex-col gap-3 ">
+13
-16
components/Blocks/EmbedBlock.tsx
+13
-16
components/Blocks/EmbedBlock.tsx
···
3
import { useCallback, useEffect, useState } from "react";
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
7
import { v7 } from "uuid";
8
import { useSmoker } from "components/Toast";
9
import { Separator } from "components/Layout";
···
84
<div
85
className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
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>
102
{/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
103
<a
104
href={url?.data.value}
···
3
import { useCallback, useEffect, useState } from "react";
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
import { v7 } from "uuid";
8
import { useSmoker } from "components/Toast";
9
import { Separator } from "components/Layout";
···
84
<div
85
className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
86
>
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>
99
{/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
100
<a
101
href={url?.data.value}
+43
-42
components/Blocks/ExternalLinkBlock.tsx
+43
-42
components/Blocks/ExternalLinkBlock.tsx
···
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
import { addLinkBlock } from "src/utils/addLinkBlock";
7
-
import { BlockProps } from "./Block";
8
import { v7 } from "uuid";
9
import { useSmoker } from "components/Toast";
10
import { Separator } from "components/Layout";
···
64
}
65
66
return (
67
-
<a
68
-
href={url?.data.value}
69
-
target="_blank"
70
-
className={`
71
-
externalLinkBlock flex relative group/linkBlock
72
-
h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline
73
-
hover:border-accent-contrast shadow-sm
74
-
${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"}
75
-
76
-
`}
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>
90
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>
102
</div>
103
-
</div>
104
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
);
114
};
115
···
4
import { useEntity, useReplicache } from "src/replicache";
5
import { useUIState } from "src/useUIState";
6
import { addLinkBlock } from "src/utils/addLinkBlock";
7
+
import { BlockProps, BlockLayout } from "./Block";
8
import { v7 } from "uuid";
9
import { useSmoker } from "components/Toast";
10
import { Separator } from "components/Layout";
···
64
}
65
66
return (
67
+
<BlockLayout
68
+
isSelected={!!isSelected}
69
+
hasBackground="page"
70
+
borderOnHover
71
+
className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!"
72
>
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>
90
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>
102
</div>
103
</div>
104
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>
114
);
115
};
116
+68
-24
components/Blocks/ImageBlock.tsx
+68
-24
components/Blocks/ImageBlock.tsx
···
1
"use client";
2
3
import { useEntity, useReplicache } from "src/replicache";
4
-
import { BlockProps } from "./Block";
5
import { useUIState } from "src/useUIState";
6
import Image from "next/image";
7
import { v7 } from "uuid";
···
17
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
18
import { set } from "colorjs.io/fn";
19
import { ImageAltSmall } from "components/Icons/ImageAlt";
20
21
export function ImageBlock(props: BlockProps & { preview?: boolean }) {
22
let { rep } = useReplicache();
···
61
factID: v7(),
62
permission_set: entity_set.set,
63
type: "text",
64
-
position: generateKeyBetween(
65
-
props.position,
66
-
props.nextPosition,
67
-
),
68
newEntityID: entity,
69
});
70
}
···
82
if (!image) {
83
if (!entity_set.permissions.write) return null;
84
return (
85
-
<div className="grow w-full">
86
<label
87
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
91
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
${props.pageType === "canvas" && "bg-bg-page"}`}
95
onMouseDown={(e) => e.preventDefault()}
96
onDragOver={(e) => {
···
104
const files = e.dataTransfer.files;
105
if (files && files.length > 0) {
106
const file = files[0];
107
-
if (file.type.startsWith('image/')) {
108
await handleImageUpload(file);
109
}
110
}
···
128
}}
129
/>
130
</label>
131
-
</div>
132
);
133
}
134
135
-
let className = isFullBleed
136
? ""
137
: isSelected
138
? "block-border-selected border-transparent! "
···
140
141
let isLocalUpload = localImages.get(image.data.src);
142
143
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}
152
{isLocalUpload || image.data.local ? (
153
<img
154
loading="lazy"
···
166
}
167
height={image?.data.height}
168
width={image?.data.width}
169
-
className={className}
170
/>
171
)}
172
{altText !== undefined && !props.preview ? (
173
<ImageAlt entityID={props.value} />
174
) : null}
175
-
</div>
176
);
177
}
178
···
188
altEditorOpen: false,
189
setAltEditorOpen: (s: boolean) => {},
190
});
191
192
const ImageAlt = (props: { entityID: string }) => {
193
let { rep } = useReplicache();
···
1
"use client";
2
3
import { useEntity, useReplicache } from "src/replicache";
4
+
import { BlockProps, BlockLayout } from "./Block";
5
import { useUIState } from "src/useUIState";
6
import Image from "next/image";
7
import { v7 } from "uuid";
···
17
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
18
import { set } from "colorjs.io/fn";
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";
23
24
export function ImageBlock(props: BlockProps & { preview?: boolean }) {
25
let { rep } = useReplicache();
···
64
factID: v7(),
65
permission_set: entity_set.set,
66
type: "text",
67
+
position: generateKeyBetween(props.position, props.nextPosition),
68
newEntityID: entity,
69
});
70
}
···
82
if (!image) {
83
if (!entity_set.permissions.write) return null;
84
return (
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
+
>
91
<label
92
className={`
93
+
94
+
w-full h-full hover:cursor-pointer
95
flex flex-col items-center justify-center
96
${props.pageType === "canvas" && "bg-bg-page"}`}
97
onMouseDown={(e) => e.preventDefault()}
98
onDragOver={(e) => {
···
106
const files = e.dataTransfer.files;
107
if (files && files.length > 0) {
108
const file = files[0];
109
+
if (file.type.startsWith("image/")) {
110
await handleImageUpload(file);
111
}
112
}
···
130
}}
131
/>
132
</label>
133
+
</BlockLayout>
134
);
135
}
136
137
+
let imageClassName = isFullBleed
138
? ""
139
: isSelected
140
? "block-border-selected border-transparent! "
···
142
143
let isLocalUpload = localImages.get(image.data.src);
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
+
152
return (
153
+
<BlockLayout isSelected={!!isSelected} className={blockClassName}>
154
{isLocalUpload || image.data.local ? (
155
<img
156
loading="lazy"
···
168
}
169
height={image?.data.height}
170
width={image?.data.width}
171
+
className={imageClassName}
172
/>
173
)}
174
{altText !== undefined && !props.preview ? (
175
<ImageAlt entityID={props.value} />
176
) : null}
177
+
{!props.preview ? <CoverImageButton entityID={props.value} /> : null}
178
+
</BlockLayout>
179
);
180
}
181
···
191
altEditorOpen: false,
192
setAltEditorOpen: (s: boolean) => {},
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
+
};
235
236
const ImageAlt = (props: { entityID: string }) => {
237
let { rep } = useReplicache();
+80
-94
components/Blocks/MailboxBlock.tsx
+80
-94
components/Blocks/MailboxBlock.tsx
···
1
import { ButtonPrimary } from "components/Buttons";
2
import { Popover } from "components/Popover";
3
-
import { Menu, MenuItem, Separator } from "components/Layout";
4
import { useUIState } from "src/useUIState";
5
import { useState } from "react";
6
import { useSmoker, useToaster } from "components/Toast";
7
-
import { BlockProps } from "./Block";
8
import { useEntity, useReplicache } from "src/replicache";
9
import { useEntitySetContext } from "components/EntitySetProvider";
10
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
···
45
46
return (
47
<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
-
}}
55
>
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>
82
<div className="flex gap-3 items-center justify-between">
83
{
84
<>
···
134
let { rep } = useReplicache();
135
return (
136
<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
-
}}
147
>
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
-
});
191
}}
192
>
193
-
unsubscribe
194
-
</button>
195
-
</div>
196
</div>
197
-
)}
198
-
</div>
199
-
</div>
200
</div>
201
);
202
};
···
1
import { ButtonPrimary } from "components/Buttons";
2
import { Popover } from "components/Popover";
3
+
import { MenuItem } from "components/Menu";
4
+
import { Separator } from "components/Layout";
5
import { useUIState } from "src/useUIState";
6
import { useState } from "react";
7
import { useSmoker, useToaster } from "components/Toast";
8
+
import { BlockProps, BlockLayout } from "./Block";
9
import { useEntity, useReplicache } from "src/replicache";
10
import { useEntitySetContext } from "components/EntitySetProvider";
11
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
···
46
47
return (
48
<div className={`mailboxContent relative w-full flex flex-col gap-1`}>
49
+
<BlockLayout
50
+
isSelected={!!isSelected}
51
+
hasBackground={"accent"}
52
+
className="flex gap-2 items-center justify-center"
53
>
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>
78
<div className="flex gap-3 items-center justify-between">
79
{
80
<>
···
130
let { rep } = useReplicache();
131
return (
132
<div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}>
133
+
<BlockLayout
134
+
isSelected={!!isSelected}
135
+
hasBackground={"accent"}
136
+
className="`h-full flex flex-col gap-2 items-center justify-center"
137
>
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
+
}
162
}}
163
>
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>
182
</div>
183
+
</div>
184
+
)}
185
+
</BlockLayout>
186
</div>
187
);
188
};
+33
-23
components/Blocks/MathBlock.tsx
+33
-23
components/Blocks/MathBlock.tsx
···
1
import { useEntity, useReplicache } from "src/replicache";
2
import "katex/dist/katex.min.css";
3
-
import { BlockProps } from "./Block";
4
import Katex from "katex";
5
import { useMemo } from "react";
6
import { useUIState } from "src/useUIState";
···
32
}
33
}, [content?.data.value]);
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
-
/>
53
) : html && content?.data.value ? (
54
<div
55
-
className="text-lg min-h-[66px] w-full border border-transparent"
56
dangerouslySetInnerHTML={{ __html: html }}
57
/>
58
) : (
59
-
<div className="text-tertiary italic rounded-md p-2 w-full min-h-16">
60
-
write some Tex here...
61
-
</div>
62
);
63
}
···
1
import { useEntity, useReplicache } from "src/replicache";
2
import "katex/dist/katex.min.css";
3
+
import { BlockLayout, BlockProps } from "./Block";
4
import Katex from "katex";
5
import { useMemo } from "react";
6
import { useUIState } from "src/useUIState";
···
32
}
33
}, [content?.data.value]);
34
return focusedBlock ? (
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>
59
) : html && content?.data.value ? (
60
<div
61
+
className="text-lg min-h-[48px] w-full border border-transparent"
62
dangerouslySetInnerHTML={{ __html: html }}
63
/>
64
) : (
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>
72
);
73
}
+26
-22
components/Blocks/PageLinkBlock.tsx
+26
-22
components/Blocks/PageLinkBlock.tsx
···
1
"use client";
2
-
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
3
import { focusBlock } from "src/utils/focusBlock";
4
5
import { focusPage } from "src/utils/focusPage";
···
29
30
return (
31
<CardThemeProvider entityID={page?.data.value}>
32
-
<div
33
-
className={`w-full cursor-pointer
34
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!"}
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
>
50
-
{type === "canvas" && page ? (
51
-
<CanvasLinkBlock entityID={page?.data.value} />
52
-
) : (
53
-
<DocLinkBlock {...props} />
54
-
)}
55
-
</div>
56
</CardThemeProvider>
57
);
58
}
···
1
"use client";
2
+
import { BlockProps, ListMarker, Block, BlockLayout } from "./Block";
3
import { focusBlock } from "src/utils/focusBlock";
4
5
import { focusPage } from "src/utils/focusPage";
···
29
30
return (
31
<CardThemeProvider entityID={page?.data.value}>
32
+
<BlockLayout
33
+
hasBackground="page"
34
+
isSelected={!!isSelected}
35
+
className={`cursor-pointer
36
pageLinkBlockWrapper relative group/pageLinkBlock
37
+
flex overflow-clip p-0!
38
+
${isOpen && "border-accent-contrast! outline-accent-contrast!"}
39
`}
40
>
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>
60
</CardThemeProvider>
61
);
62
}
+7
-10
components/Blocks/PollBlock/index.tsx
+7
-10
components/Blocks/PollBlock/index.tsx
···
1
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "../Block";
3
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
import { useCallback, useEffect, useState } from "react";
5
import { Input } from "components/Input";
···
61
let totalVotes = votes.length;
62
63
return (
64
-
<div
65
-
className={`poll flex flex-col gap-2 p-3 w-full
66
-
${isSelected ? "block-border-selected " : "block-border"}`}
67
-
style={{
68
-
backgroundColor:
69
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
70
-
}}
71
>
72
{pollState === "editing" ? (
73
<EditPoll
···
95
hasVoted={!!hasVoted}
96
/>
97
)}
98
-
</div>
99
);
100
};
101
···
486
}) => {
487
return (
488
<button
489
-
className="text-sm text-accent-contrast sm:hover:underline"
490
onClick={() => {
491
props.setPollState(props.pollState === "voting" ? "results" : "voting");
492
}}
···
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";
···
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
···
92
hasVoted={!!hasVoted}
93
/>
94
)}
95
+
</BlockLayout>
96
);
97
};
98
···
483
}) => {
484
return (
485
<button
486
+
className="text-sm text-accent-contrast "
487
onClick={() => {
488
props.setPollState(props.pollState === "voting" ? "results" : "voting");
489
}}
+6
-9
components/Blocks/PublicationPollBlock.tsx
+6
-9
components/Blocks/PublicationPollBlock.tsx
···
1
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "./Block";
3
import { useMemo } from "react";
4
import { AsyncValueInput } from "components/Input";
5
import { focusElement } from "src/utils/focusElement";
···
53
}, [publicationData, props.entityID]);
54
55
return (
56
-
<div
57
-
className={`poll flex flex-col gap-2 p-3 w-full
58
-
${isSelected ? "block-border-selected " : "block-border"}`}
59
-
style={{
60
-
backgroundColor:
61
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
62
-
}}
63
>
64
<EditPollForPublication
65
entityID={props.entityID}
66
isPublished={isPublished}
67
/>
68
-
</div>
69
);
70
};
71
···
1
import { useUIState } from "src/useUIState";
2
+
import { BlockLayout, BlockProps } from "./Block";
3
import { useMemo } from "react";
4
import { AsyncValueInput } from "components/Input";
5
import { focusElement } from "src/utils/focusElement";
···
53
}, [publicationData, props.entityID]);
54
55
return (
56
+
<BlockLayout
57
+
className="poll flex flex-col gap-2"
58
+
hasBackground={"accent"}
59
+
isSelected={!!isSelected}
60
>
61
<EditPollForPublication
62
entityID={props.entityID}
63
isPublished={isPublished}
64
/>
65
+
</BlockLayout>
66
);
67
};
68
+6
-8
components/Blocks/RSVPBlock/index.tsx
+6
-8
components/Blocks/RSVPBlock/index.tsx
···
1
"use client";
2
import { Database } from "supabase/database.types";
3
-
import { BlockProps } from "components/Blocks/Block";
4
import { useState } from "react";
5
import { submitRSVP } from "actions/phone_rsvp_to_event";
6
import { useRSVPData } from "components/PageSWRDataProvider";
···
29
s.selectedBlocks.find((b) => b.value === props.entityID),
30
);
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
-
}}
38
>
39
<RSVPForm entityID={props.entityID} />
40
-
</div>
41
);
42
}
43
···
1
"use client";
2
import { Database } from "supabase/database.types";
3
+
import { BlockProps, BlockLayout } from "components/Blocks/Block";
4
import { useState } from "react";
5
import { submitRSVP } from "actions/phone_rsvp_to_event";
6
import { useRSVPData } from "components/PageSWRDataProvider";
···
29
s.selectedBlocks.find((b) => b.value === props.entityID),
30
);
31
return (
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"
36
>
37
<RSVPForm entityID={props.entityID} />
38
+
</BlockLayout>
39
);
40
}
41
+1
-1
components/Blocks/TextBlock/RenderYJSFragment.tsx
+1
-1
components/Blocks/TextBlock/RenderYJSFragment.tsx
+18
-4
components/Blocks/TextBlock/index.tsx
+18
-4
components/Blocks/TextBlock/index.tsx
···
120
}) {
121
let initialFact = useEntity(props.entityID, "block/text");
122
let headingLevel = useEntity(props.entityID, "block/heading-level");
123
let alignment =
124
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
125
let alignmentClass = {
···
128
center: "text-center",
129
justify: "text-justify",
130
}[alignment];
131
let { permissions } = useEntitySetContext();
132
133
let content = <br />;
···
159
className={`
160
${alignmentClass}
161
${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}
162
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
163
w-full whitespace-pre-wrap outline-hidden ${props.className} `}
164
>
165
{content}
···
169
170
export function BaseTextBlock(props: BlockProps & { className?: string }) {
171
let headingLevel = useEntity(props.entityID, "block/heading-level");
172
let alignment =
173
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
174
···
184
center: "text-center",
185
justify: "text-justify",
186
}[alignment];
187
188
let editorState = useEditorStates(
189
(s) => s.editorStates[props.entityID],
···
258
grow resize-none align-top whitespace-pre-wrap bg-transparent
259
outline-hidden
260
261
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
262
${props.className}`}
263
ref={mountRef}
264
/>
···
277
// if this is the only block on the page and is empty or is a canvas, show placeholder
278
<div
279
className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col
280
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
281
`}
282
>
283
{props.type === "text"
···
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();
···
120
}) {
121
let initialFact = useEntity(props.entityID, "block/text");
122
let headingLevel = useEntity(props.entityID, "block/heading-level");
123
+
let textSize = useEntity(props.entityID, "block/text-size");
124
let alignment =
125
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
126
let alignmentClass = {
···
129
center: "text-center",
130
justify: "text-justify",
131
}[alignment];
132
+
let textStyle =
133
+
textSize?.data.value === "small"
134
+
? "text-sm"
135
+
: textSize?.data.value === "large"
136
+
? "text-lg"
137
+
: "";
138
let { permissions } = useEntitySetContext();
139
140
let content = <br />;
···
166
className={`
167
${alignmentClass}
168
${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}
169
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
170
w-full whitespace-pre-wrap outline-hidden ${props.className} `}
171
>
172
{content}
···
176
177
export function BaseTextBlock(props: BlockProps & { className?: string }) {
178
let headingLevel = useEntity(props.entityID, "block/heading-level");
179
+
let textSize = useEntity(props.entityID, "block/text-size");
180
let alignment =
181
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
182
···
192
center: "text-center",
193
justify: "text-justify",
194
}[alignment];
195
+
let textStyle =
196
+
textSize?.data.value === "small"
197
+
? "text-sm text-secondary"
198
+
: textSize?.data.value === "large"
199
+
? "text-lg text-primary"
200
+
: "text-base text-primary";
201
202
let editorState = useEditorStates(
203
(s) => s.editorStates[props.entityID],
···
272
grow resize-none align-top whitespace-pre-wrap bg-transparent
273
outline-hidden
274
275
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
276
${props.className}`}
277
ref={mountRef}
278
/>
···
291
// if this is the only block on the page and is empty or is a canvas, show placeholder
292
<div
293
className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col
294
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
295
`}
296
>
297
{props.type === "text"
···
510
511
// Find the relative positioned parent container
512
const editorEl = view.dom;
513
+
const container = editorEl.closest(".relative") as HTMLElement | null;
514
515
if (container) {
516
const containerRect = container.getBoundingClientRect();
+14
components/Blocks/TextBlock/keymap.ts
+14
components/Blocks/TextBlock/keymap.ts
···
555
},
556
});
557
}
558
+
let [textSize] =
559
+
(await repRef.current?.query((tx) =>
560
+
scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"),
561
+
)) || [];
562
+
if (textSize) {
563
+
await repRef.current?.mutate.assertFact({
564
+
entity: newEntityID,
565
+
attribute: "block/text-size",
566
+
data: {
567
+
type: "text-size-union",
568
+
value: textSize.data.value,
569
+
},
570
+
});
571
+
}
572
};
573
asyncRun().then(() => {
574
useUIState.getState().setSelectedBlock({
+11
components/Blocks/TextBlock/useHandlePaste.ts
+11
components/Blocks/TextBlock/useHandlePaste.ts
···
299
},
300
});
301
}
302
+
let textSize = child.getAttribute("data-text-size");
303
+
if (textSize && ["default", "small", "large"].includes(textSize)) {
304
+
rep.mutate.assertFact({
305
+
entity: entityID,
306
+
attribute: "block/text-size",
307
+
data: {
308
+
type: "text-size-union",
309
+
value: textSize as "default" | "small" | "large",
310
+
},
311
+
});
312
+
}
313
if (child.tagName === "A") {
314
let href = child.getAttribute("href");
315
let dataType = child.getAttribute("data-type");
+11
-5
components/Buttons.tsx
+11
-5
components/Buttons.tsx
···
38
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
39
bg-accent-1 disabled:bg-border-light
40
border border-accent-1 rounded-md disabled:border-border-light
41
-
outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
42
text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border
43
flex gap-2 items-center justify-center shrink-0
44
${className}
···
77
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
78
bg-bg-page disabled:bg-border-light
79
border border-accent-contrast rounded-md
80
-
outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
81
text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border
82
flex gap-2 items-center justify-center shrink-0
83
${props.className}
···
116
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
117
bg-transparent hover:bg-[var(--accent-light)]
118
border border-transparent rounded-md hover:border-[var(--accent-light)]
119
-
outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
120
text-base font-bold text-accent-contrast disabled:text-border
121
flex gap-2 items-center justify-center shrink-0
122
${props.className}
···
165
side={props.side ? props.side : undefined}
166
sideOffset={6}
167
alignOffset={12}
168
-
className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm"
169
>
170
{props.tooltipContent}
171
<RadixTooltip.Arrow
···
175
viewBox="0 0 16 8"
176
>
177
<PopoverArrow
178
-
arrowFill={theme.colors["border"]}
179
arrowStroke="transparent"
180
/>
181
</RadixTooltip.Arrow>
···
38
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
39
bg-accent-1 disabled:bg-border-light
40
border border-accent-1 rounded-md disabled:border-border-light
41
+
outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
42
text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border
43
flex gap-2 items-center justify-center shrink-0
44
${className}
···
77
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
78
bg-bg-page disabled:bg-border-light
79
border border-accent-contrast rounded-md
80
+
outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
81
text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border
82
flex gap-2 items-center justify-center shrink-0
83
${props.className}
···
116
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
117
bg-transparent hover:bg-[var(--accent-light)]
118
border border-transparent rounded-md hover:border-[var(--accent-light)]
119
+
outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
120
text-base font-bold text-accent-contrast disabled:text-border
121
flex gap-2 items-center justify-center shrink-0
122
${props.className}
···
165
side={props.side ? props.side : undefined}
166
sideOffset={6}
167
alignOffset={12}
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
+
}}
173
>
174
{props.tooltipContent}
175
<RadixTooltip.Arrow
···
179
viewBox="0 0 16 8"
180
>
181
<PopoverArrow
182
+
arrowFill={
183
+
"color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)"
184
+
}
185
arrowStroke="transparent"
186
/>
187
</RadixTooltip.Arrow>
+6
-3
components/Canvas.tsx
+6
-3
components/Canvas.tsx
···
170
171
let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172
let showComments = pubRecord.preferences?.showComments;
173
174
return (
175
<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
<CommentTiny className="text-border" /> โ
179
</div>
180
)}
181
-
<div className="flex gap-1 text-tertiary items-center">
182
-
<QuoteTiny className="text-border" /> โ
183
-
</div>
184
185
{!props.isSubpage && (
186
<>
···
170
171
let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172
let showComments = pubRecord.preferences?.showComments;
173
+
let showMentions = pubRecord.preferences?.showMentions;
174
175
return (
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">
···
179
<CommentTiny className="text-border" /> โ
180
</div>
181
)}
182
+
{showComments && (
183
+
<div className="flex gap-1 text-tertiary items-center">
184
+
<QuoteTiny className="text-border" /> โ
185
+
</div>
186
+
)}
187
188
{!props.isSubpage && (
189
<>
+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
+
);
+4
-2
components/InteractionsPreview.tsx
+4
-2
components/InteractionsPreview.tsx
···
14
tags?: string[];
15
postUrl: string;
16
showComments: boolean | undefined;
17
share?: boolean;
18
}) => {
19
let smoker = useSmoker();
20
let interactionsAvailable =
21
-
props.quotesCount > 0 ||
22
(props.showComments !== false && props.commentsCount > 0);
23
24
const tagsCount = props.tags?.length || 0;
···
36
</>
37
)}
38
39
-
{props.quotesCount === 0 ? null : (
40
<SpeedyLink
41
aria-label="Post quotes"
42
href={`${props.postUrl}?interactionDrawer=quotes`}
···
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;
···
38
</>
39
)}
40
41
+
{props.showMentions === false || props.quotesCount === 0 ? null : (
42
<SpeedyLink
43
aria-label="Post quotes"
44
href={`${props.postUrl}?interactionDrawer=quotes`}
-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/PopoverContext";
7
-
import { useState } from "react";
8
-
9
export const Separator = (props: { classname?: string }) => {
10
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
};
98
99
export const ShortcutKey = (props: { children: React.ReactNode }) => {
+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
+
}
+1
-1
components/PageLayouts/DashboardLayout.tsx
+1
-1
components/PageLayouts/DashboardLayout.tsx
+2
-2
components/Pages/PageOptions.tsx
+2
-2
components/Pages/PageOptions.tsx
···
7
import { useReplicache } from "src/replicache";
8
9
import { Media } from "../Media";
10
-
import { MenuItem, Menu } from "../Layout";
11
import { PageThemeSetter } from "../ThemeManager/PageThemeSetter";
12
import { PageShareMenu } from "./PageShareMenu";
13
import { useUndoState } from "src/undoManager";
···
61
<div
62
className={`pageOptions w-fit z-10
63
${props.isFocused ? "block" : "sm:hidden block"}
64
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
65
flex sm:flex-col flex-row-reverse gap-1 items-start`}
66
>
67
{!props.first && (
···
7
import { useReplicache } from "src/replicache";
8
9
import { Media } from "../Media";
10
+
import { MenuItem, Menu } from "../Menu";
11
import { PageThemeSetter } from "../ThemeManager/PageThemeSetter";
12
import { PageShareMenu } from "./PageShareMenu";
13
import { useUndoState } from "src/undoManager";
···
61
<div
62
className={`pageOptions w-fit z-10
63
${props.isFocused ? "block" : "sm:hidden block"}
64
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
65
flex sm:flex-col flex-row-reverse gap-1 items-start`}
66
>
67
{!props.first && (
+5
-3
components/Pages/PublicationMetadata.tsx
+5
-3
components/Pages/PublicationMetadata.tsx
···
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 />โ
+13
-9
components/PostListing.tsx
+13
-9
components/PostListing.tsx
···
22
23
let postRecord = props.documents.data as PubLeafletDocument.Record;
24
let postUri = new AtUri(props.documents.uri);
25
26
-
let theme = usePubTheme(pubRecord?.theme || postRecord?.theme);
27
let backgroundImage =
28
-
pubRecord?.theme?.backgroundImage?.image?.ref && props.publication
29
-
? blobRefToSrc(
30
-
pubRecord.theme.backgroundImage.image.ref,
31
-
new AtUri(props.publication.uri).host,
32
-
)
33
: null;
34
35
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
36
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
37
38
-
let showPageBackground = pubRecord?.theme?.showPageBackground;
39
40
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
41
let comments =
···
94
commentsCount={comments}
95
tags={tags}
96
showComments={pubRecord?.preferences?.showComments}
97
share
98
/>
99
</div>
···
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 =
···
97
commentsCount={comments}
98
tags={tags}
99
showComments={pubRecord?.preferences?.showComments}
100
+
showMentions={pubRecord?.preferences?.showMentions}
101
share
102
/>
103
</div>
+148
-1
components/SelectionManager/index.tsx
+148
-1
components/SelectionManager/index.tsx
···
89
},
90
{
91
metaKey: true,
92
+
altKey: true,
93
+
key: ["1", "ยก"],
94
+
handler: async () => {
95
+
let [sortedBlocks] = await getSortedSelectionBound();
96
+
for (let block of sortedBlocks) {
97
+
await rep?.mutate.assertFact({
98
+
entity: block.value,
99
+
attribute: "block/heading-level",
100
+
data: { type: "number", value: 1 },
101
+
});
102
+
await rep?.mutate.assertFact({
103
+
entity: block.value,
104
+
attribute: "block/type",
105
+
data: { type: "block-type-union", value: "heading" },
106
+
});
107
+
}
108
+
},
109
+
},
110
+
{
111
+
metaKey: true,
112
+
altKey: true,
113
+
key: ["2", "โข"],
114
+
handler: async () => {
115
+
let [sortedBlocks] = await getSortedSelectionBound();
116
+
for (let block of sortedBlocks) {
117
+
await rep?.mutate.assertFact({
118
+
entity: block.value,
119
+
attribute: "block/heading-level",
120
+
data: { type: "number", value: 2 },
121
+
});
122
+
await rep?.mutate.assertFact({
123
+
entity: block.value,
124
+
attribute: "block/type",
125
+
data: { type: "block-type-union", value: "heading" },
126
+
});
127
+
}
128
+
},
129
+
},
130
+
{
131
+
metaKey: true,
132
+
altKey: true,
133
+
key: ["3", "ยฃ"],
134
+
handler: async () => {
135
+
let [sortedBlocks] = await getSortedSelectionBound();
136
+
for (let block of sortedBlocks) {
137
+
await rep?.mutate.assertFact({
138
+
entity: block.value,
139
+
attribute: "block/heading-level",
140
+
data: { type: "number", value: 3 },
141
+
});
142
+
await rep?.mutate.assertFact({
143
+
entity: block.value,
144
+
attribute: "block/type",
145
+
data: { type: "block-type-union", value: "heading" },
146
+
});
147
+
}
148
+
},
149
+
},
150
+
{
151
+
metaKey: true,
152
+
altKey: true,
153
+
key: ["0", "ยบ"],
154
+
handler: async () => {
155
+
let [sortedBlocks] = await getSortedSelectionBound();
156
+
for (let block of sortedBlocks) {
157
+
// Convert to text block
158
+
await rep?.mutate.assertFact({
159
+
entity: block.value,
160
+
attribute: "block/type",
161
+
data: { type: "block-type-union", value: "text" },
162
+
});
163
+
// Remove heading level if exists
164
+
let headingLevel = await rep?.query((tx) =>
165
+
scanIndex(tx).eav(block.value, "block/heading-level"),
166
+
);
167
+
if (headingLevel?.[0]) {
168
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
169
+
}
170
+
// Remove text-size to make it default
171
+
let textSizeFact = await rep?.query((tx) =>
172
+
scanIndex(tx).eav(block.value, "block/text-size"),
173
+
);
174
+
if (textSizeFact?.[0]) {
175
+
await rep?.mutate.retractFact({ factID: textSizeFact[0].id });
176
+
}
177
+
}
178
+
},
179
+
},
180
+
{
181
+
metaKey: true,
182
+
altKey: true,
183
+
key: ["+", "โ "],
184
+
handler: async () => {
185
+
let [sortedBlocks] = await getSortedSelectionBound();
186
+
for (let block of sortedBlocks) {
187
+
// Convert to text block
188
+
await rep?.mutate.assertFact({
189
+
entity: block.value,
190
+
attribute: "block/type",
191
+
data: { type: "block-type-union", value: "text" },
192
+
});
193
+
// Remove heading level if exists
194
+
let headingLevel = await rep?.query((tx) =>
195
+
scanIndex(tx).eav(block.value, "block/heading-level"),
196
+
);
197
+
if (headingLevel?.[0]) {
198
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
199
+
}
200
+
// Set text size to large
201
+
await rep?.mutate.assertFact({
202
+
entity: block.value,
203
+
attribute: "block/text-size",
204
+
data: { type: "text-size-union", value: "large" },
205
+
});
206
+
}
207
+
},
208
+
},
209
+
{
210
+
metaKey: true,
211
+
altKey: true,
212
+
key: ["-", "โ"],
213
+
handler: async () => {
214
+
let [sortedBlocks] = await getSortedSelectionBound();
215
+
for (let block of sortedBlocks) {
216
+
// Convert to text block
217
+
await rep?.mutate.assertFact({
218
+
entity: block.value,
219
+
attribute: "block/type",
220
+
data: { type: "block-type-union", value: "text" },
221
+
});
222
+
// Remove heading level if exists
223
+
let headingLevel = await rep?.query((tx) =>
224
+
scanIndex(tx).eav(block.value, "block/heading-level"),
225
+
);
226
+
if (headingLevel?.[0]) {
227
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
228
+
}
229
+
// Set text size to small
230
+
await rep?.mutate.assertFact({
231
+
entity: block.value,
232
+
attribute: "block/text-size",
233
+
data: { type: "text-size-union", value: "small" },
234
+
});
235
+
}
236
+
},
237
+
},
238
+
{
239
+
metaKey: true,
240
shift: true,
241
key: ["ArrowDown", "J"],
242
handler: async () => {
···
832
}
833
return null;
834
}
835
836
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
837
let everyBlockHasMark = blocks.reduce((acc, block) => {
+4
-5
components/ThemeManager/PageThemeSetter.tsx
+4
-5
components/ThemeManager/PageThemeSetter.tsx
···
3
import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter";
4
5
import {
6
-
PageBackgroundPicker,
7
PageThemePickers,
8
} from "./Pickers/PageThemePickers";
9
import { useMemo, useState } from "react";
···
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
style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }}
56
>
57
-
<PageBackgroundPicker
58
entityID={props.entityID}
59
openPicker={openPicker}
60
-
setOpenPicker={(pickers) => setOpenPicker(pickers)}
61
-
setValue={set("theme/card-background")}
62
/>
63
</div>
64
···
147
<div
148
className={
149
pageBorderHidden
150
-
? "py-2 px-0 border border-transparent"
151
: `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent`
152
}
153
style={
···
3
import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter";
4
5
import {
6
+
SubpageBackgroundPicker,
7
PageThemePickers,
8
} from "./Pickers/PageThemePickers";
9
import { useMemo, useState } from "react";
···
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
style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }}
56
>
57
+
<SubpageBackgroundPicker
58
entityID={props.entityID}
59
openPicker={openPicker}
60
+
setOpenPicker={setOpenPicker}
61
/>
62
</div>
63
···
146
<div
147
className={
148
pageBorderHidden
149
+
? "relative py-2 px-0 border border-transparent"
150
: `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent`
151
}
152
style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
+6
components/ThemeManager/Pickers/ColorPicker.tsx
···
21
22
export const ColorPicker = (props: {
23
label?: string;
24
+
helpText?: string;
25
value: Color | undefined;
26
alpha?: boolean;
27
image?: boolean;
···
117
<div className="w-full flex flex-col gap-2 px-1 pb-2">
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
+
)}
125
<ColorArea
126
className="w-full h-[128px] rounded-md"
127
colorSpace="hsb"
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
···
73
});
74
}}
75
>
76
-
<div className="flex flex-col gap-2 w-full">
77
<div className="flex gap-2">
78
<div
79
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
122
}}
123
>
124
<Slider.Track
125
-
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
126
></Slider.Track>
127
<Slider.Thumb
128
className={`
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]"} `}
132
aria-label="Volume"
133
/>
134
</Slider.Root>
···
73
});
74
}}
75
>
76
+
<div className="flex flex-col w-full">
77
<div className="flex gap-2">
78
<div
79
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
122
}}
123
>
124
<Slider.Track
125
+
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
126
></Slider.Track>
127
<Slider.Thumb
128
className={`
129
flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
130
+
${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "}
131
+
`}
132
aria-label="Volume"
133
/>
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
<hr className="border-border-light w-full" />
52
</>
53
)}
54
-
<PageTextPicker
55
value={primaryValue}
56
setValue={set("theme/primary")}
57
openPicker={props.openPicker}
···
61
);
62
};
63
64
-
export const PageBackgroundPicker = (props: {
65
entityID: string;
66
-
setValue: (c: Color) => void;
67
openPicker: pickers;
68
setOpenPicker: (p: pickers) => void;
69
-
home?: boolean;
70
}) => {
71
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
72
let pageBGImage = useEntity(props.entityID, "theme/card-background-image");
73
-
let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
74
75
return (
76
<>
77
-
{pageBGImage && pageBGImage !== null && (
78
-
<PageBackgroundImagePicker
79
-
disabled={pageBorderHidden?.data.value}
80
entityID={props.entityID}
81
-
thisPicker={"page-background-image"}
82
openPicker={props.openPicker}
83
setOpenPicker={props.setOpenPicker}
84
-
closePicker={() => props.setOpenPicker("null")}
85
-
setValue={props.setValue}
86
-
home={props.home}
87
/>
88
)}
89
<div className="relative">
90
-
<PageBackgroundColorPicker
91
-
label={pageBorderHidden?.data.value ? "Menus" : "Page"}
92
value={pageValue}
93
-
setValue={props.setValue}
94
-
thisPicker={"page"}
95
openPicker={props.openPicker}
96
setOpenPicker={props.setOpenPicker}
97
alpha
98
/>
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
-
>
107
<BlockImageSmall />
108
<div className="hidden">
109
<ImageInput
···
119
);
120
};
121
122
export const PageBackgroundColorPicker = (props: {
123
disabled?: boolean;
124
label: string;
···
128
setValue: (c: Color) => void;
129
value: Color;
130
alpha?: boolean;
131
}) => {
132
return (
133
<ColorPicker
134
disabled={props.disabled}
135
label={props.label}
136
value={props.value}
137
setValue={props.setValue}
138
thisPicker={"page"}
···
347
);
348
};
349
350
-
export const PageTextPicker = (props: {
351
openPicker: pickers;
352
setOpenPicker: (thisPicker: pickers) => void;
353
value: Color;
···
394
395
return (
396
<>
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
-
>
412
<div className="font-bold">Page Background</div>
413
<div className="italic text-[#8C8C8C]">
414
-
{pageBorderHidden ? "hidden" : ""}
415
</div>
416
-
</button>
417
-
</div>
418
</>
419
);
420
};
···
51
<hr className="border-border-light w-full" />
52
</>
53
)}
54
+
<TextPickers
55
value={primaryValue}
56
setValue={set("theme/primary")}
57
openPicker={props.openPicker}
···
61
);
62
};
63
64
+
// Page background picker for subpages - shows Page/Containers color with optional background image
65
+
export const SubpageBackgroundPicker = (props: {
66
entityID: string;
67
openPicker: pickers;
68
setOpenPicker: (p: pickers) => void;
69
}) => {
70
+
let { rep, rootEntity } = useReplicache();
71
+
let set = useMemo(() => {
72
+
return setColorAttribute(rep, props.entityID);
73
+
}, [rep, props.entityID]);
74
+
75
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
76
let pageBGImage = useEntity(props.entityID, "theme/card-background-image");
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
+
}
105
106
return (
107
<>
108
+
{pageBGImage && (
109
+
<SubpageBackgroundImagePicker
110
entityID={props.entityID}
111
openPicker={props.openPicker}
112
setOpenPicker={props.setOpenPicker}
113
+
setValue={set("theme/card-background")}
114
/>
115
)}
116
<div className="relative">
117
+
<ColorPicker
118
+
label={label}
119
value={pageValue}
120
+
setValue={set("theme/card-background")}
121
+
thisPicker="page"
122
openPicker={props.openPicker}
123
setOpenPicker={props.setOpenPicker}
124
+
closePicker={() => props.setOpenPicker("null")}
125
alpha
126
/>
127
+
{!pageBGImage && (
128
+
<label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
129
<BlockImageSmall />
130
<div className="hidden">
131
<ImageInput
···
141
);
142
};
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
+
436
export const PageBackgroundColorPicker = (props: {
437
disabled?: boolean;
438
label: string;
···
442
setValue: (c: Color) => void;
443
value: Color;
444
alpha?: boolean;
445
+
helpText?: string;
446
}) => {
447
return (
448
<ColorPicker
449
disabled={props.disabled}
450
label={props.label}
451
+
helpText={props.helpText}
452
value={props.value}
453
setValue={props.setValue}
454
thisPicker={"page"}
···
663
);
664
};
665
666
+
export const TextPickers = (props: {
667
openPicker: pickers;
668
setOpenPicker: (thisPicker: pickers) => void;
669
value: Color;
···
710
711
return (
712
<>
713
+
<Toggle
714
+
toggle={!pageBorderHidden}
715
+
onToggle={() => {
716
+
handleToggle();
717
+
}}
718
+
disabledColor1="#8C8C8C"
719
+
disabledColor2="#DBDBDB"
720
+
>
721
+
<div className="flex gap-2">
722
<div className="font-bold">Page Background</div>
723
<div className="italic text-[#8C8C8C]">
724
+
{pageBorderHidden ? "none" : ""}
725
</div>
726
+
</div>
727
+
</Toggle>
728
</>
729
);
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
hasPageBackground: boolean;
25
setHasPageBackground: (s: boolean) => void;
26
}) => {
27
return (
28
<>
29
{props.bgImage && props.bgImage !== null ? (
···
83
)}
84
</div>
85
)}
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
-
/>
95
<hr className="border-border-light" />
96
<div className="flex gap-2 items-center">
97
<Toggle
98
-
toggleOn={props.hasPageBackground}
99
-
setToggleOn={() => {
100
props.setHasPageBackground(!props.hasPageBackground);
101
props.hasPageBackground &&
102
props.openPicker === "page" &&
···
104
}}
105
disabledColor1="#8C8C8C"
106
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
>
115
-
<div className="font-bold">Page Background</div>
116
-
<div className="italic text-[#8C8C8C]">
117
-
{props.hasPageBackground ? "" : "hidden"}
118
</div>
119
-
</button>
120
</div>
121
</>
122
);
···
250
props.setBgImage({ ...props.bgImage, repeat: 500 });
251
}}
252
>
253
-
<div className="flex flex-col gap-2 w-full">
254
<div className="flex gap-2">
255
<div
256
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
289
}}
290
>
291
<Slider.Track
292
-
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
293
></Slider.Track>
294
<Slider.Thumb
295
className={`
···
24
hasPageBackground: boolean;
25
setHasPageBackground: (s: boolean) => void;
26
}) => {
27
+
// When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker
28
+
let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage;
29
+
30
return (
31
<>
32
{props.bgImage && props.bgImage !== null ? (
···
86
)}
87
</div>
88
)}
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
+
)}
106
<hr className="border-border-light" />
107
<div className="flex gap-2 items-center">
108
<Toggle
109
+
toggle={props.hasPageBackground}
110
+
onToggle={() => {
111
props.setHasPageBackground(!props.hasPageBackground);
112
props.hasPageBackground &&
113
props.openPicker === "page" &&
···
115
}}
116
disabledColor1="#8C8C8C"
117
disabledColor2="#DBDBDB"
118
>
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>
124
</div>
125
+
</Toggle>
126
</div>
127
</>
128
);
···
256
props.setBgImage({ ...props.bgImage, repeat: 500 });
257
}}
258
>
259
+
<div className="flex flex-col w-full">
260
<div className="flex gap-2">
261
<div
262
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
295
}}
296
>
297
<Slider.Track
298
+
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
299
></Slider.Track>
300
<Slider.Thumb
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
import { pickers } from "../ThemeSetter";
2
-
import { PageTextPicker } from "../Pickers/PageThemePickers";
3
import { Color } from "react-aria-components";
4
5
export const PagePickers = (props: {
···
20
: "transparent",
21
}}
22
>
23
-
<PageTextPicker
24
value={props.primary}
25
setValue={props.setPrimary}
26
openPicker={props.openPicker}
···
1
import { pickers } from "../ThemeSetter";
2
+
import { TextPickers } from "../Pickers/PageThemePickers";
3
import { Color } from "react-aria-components";
4
5
export const PagePickers = (props: {
···
20
: "transparent",
21
}}
22
>
23
+
<TextPickers
24
value={props.primary}
25
setValue={props.setPrimary}
26
openPicker={props.openPicker}
+41
-8
components/ThemeManager/PubThemeSetter.tsx
+41
-8
components/ThemeManager/PubThemeSetter.tsx
···
15
import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
16
import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17
import { Separator } from "components/Layout";
18
-
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings";
19
import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
20
21
export type ImageState = {
22
src: string;
···
54
}
55
: null,
56
);
57
-
58
let pubBGImage = image?.src || null;
59
let leafletBGRepeat = image?.repeat || null;
60
61
return (
62
-
<BaseThemeProvider local {...localPubTheme}>
63
<form
64
onSubmit={async (e) => {
65
e.preventDefault();
···
75
: ColorToRGB(localPubTheme.bgLeaflet),
76
backgroundRepeat: image?.repeat,
77
backgroundImage: image ? image.file : null,
78
primary: ColorToRGB(localPubTheme.primary),
79
accentBackground: ColorToRGB(localPubTheme.accent1),
80
accentText: ColorToRGB(localPubTheme.accent2),
81
},
82
});
83
mutate((pub) => {
84
-
if (result?.publication && pub?.publication)
85
return {
86
...pub,
87
publication: { ...pub.publication, ...result.publication },
···
96
setLoadingAction={props.setLoading}
97
backToMenuAction={props.backToMenu}
98
state={"theme"}
99
-
/>
100
</form>
101
102
-
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 ">
103
-
<div className="themeBGLeaflet flex">
104
<div
105
-
className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
106
>
107
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
108
<BackgroundPicker
···
15
import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
16
import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17
import { Separator } from "components/Layout";
18
+
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings";
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";
23
24
export type ImageState = {
25
src: string;
···
57
}
58
: null,
59
);
60
+
let [pageWidth, setPageWidth] = useState<number>(
61
+
record?.theme?.pageWidth || 624,
62
+
);
63
let pubBGImage = image?.src || null;
64
let leafletBGRepeat = image?.repeat || null;
65
+
let toaster = useToaster();
66
67
return (
68
+
<BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}>
69
<form
70
onSubmit={async (e) => {
71
e.preventDefault();
···
81
: ColorToRGB(localPubTheme.bgLeaflet),
82
backgroundRepeat: image?.repeat,
83
backgroundImage: image ? image.file : null,
84
+
pageWidth: pageWidth,
85
primary: ColorToRGB(localPubTheme.primary),
86
accentBackground: ColorToRGB(localPubTheme.accent1),
87
accentText: ColorToRGB(localPubTheme.accent2),
88
},
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
+
107
mutate((pub) => {
108
+
if (result.publication && pub?.publication)
109
return {
110
...pub,
111
publication: { ...pub.publication, ...result.publication },
···
120
setLoadingAction={props.setLoading}
121
backToMenuAction={props.backToMenu}
122
state={"theme"}
123
+
>
124
+
Theme and Layout
125
+
</PubSettingsHeader>
126
</form>
127
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">
137
<div
138
+
className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
139
>
140
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
141
<BackgroundPicker
+18
-10
components/ThemeManager/PublicationThemeProvider.tsx
+18
-10
components/ThemeManager/PublicationThemeProvider.tsx
···
2
import { useMemo, useState } from "react";
3
import { parseColor } from "react-aria-components";
4
import { useEntity } from "src/replicache";
5
-
import { getColorContrast } from "./themeUtils";
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
102
pub_creator: string;
103
isStandalone?: boolean;
104
}) {
105
-
let colors = usePubTheme(props.theme, props.isStandalone);
106
-
let cardBorderHidden = !colors.showPageBackground;
107
return (
108
<CardBorderHiddenContext.Provider value={cardBorderHidden}>
109
-
<BaseThemeProvider local={props.local} {...colors}>
110
{props.children}
111
</BaseThemeProvider>
112
</CardBorderHiddenContext.Provider>
···
127
bgPage = bgLeaflet;
128
}
129
let showPageBackground = theme?.showPageBackground;
130
131
let primary = useColor(theme, "primary");
132
···
147
highlight2,
148
highlight3,
149
showPageBackground,
150
};
151
};
152
···
166
let newAccentContrast;
167
let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => {
168
return (
169
-
getColorContrast(
170
colorToString(b, "rgb"),
171
colorToString(
172
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
173
"rgb",
174
),
175
) -
176
-
getColorContrast(
177
colorToString(a, "rgb"),
178
colorToString(
179
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
···
183
);
184
});
185
if (
186
-
getColorContrast(
187
colorToString(sortedAccents[0], "rgb"),
188
colorToString(newTheme.primary, "rgb"),
189
-
) < 30 &&
190
-
getColorContrast(
191
colorToString(sortedAccents[1], "rgb"),
192
colorToString(
193
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
194
"rgb",
195
),
196
-
) > 12
197
) {
198
newAccentContrast = sortedAccents[1];
199
} else newAccentContrast = sortedAccents[0];
···
2
import { useMemo, useState } from "react";
3
import { parseColor } from "react-aria-components";
4
import { useEntity } from "src/replicache";
5
+
import { getColorDifference } from "./themeUtils";
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
102
pub_creator: string;
103
isStandalone?: boolean;
104
}) {
105
+
let theme = usePubTheme(props.theme, props.isStandalone);
106
+
let cardBorderHidden = !theme.showPageBackground;
107
+
let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref;
108
+
109
return (
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>
···
133
bgPage = bgLeaflet;
134
}
135
let showPageBackground = theme?.showPageBackground;
136
+
let pageWidth = theme?.pageWidth;
137
138
let primary = useColor(theme, "primary");
139
···
154
highlight2,
155
highlight3,
156
showPageBackground,
157
+
pageWidth,
158
};
159
};
160
···
174
let newAccentContrast;
175
let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => {
176
return (
177
+
getColorDifference(
178
colorToString(b, "rgb"),
179
colorToString(
180
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
181
"rgb",
182
),
183
) -
184
+
getColorDifference(
185
colorToString(a, "rgb"),
186
colorToString(
187
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
···
191
);
192
});
193
if (
194
+
getColorDifference(
195
colorToString(sortedAccents[0], "rgb"),
196
colorToString(newTheme.primary, "rgb"),
197
+
) < 0.15 &&
198
+
getColorDifference(
199
colorToString(sortedAccents[1], "rgb"),
200
colorToString(
201
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
202
"rgb",
203
),
204
+
) > 0.08
205
) {
206
newAccentContrast = sortedAccents[1];
207
} else newAccentContrast = sortedAccents[0];
+34
-10
components/ThemeManager/ThemeProvider.tsx
+34
-10
components/ThemeManager/ThemeProvider.tsx
···
22
PublicationThemeProvider,
23
} from "./PublicationThemeProvider";
24
import { PubLeafletPublication } from "lexicons/api";
25
-
import { getColorContrast } from "./themeUtils";
26
27
// define a function to set an Aria Color to a CSS Variable in RGB
28
function setCSSVariableToColor(
···
65
"theme/card-border-hidden",
66
)?.data.value;
67
let showPageBackground = !cardBorderHiddenValue;
68
let primary = useColorAttribute(props.entityID, "theme/primary");
69
70
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
73
74
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
75
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
76
77
return (
78
<CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
···
87
accent1={accent1}
88
accent2={accent2}
89
showPageBackground={showPageBackground}
90
>
91
{props.children}
92
</BaseThemeProvider>
···
98
export const BaseThemeProvider = ({
99
local,
100
bgLeaflet,
101
-
bgPage,
102
primary,
103
accent1,
104
accent2,
···
106
highlight2,
107
highlight3,
108
showPageBackground,
109
children,
110
}: {
111
local?: boolean;
112
showPageBackground?: boolean;
113
bgLeaflet: AriaColor;
114
bgPage: AriaColor;
115
primary: AriaColor;
···
118
highlight1?: string;
119
highlight2: AriaColor;
120
highlight3: AriaColor;
121
children: React.ReactNode;
122
}) => {
123
// set accent contrast to the accent color that has the highest contrast with the page background
124
let accentContrast;
125
126
//sorting the accents by contrast on background
127
let sortedAccents = [accent1, accent2].sort((a, b) => {
128
return (
129
-
getColorContrast(
130
colorToString(b, "rgb"),
131
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
132
) -
133
-
getColorContrast(
134
colorToString(a, "rgb"),
135
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
136
)
···
142
// then use the not contrasty option
143
144
if (
145
-
getColorContrast(
146
colorToString(sortedAccents[0], "rgb"),
147
colorToString(primary, "rgb"),
148
-
) < 30 &&
149
-
getColorContrast(
150
colorToString(sortedAccents[1], "rgb"),
151
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
152
-
) > 12
153
) {
154
accentContrast = sortedAccents[1];
155
} else accentContrast = sortedAccents[0];
···
196
"--accent-1-is-contrast",
197
accentContrast === accent1 ? "1" : "0",
198
);
199
}, [
200
local,
201
bgLeaflet,
···
207
accent1,
208
accent2,
209
accentContrast,
210
]);
211
return (
212
<div
···
226
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
227
"--highlight-2": colorToString(highlight2, "rgb"),
228
"--highlight-3": colorToString(highlight3, "rgb"),
229
} as CSSProperties
230
}
231
>
···
262
bgPage && accent1 && accent2
263
? [accent1, accent2].sort((a, b) => {
264
return (
265
-
getColorContrast(
266
colorToString(b, "rgb"),
267
colorToString(bgPage, "rgb"),
268
) -
269
-
getColorContrast(
270
colorToString(a, "rgb"),
271
colorToString(bgPage, "rgb"),
272
)
···
22
PublicationThemeProvider,
23
} from "./PublicationThemeProvider";
24
import { PubLeafletPublication } from "lexicons/api";
25
+
import { getColorDifference } from "./themeUtils";
26
27
// define a function to set an Aria Color to a CSS Variable in RGB
28
function setCSSVariableToColor(
···
65
"theme/card-border-hidden",
66
)?.data.value;
67
let showPageBackground = !cardBorderHiddenValue;
68
+
let backgroundImage = useEntity(props.entityID, "theme/background-image");
69
+
let hasBackgroundImage = !!backgroundImage;
70
let primary = useColorAttribute(props.entityID, "theme/primary");
71
72
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
75
76
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
77
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
78
+
79
+
let pageWidth = useEntity(props.entityID, "theme/page-width");
80
81
return (
82
<CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
···
91
accent1={accent1}
92
accent2={accent2}
93
showPageBackground={showPageBackground}
94
+
pageWidth={pageWidth?.data.value}
95
+
hasBackgroundImage={hasBackgroundImage}
96
>
97
{props.children}
98
</BaseThemeProvider>
···
104
export const BaseThemeProvider = ({
105
local,
106
bgLeaflet,
107
+
bgPage: bgPageProp,
108
primary,
109
accent1,
110
accent2,
···
112
highlight2,
113
highlight3,
114
showPageBackground,
115
+
pageWidth,
116
+
hasBackgroundImage,
117
children,
118
}: {
119
local?: boolean;
120
showPageBackground?: boolean;
121
+
hasBackgroundImage?: boolean;
122
bgLeaflet: AriaColor;
123
bgPage: AriaColor;
124
primary: AriaColor;
···
127
highlight1?: string;
128
highlight2: AriaColor;
129
highlight3: AriaColor;
130
+
pageWidth?: number;
131
children: React.ReactNode;
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;
137
// set accent contrast to the accent color that has the highest contrast with the page background
138
let accentContrast;
139
140
//sorting the accents by contrast on background
141
let sortedAccents = [accent1, accent2].sort((a, b) => {
142
return (
143
+
getColorDifference(
144
colorToString(b, "rgb"),
145
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
146
) -
147
+
getColorDifference(
148
colorToString(a, "rgb"),
149
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
150
)
···
156
// then use the not contrasty option
157
158
if (
159
+
getColorDifference(
160
colorToString(sortedAccents[0], "rgb"),
161
colorToString(primary, "rgb"),
162
+
) < 0.15 &&
163
+
getColorDifference(
164
colorToString(sortedAccents[1], "rgb"),
165
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
166
+
) > 0.08
167
) {
168
accentContrast = sortedAccents[1];
169
} else accentContrast = sortedAccents[0];
···
210
"--accent-1-is-contrast",
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(),
218
+
);
219
}, [
220
local,
221
bgLeaflet,
···
227
accent1,
228
accent2,
229
accentContrast,
230
+
pageWidth,
231
]);
232
return (
233
<div
···
247
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
248
"--highlight-2": colorToString(highlight2, "rgb"),
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))`,
253
} as CSSProperties
254
}
255
>
···
286
bgPage && accent1 && accent2
287
? [accent1, accent2].sort((a, b) => {
288
return (
289
+
getColorDifference(
290
colorToString(b, "rgb"),
291
colorToString(bgPage, "rgb"),
292
) -
293
+
getColorDifference(
294
colorToString(a, "rgb"),
295
colorToString(bgPage, "rgb"),
296
)
+21
-35
components/ThemeManager/ThemeSetter.tsx
+21
-35
components/ThemeManager/ThemeSetter.tsx
···
1
"use client";
2
import { Popover } from "components/Popover";
3
-
import { theme } from "../../tailwind.config";
4
5
import { Color } from "react-aria-components";
6
7
-
import { LeafletBGPicker } from "./Pickers/LeafletBGPicker";
8
import {
9
-
PageBackgroundPicker,
10
-
PageBorderHider,
11
PageThemePickers,
12
} from "./Pickers/PageThemePickers";
13
import { useMemo, useState } from "react";
14
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
15
import { Replicache } from "replicache";
···
35
| "highlight-1"
36
| "highlight-2"
37
| "highlight-3"
38
-
| "page-background-image";
39
40
export function setColorAttribute(
41
rep: Replicache<ReplicacheMutators> | null,
···
75
return (
76
<>
77
<Popover
78
-
className="w-80 bg-white"
79
arrowFill="#FFFFFF"
80
asChild
81
side={isMobile ? "top" : "right"}
···
114
if (pub?.publications) return null;
115
return (
116
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar">
117
<div className="themeBGLeaflet flex">
118
<div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}>
119
<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
137
entityID={props.entityID}
138
openPicker={openPicker}
139
setOpenPicker={setOpenPicker}
···
173
setOpenPicker={(pickers) => setOpenPicker(pickers)}
174
/>
175
<SectionArrow
176
-
fill={theme.colors["accent-2"]}
177
-
stroke={theme.colors["accent-1"]}
178
className="ml-2"
179
/>
180
</div>
···
209
return (
210
<div className="flex gap-2 items-start mt-0.5">
211
<Toggle
212
-
toggleOn={!!checked?.data.value}
213
-
setToggleOn={() => {
214
handleToggle();
215
}}
216
disabledColor1="#8C8C8C"
217
disabledColor2="#DBDBDB"
218
-
/>
219
-
<button
220
-
className="flex gap-2 items-center -mt-0.5"
221
-
onClick={() => {
222
-
handleToggle();
223
-
}}
224
>
225
-
<div className="flex flex-col gap-0 items-start">
226
<div className="font-bold">Show Leaflet Watermark</div>
227
<div className="text-sm text-[#969696]">Help us spread the word!</div>
228
</div>
229
-
</button>
230
</div>
231
);
232
}
···
1
"use client";
2
import { Popover } from "components/Popover";
3
4
import { Color } from "react-aria-components";
5
6
import {
7
+
LeafletBackgroundPicker,
8
PageThemePickers,
9
} from "./Pickers/PageThemePickers";
10
+
import { PageWidthSetter } from "./Pickers/PageWidthSetter";
11
import { useMemo, useState } from "react";
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13
import { Replicache } from "replicache";
···
33
| "highlight-1"
34
| "highlight-2"
35
| "highlight-3"
36
+
| "page-background-image"
37
+
| "page-width";
38
39
export function setColorAttribute(
40
rep: Replicache<ReplicacheMutators> | null,
···
74
return (
75
<>
76
<Popover
77
+
className="w-80 bg-white py-3!"
78
arrowFill="#FFFFFF"
79
asChild
80
side={isMobile ? "top" : "right"}
···
113
if (pub?.publications) return null;
114
return (
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
+
)}
125
<div className="themeBGLeaflet flex">
126
<div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}>
127
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md">
128
+
<LeafletBackgroundPicker
129
entityID={props.entityID}
130
openPicker={openPicker}
131
setOpenPicker={setOpenPicker}
···
165
setOpenPicker={(pickers) => setOpenPicker(pickers)}
166
/>
167
<SectionArrow
168
+
fill="rgb(var(--accent-2))"
169
+
stroke="rgb(var(--accent-1))"
170
className="ml-2"
171
/>
172
</div>
···
201
return (
202
<div className="flex gap-2 items-start mt-0.5">
203
<Toggle
204
+
toggle={!!checked?.data.value}
205
+
onToggle={() => {
206
handleToggle();
207
}}
208
disabledColor1="#8C8C8C"
209
disabledColor2="#DBDBDB"
210
>
211
+
<div className="flex flex-col gap-0 items-start ">
212
<div className="font-bold">Show Leaflet Watermark</div>
213
<div className="text-sm text-[#969696]">Help us spread the word!</div>
214
</div>
215
+
</Toggle>
216
</div>
217
);
218
}
+4
-3
components/ThemeManager/themeUtils.ts
+4
-3
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 = {
···
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
+
import { parse, ColorSpace, sRGB, distance, OKLab } from "colorjs.io/fn";
2
3
// define the color defaults for everything
4
export const ThemeDefaults = {
···
17
};
18
19
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
20
+
export function getColorDifference(color1: string, color2: string) {
21
ColorSpace.register(sRGB);
22
+
ColorSpace.register(OKLab);
23
24
let parsedColor1 = parse(`rgb(${color1})`);
25
let parsedColor2 = parse(`rgb(${color2})`);
26
27
+
return distance(parsedColor1, parsedColor2, "oklab");
28
}
+32
-20
components/Toggle.tsx
+32
-20
components/Toggle.tsx
···
1
import { theme } from "tailwind.config";
2
3
export const Toggle = (props: {
4
-
toggleOn: boolean;
5
-
setToggleOn: (s: boolean) => void;
6
disabledColor1?: string;
7
disabledColor2?: string;
8
}) => {
9
return (
10
<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"],
20
}}
21
-
onClick={() => props.setToggleOn(!props.toggleOn)}
22
>
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
-
/>
31
</button>
32
);
33
};
···
1
import { theme } from "tailwind.config";
2
3
export const Toggle = (props: {
4
+
toggle: boolean;
5
+
onToggle: () => void;
6
disabledColor1?: string;
7
disabledColor2?: string;
8
+
children: React.ReactNode;
9
}) => {
10
return (
11
<button
12
+
type="button"
13
+
className="toggle flex gap-2 items-start justify-start text-left"
14
+
onClick={() => {
15
+
props.onToggle();
16
}}
17
>
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}
43
</button>
44
);
45
};
+10
-5
components/Toolbar/BlockToolbar.tsx
+10
-5
components/Toolbar/BlockToolbar.tsx
···
5
import { useUIState } from "src/useUIState";
6
import { LockBlockButton } from "./LockBlockButton";
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
-
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
···
37
>
38
<DeleteSmall />
39
</ToolbarButton>
40
-
<Separator classname="h-6" />
41
<MoveBlockButtons />
42
{blockType === "image" && (
43
<>
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
<ImageFullBleedButton />
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
{focusedEntityType?.data.value !== "canvas" && (
48
-
<Separator classname="h-6" />
49
)}
50
</>
51
)}
···
53
<>
54
<TextAlignmentButton setToolbarState={props.setToolbarState} />
55
{focusedEntityType?.data.value !== "canvas" && (
56
-
<Separator classname="h-6" />
57
)}
58
</>
59
)}
···
174
>
175
<MoveBlockDown />
176
</ToolbarButton>
177
-
<Separator classname="h-6" />
178
</>
179
);
180
};
···
5
import { useUIState } from "src/useUIState";
6
import { LockBlockButton } from "./LockBlockButton";
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
+
import {
9
+
ImageFullBleedButton,
10
+
ImageAltTextButton,
11
+
ImageCoverButton,
12
+
} from "./ImageToolbar";
13
import { DeleteSmall } from "components/Icons/DeleteSmall";
14
import { getSortedSelection } from "components/SelectionManager/selectionState";
15
···
41
>
42
<DeleteSmall />
43
</ToolbarButton>
44
+
<Separator classname="h-6!" />
45
<MoveBlockButtons />
46
{blockType === "image" && (
47
<>
48
<TextAlignmentButton setToolbarState={props.setToolbarState} />
49
<ImageFullBleedButton />
50
<ImageAltTextButton setToolbarState={props.setToolbarState} />
51
+
<ImageCoverButton />
52
{focusedEntityType?.data.value !== "canvas" && (
53
+
<Separator classname="h-6!" />
54
)}
55
</>
56
)}
···
58
<>
59
<TextAlignmentButton setToolbarState={props.setToolbarState} />
60
{focusedEntityType?.data.value !== "canvas" && (
61
+
<Separator classname="h-6!" />
62
)}
63
</>
64
)}
···
179
>
180
<MoveBlockDown />
181
</ToolbarButton>
182
+
<Separator classname="h-6!" />
183
</>
184
);
185
};
+1
-1
components/Toolbar/HighlightToolbar.tsx
+1
-1
components/Toolbar/HighlightToolbar.tsx
+37
components/Toolbar/ImageToolbar.tsx
+37
components/Toolbar/ImageToolbar.tsx
···
4
import { useUIState } from "src/useUIState";
5
import { Props } from "components/Icons/Props";
6
import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt";
7
8
export const ImageFullBleedButton = (props: {}) => {
9
let { rep } = useReplicache();
···
76
) : (
77
<ImageRemoveAltSmall />
78
)}
79
</ToolbarButton>
80
);
81
};
···
4
import { useUIState } from "src/useUIState";
5
import { Props } from "components/Icons/Props";
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";
10
11
export const ImageFullBleedButton = (props: {}) => {
12
let { rep } = useReplicache();
···
79
) : (
80
<ImageRemoveAltSmall />
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 />
116
</ToolbarButton>
117
);
118
};
+1
-1
components/Toolbar/InlineLinkToolbar.tsx
+1
-1
components/Toolbar/InlineLinkToolbar.tsx
+2
-2
components/Toolbar/ListToolbar.tsx
+2
-2
components/Toolbar/ListToolbar.tsx
···
131
>
132
<ListIndentIncreaseSmall />
133
</ToolbarButton>
134
-
<Separator classname="h-6" />
135
<ToolbarButton
136
disabled={!isList?.data.value}
137
tooltipContent=<div className="flex flex-col gap-1 justify-center">
138
<div className="text-center">Add a Checkbox</div>
139
<div className="flex gap-1 font-normal">
140
-
start line with <ShortcutKey>[</ShortcutKey>
141
<ShortcutKey>]</ShortcutKey>
142
</div>
143
</div>
···
131
>
132
<ListIndentIncreaseSmall />
133
</ToolbarButton>
134
+
<Separator classname="h-6!" />
135
<ToolbarButton
136
disabled={!isList?.data.value}
137
tooltipContent=<div className="flex flex-col gap-1 justify-center">
138
<div className="text-center">Add a Checkbox</div>
139
<div className="flex gap-1 font-normal">
140
+
<ShortcutKey>[</ShortcutKey>
141
<ShortcutKey>]</ShortcutKey>
142
</div>
143
</div>
+154
-95
components/Toolbar/TextBlockTypeToolbar.tsx
+154
-95
components/Toolbar/TextBlockTypeToolbar.tsx
···
4
Header3Small,
5
} from "components/Icons/BlockTextSmall";
6
import { Props } from "components/Icons/Props";
7
-
import { ShortcutKey } from "components/Layout";
8
import { ToolbarButton } from "components/Toolbar";
9
import { TextSelection } from "prosemirror-state";
10
import { useCallback } from "react";
···
22
focusedBlock?.entityID || null,
23
"block/heading-level",
24
);
25
let { rep } = useReplicache();
26
27
let setLevel = useCallback(
···
51
);
52
return (
53
// This Toolbar should close once the user starts typing again
54
-
<div className="flex w-full justify-between items-center gap-4">
55
-
<div className="flex items-center gap-[6px]">
56
-
<ToolbarButton
57
-
className={props.className}
58
-
onClick={() => {
59
-
setLevel(1);
60
-
}}
61
-
active={
62
-
blockType?.data.value === "heading" &&
63
-
headingLevel?.data.value === 1
64
-
}
65
-
tooltipContent={
66
-
<div className="flex flex-col justify-center">
67
-
<div className="font-bold text-center">Title</div>
68
-
<div className="flex gap-1 font-normal">
69
-
start line with
70
-
<ShortcutKey>#</ShortcutKey>
71
-
</div>
72
</div>
73
-
}
74
-
>
75
-
<Header1Small />
76
-
</ToolbarButton>
77
-
<ToolbarButton
78
-
className={props.className}
79
-
onClick={() => {
80
-
setLevel(2);
81
-
}}
82
-
active={
83
-
blockType?.data.value === "heading" &&
84
-
headingLevel?.data.value === 2
85
-
}
86
-
tooltipContent={
87
-
<div className="flex flex-col justify-center">
88
-
<div className="font-bold text-center">Heading</div>
89
-
<div className="flex gap-1 font-normal">
90
-
start line with
91
-
<ShortcutKey>##</ShortcutKey>
92
-
</div>
93
</div>
94
}
95
-
>
96
-
<Header2Small />
97
-
</ToolbarButton>
98
-
<ToolbarButton
99
-
className={props.className}
100
-
onClick={() => {
101
-
setLevel(3);
102
-
}}
103
-
active={
104
-
blockType?.data.value === "heading" &&
105
-
headingLevel?.data.value === 3
106
-
}
107
-
tooltipContent={
108
-
<div className="flex flex-col justify-center">
109
-
<div className="font-bold text-center">Subheading</div>
110
-
<div className="flex gap-1 font-normal">
111
-
start line with
112
-
<ShortcutKey>###</ShortcutKey>
113
-
</div>
114
-
</div>
115
}
116
-
>
117
-
<Header3Small />
118
-
</ToolbarButton>
119
-
<ToolbarButton
120
-
className={`px-[6px] ${props.className}`}
121
-
onClick={async () => {
122
if (headingLevel)
123
await rep?.mutate.retractFact({ factID: headingLevel.id });
124
-
if (!focusedBlock || !blockType) return;
125
-
if (blockType.data.value !== "text") {
126
-
let existingEditor =
127
-
useEditorStates.getState().editorStates[focusedBlock.entityID];
128
-
let selection = existingEditor?.editor.selection;
129
-
await rep?.mutate.assertFact({
130
-
entity: focusedBlock?.entityID,
131
-
attribute: "block/type",
132
-
data: { type: "block-type-union", value: "text" },
133
-
});
134
-
135
-
let newEditor =
136
-
useEditorStates.getState().editorStates[focusedBlock.entityID];
137
-
if (!newEditor || !selection) return;
138
-
newEditor.view?.dispatch(
139
-
newEditor.editor.tr.setSelection(
140
-
TextSelection.create(newEditor.editor.doc, selection.anchor),
141
-
),
142
-
);
143
-
144
-
newEditor.view?.focus();
145
-
}
146
-
}}
147
-
active={blockType?.data.value === "text"}
148
-
tooltipContent={<div>Paragraph</div>}
149
-
>
150
-
Paragraph
151
-
</ToolbarButton>
152
-
</div>
153
-
</div>
154
);
155
};
156
···
4
Header3Small,
5
} from "components/Icons/BlockTextSmall";
6
import { Props } from "components/Icons/Props";
7
+
import { ShortcutKey, Separator } from "components/Layout";
8
import { ToolbarButton } from "components/Toolbar";
9
import { TextSelection } from "prosemirror-state";
10
import { useCallback } from "react";
···
22
focusedBlock?.entityID || null,
23
"block/heading-level",
24
);
25
+
26
+
let textSize = useEntity(focusedBlock?.entityID || null, "block/text-size");
27
let { rep } = useReplicache();
28
29
let setLevel = useCallback(
···
53
);
54
return (
55
// This Toolbar should close once the user starts typing again
56
+
<>
57
+
<ToolbarButton
58
+
className={props.className}
59
+
onClick={() => {
60
+
setLevel(1);
61
+
}}
62
+
active={
63
+
blockType?.data.value === "heading" && headingLevel?.data.value === 1
64
+
}
65
+
tooltipContent={
66
+
<div className="flex flex-col justify-center">
67
+
<div className="font-bold text-center">Title</div>
68
+
<div className="flex gap-1 font-normal">
69
+
start line with
70
+
<ShortcutKey>#</ShortcutKey>
71
+
</div>
72
+
</div>
73
+
}
74
+
>
75
+
<Header1Small />
76
+
</ToolbarButton>
77
+
<ToolbarButton
78
+
className={props.className}
79
+
onClick={() => {
80
+
setLevel(2);
81
+
}}
82
+
active={
83
+
blockType?.data.value === "heading" && headingLevel?.data.value === 2
84
+
}
85
+
tooltipContent={
86
+
<div className="flex flex-col justify-center">
87
+
<div className="font-bold text-center">Heading</div>
88
+
<div className="flex gap-1 font-normal">
89
+
start line with
90
+
<ShortcutKey>##</ShortcutKey>
91
</div>
92
+
</div>
93
+
}
94
+
>
95
+
<Header2Small />
96
+
</ToolbarButton>
97
+
<ToolbarButton
98
+
className={props.className}
99
+
onClick={() => {
100
+
setLevel(3);
101
+
}}
102
+
active={
103
+
blockType?.data.value === "heading" && headingLevel?.data.value === 3
104
+
}
105
+
tooltipContent={
106
+
<div className="flex flex-col justify-center">
107
+
<div className="font-bold text-center">Subheading</div>
108
+
<div className="flex gap-1 font-normal">
109
+
start line with
110
+
<ShortcutKey>###</ShortcutKey>
111
</div>
112
+
</div>
113
+
}
114
+
>
115
+
<Header3Small />
116
+
</ToolbarButton>
117
+
<Separator classname="h-6!" />
118
+
<ToolbarButton
119
+
className={`px-[6px] ${props.className}`}
120
+
onClick={async () => {
121
+
if (headingLevel)
122
+
await rep?.mutate.retractFact({ factID: headingLevel.id });
123
+
if (textSize) await rep?.mutate.retractFact({ factID: textSize.id });
124
+
if (!focusedBlock || !blockType) return;
125
+
if (blockType.data.value !== "text") {
126
+
let existingEditor =
127
+
useEditorStates.getState().editorStates[focusedBlock.entityID];
128
+
let selection = existingEditor?.editor.selection;
129
+
await rep?.mutate.assertFact({
130
+
entity: focusedBlock?.entityID,
131
+
attribute: "block/type",
132
+
data: { type: "block-type-union", value: "text" },
133
+
});
134
+
135
+
let newEditor =
136
+
useEditorStates.getState().editorStates[focusedBlock.entityID];
137
+
if (!newEditor || !selection) return;
138
+
newEditor.view?.dispatch(
139
+
newEditor.editor.tr.setSelection(
140
+
TextSelection.create(newEditor.editor.doc, selection.anchor),
141
+
),
142
+
);
143
+
144
+
newEditor.view?.focus();
145
}
146
+
}}
147
+
active={
148
+
blockType?.data.value === "text" &&
149
+
textSize?.data.value !== "small" &&
150
+
textSize?.data.value !== "large"
151
+
}
152
+
tooltipContent={<div>Normal Text</div>}
153
+
>
154
+
Text
155
+
</ToolbarButton>
156
+
<ToolbarButton
157
+
className={`px-[6px] text-lg ${props.className}`}
158
+
onClick={async () => {
159
+
if (!focusedBlock || !blockType) return;
160
+
if (blockType.data.value !== "text") {
161
+
// Convert to text block first if it's a heading
162
+
if (headingLevel)
163
+
await rep?.mutate.retractFact({ factID: headingLevel.id });
164
+
await rep?.mutate.assertFact({
165
+
entity: focusedBlock.entityID,
166
+
attribute: "block/type",
167
+
data: { type: "block-type-union", value: "text" },
168
+
});
169
}
170
+
// Set text size to large
171
+
await rep?.mutate.assertFact({
172
+
entity: focusedBlock.entityID,
173
+
attribute: "block/text-size",
174
+
data: { type: "text-size-union", value: "large" },
175
+
});
176
+
}}
177
+
active={
178
+
blockType?.data.value === "text" && textSize?.data.value === "large"
179
+
}
180
+
tooltipContent={<div>Large Text</div>}
181
+
>
182
+
<div className="leading-[1.625rem]">Large</div>
183
+
</ToolbarButton>
184
+
<ToolbarButton
185
+
className={`px-[6px] text-sm text-secondary ${props.className}`}
186
+
onClick={async () => {
187
+
if (!focusedBlock || !blockType) return;
188
+
if (blockType.data.value !== "text") {
189
+
// Convert to text block first if it's a heading
190
if (headingLevel)
191
await rep?.mutate.retractFact({ factID: headingLevel.id });
192
+
await rep?.mutate.assertFact({
193
+
entity: focusedBlock.entityID,
194
+
attribute: "block/type",
195
+
data: { type: "block-type-union", value: "text" },
196
+
});
197
+
}
198
+
// Set text size to small
199
+
await rep?.mutate.assertFact({
200
+
entity: focusedBlock.entityID,
201
+
attribute: "block/text-size",
202
+
data: { type: "text-size-union", value: "small" },
203
+
});
204
+
}}
205
+
active={
206
+
blockType?.data.value === "text" && textSize?.data.value === "small"
207
+
}
208
+
tooltipContent={<div>Small Text</div>}
209
+
>
210
+
<div className="leading-[1.625rem]">Small</div>
211
+
</ToolbarButton>
212
+
</>
213
);
214
};
215
+3
-3
components/Toolbar/TextToolbar.tsx
+3
-3
components/Toolbar/TextToolbar.tsx
···
74
lastUsedHighlight={props.lastUsedHighlight}
75
setToolbarState={props.setToolbarState}
76
/>
77
-
<Separator classname="h-6" />
78
<LinkButton setToolbarState={props.setToolbarState} />
79
-
<Separator classname="h-6" />
80
<TextBlockTypeButton setToolbarState={props.setToolbarState} />
81
<TextAlignmentButton setToolbarState={props.setToolbarState} />
82
<ListButton setToolbarState={props.setToolbarState} />
83
-
<Separator classname="h-6" />
84
85
<LockBlockButton />
86
</>
···
74
lastUsedHighlight={props.lastUsedHighlight}
75
setToolbarState={props.setToolbarState}
76
/>
77
+
<Separator classname="h-6!" />
78
<LinkButton setToolbarState={props.setToolbarState} />
79
+
<Separator classname="h-6!" />
80
<TextBlockTypeButton setToolbarState={props.setToolbarState} />
81
<TextAlignmentButton setToolbarState={props.setToolbarState} />
82
<ListButton setToolbarState={props.setToolbarState} />
83
+
<Separator classname="h-6!" />
84
85
<LockBlockButton />
86
</>
+2
-2
components/utils/DotLoader.tsx
+2
-2
components/utils/DotLoader.tsx
···
1
import { useEffect, useState } from "react";
2
3
-
export function DotLoader() {
4
let [dots, setDots] = useState(1);
5
useEffect(() => {
6
let id = setInterval(() => {
···
11
};
12
}, []);
13
return (
14
-
<div className="w-[26px] h-[24px] text-center text-sm">
15
{".".repeat(dots) + "\u00a0".repeat(3 - dots)}
16
</div>
17
);
···
1
import { useEffect, useState } from "react";
2
3
+
export function DotLoader(props: { className?: string }) {
4
let [dots, setDots] = useState(1);
5
useEffect(() => {
6
let id = setInterval(() => {
···
11
};
12
}, []);
13
return (
14
+
<div className={`w-[26px] h-[24px] text-center text-sm ${props.className}`}>
15
{".".repeat(dots) + "\u00a0".repeat(3 - dots)}
16
</div>
17
);
+22
lexicons/api/lexicons.ts
+22
lexicons/api/lexicons.ts
···
1246
plaintext: {
1247
type: 'string',
1248
},
1249
facets: {
1250
type: 'array',
1251
items: {
···
1446
type: 'string',
1447
maxLength: 50,
1448
},
1449
},
1450
pages: {
1451
type: 'array',
···
1801
type: 'boolean',
1802
default: true,
1803
},
1804
},
1805
},
1806
theme: {
···
1816
backgroundImage: {
1817
type: 'ref',
1818
ref: 'lex:pub.leaflet.theme.backgroundImage',
1819
},
1820
primary: {
1821
type: 'union',
···
1246
plaintext: {
1247
type: 'string',
1248
},
1249
+
textSize: {
1250
+
type: 'string',
1251
+
enum: ['default', 'small', 'large'],
1252
+
},
1253
facets: {
1254
type: 'array',
1255
items: {
···
1450
type: 'string',
1451
maxLength: 50,
1452
},
1453
+
},
1454
+
coverImage: {
1455
+
type: 'blob',
1456
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
1457
+
maxSize: 1000000,
1458
},
1459
pages: {
1460
type: 'array',
···
1810
type: 'boolean',
1811
default: true,
1812
},
1813
+
showMentions: {
1814
+
type: 'boolean',
1815
+
default: true,
1816
+
},
1817
+
showPrevNext: {
1818
+
type: 'boolean',
1819
+
default: false,
1820
+
},
1821
},
1822
},
1823
theme: {
···
1833
backgroundImage: {
1834
type: 'ref',
1835
ref: 'lex:pub.leaflet.theme.backgroundImage',
1836
+
},
1837
+
pageWidth: {
1838
+
type: 'integer',
1839
+
minimum: 0,
1840
+
maximum: 1600,
1841
},
1842
primary: {
1843
type: 'union',
+1
lexicons/api/types/pub/leaflet/blocks/text.ts
+1
lexicons/api/types/pub/leaflet/blocks/text.ts
+1
lexicons/api/types/pub/leaflet/document.ts
+1
lexicons/api/types/pub/leaflet/document.ts
+3
lexicons/api/types/pub/leaflet/publication.ts
+3
lexicons/api/types/pub/leaflet/publication.ts
···
37
$type?: 'pub.leaflet.publication#preferences'
38
showInDiscover: boolean
39
showComments: boolean
40
}
41
42
const hashPreferences = 'preferences'
···
56
| $Typed<PubLeafletThemeColor.Rgb>
57
| { $type: string }
58
backgroundImage?: PubLeafletThemeBackgroundImage.Main
59
primary?:
60
| $Typed<PubLeafletThemeColor.Rgba>
61
| $Typed<PubLeafletThemeColor.Rgb>
···
37
$type?: 'pub.leaflet.publication#preferences'
38
showInDiscover: boolean
39
showComments: boolean
40
+
showMentions: boolean
41
+
showPrevNext: boolean
42
}
43
44
const hashPreferences = 'preferences'
···
58
| $Typed<PubLeafletThemeColor.Rgb>
59
| { $type: string }
60
backgroundImage?: PubLeafletThemeBackgroundImage.Main
61
+
pageWidth?: number
62
primary?:
63
| $Typed<PubLeafletThemeColor.Rgba>
64
| $Typed<PubLeafletThemeColor.Rgb>
+2
lexicons/build.ts
+2
lexicons/build.ts
···
9
import * as path from "path";
10
import { PubLeafletRichTextFacet } from "./src/facet";
11
import { PubLeafletComment } from "./src/comment";
12
13
const outdir = path.join("lexicons", "pub", "leaflet");
14
···
21
PubLeafletDocument,
22
PubLeafletComment,
23
PubLeafletRichTextFacet,
24
PageLexicons.PubLeafletPagesLinearDocument,
25
PageLexicons.PubLeafletPagesCanvasDocument,
26
...ThemeLexicons,
···
9
import * as path from "path";
10
import { PubLeafletRichTextFacet } from "./src/facet";
11
import { PubLeafletComment } from "./src/comment";
12
+
import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions";
13
14
const outdir = path.join("lexicons", "pub", "leaflet");
15
···
22
PubLeafletDocument,
23
PubLeafletComment,
24
PubLeafletRichTextFacet,
25
+
PubLeafletAuthFullPermissions,
26
PageLexicons.PubLeafletPagesLinearDocument,
27
PageLexicons.PubLeafletPagesCanvasDocument,
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
+
}
+8
lexicons/pub/leaflet/blocks/text.json
+8
lexicons/pub/leaflet/blocks/text.json
+9
lexicons/pub/leaflet/document.json
+9
lexicons/pub/leaflet/document.json
+13
lexicons/pub/leaflet/publication.json
+13
lexicons/pub/leaflet/publication.json
···
51
"showComments": {
52
"type": "boolean",
53
"default": true
54
+
},
55
+
"showMentions": {
56
+
"type": "boolean",
57
+
"default": true
58
+
},
59
+
"showPrevNext": {
60
+
"type": "boolean",
61
+
"default": false
62
}
63
}
64
},
···
75
"backgroundImage": {
76
"type": "ref",
77
"ref": "pub.leaflet.theme.backgroundImage"
78
+
},
79
+
"pageWidth": {
80
+
"type": "integer",
81
+
"minimum": 0,
82
+
"maximum": 1600
83
},
84
"primary": {
85
"type": "union",
+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
+
};
+1
lexicons/src/blocks.ts
+1
lexicons/src/blocks.ts
+5
lexicons/src/document.ts
+5
lexicons/src/document.ts
···
24
author: { type: "string", format: "at-identifier" },
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
tags: { type: "array", items: { type: "string", maxLength: 50 } },
27
+
coverImage: {
28
+
type: "blob",
29
+
accept: ["image/png", "image/jpeg", "image/webp"],
30
+
maxSize: 1000000,
31
+
},
32
pages: {
33
type: "array",
34
items: {
+7
lexicons/src/publication.ts
+7
lexicons/src/publication.ts
···
27
properties: {
28
showInDiscover: { type: "boolean", default: true },
29
showComments: { type: "boolean", default: true },
30
+
showMentions: { type: "boolean", default: true },
31
+
showPrevNext: { type: "boolean", default: false },
32
},
33
},
34
theme: {
···
38
backgroundImage: {
39
type: "ref",
40
ref: PubLeafletThemeBackgroundImage.id,
41
+
},
42
+
pageWidth: {
43
+
type: "integer",
44
+
minimum: 0,
45
+
maximum: 1600,
46
},
47
primary: ColorUnion,
48
pageBackground: ColorUnion,
+1
-1
package.json
+1
-1
package.json
···
7
"dev": "TZ=UTC next dev --turbo",
8
"publish-lexicons": "tsx lexicons/publish.ts",
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' {} \\;",
11
"wrangler-dev": "wrangler dev",
12
"build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node",
13
"build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
···
7
"dev": "TZ=UTC next dev --turbo",
8
"publish-lexicons": "tsx lexicons/publish.ts",
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/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
"wrangler-dev": "wrangler dev",
12
"build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node",
13
"build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
+27
src/atproto-oauth.ts
+27
src/atproto-oauth.ts
···
3
NodeSavedSession,
4
NodeSavedState,
5
RuntimeLock,
6
} from "@atproto/oauth-client-node";
7
import { JoseKey } from "@atproto/jwk-jose";
8
import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata";
···
10
11
import Client from "ioredis";
12
import Redlock from "redlock";
13
export async function createOauthClient() {
14
let keyset =
15
process.env.NODE_ENV === "production"
···
90
.eq("key", key);
91
},
92
};
···
3
NodeSavedSession,
4
NodeSavedState,
5
RuntimeLock,
6
+
OAuthSession,
7
} from "@atproto/oauth-client-node";
8
import { JoseKey } from "@atproto/jwk-jose";
9
import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata";
···
11
12
import Client from "ioredis";
13
import Redlock from "redlock";
14
+
import { Result, Ok, Err } from "./result";
15
export async function createOauthClient() {
16
let keyset =
17
process.env.NODE_ENV === "production"
···
92
.eq("key", key);
93
},
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
+
}
+12
src/replicache/attributes.ts
+12
src/replicache/attributes.ts
···
71
type: "number",
72
cardinality: "one",
73
},
74
"block/image": {
75
type: "image",
76
cardinality: "one",
···
191
type: "boolean",
192
cardinality: "one",
193
},
194
"theme/page-background": {
195
type: "color",
196
cardinality: "one",
···
317
"text-alignment-type-union": {
318
type: "text-alignment-type-union";
319
value: "right" | "left" | "center" | "justify";
320
};
321
"page-type-union": { type: "page-type-union"; value: "doc" | "canvas" };
322
"block-type-union": {
···
71
type: "number",
72
cardinality: "one",
73
},
74
+
"block/text-size": {
75
+
type: "text-size-union",
76
+
cardinality: "one",
77
+
},
78
"block/image": {
79
type: "image",
80
cardinality: "one",
···
195
type: "boolean",
196
cardinality: "one",
197
},
198
+
"theme/page-width": {
199
+
type: "number",
200
+
cardinality: "one",
201
+
},
202
"theme/page-background": {
203
type: "color",
204
cardinality: "one",
···
325
"text-alignment-type-union": {
326
type: "text-alignment-type-union";
327
value: "right" | "left" | "center" | "justify";
328
+
};
329
+
"text-size-union": {
330
+
type: "text-size-union";
331
+
value: "default" | "small" | "large";
332
};
333
"page-type-union": { type: "page-type-union"; value: "doc" | "canvas" };
334
"block-type-union": {
+30
-1
src/replicache/mutations.ts
+30
-1
src/replicache/mutations.ts
···
319
await supabase.storage
320
.from("minilink-user-assets")
321
.remove([paths[paths.length - 1]]);
322
}
323
});
324
-
await ctx.runOnClient(async () => {
325
let cache = await caches.open("minilink-user-assets");
326
if (image) {
327
await cache.delete(image.data.src + "?local");
328
}
329
});
330
await ctx.deleteEntity(block.blockEntity);
···
612
title?: string;
613
description?: string;
614
tags?: string[];
615
}> = async (args, ctx) => {
616
await ctx.runOnServer(async (serverCtx) => {
617
console.log("updating");
···
619
description?: string;
620
title?: string;
621
tags?: string[];
622
} = {};
623
if (args.description !== undefined) updates.description = args.description;
624
if (args.title !== undefined) updates.title = args.title;
625
if (args.tags !== undefined) updates.tags = args.tags;
626
627
if (Object.keys(updates).length > 0) {
628
// First try to update leaflets_in_publications (for publications)
···
648
if (args.description !== undefined)
649
await tx.set("publication_description", args.description);
650
if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
651
});
652
};
653
···
319
await supabase.storage
320
.from("minilink-user-assets")
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
+
}
340
}
341
});
342
+
await ctx.runOnClient(async ({ tx }) => {
343
let cache = await caches.open("minilink-user-assets");
344
if (image) {
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
+
}
352
}
353
});
354
await ctx.deleteEntity(block.blockEntity);
···
636
title?: string;
637
description?: string;
638
tags?: string[];
639
+
cover_image?: string | null;
640
}> = async (args, ctx) => {
641
await ctx.runOnServer(async (serverCtx) => {
642
console.log("updating");
···
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)
···
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);
680
});
681
};
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
+
);
+3
src/utils/getBlocksAsHTML.tsx
+3
src/utils/getBlocksAsHTML.tsx
···
171
},
172
text: async (b, tx, a) => {
173
let [value] = await scanIndex(tx).eav(b.value, "block/text");
174
+
let [textSize] = await scanIndex(tx).eav(b.value, "block/text-size");
175
+
176
return (
177
<RenderYJSFragment
178
value={value?.data.value}
179
attrs={{
180
"data-alignment": a,
181
+
"data-text-size": textSize?.data.value,
182
}}
183
wrapper="p"
184
/>
+11
-1
supabase/database.types.ts
+11
-1
supabase/database.types.ts
···
556
atp_did?: string | null
557
created_at?: string
558
email?: string | null
559
-
home_page: string
560
id?: string
561
interface_state?: Json | null
562
}
···
581
leaflets_in_publications: {
582
Row: {
583
archived: boolean | null
584
description: string
585
doc: string | null
586
leaflet: string
···
589
}
590
Insert: {
591
archived?: boolean | null
592
description?: string
593
doc?: string | null
594
leaflet: string
···
597
}
598
Update: {
599
archived?: boolean | null
600
description?: string
601
doc?: string | null
602
leaflet?: string
···
629
}
630
leaflets_to_documents: {
631
Row: {
632
created_at: string
633
description: string
634
document: string
···
636
title: string
637
}
638
Insert: {
639
created_at?: string
640
description?: string
641
document: string
···
643
title?: string
644
}
645
Update: {
646
created_at?: string
647
description?: string
648
document?: string
···
1112
[_ in never]: never
1113
}
1114
Functions: {
1115
get_facts: {
1116
Args: {
1117
root: string
···
556
atp_did?: string | null
557
created_at?: string
558
email?: string | null
559
+
home_page?: string
560
id?: string
561
interface_state?: Json | null
562
}
···
581
leaflets_in_publications: {
582
Row: {
583
archived: boolean | null
584
+
cover_image: string | null
585
description: string
586
doc: string | null
587
leaflet: string
···
590
}
591
Insert: {
592
archived?: boolean | null
593
+
cover_image?: string | null
594
description?: string
595
doc?: string | null
596
leaflet: string
···
599
}
600
Update: {
601
archived?: boolean | null
602
+
cover_image?: string | null
603
description?: string
604
doc?: string | null
605
leaflet?: string
···
632
}
633
leaflets_to_documents: {
634
Row: {
635
+
cover_image: string | null
636
created_at: string
637
description: string
638
document: string
···
640
title: string
641
}
642
Insert: {
643
+
cover_image?: string | null
644
created_at?: string
645
description?: string
646
document: string
···
648
title?: string
649
}
650
Update: {
651
+
cover_image?: string | null
652
created_at?: string
653
description?: string
654
document?: string
···
1118
[_ in never]: never
1119
}
1120
Functions: {
1121
+
create_identity_homepage: {
1122
+
Args: Record<PropertyKey, never>
1123
+
Returns: string
1124
+
}
1125
get_facts: {
1126
Args: {
1127
root: string
+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";