-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
6
import { email_auth_tokens, identities } from "drizzle/schema";
7
7
import { and, eq } from "drizzle-orm";
8
8
import { cookies } from "next/headers";
9
-
import { createIdentity } from "./createIdentity";
10
9
import { setAuthToken } from "src/auth";
11
10
import { pool } from "supabase/pool";
11
+
import { supabaseServerClient } from "supabase/serverClient";
12
12
13
13
async function sendAuthCode(email: string, code: string) {
14
14
if (process.env.NODE_ENV === "development") {
···
114
114
.from(identities)
115
115
.where(eq(identities.email, token.email));
116
116
if (!identity) {
117
-
let newIdentity = await createIdentity(db, { email: token.email });
118
-
identityID = newIdentity.id;
117
+
const { data: newIdentity } = await supabaseServerClient
118
+
.from("identities")
119
+
.insert({ email: token.email })
120
+
.select()
121
+
.single();
122
+
identityID = newIdentity!.id;
119
123
} else {
120
124
identityID = identity.id;
121
125
}
+7
-8
actions/login.ts
+7
-8
actions/login.ts
···
4
4
import {
5
5
email_auth_tokens,
6
6
identities,
7
-
entity_sets,
8
-
entities,
9
-
permission_tokens,
10
-
permission_token_rights,
11
7
permission_token_on_homepage,
12
8
poll_votes_on_entity,
13
9
} from "drizzle/schema";
14
10
import { and, eq, isNull } from "drizzle-orm";
15
11
import { cookies } from "next/headers";
16
12
import { redirect } from "next/navigation";
17
-
import { v7 } from "uuid";
18
-
import { createIdentity } from "./createIdentity";
19
13
import { pool } from "supabase/pool";
14
+
import { supabaseServerClient } from "supabase/serverClient";
20
15
21
16
export async function loginWithEmailToken(
22
17
localLeaflets: { token: { id: string }; added_at: string }[],
···
77
72
identity = existingIdentityFromCookie;
78
73
}
79
74
} else {
80
-
// Create a new identity
81
-
identity = await createIdentity(tx, { email: token.email });
75
+
const { data: newIdentity } = await supabaseServerClient
76
+
.from("identities")
77
+
.insert({ email: token.email })
78
+
.select()
79
+
.single();
80
+
identity = newIdentity!;
82
81
}
83
82
}
84
83
+45
-6
actions/publishToPublication.ts
+45
-6
actions/publishToPublication.ts
···
2
2
3
3
import * as Y from "yjs";
4
4
import * as base64 from "base64-js";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
import { getIdentityData } from "actions/getIdentityData";
7
10
import {
8
11
AtpBaseClient,
···
50
53
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
51
54
import { v7 } from "uuid";
52
55
56
+
type PublishResult =
57
+
| { success: true; rkey: string; record: PubLeafletDocument.Record }
58
+
| { success: false; error: OAuthSessionError };
59
+
53
60
export async function publishToPublication({
54
61
root_entity,
55
62
publication_uri,
···
57
64
title,
58
65
description,
59
66
tags,
67
+
cover_image,
60
68
entitiesToDelete,
61
69
}: {
62
70
root_entity: string;
···
65
73
title?: string;
66
74
description?: string;
67
75
tags?: string[];
76
+
cover_image?: string | null;
68
77
entitiesToDelete?: string[];
69
-
}) {
70
-
const oauthClient = await createOauthClient();
78
+
}): Promise<PublishResult> {
71
79
let identity = await getIdentityData();
72
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
80
+
if (!identity || !identity.atp_did) {
81
+
return {
82
+
success: false,
83
+
error: {
84
+
type: "oauth_session_expired",
85
+
message: "Not authenticated",
86
+
did: "",
87
+
},
88
+
};
89
+
}
73
90
74
-
let credentialSession = await oauthClient.restore(identity.atp_did);
91
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
92
+
if (!sessionResult.ok) {
93
+
return { success: false, error: sessionResult.error };
94
+
}
95
+
let credentialSession = sessionResult.value;
75
96
let agent = new AtpBaseClient(
76
97
credentialSession.fetchHandler.bind(credentialSession),
77
98
);
···
135
156
theme = await extractThemeFromFacts(facts, root_entity, agent);
136
157
}
137
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
+
138
176
let record: PubLeafletDocument.Record = {
139
177
publishedAt: new Date().toISOString(),
140
178
...existingRecord,
···
145
183
title: title || "Untitled",
146
184
description: description || "",
147
185
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
186
+
...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded
148
187
pages: pages.map((p) => {
149
188
if (p.type === "canvas") {
150
189
return {
···
217
256
await createMentionNotifications(result.uri, record, credentialSession.did!);
218
257
}
219
258
220
-
return { rkey, record: JSON.parse(JSON.stringify(record)) };
259
+
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
221
260
}
222
261
223
262
async function processBlocksToPages(
+1
-3
app/(home-pages)/discover/page.tsx
+1
-3
app/(home-pages)/discover/page.tsx
···
17
17
return (
18
18
<DashboardLayout
19
19
id="discover"
20
-
cardBorderHidden={false}
21
20
currentPage="discover"
22
21
defaultTab="default"
23
22
actions={null}
···
32
31
}
33
32
34
33
const DiscoverContent = async (props: { order: string }) => {
35
-
const orderValue =
36
-
props.order === "popular" ? "popular" : "recentlyUpdated";
34
+
const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated";
37
35
let { publications, nextCursor } = await getPublications(orderValue);
38
36
39
37
return (
+1
-1
app/(home-pages)/home/Actions/CreateNewButton.tsx
+1
-1
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
5
5
import { AddTiny } from "components/Icons/AddTiny";
6
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
7
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
8
-
import { Menu, MenuItem } from "components/Layout";
8
+
import { Menu, MenuItem } from "components/Menu";
9
9
import { useIsMobile } from "src/hooks/isMobile";
10
10
11
11
export const CreateNewLeafletButton = (props: {}) => {
-8
app/(home-pages)/home/HomeLayout.tsx
-8
app/(home-pages)/home/HomeLayout.tsx
···
20
20
useDashboardState,
21
21
} from "components/PageLayouts/DashboardLayout";
22
22
import { Actions } from "./Actions/Actions";
23
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
24
23
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
25
24
import { useState } from "react";
26
25
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
56
55
props.entityID,
57
56
"theme/background-image",
58
57
);
59
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
60
58
61
59
let [searchValue, setSearchValue] = useState("");
62
60
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
···
81
79
return (
82
80
<DashboardLayout
83
81
id="home"
84
-
cardBorderHidden={cardBorderHidden}
85
82
currentPage="home"
86
83
defaultTab="home"
87
84
actions={<Actions />}
···
101
98
<HomeLeafletList
102
99
titles={props.titles}
103
100
initialFacts={props.initialFacts}
104
-
cardBorderHidden={cardBorderHidden}
105
101
searchValue={debouncedSearchValue}
106
102
/>
107
103
),
···
117
113
[root_entity: string]: Fact<Attribute>[];
118
114
};
119
115
searchValue: string;
120
-
cardBorderHidden: boolean;
121
116
}) {
122
117
let { identity } = useIdentityData();
123
118
let { data: initialFacts } = useSWR(
···
171
166
searchValue={props.searchValue}
172
167
leaflets={leaflets}
173
168
titles={initialFacts?.titles || {}}
174
-
cardBorderHidden={props.cardBorderHidden}
175
169
initialFacts={initialFacts?.facts || {}}
176
170
showPreview
177
171
/>
···
192
186
[root_entity: string]: Fact<Attribute>[];
193
187
};
194
188
searchValue: string;
195
-
cardBorderHidden: boolean;
196
189
showPreview?: boolean;
197
190
}) {
198
191
let { identity } = useIdentityData();
···
238
231
loggedIn={!!identity}
239
232
display={display}
240
233
added_at={added_at}
241
-
cardBorderHidden={props.cardBorderHidden}
242
234
index={index}
243
235
showPreview={props.showPreview}
244
236
isHidden={
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
4
4
import { useState, useRef, useEffect } from "react";
5
5
import { SpeedyLink } from "components/SpeedyLink";
6
6
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
8
8
9
export const LeafletListItem = (props: {
9
10
archived?: boolean | null;
10
11
loggedIn: boolean;
11
12
display: "list" | "grid";
12
-
cardBorderHidden: boolean;
13
13
added_at: string;
14
14
title?: string;
15
15
index: number;
16
16
isHidden: boolean;
17
17
showPreview?: boolean;
18
18
}) => {
19
+
const cardBorderHidden = useCardBorderHidden();
19
20
const pubStatus = useLeafletPublicationStatus();
20
21
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
21
22
let previewRef = useRef<HTMLDivElement | null>(null);
···
47
48
ref={previewRef}
48
49
className={`relative flex gap-3 w-full
49
50
${props.isHidden ? "hidden" : "flex"}
50
-
${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
+
${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
52
style={{
52
-
backgroundColor: props.cardBorderHidden
53
+
backgroundColor: cardBorderHidden
53
54
? "transparent"
54
55
: "rgba(var(--bg-page), var(--bg-page-alpha))",
55
56
}}
···
67
68
loggedIn={props.loggedIn}
68
69
/>
69
70
</div>
70
-
{props.cardBorderHidden && (
71
+
{cardBorderHidden && (
71
72
<hr
72
73
className="last:hidden border-border-light"
73
74
style={{
···
87
88
${props.isHidden ? "hidden" : "flex"}
88
89
`}
89
90
style={{
90
-
backgroundColor: props.cardBorderHidden
91
+
backgroundColor: cardBorderHidden
91
92
? "transparent"
92
93
: "rgba(var(--bg-page), var(--bg-page-alpha))",
93
94
}}
+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
18
const firstPage = useEntity(root, "root/page")[0];
19
19
const page = firstPage?.data.value || root;
20
20
21
-
const cardBorderHidden = useCardBorderHidden(root);
21
+
const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data
22
+
.value;
22
23
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
23
24
const rootBackgroundRepeat = useEntity(
24
25
root,
···
49
50
50
51
const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`;
51
52
52
-
return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass };
53
+
return {
54
+
root,
55
+
page,
56
+
cardBorderHidden,
57
+
contentWrapperStyle,
58
+
contentWrapperClass,
59
+
};
53
60
}
54
61
55
62
export const LeafletListPreview = (props: { isVisible: boolean }) => {
56
-
const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } =
57
-
useLeafletPreviewData();
63
+
const {
64
+
root,
65
+
page,
66
+
cardBorderHidden,
67
+
contentWrapperStyle,
68
+
contentWrapperClass,
69
+
} = useLeafletPreviewData();
58
70
59
71
return (
60
72
<Tooltip
61
-
open={true}
62
-
delayDuration={0}
63
73
side="right"
74
+
asChild
64
75
trigger={
65
-
<div className="w-12 h-full py-1">
76
+
<div className="w-12 h-full py-1 z-10">
66
77
<div className="rounded-md h-full overflow-hidden">
67
78
<ThemeProvider local entityID={root} className="">
68
79
<ThemeBackgroundProvider entityID={root}>
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
1
"use client";
2
2
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
3
import { useState } from "react";
5
4
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
5
import { Fact, PermissionToken } from "src/replicache";
···
30
29
[searchValue],
31
30
);
32
31
33
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
34
32
return (
35
33
<DashboardLayout
36
34
id="looseleafs"
37
-
cardBorderHidden={cardBorderHidden}
38
35
currentPage="looseleafs"
39
36
defaultTab="home"
40
37
actions={<Actions />}
···
45
42
<LooseleafList
46
43
titles={props.titles}
47
44
initialFacts={props.initialFacts}
48
-
cardBorderHidden={cardBorderHidden}
49
45
searchValue={debouncedSearchValue}
50
46
/>
51
47
),
···
61
57
[root_entity: string]: Fact<Attribute>[];
62
58
};
63
59
searchValue: string;
64
-
cardBorderHidden: boolean;
65
60
}) => {
66
61
let { identity } = useIdentityData();
67
62
let { data: initialFacts } = useSWR(
···
108
103
searchValue={props.searchValue}
109
104
leaflets={leaflets}
110
105
titles={initialFacts?.titles || {}}
111
-
cardBorderHidden={props.cardBorderHidden}
112
106
initialFacts={initialFacts?.facts || {}}
113
107
showPreview
114
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
···
32
32
33
33
const { data, error, size, setSize, isValidating } = useSWRInfinite(
34
34
getKey,
35
-
([_, cursor]) => getSubscriptions(cursor),
35
+
([_, cursor]) => getSubscriptions(null, cursor),
36
36
{
37
37
fallbackData: [
38
38
{ subscriptions: props.publications, nextCursor: props.nextCursor },
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
···
8
8
import { idResolver } from "./idResolver";
9
9
import { Cursor } from "./getReaderFeed";
10
10
11
-
export async function getSubscriptions(cursor?: Cursor | null): Promise<{
11
+
export async function getSubscriptions(
12
+
did?: string | null,
13
+
cursor?: Cursor | null,
14
+
): Promise<{
12
15
nextCursor: null | Cursor;
13
16
subscriptions: PublicationSubscription[];
14
17
}> {
15
-
let auth_res = await getIdentityData();
16
-
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
18
+
// If no DID provided, use logged-in user's DID
19
+
let identity = did;
20
+
if (!identity) {
21
+
const auth_res = await getIdentityData();
22
+
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
23
+
identity = auth_res.atp_did;
24
+
}
25
+
17
26
let query = supabaseServerClient
18
27
.from("publication_subscriptions")
19
28
.select(`*, publications(*, documents_in_publications(*, documents(*)))`)
···
25
34
})
26
35
.limit(1, { referencedTable: "publications.documents_in_publications" })
27
36
.limit(25)
28
-
.eq("identity", auth_res.atp_did);
37
+
.eq("identity", identity);
29
38
30
39
if (cursor) {
31
40
query = query.or(
-1
app/(home-pages)/reader/page.tsx
-1
app/(home-pages)/reader/page.tsx
+9
-1
app/(home-pages)/tag/[tag]/page.tsx
+9
-1
app/(home-pages)/tag/[tag]/page.tsx
···
3
3
import { PostListing } from "components/PostListing";
4
4
import { getDocumentsByTag } from "./getDocumentsByTag";
5
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
+
}
6
15
7
16
export default async function TagPage(props: {
8
17
params: Promise<{ tag: string }>;
···
14
23
return (
15
24
<DashboardLayout
16
25
id="tag"
17
-
cardBorderHidden={false}
18
26
currentPage="tag"
19
27
defaultTab="default"
20
28
actions={null}
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
+1
-1
app/[leaflet_id]/actions/HelpButton.tsx
···
161
161
className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline"
162
162
style={{
163
163
backgroundColor: isHovered
164
-
? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)"
164
+
? "rgb(var(--accent-light))"
165
165
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
166
166
}}
167
167
onMouseEnter={handleMouseEnter}
+45
-7
app/[leaflet_id]/actions/PublishButton.tsx
+45
-7
app/[leaflet_id]/actions/PublishButton.tsx
···
13
13
import { PublishSmall } from "components/Icons/PublishSmall";
14
14
import { useIdentityData } from "components/IdentityProvider";
15
15
import { InputWithLabel } from "components/Input";
16
-
import { Menu, MenuItem } from "components/Layout";
16
+
import { Menu, MenuItem } from "components/Menu";
17
17
import {
18
18
useLeafletDomains,
19
19
useLeafletPublicationData,
···
39
39
import { BlueskyLogin } from "app/login/LoginForm";
40
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41
41
import { AddTiny } from "components/Icons/AddTiny";
42
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
42
43
43
44
export const PublishButton = (props: { entityID: string }) => {
44
45
let { data: pub } = useLeafletPublicationData();
···
68
69
let { identity } = useIdentityData();
69
70
let toaster = useToaster();
70
71
72
+
// Get title and description from Replicache state (same as draft editor)
73
+
// This ensures we use the latest edited values, not stale cached data
74
+
let replicacheTitle = useSubscribe(rep, (tx) =>
75
+
tx.get<string>("publication_title"),
76
+
);
77
+
let replicacheDescription = useSubscribe(rep, (tx) =>
78
+
tx.get<string>("publication_description"),
79
+
);
80
+
81
+
// Use Replicache state if available, otherwise fall back to pub data
82
+
const currentTitle =
83
+
typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || "";
84
+
const currentDescription =
85
+
typeof replicacheDescription === "string"
86
+
? replicacheDescription
87
+
: pub?.description || "";
88
+
71
89
// Get tags from Replicache state (same as draft editor)
72
90
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73
91
const currentTags = Array.isArray(tags) ? tags : [];
74
92
93
+
// Get cover image from Replicache state
94
+
let coverImage = useSubscribe(rep, (tx) =>
95
+
tx.get<string | null>("publication_cover_image"),
96
+
);
97
+
75
98
return (
76
99
<ActionButton
77
100
primary
···
80
103
onClick={async () => {
81
104
if (!pub) return;
82
105
setIsLoading(true);
83
-
let doc = await publishToPublication({
106
+
let result = await publishToPublication({
84
107
root_entity: rootEntity,
85
108
publication_uri: pub.publications?.uri,
86
109
leaflet_id: permission_token.id,
87
-
title: pub.title,
88
-
description: pub.description,
110
+
title: currentTitle,
111
+
description: currentDescription,
89
112
tags: currentTags,
113
+
cover_image: coverImage,
90
114
});
91
115
setIsLoading(false);
92
116
mutate();
93
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
+
94
130
// Generate URL based on whether it's in a publication or standalone
95
131
let docUrl = pub.publications
96
-
? `${getPublicationURL(pub.publications)}/${doc?.rkey}`
97
-
: `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`;
132
+
? `${getPublicationURL(pub.publications)}/${result.rkey}`
133
+
: `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`;
98
134
99
135
toaster({
100
136
content: (
101
137
<div>
102
138
{pub.doc ? "Updated! " : "Published! "}
103
-
<SpeedyLink href={docUrl}>link</SpeedyLink>
139
+
<SpeedyLink className="underline" href={docUrl}>
140
+
See Published Post
141
+
</SpeedyLink>
104
142
</div>
105
143
),
106
144
type: "success",
+54
-22
app/[leaflet_id]/publish/PublishPost.tsx
+54
-22
app/[leaflet_id]/publish/PublishPost.tsx
···
22
22
import { TagSelector } from "../../../components/Tags";
23
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
24
24
import { PubIcon } from "components/ActionBar/Publications";
25
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
25
26
26
27
type Props = {
27
28
title: string;
···
65
66
let [charCount, setCharCount] = useState(0);
66
67
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
67
68
let [isLoading, setIsLoading] = useState(false);
69
+
let [oauthError, setOauthError] = useState<
70
+
import("src/atproto-oauth").OAuthSessionError | null
71
+
>(null);
68
72
let params = useParams();
69
73
let { rep } = useReplicache();
70
74
···
73
77
tx.get<string[]>("publication_tags"),
74
78
);
75
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
+
);
76
85
77
86
// Use Replicache tags only when we have a draft
78
87
const hasDraft = props.hasDraft;
···
96
105
async function submit() {
97
106
if (isLoading) return;
98
107
setIsLoading(true);
108
+
setOauthError(null);
99
109
await rep?.push();
100
-
let doc = await publishToPublication({
110
+
let result = await publishToPublication({
101
111
root_entity: props.root_entity,
102
112
publication_uri: props.publication_uri,
103
113
leaflet_id: props.leaflet_id,
104
114
title: props.title,
105
115
description: props.description,
106
116
tags: currentTags,
117
+
cover_image: replicacheCoverImage,
107
118
entitiesToDelete: props.entitiesToDelete,
108
119
});
109
-
if (!doc) return;
120
+
121
+
if (!result.success) {
122
+
setIsLoading(false);
123
+
if (isOAuthSessionError(result.error)) {
124
+
setOauthError(result.error);
125
+
}
126
+
return;
127
+
}
110
128
111
129
// Generate post URL based on whether it's in a publication or standalone
112
130
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}`;
131
+
? `https://${props.record.base_path}/${result.rkey}`
132
+
: `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`;
115
133
116
134
let [text, facets] = editorStateRef.current
117
135
? editorStateToFacetedText(editorStateRef.current)
118
136
: [];
119
-
if (shareOption === "bluesky")
120
-
await publishPostToBsky({
137
+
if (shareOption === "bluesky") {
138
+
let bskyResult = await publishPostToBsky({
121
139
facets: facets || [],
122
140
text: text || "",
123
141
title: props.title,
124
142
url: post_url,
125
143
description: props.description,
126
-
document_record: doc.record,
127
-
rkey: doc.rkey,
144
+
document_record: result.record,
145
+
rkey: result.rkey,
128
146
});
147
+
if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) {
148
+
setIsLoading(false);
149
+
setOauthError(bskyResult.error);
150
+
return;
151
+
}
152
+
}
129
153
setIsLoading(false);
130
154
props.setPublishState({ state: "success", post_url });
131
155
}
···
162
186
</div>
163
187
<hr className="border-border mb-2" />
164
188
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>
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
+
)}
179
211
</div>
180
212
</div>
181
213
</form>
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
+56
-16
app/[leaflet_id]/publish/publishBskyPost.ts
···
9
9
import { TID } from "@atproto/common";
10
10
import { getIdentityData } from "actions/getIdentityData";
11
11
import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
12
-
import { createOauthClient } from "src/atproto-oauth";
12
+
import {
13
+
restoreOAuthSession,
14
+
OAuthSessionError,
15
+
} from "src/atproto-oauth";
13
16
import { supabaseServerClient } from "supabase/serverClient";
14
17
import { Json } from "supabase/database.types";
15
18
import {
16
19
getMicroLinkOgImage,
17
20
getWebpageImage,
18
21
} from "src/utils/getMicroLinkOgImage";
22
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
23
+
24
+
type PublishBskyResult =
25
+
| { success: true }
26
+
| { success: false; error: OAuthSessionError };
19
27
20
28
export async function publishPostToBsky(args: {
21
29
text: string;
···
25
33
document_record: PubLeafletDocument.Record;
26
34
rkey: string;
27
35
facets: AppBskyRichtextFacet.Main[];
28
-
}) {
29
-
const oauthClient = await createOauthClient();
36
+
}): Promise<PublishBskyResult> {
30
37
let identity = await getIdentityData();
31
-
if (!identity || !identity.atp_did) return null;
38
+
if (!identity || !identity.atp_did) {
39
+
return {
40
+
success: false,
41
+
error: {
42
+
type: "oauth_session_expired",
43
+
message: "Not authenticated",
44
+
did: "",
45
+
},
46
+
};
47
+
}
32
48
33
-
let credentialSession = await oauthClient.restore(identity.atp_did);
49
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
50
+
if (!sessionResult.ok) {
51
+
return { success: false, error: sessionResult.error };
52
+
}
53
+
let credentialSession = sessionResult.value;
34
54
let agent = new AtpBaseClient(
35
55
credentialSession.fetchHandler.bind(credentialSession),
36
56
);
37
-
let newPostUrl = args.url;
38
-
let preview_image = await getWebpageImage(newPostUrl, {
39
-
width: 1400,
40
-
height: 733,
41
-
noCache: true,
42
-
});
43
57
44
-
let binary = await preview_image.blob();
45
-
let resized_preview_image = await sharp(await binary.arrayBuffer())
58
+
// Get image binary - prefer cover image, fall back to screenshot
59
+
let imageBinary: Blob | null = null;
60
+
61
+
if (args.document_record.coverImage) {
62
+
let cid =
63
+
(args.document_record.coverImage.ref as unknown as { $link: string })[
64
+
"$link"
65
+
] || args.document_record.coverImage.ref.toString();
66
+
67
+
let coverImageResponse = await fetchAtprotoBlob(identity.atp_did, cid);
68
+
if (coverImageResponse) {
69
+
imageBinary = await coverImageResponse.blob();
70
+
}
71
+
}
72
+
73
+
// Fall back to screenshot if no cover image or fetch failed
74
+
if (!imageBinary) {
75
+
let preview_image = await getWebpageImage(args.url, {
76
+
width: 1400,
77
+
height: 733,
78
+
noCache: true,
79
+
});
80
+
imageBinary = await preview_image.blob();
81
+
}
82
+
83
+
// Resize and upload
84
+
let resizedImage = await sharp(await imageBinary.arrayBuffer())
46
85
.resize({
47
86
width: 1200,
87
+
height: 630,
48
88
fit: "cover",
49
89
})
50
90
.webp({ quality: 85 })
51
91
.toBuffer();
52
92
53
-
let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, {
54
-
headers: { "Content-Type": binary.type },
93
+
let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, {
94
+
headers: { "Content-Type": "image/webp" },
55
95
});
56
96
let bsky = new BskyAgent(credentialSession);
57
97
let post = await bsky.app.bsky.feed.post.create(
···
90
130
data: record as Json,
91
131
})
92
132
.eq("uri", result.uri);
93
-
return true;
133
+
return { success: true };
94
134
}
+29
-11
app/api/atproto_images/route.ts
+29
-11
app/api/atproto_images/route.ts
···
1
1
import { IdResolver } from "@atproto/identity";
2
2
import { NextRequest, NextResponse } from "next/server";
3
+
3
4
let idResolver = new IdResolver();
4
5
5
-
export async function GET(req: NextRequest) {
6
-
const url = new URL(req.url);
7
-
const params = {
8
-
did: url.searchParams.get("did") ?? "",
9
-
cid: url.searchParams.get("cid") ?? "",
10
-
};
11
-
if (!params.did || !params.cid)
12
-
return new NextResponse(null, { status: 404 });
6
+
/**
7
+
* Fetches a blob from an AT Protocol PDS given a DID and CID
8
+
* Returns the Response object or null if the blob couldn't be fetched
9
+
*/
10
+
export async function fetchAtprotoBlob(
11
+
did: string,
12
+
cid: string,
13
+
): Promise<Response | null> {
14
+
if (!did || !cid) return null;
13
15
14
-
let identity = await idResolver.did.resolve(params.did);
16
+
let identity = await idResolver.did.resolve(did);
15
17
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
16
-
if (!service) return new NextResponse(null, { status: 404 });
18
+
if (!service) return null;
19
+
17
20
const response = await fetch(
18
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`,
21
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
19
22
{
20
23
headers: {
21
24
"Accept-Encoding": "gzip, deflate, br, zstd",
22
25
},
23
26
},
24
27
);
28
+
29
+
if (!response.ok) return null;
30
+
31
+
return response;
32
+
}
33
+
34
+
export async function GET(req: NextRequest) {
35
+
const url = new URL(req.url);
36
+
const params = {
37
+
did: url.searchParams.get("did") ?? "",
38
+
cid: url.searchParams.get("cid") ?? "",
39
+
};
40
+
41
+
const response = await fetchAtprotoBlob(params.did, params.cid);
42
+
if (!response) return new NextResponse(null, { status: 404 });
25
43
26
44
// Clone the response to modify headers
27
45
const cachedResponse = new Response(response.body, response);
+41
app/api/bsky/agent.ts
+41
app/api/bsky/agent.ts
···
1
+
import { Agent } from "@atproto/api";
2
+
import { cookies } from "next/headers";
3
+
import { createOauthClient } from "src/atproto-oauth";
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
+
6
+
export async function getAuthenticatedAgent(): Promise<Agent | null> {
7
+
try {
8
+
const cookieStore = await cookies();
9
+
const authToken =
10
+
cookieStore.get("auth_token")?.value ||
11
+
cookieStore.get("external_auth_token")?.value;
12
+
13
+
if (!authToken || authToken === "null") return null;
14
+
15
+
const { data } = await supabaseServerClient
16
+
.from("email_auth_tokens")
17
+
.select("identities(atp_did)")
18
+
.eq("id", authToken)
19
+
.eq("confirmed", true)
20
+
.single();
21
+
22
+
const did = data?.identities?.atp_did;
23
+
if (!did) return null;
24
+
25
+
const oauthClient = await createOauthClient();
26
+
const session = await oauthClient.restore(did);
27
+
return new Agent(session);
28
+
} catch (error) {
29
+
console.error("Failed to get authenticated agent:", error);
30
+
return null;
31
+
}
32
+
}
33
+
34
+
export async function getAgent(): Promise<Agent> {
35
+
const agent = await getAuthenticatedAgent();
36
+
if (agent) return agent;
37
+
38
+
return new Agent({
39
+
service: "https://public.api.bsky.app",
40
+
});
41
+
}
+41
app/api/bsky/quotes/route.ts
+41
app/api/bsky/quotes/route.ts
···
1
+
import { lexToJson } from "@atproto/api";
2
+
import { NextRequest } from "next/server";
3
+
import { getAgent } from "../agent";
4
+
5
+
export const runtime = "nodejs";
6
+
7
+
export async function GET(req: NextRequest) {
8
+
try {
9
+
const searchParams = req.nextUrl.searchParams;
10
+
const uri = searchParams.get("uri");
11
+
const cursor = searchParams.get("cursor");
12
+
const limit = searchParams.get("limit");
13
+
14
+
if (!uri) {
15
+
return Response.json(
16
+
{ error: "uri parameter is required" },
17
+
{ status: 400 },
18
+
);
19
+
}
20
+
21
+
const agent = await getAgent();
22
+
23
+
const response = await agent.app.bsky.feed.getQuotes({
24
+
uri,
25
+
limit: limit ? parseInt(limit, 10) : 50,
26
+
cursor: cursor || undefined,
27
+
});
28
+
29
+
const result = lexToJson(response.data);
30
+
31
+
return Response.json(result, {
32
+
headers: {
33
+
// Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating
34
+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600",
35
+
},
36
+
});
37
+
} catch (error) {
38
+
console.error("Error fetching Bluesky quotes:", error);
39
+
return Response.json({ error: "Failed to fetch quotes" }, { status: 500 });
40
+
}
41
+
}
+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";
1
+
import { lexToJson } from "@atproto/api";
4
2
import { NextRequest } from "next/server";
5
-
import { createOauthClient } from "src/atproto-oauth";
6
-
import { supabaseServerClient } from "supabase/serverClient";
3
+
import { getAgent } from "../agent";
7
4
8
5
export const runtime = "nodejs";
9
6
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
7
export async function GET(req: NextRequest) {
39
8
try {
40
9
const searchParams = req.nextUrl.searchParams;
···
49
18
);
50
19
}
51
20
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
-
}
21
+
const agent = await getAgent();
59
22
60
23
const response = await agent.getPostThread({
61
24
uri,
+5
-7
app/api/inngest/functions/index_follows.ts
+5
-7
app/api/inngest/functions/index_follows.ts
···
1
1
import { supabaseServerClient } from "supabase/serverClient";
2
2
import { AtpAgent, AtUri } from "@atproto/api";
3
-
import { createIdentity } from "actions/createIdentity";
4
-
import { drizzle } from "drizzle-orm/node-postgres";
5
3
import { inngest } from "../client";
6
-
import { pool } from "supabase/pool";
7
4
8
5
export const index_follows = inngest.createFunction(
9
6
{
···
58
55
.eq("atp_did", event.data.did)
59
56
.single();
60
57
if (!exists) {
61
-
const client = await pool.connect();
62
-
let db = drizzle(client);
63
-
let identity = await createIdentity(db, { atp_did: event.data.did });
64
-
client.release();
58
+
const { data: identity } = await supabaseServerClient
59
+
.from("identities")
60
+
.insert({ atp_did: event.data.did })
61
+
.select()
62
+
.single();
65
63
return identity;
66
64
}
67
65
}),
+8
-9
app/api/oauth/[route]/route.ts
+8
-9
app/api/oauth/[route]/route.ts
···
1
-
import { createIdentity } from "actions/createIdentity";
2
1
import { subscribeToPublication } from "app/lish/subscribeToPublication";
3
-
import { drizzle } from "drizzle-orm/node-postgres";
4
2
import { cookies } from "next/headers";
5
3
import { redirect } from "next/navigation";
6
4
import { NextRequest, NextResponse } from "next/server";
···
13
11
ActionAfterSignIn,
14
12
parseActionFromSearchParam,
15
13
} from "./afterSignInActions";
16
-
import { pool } from "supabase/pool";
17
14
18
15
type OauthRequestClientState = {
19
16
redirect: string | null;
···
80
77
81
78
return handleAction(s.action, redirectPath);
82
79
}
83
-
const client = await pool.connect();
84
-
const db = drizzle(client);
85
-
identity = await createIdentity(db, { atp_did: session.did });
86
-
client.release();
80
+
const { data } = await supabaseServerClient
81
+
.from("identities")
82
+
.insert({ atp_did: session.did })
83
+
.select()
84
+
.single();
85
+
identity = data;
87
86
}
88
87
let { data: token } = await supabaseServerClient
89
88
.from("email_auth_tokens")
90
89
.insert({
91
-
identity: identity.id,
90
+
identity: identity!.id,
92
91
confirmed: true,
93
92
confirmation_code: "",
94
93
})
···
121
120
else url = new URL(decodeURIComponent(redirectPath), "https://example.com");
122
121
if (action?.action === "subscribe") {
123
122
let result = await subscribeToPublication(action.publication);
124
-
if (result.hasFeed === false)
123
+
if (result.success && result.hasFeed === false)
125
124
url.searchParams.set("showSubscribeSuccess", "true");
126
125
}
127
126
+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
74
description: string;
75
75
title: string;
76
76
tags: string[];
77
+
cover_image: string | null;
77
78
}[];
78
79
let pub_patch = publication_data?.[0]
79
80
? [
···
91
92
op: "put",
92
93
key: "publication_tags",
93
94
value: publication_data[0].tags || [],
95
+
},
96
+
{
97
+
op: "put",
98
+
key: "publication_cover_image",
99
+
value: publication_data[0].cover_image || null,
94
100
},
95
101
]
96
102
: [];
+2
app/api/rpc/[command]/route.ts
+2
app/api/rpc/[command]/route.ts
···
13
13
import { get_publication_data } from "./get_publication_data";
14
14
import { search_publication_names } from "./search_publication_names";
15
15
import { search_publication_documents } from "./search_publication_documents";
16
+
import { get_profile_data } from "./get_profile_data";
16
17
17
18
let supabase = createClient<Database>(
18
19
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
39
40
get_publication_data,
40
41
search_publication_names,
41
42
search_publication_documents,
43
+
get_profile_data,
42
44
];
43
45
export async function POST(
44
46
req: Request,
+40
-17
app/globals.css
+40
-17
app/globals.css
···
107
107
--highlight-3: 255, 205, 195;
108
108
109
109
--list-marker-width: 36px;
110
-
--page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12));
111
-
--page-width-units: min(624px, calc(100vw - 12px));
110
+
--page-width-unitless: min(
111
+
var(--page-width-setting),
112
+
calc(var(--leaflet-width-unitless) - 12)
113
+
);
114
+
--page-width-units: min(
115
+
calc(var(--page-width-unitless) * 1px),
116
+
calc(100vw - 12px)
117
+
);
112
118
113
119
--gripperSVG: url("/gripperPattern.svg");
114
120
--gripperSVG2: url("/gripperPattern2.svg");
···
125
131
126
132
@media (min-width: 640px) {
127
133
:root {
134
+
/*picks between max width and screen width with 64px of padding*/
128
135
--page-width-unitless: min(
129
-
624,
136
+
var(--page-width-setting),
130
137
calc(var(--leaflet-width-unitless) - 128)
131
138
);
132
-
--page-width-units: min(624px, calc(100vw - 128px));
133
-
}
134
-
}
135
-
136
-
@media (min-width: 1280px) {
137
-
:root {
138
-
--page-width-unitless: min(
139
-
624,
140
-
calc((var(--leaflet-width-unitless) / 2) - 32)
139
+
--page-width-units: min(
140
+
calc(var(--page-width-unitless) * 1px),
141
+
calc(100vw - 128px)
141
142
);
142
-
--page-width-units: min(624px, calc((100vw / 2) - 32px));
143
143
}
144
144
}
145
145
···
270
270
}
271
271
272
272
pre.shiki {
273
+
@apply sm:p-3;
273
274
@apply p-2;
274
275
@apply rounded-md;
275
276
@apply overflow-auto;
277
+
278
+
@media (min-width: 640px) {
279
+
@apply p-3;
280
+
}
276
281
}
277
282
278
283
.highlight:has(+ .highlight) {
···
296
301
@apply py-[1.5px];
297
302
}
298
303
299
-
/* Underline mention nodes when selected in ProseMirror */
304
+
.ProseMirror:focus-within .selection-highlight {
305
+
background-color: transparent;
306
+
}
307
+
300
308
.ProseMirror .atMention.ProseMirror-selectednode,
301
309
.ProseMirror .didMention.ProseMirror-selectednode {
302
-
text-decoration: underline;
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);
303
318
}
304
319
305
-
.ProseMirror:focus-within .selection-highlight {
306
-
background-color: transparent;
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;
307
330
}
308
331
309
332
.multiselected:focus-within .selection-highlight {
+19
-2
app/lish/Subscribe.tsx
+19
-2
app/lish/Subscribe.tsx
···
23
23
import { useSearchParams } from "next/navigation";
24
24
import LoginForm from "app/login/LoginForm";
25
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
26
27
27
28
export const SubscribeWithBluesky = (props: {
28
29
pubName: string;
···
133
134
}) => {
134
135
let { identity } = useIdentityData();
135
136
let toaster = useToaster();
137
+
let [oauthError, setOauthError] = useState<
138
+
import("src/atproto-oauth").OAuthSessionError | null
139
+
>(null);
136
140
let [, subscribe, subscribePending] = useActionState(async () => {
141
+
setOauthError(null);
137
142
let result = await subscribeToPublication(
138
143
props.pub_uri,
139
144
window.location.href + "?refreshAuth",
140
145
);
146
+
if (!result.success) {
147
+
if (isOAuthSessionError(result.error)) {
148
+
setOauthError(result.error);
149
+
}
150
+
return;
151
+
}
141
152
if (result.hasFeed === false) {
142
153
props.setSuccessModalOpen(true);
143
154
}
···
172
183
}
173
184
174
185
return (
175
-
<>
186
+
<div className="flex flex-col gap-2 place-self-center">
176
187
<form
177
188
action={subscribe}
178
189
className="place-self-center flex flex-row gap-1"
···
187
198
)}
188
199
</ButtonPrimary>
189
200
</form>
190
-
</>
201
+
{oauthError && (
202
+
<OAuthErrorMessage
203
+
error={oauthError}
204
+
className="text-center text-sm text-accent-1"
205
+
/>
206
+
)}
207
+
</div>
191
208
);
192
209
};
193
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
-
}
1
+
import { ProfilePopover } from "components/ProfilePopover";
2
+
import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore";
3
+
import { ReactNode } from "react";
123
4
124
-
*segments(): Generator<RichTextSegment, void, void> {
125
-
const facets = this.facets || [];
126
-
if (!facets.length) {
127
-
yield { text: this.unicodeText.utf16 };
128
-
return;
129
-
}
5
+
// Re-export RichText for backwards compatibility
6
+
export { RichText };
130
7
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
-
}
8
+
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
9
+
return (
10
+
<ProfilePopover
11
+
didOrHandle={props.did}
12
+
trigger={props.children}
13
+
/>
14
+
);
164
15
}
165
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
166
-
if (facets.length === 0) {
167
-
return [newFacet];
168
-
}
169
16
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;
17
+
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
18
+
return (
19
+
<TextBlockCore
20
+
{...props}
21
+
renderers={{
22
+
DidMention: DidMentionWithPopover,
23
+
}}
24
+
/>
25
+
);
206
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
57
<PageWrapper
58
58
pageType="canvas"
59
59
fullPageScroll={fullPageScroll}
60
-
cardBorderHidden={!hasPageBackground}
61
-
id={pageId ? `post-page-${pageId}` : "post-page"}
60
+
id={`post-page-${pageId ?? document_uri}`}
62
61
drawerOpen={
63
62
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
64
63
}
···
203
202
isSubpage: boolean | undefined;
204
203
data: PostPageData;
205
204
profile: ProfileViewDetailed;
206
-
preferences: { showComments?: boolean };
205
+
preferences: {
206
+
showComments?: boolean;
207
+
showMentions?: boolean;
208
+
showPrevNext?: boolean;
209
+
};
207
210
quotesCount: number | undefined;
208
211
commentsCount: number | undefined;
209
212
}) => {
···
214
217
quotesCount={props.quotesCount || 0}
215
218
commentsCount={props.commentsCount || 0}
216
219
showComments={props.preferences.showComments}
220
+
showMentions={props.preferences.showMentions}
217
221
pageId={props.pageId}
218
222
/>
219
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";
1
+
import { AtUri, UnicodeString } from "@atproto/api";
2
2
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
3
3
import { multiBlockSchema } from "components/Blocks/TextBlock/schema";
4
4
import { PubLeafletRichtextFacet } from "lexicons/api";
···
38
38
import { CloseTiny } from "components/Icons/CloseTiny";
39
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
40
40
import { betterIsUrl } from "src/utils/isURL";
41
+
import { useToaster } from "components/Toast";
42
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
41
43
import { Mention, MentionAutocomplete } from "components/Mention";
42
44
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
43
45
···
95
97
} = useInteractionState(props.doc_uri);
96
98
let [loading, setLoading] = useState(false);
97
99
let view = useRef<null | EditorView>(null);
100
+
let toaster = useToaster();
98
101
99
102
// Mention autocomplete state
100
103
const [mentionOpen, setMentionOpen] = useState(false);
···
161
164
setLoading(true);
162
165
let currentState = view.current.state;
163
166
let [plaintext, facets] = docToFacetedText(currentState.doc);
164
-
let comment = await publishComment({
167
+
let result = await publishComment({
165
168
pageId: props.pageId,
166
169
document: props.doc_uri,
167
170
comment: {
···
178
181
},
179
182
});
180
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
+
181
197
let tr = currentState.tr;
182
198
tr = tr.replaceWith(
183
199
0,
···
194
210
localComments: [
195
211
...s.localComments,
196
212
{
197
-
record: comment.record,
198
-
uri: comment.uri,
199
-
bsky_profiles: { record: comment.profile as Json },
213
+
record: result.record,
214
+
uri: result.uri,
215
+
bsky_profiles: {
216
+
record: result.profile as Json,
217
+
did: new AtUri(result.uri).host,
218
+
},
200
219
},
201
220
],
202
221
}));
+25
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
+25
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
3
3
import { AtpBaseClient, PubLeafletComment } from "lexicons/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
5
import { PubLeafletRichtextFacet } from "lexicons/api";
6
-
import { createOauthClient } from "src/atproto-oauth";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
7
10
import { TID } from "@atproto/common";
8
11
import { AtUri, lexToJson, Un$Typed } from "@atproto/api";
9
12
import { supabaseServerClient } from "supabase/serverClient";
···
15
18
} from "src/notifications";
16
19
import { v7 } from "uuid";
17
20
21
+
type PublishCommentResult =
22
+
| { success: true; record: Json; profile: any; uri: string }
23
+
| { success: false; error: OAuthSessionError };
24
+
18
25
export async function publishComment(args: {
19
26
document: string;
20
27
pageId?: string;
···
24
31
replyTo?: string;
25
32
attachment: PubLeafletComment.Record["attachment"];
26
33
};
27
-
}) {
28
-
const oauthClient = await createOauthClient();
34
+
}): Promise<PublishCommentResult> {
29
35
let identity = await getIdentityData();
30
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
36
+
if (!identity || !identity.atp_did) {
37
+
return {
38
+
success: false,
39
+
error: {
40
+
type: "oauth_session_expired",
41
+
message: "Not authenticated",
42
+
did: "",
43
+
},
44
+
};
45
+
}
31
46
32
-
let credentialSession = await oauthClient.restore(identity.atp_did);
47
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
48
+
if (!sessionResult.ok) {
49
+
return { success: false, error: sessionResult.error };
50
+
}
51
+
let credentialSession = sessionResult.value;
33
52
let agent = new AtpBaseClient(
34
53
credentialSession.fetchHandler.bind(credentialSession),
35
54
);
···
108
127
}
109
128
110
129
return {
130
+
success: true,
111
131
record: data?.[0].record as Json,
112
132
profile: lexToJson(profile.value),
113
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
18
import { QuoteContent } from "../Quotes";
19
19
import { timeAgo } from "src/utils/timeAgo";
20
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
+
import { ProfilePopover } from "components/ProfilePopover";
21
22
22
23
export type Comment = {
23
24
record: Json;
24
25
uri: string;
25
-
bsky_profiles: { record: Json } | null;
26
+
bsky_profiles: { record: Json; did: string } | null;
26
27
};
27
28
export function Comments(props: {
28
29
document_uri: string;
···
50
51
}, []);
51
52
52
53
return (
53
-
<div id={"commentsDrawer"} className="flex flex-col gap-2 relative">
54
+
<div
55
+
id={"commentsDrawer"}
56
+
className="flex flex-col gap-2 relative text-sm text-secondary"
57
+
>
54
58
<div className="w-full flex justify-between text-secondary font-bold">
55
59
Comments
56
60
<button
···
109
113
document: string;
110
114
comment: Comment;
111
115
comments: Comment[];
112
-
profile?: AppBskyActorProfile.Record;
116
+
profile: AppBskyActorProfile.Record;
113
117
record: PubLeafletComment.Record;
114
118
pageId?: string;
115
119
}) => {
120
+
const did = props.comment.bsky_profiles?.did;
121
+
116
122
return (
117
-
<div className="comment">
123
+
<div id={props.comment.uri} className="comment">
118
124
<div className="flex gap-2">
119
-
{props.profile && (
120
-
<ProfilePopover profile={props.profile} comment={props.comment.uri} />
125
+
{did && (
126
+
<ProfilePopover
127
+
didOrHandle={did}
128
+
trigger={
129
+
<div className="text-sm text-tertiary font-bold hover:underline">
130
+
{props.profile.displayName}
131
+
</div>
132
+
}
133
+
/>
121
134
)}
122
-
<DatePopover date={props.record.createdAt} />
123
135
</div>
124
136
{props.record.attachment &&
125
137
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
291
303
</Popover>
292
304
);
293
305
};
294
-
295
-
const ProfilePopover = (props: {
296
-
profile: AppBskyActorProfile.Record;
297
-
comment: string;
298
-
}) => {
299
-
let commenterId = new AtUri(props.comment).host;
300
-
301
-
return (
302
-
<>
303
-
<a
304
-
className="font-bold text-tertiary text-sm hover:underline"
305
-
href={`https://bsky.app/profile/${commenterId}`}
306
-
>
307
-
{props.profile.displayName}
308
-
</a>
309
-
{/*<Media mobile={false}>
310
-
<Popover
311
-
align="start"
312
-
trigger={
313
-
<div
314
-
onMouseOver={() => {
315
-
setHovering(true);
316
-
hoverTimeout.current = window.setTimeout(() => {
317
-
setLoadProfile(true);
318
-
}, 500);
319
-
}}
320
-
onMouseOut={() => {
321
-
setHovering(false);
322
-
clearTimeout(hoverTimeout.current);
323
-
}}
324
-
className="font-bold text-tertiary text-sm hover:underline"
325
-
>
326
-
{props.profile.displayName}
327
-
</div>
328
-
}
329
-
className="max-w-sm"
330
-
>
331
-
{profile && (
332
-
<>
333
-
<div className="profilePopover text-sm flex gap-2">
334
-
<div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" />
335
-
<div className="flex flex-col">
336
-
<div className="flex justify-between">
337
-
<div className="profileHeader flex gap-2 items-center">
338
-
<div className="font-bold">celine</div>
339
-
<a className="text-tertiary" href="/">
340
-
@{profile.handle}
341
-
</a>
342
-
</div>
343
-
</div>
344
-
345
-
<div className="profileBio text-secondary ">
346
-
{profile.description}
347
-
</div>
348
-
<div className="flex flex-row gap-2 items-center pt-2 font-bold">
349
-
{!profile.viewer?.following ? (
350
-
<div className="text-tertiary bg-border-light rounded-md px-1 py-0">
351
-
Following
352
-
</div>
353
-
) : (
354
-
<ButtonPrimary compact className="text-sm">
355
-
Follow <BlueskyTiny />
356
-
</ButtonPrimary>
357
-
)}
358
-
{profile.viewer?.followedBy && (
359
-
<div className="text-tertiary">Follows You</div>
360
-
)}
361
-
</div>
362
-
</div>
363
-
</div>
364
-
365
-
<hr className="my-2 border-border-light" />
366
-
<div className="flex gap-2 leading-tight items-center text-tertiary text-sm">
367
-
<div className="flex flex-col w-6 justify-center">
368
-
{profile.viewer?.knownFollowers?.followers.map((follower) => {
369
-
return (
370
-
<div
371
-
className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page"
372
-
key={follower.did}
373
-
/>
374
-
);
375
-
})}
376
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
377
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
378
-
</div>
379
-
</div>
380
-
</>
381
-
)}
382
-
</Popover>
383
-
</Media>*/}
384
-
</>
385
-
);
386
-
};
+6
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+6
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
9
9
import { decodeQuotePosition } from "../quotePosition";
10
10
11
11
export const InteractionDrawer = (props: {
12
+
showPageBackground: boolean | undefined;
12
13
document_uri: string;
13
14
quotesAndMentions: { uri: string; link?: string }[];
14
15
comments: Comment[];
···
38
39
<div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]">
39
40
<div
40
41
id="interaction-drawer"
41
-
className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] "
42
+
className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`}
42
43
>
43
44
{drawer.drawer === "quotes" ? (
44
45
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
···
58
59
export const useDrawerOpen = (uri: string) => {
59
60
let params = useSearchParams();
60
61
let interactionDrawerSearchParam = params.get("interactionDrawer");
62
+
let pageParam = params.get("page");
61
63
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
62
64
if (open === false || (open === undefined && !interactionDrawerSearchParam))
63
65
return null;
64
66
drawer =
65
67
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
66
-
return { drawer, pageId };
68
+
// Use pageId from state, or fall back to page search param
69
+
const resolvedPageId = pageId ?? pageParam ?? undefined;
70
+
return { drawer, pageId: resolvedPageId };
67
71
};
+68
-44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+68
-44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
108
108
commentsCount: number;
109
109
className?: string;
110
110
showComments?: boolean;
111
+
showMentions?: boolean;
111
112
pageId?: string;
112
113
}) => {
113
114
const data = useContext(PostPageContext);
···
131
132
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
132
133
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
133
134
134
-
{props.quotesCount > 0 && (
135
+
{props.quotesCount === 0 || props.showMentions === false ? null : (
135
136
<button
136
137
className="flex w-fit gap-2 items-center"
137
138
onClick={() => {
···
168
169
commentsCount: number;
169
170
className?: string;
170
171
showComments?: boolean;
172
+
showMentions?: boolean;
171
173
pageId?: string;
172
174
}) => {
173
175
const data = useContext(PostPageContext);
···
189
191
const tags = (data?.data as any)?.tags as string[] | undefined;
190
192
const tagCount = tags?.length || 0;
191
193
194
+
let noInteractions = !props.showComments && !props.showMentions;
195
+
192
196
let subscribed =
193
197
identity?.atp_did &&
194
198
publication?.publication_subscriptions &&
···
229
233
<TagList tags={tags} className="mb-3" />
230
234
</>
231
235
)}
236
+
232
237
<hr className="border-border-light mb-3 " />
238
+
233
239
<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"
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>
274
268
)}
275
-
</button>
276
-
)}
277
-
</div>
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
+
278
302
<EditButton document={data} />
279
303
{subscribed && publication && (
280
304
<ManageSubscription
+30
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+30
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
23
23
import useSWR, { mutate } from "swr";
24
24
import { DotLoader } from "components/utils/DotLoader";
25
25
import { CommentTiny } from "components/Icons/CommentTiny";
26
-
import { ThreadLink } from "../ThreadPage";
26
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
+
import { ThreadLink, QuotesLink } from "../PostLinks";
27
28
28
29
// Helper to get SWR key for quotes
29
30
export function getQuotesSWRKey(uris: string[]) {
···
138
139
profile={pv.author}
139
140
handle={pv.author.handle}
140
141
replyCount={pv.replyCount}
142
+
quoteCount={pv.quoteCount}
141
143
/>
142
144
</div>
143
145
);
···
161
163
profile={pv.author}
162
164
handle={pv.author.handle}
163
165
replyCount={pv.replyCount}
166
+
quoteCount={pv.quoteCount}
164
167
/>
165
168
);
166
169
})}
···
180
183
}) => {
181
184
let isMobile = useIsMobile();
182
185
const data = useContext(PostPageContext);
186
+
const document_uri = data?.uri;
183
187
184
188
let record = data?.data as PubLeafletDocument.Record;
185
189
let page: PubLeafletPagesLinearDocument.Main | undefined = (
···
211
215
let scrollMargin = isMobile
212
216
? 16
213
217
: e.currentTarget.getBoundingClientRect().top;
214
-
let scrollContainer = window.document.getElementById("post-page");
218
+
let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`;
219
+
let scrollContainer = window.document.getElementById(scrollContainerId);
215
220
let el = window.document.getElementById(
216
221
props.position.start.block.join("."),
217
222
);
···
252
257
handle: string;
253
258
profile: ProfileViewBasic;
254
259
replyCount?: number;
260
+
quoteCount?: number;
255
261
}) => {
256
262
const handleOpenThread = () => {
257
263
openPage(undefined, { type: "thread", uri: props.uri });
···
282
288
</a>
283
289
</div>
284
290
<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
-
)}
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>
295
313
</div>
296
314
</div>
297
315
);
+8
-4
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+8
-4
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
14
14
ExpandedInteractions,
15
15
getCommentCount,
16
16
getQuoteCount,
17
-
Interactions,
18
17
} from "./Interactions/Interactions";
19
18
import { PostContent } from "./PostContent";
20
19
import { PostHeader } from "./PostHeader/PostHeader";
···
25
24
import { decodeQuotePosition } from "./quotePosition";
26
25
import { PollData } from "./fetchPollData";
27
26
import { SharedPageProps } from "./PostPages";
27
+
import { PostPrevNextButtons } from "./PostPrevNextButtons";
28
28
29
29
export function LinearDocumentPage({
30
30
blocks,
···
56
56
57
57
const isSubpage = !!pageId;
58
58
59
+
console.log("prev/next?: " + preferences.showPrevNext);
60
+
59
61
return (
60
62
<>
61
63
<PageWrapper
62
64
pageType="doc"
63
65
fullPageScroll={fullPageScroll}
64
-
cardBorderHidden={!hasPageBackground}
65
-
id={pageId ? `post-page-${pageId}` : "post-page"}
66
+
id={`post-page-${pageId ?? document_uri}`}
66
67
drawerOpen={
67
68
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
68
69
}
···
84
85
did={did}
85
86
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
87
/>
87
-
88
+
<PostPrevNextButtons
89
+
showPrevNext={preferences.showPrevNext && !isSubpage}
90
+
/>
88
91
<ExpandedInteractions
89
92
pageId={pageId}
90
93
showComments={preferences.showComments}
94
+
showMentions={preferences.showMentions}
91
95
commentsCount={getCommentCount(document, pageId) || 0}
92
96
quotesCount={getQuoteCount(document, pageId) || 0}
93
97
/>
+14
-12
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+14
-12
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
18
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
19
import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page";
20
20
import { Separator } from "components/Layout";
21
+
import { ProfilePopover } from "components/ProfilePopover";
21
22
22
23
export function PostHeader(props: {
23
24
data: PostPageData;
24
25
profile: ProfileViewDetailed;
25
-
preferences: { showComments?: boolean };
26
+
preferences: { showComments?: boolean; showMentions?: boolean };
26
27
}) {
27
28
let { identity } = useIdentityData();
28
29
let document = props.data;
···
72
73
<>
73
74
<div className="flex flex-row gap-2 items-center">
74
75
{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
-
</>
76
+
<ProfilePopover
77
+
didOrHandle={profile.did}
78
+
trigger={
79
+
<span className="text-tertiary hover:underline">
80
+
{profile.displayName || profile.handle}
81
+
</span>
82
+
}
83
+
/>
83
84
) : null}
84
85
{record.publishedAt ? (
85
86
<>
···
90
91
</div>
91
92
<Interactions
92
93
showComments={props.preferences.showComments}
94
+
showMentions={props.preferences.showMentions}
93
95
quotesCount={getQuoteCount(document) || 0}
94
96
commentsCount={getCommentCount(document) || 0}
95
97
/>
···
107
109
}) => {
108
110
return (
109
111
<div
110
-
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
112
+
className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
113
id="post-header"
112
114
>
113
115
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
···
119
121
{props.postTitle ? props.postTitle : "Untitled"}
120
122
</h2>
121
123
{props.postDescription ? (
122
-
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
124
+
<div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
125
{props.postDescription}
124
-
</p>
126
+
</div>
125
127
) : null}
126
128
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
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
25
import { LinearDocumentPage } from "./LinearDocumentPage";
26
26
import { CanvasPage } from "./CanvasPage";
27
27
import { ThreadPage as ThreadPageComponent } from "./ThreadPage";
28
+
import { BlueskyQuotesPage } from "./BlueskyQuotesPage";
28
29
29
30
// Page types
30
31
export type DocPage = { type: "doc"; id: string };
31
32
export type ThreadPage = { type: "thread"; uri: string };
32
-
export type OpenPage = DocPage | ThreadPage;
33
+
export type QuotesPage = { type: "quotes"; uri: string };
34
+
export type OpenPage = DocPage | ThreadPage | QuotesPage;
33
35
34
36
// Get a stable key for a page
35
37
const getPageKey = (page: OpenPage): string => {
36
38
if (page.type === "doc") return page.id;
39
+
if (page.type === "quotes") return `quotes:${page.uri}`;
37
40
return `thread:${page.uri}`;
38
41
};
39
42
···
81
84
});
82
85
return;
83
86
}
84
-
85
87
// Then check for quote param
86
88
if (quote) {
87
89
const decodedQuote = decodeQuotePosition(quote as string);
···
96
98
// Mark as initialized even if no pageId found
97
99
usePostPageUIState.setState({ initialized: true });
98
100
}
99
-
}, [quote]);
101
+
}, [quote, pageParam]);
100
102
};
101
103
102
104
export const openPage = (
···
145
147
document: PostPageData;
146
148
did: string;
147
149
profile: ProfileViewDetailed;
148
-
preferences: { showComments?: boolean };
150
+
preferences: {
151
+
showComments?: boolean;
152
+
showMentions?: boolean;
153
+
showPrevNext?: boolean;
154
+
};
149
155
pubRecord?: PubLeafletPublication.Record;
150
156
theme?: PubLeafletPublication.Theme | null;
151
157
prerenderedCodeBlocks?: Map<string, string>;
···
204
210
did: string;
205
211
prerenderedCodeBlocks?: Map<string, string>;
206
212
bskyPostData: AppBskyFeedDefs.PostView[];
207
-
preferences: { showComments?: boolean };
213
+
preferences: {
214
+
showComments?: boolean;
215
+
showMentions?: boolean;
216
+
showPrevNext?: boolean;
217
+
};
208
218
pollData: PollData[];
209
219
}) {
210
220
let drawer = useDrawerOpen(document_uri);
···
259
269
260
270
{drawer && !drawer.pageId && (
261
271
<InteractionDrawer
272
+
showPageBackground={pubRecord?.theme?.showPageBackground}
262
273
document_uri={document.uri}
263
274
comments={
264
275
pubRecord?.preferences?.showComments === false
265
276
? []
266
277
: document.comments_on_documents
267
278
}
268
-
quotesAndMentions={quotesAndMentions}
279
+
quotesAndMentions={
280
+
pubRecord?.preferences?.showMentions === false
281
+
? []
282
+
: quotesAndMentions
283
+
}
269
284
did={did}
270
285
/>
271
286
)}
···
293
308
);
294
309
}
295
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
+
296
331
// Handle document pages
297
332
let page = record.pages.find(
298
333
(p) =>
···
325
360
/>
326
361
{drawer && drawer.pageId === page.id && (
327
362
<InteractionDrawer
363
+
showPageBackground={pubRecord?.theme?.showPageBackground}
328
364
pageId={page.id}
329
365
document_uri={document.uri}
330
366
comments={
···
332
368
? []
333
369
: document.comments_on_documents
334
370
}
335
-
quotesAndMentions={quotesAndMentions}
371
+
quotesAndMentions={
372
+
pubRecord?.preferences?.showMentions === false
373
+
? []
374
+
: quotesAndMentions
375
+
}
336
376
did={did}
337
377
/>
338
378
)}
···
352
392
return (
353
393
<div
354
394
className={`pageOptions w-fit z-10
355
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
395
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
356
396
flex sm:flex-col flex-row-reverse gap-1 items-start`}
357
397
>
358
-
<PageOptionButton
359
-
cardBorderHidden={!props.hasPageBackground}
360
-
onClick={props.onClick}
361
-
>
398
+
<PageOptionButton onClick={props.onClick}>
362
399
<CloseTiny />
363
400
</PageOptionButton>
364
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
4
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
5
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
6
import { CommentTiny } from "components/Icons/CommentTiny";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { ThreadLink, QuotesLink } from "./PostLinks";
7
9
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
8
10
import {
9
11
BlueskyEmbed,
···
11
13
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
12
14
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
13
15
import { openPage } from "./PostPages";
14
-
import { ThreadLink } from "./ThreadPage";
15
16
16
17
export const PubBlueskyPostBlock = (props: {
17
18
post: PostView;
···
118
119
{post.replyCount}
119
120
<CommentTiny />
120
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>
121
136
<Separator classname="h-4" />
122
137
</>
123
138
)}
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
+3
-2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
···
186
186
<BlueskyLinkTiny className="shrink-0" />
187
187
Bluesky
188
188
</a>
189
-
<Separator classname="h-4" />
189
+
<Separator classname="h-4!" />
190
190
<button
191
191
id="copy-quote-link"
192
192
className="flex gap-1 items-center hover:font-bold px-1"
···
211
211
</button>
212
212
{pubRecord?.preferences?.showComments !== false && identity?.atp_did && (
213
213
<>
214
-
<Separator classname="h-4" />
214
+
<Separator classname="h-4! " />
215
+
215
216
<button
216
217
className="flex gap-1 items-center hover:font-bold px-1"
217
218
onClick={() => {
+11
-7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+11
-7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
···
12
12
PubLeafletPagesLinearDocument,
13
13
} from "lexicons/api";
14
14
import { blobRefToSrc } from "src/utils/blobRefToSrc";
15
-
import { BaseTextBlock } from "./BaseTextBlock";
15
+
import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore";
16
16
import { StaticMathBlock } from "./StaticMathBlock";
17
17
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
18
+
19
+
function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
20
+
return <TextBlockCore {...props} />;
21
+
}
18
22
19
23
export function StaticPostContent({
20
24
blocks,
···
47
51
case PubLeafletBlocksBlockquote.isMain(b.block): {
48
52
return (
49
53
<blockquote className={` blockquote `}>
50
-
<BaseTextBlock
54
+
<StaticBaseTextBlock
51
55
facets={b.block.facets}
52
56
plaintext={b.block.plaintext}
53
57
index={[]}
···
116
120
case PubLeafletBlocksText.isMain(b.block):
117
121
return (
118
122
<p>
119
-
<BaseTextBlock
123
+
<StaticBaseTextBlock
120
124
facets={b.block.facets}
121
125
plaintext={b.block.plaintext}
122
126
index={[]}
···
127
131
if (b.block.level === 1)
128
132
return (
129
133
<h1>
130
-
<BaseTextBlock {...b.block} index={[]} />
134
+
<StaticBaseTextBlock {...b.block} index={[]} />
131
135
</h1>
132
136
);
133
137
if (b.block.level === 2)
134
138
return (
135
139
<h2>
136
-
<BaseTextBlock {...b.block} index={[]} />
140
+
<StaticBaseTextBlock {...b.block} index={[]} />
137
141
</h2>
138
142
);
139
143
if (b.block.level === 3)
140
144
return (
141
145
<h3>
142
-
<BaseTextBlock {...b.block} index={[]} />
146
+
<StaticBaseTextBlock {...b.block} index={[]} />
143
147
</h3>
144
148
);
145
149
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
146
150
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
147
151
return (
148
152
<h6>
149
-
<BaseTextBlock {...b.block} index={[]} />
153
+
<StaticBaseTextBlock {...b.block} index={[]} />
150
154
</h6>
151
155
);
152
156
}
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
···
1
+
import { UnicodeString } from "@atproto/api";
2
+
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { AtMentionLink } from "components/AtMentionLink";
4
+
import { ReactNode } from "react";
5
+
6
+
type Facet = PubLeafletRichtextFacet.Main;
7
+
8
+
export type FacetRenderers = {
9
+
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
+
};
11
+
12
+
export type TextBlockCoreProps = {
13
+
plaintext: string;
14
+
facets?: Facet[];
15
+
index: number[];
16
+
preview?: boolean;
17
+
renderers?: FacetRenderers;
18
+
};
19
+
20
+
export function TextBlockCore(props: TextBlockCoreProps) {
21
+
let children = [];
22
+
let richText = new RichText({
23
+
text: props.plaintext,
24
+
facets: props.facets || [],
25
+
});
26
+
let counter = 0;
27
+
for (const segment of richText.segments()) {
28
+
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
29
+
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
30
+
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
31
+
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
32
+
let isStrikethrough = segment.facet?.find(
33
+
PubLeafletRichtextFacet.isStrikethrough,
34
+
);
35
+
let isDidMention = segment.facet?.find(
36
+
PubLeafletRichtextFacet.isDidMention,
37
+
);
38
+
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
+
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
+
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
41
+
let isHighlighted = segment.facet?.find(
42
+
PubLeafletRichtextFacet.isHighlight,
43
+
);
44
+
let className = `
45
+
${isCode ? "inline-code" : ""}
46
+
${id ? "scroll-mt-12 scroll-mb-10" : ""}
47
+
${isBold ? "font-bold" : ""}
48
+
${isItalic ? "italic" : ""}
49
+
${isUnderline ? "underline" : ""}
50
+
${isStrikethrough ? "line-through decoration-tertiary" : ""}
51
+
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
52
+
53
+
// Split text by newlines and insert <br> tags
54
+
const textParts = segment.text.split("\n");
55
+
const renderedText = textParts.flatMap((part, i) =>
56
+
i < textParts.length - 1
57
+
? [part, <br key={`br-${counter}-${i}`} />]
58
+
: [part],
59
+
);
60
+
61
+
if (isCode) {
62
+
children.push(
63
+
<code key={counter} className={className} id={id?.id}>
64
+
{renderedText}
65
+
</code>,
66
+
);
67
+
} else if (isDidMention) {
68
+
const DidMentionRenderer = props.renderers?.DidMention;
69
+
if (DidMentionRenderer) {
70
+
children.push(
71
+
<DidMentionRenderer key={counter} did={isDidMention.did}>
72
+
<span className="mention">{renderedText}</span>
73
+
</DidMentionRenderer>,
74
+
);
75
+
} else {
76
+
// Default: render as a simple link
77
+
children.push(
78
+
<a
79
+
key={counter}
80
+
href={`https://leaflet.pub/p/${isDidMention.did}`}
81
+
target="_blank"
82
+
className="no-underline"
83
+
>
84
+
<span className="mention">{renderedText}</span>
85
+
</a>,
86
+
);
87
+
}
88
+
} else if (isAtMention) {
89
+
children.push(
90
+
<AtMentionLink
91
+
key={counter}
92
+
atURI={isAtMention.atURI}
93
+
className={className}
94
+
>
95
+
{renderedText}
96
+
</AtMentionLink>,
97
+
);
98
+
} else if (link) {
99
+
children.push(
100
+
<a
101
+
key={counter}
102
+
href={link.uri.trim()}
103
+
className={`text-accent-contrast hover:underline ${className}`}
104
+
target="_blank"
105
+
>
106
+
{renderedText}
107
+
</a>,
108
+
);
109
+
} else {
110
+
children.push(
111
+
<span key={counter} className={className} id={id?.id}>
112
+
{renderedText}
113
+
</span>,
114
+
);
115
+
}
116
+
117
+
counter++;
118
+
}
119
+
return <>{children}</>;
120
+
}
121
+
122
+
type RichTextSegment = {
123
+
text: string;
124
+
facet?: Exclude<Facet["features"], { $type: string }>;
125
+
};
126
+
127
+
export class RichText {
128
+
unicodeText: UnicodeString;
129
+
facets?: Facet[];
130
+
131
+
constructor(props: { text: string; facets: Facet[] }) {
132
+
this.unicodeText = new UnicodeString(props.text);
133
+
this.facets = props.facets;
134
+
if (this.facets) {
135
+
this.facets = this.facets
136
+
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
137
+
.sort((a, b) => a.index.byteStart - b.index.byteStart);
138
+
}
139
+
}
140
+
141
+
*segments(): Generator<RichTextSegment, void, void> {
142
+
const facets = this.facets || [];
143
+
if (!facets.length) {
144
+
yield { text: this.unicodeText.utf16 };
145
+
return;
146
+
}
147
+
148
+
let textCursor = 0;
149
+
let facetCursor = 0;
150
+
do {
151
+
const currFacet = facets[facetCursor];
152
+
if (textCursor < currFacet.index.byteStart) {
153
+
yield {
154
+
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
155
+
};
156
+
} else if (textCursor > currFacet.index.byteStart) {
157
+
facetCursor++;
158
+
continue;
159
+
}
160
+
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
161
+
const subtext = this.unicodeText.slice(
162
+
currFacet.index.byteStart,
163
+
currFacet.index.byteEnd,
164
+
);
165
+
if (!subtext.trim()) {
166
+
// dont empty string entities
167
+
yield { text: subtext };
168
+
} else {
169
+
yield { text: subtext, facet: currFacet.features };
170
+
}
171
+
}
172
+
textCursor = currFacet.index.byteEnd;
173
+
facetCursor++;
174
+
} while (facetCursor < facets.length);
175
+
if (textCursor < this.unicodeText.length) {
176
+
yield {
177
+
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
178
+
};
179
+
}
180
+
}
181
+
}
+110
-225
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+110
-225
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
1
1
"use client";
2
-
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
-
import useSWR, { preload } from "swr";
2
+
import { useEffect, useRef } from "react";
3
+
import { AppBskyFeedDefs } from "@atproto/api";
4
+
import useSWR from "swr";
4
5
import { PageWrapper } from "components/Pages/Page";
5
6
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
6
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";
7
12
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";
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 };
19
21
20
22
type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
21
23
type NotFoundPost = AppBskyFeedDefs.NotFoundPost;
22
24
type BlockedPost = AppBskyFeedDefs.BlockedPost;
23
25
type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost;
24
26
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
27
export function ThreadPage(props: {
78
28
threadUri: string;
79
29
pageId: string;
···
90
40
} = useSWR(threadUri ? getThreadKey(threadUri) : null, () =>
91
41
fetchThread(threadUri),
92
42
);
93
-
let cardBorderHidden = useCardBorderHidden(null);
94
43
95
44
return (
96
45
<PageWrapper
97
-
cardBorderHidden={!!cardBorderHidden}
98
46
pageType="doc"
99
47
fullPageScroll={false}
100
48
id={`post-page-${pageId}`}
···
121
69
122
70
function ThreadContent(props: { thread: ThreadType; threadUri: string }) {
123
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
+
}, []);
124
83
125
84
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
126
85
return <PostNotAvailable />;
···
161
120
))}
162
121
163
122
{/* Main post */}
164
-
<ThreadPost
165
-
post={thread}
166
-
isMainPost={true}
167
-
showReplyLine={false}
168
-
threadUri={threadUri}
169
-
/>
123
+
<div ref={mainPostRef}>
124
+
<ThreadPost
125
+
post={thread}
126
+
isMainPost={true}
127
+
showReplyLine={false}
128
+
threadUri={threadUri}
129
+
/>
130
+
</div>
170
131
171
132
{/* Replies */}
172
133
{thread.replies && thread.replies.length > 0 && (
···
178
139
replies={thread.replies as any[]}
179
140
threadUri={threadUri}
180
141
depth={0}
142
+
parentAuthorDid={thread.post.author.did}
181
143
/>
182
144
</div>
183
145
)}
···
193
155
}) {
194
156
const { post, isMainPost, showReplyLine, threadUri } = props;
195
157
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}`;
158
+
const parent = { type: "thread" as const, uri: threadUri };
200
159
201
160
return (
202
161
<div className="flex gap-2 relative">
···
205
164
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
206
165
)}
207
166
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>
167
+
<BskyPostContent
168
+
post={postView}
169
+
parent={parent}
170
+
linksEnabled={!isMainPost}
171
+
showBlueskyLink={true}
172
+
showEmbed={true}
173
+
/>
274
174
</div>
275
175
);
276
176
}
···
279
179
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
280
180
threadUri: string;
281
181
depth: number;
182
+
parentAuthorDid?: string;
282
183
}) {
283
-
const { replies, threadUri, depth } = props;
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;
284
202
285
203
return (
286
204
<div className="flex flex-col gap-0">
287
-
{replies.map((reply, index) => {
205
+
{sortedReplies.map((reply, index) => {
288
206
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
289
207
return (
290
208
<div
···
312
230
}
313
231
314
232
const hasReplies = reply.replies && reply.replies.length > 0;
233
+
const isCollapsed = collapsedThreads.has(reply.post.uri);
234
+
const replyCount = reply.replies?.length ?? 0;
315
235
316
236
return (
317
237
<div key={reply.post.uri} className="flex flex-col">
···
322
242
threadUri={threadUri}
323
243
/>
324
244
{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
-
/>
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
+
)}
331
279
</div>
332
280
)}
333
281
{hasReplies && depth >= 3 && (
···
352
300
isLast: boolean;
353
301
threadUri: string;
354
302
}) {
355
-
const { post, showReplyLine, isLast, threadUri } = props;
303
+
const { post, threadUri } = props;
356
304
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
305
const parent = { type: "thread" as const, uri: threadUri };
363
306
364
307
return (
···
366
309
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
367
310
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
368
311
>
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>
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
+
/>
418
322
</div>
419
323
);
420
324
}
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
-
};
+58
-1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+58
-1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
10
10
data,
11
11
uri,
12
12
comments_on_documents(*, bsky_profiles(*)),
13
-
documents_in_publications(publications(*, publication_subscriptions(*))),
13
+
documents_in_publications(publications(*,
14
+
documents_in_publications(documents(uri, data)),
15
+
publication_subscriptions(*))
16
+
),
14
17
document_mentions_in_bsky(*),
15
18
leaflets_in_publications(*)
16
19
`,
···
51
54
?.record as PubLeafletPublication.Record
52
55
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
53
56
57
+
// Calculate prev/next documents from the fetched publication documents
58
+
let prevNext:
59
+
| {
60
+
prev?: { uri: string; title: string };
61
+
next?: { uri: string; title: string };
62
+
}
63
+
| undefined;
64
+
65
+
const currentPublishedAt = (document.data as PubLeafletDocument.Record)
66
+
?.publishedAt;
67
+
const allDocs =
68
+
document.documents_in_publications[0]?.publications
69
+
?.documents_in_publications;
70
+
71
+
if (currentPublishedAt && allDocs) {
72
+
// Filter and sort documents by publishedAt
73
+
const sortedDocs = allDocs
74
+
.map((dip) => ({
75
+
uri: dip?.documents?.uri,
76
+
title: (dip?.documents?.data as PubLeafletDocument.Record).title,
77
+
publishedAt: (dip?.documents?.data as PubLeafletDocument.Record)
78
+
.publishedAt,
79
+
}))
80
+
.filter((doc) => doc.publishedAt) // Only include docs with publishedAt
81
+
.sort(
82
+
(a, b) =>
83
+
new Date(a.publishedAt!).getTime() -
84
+
new Date(b.publishedAt!).getTime(),
85
+
);
86
+
87
+
// Find current document index
88
+
const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri);
89
+
90
+
if (currentIndex !== -1) {
91
+
prevNext = {
92
+
prev:
93
+
currentIndex > 0
94
+
? {
95
+
uri: sortedDocs[currentIndex - 1].uri || "",
96
+
title: sortedDocs[currentIndex - 1].title,
97
+
}
98
+
: undefined,
99
+
next:
100
+
currentIndex < sortedDocs.length - 1
101
+
? {
102
+
uri: sortedDocs[currentIndex + 1].uri || "",
103
+
title: sortedDocs[currentIndex + 1].title,
104
+
}
105
+
: undefined,
106
+
};
107
+
}
108
+
}
109
+
54
110
return {
55
111
...document,
56
112
quotesAndMentions,
57
113
theme,
114
+
prevNext,
58
115
};
59
116
}
60
117
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
+44
-1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { ids } from "lexicons/api/lexicons";
5
+
import { PubLeafletDocument } from "lexicons/api";
6
+
import { jsonToLex } from "@atproto/lexicon";
7
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
8
3
-
export const runtime = "edge";
4
9
export const revalidate = 60;
5
10
6
11
export default async function OpenGraphImage(props: {
7
12
params: Promise<{ publication: string; did: string; rkey: string }>;
8
13
}) {
9
14
let params = await props.params;
15
+
let did = decodeURIComponent(params.did);
16
+
17
+
// Try to get the document's cover image
18
+
let { data: document } = await supabaseServerClient
19
+
.from("documents")
20
+
.select("data")
21
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
22
+
.single();
23
+
24
+
if (document) {
25
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
26
+
if (docRecord.coverImage) {
27
+
try {
28
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
29
+
let cid =
30
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
31
+
docRecord.coverImage.ref.toString();
32
+
33
+
let imageResponse = await fetchAtprotoBlob(did, cid);
34
+
if (imageResponse) {
35
+
let imageBlob = await imageResponse.blob();
36
+
37
+
// Return the image with appropriate headers
38
+
return new Response(imageBlob, {
39
+
headers: {
40
+
"Content-Type": imageBlob.type || "image/jpeg",
41
+
"Cache-Control": "public, max-age=3600",
42
+
},
43
+
});
44
+
}
45
+
} catch (e) {
46
+
// Fall through to screenshot if cover image fetch fails
47
+
console.error("Failed to fetch cover image:", e);
48
+
}
49
+
}
50
+
}
51
+
52
+
// Fall back to screenshot
10
53
return getMicroLinkOgImage(
11
54
`/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`,
12
55
);
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
+12
-4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
···
1
1
"use server";
2
2
3
-
import { createOauthClient } from "src/atproto-oauth";
3
+
import {
4
+
restoreOAuthSession,
5
+
OAuthSessionError,
6
+
} from "src/atproto-oauth";
4
7
import { getIdentityData } from "actions/getIdentityData";
5
8
import { AtpBaseClient, AtUri } from "@atproto/api";
6
9
import { PubLeafletPollVote } from "lexicons/api";
···
12
15
pollUri: string,
13
16
pollCid: string,
14
17
selectedOption: string,
15
-
): Promise<{ success: boolean; error?: string }> {
18
+
): Promise<
19
+
{ success: true } | { success: false; error: string | OAuthSessionError }
20
+
> {
16
21
try {
17
22
const identity = await getIdentityData();
18
23
···
20
25
return { success: false, error: "Not authenticated" };
21
26
}
22
27
23
-
const oauthClient = await createOauthClient();
24
-
const session = await oauthClient.restore(identity.atp_did);
28
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
29
+
if (!sessionResult.ok) {
30
+
return { success: false, error: sessionResult.error };
31
+
}
32
+
const session = sessionResult.value;
25
33
let agent = new AtpBaseClient(session.fetchHandler.bind(session));
26
34
27
35
const voteRecord: PubLeafletPollVote.Record = {
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
+2
-3
app/lish/[did]/[publication]/dashboard/Actions.tsx
···
1
1
"use client";
2
2
3
3
import { NewDraftActionButton } from "./NewDraftButton";
4
-
import { PublicationSettingsButton } from "./PublicationSettings";
4
+
import { PublicationSettingsButton } from "./settings/PublicationSettings";
5
5
import { ActionButton } from "components/ActionBar/ActionButton";
6
6
import { ShareSmall } from "components/Icons/ShareSmall";
7
-
import { Menu } from "components/Layout";
8
-
import { MenuItem } from "components/Layout";
7
+
import { Menu, MenuItem } from "components/Menu";
9
8
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
9
import { usePublicationData } from "./PublicationSWRProvider";
11
10
import { useSmoker } from "components/Toast";
-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
4
import { ButtonPrimary } from "components/Buttons";
5
5
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
6
import { useSmoker } from "components/Toast";
7
-
import { Menu, MenuItem, Separator } from "components/Layout";
7
+
import { Menu, MenuItem } from "components/Menu";
8
+
import { Separator } from "components/Layout";
8
9
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
9
10
import { Checkbox } from "components/Checkbox";
10
11
import { useEffect, useState } from "react";
+2
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+2
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
···
7
7
import { Fragment, useState } from "react";
8
8
import { useParams } from "next/navigation";
9
9
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
-
import { Menu, MenuItem } from "components/Layout";
10
+
import { Menu, MenuItem } from "components/Menu";
11
11
import { deletePost } from "./deletePost";
12
12
import { ButtonPrimary } from "components/Buttons";
13
13
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
···
140
140
commentsCount={comments}
141
141
tags={tags}
142
142
showComments={pubRecord?.preferences?.showComments}
143
+
showMentions={pubRecord?.preferences?.showMentions}
143
144
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
144
145
/>
145
146
</div>
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
+50
-13
app/lish/[did]/[publication]/dashboard/deletePost.ts
···
2
2
3
3
import { AtpBaseClient } from "lexicons/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
import { AtUri } from "@atproto/syntax";
7
10
import { supabaseServerClient } from "supabase/serverClient";
8
11
import { revalidatePath } from "next/cache";
9
12
10
-
export async function deletePost(document_uri: string) {
13
+
export async function deletePost(
14
+
document_uri: string
15
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
11
16
let identity = await getIdentityData();
12
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
17
+
if (!identity || !identity.atp_did) {
18
+
return {
19
+
success: false,
20
+
error: {
21
+
type: "oauth_session_expired",
22
+
message: "Not authenticated",
23
+
did: "",
24
+
},
25
+
};
26
+
}
13
27
14
-
const oauthClient = await createOauthClient();
15
-
let credentialSession = await oauthClient.restore(identity.atp_did);
28
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
29
+
if (!sessionResult.ok) {
30
+
return { success: false, error: sessionResult.error };
31
+
}
32
+
let credentialSession = sessionResult.value;
16
33
let agent = new AtpBaseClient(
17
34
credentialSession.fetchHandler.bind(credentialSession),
18
35
);
19
36
let uri = new AtUri(document_uri);
20
-
if (uri.host !== identity.atp_did) return;
37
+
if (uri.host !== identity.atp_did) {
38
+
return { success: true };
39
+
}
21
40
22
41
await Promise.all([
23
42
agent.pub.leaflet.document.delete({
···
31
50
.eq("doc", document_uri),
32
51
]);
33
52
34
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
53
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
54
+
return { success: true };
35
55
}
36
56
37
-
export async function unpublishPost(document_uri: string) {
57
+
export async function unpublishPost(
58
+
document_uri: string
59
+
): Promise<{ success: true } | { success: false; error: OAuthSessionError }> {
38
60
let identity = await getIdentityData();
39
-
if (!identity || !identity.atp_did) throw new Error("No Identity");
61
+
if (!identity || !identity.atp_did) {
62
+
return {
63
+
success: false,
64
+
error: {
65
+
type: "oauth_session_expired",
66
+
message: "Not authenticated",
67
+
did: "",
68
+
},
69
+
};
70
+
}
40
71
41
-
const oauthClient = await createOauthClient();
42
-
let credentialSession = await oauthClient.restore(identity.atp_did);
72
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
73
+
if (!sessionResult.ok) {
74
+
return { success: false, error: sessionResult.error };
75
+
}
76
+
let credentialSession = sessionResult.value;
43
77
let agent = new AtpBaseClient(
44
78
credentialSession.fetchHandler.bind(credentialSession),
45
79
);
46
80
let uri = new AtUri(document_uri);
47
-
if (uri.host !== identity.atp_did) return;
81
+
if (uri.host !== identity.atp_did) {
82
+
return { success: true };
83
+
}
48
84
49
85
await Promise.all([
50
86
agent.pub.leaflet.document.delete({
···
53
89
}),
54
90
supabaseServerClient.from("documents").delete().eq("uri", document_uri),
55
91
]);
56
-
return revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
92
+
revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
93
+
return { success: true };
57
94
}
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
···
1
+
import { PubLeafletPublication } from "lexicons/api";
2
+
import { usePublicationData } from "../PublicationSWRProvider";
3
+
import { PubSettingsHeader } from "./PublicationSettings";
4
+
import { useState } from "react";
5
+
import { Toggle } from "components/Toggle";
6
+
import { updatePublication } from "app/lish/createPub/updatePublication";
7
+
import { useToaster } from "components/Toast";
8
+
import { mutate } from "swr";
9
+
10
+
export const PostOptions = (props: {
11
+
backToMenu: () => void;
12
+
loading: boolean;
13
+
setLoading: (l: boolean) => void;
14
+
}) => {
15
+
let { data } = usePublicationData();
16
+
17
+
let { publication: pubData } = data || {};
18
+
let record = pubData?.record as PubLeafletPublication.Record;
19
+
20
+
let [showComments, setShowComments] = useState(
21
+
record?.preferences?.showComments === undefined
22
+
? true
23
+
: record.preferences.showComments,
24
+
);
25
+
let [showMentions, setShowMentions] = useState(
26
+
record?.preferences?.showMentions === undefined
27
+
? true
28
+
: record.preferences.showMentions,
29
+
);
30
+
let [showPrevNext, setShowPrevNext] = useState(
31
+
record?.preferences?.showPrevNext === undefined
32
+
? true
33
+
: record.preferences.showPrevNext,
34
+
);
35
+
36
+
let toast = useToaster();
37
+
return (
38
+
<form
39
+
onSubmit={async (e) => {
40
+
if (!pubData) return;
41
+
e.preventDefault();
42
+
props.setLoading(true);
43
+
let data = await updatePublication({
44
+
name: record.name,
45
+
uri: pubData.uri,
46
+
preferences: {
47
+
showInDiscover:
48
+
record?.preferences?.showInDiscover === undefined
49
+
? true
50
+
: record.preferences.showInDiscover,
51
+
showComments: showComments,
52
+
showMentions: showMentions,
53
+
showPrevNext: showPrevNext,
54
+
},
55
+
});
56
+
toast({ type: "success", content: <strong>Posts Updated!</strong> });
57
+
console.log(record.preferences?.showPrevNext);
58
+
props.setLoading(false);
59
+
mutate("publication-data");
60
+
}}
61
+
className="text-primary flex flex-col"
62
+
>
63
+
<PubSettingsHeader
64
+
loading={props.loading}
65
+
setLoadingAction={props.setLoading}
66
+
backToMenuAction={props.backToMenu}
67
+
state={"post-options"}
68
+
>
69
+
Post Options
70
+
</PubSettingsHeader>
71
+
<h4 className="mb-1">Layout</h4>
72
+
<Toggle
73
+
toggle={showPrevNext}
74
+
onToggle={() => {
75
+
setShowPrevNext(!showPrevNext);
76
+
}}
77
+
>
78
+
<div className="font-bold">Show Prev/Next Buttons</div>
79
+
</Toggle>
80
+
<hr className="my-2 border-border-light" />
81
+
<h4 className="mb-1">Interactions</h4>
82
+
<div className="flex flex-col gap-2">
83
+
<Toggle
84
+
toggle={showComments}
85
+
onToggle={() => {
86
+
setShowComments(!showComments);
87
+
}}
88
+
>
89
+
<div className="font-bold">Show Comments</div>
90
+
</Toggle>
91
+
92
+
<Toggle
93
+
toggle={showMentions}
94
+
onToggle={() => {
95
+
setShowMentions(!showMentions);
96
+
}}
97
+
>
98
+
<div className="flex flex-col justify-start">
99
+
<div className="font-bold">Show Mentions</div>
100
+
<div className="text-tertiary text-sm leading-tight">
101
+
Display a list of posts on Bluesky that mention your post
102
+
</div>
103
+
</div>
104
+
</Toggle>
105
+
</div>
106
+
</form>
107
+
);
108
+
};
+146
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
+146
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
···
1
+
"use client";
2
+
3
+
import { ActionButton } from "components/ActionBar/ActionButton";
4
+
import { Popover } from "components/Popover";
5
+
import { SettingsSmall } from "components/Icons/SettingsSmall";
6
+
import { EditPubForm } from "app/lish/createPub/UpdatePubForm";
7
+
import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter";
8
+
import { useIsMobile } from "src/hooks/isMobile";
9
+
import { useState } from "react";
10
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
11
+
import { theme } from "tailwind.config";
12
+
import { ButtonPrimary } from "components/Buttons";
13
+
import { DotLoader } from "components/utils/DotLoader";
14
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
15
+
import { PostOptions } from "./PostOptions";
16
+
17
+
type menuState = "menu" | "general" | "theme" | "post-options";
18
+
19
+
export function PublicationSettingsButton(props: { publication: string }) {
20
+
let isMobile = useIsMobile();
21
+
let [state, setState] = useState<menuState>("menu");
22
+
let [loading, setLoading] = useState(false);
23
+
24
+
return (
25
+
<Popover
26
+
asChild
27
+
onOpenChange={() => setState("menu")}
28
+
side={isMobile ? "top" : "right"}
29
+
align={isMobile ? "center" : "start"}
30
+
className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`}
31
+
arrowFill={theme.colors["border-light"]}
32
+
trigger={
33
+
<ActionButton
34
+
id="pub-settings-button"
35
+
icon=<SettingsSmall />
36
+
label="Settings"
37
+
/>
38
+
}
39
+
>
40
+
{state === "general" ? (
41
+
<EditPubForm
42
+
backToMenuAction={() => setState("menu")}
43
+
loading={loading}
44
+
setLoadingAction={setLoading}
45
+
/>
46
+
) : state === "theme" ? (
47
+
<PubThemeSetter
48
+
backToMenu={() => setState("menu")}
49
+
loading={loading}
50
+
setLoading={setLoading}
51
+
/>
52
+
) : state === "post-options" ? (
53
+
<PostOptions
54
+
backToMenu={() => setState("menu")}
55
+
loading={loading}
56
+
setLoading={setLoading}
57
+
/>
58
+
) : (
59
+
<PubSettingsMenu
60
+
state={state}
61
+
setState={setState}
62
+
loading={loading}
63
+
setLoading={setLoading}
64
+
/>
65
+
)}
66
+
</Popover>
67
+
);
68
+
}
69
+
70
+
const PubSettingsMenu = (props: {
71
+
state: menuState;
72
+
setState: (s: menuState) => void;
73
+
loading: boolean;
74
+
setLoading: (l: boolean) => void;
75
+
}) => {
76
+
let menuItemClassName =
77
+
"menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!";
78
+
79
+
return (
80
+
<div className="flex flex-col gap-0.5">
81
+
<PubSettingsHeader
82
+
loading={props.loading}
83
+
setLoadingAction={props.setLoading}
84
+
state={"menu"}
85
+
>
86
+
Settings
87
+
</PubSettingsHeader>
88
+
<button
89
+
className={menuItemClassName}
90
+
type="button"
91
+
onClick={() => {
92
+
props.setState("general");
93
+
}}
94
+
>
95
+
General Settings
96
+
<ArrowRightTiny />
97
+
</button>
98
+
<button
99
+
className={menuItemClassName}
100
+
type="button"
101
+
onClick={() => props.setState("theme")}
102
+
>
103
+
Theme and Layout
104
+
<ArrowRightTiny />
105
+
</button>
106
+
<button
107
+
className={menuItemClassName}
108
+
type="button"
109
+
onClick={() => props.setState("post-options")}
110
+
>
111
+
Post Options
112
+
<ArrowRightTiny />
113
+
</button>
114
+
</div>
115
+
);
116
+
};
117
+
118
+
export const PubSettingsHeader = (props: {
119
+
state: menuState;
120
+
backToMenuAction?: () => void;
121
+
loading: boolean;
122
+
setLoadingAction: (l: boolean) => void;
123
+
children: React.ReactNode;
124
+
}) => {
125
+
return (
126
+
<div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1">
127
+
{props.children}
128
+
{props.state !== "menu" && (
129
+
<div className="flex gap-2">
130
+
<button
131
+
type="button"
132
+
onClick={() => {
133
+
props.backToMenuAction && props.backToMenuAction();
134
+
}}
135
+
>
136
+
<GoBackSmall className="text-accent-contrast" />
137
+
</button>
138
+
139
+
<ButtonPrimary compact type="submit">
140
+
{props.loading ? <DotLoader /> : "Update"}
141
+
</ButtonPrimary>
142
+
</div>
143
+
)}
144
+
</div>
145
+
);
146
+
};
+8
-10
app/lish/[did]/[publication]/page.tsx
+8
-10
app/lish/[did]/[publication]/page.tsx
···
17
17
import { InteractionPreview } from "components/InteractionsPreview";
18
18
import { LocalizedDate } from "./LocalizedDate";
19
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
+
import { PublicationAuthor } from "./PublicationAuthor";
20
21
21
22
export default async function Publication(props: {
22
23
params: Promise<{ publication: string; did: string }>;
···
91
92
{record?.description}{" "}
92
93
</p>
93
94
{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>
95
+
<PublicationAuthor
96
+
did={profile.did}
97
+
displayName={profile.displayName}
98
+
handle={profile.handle}
99
+
/>
103
100
)}
104
101
<div className="sm:pt-4 pt-4">
105
102
<SubscribeWithBluesky
···
168
165
quotesCount={quotes}
169
166
commentsCount={comments}
170
167
tags={tags}
171
-
postUrl=""
168
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
172
169
showComments={record?.preferences?.showComments}
170
+
showMentions={record?.preferences?.showMentions}
173
171
/>
174
172
</div>
175
173
</div>
+22
-6
app/lish/addFeed.tsx
+22
-6
app/lish/addFeed.tsx
···
2
2
3
3
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
4
4
import { getIdentityData } from "actions/getIdentityData";
5
-
import { createOauthClient } from "src/atproto-oauth";
5
+
import {
6
+
restoreOAuthSession,
7
+
OAuthSessionError,
8
+
} from "src/atproto-oauth";
6
9
const leafletFeedURI =
7
10
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
8
11
9
-
export async function addFeed() {
10
-
const oauthClient = await createOauthClient();
12
+
export async function addFeed(): Promise<
13
+
{ success: true } | { success: false; error: OAuthSessionError }
14
+
> {
11
15
let identity = await getIdentityData();
12
16
if (!identity || !identity.atp_did) {
13
-
throw new Error("Invalid identity data");
17
+
return {
18
+
success: false,
19
+
error: {
20
+
type: "oauth_session_expired",
21
+
message: "Not authenticated",
22
+
did: "",
23
+
},
24
+
};
14
25
}
15
26
16
-
let credentialSession = await oauthClient.restore(identity.atp_did);
27
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
28
+
if (!sessionResult.ok) {
29
+
return { success: false, error: sessionResult.error };
30
+
}
31
+
let credentialSession = sessionResult.value;
17
32
let bsky = new BskyAgent(credentialSession);
18
33
let prefs = await bsky.app.bsky.actor.getPreferences();
19
34
let savedFeeds = prefs.data.preferences.find(
···
23
38
let hasFeed = !!savedFeeds.items.find(
24
39
(feed) => feed.value === leafletFeedURI,
25
40
);
26
-
if (hasFeed) return;
41
+
if (hasFeed) return { success: true };
27
42
28
43
await bsky.addSavedFeeds([
29
44
{
···
32
47
type: "feed",
33
48
},
34
49
]);
50
+
return { success: true };
35
51
}
+42
-13
app/lish/createPub/CreatePubForm.tsx
+42
-13
app/lish/createPub/CreatePubForm.tsx
···
13
13
import { string } from "zod";
14
14
import { DotLoader } from "components/utils/DotLoader";
15
15
import { Checkbox } from "components/Checkbox";
16
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
16
17
17
18
type DomainState =
18
19
| { status: "empty" }
···
32
33
let [domainState, setDomainState] = useState<DomainState>({
33
34
status: "empty",
34
35
});
36
+
let [oauthError, setOauthError] = useState<
37
+
import("src/atproto-oauth").OAuthSessionError | null
38
+
>(null);
35
39
let fileInputRef = useRef<HTMLInputElement>(null);
36
40
37
41
let router = useRouter();
···
43
47
e.preventDefault();
44
48
if (!subdomainValidator.safeParse(domainValue).success) return;
45
49
setFormState("loading");
46
-
let data = await createPublication({
50
+
setOauthError(null);
51
+
let result = await createPublication({
47
52
name: nameValue,
48
53
description: descriptionValue,
49
54
iconFile: logoFile,
50
55
subdomain: domainValue,
51
-
preferences: { showInDiscover, showComments: true },
56
+
preferences: {
57
+
showInDiscover,
58
+
showComments: true,
59
+
showMentions: true,
60
+
showPrevNext: false,
61
+
},
52
62
});
63
+
64
+
if (!result.success) {
65
+
setFormState("normal");
66
+
if (result.error && isOAuthSessionError(result.error)) {
67
+
setOauthError(result.error);
68
+
}
69
+
return;
70
+
}
71
+
53
72
// Show a spinner while this is happening! Maybe a progress bar?
54
73
setTimeout(() => {
55
74
setFormState("normal");
56
-
if (data?.publication)
57
-
router.push(`${getBasePublicationURL(data.publication)}/dashboard`);
75
+
if (result.publication)
76
+
router.push(
77
+
`${getBasePublicationURL(result.publication)}/dashboard`,
78
+
);
58
79
}, 500);
59
80
}}
60
81
>
···
139
160
</Checkbox>
140
161
<hr className="border-border-light" />
141
162
142
-
<div className="flex w-full justify-end">
143
-
<ButtonPrimary
144
-
type="submit"
145
-
disabled={
146
-
!nameValue || !domainValue || domainState.status !== "valid"
147
-
}
148
-
>
149
-
{formState === "loading" ? <DotLoader /> : "Create Publication!"}
150
-
</ButtonPrimary>
163
+
<div className="flex flex-col gap-2">
164
+
<div className="flex w-full justify-end">
165
+
<ButtonPrimary
166
+
type="submit"
167
+
disabled={
168
+
!nameValue || !domainValue || domainState.status !== "valid"
169
+
}
170
+
>
171
+
{formState === "loading" ? <DotLoader /> : "Create Publication!"}
172
+
</ButtonPrimary>
173
+
</div>
174
+
{oauthError && (
175
+
<OAuthErrorMessage
176
+
error={oauthError}
177
+
className="text-right text-sm text-accent-1"
178
+
/>
179
+
)}
151
180
</div>
152
181
</form>
153
182
);
+23
-16
app/lish/createPub/UpdatePubForm.tsx
+23
-16
app/lish/createPub/UpdatePubForm.tsx
···
20
20
import Link from "next/link";
21
21
import { Checkbox } from "components/Checkbox";
22
22
import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
23
-
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings";
23
+
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings";
24
+
import { Toggle } from "components/Toggle";
24
25
25
26
export const EditPubForm = (props: {
26
27
backToMenuAction: () => void;
···
43
44
? true
44
45
: record.preferences.showComments,
45
46
);
47
+
let showMentions =
48
+
record?.preferences?.showMentions === undefined
49
+
? true
50
+
: record.preferences.showMentions;
51
+
let showPrevNext =
52
+
record?.preferences?.showPrevNext === undefined
53
+
? true
54
+
: record.preferences.showPrevNext;
55
+
46
56
let [descriptionValue, setDescriptionValue] = useState(
47
57
record?.description || "",
48
58
);
···
74
84
preferences: {
75
85
showInDiscover: showInDiscover,
76
86
showComments: showComments,
87
+
showMentions: showMentions,
88
+
showPrevNext: showPrevNext,
77
89
},
78
90
});
79
91
toast({ type: "success", content: "Updated!" });
···
86
98
setLoadingAction={props.setLoadingAction}
87
99
backToMenuAction={props.backToMenuAction}
88
100
state={"theme"}
89
-
/>
101
+
>
102
+
General Settings
103
+
</PubSettingsHeader>
90
104
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
91
-
<div className="flex items-center justify-between gap-2 ">
105
+
<div className="flex items-center justify-between gap-2 mt-2 ">
92
106
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
93
107
Logo <span className="font-normal">(optional)</span>
94
108
</p>
···
158
172
<CustomDomainForm />
159
173
<hr className="border-border-light" />
160
174
161
-
<Checkbox
162
-
checked={showInDiscover}
163
-
onChange={(e) => setShowInDiscover(e.target.checked)}
175
+
<Toggle
176
+
toggle={showInDiscover}
177
+
onToggle={() => setShowInDiscover(!showInDiscover)}
164
178
>
165
-
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
179
+
<div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
166
180
<p className="font-bold">
167
181
Show In{" "}
168
182
<a href="/discover" target="_blank">
···
177
191
page. You can change this at any time!
178
192
</p>
179
193
</div>
180
-
</Checkbox>
194
+
</Toggle>
181
195
182
-
<Checkbox
183
-
checked={showComments}
184
-
onChange={(e) => setShowComments(e.target.checked)}
185
-
>
186
-
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
187
-
<p className="font-bold">Show comments on posts</p>
188
-
</div>
189
-
</Checkbox>
196
+
190
197
</div>
191
198
</form>
192
199
);
+24
-5
app/lish/createPub/createPublication.ts
+24
-5
app/lish/createPub/createPublication.ts
···
1
1
"use server";
2
2
import { TID } from "@atproto/common";
3
3
import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
4
-
import { createOauthClient } from "src/atproto-oauth";
4
+
import {
5
+
restoreOAuthSession,
6
+
OAuthSessionError,
7
+
} from "src/atproto-oauth";
5
8
import { getIdentityData } from "actions/getIdentityData";
6
9
import { supabaseServerClient } from "supabase/serverClient";
7
10
import { Un$Typed } from "@atproto/api";
···
18
21
.min(3)
19
22
.max(63)
20
23
.regex(/^[a-z0-9-]+$/);
24
+
type CreatePublicationResult =
25
+
| { success: true; publication: any }
26
+
| { success: false; error?: OAuthSessionError };
27
+
21
28
export async function createPublication({
22
29
name,
23
30
description,
···
30
37
iconFile: File | null;
31
38
subdomain: string;
32
39
preferences: Omit<PubLeafletPublication.Preferences, "$type">;
33
-
}) {
40
+
}): Promise<CreatePublicationResult> {
34
41
let isSubdomainValid = subdomainValidator.safeParse(subdomain);
35
42
if (!isSubdomainValid.success) {
36
43
return { success: false };
37
44
}
38
-
const oauthClient = await createOauthClient();
39
45
let identity = await getIdentityData();
40
-
if (!identity || !identity.atp_did) return;
46
+
if (!identity || !identity.atp_did) {
47
+
return {
48
+
success: false,
49
+
error: {
50
+
type: "oauth_session_expired",
51
+
message: "Not authenticated",
52
+
did: "",
53
+
},
54
+
};
55
+
}
41
56
42
57
let domain = `${subdomain}.leaflet.pub`;
43
58
44
-
let credentialSession = await oauthClient.restore(identity.atp_did);
59
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
60
+
if (!sessionResult.ok) {
61
+
return { success: false, error: sessionResult.error };
62
+
}
63
+
let credentialSession = sessionResult.value;
45
64
let agent = new AtpBaseClient(
46
65
credentialSession.fetchHandler.bind(credentialSession),
47
66
);
+66
-18
app/lish/createPub/updatePublication.ts
+66
-18
app/lish/createPub/updatePublication.ts
···
5
5
PubLeafletPublication,
6
6
PubLeafletThemeColor,
7
7
} from "lexicons/api";
8
-
import { createOauthClient } from "src/atproto-oauth";
8
+
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
9
9
import { getIdentityData } from "actions/getIdentityData";
10
10
import { supabaseServerClient } from "supabase/serverClient";
11
11
import { Json } from "supabase/database.types";
12
12
import { AtUri } from "@atproto/syntax";
13
13
import { $Typed } from "@atproto/api";
14
+
15
+
type UpdatePublicationResult =
16
+
| { success: true; publication: any }
17
+
| { success: false; error?: OAuthSessionError };
14
18
15
19
export async function updatePublication({
16
20
uri,
···
21
25
}: {
22
26
uri: string;
23
27
name: string;
24
-
description: string;
25
-
iconFile: File | null;
28
+
description?: string;
29
+
iconFile?: File | null;
26
30
preferences?: Omit<PubLeafletPublication.Preferences, "$type">;
27
-
}) {
28
-
const oauthClient = await createOauthClient();
31
+
}): Promise<UpdatePublicationResult> {
29
32
let identity = await getIdentityData();
30
-
if (!identity || !identity.atp_did) return;
33
+
if (!identity || !identity.atp_did) {
34
+
return {
35
+
success: false,
36
+
error: {
37
+
type: "oauth_session_expired",
38
+
message: "Not authenticated",
39
+
did: "",
40
+
},
41
+
};
42
+
}
31
43
32
-
let credentialSession = await oauthClient.restore(identity.atp_did);
44
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
45
+
if (!sessionResult.ok) {
46
+
return { success: false, error: sessionResult.error };
47
+
}
48
+
let credentialSession = sessionResult.value;
33
49
let agent = new AtpBaseClient(
34
50
credentialSession.fetchHandler.bind(credentialSession),
35
51
);
···
38
54
.select("*")
39
55
.eq("uri", uri)
40
56
.single();
41
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
57
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
58
+
return { success: false };
59
+
}
42
60
let aturi = new AtUri(existingPub.uri);
43
61
44
62
let record: PubLeafletPublication.Record = {
···
94
112
}: {
95
113
uri: string;
96
114
base_path: string;
97
-
}) {
98
-
const oauthClient = await createOauthClient();
115
+
}): Promise<UpdatePublicationResult> {
99
116
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
117
+
if (!identity || !identity.atp_did) {
118
+
return {
119
+
success: false,
120
+
error: {
121
+
type: "oauth_session_expired",
122
+
message: "Not authenticated",
123
+
did: "",
124
+
},
125
+
};
126
+
}
101
127
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
128
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
129
+
if (!sessionResult.ok) {
130
+
return { success: false, error: sessionResult.error };
131
+
}
132
+
let credentialSession = sessionResult.value;
103
133
let agent = new AtpBaseClient(
104
134
credentialSession.fetchHandler.bind(credentialSession),
105
135
);
···
108
138
.select("*")
109
139
.eq("uri", uri)
110
140
.single();
111
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
141
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
142
+
return { success: false };
143
+
}
112
144
let aturi = new AtUri(existingPub.uri);
113
145
114
146
let record: PubLeafletPublication.Record = {
···
149
181
backgroundImage?: File | null;
150
182
backgroundRepeat?: number | null;
151
183
backgroundColor: Color;
184
+
pageWidth?: number;
152
185
primary: Color;
153
186
pageBackground: Color;
154
187
showPageBackground: boolean;
155
188
accentBackground: Color;
156
189
accentText: Color;
157
190
};
158
-
}) {
159
-
const oauthClient = await createOauthClient();
191
+
}): Promise<UpdatePublicationResult> {
160
192
let identity = await getIdentityData();
161
-
if (!identity || !identity.atp_did) return;
193
+
if (!identity || !identity.atp_did) {
194
+
return {
195
+
success: false,
196
+
error: {
197
+
type: "oauth_session_expired",
198
+
message: "Not authenticated",
199
+
did: "",
200
+
},
201
+
};
202
+
}
162
203
163
-
let credentialSession = await oauthClient.restore(identity.atp_did);
204
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
205
+
if (!sessionResult.ok) {
206
+
return { success: false, error: sessionResult.error };
207
+
}
208
+
let credentialSession = sessionResult.value;
164
209
let agent = new AtpBaseClient(
165
210
credentialSession.fetchHandler.bind(credentialSession),
166
211
);
···
169
214
.select("*")
170
215
.eq("uri", uri)
171
216
.single();
172
-
if (!existingPub || existingPub.identity_did !== identity.atp_did) return;
217
+
if (!existingPub || existingPub.identity_did !== identity.atp_did) {
218
+
return { success: false };
219
+
}
173
220
let aturi = new AtUri(existingPub.uri);
174
221
175
222
let oldRecord = existingPub.record as PubLeafletPublication.Record;
···
197
244
...theme.backgroundColor,
198
245
}
199
246
: undefined,
247
+
pageWidth: theme.pageWidth,
200
248
primary: {
201
249
...theme.primary,
202
250
},
+40
-9
app/lish/subscribeToPublication.ts
+40
-9
app/lish/subscribeToPublication.ts
···
3
3
import { AtpBaseClient } from "lexicons/api";
4
4
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5
5
import { getIdentityData } from "actions/getIdentityData";
6
-
import { createOauthClient } from "src/atproto-oauth";
6
+
import {
7
+
restoreOAuthSession,
8
+
OAuthSessionError,
9
+
} from "src/atproto-oauth";
7
10
import { TID } from "@atproto/common";
8
11
import { supabaseServerClient } from "supabase/serverClient";
9
12
import { revalidatePath } from "next/cache";
···
21
24
let leafletFeedURI =
22
25
"at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
23
26
let idResolver = new IdResolver();
27
+
28
+
type SubscribeResult =
29
+
| { success: true; hasFeed: boolean }
30
+
| { success: false; error: OAuthSessionError };
31
+
24
32
export async function subscribeToPublication(
25
33
publication: string,
26
34
redirectRoute?: string,
27
-
) {
28
-
const oauthClient = await createOauthClient();
35
+
): Promise<SubscribeResult | never> {
29
36
let identity = await getIdentityData();
30
37
if (!identity || !identity.atp_did) {
31
38
return redirect(
···
33
40
);
34
41
}
35
42
36
-
let credentialSession = await oauthClient.restore(identity.atp_did);
43
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
44
+
if (!sessionResult.ok) {
45
+
return { success: false, error: sessionResult.error };
46
+
}
47
+
let credentialSession = sessionResult.value;
37
48
let agent = new AtpBaseClient(
38
49
credentialSession.fetchHandler.bind(credentialSession),
39
50
);
···
90
101
) as AppBskyActorDefs.SavedFeedsPrefV2;
91
102
revalidatePath("/lish/[did]/[publication]", "layout");
92
103
return {
104
+
success: true,
93
105
hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
94
106
};
95
107
}
96
108
97
-
export async function unsubscribeToPublication(publication: string) {
98
-
const oauthClient = await createOauthClient();
109
+
type UnsubscribeResult =
110
+
| { success: true }
111
+
| { success: false; error: OAuthSessionError };
112
+
113
+
export async function unsubscribeToPublication(
114
+
publication: string
115
+
): Promise<UnsubscribeResult> {
99
116
let identity = await getIdentityData();
100
-
if (!identity || !identity.atp_did) return;
117
+
if (!identity || !identity.atp_did) {
118
+
return {
119
+
success: false,
120
+
error: {
121
+
type: "oauth_session_expired",
122
+
message: "Not authenticated",
123
+
did: "",
124
+
},
125
+
};
126
+
}
101
127
102
-
let credentialSession = await oauthClient.restore(identity.atp_did);
128
+
const sessionResult = await restoreOAuthSession(identity.atp_did);
129
+
if (!sessionResult.ok) {
130
+
return { success: false, error: sessionResult.error };
131
+
}
132
+
let credentialSession = sessionResult.value;
103
133
let agent = new AtpBaseClient(
104
134
credentialSession.fetchHandler.bind(credentialSession),
105
135
);
···
109
139
.eq("identity", identity.atp_did)
110
140
.eq("publication", publication)
111
141
.single();
112
-
if (!existingSubscription) return;
142
+
if (!existingSubscription) return { success: true };
113
143
await agent.pub.leaflet.graph.subscription.delete({
114
144
repo: credentialSession.did!,
115
145
rkey: new AtUri(existingSubscription.uri).rkey,
···
120
150
.eq("identity", identity.atp_did)
121
151
.eq("publication", publication);
122
152
revalidatePath("/lish/[did]/[publication]", "layout");
153
+
return { success: true };
123
154
}
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
+59
-4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···
1
1
import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { ids } from "lexicons/api/lexicons";
5
+
import { PubLeafletDocument } from "lexicons/api";
6
+
import { jsonToLex } from "@atproto/lexicon";
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
+
import { fetchAtprotoBlob } from "app/api/atproto_images/route";
2
9
3
-
export const runtime = "edge";
4
10
export const revalidate = 60;
5
11
6
12
export default async function OpenGraphImage(props: {
7
13
params: Promise<{ rkey: string; didOrHandle: string }>;
8
14
}) {
9
15
let params = await props.params;
10
-
return getMicroLinkOgImage(
11
-
`/p/${params.didOrHandle}/${params.rkey}/`,
12
-
);
16
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
17
+
18
+
// Resolve handle to DID if needed
19
+
let did = didOrHandle;
20
+
if (!didOrHandle.startsWith("did:")) {
21
+
try {
22
+
let resolved = await idResolver.handle.resolve(didOrHandle);
23
+
if (resolved) did = resolved;
24
+
} catch (e) {
25
+
// Fall back to screenshot if handle resolution fails
26
+
}
27
+
}
28
+
29
+
if (did) {
30
+
// Try to get the document's cover image
31
+
let { data: document } = await supabaseServerClient
32
+
.from("documents")
33
+
.select("data")
34
+
.eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
35
+
.single();
36
+
37
+
if (document) {
38
+
let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
39
+
if (docRecord.coverImage) {
40
+
try {
41
+
// Get CID from the blob ref (handle both serialized and hydrated forms)
42
+
let cid =
43
+
(docRecord.coverImage.ref as unknown as { $link: string })["$link"] ||
44
+
docRecord.coverImage.ref.toString();
45
+
46
+
let imageResponse = await fetchAtprotoBlob(did, cid);
47
+
if (imageResponse) {
48
+
let imageBlob = await imageResponse.blob();
49
+
50
+
// Return the image with appropriate headers
51
+
return new Response(imageBlob, {
52
+
headers: {
53
+
"Content-Type": imageBlob.type || "image/jpeg",
54
+
"Cache-Control": "public, max-age=3600",
55
+
},
56
+
});
57
+
}
58
+
} catch (e) {
59
+
// Fall through to screenshot if cover image fetch fails
60
+
console.error("Failed to fetch cover image:", e);
61
+
}
62
+
}
63
+
}
64
+
}
65
+
66
+
// Fall back to screenshot
67
+
return getMicroLinkOgImage(`/p/${params.didOrHandle}/${params.rkey}/`);
13
68
}
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
···
5
5
import { Metadata } from "next";
6
6
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
7
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
8
9
9
10
export async function generateMetadata(props: {
10
11
params: Promise<{ didOrHandle: string; rkey: string }>;
···
34
35
let docRecord = document.data as PubLeafletDocument.Record;
35
36
36
37
// For documents in publications, include publication name
37
-
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
+
let publicationName =
39
+
document.documents_in_publications[0]?.publications?.name;
38
40
39
41
return {
40
42
icons: {
···
63
65
let resolved = await idResolver.handle.resolve(didOrHandle);
64
66
if (!resolved) {
65
67
return (
66
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
-
<p>Sorry, can't resolve handle.</p>
68
+
<NotFoundLayout>
69
+
<p className="font-bold">Sorry, we can't find this handle!</p>
68
70
<p>
69
71
This may be a glitch on our end. If the issue persists please{" "}
70
72
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
73
</p>
72
-
</div>
74
+
</NotFoundLayout>
73
75
);
74
76
}
75
77
did = resolved;
76
78
} catch (e) {
77
79
return (
78
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
-
<p>Sorry, can't resolve handle.</p>
80
+
<NotFoundLayout>
81
+
<p className="font-bold">Sorry, we can't find this leaflet!</p>
80
82
<p>
81
83
This may be a glitch on our end. If the issue persists please{" "}
82
84
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
85
</p>
84
-
</div>
86
+
</NotFoundLayout>
85
87
);
86
88
}
87
89
}
+8
-32
appview/index.ts
+8
-32
appview/index.ts
···
20
20
} from "@atproto/api";
21
21
import { AtUri } from "@atproto/syntax";
22
22
import { writeFile, readFile } from "fs/promises";
23
-
import { createIdentity } from "actions/createIdentity";
24
-
import { drizzle } from "drizzle-orm/node-postgres";
25
23
import { inngest } from "app/api/inngest/client";
26
-
import { Client } from "pg";
27
24
28
25
const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor";
29
26
···
135
132
if (evt.event === "create" || evt.event === "update") {
136
133
let record = PubLeafletPublication.validateRecord(evt.record);
137
134
if (!record.success) return;
138
-
let { error } = await supabase.from("publications").upsert({
135
+
await supabase
136
+
.from("identities")
137
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
138
+
await supabase.from("publications").upsert({
139
139
uri: evt.uri.toString(),
140
140
identity_did: evt.did,
141
141
name: record.value.name,
142
142
record: record.value as Json,
143
143
});
144
-
145
-
if (error && error.code === "23503") {
146
-
console.log("creating identity");
147
-
let client = new Client({ connectionString: process.env.DB_URL });
148
-
let db = drizzle(client);
149
-
await createIdentity(db, { atp_did: evt.did });
150
-
client.end();
151
-
await supabase.from("publications").upsert({
152
-
uri: evt.uri.toString(),
153
-
identity_did: evt.did,
154
-
name: record.value.name,
155
-
record: record.value as Json,
156
-
});
157
-
}
158
144
}
159
145
if (evt.event === "delete") {
160
146
await supabase
···
222
208
if (evt.event === "create" || evt.event === "update") {
223
209
let record = PubLeafletGraphSubscription.validateRecord(evt.record);
224
210
if (!record.success) return;
225
-
let { error } = await supabase.from("publication_subscriptions").upsert({
211
+
await supabase
212
+
.from("identities")
213
+
.upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
214
+
await supabase.from("publication_subscriptions").upsert({
226
215
uri: evt.uri.toString(),
227
216
identity: evt.did,
228
217
publication: record.value.publication,
229
218
record: record.value as Json,
230
219
});
231
-
if (error && error.code === "23503") {
232
-
console.log("creating identity");
233
-
let client = new Client({ connectionString: process.env.DB_URL });
234
-
let db = drizzle(client);
235
-
await createIdentity(db, { atp_did: evt.did });
236
-
client.end();
237
-
await supabase.from("publication_subscriptions").upsert({
238
-
uri: evt.uri.toString(),
239
-
identity: evt.did,
240
-
publication: record.value.publication,
241
-
record: record.value as Json,
242
-
});
243
-
}
244
220
}
245
221
if (evt.event === "delete") {
246
222
await supabase
+1
-1
components/ActionBar/Publications.tsx
+1
-1
components/ActionBar/Publications.tsx
···
193
193
194
194
return props.record.icon ? (
195
195
<div
196
-
className={`${iconSizeClassName} ${props.className} relative overflow-hidden`}
196
+
className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`}
197
197
>
198
198
<img
199
199
src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+2
-2
components/AtMentionLink.tsx
+2
-2
components/AtMentionLink.tsx
···
24
24
isPublication || isDocument ? (
25
25
<img
26
26
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
-
className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
27
+
className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top"
28
28
alt=""
29
29
width="20"
30
30
height="20"
···
37
37
href={atUriToUrl(atURI)}
38
38
target="_blank"
39
39
rel="noopener noreferrer"
40
-
className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
40
+
className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
41
>
42
42
{icon}
43
43
{children}
+4
-1
components/Avatar.tsx
+4
-1
components/Avatar.tsx
···
3
3
export const Avatar = (props: {
4
4
src: string | undefined;
5
5
displayName: string | undefined;
6
+
className?: string;
6
7
tiny?: boolean;
8
+
large?: boolean;
9
+
giant?: boolean;
7
10
}) => {
8
11
if (props.src)
9
12
return (
10
13
<img
11
-
className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`}
14
+
className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`}
12
15
src={props.src}
13
16
alt={
14
17
props.displayName
+26
components/Blocks/Block.tsx
+26
components/Blocks/Block.tsx
···
383
383
);
384
384
};
385
385
386
+
export const BlockLayout = (props: {
387
+
isSelected?: boolean;
388
+
children: React.ReactNode;
389
+
className?: string;
390
+
hasBackground?: "accent" | "page";
391
+
borderOnHover?: boolean;
392
+
}) => {
393
+
return (
394
+
<div
395
+
className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden
396
+
${props.isSelected ? "block-border-selected " : "block-border"}
397
+
${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`}
398
+
style={{
399
+
backgroundColor:
400
+
props.hasBackground === "accent"
401
+
? "var(--accent-light)"
402
+
: props.hasBackground === "page"
403
+
? "rgb(var(--bg-page))"
404
+
: "transparent",
405
+
}}
406
+
>
407
+
{props.children}
408
+
</div>
409
+
);
410
+
};
411
+
386
412
export const ListMarker = (
387
413
props: Block & {
388
414
previousBlock?: Block | null;
+7
-5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
+7
-5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
148
148
}
149
149
return (
150
150
<div
151
-
className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`}
151
+
className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`}
152
152
>
153
-
<div className="bskyAuthor w-full flex items-center gap-1">
153
+
<div className="bskyAuthor w-full flex items-center ">
154
154
{record.author.avatar && (
155
155
<img
156
156
src={record.author?.avatar}
157
157
alt={`${record.author?.displayName}'s avatar`}
158
-
className="shink-0 w-6 h-6 rounded-full border border-border-light"
158
+
className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]"
159
159
/>
160
160
)}
161
-
<div className=" font-bold text-secondary">
161
+
<div className=" font-bold text-secondary mr-1">
162
162
{record.author?.displayName}
163
163
</div>
164
164
<a
···
171
171
</div>
172
172
173
173
<div className="flex flex-col gap-2 ">
174
-
{text && <pre className="whitespace-pre-wrap">{text}</pre>}
174
+
{text && (
175
+
<pre className="whitespace-pre-wrap text-secondary">{text}</pre>
176
+
)}
175
177
{record.embeds !== undefined
176
178
? record.embeds.map((embed, index) => (
177
179
<BlueskyEmbed embed={embed} key={index} />
+8
-11
components/Blocks/BlueskyPostBlock/index.tsx
+8
-11
components/Blocks/BlueskyPostBlock/index.tsx
···
2
2
import { useEffect, useState } from "react";
3
3
import { useEntity } from "src/replicache";
4
4
import { useUIState } from "src/useUIState";
5
-
import { BlockProps } from "../Block";
5
+
import { BlockProps, BlockLayout } from "../Block";
6
6
import { elementId } from "src/utils/elementId";
7
7
import { focusBlock } from "src/utils/focusBlock";
8
8
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
···
56
56
AppBskyFeedDefs.isBlockedAuthor(post) ||
57
57
AppBskyFeedDefs.isNotFoundPost(post):
58
58
return (
59
-
<div
60
-
className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`}
61
-
>
59
+
<BlockLayout isSelected={!!isSelected} className="w-full">
62
60
<PostNotAvailable />
63
-
</div>
61
+
</BlockLayout>
64
62
);
65
63
66
64
case AppBskyFeedDefs.isThreadViewPost(post):
···
81
79
let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
82
80
83
81
return (
84
-
<div
85
-
className={`
86
-
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
87
-
${isSelected ? "block-border-selected " : "block-border"}
88
-
`}
82
+
<BlockLayout
83
+
isSelected={!!isSelected}
84
+
hasBackground="page"
85
+
className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary"
89
86
>
90
87
{post.post.author && record && (
91
88
<>
···
149
146
</a>
150
147
</div>
151
148
</div>
152
-
</div>
149
+
</BlockLayout>
153
150
);
154
151
}
155
152
};
+103
-103
components/Blocks/ButtonBlock.tsx
+103
-103
components/Blocks/ButtonBlock.tsx
···
3
3
import { useCallback, useEffect, useState } from "react";
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
7
import { v7 } from "uuid";
8
8
import { useSmoker } from "components/Toast";
9
9
···
106
106
};
107
107
108
108
return (
109
-
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full">
109
+
<div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full ">
110
110
<ButtonPrimary className="mx-auto">
111
111
{text !== "" ? text : "Button"}
112
112
</ButtonPrimary>
113
-
114
-
<form
115
-
className={`
116
-
buttonBlockSettingsBorder
117
-
w-full bg-bg-page
118
-
text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0
119
-
flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
120
-
${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"}
121
-
`}
122
-
onSubmit={(e) => {
123
-
e.preventDefault();
124
-
let rect = document
125
-
.getElementById("button-block-settings")
126
-
?.getBoundingClientRect();
127
-
if (!textValue) {
128
-
smoker({
129
-
error: true,
130
-
text: "missing button text!",
131
-
position: {
132
-
y: rect ? rect.top : 0,
133
-
x: rect ? rect.left + 12 : 0,
134
-
},
135
-
});
136
-
return;
137
-
}
138
-
if (!urlValue) {
139
-
smoker({
140
-
error: true,
141
-
text: "missing url!",
142
-
position: {
143
-
y: rect ? rect.top : 0,
144
-
x: rect ? rect.left + 12 : 0,
145
-
},
146
-
});
147
-
return;
148
-
}
149
-
if (!isUrl(urlValue)) {
150
-
smoker({
151
-
error: true,
152
-
text: "invalid url!",
153
-
position: {
154
-
y: rect ? rect.top : 0,
155
-
x: rect ? rect.left + 12 : 0,
156
-
},
157
-
});
158
-
return;
159
-
}
160
-
submit();
161
-
}}
113
+
<BlockLayout
114
+
isSelected={!!isSelected}
115
+
borderOnHover
116
+
hasBackground="accent"
117
+
className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!"
162
118
>
163
-
<div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
164
-
<div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
165
-
<BlockButtonSmall
166
-
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
167
-
/>
168
-
<Separator />
169
-
<Input
170
-
type="text"
171
-
autoFocus
172
-
className="w-full grow border-none outline-hidden bg-transparent"
173
-
placeholder="button text"
174
-
value={textValue}
175
-
disabled={isLocked}
176
-
onChange={(e) => setTextValue(e.target.value)}
177
-
onKeyDown={(e) => {
178
-
if (
179
-
e.key === "Backspace" &&
180
-
!e.currentTarget.value &&
181
-
urlValue !== ""
182
-
)
183
-
e.preventDefault();
184
-
}}
185
-
/>
186
-
</div>
187
-
<div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
188
-
<LinkSmall
189
-
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
190
-
/>
191
-
<Separator />
192
-
<Input
193
-
type="text"
194
-
id="button-block-url-input"
195
-
className="w-full grow border-none outline-hidden bg-transparent"
196
-
placeholder="www.example.com"
197
-
value={urlValue}
198
-
disabled={isLocked}
199
-
onChange={(e) => setUrlValue(e.target.value)}
200
-
onKeyDown={(e) => {
201
-
if (e.key === "Backspace" && !e.currentTarget.value)
202
-
e.preventDefault();
203
-
}}
204
-
/>
119
+
<form
120
+
className={`w-full`}
121
+
onSubmit={(e) => {
122
+
e.preventDefault();
123
+
let rect = document
124
+
.getElementById("button-block-settings")
125
+
?.getBoundingClientRect();
126
+
if (!textValue) {
127
+
smoker({
128
+
error: true,
129
+
text: "missing button text!",
130
+
position: {
131
+
y: rect ? rect.top : 0,
132
+
x: rect ? rect.left + 12 : 0,
133
+
},
134
+
});
135
+
return;
136
+
}
137
+
if (!urlValue) {
138
+
smoker({
139
+
error: true,
140
+
text: "missing url!",
141
+
position: {
142
+
y: rect ? rect.top : 0,
143
+
x: rect ? rect.left + 12 : 0,
144
+
},
145
+
});
146
+
return;
147
+
}
148
+
if (!isUrl(urlValue)) {
149
+
smoker({
150
+
error: true,
151
+
text: "invalid url!",
152
+
position: {
153
+
y: rect ? rect.top : 0,
154
+
x: rect ? rect.left + 12 : 0,
155
+
},
156
+
});
157
+
return;
158
+
}
159
+
submit();
160
+
}}
161
+
>
162
+
<div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
163
+
<div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
164
+
<BlockButtonSmall
165
+
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
166
+
/>
167
+
<Separator />
168
+
<Input
169
+
type="text"
170
+
autoFocus
171
+
className="w-full grow border-none outline-hidden bg-transparent"
172
+
placeholder="button text"
173
+
value={textValue}
174
+
disabled={isLocked}
175
+
onChange={(e) => setTextValue(e.target.value)}
176
+
onKeyDown={(e) => {
177
+
if (
178
+
e.key === "Backspace" &&
179
+
!e.currentTarget.value &&
180
+
urlValue !== ""
181
+
)
182
+
e.preventDefault();
183
+
}}
184
+
/>
185
+
</div>
186
+
<div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
187
+
<LinkSmall
188
+
className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
189
+
/>
190
+
<Separator />
191
+
<Input
192
+
type="text"
193
+
id="button-block-url-input"
194
+
className="w-full grow border-none outline-hidden bg-transparent"
195
+
placeholder="www.example.com"
196
+
value={urlValue}
197
+
disabled={isLocked}
198
+
onChange={(e) => setUrlValue(e.target.value)}
199
+
onKeyDown={(e) => {
200
+
if (e.key === "Backspace" && !e.currentTarget.value)
201
+
e.preventDefault();
202
+
}}
203
+
/>
204
+
</div>
205
+
<button
206
+
id="button-block-settings"
207
+
type="submit"
208
+
className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`}
209
+
>
210
+
<div className="sm:hidden block">Save</div>
211
+
<CheckTiny />
212
+
</button>
205
213
</div>
206
-
<button
207
-
id="button-block-settings"
208
-
type="submit"
209
-
className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`}
210
-
>
211
-
<div className="sm:hidden block">Save</div>
212
-
<CheckTiny />
213
-
</button>
214
-
</div>
215
-
</form>
214
+
</form>
215
+
</BlockLayout>
216
216
</div>
217
217
);
218
218
};
+17
-6
components/Blocks/CodeBlock.tsx
+17
-6
components/Blocks/CodeBlock.tsx
···
6
6
} from "shiki";
7
7
import { useEntity, useReplicache } from "src/replicache";
8
8
import "katex/dist/katex.min.css";
9
-
import { BlockProps } from "./Block";
9
+
import { BlockLayout, BlockProps } from "./Block";
10
10
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
11
11
import { useUIState } from "src/useUIState";
12
12
import { BaseTextareaBlock } from "./BaseTextareaBlock";
···
119
119
</select>
120
120
</div>
121
121
)}
122
-
<div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline">
122
+
123
+
<BlockLayout
124
+
isSelected={focusedBlock}
125
+
hasBackground="accent"
126
+
borderOnHover
127
+
className="p-0! min-h-[48px]"
128
+
>
123
129
{focusedBlock && permissions.write ? (
124
130
<BaseTextareaBlock
131
+
placeholder="write some codeโฆ"
125
132
data-editable-block
126
133
data-entityid={props.entityID}
127
134
id={elementId.block(props.entityID).input}
···
131
138
spellCheck={false}
132
139
autoCapitalize="none"
133
140
autoCorrect="off"
134
-
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2"
141
+
className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3"
135
142
value={content?.data.value}
136
143
onChange={async (e) => {
137
144
// Update the entity with the new value
···
146
153
<pre
147
154
onClick={onClick}
148
155
onMouseDown={(e) => e.stopPropagation()}
149
-
className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full"
156
+
className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full"
150
157
>
151
-
{content?.data.value}
158
+
{content?.data.value === "" || content?.data.value === undefined ? (
159
+
<div className="text-tertiary italic">write some codeโฆ</div>
160
+
) : (
161
+
content?.data.value
162
+
)}
152
163
</pre>
153
164
) : (
154
165
<div
···
159
170
dangerouslySetInnerHTML={{ __html: html || "" }}
160
171
/>
161
172
)}
162
-
</div>
173
+
</BlockLayout>
163
174
</div>
164
175
);
165
176
}
+5
-5
components/Blocks/DateTimeBlock.tsx
+5
-5
components/Blocks/DateTimeBlock.tsx
···
1
1
import { useEntity, useReplicache } from "src/replicache";
2
-
import { BlockProps } from "./Block";
2
+
import { BlockProps, BlockLayout } from "./Block";
3
3
import { ChevronProps, DayPicker } from "react-day-picker";
4
4
import { Popover } from "components/Popover";
5
5
import { useEffect, useMemo, useState } from "react";
···
121
121
disabled={isLocked || !permissions.write}
122
122
className="w-64 z-10 px-2!"
123
123
trigger={
124
-
<div
125
-
className={`flex flex-row gap-2 group/date w-64 z-1
126
-
${isSelected ? "block-border-selected border-transparent!" : "border border-transparent"}
124
+
<BlockLayout
125
+
isSelected={!!isSelected}
126
+
className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent!
127
127
${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"}
128
128
`}
129
129
>
···
163
163
</div>
164
164
)}
165
165
</FadeIn>
166
-
</div>
166
+
</BlockLayout>
167
167
}
168
168
>
169
169
<div className="flex flex-col gap-3 ">
+13
-16
components/Blocks/EmbedBlock.tsx
+13
-16
components/Blocks/EmbedBlock.tsx
···
3
3
import { useCallback, useEffect, useState } from "react";
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
-
import { BlockProps } from "./Block";
6
+
import { BlockProps, BlockLayout } from "./Block";
7
7
import { v7 } from "uuid";
8
8
import { useSmoker } from "components/Toast";
9
9
import { Separator } from "components/Layout";
···
84
84
<div
85
85
className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
86
86
>
87
-
{/*
88
-
the iframe!
89
-
can also add 'allow' and 'referrerpolicy' attributes later if needed
90
-
*/}
91
-
<iframe
92
-
className={`
93
-
flex flex-col relative w-full overflow-hidden group/embedBlock
94
-
${isSelected ? "block-border-selected " : "block-border"}
95
-
`}
96
-
width="100%"
97
-
height={height + (heightHandle.dragDelta?.y || 0)}
98
-
src={url?.data.value}
99
-
allow="fullscreen"
100
-
loading="lazy"
101
-
></iframe>
87
+
<BlockLayout
88
+
isSelected={!!isSelected}
89
+
className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!"
90
+
>
91
+
<iframe
92
+
width="100%"
93
+
height={height + (heightHandle.dragDelta?.y || 0)}
94
+
src={url?.data.value}
95
+
allow="fullscreen"
96
+
loading="lazy"
97
+
></iframe>
98
+
</BlockLayout>
102
99
{/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
103
100
<a
104
101
href={url?.data.value}
+43
-42
components/Blocks/ExternalLinkBlock.tsx
+43
-42
components/Blocks/ExternalLinkBlock.tsx
···
4
4
import { useEntity, useReplicache } from "src/replicache";
5
5
import { useUIState } from "src/useUIState";
6
6
import { addLinkBlock } from "src/utils/addLinkBlock";
7
-
import { BlockProps } from "./Block";
7
+
import { BlockProps, BlockLayout } from "./Block";
8
8
import { v7 } from "uuid";
9
9
import { useSmoker } from "components/Toast";
10
10
import { Separator } from "components/Layout";
···
64
64
}
65
65
66
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
-
`}
67
+
<BlockLayout
68
+
isSelected={!!isSelected}
69
+
hasBackground="page"
70
+
borderOnHover
71
+
className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!"
77
72
>
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>
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
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}
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>
101
102
</div>
102
103
</div>
103
-
</div>
104
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>
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>
113
114
);
114
115
};
115
116
+68
-24
components/Blocks/ImageBlock.tsx
+68
-24
components/Blocks/ImageBlock.tsx
···
1
1
"use client";
2
2
3
3
import { useEntity, useReplicache } from "src/replicache";
4
-
import { BlockProps } from "./Block";
4
+
import { BlockProps, BlockLayout } from "./Block";
5
5
import { useUIState } from "src/useUIState";
6
6
import Image from "next/image";
7
7
import { v7 } from "uuid";
···
17
17
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
18
18
import { set } from "colorjs.io/fn";
19
19
import { ImageAltSmall } from "components/Icons/ImageAlt";
20
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
21
+
import { useSubscribe } from "src/replicache/useSubscribe";
22
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
20
23
21
24
export function ImageBlock(props: BlockProps & { preview?: boolean }) {
22
25
let { rep } = useReplicache();
···
61
64
factID: v7(),
62
65
permission_set: entity_set.set,
63
66
type: "text",
64
-
position: generateKeyBetween(
65
-
props.position,
66
-
props.nextPosition,
67
-
),
67
+
position: generateKeyBetween(props.position, props.nextPosition),
68
68
newEntityID: entity,
69
69
});
70
70
}
···
82
82
if (!image) {
83
83
if (!entity_set.permissions.write) return null;
84
84
return (
85
-
<div className="grow w-full">
85
+
<BlockLayout
86
+
hasBackground="accent"
87
+
isSelected={!!isSelected && !isLocked}
88
+
borderOnHover
89
+
className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg"
90
+
>
86
91
<label
87
92
className={`
88
-
group/image-block
89
-
w-full h-[104px] hover:cursor-pointer p-2
90
-
text-tertiary hover:text-accent-contrast hover:font-bold
93
+
94
+
w-full h-full hover:cursor-pointer
91
95
flex flex-col items-center justify-center
92
-
hover:border-2 border-dashed hover:border-accent-contrast rounded-lg
93
-
${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"}
94
96
${props.pageType === "canvas" && "bg-bg-page"}`}
95
97
onMouseDown={(e) => e.preventDefault()}
96
98
onDragOver={(e) => {
···
104
106
const files = e.dataTransfer.files;
105
107
if (files && files.length > 0) {
106
108
const file = files[0];
107
-
if (file.type.startsWith('image/')) {
109
+
if (file.type.startsWith("image/")) {
108
110
await handleImageUpload(file);
109
111
}
110
112
}
···
128
130
}}
129
131
/>
130
132
</label>
131
-
</div>
133
+
</BlockLayout>
132
134
);
133
135
}
134
136
135
-
let className = isFullBleed
137
+
let imageClassName = isFullBleed
136
138
? ""
137
139
: isSelected
138
140
? "block-border-selected border-transparent! "
···
140
142
141
143
let isLocalUpload = localImages.get(image.data.src);
142
144
145
+
let blockClassName = `
146
+
relative group/image border-transparent! p-0! w-fit!
147
+
${isFullBleed && "-mx-3 sm:-mx-4"}
148
+
${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""}
149
+
${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""}
150
+
`;
151
+
143
152
return (
144
-
<div
145
-
className={`relative group/image
146
-
${className}
147
-
${isFullBleed && "-mx-3 sm:-mx-4"}
148
-
${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""}
149
-
${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `}
150
-
>
151
-
{isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null}
153
+
<BlockLayout isSelected={!!isSelected} className={blockClassName}>
152
154
{isLocalUpload || image.data.local ? (
153
155
<img
154
156
loading="lazy"
···
166
168
}
167
169
height={image?.data.height}
168
170
width={image?.data.width}
169
-
className={className}
171
+
className={imageClassName}
170
172
/>
171
173
)}
172
174
{altText !== undefined && !props.preview ? (
173
175
<ImageAlt entityID={props.value} />
174
176
) : null}
175
-
</div>
177
+
{!props.preview ? <CoverImageButton entityID={props.value} /> : null}
178
+
</BlockLayout>
176
179
);
177
180
}
178
181
···
188
191
altEditorOpen: false,
189
192
setAltEditorOpen: (s: boolean) => {},
190
193
});
194
+
195
+
const CoverImageButton = (props: { entityID: string }) => {
196
+
let { rep } = useReplicache();
197
+
let entity_set = useEntitySetContext();
198
+
let { data: pubData } = useLeafletPublicationData();
199
+
let coverImage = useSubscribe(rep, (tx) =>
200
+
tx.get<string | null>("publication_cover_image"),
201
+
);
202
+
let isFocused = useUIState(
203
+
(s) => s.focusedEntity?.entityID === props.entityID,
204
+
);
205
+
206
+
// Only show if focused, in a publication, has write permissions, and no cover image is set
207
+
if (
208
+
!isFocused ||
209
+
!pubData?.publications ||
210
+
!entity_set.permissions.write ||
211
+
coverImage
212
+
)
213
+
return null;
214
+
215
+
return (
216
+
<div className="absolute top-2 left-2">
217
+
<button
218
+
className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors"
219
+
onClick={async (e) => {
220
+
e.preventDefault();
221
+
e.stopPropagation();
222
+
await rep?.mutate.updatePublicationDraft({
223
+
cover_image: props.entityID,
224
+
});
225
+
}}
226
+
>
227
+
<span className="w-4 h-4 flex items-center justify-center">
228
+
<ImageCoverImage />
229
+
</span>
230
+
Set as Cover
231
+
</button>
232
+
</div>
233
+
);
234
+
};
191
235
192
236
const ImageAlt = (props: { entityID: string }) => {
193
237
let { rep } = useReplicache();
+80
-94
components/Blocks/MailboxBlock.tsx
+80
-94
components/Blocks/MailboxBlock.tsx
···
1
1
import { ButtonPrimary } from "components/Buttons";
2
2
import { Popover } from "components/Popover";
3
-
import { Menu, MenuItem, Separator } from "components/Layout";
3
+
import { MenuItem } from "components/Menu";
4
+
import { Separator } from "components/Layout";
4
5
import { useUIState } from "src/useUIState";
5
6
import { useState } from "react";
6
7
import { useSmoker, useToaster } from "components/Toast";
7
-
import { BlockProps } from "./Block";
8
+
import { BlockProps, BlockLayout } from "./Block";
8
9
import { useEntity, useReplicache } from "src/replicache";
9
10
import { useEntitySetContext } from "components/EntitySetProvider";
10
11
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
···
45
46
46
47
return (
47
48
<div className={`mailboxContent relative w-full flex flex-col gap-1`}>
48
-
<div
49
-
className={`flex flex-col gap-2 items-center justify-center w-full
50
-
${isSelected ? "block-border-selected " : "block-border"} `}
51
-
style={{
52
-
backgroundColor:
53
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
54
-
}}
49
+
<BlockLayout
50
+
isSelected={!!isSelected}
51
+
hasBackground={"accent"}
52
+
className="flex gap-2 items-center justify-center"
55
53
>
56
-
<div className="flex gap-2 p-4">
57
-
<ButtonPrimary
58
-
onClick={async () => {
59
-
let entity;
60
-
if (draft) {
61
-
entity = draft.data.value;
62
-
} else {
63
-
entity = v7();
64
-
await rep?.mutate.createDraft({
65
-
mailboxEntity: props.entityID,
66
-
permission_set: entity_set.set,
67
-
newEntity: entity,
68
-
firstBlockEntity: v7(),
69
-
firstBlockFactID: v7(),
70
-
});
71
-
}
72
-
useUIState.getState().openPage(props.parent, entity);
73
-
if (rep) focusPage(entity, rep, "focusFirstBlock");
74
-
return;
75
-
}}
76
-
>
77
-
{draft ? "Edit Draft" : "Write a Post"}
78
-
</ButtonPrimary>
79
-
<MailboxInfo />
80
-
</div>
81
-
</div>
54
+
<ButtonPrimary
55
+
onClick={async () => {
56
+
let entity;
57
+
if (draft) {
58
+
entity = draft.data.value;
59
+
} else {
60
+
entity = v7();
61
+
await rep?.mutate.createDraft({
62
+
mailboxEntity: props.entityID,
63
+
permission_set: entity_set.set,
64
+
newEntity: entity,
65
+
firstBlockEntity: v7(),
66
+
firstBlockFactID: v7(),
67
+
});
68
+
}
69
+
useUIState.getState().openPage(props.parent, entity);
70
+
if (rep) focusPage(entity, rep, "focusFirstBlock");
71
+
return;
72
+
}}
73
+
>
74
+
{draft ? "Edit Draft" : "Write a Post"}
75
+
</ButtonPrimary>
76
+
<MailboxInfo />
77
+
</BlockLayout>
82
78
<div className="flex gap-3 items-center justify-between">
83
79
{
84
80
<>
···
134
130
let { rep } = useReplicache();
135
131
return (
136
132
<div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}>
137
-
<div
138
-
className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${
139
-
isSelected
140
-
? "border-border outline-border"
141
-
: "border-border-light outline-transparent"
142
-
}`}
143
-
style={{
144
-
backgroundColor:
145
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
146
-
}}
133
+
<BlockLayout
134
+
isSelected={!!isSelected}
135
+
hasBackground={"accent"}
136
+
className="`h-full flex flex-col gap-2 items-center justify-center"
147
137
>
148
-
<div className="flex flex-col w-full gap-2 p-4">
149
-
{!isSubscribed?.confirmed ? (
150
-
<>
151
-
<SubscribeForm
152
-
entityID={props.entityID}
153
-
role={"reader"}
154
-
parent={props.parent}
155
-
/>
156
-
</>
157
-
) : (
158
-
<div className="flex flex-col gap-2 items-center place-self-center">
159
-
<div className=" font-bold text-secondary ">
160
-
You're Subscribed!
161
-
</div>
162
-
<div className="flex flex-col gap-1 items-center place-self-center">
163
-
{archive ? (
164
-
<ButtonPrimary
165
-
onMouseDown={(e) => {
166
-
e.preventDefault();
167
-
if (rep) {
168
-
useUIState
169
-
.getState()
170
-
.openPage(props.parent, archive.data.value);
171
-
focusPage(archive.data.value, rep);
172
-
}
173
-
}}
174
-
>
175
-
See All Posts
176
-
</ButtonPrimary>
177
-
) : (
178
-
<div className="text-tertiary">
179
-
Nothing has been posted yet
180
-
</div>
181
-
)}
182
-
<button
183
-
className="text-accent-contrast hover:underline text-sm"
184
-
onClick={(e) => {
185
-
let rect = e.currentTarget.getBoundingClientRect();
186
-
unsubscribe(isSubscribed);
187
-
smoke({
188
-
text: "unsubscribed!",
189
-
position: { x: rect.left, y: rect.top - 8 },
190
-
});
138
+
{!isSubscribed?.confirmed ? (
139
+
<>
140
+
<SubscribeForm
141
+
entityID={props.entityID}
142
+
role={"reader"}
143
+
parent={props.parent}
144
+
/>
145
+
</>
146
+
) : (
147
+
<div className="flex flex-col gap-2 items-center place-self-center">
148
+
<div className=" font-bold text-secondary ">
149
+
You're Subscribed!
150
+
</div>
151
+
<div className="flex flex-col gap-1 items-center place-self-center">
152
+
{archive ? (
153
+
<ButtonPrimary
154
+
onMouseDown={(e) => {
155
+
e.preventDefault();
156
+
if (rep) {
157
+
useUIState
158
+
.getState()
159
+
.openPage(props.parent, archive.data.value);
160
+
focusPage(archive.data.value, rep);
161
+
}
191
162
}}
192
163
>
193
-
unsubscribe
194
-
</button>
195
-
</div>
164
+
See All Posts
165
+
</ButtonPrimary>
166
+
) : (
167
+
<div className="text-tertiary">Nothing has been posted yet</div>
168
+
)}
169
+
<button
170
+
className="text-accent-contrast hover:underline text-sm"
171
+
onClick={(e) => {
172
+
let rect = e.currentTarget.getBoundingClientRect();
173
+
unsubscribe(isSubscribed);
174
+
smoke({
175
+
text: "unsubscribed!",
176
+
position: { x: rect.left, y: rect.top - 8 },
177
+
});
178
+
}}
179
+
>
180
+
unsubscribe
181
+
</button>
196
182
</div>
197
-
)}
198
-
</div>
199
-
</div>
183
+
</div>
184
+
)}
185
+
</BlockLayout>
200
186
</div>
201
187
);
202
188
};
+33
-23
components/Blocks/MathBlock.tsx
+33
-23
components/Blocks/MathBlock.tsx
···
1
1
import { useEntity, useReplicache } from "src/replicache";
2
2
import "katex/dist/katex.min.css";
3
-
import { BlockProps } from "./Block";
3
+
import { BlockLayout, BlockProps } from "./Block";
4
4
import Katex from "katex";
5
5
import { useMemo } from "react";
6
6
import { useUIState } from "src/useUIState";
···
32
32
}
33
33
}, [content?.data.value]);
34
34
return focusedBlock ? (
35
-
<BaseTextareaBlock
36
-
id={elementId.block(props.entityID).input}
37
-
block={props}
38
-
spellCheck={false}
39
-
autoCapitalize="none"
40
-
autoCorrect="off"
41
-
className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline"
42
-
placeholder="write some Tex here..."
43
-
value={content?.data.value}
44
-
onChange={async (e) => {
45
-
// Update the entity with the new value
46
-
await rep?.mutate.assertFact({
47
-
attribute: "block/math",
48
-
entity: props.entityID,
49
-
data: { type: "string", value: e.target.value },
50
-
});
51
-
}}
52
-
/>
35
+
<BlockLayout
36
+
isSelected={focusedBlock}
37
+
hasBackground="accent"
38
+
className="min-h-[48px]"
39
+
>
40
+
<BaseTextareaBlock
41
+
id={elementId.block(props.entityID).input}
42
+
block={props}
43
+
spellCheck={false}
44
+
autoCapitalize="none"
45
+
autoCorrect="off"
46
+
className="h-full w-full whitespace-nowrap overflow-auto!"
47
+
placeholder="write some Tex here..."
48
+
value={content?.data.value}
49
+
onChange={async (e) => {
50
+
// Update the entity with the new value
51
+
await rep?.mutate.assertFact({
52
+
attribute: "block/math",
53
+
entity: props.entityID,
54
+
data: { type: "string", value: e.target.value },
55
+
});
56
+
}}
57
+
/>
58
+
</BlockLayout>
53
59
) : html && content?.data.value ? (
54
60
<div
55
-
className="text-lg min-h-[66px] w-full border border-transparent"
61
+
className="text-lg min-h-[48px] w-full border border-transparent"
56
62
dangerouslySetInnerHTML={{ __html: html }}
57
63
/>
58
64
) : (
59
-
<div className="text-tertiary italic rounded-md p-2 w-full min-h-16">
60
-
write some Tex here...
61
-
</div>
65
+
<BlockLayout
66
+
isSelected={focusedBlock}
67
+
hasBackground="accent"
68
+
className="min-h-[48px]"
69
+
>
70
+
<div className="text-tertiary italic w-full ">write some Tex here...</div>
71
+
</BlockLayout>
62
72
);
63
73
}
+26
-22
components/Blocks/PageLinkBlock.tsx
+26
-22
components/Blocks/PageLinkBlock.tsx
···
1
1
"use client";
2
-
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
2
+
import { BlockProps, ListMarker, Block, BlockLayout } from "./Block";
3
3
import { focusBlock } from "src/utils/focusBlock";
4
4
5
5
import { focusPage } from "src/utils/focusPage";
···
29
29
30
30
return (
31
31
<CardThemeProvider entityID={page?.data.value}>
32
-
<div
33
-
className={`w-full cursor-pointer
32
+
<BlockLayout
33
+
hasBackground="page"
34
+
isSelected={!!isSelected}
35
+
className={`cursor-pointer
34
36
pageLinkBlockWrapper relative group/pageLinkBlock
35
-
bg-bg-page shadow-sm
36
-
flex overflow-clip
37
-
${isSelected ? "block-border-selected " : "block-border"}
38
-
${isOpen && "border-tertiary!"}
37
+
flex overflow-clip p-0!
38
+
${isOpen && "border-accent-contrast! outline-accent-contrast!"}
39
39
`}
40
-
onClick={(e) => {
41
-
if (!page) return;
42
-
if (e.isDefaultPrevented()) return;
43
-
if (e.shiftKey) return;
44
-
e.preventDefault();
45
-
e.stopPropagation();
46
-
useUIState.getState().openPage(props.parent, page.data.value);
47
-
if (rep) focusPage(page.data.value, rep);
48
-
}}
49
40
>
50
-
{type === "canvas" && page ? (
51
-
<CanvasLinkBlock entityID={page?.data.value} />
52
-
) : (
53
-
<DocLinkBlock {...props} />
54
-
)}
55
-
</div>
41
+
<div
42
+
className="w-full h-full"
43
+
onClick={(e) => {
44
+
if (!page) return;
45
+
if (e.isDefaultPrevented()) return;
46
+
if (e.shiftKey) return;
47
+
e.preventDefault();
48
+
e.stopPropagation();
49
+
useUIState.getState().openPage(props.parent, page.data.value);
50
+
if (rep) focusPage(page.data.value, rep);
51
+
}}
52
+
>
53
+
{type === "canvas" && page ? (
54
+
<CanvasLinkBlock entityID={page?.data.value} />
55
+
) : (
56
+
<DocLinkBlock {...props} />
57
+
)}
58
+
</div>
59
+
</BlockLayout>
56
60
</CardThemeProvider>
57
61
);
58
62
}
+7
-10
components/Blocks/PollBlock/index.tsx
+7
-10
components/Blocks/PollBlock/index.tsx
···
1
1
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "../Block";
2
+
import { BlockProps, BlockLayout } from "../Block";
3
3
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
4
import { useCallback, useEffect, useState } from "react";
5
5
import { Input } from "components/Input";
···
61
61
let totalVotes = votes.length;
62
62
63
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
-
}}
64
+
<BlockLayout
65
+
isSelected={!!isSelected}
66
+
hasBackground={"accent"}
67
+
className="poll flex flex-col gap-2 w-full"
71
68
>
72
69
{pollState === "editing" ? (
73
70
<EditPoll
···
95
92
hasVoted={!!hasVoted}
96
93
/>
97
94
)}
98
-
</div>
95
+
</BlockLayout>
99
96
);
100
97
};
101
98
···
486
483
}) => {
487
484
return (
488
485
<button
489
-
className="text-sm text-accent-contrast sm:hover:underline"
486
+
className="text-sm text-accent-contrast "
490
487
onClick={() => {
491
488
props.setPollState(props.pollState === "voting" ? "results" : "voting");
492
489
}}
+6
-9
components/Blocks/PublicationPollBlock.tsx
+6
-9
components/Blocks/PublicationPollBlock.tsx
···
1
1
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "./Block";
2
+
import { BlockLayout, BlockProps } from "./Block";
3
3
import { useMemo } from "react";
4
4
import { AsyncValueInput } from "components/Input";
5
5
import { focusElement } from "src/utils/focusElement";
···
53
53
}, [publicationData, props.entityID]);
54
54
55
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
-
}}
56
+
<BlockLayout
57
+
className="poll flex flex-col gap-2"
58
+
hasBackground={"accent"}
59
+
isSelected={!!isSelected}
63
60
>
64
61
<EditPollForPublication
65
62
entityID={props.entityID}
66
63
isPublished={isPublished}
67
64
/>
68
-
</div>
65
+
</BlockLayout>
69
66
);
70
67
};
71
68
+6
-8
components/Blocks/RSVPBlock/index.tsx
+6
-8
components/Blocks/RSVPBlock/index.tsx
···
1
1
"use client";
2
2
import { Database } from "supabase/database.types";
3
-
import { BlockProps } from "components/Blocks/Block";
3
+
import { BlockProps, BlockLayout } from "components/Blocks/Block";
4
4
import { useState } from "react";
5
5
import { submitRSVP } from "actions/phone_rsvp_to_event";
6
6
import { useRSVPData } from "components/PageSWRDataProvider";
···
29
29
s.selectedBlocks.find((b) => b.value === props.entityID),
30
30
);
31
31
return (
32
-
<div
33
-
className={`rsvp relative flex flex-col gap-1 border p-3 w-full rounded-lg place-items-center justify-center ${isSelected ? "block-border-selected " : "block-border"}`}
34
-
style={{
35
-
backgroundColor:
36
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
37
-
}}
32
+
<BlockLayout
33
+
isSelected={!!isSelected}
34
+
hasBackground={"accent"}
35
+
className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center"
38
36
>
39
37
<RSVPForm entityID={props.entityID} />
40
-
</div>
38
+
</BlockLayout>
41
39
);
42
40
}
43
41
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
6
6
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
7
import { AtMentionLink } from "components/AtMentionLink";
8
8
import { Delta } from "src/utils/yjsFragmentToString";
9
+
import { ProfilePopover } from "components/ProfilePopover";
9
10
10
11
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
11
12
export function RenderYJSFragment({
···
63
64
);
64
65
}
65
66
66
-
if (node.constructor === XmlElement && node.nodeName === "hard_break") {
67
+
if (
68
+
node.constructor === XmlElement &&
69
+
node.nodeName === "hard_break"
70
+
) {
67
71
return <br key={index} />;
68
72
}
69
73
70
74
// Handle didMention inline nodes
71
-
if (node.constructor === XmlElement && node.nodeName === "didMention") {
75
+
if (
76
+
node.constructor === XmlElement &&
77
+
node.nodeName === "didMention"
78
+
) {
72
79
const did = node.getAttribute("did") || "";
73
80
const text = node.getAttribute("text") || "";
74
81
return (
···
77
84
target="_blank"
78
85
rel="noopener noreferrer"
79
86
key={index}
80
-
className="text-accent-contrast hover:underline cursor-pointer"
87
+
className="mention"
81
88
>
82
89
{text}
83
90
</a>
···
85
92
}
86
93
87
94
// Handle atMention inline nodes
88
-
if (node.constructor === XmlElement && node.nodeName === "atMention") {
95
+
if (
96
+
node.constructor === XmlElement &&
97
+
node.nodeName === "atMention"
98
+
) {
89
99
const atURI = node.getAttribute("atURI") || "";
90
100
const text = node.getAttribute("text") || "";
91
101
return (
···
161
171
162
172
return props;
163
173
}
164
-
+4
-3
components/Blocks/TextBlock/schema.ts
+4
-3
components/Blocks/TextBlock/schema.ts
···
147
147
toDOM(node) {
148
148
// NOTE: This rendering should match the AtMentionLink component in
149
149
// components/AtMentionLink.tsx. If you update one, update the other.
150
-
let className = "atMention text-accent-contrast";
150
+
let className = "atMention mention";
151
151
let aturi = new AtUri(node.attrs.atURI);
152
152
if (aturi.collection === "pub.leaflet.publication")
153
153
className += " font-bold";
···
168
168
"img",
169
169
{
170
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",
171
+
class:
172
+
"inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top",
172
173
alt: "",
173
174
width: "16",
174
175
height: "16",
···
214
215
return [
215
216
"span",
216
217
{
217
-
class: "didMention text-accent-contrast",
218
+
class: "didMention mention",
218
219
"data-did": node.attrs.did,
219
220
},
220
221
node.attrs.text,
+11
-5
components/Buttons.tsx
+11
-5
components/Buttons.tsx
···
38
38
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
39
39
bg-accent-1 disabled:bg-border-light
40
40
border border-accent-1 rounded-md disabled:border-border-light
41
-
outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
41
+
outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1
42
42
text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border
43
43
flex gap-2 items-center justify-center shrink-0
44
44
${className}
···
77
77
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
78
78
bg-bg-page disabled:bg-border-light
79
79
border border-accent-contrast rounded-md
80
-
outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
80
+
outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1
81
81
text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border
82
82
flex gap-2 items-center justify-center shrink-0
83
83
${props.className}
···
116
116
${compact ? "py-0 px-1" : "px-2 py-0.5 "}
117
117
bg-transparent hover:bg-[var(--accent-light)]
118
118
border border-transparent rounded-md hover:border-[var(--accent-light)]
119
-
outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
119
+
outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1
120
120
text-base font-bold text-accent-contrast disabled:text-border
121
121
flex gap-2 items-center justify-center shrink-0
122
122
${props.className}
···
165
165
side={props.side ? props.side : undefined}
166
166
sideOffset={6}
167
167
alignOffset={12}
168
-
className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm"
168
+
className="z-10 rounded-md py-1 px-[6px] font-bold text-secondary text-sm"
169
+
style={{
170
+
backgroundColor:
171
+
"color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)",
172
+
}}
169
173
>
170
174
{props.tooltipContent}
171
175
<RadixTooltip.Arrow
···
175
179
viewBox="0 0 16 8"
176
180
>
177
181
<PopoverArrow
178
-
arrowFill={theme.colors["border"]}
182
+
arrowFill={
183
+
"color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)"
184
+
}
179
185
arrowStroke="transparent"
180
186
/>
181
187
</RadixTooltip.Arrow>
+6
-3
components/Canvas.tsx
+6
-3
components/Canvas.tsx
···
170
170
171
171
let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172
172
let showComments = pubRecord.preferences?.showComments;
173
+
let showMentions = pubRecord.preferences?.showMentions;
173
174
174
175
return (
175
176
<div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
···
178
179
<CommentTiny className="text-border" /> โ
179
180
</div>
180
181
)}
181
-
<div className="flex gap-1 text-tertiary items-center">
182
-
<QuoteTiny className="text-border" /> โ
183
-
</div>
182
+
{showComments && (
183
+
<div className="flex gap-1 text-tertiary items-center">
184
+
<QuoteTiny className="text-border" /> โ
185
+
</div>
186
+
)}
184
187
185
188
{!props.isSubpage && (
186
189
<>
+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
14
tags?: string[];
15
15
postUrl: string;
16
16
showComments: boolean | undefined;
17
+
showMentions: boolean | undefined;
18
+
17
19
share?: boolean;
18
20
}) => {
19
21
let smoker = useSmoker();
20
22
let interactionsAvailable =
21
-
props.quotesCount > 0 ||
23
+
(props.quotesCount > 0 && props.showMentions !== false) ||
22
24
(props.showComments !== false && props.commentsCount > 0);
23
25
24
26
const tagsCount = props.tags?.length || 0;
···
36
38
</>
37
39
)}
38
40
39
-
{props.quotesCount === 0 ? null : (
41
+
{props.showMentions === false || props.quotesCount === 0 ? null : (
40
42
<SpeedyLink
41
43
aria-label="Post quotes"
42
44
href={`${props.postUrl}?interactionDrawer=quotes`}
43
-
className="flex flex-row gap-1 text-sm items-center text-accent-contrast!"
45
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
44
46
>
45
47
<QuoteTiny /> {props.quotesCount}
46
48
</SpeedyLink>
···
49
51
<SpeedyLink
50
52
aria-label="Post comments"
51
53
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"
54
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
53
55
>
54
56
<CommentTiny /> {props.commentsCount}
55
57
</SpeedyLink>
···
93
95
<Popover
94
96
className="p-2! max-w-xs"
95
97
trigger={
96
-
<div className="relative flex gap-1 items-center hover:text-accent-contrast ">
98
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast">
97
99
<TagTiny /> {props.tags.length}
98
100
</div>
99
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
1
export const Separator = (props: { classname?: string }) => {
10
2
return <div className={`h-full border-r border-border ${props.classname}`} />;
11
-
};
12
-
13
-
export const Menu = (props: {
14
-
open?: boolean;
15
-
trigger: React.ReactNode;
16
-
children: React.ReactNode;
17
-
align?: "start" | "end" | "center" | undefined;
18
-
alignOffset?: number;
19
-
side?: "top" | "bottom" | "right" | "left" | undefined;
20
-
background?: string;
21
-
border?: string;
22
-
className?: string;
23
-
onOpenChange?: (o: boolean) => void;
24
-
asChild?: boolean;
25
-
}) => {
26
-
let [open, setOpen] = useState(props.open || false);
27
-
return (
28
-
<DropdownMenu.Root
29
-
onOpenChange={(o) => {
30
-
setOpen(o);
31
-
props.onOpenChange?.(o);
32
-
}}
33
-
open={props.open}
34
-
>
35
-
<PopoverOpenContext value={open}>
36
-
<DropdownMenu.Trigger asChild={props.asChild}>
37
-
{props.trigger}
38
-
</DropdownMenu.Trigger>
39
-
<DropdownMenu.Portal>
40
-
<NestedCardThemeProvider>
41
-
<DropdownMenu.Content
42
-
side={props.side ? props.side : "bottom"}
43
-
align={props.align ? props.align : "center"}
44
-
alignOffset={props.alignOffset ? props.alignOffset : undefined}
45
-
sideOffset={4}
46
-
collisionPadding={16}
47
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`}
48
-
>
49
-
{props.children}
50
-
<DropdownMenu.Arrow
51
-
asChild
52
-
width={16}
53
-
height={8}
54
-
viewBox="0 0 16 8"
55
-
>
56
-
<PopoverArrow
57
-
arrowFill={
58
-
props.background
59
-
? props.background
60
-
: theme.colors["bg-page"]
61
-
}
62
-
arrowStroke={
63
-
props.border ? props.border : theme.colors["border"]
64
-
}
65
-
/>
66
-
</DropdownMenu.Arrow>
67
-
</DropdownMenu.Content>
68
-
</NestedCardThemeProvider>
69
-
</DropdownMenu.Portal>
70
-
</PopoverOpenContext>
71
-
</DropdownMenu.Root>
72
-
);
73
-
};
74
-
75
-
export const MenuItem = (props: {
76
-
children?: React.ReactNode;
77
-
className?: string;
78
-
onSelect: (e: Event) => void;
79
-
id?: string;
80
-
}) => {
81
-
return (
82
-
<DropdownMenu.Item
83
-
id={props.id}
84
-
onSelect={(event) => {
85
-
props.onSelect(event);
86
-
}}
87
-
className={`
88
-
menuItem
89
-
z-10 py-1! px-2!
90
-
flex gap-2
91
-
${props.className}
92
-
`}
93
-
>
94
-
{props.children}
95
-
</DropdownMenu.Item>
96
-
);
97
3
};
98
4
99
5
export const ShortcutKey = (props: { children: React.ReactNode }) => {
+97
components/Menu.tsx
+97
components/Menu.tsx
···
1
+
"use client";
2
+
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
+
import { theme } from "tailwind.config";
4
+
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
+
import { PopoverArrow } from "./Icons/PopoverArrow";
6
+
import { PopoverOpenContext } from "./Popover/PopoverContext";
7
+
import { useState } from "react";
8
+
9
+
export const Menu = (props: {
10
+
open?: boolean;
11
+
trigger: React.ReactNode;
12
+
children: React.ReactNode;
13
+
align?: "start" | "end" | "center" | undefined;
14
+
alignOffset?: number;
15
+
side?: "top" | "bottom" | "right" | "left" | undefined;
16
+
background?: string;
17
+
border?: string;
18
+
className?: string;
19
+
onOpenChange?: (o: boolean) => void;
20
+
asChild?: boolean;
21
+
}) => {
22
+
let [open, setOpen] = useState(props.open || false);
23
+
24
+
return (
25
+
<DropdownMenu.Root
26
+
onOpenChange={(o) => {
27
+
setOpen(o);
28
+
props.onOpenChange?.(o);
29
+
}}
30
+
open={props.open}
31
+
>
32
+
<PopoverOpenContext value={open}>
33
+
<DropdownMenu.Trigger asChild={props.asChild}>
34
+
{props.trigger}
35
+
</DropdownMenu.Trigger>
36
+
<DropdownMenu.Portal>
37
+
<NestedCardThemeProvider>
38
+
<DropdownMenu.Content
39
+
side={props.side ? props.side : "bottom"}
40
+
align={props.align ? props.align : "center"}
41
+
alignOffset={props.alignOffset ? props.alignOffset : undefined}
42
+
sideOffset={4}
43
+
collisionPadding={16}
44
+
className={`
45
+
dropdownMenu z-20 p-1
46
+
flex flex-col gap-0.5
47
+
bg-bg-page
48
+
border border-border rounded-md shadow-md
49
+
${props.className}`}
50
+
>
51
+
{props.children}
52
+
<DropdownMenu.Arrow
53
+
asChild
54
+
width={16}
55
+
height={8}
56
+
viewBox="0 0 16 8"
57
+
>
58
+
<PopoverArrow
59
+
arrowFill={
60
+
props.background
61
+
? props.background
62
+
: theme.colors["bg-page"]
63
+
}
64
+
arrowStroke={
65
+
props.border ? props.border : theme.colors["border"]
66
+
}
67
+
/>
68
+
</DropdownMenu.Arrow>
69
+
</DropdownMenu.Content>
70
+
</NestedCardThemeProvider>
71
+
</DropdownMenu.Portal>
72
+
</PopoverOpenContext>
73
+
</DropdownMenu.Root>
74
+
);
75
+
};
76
+
77
+
export const MenuItem = (props: {
78
+
children?: React.ReactNode;
79
+
className?: string;
80
+
onSelect: (e: Event) => void;
81
+
id?: string;
82
+
}) => {
83
+
return (
84
+
<DropdownMenu.Item
85
+
id={props.id}
86
+
onSelect={(event) => {
87
+
props.onSelect(event);
88
+
}}
89
+
className={`
90
+
menuItem
91
+
${props.className}
92
+
`}
93
+
>
94
+
{props.children}
95
+
</DropdownMenu.Item>
96
+
);
97
+
};
+33
components/OAuthError.tsx
+33
components/OAuthError.tsx
···
1
+
"use client";
2
+
3
+
import { OAuthSessionError } from "src/atproto-oauth";
4
+
5
+
export function OAuthErrorMessage({
6
+
error,
7
+
className,
8
+
}: {
9
+
error: OAuthSessionError;
10
+
className?: string;
11
+
}) {
12
+
const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(window.location.href)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`;
13
+
14
+
return (
15
+
<div className={className}>
16
+
<span>Your session has expired or is invalid. </span>
17
+
<a href={signInUrl} className="underline font-bold whitespace-nowrap">
18
+
Sign in again
19
+
</a>
20
+
</div>
21
+
);
22
+
}
23
+
24
+
export function isOAuthSessionError(
25
+
error: unknown,
26
+
): error is OAuthSessionError {
27
+
return (
28
+
typeof error === "object" &&
29
+
error !== null &&
30
+
"type" in error &&
31
+
(error as OAuthSessionError).type === "oauth_session_expired"
32
+
);
33
+
}
+7
-8
components/PageHeader.tsx
+7
-8
components/PageHeader.tsx
···
1
1
"use client";
2
2
import { useState, useEffect } from "react";
3
+
import { useCardBorderHidden } from "./Pages/useCardBorderHidden";
3
4
4
-
export const Header = (props: {
5
-
children: React.ReactNode;
6
-
cardBorderHidden: boolean;
7
-
}) => {
5
+
export const Header = (props: { children: React.ReactNode }) => {
6
+
let cardBorderHidden = useCardBorderHidden();
8
7
let [scrollPos, setScrollPos] = useState(0);
9
8
10
9
useEffect(() => {
···
22
21
}
23
22
}, []);
24
23
25
-
let headerBGColor = props.cardBorderHidden
24
+
let headerBGColor = !cardBorderHidden
26
25
? "var(--bg-leaflet)"
27
26
: "var(--bg-page)";
28
27
···
54
53
style={
55
54
scrollPos < 20
56
55
? {
57
-
backgroundColor: props.cardBorderHidden
56
+
backgroundColor: !cardBorderHidden
58
57
? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`
59
58
: `rgba(${headerBGColor}, ${scrollPos / 20})`,
60
-
paddingLeft: props.cardBorderHidden
59
+
paddingLeft: !cardBorderHidden
61
60
? "4px"
62
61
: `calc(${scrollPos / 20}*4px)`,
63
-
paddingRight: props.cardBorderHidden
62
+
paddingRight: !cardBorderHidden
64
63
? "8px"
65
64
: `calc(${scrollPos / 20}*8px)`,
66
65
}
+4
-24
components/PageLayouts/DashboardLayout.tsx
+4
-24
components/PageLayouts/DashboardLayout.tsx
···
25
25
import Link from "next/link";
26
26
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27
27
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
+
import { Tab } from "components/Tab";
28
29
29
30
export type DashboardState = {
30
31
display?: "grid" | "list";
···
133
134
},
134
135
>(props: {
135
136
id: string;
136
-
cardBorderHidden: boolean;
137
137
tabs: T;
138
138
defaultTab: keyof T;
139
139
currentPage: navPages;
···
180
180
</div>
181
181
</MediaContents>
182
182
<div
183
-
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `}
183
+
className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `}
184
184
ref={ref}
185
185
id="home-content"
186
186
>
187
187
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
188
188
<>
189
-
<Header cardBorderHidden={props.cardBorderHidden}>
189
+
<Header>
190
190
{headerState === "default" ? (
191
191
<>
192
192
{Object.keys(props.tabs).length > 1 && (
···
355
355
);
356
356
};
357
357
358
-
function Tab(props: {
359
-
name: string;
360
-
selected: boolean;
361
-
onSelect: () => void;
362
-
href?: string;
363
-
}) {
364
-
return (
365
-
<div
366
-
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
367
-
onClick={() => props.onSelect()}
368
-
>
369
-
{props.name}
370
-
{props.href && <ExternalLinkTiny />}
371
-
</div>
372
-
);
373
-
}
374
-
375
-
const FilterOptions = (props: {
376
-
hasPubs: boolean;
377
-
hasArchived: boolean;
378
-
}) => {
358
+
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
379
359
let { filter } = useDashboardState();
380
360
let setState = useSetDashboardState();
381
361
let filterCount = Object.values(filter).filter(Boolean).length;
+3
-5
components/Pages/Page.tsx
+3
-5
components/Pages/Page.tsx
···
34
34
return focusedPageID === props.entityID;
35
35
});
36
36
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
38
37
39
38
let drawerOpen = useDrawerOpen(props.entityID);
40
39
return (
···
49
48
}}
50
49
id={elementId.page(props.entityID).container}
51
50
drawerOpen={!!drawerOpen}
52
-
cardBorderHidden={!!cardBorderHidden}
53
51
isFocused={isFocused}
54
52
fullPageScroll={props.fullPageScroll}
55
53
pageType={pageType}
···
77
75
id: string;
78
76
children: React.ReactNode;
79
77
pageOptions?: React.ReactNode;
80
-
cardBorderHidden: boolean;
81
78
fullPageScroll: boolean;
82
79
isFocused?: boolean;
83
80
onClickAction?: (e: React.MouseEvent) => void;
84
81
pageType: "canvas" | "doc";
85
82
drawerOpen: boolean | undefined;
86
83
}) => {
84
+
const cardBorderHidden = useCardBorderHidden();
87
85
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
88
86
return (
89
87
// this div wraps the contents AND the page options.
···
106
104
shrink-0 snap-center
107
105
overflow-y-scroll
108
106
${
109
-
!props.cardBorderHidden &&
107
+
!cardBorderHidden &&
110
108
`h-full border
111
109
bg-[rgba(var(--bg-page),var(--bg-page-alpha))]
112
110
${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"}
113
111
${props.isFocused ? "shadow-md border-border" : "border-border-light"}`
114
112
}
115
-
${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
113
+
${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
116
114
${props.fullPageScroll && "max-w-full "}
117
115
${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"}
118
116
${
+9
-31
components/Pages/PageOptions.tsx
+9
-31
components/Pages/PageOptions.tsx
···
7
7
import { useReplicache } from "src/replicache";
8
8
9
9
import { Media } from "../Media";
10
-
import { MenuItem, Menu } from "../Layout";
10
+
import { MenuItem, Menu } from "../Menu";
11
11
import { PageThemeSetter } from "../ThemeManager/PageThemeSetter";
12
12
import { PageShareMenu } from "./PageShareMenu";
13
13
import { useUndoState } from "src/undoManager";
···
21
21
export const PageOptionButton = ({
22
22
children,
23
23
secondary,
24
-
cardBorderHidden,
25
24
className,
26
25
disabled,
27
26
...props
28
27
}: {
29
28
children: React.ReactNode;
30
29
secondary?: boolean;
31
-
cardBorderHidden: boolean | undefined;
32
30
className?: string;
33
31
disabled?: boolean;
34
32
} & Omit<JSX.IntrinsicElements["button"], "content">) => {
33
+
const cardBorderHidden = useCardBorderHidden();
35
34
return (
36
35
<button
37
36
className={`
···
58
57
first: boolean | undefined;
59
58
isFocused: boolean;
60
59
}) => {
61
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
62
-
63
60
return (
64
61
<div
65
62
className={`pageOptions w-fit z-10
66
63
${props.isFocused ? "block" : "sm:hidden block"}
67
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
64
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
68
65
flex sm:flex-col flex-row-reverse gap-1 items-start`}
69
66
>
70
67
{!props.first && (
71
68
<PageOptionButton
72
-
cardBorderHidden={cardBorderHidden}
73
69
secondary
74
70
onClick={() => {
75
71
useUIState.getState().closePage(props.entityID);
···
78
74
<CloseTiny />
79
75
</PageOptionButton>
80
76
)}
81
-
<OptionsMenu
82
-
entityID={props.entityID}
83
-
first={!!props.first}
84
-
cardBorderHidden={cardBorderHidden}
85
-
/>
86
-
<UndoButtons cardBorderHidden={cardBorderHidden} />
77
+
<OptionsMenu entityID={props.entityID} first={!!props.first} />
78
+
<UndoButtons />
87
79
</div>
88
80
);
89
81
};
90
82
91
-
export const UndoButtons = (props: {
92
-
cardBorderHidden: boolean | undefined;
93
-
}) => {
83
+
export const UndoButtons = () => {
94
84
let undoState = useUndoState();
95
85
let { undoManager } = useReplicache();
96
86
return (
97
87
<Media mobile>
98
88
{undoState.canUndo && (
99
89
<div className="gap-1 flex sm:flex-col">
100
-
<PageOptionButton
101
-
secondary
102
-
cardBorderHidden={props.cardBorderHidden}
103
-
onClick={() => undoManager.undo()}
104
-
>
90
+
<PageOptionButton secondary onClick={() => undoManager.undo()}>
105
91
<UndoTiny />
106
92
</PageOptionButton>
107
93
108
94
<PageOptionButton
109
95
secondary
110
-
cardBorderHidden={props.cardBorderHidden}
111
96
onClick={() => undoManager.undo()}
112
97
disabled={!undoState.canRedo}
113
98
>
···
119
104
);
120
105
};
121
106
122
-
export const OptionsMenu = (props: {
123
-
entityID: string;
124
-
first: boolean;
125
-
cardBorderHidden: boolean | undefined;
126
-
}) => {
107
+
export const OptionsMenu = (props: { entityID: string; first: boolean }) => {
127
108
let [state, setState] = useState<"normal" | "theme" | "share">("normal");
128
109
let { permissions } = useEntitySetContext();
129
110
if (!permissions.write) return null;
···
138
119
if (!open) setState("normal");
139
120
}}
140
121
trigger={
141
-
<PageOptionButton
142
-
cardBorderHidden={props.cardBorderHidden}
143
-
className="!w-8 !h-5 sm:!w-5 sm:!h-8"
144
-
>
122
+
<PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8">
145
123
<MoreOptionsTiny className="sm:rotate-90" />
146
124
</PageOptionButton>
147
125
}
+5
-3
components/Pages/PublicationMetadata.tsx
+5
-3
components/Pages/PublicationMetadata.tsx
···
121
121
<Separator classname="h-4!" />
122
122
</>
123
123
)}
124
-
<div className="flex gap-1 items-center">
125
-
<QuoteTiny />โ
126
-
</div>
124
+
{pubRecord?.preferences?.showMentions && (
125
+
<div className="flex gap-1 items-center">
126
+
<QuoteTiny />โ
127
+
</div>
128
+
)}
127
129
{pubRecord?.preferences?.showComments && (
128
130
<div className="flex gap-1 items-center">
129
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";
1
+
import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider";
4
2
5
-
export function useCardBorderHidden(entityID: string | null) {
6
-
let { rootEntity } = useReplicache();
7
-
let { data: pub } = useLeafletPublicationData();
8
-
let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
9
-
10
-
let cardBorderHidden =
11
-
useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden;
12
-
if (!cardBorderHidden && !rootCardBorderHidden) {
13
-
if (pub?.publications?.record) {
14
-
let record = pub.publications.record as PubLeafletPublication.Record;
15
-
return !record.theme?.showPageBackground;
16
-
}
17
-
return false;
18
-
}
19
-
return (cardBorderHidden || rootCardBorderHidden)?.data.value;
3
+
export function useCardBorderHidden(entityID?: string | null) {
4
+
return useCardBorderHiddenContext();
20
5
}
+44
-31
components/PostListing.tsx
+44
-31
components/PostListing.tsx
···
13
13
14
14
import Link from "next/link";
15
15
import { InteractionPreview } from "./InteractionsPreview";
16
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16
17
17
18
export const PostListing = (props: Post) => {
18
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
19
+
let pubRecord = props.publication?.pubRecord as
20
+
| PubLeafletPublication.Record
21
+
| undefined;
19
22
20
23
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
24
let postUri = new AtUri(props.documents.uri);
25
+
let uri = props.publication ? props.publication?.uri : props.documents.uri;
22
26
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;
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;
30
35
31
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
32
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
36
+
let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat;
37
+
let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
33
38
34
-
let showPageBackground = pubRecord.theme?.showPageBackground;
39
+
let showPageBackground = pubRecord
40
+
? pubRecord?.theme?.showPageBackground
41
+
: postRecord.theme?.showPageBackground ?? true;
35
42
36
43
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
37
44
let comments =
38
-
pubRecord.preferences?.showComments === false
45
+
pubRecord?.preferences?.showComments === false
39
46
? 0
40
47
: props.documents.comments_on_documents?.[0]?.count || 0;
41
48
let tags = (postRecord?.tags as string[] | undefined) || [];
42
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
+
43
55
return (
44
56
<BaseThemeProvider {...theme} local>
45
57
<div
46
58
style={{
47
-
backgroundImage: `url(${backgroundImage})`,
59
+
backgroundImage: backgroundImage
60
+
? `url(${backgroundImage})`
61
+
: undefined,
48
62
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
49
63
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
50
64
}}
···
55
69
hover:outline-accent-contrast hover:border-accent-contrast
56
70
`}
57
71
>
58
-
<Link
59
-
className="h-full w-full absolute top-0 left-0"
60
-
href={`${props.publication.href}/${postUri.rkey}`}
61
-
/>
72
+
<Link className="h-full w-full absolute top-0 left-0" href={postHref} />
62
73
<div
63
74
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
64
75
style={{
···
71
82
72
83
<p className="text-secondary italic">{postRecord.description}</p>
73
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">
74
-
<PubInfo
75
-
href={props.publication.href}
76
-
pubRecord={pubRecord}
77
-
uri={props.publication.uri}
78
-
/>
85
+
{props.publication && pubRecord && (
86
+
<PubInfo
87
+
href={props.publication.href}
88
+
pubRecord={pubRecord}
89
+
uri={props.publication.uri}
90
+
/>
91
+
)}
79
92
<div className="flex flex-row justify-between gap-2 items-center w-full">
80
93
<PostInfo publishedAt={postRecord.publishedAt} />
81
94
<InteractionPreview
82
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
95
+
postUrl={postHref}
83
96
quotesCount={quotes}
84
97
commentsCount={comments}
85
98
tags={tags}
86
-
showComments={pubRecord.preferences?.showComments}
99
+
showComments={pubRecord?.preferences?.showComments}
100
+
showMentions={pubRecord?.preferences?.showMentions}
87
101
share
88
102
/>
89
103
</div>
···
114
128
};
115
129
116
130
const PostInfo = (props: { publishedAt: string | undefined }) => {
131
+
let localizedDate = useLocalizedDate(props.publishedAt || "", {
132
+
year: "numeric",
133
+
month: "short",
134
+
day: "numeric",
135
+
});
117
136
return (
118
137
<div className="flex gap-2 items-center shrink-0 self-start">
119
138
{props.publishedAt && (
120
139
<>
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>
140
+
<div className="shrink-0">{localizedDate}</div>
128
141
</>
129
142
)}
130
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
3
import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter";
4
4
5
5
import {
6
-
PageBackgroundPicker,
6
+
SubpageBackgroundPicker,
7
7
PageThemePickers,
8
8
} from "./Pickers/PageThemePickers";
9
9
import { useMemo, useState } from "react";
···
54
54
className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]"
55
55
style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }}
56
56
>
57
-
<PageBackgroundPicker
57
+
<SubpageBackgroundPicker
58
58
entityID={props.entityID}
59
59
openPicker={openPicker}
60
-
setOpenPicker={(pickers) => setOpenPicker(pickers)}
61
-
setValue={set("theme/card-background")}
60
+
setOpenPicker={setOpenPicker}
62
61
/>
63
62
</div>
64
63
···
147
146
<div
148
147
className={
149
148
pageBorderHidden
150
-
? "py-2 px-0 border border-transparent"
149
+
? "relative py-2 px-0 border border-transparent"
151
150
: `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent`
152
151
}
153
152
style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
+6
components/ThemeManager/Pickers/ColorPicker.tsx
···
21
21
22
22
export const ColorPicker = (props: {
23
23
label?: string;
24
+
helpText?: string;
24
25
value: Color | undefined;
25
26
alpha?: boolean;
26
27
image?: boolean;
···
116
117
<div className="w-full flex flex-col gap-2 px-1 pb-2">
117
118
{
118
119
<>
120
+
{props.helpText && (
121
+
<div className="text-sm leading-tight text-tertiary pl-7 -mt-2.5">
122
+
{props.helpText}
123
+
</div>
124
+
)}
119
125
<ColorArea
120
126
className="w-full h-[128px] rounded-md"
121
127
colorSpace="hsb"
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
+4
-4
components/ThemeManager/Pickers/ImagePicker.tsx
···
73
73
});
74
74
}}
75
75
>
76
-
<div className="flex flex-col gap-2 w-full">
76
+
<div className="flex flex-col w-full">
77
77
<div className="flex gap-2">
78
78
<div
79
79
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
122
122
}}
123
123
>
124
124
<Slider.Track
125
-
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
125
+
className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
126
126
></Slider.Track>
127
127
<Slider.Thumb
128
128
className={`
129
129
flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
130
-
${repeat ? "bg-[#595959]" : " bg-[#C3C3C3] "}
131
-
${repeat && "shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"} `}
130
+
${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "}
131
+
`}
132
132
aria-label="Volume"
133
133
/>
134
134
</Slider.Root>
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
···
1
-
"use client";
2
-
3
-
import {
4
-
ColorPicker as SpectrumColorPicker,
5
-
parseColor,
6
-
Color,
7
-
ColorArea,
8
-
ColorThumb,
9
-
ColorSlider,
10
-
Input,
11
-
ColorField,
12
-
SliderTrack,
13
-
ColorSwatch,
14
-
} from "react-aria-components";
15
-
import { pickers, setColorAttribute } from "../ThemeSetter";
16
-
import { thumbStyle } from "./ColorPicker";
17
-
import { ImageInput, ImageSettings } from "./ImagePicker";
18
-
import { useEntity, useReplicache } from "src/replicache";
19
-
import { useColorAttribute } from "components/ThemeManager/useColorAttribute";
20
-
import { Separator } from "components/Layout";
21
-
import { onMouseDown } from "src/utils/iosInputMouseDown";
22
-
import { BlockImageSmall } from "components/Icons/BlockImageSmall";
23
-
import { DeleteSmall } from "components/Icons/DeleteSmall";
24
-
25
-
export const LeafletBGPicker = (props: {
26
-
entityID: string;
27
-
openPicker: pickers;
28
-
thisPicker: pickers;
29
-
setOpenPicker: (thisPicker: pickers) => void;
30
-
closePicker: () => void;
31
-
setValue: (c: Color) => void;
32
-
}) => {
33
-
let bgImage = useEntity(props.entityID, "theme/background-image");
34
-
let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat");
35
-
let bgColor = useColorAttribute(props.entityID, "theme/page-background");
36
-
let open = props.openPicker == props.thisPicker;
37
-
let { rep } = useReplicache();
38
-
39
-
return (
40
-
<>
41
-
<div className="bgPickerLabel flex justify-between place-items-center ">
42
-
<div className="bgPickerColorLabel flex gap-2 items-center">
43
-
<button
44
-
onClick={() => {
45
-
if (props.openPicker === props.thisPicker) {
46
-
props.setOpenPicker("null");
47
-
} else {
48
-
props.setOpenPicker(props.thisPicker);
49
-
}
50
-
}}
51
-
className="flex gap-2 items-center"
52
-
>
53
-
<ColorSwatch
54
-
color={bgColor}
55
-
className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`}
56
-
style={{
57
-
backgroundImage: bgImage?.data.src
58
-
? `url(${bgImage.data.src})`
59
-
: undefined,
60
-
backgroundSize: "cover",
61
-
}}
62
-
/>
63
-
<strong className={` "text-[#595959]`}>{"Background"}</strong>
64
-
</button>
65
-
66
-
<div className="flex">
67
-
{bgImage ? (
68
-
<div className={`"text-[#969696]`}>Image</div>
69
-
) : (
70
-
<>
71
-
<ColorField className="w-fit gap-1" value={bgColor}>
72
-
<Input
73
-
onMouseDown={onMouseDown}
74
-
onFocus={(e) => {
75
-
e.currentTarget.setSelectionRange(
76
-
1,
77
-
e.currentTarget.value.length,
78
-
);
79
-
}}
80
-
onPaste={(e) => {
81
-
console.log(e);
82
-
}}
83
-
onKeyDown={(e) => {
84
-
if (e.key === "Enter") {
85
-
e.currentTarget.blur();
86
-
} else return;
87
-
}}
88
-
onBlur={(e) => {
89
-
props.setValue(parseColor(e.currentTarget.value));
90
-
}}
91
-
className={`w-[72px] bg-transparent outline-nonetext-[#595959]`}
92
-
/>
93
-
</ColorField>
94
-
</>
95
-
)}
96
-
</div>
97
-
</div>
98
-
<div className="flex gap-1 justify-end grow text-[#969696]">
99
-
{bgImage && (
100
-
<button
101
-
onClick={() => {
102
-
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
103
-
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
104
-
}}
105
-
>
106
-
<DeleteSmall />
107
-
</button>
108
-
)}
109
-
<label>
110
-
<BlockImageSmall />
111
-
<div className="hidden">
112
-
<ImageInput
113
-
{...props}
114
-
onChange={() => {
115
-
props.setOpenPicker(props.thisPicker);
116
-
}}
117
-
/>
118
-
</div>
119
-
</label>
120
-
</div>
121
-
</div>
122
-
{open && (
123
-
<div className="bgImageAndColorPicker w-full flex flex-col gap-2 ">
124
-
<SpectrumColorPicker
125
-
value={bgColor}
126
-
onChange={setColorAttribute(
127
-
rep,
128
-
props.entityID,
129
-
)("theme/page-background")}
130
-
>
131
-
{bgImage ? (
132
-
<ImageSettings
133
-
entityID={props.entityID}
134
-
setValue={props.setValue}
135
-
/>
136
-
) : (
137
-
<>
138
-
<ColorArea
139
-
className="w-full h-[128px] rounded-md"
140
-
colorSpace="hsb"
141
-
xChannel="saturation"
142
-
yChannel="brightness"
143
-
>
144
-
<ColorThumb className={thumbStyle} />
145
-
</ColorArea>
146
-
<ColorSlider
147
-
colorSpace="hsb"
148
-
className="w-full "
149
-
channel="hue"
150
-
>
151
-
<SliderTrack className="h-2 w-full rounded-md">
152
-
<ColorThumb className={`${thumbStyle} mt-[4px]`} />
153
-
</SliderTrack>
154
-
</ColorSlider>
155
-
</>
156
-
)}
157
-
</SpectrumColorPicker>
158
-
</div>
159
-
)}
160
-
</>
161
-
);
162
-
};
+353
-43
components/ThemeManager/Pickers/PageThemePickers.tsx
+353
-43
components/ThemeManager/Pickers/PageThemePickers.tsx
···
51
51
<hr className="border-border-light w-full" />
52
52
</>
53
53
)}
54
-
<PageTextPicker
54
+
<TextPickers
55
55
value={primaryValue}
56
56
setValue={set("theme/primary")}
57
57
openPicker={props.openPicker}
···
61
61
);
62
62
};
63
63
64
-
export const PageBackgroundPicker = (props: {
64
+
// Page background picker for subpages - shows Page/Containers color with optional background image
65
+
export const SubpageBackgroundPicker = (props: {
65
66
entityID: string;
66
-
setValue: (c: Color) => void;
67
67
openPicker: pickers;
68
68
setOpenPicker: (p: pickers) => void;
69
-
home?: boolean;
70
69
}) => {
70
+
let { rep, rootEntity } = useReplicache();
71
+
let set = useMemo(() => {
72
+
return setColorAttribute(rep, props.entityID);
73
+
}, [rep, props.entityID]);
74
+
71
75
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
72
76
let pageBGImage = useEntity(props.entityID, "theme/card-background-image");
73
-
let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
77
+
let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
78
+
let entityPageBorderHidden = useEntity(
79
+
props.entityID,
80
+
"theme/card-border-hidden",
81
+
);
82
+
let pageBorderHidden =
83
+
(entityPageBorderHidden || rootPageBorderHidden)?.data.value || false;
84
+
let hasPageBackground = !pageBorderHidden;
85
+
86
+
// Label is "Page" when page background is visible, "Containers" when hidden
87
+
let label = hasPageBackground ? "Page" : "Containers";
88
+
89
+
// If root page border is hidden, only show color picker (no image support)
90
+
if (!hasPageBackground) {
91
+
return (
92
+
<ColorPicker
93
+
label={label}
94
+
helpText={"Affects menus, tooltips and some block backgrounds"}
95
+
value={pageValue}
96
+
setValue={set("theme/card-background")}
97
+
thisPicker="page"
98
+
openPicker={props.openPicker}
99
+
setOpenPicker={props.setOpenPicker}
100
+
closePicker={() => props.setOpenPicker("null")}
101
+
alpha
102
+
/>
103
+
);
104
+
}
74
105
75
106
return (
76
107
<>
77
-
{pageBGImage && pageBGImage !== null && (
78
-
<PageBackgroundImagePicker
79
-
disabled={pageBorderHidden?.data.value}
108
+
{pageBGImage && (
109
+
<SubpageBackgroundImagePicker
80
110
entityID={props.entityID}
81
-
thisPicker={"page-background-image"}
82
111
openPicker={props.openPicker}
83
112
setOpenPicker={props.setOpenPicker}
84
-
closePicker={() => props.setOpenPicker("null")}
85
-
setValue={props.setValue}
86
-
home={props.home}
113
+
setValue={set("theme/card-background")}
87
114
/>
88
115
)}
89
116
<div className="relative">
90
-
<PageBackgroundColorPicker
91
-
label={pageBorderHidden?.data.value ? "Menus" : "Page"}
117
+
<ColorPicker
118
+
label={label}
92
119
value={pageValue}
93
-
setValue={props.setValue}
94
-
thisPicker={"page"}
120
+
setValue={set("theme/card-background")}
121
+
thisPicker="page"
95
122
openPicker={props.openPicker}
96
123
setOpenPicker={props.setOpenPicker}
124
+
closePicker={() => props.setOpenPicker("null")}
97
125
alpha
98
126
/>
99
-
{(pageBGImage === null ||
100
-
(!pageBGImage && !pageBorderHidden?.data.value && !props.home)) && (
101
-
<label
102
-
className={`
103
-
hover:cursor-pointer text-[#969696] shrink-0
104
-
absolute top-0 right-0
105
-
`}
106
-
>
127
+
{!pageBGImage && (
128
+
<label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
107
129
<BlockImageSmall />
108
130
<div className="hidden">
109
131
<ImageInput
···
119
141
);
120
142
};
121
143
144
+
const SubpageBackgroundImagePicker = (props: {
145
+
entityID: string;
146
+
openPicker: pickers;
147
+
setOpenPicker: (p: pickers) => void;
148
+
setValue: (c: Color) => void;
149
+
}) => {
150
+
let { rep } = useReplicache();
151
+
let bgImage = useEntity(props.entityID, "theme/card-background-image");
152
+
let bgRepeat = useEntity(
153
+
props.entityID,
154
+
"theme/card-background-image-repeat",
155
+
);
156
+
let bgColor = useColorAttribute(props.entityID, "theme/card-background");
157
+
let bgAlpha =
158
+
useEntity(props.entityID, "theme/card-background-image-opacity")?.data
159
+
.value || 1;
160
+
let alphaColor = useMemo(() => {
161
+
return parseColor(`rgba(0,0,0,${bgAlpha})`);
162
+
}, [bgAlpha]);
163
+
let open = props.openPicker === "page-background-image";
164
+
165
+
return (
166
+
<>
167
+
<div className="bgPickerColorLabel flex gap-2 items-center">
168
+
<button
169
+
onClick={() => {
170
+
props.setOpenPicker(open ? "null" : "page-background-image");
171
+
}}
172
+
className="flex gap-2 items-center grow"
173
+
>
174
+
<ColorSwatch
175
+
color={bgColor}
176
+
className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
177
+
style={{
178
+
backgroundImage: bgImage?.data.src
179
+
? `url(${bgImage.data.src})`
180
+
: undefined,
181
+
backgroundPosition: "center",
182
+
backgroundSize: "cover",
183
+
}}
184
+
/>
185
+
<strong className="text-[#595959]">Page</strong>
186
+
<div className="italic text-[#8C8C8C]">image</div>
187
+
</button>
188
+
189
+
<SpectrumColorPicker
190
+
value={alphaColor}
191
+
onChange={(c) => {
192
+
let alpha = c.getChannelValue("alpha");
193
+
rep?.mutate.assertFact({
194
+
entity: props.entityID,
195
+
attribute: "theme/card-background-image-opacity",
196
+
data: { type: "number", value: alpha },
197
+
});
198
+
}}
199
+
>
200
+
<Separator classname="h-4! my-1 border-[#C3C3C3]!" />
201
+
<ColorField className="w-fit pl-[6px]" channel="alpha">
202
+
<Input
203
+
onMouseDown={onMouseDown}
204
+
onFocus={(e) => {
205
+
e.currentTarget.setSelectionRange(
206
+
0,
207
+
e.currentTarget.value.length - 1,
208
+
);
209
+
}}
210
+
onKeyDown={(e) => {
211
+
if (e.key === "Enter") {
212
+
e.currentTarget.blur();
213
+
} else return;
214
+
}}
215
+
className="w-[48px] bg-transparent outline-hidden"
216
+
/>
217
+
</ColorField>
218
+
</SpectrumColorPicker>
219
+
220
+
<div className="flex gap-1 text-[#8C8C8C]">
221
+
<button
222
+
onClick={() => {
223
+
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
224
+
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
225
+
}}
226
+
>
227
+
<DeleteSmall />
228
+
</button>
229
+
<label className="hover:cursor-pointer">
230
+
<BlockImageSmall />
231
+
<div className="hidden">
232
+
<ImageInput
233
+
entityID={props.entityID}
234
+
onChange={() => props.setOpenPicker("page-background-image")}
235
+
card
236
+
/>
237
+
</div>
238
+
</label>
239
+
</div>
240
+
</div>
241
+
{open && (
242
+
<div className="pageImagePicker flex flex-col gap-2">
243
+
<ImageSettings
244
+
entityID={props.entityID}
245
+
card
246
+
setValue={props.setValue}
247
+
/>
248
+
<div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2">
249
+
<hr className="border-[#DBDBDB]" />
250
+
<SpectrumColorPicker
251
+
value={alphaColor}
252
+
onChange={(c) => {
253
+
let alpha = c.getChannelValue("alpha");
254
+
rep?.mutate.assertFact({
255
+
entity: props.entityID,
256
+
attribute: "theme/card-background-image-opacity",
257
+
data: { type: "number", value: alpha },
258
+
});
259
+
}}
260
+
>
261
+
<ColorSlider
262
+
colorSpace="hsb"
263
+
className="w-full mt-1 rounded-full"
264
+
style={{
265
+
backgroundImage: `url(/transparent-bg.png)`,
266
+
backgroundRepeat: "repeat",
267
+
backgroundSize: "8px",
268
+
}}
269
+
channel="alpha"
270
+
>
271
+
<SliderTrack className="h-2 w-full rounded-md">
272
+
<ColorThumb className={`${thumbStyle} mt-[4px]`} />
273
+
</SliderTrack>
274
+
</ColorSlider>
275
+
</SpectrumColorPicker>
276
+
</div>
277
+
</div>
278
+
)}
279
+
</>
280
+
);
281
+
};
282
+
283
+
// Unified background picker for leaflets - matches structure of BackgroundPicker for publications
284
+
export const LeafletBackgroundPicker = (props: {
285
+
entityID: string;
286
+
openPicker: pickers;
287
+
setOpenPicker: (p: pickers) => void;
288
+
}) => {
289
+
let { rep } = useReplicache();
290
+
let set = useMemo(() => {
291
+
return setColorAttribute(rep, props.entityID);
292
+
}, [rep, props.entityID]);
293
+
294
+
let leafletBgValue = useColorAttribute(
295
+
props.entityID,
296
+
"theme/page-background",
297
+
);
298
+
let pageValue = useColorAttribute(props.entityID, "theme/card-background");
299
+
let leafletBGImage = useEntity(props.entityID, "theme/background-image");
300
+
let leafletBGRepeat = useEntity(
301
+
props.entityID,
302
+
"theme/background-image-repeat",
303
+
);
304
+
let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
305
+
let hasPageBackground = !pageBorderHidden?.data.value;
306
+
307
+
// When page background is hidden and no background image, only show the Background picker
308
+
let showPagePicker = hasPageBackground || !!leafletBGImage;
309
+
310
+
return (
311
+
<>
312
+
{/* Background color/image picker */}
313
+
{leafletBGImage ? (
314
+
<LeafletBackgroundImagePicker
315
+
entityID={props.entityID}
316
+
openPicker={props.openPicker}
317
+
setOpenPicker={props.setOpenPicker}
318
+
/>
319
+
) : (
320
+
<div className="relative">
321
+
<ColorPicker
322
+
label="Background"
323
+
value={leafletBgValue}
324
+
setValue={set("theme/page-background")}
325
+
thisPicker="leaflet"
326
+
openPicker={props.openPicker}
327
+
setOpenPicker={props.setOpenPicker}
328
+
closePicker={() => props.setOpenPicker("null")}
329
+
/>
330
+
<label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
331
+
<BlockImageSmall />
332
+
<div className="hidden">
333
+
<ImageInput
334
+
entityID={props.entityID}
335
+
onChange={() => props.setOpenPicker("leaflet")}
336
+
/>
337
+
</div>
338
+
</label>
339
+
</div>
340
+
)}
341
+
342
+
{/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */}
343
+
{showPagePicker && (
344
+
<ColorPicker
345
+
label={hasPageBackground ? "Page" : "Containers"}
346
+
helpText={
347
+
hasPageBackground
348
+
? undefined
349
+
: "Affects menus, tooltips and some block backgrounds"
350
+
}
351
+
value={pageValue}
352
+
setValue={set("theme/card-background")}
353
+
thisPicker="page"
354
+
openPicker={props.openPicker}
355
+
setOpenPicker={props.setOpenPicker}
356
+
closePicker={() => props.setOpenPicker("null")}
357
+
alpha
358
+
/>
359
+
)}
360
+
361
+
<hr className="border-[#CCCCCC]" />
362
+
363
+
{/* Page Background toggle */}
364
+
<PageBorderHider
365
+
entityID={props.entityID}
366
+
openPicker={props.openPicker}
367
+
setOpenPicker={props.setOpenPicker}
368
+
/>
369
+
</>
370
+
);
371
+
};
372
+
373
+
const LeafletBackgroundImagePicker = (props: {
374
+
entityID: string;
375
+
openPicker: pickers;
376
+
setOpenPicker: (p: pickers) => void;
377
+
}) => {
378
+
let { rep } = useReplicache();
379
+
let bgImage = useEntity(props.entityID, "theme/background-image");
380
+
let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat");
381
+
let bgColor = useColorAttribute(props.entityID, "theme/page-background");
382
+
let open = props.openPicker === "leaflet";
383
+
384
+
return (
385
+
<>
386
+
<div className="bgPickerColorLabel flex gap-2 items-center">
387
+
<button
388
+
onClick={() => {
389
+
props.setOpenPicker(open ? "null" : "leaflet");
390
+
}}
391
+
className="flex gap-2 items-center grow"
392
+
>
393
+
<ColorSwatch
394
+
color={bgColor}
395
+
className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
396
+
style={{
397
+
backgroundImage: bgImage?.data.src
398
+
? `url(${bgImage.data.src})`
399
+
: undefined,
400
+
backgroundPosition: "center",
401
+
backgroundSize: "cover",
402
+
}}
403
+
/>
404
+
<strong className="text-[#595959]">Background</strong>
405
+
<div className="italic text-[#8C8C8C]">image</div>
406
+
</button>
407
+
<div className="flex gap-1 text-[#8C8C8C]">
408
+
<button
409
+
onClick={() => {
410
+
if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
411
+
if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
412
+
}}
413
+
>
414
+
<DeleteSmall />
415
+
</button>
416
+
<label className="hover:cursor-pointer">
417
+
<BlockImageSmall />
418
+
<div className="hidden">
419
+
<ImageInput
420
+
entityID={props.entityID}
421
+
onChange={() => props.setOpenPicker("leaflet")}
422
+
/>
423
+
</div>
424
+
</label>
425
+
</div>
426
+
</div>
427
+
{open && (
428
+
<div className="pageImagePicker flex flex-col gap-2">
429
+
<ImageSettings entityID={props.entityID} setValue={() => {}} />
430
+
</div>
431
+
)}
432
+
</>
433
+
);
434
+
};
435
+
122
436
export const PageBackgroundColorPicker = (props: {
123
437
disabled?: boolean;
124
438
label: string;
···
128
442
setValue: (c: Color) => void;
129
443
value: Color;
130
444
alpha?: boolean;
445
+
helpText?: string;
131
446
}) => {
132
447
return (
133
448
<ColorPicker
134
449
disabled={props.disabled}
135
450
label={props.label}
451
+
helpText={props.helpText}
136
452
value={props.value}
137
453
setValue={props.setValue}
138
454
thisPicker={"page"}
···
347
663
);
348
664
};
349
665
350
-
export const PageTextPicker = (props: {
666
+
export const TextPickers = (props: {
351
667
openPicker: pickers;
352
668
setOpenPicker: (thisPicker: pickers) => void;
353
669
value: Color;
···
394
710
395
711
return (
396
712
<>
397
-
<div className="flex gap-2 items-center">
398
-
<Toggle
399
-
toggleOn={!pageBorderHidden}
400
-
setToggleOn={() => {
401
-
handleToggle();
402
-
}}
403
-
disabledColor1="#8C8C8C"
404
-
disabledColor2="#DBDBDB"
405
-
/>
406
-
<button
407
-
className="flex gap-2 items-center"
408
-
onClick={() => {
409
-
handleToggle();
410
-
}}
411
-
>
713
+
<Toggle
714
+
toggle={!pageBorderHidden}
715
+
onToggle={() => {
716
+
handleToggle();
717
+
}}
718
+
disabledColor1="#8C8C8C"
719
+
disabledColor2="#DBDBDB"
720
+
>
721
+
<div className="flex gap-2">
412
722
<div className="font-bold">Page Background</div>
413
723
<div className="italic text-[#8C8C8C]">
414
-
{pageBorderHidden ? "hidden" : ""}
724
+
{pageBorderHidden ? "none" : ""}
415
725
</div>
416
-
</button>
417
-
</div>
726
+
</div>
727
+
</Toggle>
418
728
</>
419
729
);
420
730
};
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
···
1
+
import * as Slider from "@radix-ui/react-slider";
2
+
import { Input } from "components/Input";
3
+
import { Radio } from "components/Checkbox";
4
+
import { useEntity, useReplicache } from "src/replicache";
5
+
import { pickers } from "../ThemeSetter";
6
+
import { useState, useEffect } from "react";
7
+
8
+
export const PageWidthSetter = (props: {
9
+
entityID: string;
10
+
openPicker: pickers;
11
+
thisPicker: pickers;
12
+
setOpenPicker: (thisPicker: pickers) => void;
13
+
closePicker: () => void;
14
+
}) => {
15
+
let { rep } = useReplicache();
16
+
17
+
let defaultPreset = 624;
18
+
let widePreset = 768;
19
+
let pageWidth = useEntity(props.entityID, "theme/page-width")?.data.value;
20
+
let currentValue = pageWidth || defaultPreset;
21
+
let [interimValue, setInterimValue] = useState<number>(currentValue);
22
+
let [selectedPreset, setSelectedPreset] = useState<
23
+
"default" | "wide" | "custom"
24
+
>(
25
+
currentValue === defaultPreset
26
+
? "default"
27
+
: currentValue === widePreset
28
+
? "wide"
29
+
: "custom",
30
+
);
31
+
let min = 320;
32
+
let max = 1200;
33
+
34
+
let open = props.openPicker == props.thisPicker;
35
+
36
+
// Update interim value when current value changes
37
+
useEffect(() => {
38
+
setInterimValue(currentValue);
39
+
}, [currentValue]);
40
+
41
+
const setPageWidth = (value: number) => {
42
+
rep?.mutate.assertFact({
43
+
entity: props.entityID,
44
+
attribute: "theme/page-width",
45
+
data: {
46
+
type: "number",
47
+
value: value,
48
+
},
49
+
});
50
+
};
51
+
52
+
return (
53
+
<div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md">
54
+
<div className="flex flex-col gap-2">
55
+
<div className="flex gap-2 items-center">
56
+
<button
57
+
className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 items-start text-left"
58
+
onClick={() => {
59
+
if (props.openPicker === props.thisPicker) {
60
+
props.setOpenPicker("null");
61
+
} else {
62
+
props.setOpenPicker(props.thisPicker);
63
+
}
64
+
}}
65
+
>
66
+
Max Page Width
67
+
<span className="flex font-normal text-[#969696]">
68
+
{currentValue}px
69
+
</span>
70
+
</button>
71
+
</div>
72
+
{open && (
73
+
<div className="flex flex-col gap-1 px-3">
74
+
<label htmlFor="default" className="w-full">
75
+
<Radio
76
+
radioCheckedClassName="text-[#595959]!"
77
+
radioEmptyClassName="text-[#969696]!"
78
+
type="radio"
79
+
id="default"
80
+
name="page-width-options"
81
+
value="default"
82
+
checked={selectedPreset === "default"}
83
+
onChange={(e) => {
84
+
if (!e.currentTarget.checked) return;
85
+
setSelectedPreset("default");
86
+
setPageWidth(defaultPreset);
87
+
}}
88
+
>
89
+
<div
90
+
className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`}
91
+
>
92
+
default (624px)
93
+
</div>
94
+
</Radio>
95
+
</label>
96
+
<label htmlFor="wide" className="w-full">
97
+
<Radio
98
+
radioCheckedClassName="text-[#595959]!"
99
+
radioEmptyClassName="text-[#969696]!"
100
+
type="radio"
101
+
id="wide"
102
+
name="page-width-options"
103
+
value="wide"
104
+
checked={selectedPreset === "wide"}
105
+
onChange={(e) => {
106
+
if (!e.currentTarget.checked) return;
107
+
setSelectedPreset("wide");
108
+
setPageWidth(widePreset);
109
+
}}
110
+
>
111
+
<div
112
+
className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`}
113
+
>
114
+
wide (756px)
115
+
</div>
116
+
</Radio>
117
+
</label>
118
+
<label htmlFor="custom" className="pb-3 w-full">
119
+
<Radio
120
+
type="radio"
121
+
id="custom"
122
+
name="page-width-options"
123
+
value="custom"
124
+
radioCheckedClassName="text-[#595959]!"
125
+
radioEmptyClassName="text-[#969696]!"
126
+
checked={selectedPreset === "custom"}
127
+
onChange={(e) => {
128
+
if (!e.currentTarget.checked) return;
129
+
setSelectedPreset("custom");
130
+
if (selectedPreset !== "custom") {
131
+
setPageWidth(currentValue);
132
+
setInterimValue(currentValue);
133
+
}
134
+
}}
135
+
>
136
+
<div className="flex flex-col w-full">
137
+
<div className="flex gap-2">
138
+
<div
139
+
className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`}
140
+
>
141
+
custom
142
+
</div>
143
+
<div
144
+
className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`}
145
+
>
146
+
<Input
147
+
type="number"
148
+
className="w-10 text-right appearance-none bg-transparent"
149
+
max={max}
150
+
min={min}
151
+
value={interimValue}
152
+
onChange={(e) => {
153
+
setInterimValue(parseInt(e.currentTarget.value));
154
+
}}
155
+
onKeyDown={(e) => {
156
+
if (e.key === "Enter" || e.key === "Escape") {
157
+
e.preventDefault();
158
+
let clampedValue = interimValue;
159
+
if (!isNaN(interimValue)) {
160
+
clampedValue = Math.max(
161
+
min,
162
+
Math.min(max, interimValue),
163
+
);
164
+
setInterimValue(clampedValue);
165
+
}
166
+
setPageWidth(clampedValue);
167
+
}
168
+
}}
169
+
onBlur={() => {
170
+
let clampedValue = interimValue;
171
+
if (!isNaN(interimValue)) {
172
+
clampedValue = Math.max(
173
+
min,
174
+
Math.min(max, interimValue),
175
+
);
176
+
setInterimValue(clampedValue);
177
+
}
178
+
setPageWidth(clampedValue);
179
+
}}
180
+
/>
181
+
px
182
+
</div>
183
+
</div>
184
+
<Slider.Root
185
+
className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`}
186
+
value={[interimValue]}
187
+
max={max}
188
+
min={min}
189
+
step={16}
190
+
onValueChange={(value) => {
191
+
setInterimValue(value[0]);
192
+
}}
193
+
onValueCommit={(value) => {
194
+
setPageWidth(value[0]);
195
+
}}
196
+
>
197
+
<Slider.Track
198
+
className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
199
+
/>
200
+
<Slider.Thumb
201
+
className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
202
+
${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"}
203
+
`}
204
+
aria-label="Max Page Width"
205
+
/>
206
+
</Slider.Root>
207
+
</div>
208
+
</Radio>
209
+
</label>
210
+
</div>
211
+
)}
212
+
</div>
213
+
</div>
214
+
);
215
+
};
+30
-24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
+30
-24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
···
24
24
hasPageBackground: boolean;
25
25
setHasPageBackground: (s: boolean) => void;
26
26
}) => {
27
+
// When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker
28
+
let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage;
29
+
27
30
return (
28
31
<>
29
32
{props.bgImage && props.bgImage !== null ? (
···
83
86
)}
84
87
</div>
85
88
)}
86
-
<PageBackgroundColorPicker
87
-
label={"Containers"}
88
-
value={props.pageBackground}
89
-
setValue={props.setPageBackground}
90
-
thisPicker={"page"}
91
-
openPicker={props.openPicker}
92
-
setOpenPicker={props.setOpenPicker}
93
-
alpha={props.hasPageBackground ? true : false}
94
-
/>
89
+
{!showLeafletBgPicker && (
90
+
// When there's a background image and page background hidden, label should say "Containers"
91
+
<PageBackgroundColorPicker
92
+
label={props.hasPageBackground ? "Page" : "Containers"}
93
+
helpText={
94
+
props.hasPageBackground
95
+
? undefined
96
+
: "Affects menus, tooltips and some block backgrounds"
97
+
}
98
+
value={props.pageBackground}
99
+
setValue={props.setPageBackground}
100
+
thisPicker={"page"}
101
+
openPicker={props.openPicker}
102
+
setOpenPicker={props.setOpenPicker}
103
+
alpha={props.hasPageBackground ? true : false}
104
+
/>
105
+
)}
95
106
<hr className="border-border-light" />
96
107
<div className="flex gap-2 items-center">
97
108
<Toggle
98
-
toggleOn={props.hasPageBackground}
99
-
setToggleOn={() => {
109
+
toggle={props.hasPageBackground}
110
+
onToggle={() => {
100
111
props.setHasPageBackground(!props.hasPageBackground);
101
112
props.hasPageBackground &&
102
113
props.openPicker === "page" &&
···
104
115
}}
105
116
disabledColor1="#8C8C8C"
106
117
disabledColor2="#DBDBDB"
107
-
/>
108
-
<button
109
-
className="flex gap-2 items-center"
110
-
onClick={() => {
111
-
props.setHasPageBackground(!props.hasPageBackground);
112
-
props.hasPageBackground && props.setOpenPicker("null");
113
-
}}
114
118
>
115
-
<div className="font-bold">Page Background</div>
116
-
<div className="italic text-[#8C8C8C]">
117
-
{props.hasPageBackground ? "" : "hidden"}
119
+
<div className="flex gap-2">
120
+
<div className="font-bold">Page Background</div>
121
+
<div className="italic text-[#8C8C8C]">
122
+
{props.hasPageBackground ? "" : "none"}
123
+
</div>
118
124
</div>
119
-
</button>
125
+
</Toggle>
120
126
</div>
121
127
</>
122
128
);
···
250
256
props.setBgImage({ ...props.bgImage, repeat: 500 });
251
257
}}
252
258
>
253
-
<div className="flex flex-col gap-2 w-full">
259
+
<div className="flex flex-col w-full">
254
260
<div className="flex gap-2">
255
261
<div
256
262
className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`}
···
289
295
}}
290
296
>
291
297
<Slider.Track
292
-
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`}
298
+
className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
293
299
></Slider.Track>
294
300
<Slider.Thumb
295
301
className={`
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
···
1
+
import * as Slider from "@radix-ui/react-slider";
2
+
import { Input } from "components/Input";
3
+
import { Radio } from "components/Checkbox";
4
+
import { useState, useEffect } from "react";
5
+
import { pickers } from "../ThemeSetter";
6
+
7
+
export const PubPageWidthSetter = (props: {
8
+
pageWidth: number | undefined;
9
+
setPageWidth: (value: number) => void;
10
+
thisPicker: pickers;
11
+
openPicker: pickers;
12
+
setOpenPicker: (p: pickers) => void;
13
+
}) => {
14
+
let defaultPreset = 624;
15
+
let widePreset = 768;
16
+
17
+
let currentValue = props.pageWidth || defaultPreset;
18
+
let [interimValue, setInterimValue] = useState<number>(currentValue);
19
+
let [selectedPreset, setSelectedPreset] = useState<
20
+
"default" | "wide" | "custom"
21
+
>(
22
+
currentValue === defaultPreset
23
+
? "default"
24
+
: currentValue === widePreset
25
+
? "wide"
26
+
: "custom",
27
+
);
28
+
let min = 320;
29
+
let max = 1200;
30
+
31
+
// Update interim value when current value changes
32
+
useEffect(() => {
33
+
setInterimValue(currentValue);
34
+
}, [currentValue]);
35
+
36
+
const setPageWidth = (value: number) => {
37
+
props.setPageWidth(value);
38
+
};
39
+
40
+
let open = props.openPicker == props.thisPicker;
41
+
42
+
return (
43
+
<div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white">
44
+
<button
45
+
type="button"
46
+
className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center"
47
+
onClick={() => {
48
+
if (!open) {
49
+
props.setOpenPicker(props.thisPicker);
50
+
} else {
51
+
props.setOpenPicker("null");
52
+
}
53
+
}}
54
+
>
55
+
Max Page Width
56
+
<div className="flex font-normal text-[#969696]">{currentValue}px</div>
57
+
</button>
58
+
59
+
{open && (
60
+
<div className="flex flex-col gap-1 px-3">
61
+
<label htmlFor="pub-default" className="w-full">
62
+
<Radio
63
+
radioCheckedClassName="text-[#595959]!"
64
+
radioEmptyClassName="text-[#969696]!"
65
+
type="radio"
66
+
id="pub-default"
67
+
name="pub-page-width-options"
68
+
value="default"
69
+
checked={selectedPreset === "default"}
70
+
onChange={(e) => {
71
+
if (!e.currentTarget.checked) return;
72
+
setSelectedPreset("default");
73
+
setPageWidth(defaultPreset);
74
+
}}
75
+
>
76
+
<div
77
+
className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`}
78
+
>
79
+
default (624px)
80
+
</div>
81
+
</Radio>
82
+
</label>
83
+
<label htmlFor="pub-wide" className="w-full">
84
+
<Radio
85
+
radioCheckedClassName="text-[#595959]!"
86
+
radioEmptyClassName="text-[#969696]!"
87
+
type="radio"
88
+
id="pub-wide"
89
+
name="pub-page-width-options"
90
+
value="wide"
91
+
checked={selectedPreset === "wide"}
92
+
onChange={(e) => {
93
+
if (!e.currentTarget.checked) return;
94
+
setSelectedPreset("wide");
95
+
setPageWidth(widePreset);
96
+
}}
97
+
>
98
+
<div
99
+
className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`}
100
+
>
101
+
wide (756px)
102
+
</div>
103
+
</Radio>
104
+
</label>
105
+
<label htmlFor="pub-custom" className="pb-3 w-full">
106
+
<Radio
107
+
type="radio"
108
+
id="pub-custom"
109
+
name="pub-page-width-options"
110
+
value="custom"
111
+
radioCheckedClassName="text-[#595959]!"
112
+
radioEmptyClassName="text-[#969696]!"
113
+
checked={selectedPreset === "custom"}
114
+
onChange={(e) => {
115
+
if (!e.currentTarget.checked) return;
116
+
setSelectedPreset("custom");
117
+
if (selectedPreset !== "custom") {
118
+
setPageWidth(currentValue);
119
+
setInterimValue(currentValue);
120
+
}
121
+
}}
122
+
>
123
+
<div className="flex flex-col w-full">
124
+
<div className="flex gap-2">
125
+
<div
126
+
className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`}
127
+
>
128
+
custom
129
+
</div>
130
+
<div
131
+
className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`}
132
+
>
133
+
<Input
134
+
type="number"
135
+
className="w-10 text-right appearance-none bg-transparent"
136
+
max={max}
137
+
min={min}
138
+
value={interimValue}
139
+
onChange={(e) => {
140
+
setInterimValue(parseInt(e.currentTarget.value));
141
+
}}
142
+
onKeyDown={(e) => {
143
+
if (e.key === "Enter" || e.key === "Escape") {
144
+
e.preventDefault();
145
+
let clampedValue = interimValue;
146
+
if (!isNaN(interimValue)) {
147
+
clampedValue = Math.max(
148
+
min,
149
+
Math.min(max, interimValue),
150
+
);
151
+
setInterimValue(clampedValue);
152
+
}
153
+
setPageWidth(clampedValue);
154
+
}
155
+
}}
156
+
onBlur={() => {
157
+
let clampedValue = interimValue;
158
+
if (!isNaN(interimValue)) {
159
+
clampedValue = Math.max(
160
+
min,
161
+
Math.min(max, interimValue),
162
+
);
163
+
setInterimValue(clampedValue);
164
+
}
165
+
setPageWidth(clampedValue);
166
+
}}
167
+
/>
168
+
px
169
+
</div>
170
+
</div>
171
+
<Slider.Root
172
+
className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`}
173
+
value={[interimValue]}
174
+
max={max}
175
+
min={min}
176
+
step={16}
177
+
onValueChange={(value) => {
178
+
setInterimValue(value[0]);
179
+
}}
180
+
onValueCommit={(value) => {
181
+
setPageWidth(value[0]);
182
+
}}
183
+
>
184
+
<Slider.Track
185
+
className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`}
186
+
/>
187
+
<Slider.Thumb
188
+
className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer
189
+
${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"}
190
+
`}
191
+
aria-label="Max Page Width"
192
+
/>
193
+
</Slider.Root>
194
+
</div>
195
+
</Radio>
196
+
</label>
197
+
</div>
198
+
)}
199
+
</div>
200
+
);
201
+
};
+2
-2
components/ThemeManager/PubPickers/PubTextPickers.tsx
+2
-2
components/ThemeManager/PubPickers/PubTextPickers.tsx
···
1
1
import { pickers } from "../ThemeSetter";
2
-
import { PageTextPicker } from "../Pickers/PageThemePickers";
2
+
import { TextPickers } from "../Pickers/PageThemePickers";
3
3
import { Color } from "react-aria-components";
4
4
5
5
export const PagePickers = (props: {
···
20
20
: "transparent",
21
21
}}
22
22
>
23
-
<PageTextPicker
23
+
<TextPickers
24
24
value={props.primary}
25
25
setValue={props.setPrimary}
26
26
openPicker={props.openPicker}
+41
-8
components/ThemeManager/PubThemeSetter.tsx
+41
-8
components/ThemeManager/PubThemeSetter.tsx
···
15
15
import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
16
16
import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17
17
import { Separator } from "components/Layout";
18
-
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings";
18
+
import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings";
19
19
import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
20
+
import { useToaster } from "components/Toast";
21
+
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
22
+
import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter";
20
23
21
24
export type ImageState = {
22
25
src: string;
···
54
57
}
55
58
: null,
56
59
);
57
-
60
+
let [pageWidth, setPageWidth] = useState<number>(
61
+
record?.theme?.pageWidth || 624,
62
+
);
58
63
let pubBGImage = image?.src || null;
59
64
let leafletBGRepeat = image?.repeat || null;
65
+
let toaster = useToaster();
60
66
61
67
return (
62
-
<BaseThemeProvider local {...localPubTheme}>
68
+
<BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}>
63
69
<form
64
70
onSubmit={async (e) => {
65
71
e.preventDefault();
···
75
81
: ColorToRGB(localPubTheme.bgLeaflet),
76
82
backgroundRepeat: image?.repeat,
77
83
backgroundImage: image ? image.file : null,
84
+
pageWidth: pageWidth,
78
85
primary: ColorToRGB(localPubTheme.primary),
79
86
accentBackground: ColorToRGB(localPubTheme.accent1),
80
87
accentText: ColorToRGB(localPubTheme.accent2),
81
88
},
82
89
});
90
+
91
+
if (!result.success) {
92
+
props.setLoading(false);
93
+
if (result.error && isOAuthSessionError(result.error)) {
94
+
toaster({
95
+
content: <OAuthErrorMessage error={result.error} />,
96
+
type: "error",
97
+
});
98
+
} else {
99
+
toaster({
100
+
content: "Failed to update theme",
101
+
type: "error",
102
+
});
103
+
}
104
+
return;
105
+
}
106
+
83
107
mutate((pub) => {
84
-
if (result?.publication && pub?.publication)
108
+
if (result.publication && pub?.publication)
85
109
return {
86
110
...pub,
87
111
publication: { ...pub.publication, ...result.publication },
···
96
120
setLoadingAction={props.setLoading}
97
121
backToMenuAction={props.backToMenu}
98
122
state={"theme"}
99
-
/>
123
+
>
124
+
Theme and Layout
125
+
</PubSettingsHeader>
100
126
</form>
101
127
102
-
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 ">
103
-
<div className="themeBGLeaflet flex">
128
+
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 mt-2 ">
129
+
<PubPageWidthSetter
130
+
pageWidth={pageWidth}
131
+
setPageWidth={setPageWidth}
132
+
thisPicker="page-width"
133
+
openPicker={openPicker}
134
+
setOpenPicker={setOpenPicker}
135
+
/>
136
+
<div className="themeBGLeaflet flex flex-col">
104
137
<div
105
-
className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
138
+
className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
106
139
>
107
140
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
108
141
<BackgroundPicker
+16
-5
components/ThemeManager/PublicationThemeProvider.tsx
+16
-5
components/ThemeManager/PublicationThemeProvider.tsx
···
4
4
import { useEntity } from "src/replicache";
5
5
import { getColorContrast } from "./themeUtils";
6
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
-
import { BaseThemeProvider } from "./ThemeProvider";
7
+
import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
8
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
9
9
import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
10
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
···
102
102
pub_creator: string;
103
103
isStandalone?: boolean;
104
104
}) {
105
-
let colors = usePubTheme(props.theme, props.isStandalone);
105
+
let theme = usePubTheme(props.theme, props.isStandalone);
106
+
let cardBorderHidden = !theme.showPageBackground;
107
+
let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref;
108
+
106
109
return (
107
-
<BaseThemeProvider local={props.local} {...colors}>
108
-
{props.children}
109
-
</BaseThemeProvider>
110
+
<CardBorderHiddenContext.Provider value={cardBorderHidden}>
111
+
<BaseThemeProvider
112
+
local={props.local}
113
+
{...theme}
114
+
hasBackgroundImage={hasBackgroundImage}
115
+
>
116
+
{props.children}
117
+
</BaseThemeProvider>
118
+
</CardBorderHiddenContext.Provider>
110
119
);
111
120
}
112
121
···
124
133
bgPage = bgLeaflet;
125
134
}
126
135
let showPageBackground = theme?.showPageBackground;
136
+
let pageWidth = theme?.pageWidth;
127
137
128
138
let primary = useColor(theme, "primary");
129
139
···
144
154
highlight2,
145
155
highlight3,
146
156
showPageBackground,
157
+
pageWidth,
147
158
};
148
159
};
149
160
+51
-23
components/ThemeManager/ThemeProvider.tsx
+51
-23
components/ThemeManager/ThemeProvider.tsx
···
1
1
"use client";
2
2
3
-
import {
4
-
createContext,
5
-
CSSProperties,
6
-
useContext,
7
-
useEffect,
8
-
} from "react";
3
+
import { createContext, CSSProperties, useContext, useEffect } from "react";
4
+
5
+
// Context for cardBorderHidden
6
+
export const CardBorderHiddenContext = createContext<boolean>(false);
7
+
8
+
export function useCardBorderHiddenContext() {
9
+
return useContext(CardBorderHiddenContext);
10
+
}
9
11
import {
10
12
colorToString,
11
13
useColorAttribute,
···
58
60
}) {
59
61
let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
60
62
let bgPage = useColorAttribute(props.entityID, "theme/card-background");
61
-
let showPageBackground = !useEntity(
63
+
let cardBorderHiddenValue = useEntity(
62
64
props.entityID,
63
65
"theme/card-border-hidden",
64
66
)?.data.value;
67
+
let showPageBackground = !cardBorderHiddenValue;
68
+
let backgroundImage = useEntity(props.entityID, "theme/background-image");
69
+
let hasBackgroundImage = !!backgroundImage;
65
70
let primary = useColorAttribute(props.entityID, "theme/primary");
66
71
67
72
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
71
76
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
72
77
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
73
78
79
+
let pageWidth = useEntity(props.entityID, "theme/page-width");
80
+
74
81
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>
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>
89
100
);
90
101
}
91
102
···
93
104
export const BaseThemeProvider = ({
94
105
local,
95
106
bgLeaflet,
96
-
bgPage,
107
+
bgPage: bgPageProp,
97
108
primary,
98
109
accent1,
99
110
accent2,
···
101
112
highlight2,
102
113
highlight3,
103
114
showPageBackground,
115
+
pageWidth,
116
+
hasBackgroundImage,
104
117
children,
105
118
}: {
106
119
local?: boolean;
107
120
showPageBackground?: boolean;
121
+
hasBackgroundImage?: boolean;
108
122
bgLeaflet: AriaColor;
109
123
bgPage: AriaColor;
110
124
primary: AriaColor;
···
113
127
highlight1?: string;
114
128
highlight2: AriaColor;
115
129
highlight3: AriaColor;
130
+
pageWidth?: number;
116
131
children: React.ReactNode;
117
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;
118
137
// set accent contrast to the accent color that has the highest contrast with the page background
119
138
let accentContrast;
120
139
···
191
210
"--accent-1-is-contrast",
192
211
accentContrast === accent1 ? "1" : "0",
193
212
);
213
+
214
+
// Set page width CSS variable
215
+
el?.style.setProperty(
216
+
"--page-width-setting",
217
+
(pageWidth || 624).toString(),
218
+
);
194
219
}, [
195
220
local,
196
221
bgLeaflet,
···
202
227
accent1,
203
228
accent2,
204
229
accentContrast,
230
+
pageWidth,
205
231
]);
206
232
return (
207
233
<div
···
221
247
: "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
222
248
"--highlight-2": colorToString(highlight2, "rgb"),
223
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))`,
224
253
} as CSSProperties
225
254
}
226
255
>
···
337
366
</div>
338
367
);
339
368
};
340
-
+21
-35
components/ThemeManager/ThemeSetter.tsx
+21
-35
components/ThemeManager/ThemeSetter.tsx
···
1
1
"use client";
2
2
import { Popover } from "components/Popover";
3
-
import { theme } from "../../tailwind.config";
4
3
5
4
import { Color } from "react-aria-components";
6
5
7
-
import { LeafletBGPicker } from "./Pickers/LeafletBGPicker";
8
6
import {
9
-
PageBackgroundPicker,
10
-
PageBorderHider,
7
+
LeafletBackgroundPicker,
11
8
PageThemePickers,
12
9
} from "./Pickers/PageThemePickers";
10
+
import { PageWidthSetter } from "./Pickers/PageWidthSetter";
13
11
import { useMemo, useState } from "react";
14
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
15
13
import { Replicache } from "replicache";
···
35
33
| "highlight-1"
36
34
| "highlight-2"
37
35
| "highlight-3"
38
-
| "page-background-image";
36
+
| "page-background-image"
37
+
| "page-width";
39
38
40
39
export function setColorAttribute(
41
40
rep: Replicache<ReplicacheMutators> | null,
···
75
74
return (
76
75
<>
77
76
<Popover
78
-
className="w-80 bg-white"
77
+
className="w-80 bg-white py-3!"
79
78
arrowFill="#FFFFFF"
80
79
asChild
81
80
side={isMobile ? "top" : "right"}
···
114
113
if (pub?.publications) return null;
115
114
return (
116
115
<div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar">
116
+
{!props.home && (
117
+
<PageWidthSetter
118
+
entityID={props.entityID}
119
+
thisPicker={"page-width"}
120
+
openPicker={openPicker}
121
+
setOpenPicker={setOpenPicker}
122
+
closePicker={() => setOpenPicker("null")}
123
+
/>
124
+
)}
117
125
<div className="themeBGLeaflet flex">
118
126
<div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}>
119
127
<div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md">
120
-
<LeafletBGPicker
121
-
entityID={props.entityID}
122
-
thisPicker={"leaflet"}
123
-
openPicker={openPicker}
124
-
setOpenPicker={setOpenPicker}
125
-
closePicker={() => setOpenPicker("null")}
126
-
setValue={set("theme/page-background")}
127
-
/>
128
-
<PageBackgroundPicker
129
-
entityID={props.entityID}
130
-
setValue={set("theme/card-background")}
131
-
openPicker={openPicker}
132
-
setOpenPicker={setOpenPicker}
133
-
home={props.home}
134
-
/>
135
-
<hr className=" border-[#CCCCCC]" />
136
-
<PageBorderHider
128
+
<LeafletBackgroundPicker
137
129
entityID={props.entityID}
138
130
openPicker={openPicker}
139
131
setOpenPicker={setOpenPicker}
···
173
165
setOpenPicker={(pickers) => setOpenPicker(pickers)}
174
166
/>
175
167
<SectionArrow
176
-
fill={theme.colors["accent-2"]}
177
-
stroke={theme.colors["accent-1"]}
168
+
fill="rgb(var(--accent-2))"
169
+
stroke="rgb(var(--accent-1))"
178
170
className="ml-2"
179
171
/>
180
172
</div>
···
209
201
return (
210
202
<div className="flex gap-2 items-start mt-0.5">
211
203
<Toggle
212
-
toggleOn={!!checked?.data.value}
213
-
setToggleOn={() => {
204
+
toggle={!!checked?.data.value}
205
+
onToggle={() => {
214
206
handleToggle();
215
207
}}
216
208
disabledColor1="#8C8C8C"
217
209
disabledColor2="#DBDBDB"
218
-
/>
219
-
<button
220
-
className="flex gap-2 items-center -mt-0.5"
221
-
onClick={() => {
222
-
handleToggle();
223
-
}}
224
210
>
225
-
<div className="flex flex-col gap-0 items-start">
211
+
<div className="flex flex-col gap-0 items-start ">
226
212
<div className="font-bold">Show Leaflet Watermark</div>
227
213
<div className="text-sm text-[#969696]">Help us spread the word!</div>
228
214
</div>
229
-
</button>
215
+
</Toggle>
230
216
</div>
231
217
);
232
218
}
+32
-20
components/Toggle.tsx
+32
-20
components/Toggle.tsx
···
1
1
import { theme } from "tailwind.config";
2
2
3
3
export const Toggle = (props: {
4
-
toggleOn: boolean;
5
-
setToggleOn: (s: boolean) => void;
4
+
toggle: boolean;
5
+
onToggle: () => void;
6
6
disabledColor1?: string;
7
7
disabledColor2?: string;
8
+
children: React.ReactNode;
8
9
}) => {
9
10
return (
10
11
<button
11
-
className="toggle selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border"
12
-
style={{
13
-
border: props.toggleOn
14
-
? "1px solid " + theme.colors["accent-2"]
15
-
: "1px solid " + props.disabledColor2 || theme.colors["border-light"],
16
-
justifyContent: props.toggleOn ? "flex-end" : "flex-start",
17
-
background: props.toggleOn
18
-
? theme.colors["accent-1"]
19
-
: props.disabledColor1 || theme.colors["tertiary"],
12
+
type="button"
13
+
className="toggle flex gap-2 items-start justify-start text-left"
14
+
onClick={() => {
15
+
props.onToggle();
20
16
}}
21
-
onClick={() => props.setToggleOn(!props.toggleOn)}
22
17
>
23
-
<div
24
-
className="h-[14px] w-[10px] m-0.5 rounded-[2px]"
25
-
style={{
26
-
background: props.toggleOn
27
-
? theme.colors["accent-2"]
28
-
: props.disabledColor2 || theme.colors["border-light"],
29
-
}}
30
-
/>
18
+
<div className="h-6 flex place-items-center">
19
+
<div
20
+
className="selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border"
21
+
style={{
22
+
border: props.toggle
23
+
? "1px solid " + theme.colors["accent-2"]
24
+
: "1px solid " + props.disabledColor2 ||
25
+
theme.colors["border-light"],
26
+
justifyContent: props.toggle ? "flex-end" : "flex-start",
27
+
background: props.toggle
28
+
? theme.colors["accent-1"]
29
+
: props.disabledColor1 || theme.colors["tertiary"],
30
+
}}
31
+
>
32
+
<div
33
+
className="h-[14px] w-[10px] m-0.5 rounded-[2px]"
34
+
style={{
35
+
background: props.toggle
36
+
? theme.colors["accent-2"]
37
+
: props.disabledColor2 || theme.colors["border-light"],
38
+
}}
39
+
/>
40
+
</div>
41
+
</div>
42
+
{props.children}
31
43
</button>
32
44
);
33
45
};
+2
-1
components/Toolbar/BlockToolbar.tsx
+2
-1
components/Toolbar/BlockToolbar.tsx
···
5
5
import { useUIState } from "src/useUIState";
6
6
import { LockBlockButton } from "./LockBlockButton";
7
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
-
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
8
+
import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar";
9
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
10
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
11
···
44
44
<TextAlignmentButton setToolbarState={props.setToolbarState} />
45
45
<ImageFullBleedButton />
46
46
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
+
<ImageCoverButton />
47
48
{focusedEntityType?.data.value !== "canvas" && (
48
49
<Separator classname="h-6" />
49
50
)}
+37
components/Toolbar/ImageToolbar.tsx
+37
components/Toolbar/ImageToolbar.tsx
···
4
4
import { useUIState } from "src/useUIState";
5
5
import { Props } from "components/Icons/Props";
6
6
import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt";
7
+
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
8
+
import { useSubscribe } from "src/replicache/useSubscribe";
9
+
import { ImageCoverImage } from "components/Icons/ImageCoverImage";
7
10
8
11
export const ImageFullBleedButton = (props: {}) => {
9
12
let { rep } = useReplicache();
···
76
79
) : (
77
80
<ImageRemoveAltSmall />
78
81
)}
82
+
</ToolbarButton>
83
+
);
84
+
};
85
+
86
+
export const ImageCoverButton = () => {
87
+
let { rep } = useReplicache();
88
+
let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null;
89
+
let hasSrc = useEntity(focusedBlock, "block/image")?.data;
90
+
let { data: pubData } = useLeafletPublicationData();
91
+
let coverImage = useSubscribe(rep, (tx) =>
92
+
tx.get<string | null>("publication_cover_image"),
93
+
);
94
+
95
+
// Only show if in a publication and has an image
96
+
if (!pubData?.publications || !hasSrc) return null;
97
+
98
+
let isCoverImage = coverImage === focusedBlock;
99
+
100
+
return (
101
+
<ToolbarButton
102
+
active={isCoverImage}
103
+
onClick={async (e) => {
104
+
e.preventDefault();
105
+
if (rep && focusedBlock) {
106
+
await rep.mutate.updatePublicationDraft({
107
+
cover_image: isCoverImage ? null : focusedBlock,
108
+
});
109
+
}
110
+
}}
111
+
tooltipContent={
112
+
<div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div>
113
+
}
114
+
>
115
+
<ImageCoverImage />
79
116
</ToolbarButton>
80
117
);
81
118
};
+1
-1
components/Tooltip.tsx
+1
-1
components/Tooltip.tsx
+18
lexicons/api/lexicons.ts
+18
lexicons/api/lexicons.ts
···
1447
1447
maxLength: 50,
1448
1448
},
1449
1449
},
1450
+
coverImage: {
1451
+
type: 'blob',
1452
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
1453
+
maxSize: 1000000,
1454
+
},
1450
1455
pages: {
1451
1456
type: 'array',
1452
1457
items: {
···
1801
1806
type: 'boolean',
1802
1807
default: true,
1803
1808
},
1809
+
showMentions: {
1810
+
type: 'boolean',
1811
+
default: true,
1812
+
},
1813
+
showPrevNext: {
1814
+
type: 'boolean',
1815
+
default: true,
1816
+
},
1804
1817
},
1805
1818
},
1806
1819
theme: {
···
1816
1829
backgroundImage: {
1817
1830
type: 'ref',
1818
1831
ref: 'lex:pub.leaflet.theme.backgroundImage',
1832
+
},
1833
+
pageWidth: {
1834
+
type: 'integer',
1835
+
minimum: 0,
1836
+
maximum: 1600,
1819
1837
},
1820
1838
primary: {
1821
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
37
$type?: 'pub.leaflet.publication#preferences'
38
38
showInDiscover: boolean
39
39
showComments: boolean
40
+
showMentions: boolean
41
+
showPrevNext: boolean
40
42
}
41
43
42
44
const hashPreferences = 'preferences'
···
56
58
| $Typed<PubLeafletThemeColor.Rgb>
57
59
| { $type: string }
58
60
backgroundImage?: PubLeafletThemeBackgroundImage.Main
61
+
pageWidth?: number
59
62
primary?:
60
63
| $Typed<PubLeafletThemeColor.Rgba>
61
64
| $Typed<PubLeafletThemeColor.Rgb>
+2
lexicons/build.ts
+2
lexicons/build.ts
···
9
9
import * as path from "path";
10
10
import { PubLeafletRichTextFacet } from "./src/facet";
11
11
import { PubLeafletComment } from "./src/comment";
12
+
import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions";
12
13
13
14
const outdir = path.join("lexicons", "pub", "leaflet");
14
15
···
21
22
PubLeafletDocument,
22
23
PubLeafletComment,
23
24
PubLeafletRichTextFacet,
25
+
PubLeafletAuthFullPermissions,
24
26
PageLexicons.PubLeafletPagesLinearDocument,
25
27
PageLexicons.PubLeafletPagesCanvasDocument,
26
28
...ThemeLexicons,
+44
lexicons/fix-extensions.ts
+44
lexicons/fix-extensions.ts
···
1
+
import * as fs from "fs";
2
+
import * as path from "path";
3
+
4
+
/**
5
+
* Recursively processes all files in a directory and removes .js extensions from imports
6
+
*/
7
+
function fixExtensionsInDirectory(dir: string): void {
8
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
9
+
10
+
for (const entry of entries) {
11
+
const fullPath = path.join(dir, entry.name);
12
+
13
+
if (entry.isDirectory()) {
14
+
fixExtensionsInDirectory(fullPath);
15
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
16
+
fixExtensionsInFile(fullPath);
17
+
}
18
+
}
19
+
}
20
+
21
+
/**
22
+
* Removes .js extensions from import/export statements in a file
23
+
*/
24
+
function fixExtensionsInFile(filePath: string): void {
25
+
const content = fs.readFileSync(filePath, "utf-8");
26
+
const fixedContent = content.replace(/\.js'/g, "'");
27
+
28
+
if (content !== fixedContent) {
29
+
fs.writeFileSync(filePath, fixedContent, "utf-8");
30
+
console.log(`Fixed: ${filePath}`);
31
+
}
32
+
}
33
+
34
+
// Get the directory to process from command line arguments
35
+
const targetDir = process.argv[2] || "./lexicons/api";
36
+
37
+
if (!fs.existsSync(targetDir)) {
38
+
console.error(`Directory not found: ${targetDir}`);
39
+
process.exit(1);
40
+
}
41
+
42
+
console.log(`Fixing extensions in: ${targetDir}`);
43
+
fixExtensionsInDirectory(targetDir);
44
+
console.log("Done!");
+30
lexicons/pub/leaflet/authFullPermissions.json
+30
lexicons/pub/leaflet/authFullPermissions.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "pub.leaflet.authFullPermissions",
4
+
"defs": {
5
+
"main": {
6
+
"type": "permission-set",
7
+
"title": "Full Leaflet Permissions",
8
+
"detail": "Manage creating and updating leaflet documents and publications and all interactions on them.",
9
+
"permissions": [
10
+
{
11
+
"type": "permission",
12
+
"resource": "repo",
13
+
"action": [
14
+
"create",
15
+
"update",
16
+
"delete"
17
+
],
18
+
"collection": [
19
+
"pub.leaflet.document",
20
+
"pub.leaflet.publication",
21
+
"pub.leaflet.comment",
22
+
"pub.leaflet.poll.definition",
23
+
"pub.leaflet.poll.vote",
24
+
"pub.leaflet.graph.subscription"
25
+
]
26
+
}
27
+
]
28
+
}
29
+
}
30
+
}
+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
51
"showComments": {
52
52
"type": "boolean",
53
53
"default": true
54
+
},
55
+
"showMentions": {
56
+
"type": "boolean",
57
+
"default": true
58
+
},
59
+
"showPrevNext": {
60
+
"type": "boolean",
61
+
"default": true
54
62
}
55
63
}
56
64
},
···
67
75
"backgroundImage": {
68
76
"type": "ref",
69
77
"ref": "pub.leaflet.theme.backgroundImage"
78
+
},
79
+
"pageWidth": {
80
+
"type": "integer",
81
+
"minimum": 0,
82
+
"maximum": 1600
70
83
},
71
84
"primary": {
72
85
"type": "union",
+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
24
author: { type: "string", format: "at-identifier" },
25
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
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
+
},
27
32
pages: {
28
33
type: "array",
29
34
items: {
+7
lexicons/src/publication.ts
+7
lexicons/src/publication.ts
···
27
27
properties: {
28
28
showInDiscover: { type: "boolean", default: true },
29
29
showComments: { type: "boolean", default: true },
30
+
showMentions: { type: "boolean", default: true },
31
+
showPrevNext: { type: "boolean", default: false },
30
32
},
31
33
},
32
34
theme: {
···
36
38
backgroundImage: {
37
39
type: "ref",
38
40
ref: PubLeafletThemeBackgroundImage.id,
41
+
},
42
+
pageWidth: {
43
+
type: "integer",
44
+
minimum: 0,
45
+
maximum: 1600,
39
46
},
40
47
primary: ColorUnion,
41
48
pageBackground: ColorUnion,
+1
-1
package.json
+1
-1
package.json
···
7
7
"dev": "TZ=UTC next dev --turbo",
8
8
"publish-lexicons": "tsx lexicons/publish.ts",
9
9
"generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
10
-
"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;",
10
+
"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api",
11
11
"wrangler-dev": "wrangler dev",
12
12
"build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node",
13
13
"build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
+27
src/atproto-oauth.ts
+27
src/atproto-oauth.ts
···
3
3
NodeSavedSession,
4
4
NodeSavedState,
5
5
RuntimeLock,
6
+
OAuthSession,
6
7
} from "@atproto/oauth-client-node";
7
8
import { JoseKey } from "@atproto/jwk-jose";
8
9
import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata";
···
10
11
11
12
import Client from "ioredis";
12
13
import Redlock from "redlock";
14
+
import { Result, Ok, Err } from "./result";
13
15
export async function createOauthClient() {
14
16
let keyset =
15
17
process.env.NODE_ENV === "production"
···
90
92
.eq("key", key);
91
93
},
92
94
};
95
+
96
+
export type OAuthSessionError = {
97
+
type: "oauth_session_expired";
98
+
message: string;
99
+
did: string;
100
+
};
101
+
102
+
export async function restoreOAuthSession(
103
+
did: string
104
+
): Promise<Result<OAuthSession, OAuthSessionError>> {
105
+
try {
106
+
const oauthClient = await createOauthClient();
107
+
const session = await oauthClient.restore(did);
108
+
return Ok(session);
109
+
} catch (error) {
110
+
return Err({
111
+
type: "oauth_session_expired",
112
+
message:
113
+
error instanceof Error
114
+
? error.message
115
+
: "OAuth session expired or invalid",
116
+
did,
117
+
});
118
+
}
119
+
}
-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
319
await supabase.storage
320
320
.from("minilink-user-assets")
321
321
.remove([paths[paths.length - 1]]);
322
+
323
+
// Clear cover image if this block is the cover image
324
+
// First try leaflets_in_publications
325
+
const { data: pubResult } = await supabase
326
+
.from("leaflets_in_publications")
327
+
.update({ cover_image: null })
328
+
.eq("leaflet", ctx.permission_token_id)
329
+
.eq("cover_image", block.blockEntity)
330
+
.select("leaflet");
331
+
332
+
// If no rows updated, try leaflets_to_documents
333
+
if (!pubResult || pubResult.length === 0) {
334
+
await supabase
335
+
.from("leaflets_to_documents")
336
+
.update({ cover_image: null })
337
+
.eq("leaflet", ctx.permission_token_id)
338
+
.eq("cover_image", block.blockEntity);
339
+
}
322
340
}
323
341
});
324
-
await ctx.runOnClient(async () => {
342
+
await ctx.runOnClient(async ({ tx }) => {
325
343
let cache = await caches.open("minilink-user-assets");
326
344
if (image) {
327
345
await cache.delete(image.data.src + "?local");
346
+
347
+
// Clear cover image in client state if this block was the cover image
348
+
let currentCoverImage = await tx.get("publication_cover_image");
349
+
if (currentCoverImage === block.blockEntity) {
350
+
await tx.set("publication_cover_image", null);
351
+
}
328
352
}
329
353
});
330
354
await ctx.deleteEntity(block.blockEntity);
···
612
636
title?: string;
613
637
description?: string;
614
638
tags?: string[];
639
+
cover_image?: string | null;
615
640
}> = async (args, ctx) => {
616
641
await ctx.runOnServer(async (serverCtx) => {
617
642
console.log("updating");
···
619
644
description?: string;
620
645
title?: string;
621
646
tags?: string[];
647
+
cover_image?: string | null;
622
648
} = {};
623
649
if (args.description !== undefined) updates.description = args.description;
624
650
if (args.title !== undefined) updates.title = args.title;
625
651
if (args.tags !== undefined) updates.tags = args.tags;
652
+
if (args.cover_image !== undefined) updates.cover_image = args.cover_image;
626
653
627
654
if (Object.keys(updates).length > 0) {
628
655
// First try to update leaflets_in_publications (for publications)
···
648
675
if (args.description !== undefined)
649
676
await tx.set("publication_description", args.description);
650
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);
651
680
});
652
681
};
653
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
556
atp_did?: string | null
557
557
created_at?: string
558
558
email?: string | null
559
-
home_page: string
559
+
home_page?: string
560
560
id?: string
561
561
interface_state?: Json | null
562
562
}
···
581
581
leaflets_in_publications: {
582
582
Row: {
583
583
archived: boolean | null
584
+
cover_image: string | null
584
585
description: string
585
586
doc: string | null
586
587
leaflet: string
···
589
590
}
590
591
Insert: {
591
592
archived?: boolean | null
593
+
cover_image?: string | null
592
594
description?: string
593
595
doc?: string | null
594
596
leaflet: string
···
597
599
}
598
600
Update: {
599
601
archived?: boolean | null
602
+
cover_image?: string | null
600
603
description?: string
601
604
doc?: string | null
602
605
leaflet?: string
···
629
632
}
630
633
leaflets_to_documents: {
631
634
Row: {
635
+
cover_image: string | null
632
636
created_at: string
633
637
description: string
634
638
document: string
···
636
640
title: string
637
641
}
638
642
Insert: {
643
+
cover_image?: string | null
639
644
created_at?: string
640
645
description?: string
641
646
document: string
···
643
648
title?: string
644
649
}
645
650
Update: {
651
+
cover_image?: string | null
646
652
created_at?: string
647
653
description?: string
648
654
document?: string
···
1112
1118
[_ in never]: never
1113
1119
}
1114
1120
Functions: {
1121
+
create_identity_homepage: {
1122
+
Args: Record<PropertyKey, never>
1123
+
Returns: string
1124
+
}
1115
1125
get_facts: {
1116
1126
Args: {
1117
1127
root: string
+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";