-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
+45
-6
actions/publishToPublication.ts
+45
-6
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,
···
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 {
···
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(
···
2
3
import * as Y from "yjs";
4
import * as base64 from "base64-js";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
9
import { getIdentityData } from "actions/getIdentityData";
10
import {
11
AtpBaseClient,
···
53
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
54
import { v7 } from "uuid";
55
56
+
type PublishResult =
57
+
| { success: true; rkey: string; record: PubLeafletDocument.Record }
58
+
| { success: false; error: OAuthSessionError };
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 {
···
256
await createMentionNotifications(result.uri, record, credentialSession.did!);
257
}
258
259
+
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
260
}
261
262
async function processBlocksToPages(
+1
-3
app/(home-pages)/discover/page.tsx
+1
-3
app/(home-pages)/discover/page.tsx
···
17
return (
18
<DashboardLayout
19
id="discover"
20
-
cardBorderHidden={false}
21
currentPage="discover"
22
defaultTab="default"
23
actions={null}
···
32
}
33
34
const DiscoverContent = async (props: { order: string }) => {
35
-
const orderValue =
36
-
props.order === "popular" ? "popular" : "recentlyUpdated";
37
let { publications, nextCursor } = await getPublications(orderValue);
38
39
return (
···
17
return (
18
<DashboardLayout
19
id="discover"
20
currentPage="discover"
21
defaultTab="default"
22
actions={null}
···
31
}
32
33
const DiscoverContent = async (props: { order: string }) => {
34
+
const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated";
35
let { publications, nextCursor } = await getPublications(orderValue);
36
37
return (
+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: {}) => {
-8
app/(home-pages)/home/HomeLayout.tsx
-8
app/(home-pages)/home/HomeLayout.tsx
···
20
useDashboardState,
21
} from "components/PageLayouts/DashboardLayout";
22
import { Actions } from "./Actions/Actions";
23
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
24
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
25
import { useState } from "react";
26
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
56
props.entityID,
57
"theme/background-image",
58
);
59
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
60
61
let [searchValue, setSearchValue] = useState("");
62
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
···
81
return (
82
<DashboardLayout
83
id="home"
84
-
cardBorderHidden={cardBorderHidden}
85
currentPage="home"
86
defaultTab="home"
87
actions={<Actions />}
···
101
<HomeLeafletList
102
titles={props.titles}
103
initialFacts={props.initialFacts}
104
-
cardBorderHidden={cardBorderHidden}
105
searchValue={debouncedSearchValue}
106
/>
107
),
···
117
[root_entity: string]: Fact<Attribute>[];
118
};
119
searchValue: string;
120
-
cardBorderHidden: boolean;
121
}) {
122
let { identity } = useIdentityData();
123
let { data: initialFacts } = useSWR(
···
171
searchValue={props.searchValue}
172
leaflets={leaflets}
173
titles={initialFacts?.titles || {}}
174
-
cardBorderHidden={props.cardBorderHidden}
175
initialFacts={initialFacts?.facts || {}}
176
showPreview
177
/>
···
192
[root_entity: string]: Fact<Attribute>[];
193
};
194
searchValue: string;
195
-
cardBorderHidden: boolean;
196
showPreview?: boolean;
197
}) {
198
let { identity } = useIdentityData();
···
238
loggedIn={!!identity}
239
display={display}
240
added_at={added_at}
241
-
cardBorderHidden={props.cardBorderHidden}
242
index={index}
243
showPreview={props.showPreview}
244
isHidden={
···
20
useDashboardState,
21
} from "components/PageLayouts/DashboardLayout";
22
import { Actions } from "./Actions/Actions";
23
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
24
import { useState } from "react";
25
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
55
props.entityID,
56
"theme/background-image",
57
);
58
59
let [searchValue, setSearchValue] = useState("");
60
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
···
79
return (
80
<DashboardLayout
81
id="home"
82
currentPage="home"
83
defaultTab="home"
84
actions={<Actions />}
···
98
<HomeLeafletList
99
titles={props.titles}
100
initialFacts={props.initialFacts}
101
searchValue={debouncedSearchValue}
102
/>
103
),
···
113
[root_entity: string]: Fact<Attribute>[];
114
};
115
searchValue: string;
116
}) {
117
let { identity } = useIdentityData();
118
let { data: initialFacts } = useSWR(
···
166
searchValue={props.searchValue}
167
leaflets={leaflets}
168
titles={initialFacts?.titles || {}}
169
initialFacts={initialFacts?.facts || {}}
170
showPreview
171
/>
···
186
[root_entity: string]: Fact<Attribute>[];
187
};
188
searchValue: string;
189
showPreview?: boolean;
190
}) {
191
let { identity } = useIdentityData();
···
231
loggedIn={!!identity}
232
display={display}
233
added_at={added_at}
234
index={index}
235
showPreview={props.showPreview}
236
isHidden={
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
4
import { useState, useRef, useEffect } from "react";
5
import { SpeedyLink } from "components/SpeedyLink";
6
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
8
export const LeafletListItem = (props: {
9
archived?: boolean | null;
10
loggedIn: boolean;
11
display: "list" | "grid";
12
-
cardBorderHidden: boolean;
13
added_at: string;
14
title?: string;
15
index: number;
16
isHidden: boolean;
17
showPreview?: boolean;
18
}) => {
19
const pubStatus = useLeafletPublicationStatus();
20
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
21
let previewRef = useRef<HTMLDivElement | null>(null);
···
47
ref={previewRef}
48
className={`relative flex gap-3 w-full
49
${props.isHidden ? "hidden" : "flex"}
50
-
${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
style={{
52
-
backgroundColor: props.cardBorderHidden
53
? "transparent"
54
: "rgba(var(--bg-page), var(--bg-page-alpha))",
55
}}
···
67
loggedIn={props.loggedIn}
68
/>
69
</div>
70
-
{props.cardBorderHidden && (
71
<hr
72
className="last:hidden border-border-light"
73
style={{
···
87
${props.isHidden ? "hidden" : "flex"}
88
`}
89
style={{
90
-
backgroundColor: props.cardBorderHidden
91
? "transparent"
92
: "rgba(var(--bg-page), var(--bg-page-alpha))",
93
}}
···
4
import { useState, useRef, useEffect } from "react";
5
import { SpeedyLink } from "components/SpeedyLink";
6
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
8
9
export const LeafletListItem = (props: {
10
archived?: boolean | null;
11
loggedIn: boolean;
12
display: "list" | "grid";
13
added_at: string;
14
title?: string;
15
index: number;
16
isHidden: boolean;
17
showPreview?: boolean;
18
}) => {
19
+
const cardBorderHidden = useCardBorderHidden();
20
const pubStatus = useLeafletPublicationStatus();
21
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
22
let previewRef = useRef<HTMLDivElement | null>(null);
···
48
ref={previewRef}
49
className={`relative flex gap-3 w-full
50
${props.isHidden ? "hidden" : "flex"}
51
+
${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
52
style={{
53
+
backgroundColor: cardBorderHidden
54
? "transparent"
55
: "rgba(var(--bg-page), var(--bg-page-alpha))",
56
}}
···
68
loggedIn={props.loggedIn}
69
/>
70
</div>
71
+
{cardBorderHidden && (
72
<hr
73
className="last:hidden border-border-light"
74
style={{
···
88
${props.isHidden ? "hidden" : "flex"}
89
`}
90
style={{
91
+
backgroundColor: cardBorderHidden
92
? "transparent"
93
: "rgba(var(--bg-page), var(--bg-page-alpha))",
94
}}
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+18
-7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
+18
-7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
···
18
const firstPage = useEntity(root, "root/page")[0];
19
const page = firstPage?.data.value || root;
20
21
-
const cardBorderHidden = useCardBorderHidden(root);
22
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
23
const rootBackgroundRepeat = useEntity(
24
root,
···
49
50
const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`;
51
52
-
return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass };
53
}
54
55
export const LeafletListPreview = (props: { isVisible: boolean }) => {
56
-
const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } =
57
-
useLeafletPreviewData();
58
59
return (
60
<Tooltip
61
-
open={true}
62
-
delayDuration={0}
63
side="right"
64
trigger={
65
-
<div className="w-12 h-full py-1">
66
<div className="rounded-md h-full overflow-hidden">
67
<ThemeProvider local entityID={root} className="">
68
<ThemeBackgroundProvider entityID={root}>
···
18
const firstPage = useEntity(root, "root/page")[0];
19
const page = firstPage?.data.value || root;
20
21
+
const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data
22
+
.value;
23
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
24
const rootBackgroundRepeat = useEntity(
25
root,
···
50
51
const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`;
52
53
+
return {
54
+
root,
55
+
page,
56
+
cardBorderHidden,
57
+
contentWrapperStyle,
58
+
contentWrapperClass,
59
+
};
60
}
61
62
export const LeafletListPreview = (props: { isVisible: boolean }) => {
63
+
const {
64
+
root,
65
+
page,
66
+
cardBorderHidden,
67
+
contentWrapperStyle,
68
+
contentWrapperClass,
69
+
} = useLeafletPreviewData();
70
71
return (
72
<Tooltip
73
side="right"
74
+
asChild
75
trigger={
76
+
<div className="w-12 h-full py-1 z-10">
77
<div className="rounded-md h-full overflow-hidden">
78
<ThemeProvider local entityID={root} className="">
79
<ThemeBackgroundProvider entityID={root}>
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
"use client";
2
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
import { useState } from "react";
5
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
import { Fact, PermissionToken } from "src/replicache";
···
30
[searchValue],
31
);
32
33
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
34
return (
35
<DashboardLayout
36
id="looseleafs"
37
-
cardBorderHidden={cardBorderHidden}
38
currentPage="looseleafs"
39
defaultTab="home"
40
actions={<Actions />}
···
45
<LooseleafList
46
titles={props.titles}
47
initialFacts={props.initialFacts}
48
-
cardBorderHidden={cardBorderHidden}
49
searchValue={debouncedSearchValue}
50
/>
51
),
···
61
[root_entity: string]: Fact<Attribute>[];
62
};
63
searchValue: string;
64
-
cardBorderHidden: boolean;
65
}) => {
66
let { identity } = useIdentityData();
67
let { data: initialFacts } = useSWR(
···
108
searchValue={props.searchValue}
109
leaflets={leaflets}
110
titles={initialFacts?.titles || {}}
111
-
cardBorderHidden={props.cardBorderHidden}
112
initialFacts={initialFacts?.facts || {}}
113
showPreview
114
/>
···
1
"use client";
2
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
import { useState } from "react";
4
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
5
import { Fact, PermissionToken } from "src/replicache";
···
29
[searchValue],
30
);
31
32
return (
33
<DashboardLayout
34
id="looseleafs"
35
currentPage="looseleafs"
36
defaultTab="home"
37
actions={<Actions />}
···
42
<LooseleafList
43
titles={props.titles}
44
initialFacts={props.initialFacts}
45
searchValue={debouncedSearchValue}
46
/>
47
),
···
57
[root_entity: string]: Fact<Attribute>[];
58
};
59
searchValue: string;
60
}) => {
61
let { identity } = useIdentityData();
62
let { data: initialFacts } = useSWR(
···
103
searchValue={props.searchValue}
104
leaflets={leaflets}
105
titles={initialFacts?.titles || {}}
106
initialFacts={initialFacts?.facts || {}}
107
showPreview
108
/>
-1
app/(home-pages)/notifications/page.tsx
-1
app/(home-pages)/notifications/page.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
···
···
1
+
"use client";
2
+
3
+
import { PostListing } from "components/PostListing";
4
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
5
+
import type { Cursor } from "./getProfilePosts";
6
+
import { getProfilePosts } from "./getProfilePosts";
7
+
import useSWRInfinite from "swr/infinite";
8
+
import { useEffect, useRef } from "react";
9
+
10
+
export const ProfilePostsContent = (props: {
11
+
did: string;
12
+
posts: Post[];
13
+
nextCursor: Cursor | null;
14
+
}) => {
15
+
const getKey = (
16
+
pageIndex: number,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
21
+
) => {
22
+
// Reached the end
23
+
if (previousPageData && !previousPageData.nextCursor) return null;
24
+
25
+
// First page, we don't have previousPageData
26
+
if (pageIndex === 0) return ["profile-posts", props.did, null] as const;
27
+
28
+
// Add the cursor to the key
29
+
return ["profile-posts", props.did, previousPageData?.nextCursor] as const;
30
+
};
31
+
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
33
+
getKey,
34
+
([_, did, cursor]) => getProfilePosts(did, cursor),
35
+
{
36
+
fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
37
+
revalidateFirstPage: false,
38
+
},
39
+
);
40
+
41
+
const loadMoreRef = useRef<HTMLDivElement>(null);
42
+
43
+
// Set up intersection observer to load more when trigger element is visible
44
+
useEffect(() => {
45
+
const observer = new IntersectionObserver(
46
+
(entries) => {
47
+
if (entries[0].isIntersecting && !isValidating) {
48
+
const hasMore = data && data[data.length - 1]?.nextCursor;
49
+
if (hasMore) {
50
+
setSize(size + 1);
51
+
}
52
+
}
53
+
},
54
+
{ threshold: 0.1 },
55
+
);
56
+
57
+
if (loadMoreRef.current) {
58
+
observer.observe(loadMoreRef.current);
59
+
}
60
+
61
+
return () => observer.disconnect();
62
+
}, [data, size, setSize, isValidating]);
63
+
64
+
const allPosts = data ? data.flatMap((page) => page.posts) : [];
65
+
66
+
if (allPosts.length === 0 && !isValidating) {
67
+
return <div className="text-tertiary text-center py-4">No posts yet</div>;
68
+
}
69
+
70
+
return (
71
+
<div className="flex flex-col gap-3 text-left relative">
72
+
{allPosts.map((post) => (
73
+
<PostListing key={post.documents.uri} {...post} />
74
+
))}
75
+
{/* Trigger element for loading more posts */}
76
+
<div
77
+
ref={loadMoreRef}
78
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
79
+
aria-hidden="true"
80
+
/>
81
+
{isValidating && (
82
+
<div className="text-center text-tertiary py-4">
83
+
Loading more posts...
84
+
</div>
85
+
)}
86
+
</div>
87
+
);
88
+
};
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
···
1
+
"use client";
2
+
import { Avatar } from "components/Avatar";
3
+
import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api";
4
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
5
+
import type { ProfileData } from "./layout";
6
+
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
7
+
import { colorToString } from "components/ThemeManager/useColorAttribute";
8
+
import { PubIcon } from "components/ActionBar/Publications";
9
+
import { Json } from "supabase/database.types";
10
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
11
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
12
+
import { SpeedyLink } from "components/SpeedyLink";
13
+
import { ReactNode } from "react";
14
+
import * as linkify from "linkifyjs";
15
+
16
+
export const ProfileHeader = (props: {
17
+
profile: ProfileViewDetailed;
18
+
publications: { record: Json; uri: string }[];
19
+
popover?: boolean;
20
+
}) => {
21
+
let profileRecord = props.profile;
22
+
const profileUrl = `/p/${props.profile.handle}`;
23
+
24
+
const avatarElement = (
25
+
<Avatar
26
+
src={profileRecord.avatar}
27
+
displayName={profileRecord.displayName}
28
+
className="mx-auto mt-3 sm:mt-4"
29
+
giant
30
+
/>
31
+
);
32
+
33
+
const displayNameElement = (
34
+
<h3 className=" px-3 sm:px-4 pt-2 leading-tight">
35
+
{profileRecord.displayName
36
+
? profileRecord.displayName
37
+
: `@${props.profile.handle}`}
38
+
</h3>
39
+
);
40
+
41
+
const handleElement = profileRecord.displayName && (
42
+
<div
43
+
className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`}
44
+
>
45
+
@{props.profile.handle}
46
+
</div>
47
+
);
48
+
49
+
return (
50
+
<div
51
+
className={`flex flex-col relative ${props.popover && "text-sm"}`}
52
+
id="profile-header"
53
+
>
54
+
<ProfileLinks handle={props.profile.handle || ""} />
55
+
<div className="flex flex-col">
56
+
<div className="flex flex-col group">
57
+
{props.popover ? (
58
+
<SpeedyLink className={"hover:no-underline!"} href={profileUrl}>
59
+
{avatarElement}
60
+
</SpeedyLink>
61
+
) : (
62
+
avatarElement
63
+
)}
64
+
{props.popover ? (
65
+
<SpeedyLink
66
+
className={" text-primary group-hover:underline"}
67
+
href={profileUrl}
68
+
>
69
+
{displayNameElement}
70
+
</SpeedyLink>
71
+
) : (
72
+
displayNameElement
73
+
)}
74
+
{props.popover && handleElement ? (
75
+
<SpeedyLink className={"group-hover:underline"} href={profileUrl}>
76
+
{handleElement}
77
+
</SpeedyLink>
78
+
) : (
79
+
handleElement
80
+
)}
81
+
</div>
82
+
<pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap">
83
+
{profileRecord.description
84
+
? parseDescription(profileRecord.description)
85
+
: null}
86
+
</pre>
87
+
<div className=" w-full overflow-x-scroll py-3 mb-3 ">
88
+
<div
89
+
className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`}
90
+
>
91
+
{props.publications.map((p) => (
92
+
<PublicationCard
93
+
key={p.uri}
94
+
record={p.record as PubLeafletPublication.Record}
95
+
uri={p.uri}
96
+
/>
97
+
))}
98
+
</div>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
);
103
+
};
104
+
105
+
const ProfileLinks = (props: { handle: string }) => {
106
+
return (
107
+
<div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2">
108
+
<a
109
+
className="text-tertiary hover:text-accent-contrast hover:no-underline!"
110
+
href={`https://bsky.app/profile/${props.handle}`}
111
+
>
112
+
<BlueskyTiny />
113
+
</a>
114
+
</div>
115
+
);
116
+
};
117
+
const PublicationCard = (props: {
118
+
record: PubLeafletPublication.Record;
119
+
uri: string;
120
+
}) => {
121
+
const { record, uri } = props;
122
+
const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme);
123
+
124
+
return (
125
+
<a
126
+
href={`https://${record.base_path}`}
127
+
className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2"
128
+
style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }}
129
+
>
130
+
<div
131
+
className="rounded-md p-2 flex flex-row gap-2"
132
+
style={{
133
+
backgroundColor: record.theme?.showPageBackground
134
+
? `rgb(${colorToString(bgPage, "rgb")})`
135
+
: undefined,
136
+
}}
137
+
>
138
+
<PubIcon record={record} uri={uri} />
139
+
<h4
140
+
className="truncate min-w-0"
141
+
style={{
142
+
color: `rgb(${colorToString(primary, "rgb")})`,
143
+
}}
144
+
>
145
+
{record.name}
146
+
</h4>
147
+
</div>
148
+
</a>
149
+
);
150
+
};
151
+
152
+
function parseDescription(description: string): ReactNode[] {
153
+
// Find all mentions using regex
154
+
const mentionRegex = /@\S+/g;
155
+
const mentions: { start: number; end: number; value: string }[] = [];
156
+
let mentionMatch;
157
+
while ((mentionMatch = mentionRegex.exec(description)) !== null) {
158
+
mentions.push({
159
+
start: mentionMatch.index,
160
+
end: mentionMatch.index + mentionMatch[0].length,
161
+
value: mentionMatch[0],
162
+
});
163
+
}
164
+
165
+
// Find all URLs using linkifyjs
166
+
const links = linkify.find(description).filter((link) => link.type === "url");
167
+
168
+
// Filter out URLs that overlap with mentions (mentions take priority)
169
+
const nonOverlappingLinks = links.filter((link) => {
170
+
return !mentions.some(
171
+
(mention) =>
172
+
(link.start >= mention.start && link.start < mention.end) ||
173
+
(link.end > mention.start && link.end <= mention.end) ||
174
+
(link.start <= mention.start && link.end >= mention.end),
175
+
);
176
+
});
177
+
178
+
// Combine into a single sorted list
179
+
const allMatches: Array<{
180
+
start: number;
181
+
end: number;
182
+
value: string;
183
+
href: string;
184
+
type: "url" | "mention";
185
+
}> = [
186
+
...nonOverlappingLinks.map((link) => ({
187
+
start: link.start,
188
+
end: link.end,
189
+
value: link.value,
190
+
href: link.href,
191
+
type: "url" as const,
192
+
})),
193
+
...mentions.map((mention) => ({
194
+
start: mention.start,
195
+
end: mention.end,
196
+
value: mention.value,
197
+
href: `/p/${mention.value.slice(1)}`,
198
+
type: "mention" as const,
199
+
})),
200
+
].sort((a, b) => a.start - b.start);
201
+
202
+
const parts: ReactNode[] = [];
203
+
let lastIndex = 0;
204
+
let key = 0;
205
+
206
+
for (const match of allMatches) {
207
+
// Add text before this match
208
+
if (match.start > lastIndex) {
209
+
parts.push(description.slice(lastIndex, match.start));
210
+
}
211
+
212
+
if (match.type === "mention") {
213
+
parts.push(
214
+
<SpeedyLink key={key++} href={match.href}>
215
+
{match.value}
216
+
</SpeedyLink>,
217
+
);
218
+
} else {
219
+
// It's a URL
220
+
const urlWithoutProtocol = match.value
221
+
.replace(/^https?:\/\//, "")
222
+
.replace(/\/+$/, "");
223
+
const displayText =
224
+
urlWithoutProtocol.length > 50
225
+
? urlWithoutProtocol.slice(0, 50) + "โฆ"
226
+
: urlWithoutProtocol;
227
+
parts.push(
228
+
<a key={key++} href={match.href} target="_blank" rel="noopener noreferrer">
229
+
{displayText}
230
+
</a>,
231
+
);
232
+
}
233
+
234
+
lastIndex = match.end;
235
+
}
236
+
237
+
// Add remaining text after last match
238
+
if (lastIndex < description.length) {
239
+
parts.push(description.slice(lastIndex));
240
+
}
241
+
242
+
return parts;
243
+
}
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
···
···
1
+
"use client";
2
+
3
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
+
5
+
export function ProfileLayout(props: { children: React.ReactNode }) {
6
+
let cardBorderHidden = useCardBorderHidden();
7
+
return (
8
+
<div
9
+
id="profile-content"
10
+
className={`
11
+
${
12
+
cardBorderHidden
13
+
? ""
14
+
: "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page"
15
+
}
16
+
max-w-prose mx-auto w-full
17
+
flex flex-col
18
+
text-center
19
+
`}
20
+
>
21
+
{props.children}
22
+
</div>
23
+
);
24
+
}
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
···
···
1
+
"use client";
2
+
3
+
import { SpeedyLink } from "components/SpeedyLink";
4
+
import { useSelectedLayoutSegment } from "next/navigation";
5
+
import { useState, useEffect } from "react";
6
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
+
8
+
export type ProfileTabType = "posts" | "comments" | "subscriptions";
9
+
10
+
export const ProfileTabs = (props: { didOrHandle: string }) => {
11
+
const cardBorderHidden = useCardBorderHidden();
12
+
const segment = useSelectedLayoutSegment();
13
+
const currentTab = (segment || "posts") as ProfileTabType;
14
+
const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0);
15
+
const [headerHeight, setHeaderHeight] = useState(0);
16
+
useEffect(() => {
17
+
let headerHeight =
18
+
document.getElementById("profile-header")?.clientHeight || 0;
19
+
setHeaderHeight(headerHeight);
20
+
21
+
const profileContent = cardBorderHidden
22
+
? document.getElementById("home-content")
23
+
: document.getElementById("profile-content");
24
+
const handleScroll = () => {
25
+
if (profileContent) {
26
+
setScrollPosWithinTabContent(
27
+
profileContent.scrollTop - headerHeight > 0
28
+
? profileContent.scrollTop - headerHeight
29
+
: 0,
30
+
);
31
+
}
32
+
};
33
+
34
+
if (profileContent) {
35
+
profileContent.addEventListener("scroll", handleScroll);
36
+
return () => profileContent.removeEventListener("scroll", handleScroll);
37
+
}
38
+
}, []);
39
+
40
+
const baseUrl = `/p/${props.didOrHandle}`;
41
+
const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)";
42
+
43
+
return (
44
+
<div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3">
45
+
<div
46
+
style={
47
+
scrollPosWithinTabContent < 20
48
+
? {
49
+
paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
50
+
paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
51
+
}
52
+
: { paddingLeft: "12px", paddingRight: "12px" }
53
+
}
54
+
>
55
+
<div
56
+
className={`
57
+
border rounded-lg
58
+
${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"}
59
+
py-1
60
+
w-full `}
61
+
style={
62
+
scrollPosWithinTabContent < 20
63
+
? {
64
+
backgroundColor: !cardBorderHidden
65
+
? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})`
66
+
: `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`,
67
+
paddingLeft: !cardBorderHidden
68
+
? "4px"
69
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
70
+
paddingRight: !cardBorderHidden
71
+
? "4px"
72
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
73
+
}
74
+
: {
75
+
backgroundColor: `rgb(${bgColor})`,
76
+
paddingLeft: "4px",
77
+
paddingRight: "4px",
78
+
}
79
+
}
80
+
>
81
+
<div className="flex gap-2 justify-between">
82
+
<div className="flex gap-2">
83
+
<TabLink
84
+
href={baseUrl}
85
+
name="Posts"
86
+
selected={currentTab === "posts"}
87
+
/>
88
+
<TabLink
89
+
href={`${baseUrl}/comments`}
90
+
name="Comments"
91
+
selected={currentTab === "comments"}
92
+
/>
93
+
</div>
94
+
<TabLink
95
+
href={`${baseUrl}/subscriptions`}
96
+
name="Subscriptions"
97
+
selected={currentTab === "subscriptions"}
98
+
/>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
</div>
103
+
);
104
+
};
105
+
106
+
const TabLink = (props: { href: string; name: string; selected: boolean }) => {
107
+
return (
108
+
<SpeedyLink
109
+
href={props.href}
110
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${
111
+
props.selected
112
+
? "text-accent-2 bg-accent-1 font-bold -mb-px"
113
+
: "text-tertiary"
114
+
}`}
115
+
>
116
+
{props.name}
117
+
</SpeedyLink>
118
+
);
119
+
};
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef, useMemo } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { AppBskyActorProfile, AtUri } from "@atproto/api";
6
+
import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
7
+
import { ReplyTiny } from "components/Icons/ReplyTiny";
8
+
import { Avatar } from "components/Avatar";
9
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
10
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
11
+
import {
12
+
getProfileComments,
13
+
type ProfileComment,
14
+
type Cursor,
15
+
} from "./getProfileComments";
16
+
import { timeAgo } from "src/utils/timeAgo";
17
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
18
+
19
+
export const ProfileCommentsContent = (props: {
20
+
did: string;
21
+
comments: ProfileComment[];
22
+
nextCursor: Cursor | null;
23
+
}) => {
24
+
const getKey = (
25
+
pageIndex: number,
26
+
previousPageData: {
27
+
comments: ProfileComment[];
28
+
nextCursor: Cursor | null;
29
+
} | null,
30
+
) => {
31
+
// Reached the end
32
+
if (previousPageData && !previousPageData.nextCursor) return null;
33
+
34
+
// First page, we don't have previousPageData
35
+
if (pageIndex === 0) return ["profile-comments", props.did, null] as const;
36
+
37
+
// Add the cursor to the key
38
+
return [
39
+
"profile-comments",
40
+
props.did,
41
+
previousPageData?.nextCursor,
42
+
] as const;
43
+
};
44
+
45
+
const { data, size, setSize, isValidating } = useSWRInfinite(
46
+
getKey,
47
+
([_, did, cursor]) => getProfileComments(did, cursor),
48
+
{
49
+
fallbackData: [
50
+
{ comments: props.comments, nextCursor: props.nextCursor },
51
+
],
52
+
revalidateFirstPage: false,
53
+
},
54
+
);
55
+
56
+
const loadMoreRef = useRef<HTMLDivElement>(null);
57
+
58
+
// Set up intersection observer to load more when trigger element is visible
59
+
useEffect(() => {
60
+
const observer = new IntersectionObserver(
61
+
(entries) => {
62
+
if (entries[0].isIntersecting && !isValidating) {
63
+
const hasMore = data && data[data.length - 1]?.nextCursor;
64
+
if (hasMore) {
65
+
setSize(size + 1);
66
+
}
67
+
}
68
+
},
69
+
{ threshold: 0.1 },
70
+
);
71
+
72
+
if (loadMoreRef.current) {
73
+
observer.observe(loadMoreRef.current);
74
+
}
75
+
76
+
return () => observer.disconnect();
77
+
}, [data, size, setSize, isValidating]);
78
+
79
+
const allComments = data ? data.flatMap((page) => page.comments) : [];
80
+
81
+
if (allComments.length === 0 && !isValidating) {
82
+
return (
83
+
<div className="text-tertiary text-center py-4">No comments yet</div>
84
+
);
85
+
}
86
+
87
+
return (
88
+
<div className="flex flex-col gap-2 text-left relative">
89
+
{allComments.map((comment) => (
90
+
<CommentItem key={comment.uri} comment={comment} />
91
+
))}
92
+
{/* Trigger element for loading more comments */}
93
+
<div
94
+
ref={loadMoreRef}
95
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
96
+
aria-hidden="true"
97
+
/>
98
+
{isValidating && (
99
+
<div className="text-center text-tertiary py-4">
100
+
Loading more comments...
101
+
</div>
102
+
)}
103
+
</div>
104
+
);
105
+
};
106
+
107
+
const CommentItem = ({ comment }: { comment: ProfileComment }) => {
108
+
const record = comment.record as PubLeafletComment.Record;
109
+
const profile = comment.bsky_profiles?.record as
110
+
| AppBskyActorProfile.Record
111
+
| undefined;
112
+
const displayName =
113
+
profile?.displayName || comment.bsky_profiles?.handle || "Unknown";
114
+
115
+
// Get commenter DID from comment URI
116
+
const commenterDid = new AtUri(comment.uri).host;
117
+
118
+
const isReply = !!record.reply;
119
+
120
+
// Get document title
121
+
const docData = comment.document?.data as
122
+
| PubLeafletDocument.Record
123
+
| undefined;
124
+
const postTitle = docData?.title || "Untitled";
125
+
126
+
// Get parent comment info for replies
127
+
const parentRecord = comment.parentComment?.record as
128
+
| PubLeafletComment.Record
129
+
| undefined;
130
+
const parentProfile = comment.parentComment?.bsky_profiles?.record as
131
+
| AppBskyActorProfile.Record
132
+
| undefined;
133
+
const parentDisplayName =
134
+
parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle;
135
+
136
+
// Build direct link to the comment
137
+
const commentLink = useMemo(() => {
138
+
if (!comment.document) return null;
139
+
const docUri = new AtUri(comment.document.uri);
140
+
141
+
// Get base URL using getPublicationURL if publication exists, otherwise build path
142
+
let baseUrl: string;
143
+
if (comment.publication) {
144
+
baseUrl = getPublicationURL(comment.publication);
145
+
const pubUri = new AtUri(comment.publication.uri);
146
+
// If getPublicationURL returns a relative path, append the document rkey
147
+
if (baseUrl.startsWith("/")) {
148
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
149
+
} else {
150
+
// For custom domains, append the document rkey
151
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
152
+
}
153
+
} else {
154
+
baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`;
155
+
}
156
+
157
+
// Build query parameters
158
+
const params = new URLSearchParams();
159
+
params.set("interactionDrawer", "comments");
160
+
if (record.onPage) {
161
+
params.set("page", record.onPage);
162
+
}
163
+
164
+
// Use comment URI as hash for direct reference
165
+
const commentId = encodeURIComponent(comment.uri);
166
+
167
+
return `${baseUrl}?${params.toString()}#${commentId}`;
168
+
}, [comment.document, comment.publication, comment.uri, record.onPage]);
169
+
170
+
// Get avatar source
171
+
const avatarSrc = profile?.avatar?.ref
172
+
? blobRefToSrc(profile.avatar.ref, commenterDid)
173
+
: undefined;
174
+
175
+
return (
176
+
<div id={comment.uri} className="w-full flex flex-col text-left mb-8">
177
+
<div className="flex gap-2 w-full">
178
+
<Avatar src={avatarSrc} displayName={displayName} />
179
+
<div className="flex flex-col w-full min-w-0 grow">
180
+
<div className="flex flex-row gap-2 justify-between">
181
+
<div className="text-tertiary text-sm truncate">
182
+
<span className="font-bold text-secondary">{displayName}</span>{" "}
183
+
{isReply ? "replied" : "commented"} on{" "}
184
+
{commentLink ? (
185
+
<a
186
+
href={commentLink}
187
+
className="italic text-accent-contrast hover:underline"
188
+
>
189
+
{postTitle}
190
+
</a>
191
+
) : (
192
+
<span className="italic text-accent-contrast">{postTitle}</span>
193
+
)}
194
+
</div>
195
+
<div className="text-tertiary text-sm shrink-0">
196
+
{timeAgo(record.createdAt)}
197
+
</div>
198
+
</div>
199
+
{isReply && parentRecord && (
200
+
<div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center">
201
+
<ReplyTiny className="shrink-0 scale-75" />
202
+
{parentDisplayName && (
203
+
<div className="font-bold shrink-0">{parentDisplayName}</div>
204
+
)}
205
+
<div className="grow truncate">{parentRecord.plaintext}</div>
206
+
</div>
207
+
)}
208
+
<pre
209
+
style={{ wordBreak: "break-word" }}
210
+
className="whitespace-pre-wrap text-secondary"
211
+
>
212
+
<BaseTextBlock
213
+
index={[]}
214
+
plaintext={record.plaintext}
215
+
facets={record.facets}
216
+
/>
217
+
</pre>
218
+
</div>
219
+
</div>
220
+
</div>
221
+
);
222
+
};
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
···
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { PubLeafletComment } from "lexicons/api";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export type ProfileComment = {
13
+
uri: string;
14
+
record: Json;
15
+
indexed_at: string;
16
+
bsky_profiles: { record: Json; handle: string | null } | null;
17
+
document: {
18
+
uri: string;
19
+
data: Json;
20
+
} | null;
21
+
publication: {
22
+
uri: string;
23
+
record: Json;
24
+
} | null;
25
+
// For replies, include the parent comment info
26
+
parentComment: {
27
+
uri: string;
28
+
record: Json;
29
+
bsky_profiles: { record: Json; handle: string | null } | null;
30
+
} | null;
31
+
};
32
+
33
+
export async function getProfileComments(
34
+
did: string,
35
+
cursor?: Cursor | null,
36
+
): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> {
37
+
const limit = 20;
38
+
39
+
let query = supabaseServerClient
40
+
.from("comments_on_documents")
41
+
.select(
42
+
`*,
43
+
bsky_profiles(record, handle),
44
+
documents(uri, data, documents_in_publications(publications(*)))`,
45
+
)
46
+
.eq("profile", did)
47
+
.order("indexed_at", { ascending: false })
48
+
.order("uri", { ascending: false })
49
+
.limit(limit);
50
+
51
+
if (cursor) {
52
+
query = query.or(
53
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
54
+
);
55
+
}
56
+
57
+
const { data: rawComments } = await query;
58
+
59
+
if (!rawComments || rawComments.length === 0) {
60
+
return { comments: [], nextCursor: null };
61
+
}
62
+
63
+
// Collect parent comment URIs for replies
64
+
const parentUris = rawComments
65
+
.map((c) => (c.record as PubLeafletComment.Record).reply?.parent)
66
+
.filter((uri): uri is string => !!uri);
67
+
68
+
// Fetch parent comments if there are any replies
69
+
let parentCommentsMap = new Map<
70
+
string,
71
+
{
72
+
uri: string;
73
+
record: Json;
74
+
bsky_profiles: { record: Json; handle: string | null } | null;
75
+
}
76
+
>();
77
+
78
+
if (parentUris.length > 0) {
79
+
const { data: parentComments } = await supabaseServerClient
80
+
.from("comments_on_documents")
81
+
.select(`uri, record, bsky_profiles(record, handle)`)
82
+
.in("uri", parentUris);
83
+
84
+
if (parentComments) {
85
+
for (const pc of parentComments) {
86
+
parentCommentsMap.set(pc.uri, {
87
+
uri: pc.uri,
88
+
record: pc.record,
89
+
bsky_profiles: pc.bsky_profiles,
90
+
});
91
+
}
92
+
}
93
+
}
94
+
95
+
// Transform to ProfileComment format
96
+
const comments: ProfileComment[] = rawComments.map((comment) => {
97
+
const record = comment.record as PubLeafletComment.Record;
98
+
const doc = comment.documents;
99
+
const pub = doc?.documents_in_publications?.[0]?.publications;
100
+
101
+
return {
102
+
uri: comment.uri,
103
+
record: comment.record,
104
+
indexed_at: comment.indexed_at,
105
+
bsky_profiles: comment.bsky_profiles,
106
+
document: doc
107
+
? {
108
+
uri: doc.uri,
109
+
data: doc.data,
110
+
}
111
+
: null,
112
+
publication: pub
113
+
? {
114
+
uri: pub.uri,
115
+
record: pub.record,
116
+
}
117
+
: null,
118
+
parentComment: record.reply?.parent
119
+
? parentCommentsMap.get(record.reply.parent) || null
120
+
: null,
121
+
};
122
+
});
123
+
124
+
const nextCursor =
125
+
comments.length === limit
126
+
? {
127
+
indexed_at: comments[comments.length - 1].indexed_at,
128
+
uri: comments[comments.length - 1].uri,
129
+
}
130
+
: null;
131
+
132
+
return { comments, nextCursor };
133
+
}
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
···
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfileComments } from "./getProfileComments";
3
+
import { ProfileCommentsContent } from "./CommentsContent";
4
+
5
+
export default async function ProfileCommentsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { comments, nextCursor } = await getProfileComments(did);
20
+
21
+
return (
22
+
<ProfileCommentsContent
23
+
did={did}
24
+
comments={comments}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
···
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export async function getProfilePosts(
13
+
did: string,
14
+
cursor?: Cursor | null,
15
+
): Promise<{ posts: Post[]; nextCursor: Cursor | null }> {
16
+
const limit = 20;
17
+
18
+
let query = supabaseServerClient
19
+
.from("documents")
20
+
.select(
21
+
`*,
22
+
comments_on_documents(count),
23
+
document_mentions_in_bsky(count),
24
+
documents_in_publications(publications(*))`,
25
+
)
26
+
.like("uri", `at://${did}/%`)
27
+
.order("indexed_at", { ascending: false })
28
+
.order("uri", { ascending: false })
29
+
.limit(limit);
30
+
31
+
if (cursor) {
32
+
query = query.or(
33
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
34
+
);
35
+
}
36
+
37
+
let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([
38
+
query,
39
+
supabaseServerClient
40
+
.from("publications")
41
+
.select("*")
42
+
.eq("identity_did", did),
43
+
supabaseServerClient
44
+
.from("bsky_profiles")
45
+
.select("handle")
46
+
.eq("did", did)
47
+
.single(),
48
+
]);
49
+
50
+
// Build a map of publications for quick lookup
51
+
let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
52
+
for (let pub of pubs || []) {
53
+
pubMap.set(pub.uri, pub);
54
+
}
55
+
56
+
// Transform data to Post[] format
57
+
let handle = profile?.handle ? `@${profile.handle}` : null;
58
+
let posts: Post[] = [];
59
+
60
+
for (let doc of docs || []) {
61
+
let pubFromDoc = doc.documents_in_publications?.[0]?.publications;
62
+
let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null;
63
+
64
+
let post: Post = {
65
+
author: handle,
66
+
documents: {
67
+
data: doc.data,
68
+
uri: doc.uri,
69
+
indexed_at: doc.indexed_at,
70
+
comments_on_documents: doc.comments_on_documents,
71
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
72
+
},
73
+
};
74
+
75
+
if (pub) {
76
+
post.publication = {
77
+
href: getPublicationURL(pub),
78
+
pubRecord: pub.record,
79
+
uri: pub.uri,
80
+
};
81
+
}
82
+
83
+
posts.push(post);
84
+
}
85
+
86
+
const nextCursor =
87
+
posts.length === limit
88
+
? {
89
+
indexed_at: posts[posts.length - 1].documents.indexed_at,
90
+
uri: posts[posts.length - 1].documents.uri,
91
+
}
92
+
: null;
93
+
94
+
return { posts, nextCursor };
95
+
}
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { ProfileHeader } from "./ProfileHeader";
6
+
import { ProfileTabs } from "./ProfileTabs";
7
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
8
+
import { ProfileLayout } from "./ProfileLayout";
9
+
import { Agent } from "@atproto/api";
10
+
import { get_profile_data } from "app/api/rpc/[command]/get_profile_data";
11
+
import { Metadata } from "next";
12
+
import { cache } from "react";
13
+
14
+
// Cache the profile data call to prevent concurrent OAuth restores
15
+
const getCachedProfileData = cache(async (did: string) => {
16
+
return get_profile_data.handler(
17
+
{ didOrHandle: did },
18
+
{ supabase: supabaseServerClient },
19
+
);
20
+
});
21
+
22
+
export async function generateMetadata(props: {
23
+
params: Promise<{ didOrHandle: string }>;
24
+
}): Promise<Metadata> {
25
+
let params = await props.params;
26
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
27
+
28
+
let did = didOrHandle;
29
+
if (!didOrHandle.startsWith("did:")) {
30
+
let resolved = await idResolver.handle.resolve(didOrHandle);
31
+
if (!resolved) return { title: "Profile - Leaflet" };
32
+
did = resolved;
33
+
}
34
+
35
+
let profileData = await getCachedProfileData(did);
36
+
let { profile } = profileData.result;
37
+
38
+
if (!profile) return { title: "Profile - Leaflet" };
39
+
40
+
const displayName = profile.displayName;
41
+
const handle = profile.handle;
42
+
43
+
const title = displayName
44
+
? `${displayName} (@${handle}) - Leaflet`
45
+
: `@${handle} - Leaflet`;
46
+
47
+
return { title };
48
+
}
49
+
50
+
export default async function ProfilePageLayout(props: {
51
+
params: Promise<{ didOrHandle: string }>;
52
+
children: React.ReactNode;
53
+
}) {
54
+
let params = await props.params;
55
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
56
+
57
+
// Resolve handle to DID if necessary
58
+
let did = didOrHandle;
59
+
60
+
if (!didOrHandle.startsWith("did:")) {
61
+
let resolved = await idResolver.handle.resolve(didOrHandle);
62
+
if (!resolved) {
63
+
return (
64
+
<NotFoundLayout>
65
+
<p className="font-bold">Sorry, can't resolve handle!</p>
66
+
<p>
67
+
This may be a glitch on our end. If the issue persists please{" "}
68
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
69
+
</p>
70
+
</NotFoundLayout>
71
+
);
72
+
}
73
+
did = resolved;
74
+
}
75
+
let profileData = await getCachedProfileData(did);
76
+
let { publications, profile } = profileData.result;
77
+
78
+
if (!profile) return null;
79
+
80
+
return (
81
+
<DashboardLayout
82
+
id="profile"
83
+
defaultTab="default"
84
+
currentPage="profile"
85
+
actions={null}
86
+
tabs={{
87
+
default: {
88
+
controls: null,
89
+
content: (
90
+
<ProfileLayout>
91
+
<ProfileHeader
92
+
profile={profile}
93
+
publications={publications || []}
94
+
/>
95
+
<ProfileTabs didOrHandle={params.didOrHandle} />
96
+
<div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col">
97
+
{props.children}
98
+
</div>
99
+
</ProfileLayout>
100
+
),
101
+
},
102
+
}}
103
+
/>
104
+
);
105
+
}
106
+
107
+
export type ProfileData = {
108
+
did: string;
109
+
handle: string | null;
110
+
indexed_at: string;
111
+
record: Json;
112
+
};
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
···
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfilePosts } from "./getProfilePosts";
3
+
import { ProfilePostsContent } from "./PostsContent";
4
+
5
+
export default async function ProfilePostsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { posts, nextCursor } = await getProfilePosts(did);
20
+
21
+
return (
22
+
<ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} />
23
+
);
24
+
}
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
···
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { PubListing } from "app/(home-pages)/discover/PubListing";
6
+
import {
7
+
getSubscriptions,
8
+
type PublicationSubscription,
9
+
} from "app/(home-pages)/reader/getSubscriptions";
10
+
import { Cursor } from "app/(home-pages)/reader/getReaderFeed";
11
+
12
+
export const ProfileSubscriptionsContent = (props: {
13
+
did: string;
14
+
subscriptions: PublicationSubscription[];
15
+
nextCursor: Cursor | null;
16
+
}) => {
17
+
const getKey = (
18
+
pageIndex: number,
19
+
previousPageData: {
20
+
subscriptions: PublicationSubscription[];
21
+
nextCursor: Cursor | null;
22
+
} | null,
23
+
) => {
24
+
// Reached the end
25
+
if (previousPageData && !previousPageData.nextCursor) return null;
26
+
27
+
// First page, we don't have previousPageData
28
+
if (pageIndex === 0)
29
+
return ["profile-subscriptions", props.did, null] as const;
30
+
31
+
// Add the cursor to the key
32
+
return [
33
+
"profile-subscriptions",
34
+
props.did,
35
+
previousPageData?.nextCursor,
36
+
] as const;
37
+
};
38
+
39
+
const { data, size, setSize, isValidating } = useSWRInfinite(
40
+
getKey,
41
+
([_, did, cursor]) => getSubscriptions(did, cursor),
42
+
{
43
+
fallbackData: [
44
+
{ subscriptions: props.subscriptions, nextCursor: props.nextCursor },
45
+
],
46
+
revalidateFirstPage: false,
47
+
},
48
+
);
49
+
50
+
const loadMoreRef = useRef<HTMLDivElement>(null);
51
+
52
+
// Set up intersection observer to load more when trigger element is visible
53
+
useEffect(() => {
54
+
const observer = new IntersectionObserver(
55
+
(entries) => {
56
+
if (entries[0].isIntersecting && !isValidating) {
57
+
const hasMore = data && data[data.length - 1]?.nextCursor;
58
+
if (hasMore) {
59
+
setSize(size + 1);
60
+
}
61
+
}
62
+
},
63
+
{ threshold: 0.1 },
64
+
);
65
+
66
+
if (loadMoreRef.current) {
67
+
observer.observe(loadMoreRef.current);
68
+
}
69
+
70
+
return () => observer.disconnect();
71
+
}, [data, size, setSize, isValidating]);
72
+
73
+
const allSubscriptions = data
74
+
? data.flatMap((page) => page.subscriptions)
75
+
: [];
76
+
77
+
if (allSubscriptions.length === 0 && !isValidating) {
78
+
return (
79
+
<div className="text-tertiary text-center py-4">No subscriptions yet</div>
80
+
);
81
+
}
82
+
83
+
return (
84
+
<div className="relative">
85
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3">
86
+
{allSubscriptions.map((sub) => (
87
+
<PubListing key={sub.uri} {...sub} />
88
+
))}
89
+
</div>
90
+
{/* Trigger element for loading more subscriptions */}
91
+
<div
92
+
ref={loadMoreRef}
93
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
94
+
aria-hidden="true"
95
+
/>
96
+
{isValidating && (
97
+
<div className="text-center text-tertiary py-4">
98
+
Loading more subscriptions...
99
+
</div>
100
+
)}
101
+
</div>
102
+
);
103
+
};
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
···
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions";
3
+
import { ProfileSubscriptionsContent } from "./SubscriptionsContent";
4
+
5
+
export default async function ProfileSubscriptionsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
const params = await props.params;
9
+
const didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
const resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { subscriptions, nextCursor } = await getSubscriptions(did);
20
+
21
+
return (
22
+
<ProfileSubscriptionsContent
23
+
did={did}
24
+
subscriptions={subscriptions}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+1
-1
app/(home-pages)/reader/SubscriptionsContent.tsx
+1
-1
app/(home-pages)/reader/SubscriptionsContent.tsx
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
···
8
import { idResolver } from "./idResolver";
9
import { Cursor } from "./getReaderFeed";
10
11
-
export async function getSubscriptions(cursor?: Cursor | null): Promise<{
12
nextCursor: null | Cursor;
13
subscriptions: PublicationSubscription[];
14
}> {
15
-
let auth_res = await getIdentityData();
16
-
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
17
let query = supabaseServerClient
18
.from("publication_subscriptions")
19
.select(`*, publications(*, documents_in_publications(*, documents(*)))`)
···
25
})
26
.limit(1, { referencedTable: "publications.documents_in_publications" })
27
.limit(25)
28
-
.eq("identity", auth_res.atp_did);
29
30
if (cursor) {
31
query = query.or(
···
8
import { idResolver } from "./idResolver";
9
import { Cursor } from "./getReaderFeed";
10
11
+
export async function getSubscriptions(
12
+
did?: string | null,
13
+
cursor?: Cursor | null,
14
+
): Promise<{
15
nextCursor: null | Cursor;
16
subscriptions: PublicationSubscription[];
17
}> {
18
+
// If no DID provided, use logged-in user's DID
19
+
let identity = did;
20
+
if (!identity) {
21
+
const auth_res = await getIdentityData();
22
+
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
23
+
identity = auth_res.atp_did;
24
+
}
25
+
26
let query = supabaseServerClient
27
.from("publication_subscriptions")
28
.select(`*, publications(*, documents_in_publications(*, documents(*)))`)
···
34
})
35
.limit(1, { referencedTable: "publications.documents_in_publications" })
36
.limit(25)
37
+
.eq("identity", identity);
38
39
if (cursor) {
40
query = query.or(
-1
app/(home-pages)/reader/page.tsx
-1
app/(home-pages)/reader/page.tsx
+9
-1
app/(home-pages)/tag/[tag]/page.tsx
+9
-1
app/(home-pages)/tag/[tag]/page.tsx
···
3
import { PostListing } from "components/PostListing";
4
import { getDocumentsByTag } from "./getDocumentsByTag";
5
import { TagTiny } from "components/Icons/TagTiny";
6
7
export default async function TagPage(props: {
8
params: Promise<{ tag: string }>;
···
14
return (
15
<DashboardLayout
16
id="tag"
17
-
cardBorderHidden={false}
18
currentPage="tag"
19
defaultTab="default"
20
actions={null}
···
3
import { PostListing } from "components/PostListing";
4
import { getDocumentsByTag } from "./getDocumentsByTag";
5
import { TagTiny } from "components/Icons/TagTiny";
6
+
import { Metadata } from "next";
7
+
8
+
export async function generateMetadata(props: {
9
+
params: Promise<{ tag: string }>;
10
+
}): Promise<Metadata> {
11
+
const params = await props.params;
12
+
const decodedTag = decodeURIComponent(params.tag);
13
+
return { title: `${decodedTag} - Leaflet` };
14
+
}
15
16
export default async function TagPage(props: {
17
params: Promise<{ tag: string }>;
···
23
return (
24
<DashboardLayout
25
id="tag"
26
currentPage="tag"
27
defaultTab="default"
28
actions={null}
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
···
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}
···
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
+
? "rgb(var(--accent-light))"
165
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
}}
167
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",
+54
-22
app/[leaflet_id]/publish/PublishPost.tsx
+54
-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 ? <DotLoader /> : "Publish this Post!"}
203
+
</ButtonPrimary>
204
+
</div>
205
+
{oauthError && (
206
+
<OAuthErrorMessage
207
+
error={oauthError}
208
+
className="text-right text-sm text-accent-contrast"
209
+
/>
210
+
)}
211
</div>
212
</div>
213
</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
+69
app/api/rpc/[command]/get_profile_data.ts
+69
app/api/rpc/[command]/get_profile_data.ts
···
···
1
+
import { z } from "zod";
2
+
import { makeRoute } from "../lib";
3
+
import type { Env } from "./route";
4
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import { Agent } from "@atproto/api";
7
+
import { getIdentityData } from "actions/getIdentityData";
8
+
import { createOauthClient } from "src/atproto-oauth";
9
+
10
+
export type GetProfileDataReturnType = Awaited<
11
+
ReturnType<(typeof get_profile_data)["handler"]>
12
+
>;
13
+
14
+
export const get_profile_data = makeRoute({
15
+
route: "get_profile_data",
16
+
input: z.object({
17
+
didOrHandle: z.string(),
18
+
}),
19
+
handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => {
20
+
// Resolve handle to DID if necessary
21
+
let did = didOrHandle;
22
+
23
+
if (!didOrHandle.startsWith("did:")) {
24
+
const resolved = await idResolver.handle.resolve(didOrHandle);
25
+
if (!resolved) {
26
+
throw new Error("Could not resolve handle to DID");
27
+
}
28
+
did = resolved;
29
+
}
30
+
let agent;
31
+
let authed_identity = await getIdentityData();
32
+
if (authed_identity?.atp_did) {
33
+
try {
34
+
const oauthClient = await createOauthClient();
35
+
let credentialSession = await oauthClient.restore(
36
+
authed_identity.atp_did,
37
+
);
38
+
agent = new Agent(credentialSession);
39
+
} catch (e) {
40
+
agent = new Agent({
41
+
service: "https://public.api.bsky.app",
42
+
});
43
+
}
44
+
} else {
45
+
agent = new Agent({
46
+
service: "https://public.api.bsky.app",
47
+
});
48
+
}
49
+
50
+
let profileReq = agent.app.bsky.actor.getProfile({ actor: did });
51
+
52
+
let publicationsReq = supabase
53
+
.from("publications")
54
+
.select("*")
55
+
.eq("identity_did", did);
56
+
57
+
let [{ data: profile }, { data: publications }] = await Promise.all([
58
+
profileReq,
59
+
publicationsReq,
60
+
]);
61
+
62
+
return {
63
+
result: {
64
+
profile,
65
+
publications: publications || [],
66
+
},
67
+
};
68
+
},
69
+
});
+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
: [];
+2
app/api/rpc/[command]/route.ts
+2
app/api/rpc/[command]/route.ts
···
13
import { get_publication_data } from "./get_publication_data";
14
import { search_publication_names } from "./search_publication_names";
15
import { search_publication_documents } from "./search_publication_documents";
16
17
let supabase = createClient<Database>(
18
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
39
get_publication_data,
40
search_publication_names,
41
search_publication_documents,
42
];
43
export async function POST(
44
req: Request,
···
13
import { get_publication_data } from "./get_publication_data";
14
import { search_publication_names } from "./search_publication_names";
15
import { search_publication_documents } from "./search_publication_documents";
16
+
import { get_profile_data } from "./get_profile_data";
17
18
let supabase = createClient<Database>(
19
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
40
get_publication_data,
41
search_publication_names,
42
search_publication_documents,
43
+
get_profile_data,
44
];
45
export async function POST(
46
req: Request,
+40
-17
app/globals.css
+40
-17
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) {
···
296
@apply py-[1.5px];
297
}
298
299
-
/* Underline mention nodes when selected in ProseMirror */
300
.ProseMirror .atMention.ProseMirror-selectednode,
301
.ProseMirror .didMention.ProseMirror-selectednode {
302
-
text-decoration: underline;
303
}
304
305
-
.ProseMirror:focus-within .selection-highlight {
306
-
background-color: transparent;
307
}
308
309
.multiselected:focus-within .selection-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) {
···
301
@apply py-[1.5px];
302
}
303
304
+
.ProseMirror:focus-within .selection-highlight {
305
+
background-color: transparent;
306
+
}
307
+
308
.ProseMirror .atMention.ProseMirror-selectednode,
309
.ProseMirror .didMention.ProseMirror-selectednode {
310
+
@apply text-accent-contrast;
311
+
@apply px-0.5;
312
+
@apply -mx-[3px]; /* extra px to account for the border*/
313
+
@apply -my-px; /*to account for the border*/
314
+
@apply rounded-[4px];
315
+
@apply box-decoration-clone;
316
+
background-color: rgba(var(--accent-contrast), 0.2);
317
+
border: 1px solid rgba(var(--accent-contrast), 1);
318
}
319
320
+
.mention {
321
+
@apply cursor-pointer;
322
+
@apply text-accent-contrast;
323
+
@apply px-0.5;
324
+
@apply -mx-[3px];
325
+
@apply -my-px; /*to account for the border*/
326
+
@apply rounded-[4px];
327
+
@apply box-decoration-clone;
328
+
background-color: rgba(var(--accent-contrast), 0.2);
329
+
border: 1px solid transparent;
330
}
331
332
.multiselected:focus-within .selection-highlight {
+19
-2
app/lish/Subscribe.tsx
+19
-2
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;
···
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;
···
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
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
···
···
1
+
"use client";
2
+
import { ProfilePopover } from "components/ProfilePopover";
3
+
4
+
export const PublicationAuthor = (props: {
5
+
did: string;
6
+
displayName?: string;
7
+
handle: string;
8
+
}) => {
9
+
return (
10
+
<p className="italic text-tertiary sm:text-base text-sm">
11
+
<ProfilePopover
12
+
didOrHandle={props.did}
13
+
trigger={
14
+
<span className="hover:underline">
15
+
<strong>by {props.displayName}</strong> @{props.handle}
16
+
</span>
17
+
}
18
+
/>
19
+
</p>
20
+
);
21
+
};
+21
-201
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
+21
-201
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
-
6
-
type Facet = PubLeafletRichtextFacet.Main;
7
-
export function BaseTextBlock(props: {
8
-
plaintext: string;
9
-
facets?: Facet[];
10
-
index: number[];
11
-
preview?: boolean;
12
-
}) {
13
-
let children = [];
14
-
let richText = new RichText({
15
-
text: props.plaintext,
16
-
facets: props.facets || [],
17
-
});
18
-
let counter = 0;
19
-
for (const segment of richText.segments()) {
20
-
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
21
-
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
22
-
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
23
-
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
24
-
let isStrikethrough = segment.facet?.find(
25
-
PubLeafletRichtextFacet.isStrikethrough,
26
-
);
27
-
let isDidMention = segment.facet?.find(
28
-
PubLeafletRichtextFacet.isDidMention,
29
-
);
30
-
let isAtMention = segment.facet?.find(
31
-
PubLeafletRichtextFacet.isAtMention,
32
-
);
33
-
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
34
-
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
35
-
let isHighlighted = segment.facet?.find(
36
-
PubLeafletRichtextFacet.isHighlight,
37
-
);
38
-
let className = `
39
-
${isCode ? "inline-code" : ""}
40
-
${id ? "scroll-mt-12 scroll-mb-10" : ""}
41
-
${isBold ? "font-bold" : ""}
42
-
${isItalic ? "italic" : ""}
43
-
${isUnderline ? "underline" : ""}
44
-
${isStrikethrough ? "line-through decoration-tertiary" : ""}
45
-
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
46
-
47
-
// Split text by newlines and insert <br> tags
48
-
const textParts = segment.text.split('\n');
49
-
const renderedText = textParts.flatMap((part, i) =>
50
-
i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part]
51
-
);
52
-
53
-
if (isCode) {
54
-
children.push(
55
-
<code key={counter} className={className} id={id?.id}>
56
-
{renderedText}
57
-
</code>,
58
-
);
59
-
} else if (isDidMention) {
60
-
children.push(
61
-
<a
62
-
key={counter}
63
-
href={didToBlueskyUrl(isDidMention.did)}
64
-
className={`text-accent-contrast hover:underline cursor-pointer ${className}`}
65
-
target="_blank"
66
-
rel="noopener noreferrer"
67
-
>
68
-
{renderedText}
69
-
</a>,
70
-
);
71
-
} else if (isAtMention) {
72
-
children.push(
73
-
<AtMentionLink
74
-
key={counter}
75
-
atURI={isAtMention.atURI}
76
-
className={className}
77
-
>
78
-
{renderedText}
79
-
</AtMentionLink>,
80
-
);
81
-
} else if (link) {
82
-
children.push(
83
-
<a
84
-
key={counter}
85
-
href={link.uri}
86
-
className={`text-accent-contrast hover:underline ${className}`}
87
-
target="_blank"
88
-
>
89
-
{renderedText}
90
-
</a>,
91
-
);
92
-
} else {
93
-
children.push(
94
-
<span key={counter} className={className} id={id?.id}>
95
-
{renderedText}
96
-
</span>,
97
-
);
98
-
}
99
-
100
-
counter++;
101
-
}
102
-
return <>{children}</>;
103
-
}
104
-
105
-
type RichTextSegment = {
106
-
text: string;
107
-
facet?: Exclude<Facet["features"], { $type: string }>;
108
-
};
109
-
110
-
export class RichText {
111
-
unicodeText: UnicodeString;
112
-
facets?: Facet[];
113
-
114
-
constructor(props: { text: string; facets: Facet[] }) {
115
-
this.unicodeText = new UnicodeString(props.text);
116
-
this.facets = props.facets;
117
-
if (this.facets) {
118
-
this.facets = this.facets
119
-
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
120
-
.sort((a, b) => a.index.byteStart - b.index.byteStart);
121
-
}
122
-
}
123
124
-
*segments(): Generator<RichTextSegment, void, void> {
125
-
const facets = this.facets || [];
126
-
if (!facets.length) {
127
-
yield { text: this.unicodeText.utf16 };
128
-
return;
129
-
}
130
131
-
let textCursor = 0;
132
-
let facetCursor = 0;
133
-
do {
134
-
const currFacet = facets[facetCursor];
135
-
if (textCursor < currFacet.index.byteStart) {
136
-
yield {
137
-
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
138
-
};
139
-
} else if (textCursor > currFacet.index.byteStart) {
140
-
facetCursor++;
141
-
continue;
142
-
}
143
-
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
144
-
const subtext = this.unicodeText.slice(
145
-
currFacet.index.byteStart,
146
-
currFacet.index.byteEnd,
147
-
);
148
-
if (!subtext.trim()) {
149
-
// dont empty string entities
150
-
yield { text: subtext };
151
-
} else {
152
-
yield { text: subtext, facet: currFacet.features };
153
-
}
154
-
}
155
-
textCursor = currFacet.index.byteEnd;
156
-
facetCursor++;
157
-
} while (facetCursor < facets.length);
158
-
if (textCursor < this.unicodeText.length) {
159
-
yield {
160
-
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
161
-
};
162
-
}
163
-
}
164
}
165
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
166
-
if (facets.length === 0) {
167
-
return [newFacet];
168
-
}
169
170
-
const allFacets = [...facets, newFacet];
171
-
172
-
// Collect all boundary positions
173
-
const boundaries = new Set<number>();
174
-
boundaries.add(0);
175
-
boundaries.add(length);
176
-
177
-
for (const facet of allFacets) {
178
-
boundaries.add(facet.index.byteStart);
179
-
boundaries.add(facet.index.byteEnd);
180
-
}
181
-
182
-
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
183
-
const result: Facet[] = [];
184
-
185
-
// Process segments between consecutive boundaries
186
-
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
187
-
const start = sortedBoundaries[i];
188
-
const end = sortedBoundaries[i + 1];
189
-
190
-
// Find facets that are active at the start position
191
-
const activeFacets = allFacets.filter(
192
-
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
193
-
);
194
-
195
-
// Only create facet if there are active facets (features present)
196
-
if (activeFacets.length > 0) {
197
-
const features = activeFacets.flatMap((f) => f.features);
198
-
result.push({
199
-
index: { byteStart: start, byteEnd: end },
200
-
features,
201
-
});
202
-
}
203
-
}
204
-
205
-
return result;
206
}
···
1
+
import { ProfilePopover } from "components/ProfilePopover";
2
+
import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore";
3
+
import { ReactNode } from "react";
4
5
+
// Re-export RichText for backwards compatibility
6
+
export { RichText };
7
8
+
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
9
+
return (
10
+
<ProfilePopover
11
+
didOrHandle={props.did}
12
+
trigger={props.children}
13
+
/>
14
+
);
15
}
16
17
+
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
18
+
return (
19
+
<TextBlockCore
20
+
{...props}
21
+
renderers={{
22
+
DidMention: DidMentionWithPopover,
23
+
}}
24
+
/>
25
+
);
26
}
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
···
···
1
+
"use client";
2
+
import { AppBskyFeedDefs } from "@atproto/api";
3
+
import useSWR from "swr";
4
+
import { PageWrapper } from "components/Pages/Page";
5
+
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
+
import { DotLoader } from "components/utils/DotLoader";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { openPage } from "./PostPages";
9
+
import { BskyPostContent } from "./BskyPostContent";
10
+
import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks";
11
+
12
+
// Re-export for backwards compatibility
13
+
export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes };
14
+
15
+
type PostView = AppBskyFeedDefs.PostView;
16
+
17
+
export function BlueskyQuotesPage(props: {
18
+
postUri: string;
19
+
pageId: string;
20
+
pageOptions?: React.ReactNode;
21
+
hasPageBackground: boolean;
22
+
}) {
23
+
const { postUri, pageId, pageOptions } = props;
24
+
const drawer = useDrawerOpen(postUri);
25
+
26
+
const {
27
+
data: quotesData,
28
+
isLoading,
29
+
error,
30
+
} = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri));
31
+
32
+
return (
33
+
<PageWrapper
34
+
pageType="doc"
35
+
fullPageScroll={false}
36
+
id={`post-page-${pageId}`}
37
+
drawerOpen={!!drawer}
38
+
pageOptions={pageOptions}
39
+
>
40
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
41
+
<div className="text-secondary font-bold mb-3 flex items-center gap-2">
42
+
<QuoteTiny />
43
+
Bluesky Quotes
44
+
</div>
45
+
{isLoading ? (
46
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
47
+
<span>loading quotes</span>
48
+
<DotLoader />
49
+
</div>
50
+
) : error ? (
51
+
<div className="text-tertiary italic text-sm text-center py-8">
52
+
Failed to load quotes
53
+
</div>
54
+
) : quotesData && quotesData.posts.length > 0 ? (
55
+
<QuotesContent posts={quotesData.posts} postUri={postUri} />
56
+
) : (
57
+
<div className="text-tertiary italic text-sm text-center py-8">
58
+
No quotes yet
59
+
</div>
60
+
)}
61
+
</div>
62
+
</PageWrapper>
63
+
);
64
+
}
65
+
66
+
function QuotesContent(props: { posts: PostView[]; postUri: string }) {
67
+
const { posts, postUri } = props;
68
+
69
+
return (
70
+
<div className="flex flex-col gap-0">
71
+
{posts.map((post) => (
72
+
<QuotePost
73
+
key={post.uri}
74
+
post={post}
75
+
quotesUri={postUri}
76
+
/>
77
+
))}
78
+
</div>
79
+
);
80
+
}
81
+
82
+
function QuotePost(props: {
83
+
post: PostView;
84
+
quotesUri: string;
85
+
}) {
86
+
const { post, quotesUri } = props;
87
+
const parent = { type: "quotes" as const, uri: quotesUri };
88
+
89
+
return (
90
+
<div
91
+
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
92
+
onClick={() => openPage(parent, { type: "thread", uri: post.uri })}
93
+
>
94
+
<BskyPostContent
95
+
post={post}
96
+
parent={parent}
97
+
linksEnabled={true}
98
+
showEmbed={true}
99
+
showBlueskyLink={true}
100
+
onLinkClick={(e) => e.stopPropagation()}
101
+
onEmbedClick={(e) => e.stopPropagation()}
102
+
/>
103
+
</div>
104
+
);
105
+
}
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
···
···
1
+
"use client";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
+
import {
4
+
BlueskyEmbed,
5
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
6
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
7
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
8
+
import { CommentTiny } from "components/Icons/CommentTiny";
9
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
10
+
import { Separator } from "components/Layout";
11
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
12
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
13
+
import { OpenPage } from "./PostPages";
14
+
import { ThreadLink, QuotesLink } from "./PostLinks";
15
+
16
+
type PostView = AppBskyFeedDefs.PostView;
17
+
18
+
export function BskyPostContent(props: {
19
+
post: PostView;
20
+
parent?: OpenPage;
21
+
linksEnabled?: boolean;
22
+
avatarSize?: "sm" | "md";
23
+
showEmbed?: boolean;
24
+
showBlueskyLink?: boolean;
25
+
onEmbedClick?: (e: React.MouseEvent) => void;
26
+
onLinkClick?: (e: React.MouseEvent) => void;
27
+
}) {
28
+
const {
29
+
post,
30
+
parent,
31
+
linksEnabled = true,
32
+
avatarSize = "md",
33
+
showEmbed = true,
34
+
showBlueskyLink = true,
35
+
onEmbedClick,
36
+
onLinkClick,
37
+
} = props;
38
+
39
+
const record = post.record as AppBskyFeedPost.Record;
40
+
const postId = post.uri.split("/")[4];
41
+
const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
42
+
43
+
const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10";
44
+
45
+
return (
46
+
<>
47
+
<div className="flex flex-col items-center shrink-0">
48
+
{post.author.avatar ? (
49
+
<img
50
+
src={post.author.avatar}
51
+
alt={`${post.author.displayName}'s avatar`}
52
+
className={`${avatarClass} rounded-full border border-border-light`}
53
+
/>
54
+
) : (
55
+
<div className={`${avatarClass} rounded-full border border-border-light bg-border`} />
56
+
)}
57
+
</div>
58
+
59
+
<div className="flex flex-col grow min-w-0">
60
+
<div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}>
61
+
<div className="font-bold text-secondary">
62
+
{post.author.displayName}
63
+
</div>
64
+
<a
65
+
className="text-xs text-tertiary hover:underline"
66
+
target="_blank"
67
+
href={`https://bsky.app/profile/${post.author.handle}`}
68
+
onClick={onLinkClick}
69
+
>
70
+
@{post.author.handle}
71
+
</a>
72
+
</div>
73
+
74
+
<div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}>
75
+
<div className="text-sm text-secondary">
76
+
<BlueskyRichText record={record} />
77
+
</div>
78
+
{showEmbed && post.embed && (
79
+
<div onClick={onEmbedClick}>
80
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
81
+
</div>
82
+
)}
83
+
</div>
84
+
85
+
<div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}>
86
+
<ClientDate date={record.createdAt} />
87
+
<PostCounts
88
+
post={post}
89
+
parent={parent}
90
+
linksEnabled={linksEnabled}
91
+
showBlueskyLink={showBlueskyLink}
92
+
url={url}
93
+
onLinkClick={onLinkClick}
94
+
/>
95
+
</div>
96
+
</div>
97
+
</>
98
+
);
99
+
}
100
+
101
+
function PostCounts(props: {
102
+
post: PostView;
103
+
parent?: OpenPage;
104
+
linksEnabled: boolean;
105
+
showBlueskyLink: boolean;
106
+
url: string;
107
+
onLinkClick?: (e: React.MouseEvent) => void;
108
+
}) {
109
+
const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props;
110
+
111
+
return (
112
+
<div className="flex gap-2 items-center">
113
+
{post.replyCount != null && post.replyCount > 0 && (
114
+
<>
115
+
<Separator classname="h-3" />
116
+
{linksEnabled ? (
117
+
<ThreadLink
118
+
threadUri={post.uri}
119
+
parent={parent}
120
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
121
+
onClick={onLinkClick}
122
+
>
123
+
{post.replyCount}
124
+
<CommentTiny />
125
+
</ThreadLink>
126
+
) : (
127
+
<div className="flex items-center gap-1 text-tertiary text-xs">
128
+
{post.replyCount}
129
+
<CommentTiny />
130
+
</div>
131
+
)}
132
+
</>
133
+
)}
134
+
{post.quoteCount != null && post.quoteCount > 0 && (
135
+
<>
136
+
<Separator classname="h-3" />
137
+
<QuotesLink
138
+
postUri={post.uri}
139
+
parent={parent}
140
+
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
141
+
onClick={onLinkClick}
142
+
>
143
+
{post.quoteCount}
144
+
<QuoteTiny />
145
+
</QuotesLink>
146
+
</>
147
+
)}
148
+
{showBlueskyLink && (
149
+
<>
150
+
<Separator classname="h-3" />
151
+
<a
152
+
className="text-tertiary"
153
+
target="_blank"
154
+
href={url}
155
+
onClick={onLinkClick}
156
+
>
157
+
<BlueskyTiny />
158
+
</a>
159
+
</>
160
+
)}
161
+
</div>
162
+
);
163
+
}
164
+
165
+
export const ClientDate = (props: { date?: string }) => {
166
+
const pageLoaded = useHasPageLoaded();
167
+
const formattedDate = useLocalizedDate(
168
+
props.date || new Date().toISOString(),
169
+
{
170
+
month: "short",
171
+
day: "numeric",
172
+
year: "numeric",
173
+
hour: "numeric",
174
+
minute: "numeric",
175
+
hour12: true,
176
+
},
177
+
);
178
+
179
+
if (!pageLoaded) return null;
180
+
181
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
182
+
};
+7
-3
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+7
-3
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
57
<PageWrapper
58
pageType="canvas"
59
fullPageScroll={fullPageScroll}
60
-
cardBorderHidden={!hasPageBackground}
61
-
id={pageId ? `post-page-${pageId}` : "post-page"}
62
drawerOpen={
63
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
64
}
···
203
isSubpage: boolean | undefined;
204
data: PostPageData;
205
profile: ProfileViewDetailed;
206
-
preferences: { showComments?: boolean };
207
quotesCount: number | undefined;
208
commentsCount: number | undefined;
209
}) => {
···
214
quotesCount={props.quotesCount || 0}
215
commentsCount={props.commentsCount || 0}
216
showComments={props.preferences.showComments}
217
pageId={props.pageId}
218
/>
219
{!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 && (
+24
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+24
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
2
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
3
import { multiBlockSchema } from "components/Blocks/TextBlock/schema";
4
import { PubLeafletRichtextFacet } from "lexicons/api";
···
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: { record: comment.profile as Json },
200
},
201
],
202
}));
···
1
+
import { AtUri, UnicodeString } from "@atproto/api";
2
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
3
import { multiBlockSchema } from "components/Blocks/TextBlock/schema";
4
import { PubLeafletRichtextFacet } from "lexicons/api";
···
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
],
221
}));
+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(),
+19
-100
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+19
-100
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
18
import { QuoteContent } from "../Quotes";
19
import { timeAgo } from "src/utils/timeAgo";
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
22
export type Comment = {
23
record: Json;
24
uri: string;
25
-
bsky_profiles: { record: Json } | null;
26
};
27
export function Comments(props: {
28
document_uri: string;
···
50
}, []);
51
52
return (
53
-
<div id={"commentsDrawer"} className="flex flex-col gap-2 relative">
54
<div className="w-full flex justify-between text-secondary font-bold">
55
Comments
56
<button
···
109
document: string;
110
comment: Comment;
111
comments: Comment[];
112
-
profile?: AppBskyActorProfile.Record;
113
record: PubLeafletComment.Record;
114
pageId?: string;
115
}) => {
116
return (
117
-
<div className="comment">
118
<div className="flex gap-2">
119
-
{props.profile && (
120
-
<ProfilePopover profile={props.profile} comment={props.comment.uri} />
121
)}
122
-
<DatePopover date={props.record.createdAt} />
123
</div>
124
{props.record.attachment &&
125
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
291
</Popover>
292
);
293
};
294
-
295
-
const ProfilePopover = (props: {
296
-
profile: AppBskyActorProfile.Record;
297
-
comment: string;
298
-
}) => {
299
-
let commenterId = new AtUri(props.comment).host;
300
-
301
-
return (
302
-
<>
303
-
<a
304
-
className="font-bold text-tertiary text-sm hover:underline"
305
-
href={`https://bsky.app/profile/${commenterId}`}
306
-
>
307
-
{props.profile.displayName}
308
-
</a>
309
-
{/*<Media mobile={false}>
310
-
<Popover
311
-
align="start"
312
-
trigger={
313
-
<div
314
-
onMouseOver={() => {
315
-
setHovering(true);
316
-
hoverTimeout.current = window.setTimeout(() => {
317
-
setLoadProfile(true);
318
-
}, 500);
319
-
}}
320
-
onMouseOut={() => {
321
-
setHovering(false);
322
-
clearTimeout(hoverTimeout.current);
323
-
}}
324
-
className="font-bold text-tertiary text-sm hover:underline"
325
-
>
326
-
{props.profile.displayName}
327
-
</div>
328
-
}
329
-
className="max-w-sm"
330
-
>
331
-
{profile && (
332
-
<>
333
-
<div className="profilePopover text-sm flex gap-2">
334
-
<div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" />
335
-
<div className="flex flex-col">
336
-
<div className="flex justify-between">
337
-
<div className="profileHeader flex gap-2 items-center">
338
-
<div className="font-bold">celine</div>
339
-
<a className="text-tertiary" href="/">
340
-
@{profile.handle}
341
-
</a>
342
-
</div>
343
-
</div>
344
-
345
-
<div className="profileBio text-secondary ">
346
-
{profile.description}
347
-
</div>
348
-
<div className="flex flex-row gap-2 items-center pt-2 font-bold">
349
-
{!profile.viewer?.following ? (
350
-
<div className="text-tertiary bg-border-light rounded-md px-1 py-0">
351
-
Following
352
-
</div>
353
-
) : (
354
-
<ButtonPrimary compact className="text-sm">
355
-
Follow <BlueskyTiny />
356
-
</ButtonPrimary>
357
-
)}
358
-
{profile.viewer?.followedBy && (
359
-
<div className="text-tertiary">Follows You</div>
360
-
)}
361
-
</div>
362
-
</div>
363
-
</div>
364
-
365
-
<hr className="my-2 border-border-light" />
366
-
<div className="flex gap-2 leading-tight items-center text-tertiary text-sm">
367
-
<div className="flex flex-col w-6 justify-center">
368
-
{profile.viewer?.knownFollowers?.followers.map((follower) => {
369
-
return (
370
-
<div
371
-
className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page"
372
-
key={follower.did}
373
-
/>
374
-
);
375
-
})}
376
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
377
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
378
-
</div>
379
-
</div>
380
-
</>
381
-
)}
382
-
</Popover>
383
-
</Media>*/}
384
-
</>
385
-
);
386
-
};
···
18
import { QuoteContent } from "../Quotes";
19
import { timeAgo } from "src/utils/timeAgo";
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
+
import { ProfilePopover } from "components/ProfilePopover";
22
23
export type Comment = {
24
record: Json;
25
uri: string;
26
+
bsky_profiles: { record: Json; did: string } | null;
27
};
28
export function Comments(props: {
29
document_uri: string;
···
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
···
113
document: string;
114
comment: Comment;
115
comments: Comment[];
116
+
profile: AppBskyActorProfile.Record;
117
record: PubLeafletComment.Record;
118
pageId?: string;
119
}) => {
120
+
const did = props.comment.bsky_profiles?.did;
121
+
122
return (
123
+
<div id={props.comment.uri} className="comment">
124
<div className="flex gap-2">
125
+
{did && (
126
+
<ProfilePopover
127
+
didOrHandle={did}
128
+
trigger={
129
+
<div className="text-sm text-tertiary font-bold hover:underline">
130
+
{props.profile.displayName}
131
+
</div>
132
+
}
133
+
/>
134
)}
135
</div>
136
{props.record.attachment &&
137
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
303
</Popover>
304
);
305
};
+6
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+6
-2
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} />
···
58
export const useDrawerOpen = (uri: string) => {
59
let params = useSearchParams();
60
let interactionDrawerSearchParam = params.get("interactionDrawer");
61
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
62
if (open === false || (open === undefined && !interactionDrawerSearchParam))
63
return null;
64
drawer =
65
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
66
-
return { drawer, pageId };
67
};
···
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} />
···
59
export const useDrawerOpen = (uri: string) => {
60
let params = useSearchParams();
61
let interactionDrawerSearchParam = params.get("interactionDrawer");
62
+
let pageParam = params.get("page");
63
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
64
if (open === false || (open === undefined && !interactionDrawerSearchParam))
65
return null;
66
drawer =
67
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
68
+
// Use pageId from state, or fall back to page search param
69
+
const resolvedPageId = pageId ?? pageParam ?? undefined;
70
+
return { drawer, pageId: resolvedPageId };
71
};
+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
-
>{`Quote${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
-4
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+8
-4
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
-
cardBorderHidden={!hasPageBackground}
65
-
id={pageId ? `post-page-${pageId}` : "post-page"}
66
drawerOpen={
67
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
68
}
···
84
did={did}
85
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
/>
87
-
88
<ExpandedInteractions
89
pageId={pageId}
90
showComments={preferences.showComments}
91
commentsCount={getCommentCount(document, pageId) || 0}
92
quotesCount={getQuoteCount(document, pageId) || 0}
93
/>
···
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
/>
+14
-12
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+14
-12
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page";
20
import { Separator } from "components/Layout";
21
22
export function PostHeader(props: {
23
data: PostPageData;
24
profile: ProfileViewDetailed;
25
-
preferences: { showComments?: boolean };
26
}) {
27
let { identity } = useIdentityData();
28
let document = props.data;
···
72
<>
73
<div className="flex flex-row gap-2 items-center">
74
{profile ? (
75
-
<>
76
-
<a
77
-
className="text-tertiary"
78
-
href={`https://bsky.app/profile/${profile.handle}`}
79
-
>
80
-
{profile.displayName || profile.handle}
81
-
</a>
82
-
</>
83
) : null}
84
{record.publishedAt ? (
85
<>
···
90
</div>
91
<Interactions
92
showComments={props.preferences.showComments}
93
quotesCount={getQuoteCount(document) || 0}
94
commentsCount={getCommentCount(document) || 0}
95
/>
···
107
}) => {
108
return (
109
<div
110
-
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
id="post-header"
112
>
113
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
···
119
{props.postTitle ? props.postTitle : "Untitled"}
120
</h2>
121
{props.postDescription ? (
122
-
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
{props.postDescription}
124
-
</p>
125
) : null}
126
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
{props.postInfo}
···
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page";
20
import { Separator } from "components/Layout";
21
+
import { ProfilePopover } from "components/ProfilePopover";
22
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;
···
73
<>
74
<div className="flex flex-row gap-2 items-center">
75
{profile ? (
76
+
<ProfilePopover
77
+
didOrHandle={profile.did}
78
+
trigger={
79
+
<span className="text-tertiary hover:underline">
80
+
{profile.displayName || profile.handle}
81
+
</span>
82
+
}
83
+
/>
84
) : null}
85
{record.publishedAt ? (
86
<>
···
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">
···
121
{props.postTitle ? props.postTitle : "Untitled"}
122
</h2>
123
{props.postDescription ? (
124
+
<div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
125
{props.postDescription}
126
+
</div>
127
) : null}
128
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
129
{props.postInfo}
+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
+
}
+49
-12
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+49
-12
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
···
81
});
82
return;
83
}
84
-
85
// Then check for quote param
86
if (quote) {
87
const decodedQuote = decodeQuotePosition(quote as string);
···
96
// Mark as initialized even if no pageId found
97
usePostPageUIState.setState({ initialized: true });
98
}
99
-
}, [quote]);
100
};
101
102
export const openPage = (
···
145
document: PostPageData;
146
did: string;
147
profile: ProfileViewDetailed;
148
-
preferences: { showComments?: boolean };
149
pubRecord?: PubLeafletPublication.Record;
150
theme?: PubLeafletPublication.Theme | null;
151
prerenderedCodeBlocks?: Map<string, string>;
···
204
did: string;
205
prerenderedCodeBlocks?: Map<string, string>;
206
bskyPostData: AppBskyFeedDefs.PostView[];
207
-
preferences: { showComments?: boolean };
208
pollData: PollData[];
209
}) {
210
let drawer = useDrawerOpen(document_uri);
···
259
260
{drawer && !drawer.pageId && (
261
<InteractionDrawer
262
document_uri={document.uri}
263
comments={
264
pubRecord?.preferences?.showComments === false
265
? []
266
: document.comments_on_documents
267
}
268
-
quotesAndMentions={quotesAndMentions}
269
did={did}
270
/>
271
)}
···
293
);
294
}
295
296
// Handle document pages
297
let page = record.pages.find(
298
(p) =>
···
325
/>
326
{drawer && drawer.pageId === page.id && (
327
<InteractionDrawer
328
pageId={page.id}
329
document_uri={document.uri}
330
comments={
···
332
? []
333
: document.comments_on_documents
334
}
335
-
quotesAndMentions={quotesAndMentions}
336
did={did}
337
/>
338
)}
···
352
return (
353
<div
354
className={`pageOptions w-fit z-10
355
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
356
flex sm:flex-col flex-row-reverse gap-1 items-start`}
357
>
358
-
<PageOptionButton
359
-
cardBorderHidden={!props.hasPageBackground}
360
-
onClick={props.onClick}
361
-
>
362
<CloseTiny />
363
</PageOptionButton>
364
</div>
···
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
···
84
});
85
return;
86
}
87
// Then check for quote param
88
if (quote) {
89
const decodedQuote = decodeQuotePosition(quote as string);
···
98
// Mark as initialized even if no pageId found
99
usePostPageUIState.setState({ initialized: true });
100
}
101
+
}, [quote, pageParam]);
102
};
103
104
export const openPage = (
···
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}>
399
<CloseTiny />
400
</PageOptionButton>
401
</div>
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
···
···
1
+
"use client";
2
+
import { PubLeafletDocument } from "lexicons/api";
3
+
import { usePublicationData } from "../dashboard/PublicationSWRProvider";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
import { AtUri } from "@atproto/api";
6
+
import { useParams } from "next/navigation";
7
+
import { getPostPageData } from "./getPostPageData";
8
+
import { PostPageContext } from "./PostPageContext";
9
+
import { useContext } from "react";
10
+
import { SpeedyLink } from "components/SpeedyLink";
11
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
12
+
13
+
export const PostPrevNextButtons = (props: {
14
+
showPrevNext: boolean | undefined;
15
+
}) => {
16
+
let postData = useContext(PostPageContext);
17
+
let pub = postData?.documents_in_publications[0]?.publications;
18
+
19
+
if (!props.showPrevNext || !pub || !postData) return;
20
+
21
+
function getPostLink(uri: string) {
22
+
return pub && uri
23
+
? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}`
24
+
: "leaflet.pub/not-found";
25
+
}
26
+
let prevPost = postData?.prevNext?.prev;
27
+
let nextPost = postData?.prevNext?.next;
28
+
29
+
return (
30
+
<div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
31
+
{/*<hr className="border-border-light" />*/}
32
+
<div className="flex justify-between w-full gap-8 ">
33
+
{nextPost ? (
34
+
<SpeedyLink
35
+
href={getPostLink(nextPost.uri)}
36
+
className="flex gap-1 items-center truncate min-w-0 basis-1/2"
37
+
>
38
+
<ArrowRightTiny className="rotate-180 shrink-0" />
39
+
<div className="min-w-0 truncate">{nextPost.title}</div>
40
+
</SpeedyLink>
41
+
) : (
42
+
<div />
43
+
)}
44
+
{prevPost ? (
45
+
<SpeedyLink
46
+
href={getPostLink(prevPost.uri)}
47
+
className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end"
48
+
>
49
+
<div className="min-w-0 truncate">{prevPost.title}</div>
50
+
<ArrowRightTiny className="shrink-0" />
51
+
</SpeedyLink>
52
+
) : (
53
+
<div />
54
+
)}
55
+
</div>
56
+
</div>
57
+
);
58
+
};
+16
-1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
+16
-1
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
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,
···
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;
···
118
{post.replyCount}
119
<CommentTiny />
120
</ThreadLink>
121
<Separator classname="h-4" />
122
</>
123
)}
···
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,
···
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;
···
119
{post.replyCount}
120
<CommentTiny />
121
</ThreadLink>
122
+
<Separator classname="h-4" />
123
+
</>
124
+
)}
125
+
{post.quoteCount != null && post.quoteCount > 0 && (
126
+
<>
127
+
<QuotesLink
128
+
postUri={post.uri}
129
+
parent={parent}
130
+
className="flex items-center gap-1 hover:text-accent-contrast"
131
+
onClick={(e) => e.stopPropagation()}
132
+
>
133
+
{post.quoteCount}
134
+
<QuoteTiny />
135
+
</QuotesLink>
136
<Separator classname="h-4" />
137
</>
138
)}
+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={() => {
+11
-7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+11
-7
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 "./TextBlockCore";
16
import { StaticMathBlock } from "./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
}
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
···
···
1
+
import { UnicodeString } from "@atproto/api";
2
+
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { AtMentionLink } from "components/AtMentionLink";
4
+
import { ReactNode } from "react";
5
+
6
+
type Facet = PubLeafletRichtextFacet.Main;
7
+
8
+
export type FacetRenderers = {
9
+
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
+
};
11
+
12
+
export type TextBlockCoreProps = {
13
+
plaintext: string;
14
+
facets?: Facet[];
15
+
index: number[];
16
+
preview?: boolean;
17
+
renderers?: FacetRenderers;
18
+
};
19
+
20
+
export function TextBlockCore(props: TextBlockCoreProps) {
21
+
let children = [];
22
+
let richText = new RichText({
23
+
text: props.plaintext,
24
+
facets: props.facets || [],
25
+
});
26
+
let counter = 0;
27
+
for (const segment of richText.segments()) {
28
+
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
29
+
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
30
+
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
31
+
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
32
+
let isStrikethrough = segment.facet?.find(
33
+
PubLeafletRichtextFacet.isStrikethrough,
34
+
);
35
+
let isDidMention = segment.facet?.find(
36
+
PubLeafletRichtextFacet.isDidMention,
37
+
);
38
+
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
+
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
+
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
41
+
let isHighlighted = segment.facet?.find(
42
+
PubLeafletRichtextFacet.isHighlight,
43
+
);
44
+
let className = `
45
+
${isCode ? "inline-code" : ""}
46
+
${id ? "scroll-mt-12 scroll-mb-10" : ""}
47
+
${isBold ? "font-bold" : ""}
48
+
${isItalic ? "italic" : ""}
49
+
${isUnderline ? "underline" : ""}
50
+
${isStrikethrough ? "line-through decoration-tertiary" : ""}
51
+
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
52
+
53
+
// Split text by newlines and insert <br> tags
54
+
const textParts = segment.text.split("\n");
55
+
const renderedText = textParts.flatMap((part, i) =>
56
+
i < textParts.length - 1
57
+
? [part, <br key={`br-${counter}-${i}`} />]
58
+
: [part],
59
+
);
60
+
61
+
if (isCode) {
62
+
children.push(
63
+
<code key={counter} className={className} id={id?.id}>
64
+
{renderedText}
65
+
</code>,
66
+
);
67
+
} else if (isDidMention) {
68
+
const DidMentionRenderer = props.renderers?.DidMention;
69
+
if (DidMentionRenderer) {
70
+
children.push(
71
+
<DidMentionRenderer key={counter} did={isDidMention.did}>
72
+
<span className="mention">{renderedText}</span>
73
+
</DidMentionRenderer>,
74
+
);
75
+
} else {
76
+
// Default: render as a simple link
77
+
children.push(
78
+
<a
79
+
key={counter}
80
+
href={`https://leaflet.pub/p/${isDidMention.did}`}
81
+
target="_blank"
82
+
className="no-underline"
83
+
>
84
+
<span className="mention">{renderedText}</span>
85
+
</a>,
86
+
);
87
+
}
88
+
} else if (isAtMention) {
89
+
children.push(
90
+
<AtMentionLink
91
+
key={counter}
92
+
atURI={isAtMention.atURI}
93
+
className={className}
94
+
>
95
+
{renderedText}
96
+
</AtMentionLink>,
97
+
);
98
+
} else if (link) {
99
+
children.push(
100
+
<a
101
+
key={counter}
102
+
href={link.uri.trim()}
103
+
className={`text-accent-contrast hover:underline ${className}`}
104
+
target="_blank"
105
+
>
106
+
{renderedText}
107
+
</a>,
108
+
);
109
+
} else {
110
+
children.push(
111
+
<span key={counter} className={className} id={id?.id}>
112
+
{renderedText}
113
+
</span>,
114
+
);
115
+
}
116
+
117
+
counter++;
118
+
}
119
+
return <>{children}</>;
120
+
}
121
+
122
+
type RichTextSegment = {
123
+
text: string;
124
+
facet?: Exclude<Facet["features"], { $type: string }>;
125
+
};
126
+
127
+
export class RichText {
128
+
unicodeText: UnicodeString;
129
+
facets?: Facet[];
130
+
131
+
constructor(props: { text: string; facets: Facet[] }) {
132
+
this.unicodeText = new UnicodeString(props.text);
133
+
this.facets = props.facets;
134
+
if (this.facets) {
135
+
this.facets = this.facets
136
+
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
137
+
.sort((a, b) => a.index.byteStart - b.index.byteStart);
138
+
}
139
+
}
140
+
141
+
*segments(): Generator<RichTextSegment, void, void> {
142
+
const facets = this.facets || [];
143
+
if (!facets.length) {
144
+
yield { text: this.unicodeText.utf16 };
145
+
return;
146
+
}
147
+
148
+
let textCursor = 0;
149
+
let facetCursor = 0;
150
+
do {
151
+
const currFacet = facets[facetCursor];
152
+
if (textCursor < currFacet.index.byteStart) {
153
+
yield {
154
+
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
155
+
};
156
+
} else if (textCursor > currFacet.index.byteStart) {
157
+
facetCursor++;
158
+
continue;
159
+
}
160
+
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
161
+
const subtext = this.unicodeText.slice(
162
+
currFacet.index.byteStart,
163
+
currFacet.index.byteEnd,
164
+
);
165
+
if (!subtext.trim()) {
166
+
// dont empty string entities
167
+
yield { text: subtext };
168
+
} else {
169
+
yield { text: subtext, facet: currFacet.features };
170
+
}
171
+
}
172
+
textCursor = currFacet.index.byteEnd;
173
+
facetCursor++;
174
+
} while (facetCursor < facets.length);
175
+
if (textCursor < this.unicodeText.length) {
176
+
yield {
177
+
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
178
+
};
179
+
}
180
+
}
181
+
}
+110
-225
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+110
-225
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
97
-
cardBorderHidden={!!cardBorderHidden}
98
pageType="doc"
99
fullPageScroll={false}
100
id={`post-page-${pageId}`}
···
121
122
function ThreadContent(props: { thread: ThreadType; threadUri: string }) {
123
const { thread, threadUri } = props;
124
125
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
126
return <PostNotAvailable />;
···
161
))}
162
163
{/* Main post */}
164
-
<ThreadPost
165
-
post={thread}
166
-
isMainPost={true}
167
-
showReplyLine={false}
168
-
threadUri={threadUri}
169
-
/>
170
171
{/* Replies */}
172
{thread.replies && thread.replies.length > 0 && (
···
178
replies={thread.replies as any[]}
179
threadUri={threadUri}
180
depth={0}
181
/>
182
</div>
183
)}
···
193
}) {
194
const { post, isMainPost, showReplyLine, threadUri } = props;
195
const postView = post.post;
196
-
const record = postView.record as AppBskyFeedPost.Record;
197
-
198
-
const postId = postView.uri.split("/")[4];
199
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
200
201
return (
202
<div className="flex gap-2 relative">
···
205
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
206
)}
207
208
-
<div className="flex flex-col items-center shrink-0">
209
-
{postView.author.avatar ? (
210
-
<img
211
-
src={postView.author.avatar}
212
-
alt={`${postView.author.displayName}'s avatar`}
213
-
className="w-10 h-10 rounded-full border border-border-light"
214
-
/>
215
-
) : (
216
-
<div className="w-10 h-10 rounded-full border border-border-light bg-border" />
217
-
)}
218
-
</div>
219
-
220
-
<div
221
-
className={`flex flex-col grow min-w-0 pb-3 ${isMainPost ? "pb-0" : ""}`}
222
-
>
223
-
<div className="flex items-center gap-2 leading-tight">
224
-
<div className="font-bold text-secondary">
225
-
{postView.author.displayName}
226
-
</div>
227
-
<a
228
-
className="text-xs text-tertiary hover:underline"
229
-
target="_blank"
230
-
href={`https://bsky.app/profile/${postView.author.handle}`}
231
-
>
232
-
@{postView.author.handle}
233
-
</a>
234
-
</div>
235
-
236
-
<div className="flex flex-col gap-2 mt-1">
237
-
<div className="text-sm text-secondary">
238
-
<BlueskyRichText record={record} />
239
-
</div>
240
-
{postView.embed && (
241
-
<BlueskyEmbed embed={postView.embed} postUrl={url} />
242
-
)}
243
-
</div>
244
-
245
-
<div className="flex gap-2 items-center justify-between mt-2">
246
-
<ClientDate date={record.createdAt} />
247
-
<div className="flex gap-2 items-center">
248
-
{postView.replyCount != null && postView.replyCount > 0 && (
249
-
<>
250
-
{isMainPost ? (
251
-
<div className="flex items-center gap-1 hover:no-underline text-tertiary text-xs">
252
-
{postView.replyCount}
253
-
<CommentTiny />
254
-
</div>
255
-
) : (
256
-
<ThreadLink
257
-
threadUri={postView.uri}
258
-
parent={{ type: "thread", uri: threadUri }}
259
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
260
-
>
261
-
{postView.replyCount}
262
-
<CommentTiny />
263
-
</ThreadLink>
264
-
)}
265
-
<Separator classname="h-4" />
266
-
</>
267
-
)}
268
-
<a className="text-tertiary" target="_blank" href={url}>
269
-
<BlueskyTiny />
270
-
</a>
271
-
</div>
272
-
</div>
273
-
</div>
274
</div>
275
);
276
}
···
279
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
280
threadUri: string;
281
depth: number;
282
}) {
283
-
const { replies, threadUri, depth } = props;
284
285
return (
286
<div className="flex flex-col gap-0">
287
-
{replies.map((reply, index) => {
288
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
289
return (
290
<div
···
312
}
313
314
const hasReplies = reply.replies && reply.replies.length > 0;
315
316
return (
317
<div key={reply.post.uri} className="flex flex-col">
···
322
threadUri={threadUri}
323
/>
324
{hasReplies && depth < 3 && (
325
-
<div className="ml-5 pl-5 border-l border-border-light">
326
-
<Replies
327
-
replies={reply.replies as any[]}
328
-
threadUri={threadUri}
329
-
depth={depth + 1}
330
-
/>
331
</div>
332
)}
333
{hasReplies && depth >= 3 && (
···
352
isLast: boolean;
353
threadUri: string;
354
}) {
355
-
const { post, showReplyLine, isLast, threadUri } = props;
356
const postView = post.post;
357
-
const record = postView.record as AppBskyFeedPost.Record;
358
-
359
-
const postId = postView.uri.split("/")[4];
360
-
const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`;
361
-
362
const parent = { type: "thread" as const, uri: threadUri };
363
364
return (
···
366
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
367
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
368
>
369
-
<div className="flex flex-col items-center shrink-0">
370
-
{postView.author.avatar ? (
371
-
<img
372
-
src={postView.author.avatar}
373
-
alt={`${postView.author.displayName}'s avatar`}
374
-
className="w-8 h-8 rounded-full border border-border-light"
375
-
/>
376
-
) : (
377
-
<div className="w-8 h-8 rounded-full border border-border-light bg-border" />
378
-
)}
379
-
</div>
380
-
381
-
<div className="flex flex-col grow min-w-0">
382
-
<div className="flex items-center gap-2 leading-tight text-sm">
383
-
<div className="font-bold text-secondary">
384
-
{postView.author.displayName}
385
-
</div>
386
-
<a
387
-
className="text-xs text-tertiary hover:underline"
388
-
target="_blank"
389
-
href={`https://bsky.app/profile/${postView.author.handle}`}
390
-
onClick={(e) => e.stopPropagation()}
391
-
>
392
-
@{postView.author.handle}
393
-
</a>
394
-
</div>
395
-
396
-
<div className="text-sm text-secondary mt-0.5">
397
-
<BlueskyRichText record={record} />
398
-
</div>
399
-
400
-
<div className="flex gap-2 items-center mt-1">
401
-
<ClientDate date={record.createdAt} />
402
-
{postView.replyCount != null && postView.replyCount > 0 && (
403
-
<>
404
-
<Separator classname="h-3" />
405
-
<ThreadLink
406
-
threadUri={postView.uri}
407
-
parent={parent}
408
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
409
-
onClick={(e) => e.stopPropagation()}
410
-
>
411
-
{postView.replyCount}
412
-
<CommentTiny />
413
-
</ThreadLink>
414
-
</>
415
-
)}
416
-
</div>
417
-
</div>
418
</div>
419
);
420
}
421
-
422
-
const ClientDate = (props: { date?: string }) => {
423
-
const pageLoaded = useHasPageLoaded();
424
-
const formattedDate = useLocalizedDate(
425
-
props.date || new Date().toISOString(),
426
-
{
427
-
month: "short",
428
-
day: "numeric",
429
-
year: "numeric",
430
-
hour: "numeric",
431
-
minute: "numeric",
432
-
hour12: true,
433
-
},
434
-
);
435
-
436
-
if (!pageLoaded) return null;
437
-
438
-
return <div className="text-xs text-tertiary">{formattedDate}</div>;
439
-
};
···
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
46
pageType="doc"
47
fullPageScroll={false}
48
id={`post-page-${pageId}`}
···
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";
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
-132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
-132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
···
1
-
"use client";
2
-
3
-
import { ActionButton } from "components/ActionBar/ActionButton";
4
-
import { Popover } from "components/Popover";
5
-
import { SettingsSmall } from "components/Icons/SettingsSmall";
6
-
import { EditPubForm } from "app/lish/createPub/UpdatePubForm";
7
-
import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter";
8
-
import { useIsMobile } from "src/hooks/isMobile";
9
-
import { useState } from "react";
10
-
import { GoBackSmall } from "components/Icons/GoBackSmall";
11
-
import { theme } from "tailwind.config";
12
-
import { ButtonPrimary } from "components/Buttons";
13
-
import { DotLoader } from "components/utils/DotLoader";
14
-
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
15
-
16
-
export function PublicationSettingsButton(props: { publication: string }) {
17
-
let isMobile = useIsMobile();
18
-
let [state, setState] = useState<"menu" | "general" | "theme">("menu");
19
-
let [loading, setLoading] = useState(false);
20
-
21
-
return (
22
-
<Popover
23
-
asChild
24
-
onOpenChange={() => setState("menu")}
25
-
side={isMobile ? "top" : "right"}
26
-
align={isMobile ? "center" : "start"}
27
-
className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`}
28
-
arrowFill={theme.colors["border-light"]}
29
-
trigger={
30
-
<ActionButton
31
-
id="pub-settings-button"
32
-
icon=<SettingsSmall />
33
-
label="Settings"
34
-
/>
35
-
}
36
-
>
37
-
{state === "general" ? (
38
-
<EditPubForm
39
-
backToMenuAction={() => setState("menu")}
40
-
loading={loading}
41
-
setLoadingAction={setLoading}
42
-
/>
43
-
) : state === "theme" ? (
44
-
<PubThemeSetter
45
-
backToMenu={() => setState("menu")}
46
-
loading={loading}
47
-
setLoading={setLoading}
48
-
/>
49
-
) : (
50
-
<PubSettingsMenu
51
-
state={state}
52
-
setState={setState}
53
-
loading={loading}
54
-
setLoading={setLoading}
55
-
/>
56
-
)}
57
-
</Popover>
58
-
);
59
-
}
60
-
61
-
const PubSettingsMenu = (props: {
62
-
state: "menu" | "general" | "theme";
63
-
setState: (s: typeof props.state) => void;
64
-
loading: boolean;
65
-
setLoading: (l: boolean) => void;
66
-
}) => {
67
-
let menuItemClassName =
68
-
"menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!";
69
-
70
-
return (
71
-
<div className="flex flex-col gap-0.5">
72
-
<PubSettingsHeader
73
-
loading={props.loading}
74
-
setLoadingAction={props.setLoading}
75
-
state={"menu"}
76
-
/>
77
-
<button
78
-
className={menuItemClassName}
79
-
type="button"
80
-
onClick={() => {
81
-
props.setState("general");
82
-
}}
83
-
>
84
-
Publication Settings
85
-
<ArrowRightTiny />
86
-
</button>
87
-
<button
88
-
className={menuItemClassName}
89
-
type="button"
90
-
onClick={() => props.setState("theme")}
91
-
>
92
-
Publication Theme
93
-
<ArrowRightTiny />
94
-
</button>
95
-
</div>
96
-
);
97
-
};
98
-
99
-
export const PubSettingsHeader = (props: {
100
-
state: "menu" | "general" | "theme";
101
-
backToMenuAction?: () => void;
102
-
loading: boolean;
103
-
setLoadingAction: (l: boolean) => void;
104
-
}) => {
105
-
return (
106
-
<div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1">
107
-
{props.state === "menu"
108
-
? "Settings"
109
-
: props.state === "general"
110
-
? "General"
111
-
: props.state === "theme"
112
-
? "Publication Theme"
113
-
: ""}
114
-
{props.state !== "menu" && (
115
-
<div className="flex gap-2">
116
-
<button
117
-
type="button"
118
-
onClick={() => {
119
-
props.backToMenuAction && props.backToMenuAction();
120
-
}}
121
-
>
122
-
<GoBackSmall className="text-accent-contrast" />
123
-
</button>
124
-
125
-
<ButtonPrimary compact type="submit">
126
-
{props.loading ? <DotLoader /> : "Update"}
127
-
</ButtonPrimary>
128
-
</div>
129
-
)}
130
-
</div>
131
-
);
132
-
};
···
+2
-1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
+2
-1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
···
4
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
-10
app/lish/[did]/[publication]/page.tsx
+8
-10
app/lish/[did]/[publication]/page.tsx
···
17
import { InteractionPreview } from "components/InteractionsPreview";
18
import { LocalizedDate } from "./LocalizedDate";
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
21
export default async function Publication(props: {
22
params: Promise<{ publication: string; did: string }>;
···
91
{record?.description}{" "}
92
</p>
93
{profile && (
94
-
<p className="italic text-tertiary sm:text-base text-sm">
95
-
<strong className="">by {profile.displayName}</strong>{" "}
96
-
<a
97
-
className="text-tertiary"
98
-
href={`https://bsky.app/profile/${profile.handle}`}
99
-
>
100
-
@{profile.handle}
101
-
</a>
102
-
</p>
103
)}
104
<div className="sm:pt-4 pt-4">
105
<SubscribeWithBluesky
···
168
quotesCount={quotes}
169
commentsCount={comments}
170
tags={tags}
171
-
postUrl=""
172
showComments={record?.preferences?.showComments}
173
/>
174
</div>
175
</div>
···
17
import { InteractionPreview } from "components/InteractionsPreview";
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 }>;
···
92
{record?.description}{" "}
93
</p>
94
{profile && (
95
+
<PublicationAuthor
96
+
did={profile.did}
97
+
displayName={profile.displayName}
98
+
handle={profile.handle}
99
+
/>
100
)}
101
<div className="sm:pt-4 pt-4">
102
<SubscribeWithBluesky
···
165
quotesCount={quotes}
166
commentsCount={comments}
167
tags={tags}
168
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
169
showComments={record?.preferences?.showComments}
170
+
showMentions={record?.preferences?.showMentions}
171
/>
172
</div>
173
</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
}
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
···
5
import { Metadata } from "next";
6
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
9
export async function generateMetadata(props: {
10
params: Promise<{ didOrHandle: string; rkey: string }>;
···
34
let docRecord = document.data as PubLeafletDocument.Record;
35
36
// For documents in publications, include publication name
37
-
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
39
return {
40
icons: {
···
63
let resolved = await idResolver.handle.resolve(didOrHandle);
64
if (!resolved) {
65
return (
66
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
-
<p>Sorry, can't resolve handle.</p>
68
<p>
69
This may be a glitch on our end. If the issue persists please{" "}
70
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
</p>
72
-
</div>
73
);
74
}
75
did = resolved;
76
} catch (e) {
77
return (
78
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
-
<p>Sorry, can't resolve handle.</p>
80
<p>
81
This may be a glitch on our end. If the issue persists please{" "}
82
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
</p>
84
-
</div>
85
);
86
}
87
}
···
5
import { Metadata } from "next";
6
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
9
10
export async function generateMetadata(props: {
11
params: Promise<{ didOrHandle: string; rkey: string }>;
···
35
let docRecord = document.data as PubLeafletDocument.Record;
36
37
// For documents in publications, include publication name
38
+
let publicationName =
39
+
document.documents_in_publications[0]?.publications?.name;
40
41
return {
42
icons: {
···
65
let resolved = await idResolver.handle.resolve(didOrHandle);
66
if (!resolved) {
67
return (
68
+
<NotFoundLayout>
69
+
<p className="font-bold">Sorry, we can't find this handle!</p>
70
<p>
71
This may be a glitch on our end. If the issue persists please{" "}
72
<a href="mailto:contact@leaflet.pub">send us a note</a>.
73
</p>
74
+
</NotFoundLayout>
75
);
76
}
77
did = resolved;
78
} catch (e) {
79
return (
80
+
<NotFoundLayout>
81
+
<p className="font-bold">Sorry, we can't find this leaflet!</p>
82
<p>
83
This may be a glitch on our end. If the issue persists please{" "}
84
<a href="mailto:contact@leaflet.pub">send us a note</a>.
85
</p>
86
+
</NotFoundLayout>
87
);
88
}
89
}
+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
+1
-1
components/ActionBar/Publications.tsx
+1
-1
components/ActionBar/Publications.tsx
+2
-2
components/AtMentionLink.tsx
+2
-2
components/AtMentionLink.tsx
···
24
isPublication || isDocument ? (
25
<img
26
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
-
className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
28
alt=""
29
width="20"
30
height="20"
···
37
href={atUriToUrl(atURI)}
38
target="_blank"
39
rel="noopener noreferrer"
40
-
className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
>
42
{icon}
43
{children}
···
24
isPublication || isDocument ? (
25
<img
26
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
+
className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top"
28
alt=""
29
width="20"
30
height="20"
···
37
href={atUriToUrl(atURI)}
38
target="_blank"
39
rel="noopener noreferrer"
40
+
className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
>
42
{icon}
43
{children}
+4
-1
components/Avatar.tsx
+4
-1
components/Avatar.tsx
···
3
export const Avatar = (props: {
4
src: string | undefined;
5
displayName: string | undefined;
6
tiny?: boolean;
7
}) => {
8
if (props.src)
9
return (
10
<img
11
-
className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`}
12
src={props.src}
13
alt={
14
props.displayName
···
3
export const Avatar = (props: {
4
src: string | undefined;
5
displayName: string | undefined;
6
+
className?: string;
7
tiny?: boolean;
8
+
large?: boolean;
9
+
giant?: boolean;
10
}) => {
11
if (props.src)
12
return (
13
<img
14
+
className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`}
15
src={props.src}
16
alt={
17
props.displayName
+26
components/Blocks/Block.tsx
+26
components/Blocks/Block.tsx
···
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 = (
413
props: Block & {
414
previousBlock?: Block | null;
+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
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
6
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
import { AtMentionLink } from "components/AtMentionLink";
8
import { Delta } from "src/utils/yjsFragmentToString";
9
10
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
11
export function RenderYJSFragment({
···
63
);
64
}
65
66
-
if (node.constructor === XmlElement && node.nodeName === "hard_break") {
67
return <br key={index} />;
68
}
69
70
// Handle didMention inline nodes
71
-
if (node.constructor === XmlElement && node.nodeName === "didMention") {
72
const did = node.getAttribute("did") || "";
73
const text = node.getAttribute("text") || "";
74
return (
···
77
target="_blank"
78
rel="noopener noreferrer"
79
key={index}
80
-
className="text-accent-contrast hover:underline cursor-pointer"
81
>
82
{text}
83
</a>
···
85
}
86
87
// Handle atMention inline nodes
88
-
if (node.constructor === XmlElement && node.nodeName === "atMention") {
89
const atURI = node.getAttribute("atURI") || "";
90
const text = node.getAttribute("text") || "";
91
return (
···
161
162
return props;
163
}
164
-
···
6
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
import { AtMentionLink } from "components/AtMentionLink";
8
import { Delta } from "src/utils/yjsFragmentToString";
9
+
import { ProfilePopover } from "components/ProfilePopover";
10
11
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
12
export function RenderYJSFragment({
···
64
);
65
}
66
67
+
if (
68
+
node.constructor === XmlElement &&
69
+
node.nodeName === "hard_break"
70
+
) {
71
return <br key={index} />;
72
}
73
74
// Handle didMention inline nodes
75
+
if (
76
+
node.constructor === XmlElement &&
77
+
node.nodeName === "didMention"
78
+
) {
79
const did = node.getAttribute("did") || "";
80
const text = node.getAttribute("text") || "";
81
return (
···
84
target="_blank"
85
rel="noopener noreferrer"
86
key={index}
87
+
className="mention"
88
>
89
{text}
90
</a>
···
92
}
93
94
// Handle atMention inline nodes
95
+
if (
96
+
node.constructor === XmlElement &&
97
+
node.nodeName === "atMention"
98
+
) {
99
const atURI = node.getAttribute("atURI") || "";
100
const text = node.getAttribute("text") || "";
101
return (
···
171
172
return props;
173
}
+4
-3
components/Blocks/TextBlock/schema.ts
+4
-3
components/Blocks/TextBlock/schema.ts
···
147
toDOM(node) {
148
// NOTE: This rendering should match the AtMentionLink component in
149
// components/AtMentionLink.tsx. If you update one, update the other.
150
-
let className = "atMention text-accent-contrast";
151
let aturi = new AtUri(node.attrs.atURI);
152
if (aturi.collection === "pub.leaflet.publication")
153
className += " font-bold";
···
168
"img",
169
{
170
src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`,
171
-
class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top",
172
alt: "",
173
width: "16",
174
height: "16",
···
214
return [
215
"span",
216
{
217
-
class: "didMention text-accent-contrast",
218
"data-did": node.attrs.did,
219
},
220
node.attrs.text,
···
147
toDOM(node) {
148
// NOTE: This rendering should match the AtMentionLink component in
149
// components/AtMentionLink.tsx. If you update one, update the other.
150
+
let className = "atMention mention";
151
let aturi = new AtUri(node.attrs.atURI);
152
if (aturi.collection === "pub.leaflet.publication")
153
className += " font-bold";
···
168
"img",
169
{
170
src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`,
171
+
class:
172
+
"inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top",
173
alt: "",
174
width: "16",
175
height: "16",
···
215
return [
216
"span",
217
{
218
+
class: "didMention mention",
219
"data-did": node.attrs.did,
220
},
221
node.attrs.text,
+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
+
);
+1
components/Icons/ReplyTiny.tsx
+1
components/Icons/ReplyTiny.tsx
+7
-5
components/InteractionsPreview.tsx
+7
-5
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`}
43
-
className="flex flex-row gap-1 text-sm items-center text-accent-contrast!"
44
>
45
<QuoteTiny /> {props.quotesCount}
46
</SpeedyLink>
···
49
<SpeedyLink
50
aria-label="Post comments"
51
href={`${props.postUrl}?interactionDrawer=comments`}
52
-
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
53
>
54
<CommentTiny /> {props.commentsCount}
55
</SpeedyLink>
···
93
<Popover
94
className="p-2! max-w-xs"
95
trigger={
96
-
<div className="relative flex gap-1 items-center hover:text-accent-contrast ">
97
<TagTiny /> {props.tags.length}
98
</div>
99
}
···
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`}
45
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
46
>
47
<QuoteTiny /> {props.quotesCount}
48
</SpeedyLink>
···
51
<SpeedyLink
52
aria-label="Post comments"
53
href={`${props.postUrl}?interactionDrawer=comments`}
54
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
55
>
56
<CommentTiny /> {props.commentsCount}
57
</SpeedyLink>
···
95
<Popover
96
className="p-2! max-w-xs"
97
trigger={
98
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast">
99
<TagTiny /> {props.tags.length}
100
</div>
101
}
-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
+
}
+7
-8
components/PageHeader.tsx
+7
-8
components/PageHeader.tsx
···
1
"use client";
2
import { useState, useEffect } from "react";
3
4
-
export const Header = (props: {
5
-
children: React.ReactNode;
6
-
cardBorderHidden: boolean;
7
-
}) => {
8
let [scrollPos, setScrollPos] = useState(0);
9
10
useEffect(() => {
···
22
}
23
}, []);
24
25
-
let headerBGColor = props.cardBorderHidden
26
? "var(--bg-leaflet)"
27
: "var(--bg-page)";
28
···
54
style={
55
scrollPos < 20
56
? {
57
-
backgroundColor: props.cardBorderHidden
58
? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`
59
: `rgba(${headerBGColor}, ${scrollPos / 20})`,
60
-
paddingLeft: props.cardBorderHidden
61
? "4px"
62
: `calc(${scrollPos / 20}*4px)`,
63
-
paddingRight: props.cardBorderHidden
64
? "8px"
65
: `calc(${scrollPos / 20}*8px)`,
66
}
···
1
"use client";
2
import { useState, useEffect } from "react";
3
+
import { useCardBorderHidden } from "./Pages/useCardBorderHidden";
4
5
+
export const Header = (props: { children: React.ReactNode }) => {
6
+
let cardBorderHidden = useCardBorderHidden();
7
let [scrollPos, setScrollPos] = useState(0);
8
9
useEffect(() => {
···
21
}
22
}, []);
23
24
+
let headerBGColor = !cardBorderHidden
25
? "var(--bg-leaflet)"
26
: "var(--bg-page)";
27
···
53
style={
54
scrollPos < 20
55
? {
56
+
backgroundColor: !cardBorderHidden
57
? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`
58
: `rgba(${headerBGColor}, ${scrollPos / 20})`,
59
+
paddingLeft: !cardBorderHidden
60
? "4px"
61
: `calc(${scrollPos / 20}*4px)`,
62
+
paddingRight: !cardBorderHidden
63
? "8px"
64
: `calc(${scrollPos / 20}*8px)`,
65
}
+4
-24
components/PageLayouts/DashboardLayout.tsx
+4
-24
components/PageLayouts/DashboardLayout.tsx
···
25
import Link from "next/link";
26
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
29
export type DashboardState = {
30
display?: "grid" | "list";
···
133
},
134
>(props: {
135
id: string;
136
-
cardBorderHidden: boolean;
137
tabs: T;
138
defaultTab: keyof T;
139
currentPage: navPages;
···
180
</div>
181
</MediaContents>
182
<div
183
-
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `}
184
ref={ref}
185
id="home-content"
186
>
187
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
188
<>
189
-
<Header cardBorderHidden={props.cardBorderHidden}>
190
{headerState === "default" ? (
191
<>
192
{Object.keys(props.tabs).length > 1 && (
···
355
);
356
};
357
358
-
function Tab(props: {
359
-
name: string;
360
-
selected: boolean;
361
-
onSelect: () => void;
362
-
href?: string;
363
-
}) {
364
-
return (
365
-
<div
366
-
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
367
-
onClick={() => props.onSelect()}
368
-
>
369
-
{props.name}
370
-
{props.href && <ExternalLinkTiny />}
371
-
</div>
372
-
);
373
-
}
374
-
375
-
const FilterOptions = (props: {
376
-
hasPubs: boolean;
377
-
hasArchived: boolean;
378
-
}) => {
379
let { filter } = useDashboardState();
380
let setState = useSetDashboardState();
381
let filterCount = Object.values(filter).filter(Boolean).length;
···
25
import Link from "next/link";
26
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
+
import { Tab } from "components/Tab";
29
30
export type DashboardState = {
31
display?: "grid" | "list";
···
134
},
135
>(props: {
136
id: string;
137
tabs: T;
138
defaultTab: keyof T;
139
currentPage: navPages;
···
180
</div>
181
</MediaContents>
182
<div
183
+
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `}
184
ref={ref}
185
id="home-content"
186
>
187
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
188
<>
189
+
<Header>
190
{headerState === "default" ? (
191
<>
192
{Object.keys(props.tabs).length > 1 && (
···
355
);
356
};
357
358
+
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
359
let { filter } = useDashboardState();
360
let setState = useSetDashboardState();
361
let filterCount = Object.values(filter).filter(Boolean).length;
+3
-5
components/Pages/Page.tsx
+3
-5
components/Pages/Page.tsx
···
34
return focusedPageID === props.entityID;
35
});
36
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
38
39
let drawerOpen = useDrawerOpen(props.entityID);
40
return (
···
49
}}
50
id={elementId.page(props.entityID).container}
51
drawerOpen={!!drawerOpen}
52
-
cardBorderHidden={!!cardBorderHidden}
53
isFocused={isFocused}
54
fullPageScroll={props.fullPageScroll}
55
pageType={pageType}
···
77
id: string;
78
children: React.ReactNode;
79
pageOptions?: React.ReactNode;
80
-
cardBorderHidden: boolean;
81
fullPageScroll: boolean;
82
isFocused?: boolean;
83
onClickAction?: (e: React.MouseEvent) => void;
84
pageType: "canvas" | "doc";
85
drawerOpen: boolean | undefined;
86
}) => {
87
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
88
return (
89
// this div wraps the contents AND the page options.
···
106
shrink-0 snap-center
107
overflow-y-scroll
108
${
109
-
!props.cardBorderHidden &&
110
`h-full border
111
bg-[rgba(var(--bg-page),var(--bg-page-alpha))]
112
${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"}
113
${props.isFocused ? "shadow-md border-border" : "border-border-light"}`
114
}
115
-
${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
116
${props.fullPageScroll && "max-w-full "}
117
${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"}
118
${
···
34
return focusedPageID === props.entityID;
35
});
36
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
38
let drawerOpen = useDrawerOpen(props.entityID);
39
return (
···
48
}}
49
id={elementId.page(props.entityID).container}
50
drawerOpen={!!drawerOpen}
51
isFocused={isFocused}
52
fullPageScroll={props.fullPageScroll}
53
pageType={pageType}
···
75
id: string;
76
children: React.ReactNode;
77
pageOptions?: React.ReactNode;
78
fullPageScroll: boolean;
79
isFocused?: boolean;
80
onClickAction?: (e: React.MouseEvent) => void;
81
pageType: "canvas" | "doc";
82
drawerOpen: boolean | undefined;
83
}) => {
84
+
const cardBorderHidden = useCardBorderHidden();
85
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
86
return (
87
// this div wraps the contents AND the page options.
···
104
shrink-0 snap-center
105
overflow-y-scroll
106
${
107
+
!cardBorderHidden &&
108
`h-full border
109
bg-[rgba(var(--bg-page),var(--bg-page-alpha))]
110
${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"}
111
${props.isFocused ? "shadow-md border-border" : "border-border-light"}`
112
}
113
+
${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
114
${props.fullPageScroll && "max-w-full "}
115
${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"}
116
${
+9
-31
components/Pages/PageOptions.tsx
+9
-31
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";
···
21
export const PageOptionButton = ({
22
children,
23
secondary,
24
-
cardBorderHidden,
25
className,
26
disabled,
27
...props
28
}: {
29
children: React.ReactNode;
30
secondary?: boolean;
31
-
cardBorderHidden: boolean | undefined;
32
className?: string;
33
disabled?: boolean;
34
} & Omit<JSX.IntrinsicElements["button"], "content">) => {
35
return (
36
<button
37
className={`
···
58
first: boolean | undefined;
59
isFocused: boolean;
60
}) => {
61
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
62
-
63
return (
64
<div
65
className={`pageOptions w-fit z-10
66
${props.isFocused ? "block" : "sm:hidden block"}
67
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
68
flex sm:flex-col flex-row-reverse gap-1 items-start`}
69
>
70
{!props.first && (
71
<PageOptionButton
72
-
cardBorderHidden={cardBorderHidden}
73
secondary
74
onClick={() => {
75
useUIState.getState().closePage(props.entityID);
···
78
<CloseTiny />
79
</PageOptionButton>
80
)}
81
-
<OptionsMenu
82
-
entityID={props.entityID}
83
-
first={!!props.first}
84
-
cardBorderHidden={cardBorderHidden}
85
-
/>
86
-
<UndoButtons cardBorderHidden={cardBorderHidden} />
87
</div>
88
);
89
};
90
91
-
export const UndoButtons = (props: {
92
-
cardBorderHidden: boolean | undefined;
93
-
}) => {
94
let undoState = useUndoState();
95
let { undoManager } = useReplicache();
96
return (
97
<Media mobile>
98
{undoState.canUndo && (
99
<div className="gap-1 flex sm:flex-col">
100
-
<PageOptionButton
101
-
secondary
102
-
cardBorderHidden={props.cardBorderHidden}
103
-
onClick={() => undoManager.undo()}
104
-
>
105
<UndoTiny />
106
</PageOptionButton>
107
108
<PageOptionButton
109
secondary
110
-
cardBorderHidden={props.cardBorderHidden}
111
onClick={() => undoManager.undo()}
112
disabled={!undoState.canRedo}
113
>
···
119
);
120
};
121
122
-
export const OptionsMenu = (props: {
123
-
entityID: string;
124
-
first: boolean;
125
-
cardBorderHidden: boolean | undefined;
126
-
}) => {
127
let [state, setState] = useState<"normal" | "theme" | "share">("normal");
128
let { permissions } = useEntitySetContext();
129
if (!permissions.write) return null;
···
138
if (!open) setState("normal");
139
}}
140
trigger={
141
-
<PageOptionButton
142
-
cardBorderHidden={props.cardBorderHidden}
143
-
className="!w-8 !h-5 sm:!w-5 sm:!h-8"
144
-
>
145
<MoreOptionsTiny className="sm:rotate-90" />
146
</PageOptionButton>
147
}
···
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";
···
21
export const PageOptionButton = ({
22
children,
23
secondary,
24
className,
25
disabled,
26
...props
27
}: {
28
children: React.ReactNode;
29
secondary?: boolean;
30
className?: string;
31
disabled?: boolean;
32
} & Omit<JSX.IntrinsicElements["button"], "content">) => {
33
+
const cardBorderHidden = useCardBorderHidden();
34
return (
35
<button
36
className={`
···
57
first: boolean | undefined;
58
isFocused: boolean;
59
}) => {
60
return (
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 && (
68
<PageOptionButton
69
secondary
70
onClick={() => {
71
useUIState.getState().closePage(props.entityID);
···
74
<CloseTiny />
75
</PageOptionButton>
76
)}
77
+
<OptionsMenu entityID={props.entityID} first={!!props.first} />
78
+
<UndoButtons />
79
</div>
80
);
81
};
82
83
+
export const UndoButtons = () => {
84
let undoState = useUndoState();
85
let { undoManager } = useReplicache();
86
return (
87
<Media mobile>
88
{undoState.canUndo && (
89
<div className="gap-1 flex sm:flex-col">
90
+
<PageOptionButton secondary onClick={() => undoManager.undo()}>
91
<UndoTiny />
92
</PageOptionButton>
93
94
<PageOptionButton
95
secondary
96
onClick={() => undoManager.undo()}
97
disabled={!undoState.canRedo}
98
>
···
104
);
105
};
106
107
+
export const OptionsMenu = (props: { entityID: string; first: boolean }) => {
108
let [state, setState] = useState<"normal" | "theme" | "share">("normal");
109
let { permissions } = useEntitySetContext();
110
if (!permissions.write) return null;
···
119
if (!open) setState("normal");
120
}}
121
trigger={
122
+
<PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8">
123
<MoreOptionsTiny className="sm:rotate-90" />
124
</PageOptionButton>
125
}
+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 />โ
+3
-18
components/Pages/useCardBorderHidden.ts
+3
-18
components/Pages/useCardBorderHidden.ts
···
1
-
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
2
-
import { PubLeafletPublication } from "lexicons/api";
3
-
import { useEntity, useReplicache } from "src/replicache";
4
5
-
export function useCardBorderHidden(entityID: string | null) {
6
-
let { rootEntity } = useReplicache();
7
-
let { data: pub } = useLeafletPublicationData();
8
-
let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
9
-
10
-
let cardBorderHidden =
11
-
useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden;
12
-
if (!cardBorderHidden && !rootCardBorderHidden) {
13
-
if (pub?.publications?.record) {
14
-
let record = pub.publications.record as PubLeafletPublication.Record;
15
-
return !record.theme?.showPageBackground;
16
-
}
17
-
return false;
18
-
}
19
-
return (cardBorderHidden || rootCardBorderHidden)?.data.value;
20
}
+44
-31
components/PostListing.tsx
+44
-31
components/PostListing.tsx
···
13
14
import Link from "next/link";
15
import { InteractionPreview } from "./InteractionsPreview";
16
17
export const PostListing = (props: Post) => {
18
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
19
20
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
let postUri = new AtUri(props.documents.uri);
22
23
-
let theme = usePubTheme(pubRecord.theme);
24
-
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
25
-
? blobRefToSrc(
26
-
pubRecord?.theme?.backgroundImage?.image?.ref,
27
-
new AtUri(props.publication.uri).host,
28
-
)
29
-
: null;
30
31
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
32
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
33
34
-
let showPageBackground = pubRecord.theme?.showPageBackground;
35
36
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
37
let comments =
38
-
pubRecord.preferences?.showComments === false
39
? 0
40
: props.documents.comments_on_documents?.[0]?.count || 0;
41
let tags = (postRecord?.tags as string[] | undefined) || [];
42
43
return (
44
<BaseThemeProvider {...theme} local>
45
<div
46
style={{
47
-
backgroundImage: `url(${backgroundImage})`,
48
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
49
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
50
}}
···
55
hover:outline-accent-contrast hover:border-accent-contrast
56
`}
57
>
58
-
<Link
59
-
className="h-full w-full absolute top-0 left-0"
60
-
href={`${props.publication.href}/${postUri.rkey}`}
61
-
/>
62
<div
63
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
64
style={{
···
71
72
<p className="text-secondary italic">{postRecord.description}</p>
73
<div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full">
74
-
<PubInfo
75
-
href={props.publication.href}
76
-
pubRecord={pubRecord}
77
-
uri={props.publication.uri}
78
-
/>
79
<div className="flex flex-row justify-between gap-2 items-center w-full">
80
<PostInfo publishedAt={postRecord.publishedAt} />
81
<InteractionPreview
82
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
83
quotesCount={quotes}
84
commentsCount={comments}
85
tags={tags}
86
-
showComments={pubRecord.preferences?.showComments}
87
share
88
/>
89
</div>
···
114
};
115
116
const PostInfo = (props: { publishedAt: string | undefined }) => {
117
return (
118
<div className="flex gap-2 items-center shrink-0 self-start">
119
{props.publishedAt && (
120
<>
121
-
<div className="shrink-0">
122
-
{new Date(props.publishedAt).toLocaleDateString("en-US", {
123
-
year: "numeric",
124
-
month: "short",
125
-
day: "numeric",
126
-
})}
127
-
</div>
128
</>
129
)}
130
</div>
···
13
14
import Link from "next/link";
15
import { InteractionPreview } from "./InteractionsPreview";
16
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
17
18
export const PostListing = (props: Post) => {
19
+
let pubRecord = props.publication?.pubRecord as
20
+
| PubLeafletPublication.Record
21
+
| undefined;
22
23
let postRecord = props.documents.data as PubLeafletDocument.Record;
24
let postUri = new AtUri(props.documents.uri);
25
+
let uri = props.publication ? props.publication?.uri : props.documents.uri;
26
27
+
// For standalone documents (no publication), pass isStandalone to get correct defaults
28
+
let isStandalone = !pubRecord;
29
+
let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone);
30
+
let themeRecord = pubRecord?.theme || postRecord?.theme;
31
+
let backgroundImage =
32
+
themeRecord?.backgroundImage?.image?.ref && uri
33
+
? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host)
34
+
: null;
35
36
+
let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat;
37
+
let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
38
39
+
let showPageBackground = pubRecord
40
+
? pubRecord?.theme?.showPageBackground
41
+
: postRecord.theme?.showPageBackground ?? true;
42
43
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
44
let comments =
45
+
pubRecord?.preferences?.showComments === false
46
? 0
47
: props.documents.comments_on_documents?.[0]?.count || 0;
48
let tags = (postRecord?.tags as string[] | undefined) || [];
49
50
+
// For standalone posts, link directly to the document
51
+
let postHref = props.publication
52
+
? `${props.publication.href}/${postUri.rkey}`
53
+
: `/p/${postUri.host}/${postUri.rkey}`;
54
+
55
return (
56
<BaseThemeProvider {...theme} local>
57
<div
58
style={{
59
+
backgroundImage: backgroundImage
60
+
? `url(${backgroundImage})`
61
+
: undefined,
62
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
63
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
64
}}
···
69
hover:outline-accent-contrast hover:border-accent-contrast
70
`}
71
>
72
+
<Link className="h-full w-full absolute top-0 left-0" href={postHref} />
73
<div
74
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
75
style={{
···
82
83
<p className="text-secondary italic">{postRecord.description}</p>
84
<div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full">
85
+
{props.publication && pubRecord && (
86
+
<PubInfo
87
+
href={props.publication.href}
88
+
pubRecord={pubRecord}
89
+
uri={props.publication.uri}
90
+
/>
91
+
)}
92
<div className="flex flex-row justify-between gap-2 items-center w-full">
93
<PostInfo publishedAt={postRecord.publishedAt} />
94
<InteractionPreview
95
+
postUrl={postHref}
96
quotesCount={quotes}
97
commentsCount={comments}
98
tags={tags}
99
+
showComments={pubRecord?.preferences?.showComments}
100
+
showMentions={pubRecord?.preferences?.showMentions}
101
share
102
/>
103
</div>
···
128
};
129
130
const PostInfo = (props: { publishedAt: string | undefined }) => {
131
+
let localizedDate = useLocalizedDate(props.publishedAt || "", {
132
+
year: "numeric",
133
+
month: "short",
134
+
day: "numeric",
135
+
});
136
return (
137
<div className="flex gap-2 items-center shrink-0 self-start">
138
{props.publishedAt && (
139
<>
140
+
<div className="shrink-0">{localizedDate}</div>
141
</>
142
)}
143
</div>
+98
components/ProfilePopover.tsx
+98
components/ProfilePopover.tsx
···
···
1
+
"use client";
2
+
import { Popover } from "./Popover";
3
+
import useSWR from "swr";
4
+
import { callRPC } from "app/api/rpc/client";
5
+
import { useRef, useState } from "react";
6
+
import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader";
7
+
import { SpeedyLink } from "./SpeedyLink";
8
+
import { Tooltip } from "./Tooltip";
9
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
10
+
11
+
export const ProfilePopover = (props: {
12
+
trigger: React.ReactNode;
13
+
didOrHandle: string;
14
+
}) => {
15
+
const [isOpen, setIsOpen] = useState(false);
16
+
let [isHovered, setIsHovered] = useState(false);
17
+
const hoverTimeout = useRef<null | number>(null);
18
+
19
+
const { data, isLoading } = useSWR(
20
+
isHovered ? ["profile-data", props.didOrHandle] : null,
21
+
async () => {
22
+
const response = await callRPC("get_profile_data", {
23
+
didOrHandle: props.didOrHandle,
24
+
});
25
+
return response.result;
26
+
},
27
+
);
28
+
29
+
return (
30
+
<Tooltip
31
+
className="max-w-sm p-0! text-center"
32
+
asChild
33
+
trigger={
34
+
<a
35
+
className="no-underline"
36
+
href={`https://leaflet.pub/p/${props.didOrHandle}`}
37
+
target="_blank"
38
+
onPointerEnter={(e) => {
39
+
if (hoverTimeout.current) {
40
+
window.clearTimeout(hoverTimeout.current);
41
+
}
42
+
hoverTimeout.current = window.setTimeout(async () => {
43
+
setIsHovered(true);
44
+
}, 150);
45
+
}}
46
+
onPointerLeave={() => {
47
+
if (isHovered) return;
48
+
if (hoverTimeout.current) {
49
+
window.clearTimeout(hoverTimeout.current);
50
+
hoverTimeout.current = null;
51
+
}
52
+
setIsHovered(false);
53
+
}}
54
+
>
55
+
{props.trigger}
56
+
</a>
57
+
}
58
+
onOpenChange={setIsOpen}
59
+
>
60
+
{isLoading ? (
61
+
<div className="text-secondary p-4">Loading...</div>
62
+
) : data ? (
63
+
<div>
64
+
<ProfileHeader
65
+
profile={data.profile}
66
+
publications={data.publications}
67
+
popover
68
+
/>
69
+
<KnownFollowers viewer={data.profile.viewer} did={data.profile.did} />
70
+
</div>
71
+
) : (
72
+
<div className="text-secondary py-2 px-4">Profile not found</div>
73
+
)}
74
+
</Tooltip>
75
+
);
76
+
};
77
+
78
+
let KnownFollowers = (props: {
79
+
viewer: ProfileViewDetailed["viewer"];
80
+
did: string;
81
+
}) => {
82
+
if (!props.viewer?.knownFollowers) return null;
83
+
let count = props.viewer.knownFollowers.count;
84
+
return (
85
+
<>
86
+
<hr className="border-border" />
87
+
Followed by{" "}
88
+
<a
89
+
className="hover:underline"
90
+
href={`https://bsky.social/profile/${props.did}/known-followers`}
91
+
target="_blank"
92
+
>
93
+
{props.viewer?.knownFollowers?.followers[0]?.displayName}{" "}
94
+
{count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""}
95
+
</a>
96
+
</>
97
+
);
98
+
};
+18
components/Tab.tsx
+18
components/Tab.tsx
···
···
1
+
import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
2
+
3
+
export const Tab = (props: {
4
+
name: string;
5
+
selected: boolean;
6
+
onSelect: () => void;
7
+
href?: string;
8
+
}) => {
9
+
return (
10
+
<div
11
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
12
+
onClick={() => props.onSelect()}
13
+
>
14
+
{props.name}
15
+
{props.href && <ExternalLinkTiny />}
16
+
</div>
17
+
);
18
+
};
+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
+16
-5
components/ThemeManager/PublicationThemeProvider.tsx
+16
-5
components/ThemeManager/PublicationThemeProvider.tsx
···
4
import { useEntity } from "src/replicache";
5
import { getColorContrast } from "./themeUtils";
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
-
import { BaseThemeProvider } from "./ThemeProvider";
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
9
import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
···
102
pub_creator: string;
103
isStandalone?: boolean;
104
}) {
105
-
let colors = usePubTheme(props.theme, props.isStandalone);
106
return (
107
-
<BaseThemeProvider local={props.local} {...colors}>
108
-
{props.children}
109
-
</BaseThemeProvider>
110
);
111
}
112
···
124
bgPage = bgLeaflet;
125
}
126
let showPageBackground = theme?.showPageBackground;
127
128
let primary = useColor(theme, "primary");
129
···
144
highlight2,
145
highlight3,
146
showPageBackground,
147
};
148
};
149
···
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";
9
import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
···
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>
119
);
120
}
121
···
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
+51
-23
components/ThemeManager/ThemeProvider.tsx
+51
-23
components/ThemeManager/ThemeProvider.tsx
···
1
"use client";
2
3
-
import {
4
-
createContext,
5
-
CSSProperties,
6
-
useContext,
7
-
useEffect,
8
-
} from "react";
9
import {
10
colorToString,
11
useColorAttribute,
···
58
}) {
59
let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
60
let bgPage = useColorAttribute(props.entityID, "theme/card-background");
61
-
let showPageBackground = !useEntity(
62
props.entityID,
63
"theme/card-border-hidden",
64
)?.data.value;
65
let primary = useColorAttribute(props.entityID, "theme/primary");
66
67
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
71
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
72
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
73
74
return (
75
-
<BaseThemeProvider
76
-
local={props.local}
77
-
bgLeaflet={bgLeaflet}
78
-
bgPage={bgPage}
79
-
primary={primary}
80
-
highlight2={highlight2}
81
-
highlight3={highlight3}
82
-
highlight1={highlight1?.data.value}
83
-
accent1={accent1}
84
-
accent2={accent2}
85
-
showPageBackground={showPageBackground}
86
-
>
87
-
{props.children}
88
-
</BaseThemeProvider>
89
);
90
}
91
···
93
export const BaseThemeProvider = ({
94
local,
95
bgLeaflet,
96
-
bgPage,
97
primary,
98
accent1,
99
accent2,
···
101
highlight2,
102
highlight3,
103
showPageBackground,
104
children,
105
}: {
106
local?: boolean;
107
showPageBackground?: boolean;
108
bgLeaflet: AriaColor;
109
bgPage: AriaColor;
110
primary: AriaColor;
···
113
highlight1?: string;
114
highlight2: AriaColor;
115
highlight3: AriaColor;
116
children: React.ReactNode;
117
}) => {
118
// set accent contrast to the accent color that has the highest contrast with the page background
119
let accentContrast;
120
···
191
"--accent-1-is-contrast",
192
accentContrast === accent1 ? "1" : "0",
193
);
194
}, [
195
local,
196
bgLeaflet,
···
202
accent1,
203
accent2,
204
accentContrast,
205
]);
206
return (
207
<div
···
221
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
222
"--highlight-2": colorToString(highlight2, "rgb"),
223
"--highlight-3": colorToString(highlight3, "rgb"),
224
} as CSSProperties
225
}
226
>
···
337
</div>
338
);
339
};
340
-
···
1
"use client";
2
3
+
import { createContext, CSSProperties, useContext, useEffect } from "react";
4
+
5
+
// Context for cardBorderHidden
6
+
export const CardBorderHiddenContext = createContext<boolean>(false);
7
+
8
+
export function useCardBorderHiddenContext() {
9
+
return useContext(CardBorderHiddenContext);
10
+
}
11
import {
12
colorToString,
13
useColorAttribute,
···
60
}) {
61
let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
62
let bgPage = useColorAttribute(props.entityID, "theme/card-background");
63
+
let cardBorderHiddenValue = useEntity(
64
props.entityID,
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");
···
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}>
83
+
<BaseThemeProvider
84
+
local={props.local}
85
+
bgLeaflet={bgLeaflet}
86
+
bgPage={bgPage}
87
+
primary={primary}
88
+
highlight2={highlight2}
89
+
highlight3={highlight3}
90
+
highlight1={highlight1?.data.value}
91
+
accent1={accent1}
92
+
accent2={accent2}
93
+
showPageBackground={showPageBackground}
94
+
pageWidth={pageWidth?.data.value}
95
+
hasBackgroundImage={hasBackgroundImage}
96
+
>
97
+
{props.children}
98
+
</BaseThemeProvider>
99
+
</CardBorderHiddenContext.Provider>
100
);
101
}
102
···
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
···
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
>
···
366
</div>
367
);
368
};
+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
}
+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
};
+2
-1
components/Toolbar/BlockToolbar.tsx
+2
-1
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
···
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
<ImageFullBleedButton />
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
{focusedEntityType?.data.value !== "canvas" && (
48
<Separator classname="h-6" />
49
)}
···
5
import { useUIState } from "src/useUIState";
6
import { LockBlockButton } from "./LockBlockButton";
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
+
import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar";
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
···
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
<ImageFullBleedButton />
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
+
<ImageCoverButton />
48
{focusedEntityType?.data.value !== "canvas" && (
49
<Separator classname="h-6" />
50
)}
+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/Tooltip.tsx
+1
-1
components/Tooltip.tsx
+18
lexicons/api/lexicons.ts
+18
lexicons/api/lexicons.ts
···
1447
maxLength: 50,
1448
},
1449
},
1450
pages: {
1451
type: 'array',
1452
items: {
···
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',
···
1447
maxLength: 50,
1448
},
1449
},
1450
+
coverImage: {
1451
+
type: 'blob',
1452
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
1453
+
maxSize: 1000000,
1454
+
},
1455
pages: {
1456
type: 'array',
1457
items: {
···
1806
type: 'boolean',
1807
default: true,
1808
},
1809
+
showMentions: {
1810
+
type: 'boolean',
1811
+
default: true,
1812
+
},
1813
+
showPrevNext: {
1814
+
type: 'boolean',
1815
+
default: true,
1816
+
},
1817
},
1818
},
1819
theme: {
···
1829
backgroundImage: {
1830
type: 'ref',
1831
ref: 'lex:pub.leaflet.theme.backgroundImage',
1832
+
},
1833
+
pageWidth: {
1834
+
type: 'integer',
1835
+
minimum: 0,
1836
+
maximum: 1600,
1837
},
1838
primary: {
1839
type: 'union',
+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
+
}
+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": true
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
+
};
+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
+
}
-2
src/hooks/useLocalizedDate.ts
-2
src/hooks/useLocalizedDate.ts
+4
src/replicache/attributes.ts
+4
src/replicache/attributes.ts
+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
+
);
+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";