-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}
+42
-6
app/[leaflet_id]/actions/PublishButton.tsx
+42
-6
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: (
+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
+
};
+1
-2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+1
-2
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
}
+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(),
+15
-99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+15
-99
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;
···
109
110
document: string;
110
111
comment: Comment;
111
112
comments: Comment[];
112
-
profile?: AppBskyActorProfile.Record;
113
+
profile: AppBskyActorProfile.Record;
113
114
record: PubLeafletComment.Record;
114
115
pageId?: string;
115
116
}) => {
117
+
const did = props.comment.bsky_profiles?.did;
118
+
116
119
return (
117
-
<div className="comment">
120
+
<div id={props.comment.uri} className="comment">
118
121
<div className="flex gap-2">
119
-
{props.profile && (
120
-
<ProfilePopover profile={props.profile} comment={props.comment.uri} />
122
+
{did && (
123
+
<ProfilePopover
124
+
didOrHandle={did}
125
+
trigger={
126
+
<div className="text-sm text-tertiary font-bold hover:underline">
127
+
{props.profile.displayName}
128
+
</div>
129
+
}
130
+
/>
121
131
)}
122
-
<DatePopover date={props.record.createdAt} />
123
132
</div>
124
133
{props.record.attachment &&
125
134
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
291
300
</Popover>
292
301
);
293
302
};
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
-
};
+4
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+4
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
58
58
export const useDrawerOpen = (uri: string) => {
59
59
let params = useSearchParams();
60
60
let interactionDrawerSearchParam = params.get("interactionDrawer");
61
+
let pageParam = params.get("page");
61
62
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
62
63
if (open === false || (open === undefined && !interactionDrawerSearchParam))
63
64
return null;
64
65
drawer =
65
66
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
66
-
return { drawer, pageId };
67
+
// Use pageId from state, or fall back to page search param
68
+
const resolvedPageId = pageId ?? pageParam ?? undefined;
69
+
return { drawer, pageId: resolvedPageId };
67
70
};
+1
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+1
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+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
);
+1
-2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+1
-2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
61
61
<PageWrapper
62
62
pageType="doc"
63
63
fullPageScroll={fullPageScroll}
64
-
cardBorderHidden={!hasPageBackground}
65
-
id={pageId ? `post-page-${pageId}` : "post-page"}
64
+
id={`post-page-${pageId ?? document_uri}`}
66
65
drawerOpen={
67
66
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
68
67
}
+12
-11
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+12
-11
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;
···
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
<>
···
107
108
}) => {
108
109
return (
109
110
<div
110
-
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
+
className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
112
id="post-header"
112
113
>
113
114
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
···
119
120
{props.postTitle ? props.postTitle : "Untitled"}
120
121
</h2>
121
122
{props.postDescription ? (
122
-
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
+
<div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
124
{props.postDescription}
124
-
</p>
125
+
</div>
125
126
) : null}
126
127
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
128
{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
+
}
+27
-8
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+27
-8
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 = (
···
293
295
);
294
296
}
295
297
298
+
// Handle quotes pages
299
+
if (openPage.type === "quotes") {
300
+
return (
301
+
<Fragment key={pageKey}>
302
+
<SandwichSpacer />
303
+
<BlueskyQuotesPage
304
+
postUri={openPage.uri}
305
+
pageId={pageKey}
306
+
hasPageBackground={hasPageBackground}
307
+
pageOptions={
308
+
<PageOptions
309
+
onClick={() => closePage(openPage)}
310
+
hasPageBackground={hasPageBackground}
311
+
/>
312
+
}
313
+
/>
314
+
</Fragment>
315
+
);
316
+
}
317
+
296
318
// Handle document pages
297
319
let page = record.pages.find(
298
320
(p) =>
···
352
374
return (
353
375
<div
354
376
className={`pageOptions w-fit z-10
355
-
absolute sm:-right-[20px] right-3 sm:top-3 top-0
377
+
absolute sm:-right-[19px] right-3 sm:top-3 top-0
356
378
flex sm:flex-col flex-row-reverse gap-1 items-start`}
357
379
>
358
-
<PageOptionButton
359
-
cardBorderHidden={!props.hasPageBackground}
360
-
onClick={props.onClick}
361
-
>
380
+
<PageOptionButton onClick={props.onClick}>
362
381
<CloseTiny />
363
382
</PageOptionButton>
364
383
</div>
+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
)}
+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
-
};
+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";
+1
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+1
-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";
+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
}
+102
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+102
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(true);
26
+
let [showPrevNext, setShowPrevNext] = useState(true);
27
+
28
+
let toast = useToaster();
29
+
return (
30
+
<form
31
+
onSubmit={async (e) => {
32
+
// if (!pubData) return;
33
+
// e.preventDefault();
34
+
// props.setLoading(true);
35
+
// let data = await updatePublication({
36
+
// uri: pubData.uri,
37
+
// name: nameValue,
38
+
// description: descriptionValue,
39
+
// iconFile: iconFile,
40
+
// preferences: {
41
+
// showInDiscover: showInDiscover,
42
+
// showComments: showComments,
43
+
// },
44
+
// });
45
+
// toast({ type: "success", content: "Posts Updated!" });
46
+
// props.setLoading(false);
47
+
// mutate("publication-data");
48
+
}}
49
+
className="text-primary flex flex-col"
50
+
>
51
+
<PubSettingsHeader
52
+
loading={props.loading}
53
+
setLoadingAction={props.setLoading}
54
+
backToMenuAction={props.backToMenu}
55
+
state={"post-options"}
56
+
>
57
+
Post Options
58
+
</PubSettingsHeader>
59
+
<h4 className="mb-1">Layout</h4>
60
+
{/*<div>Max Post Width</div>*/}
61
+
<Toggle
62
+
toggle={showPrevNext}
63
+
onToggle={() => {
64
+
setShowPrevNext(!showPrevNext);
65
+
}}
66
+
>
67
+
<div className="flex flex-col justify-start">
68
+
<div className="font-bold">Show Prev/Next Buttons</div>
69
+
<div className="text-tertiary text-sm leading-tight">
70
+
Show buttons that navigate to the previous and next posts
71
+
</div>
72
+
</div>
73
+
</Toggle>
74
+
<hr className="my-2 border-border-light" />
75
+
<h4 className="mb-1">Interactions</h4>
76
+
<div className="flex flex-col gap-2">
77
+
<Toggle
78
+
toggle={showComments}
79
+
onToggle={() => {
80
+
setShowComments(!showComments);
81
+
}}
82
+
>
83
+
<div className="font-bold">Show Comments</div>
84
+
</Toggle>
85
+
86
+
<Toggle
87
+
toggle={showMentions}
88
+
onToggle={() => {
89
+
setShowMentions(!showMentions);
90
+
}}
91
+
>
92
+
<div className="flex flex-col justify-start">
93
+
<div className="font-bold">Show Mentions</div>
94
+
<div className="text-tertiary text-sm leading-tight">
95
+
Display a list of posts on Bluesky that mention your post
96
+
</div>
97
+
</div>
98
+
</Toggle>
99
+
</div>
100
+
</form>
101
+
);
102
+
};
+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
+
};
+7
-10
app/lish/[did]/[publication]/page.tsx
+7
-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}
173
170
/>
174
171
</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
}
+34
-12
app/lish/createPub/CreatePubForm.tsx
+34
-12
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
56
preferences: { showInDiscover, showComments: true },
52
57
});
58
+
59
+
if (!result.success) {
60
+
setFormState("normal");
61
+
if (result.error && isOAuthSessionError(result.error)) {
62
+
setOauthError(result.error);
63
+
}
64
+
return;
65
+
}
66
+
53
67
// Show a spinner while this is happening! Maybe a progress bar?
54
68
setTimeout(() => {
55
69
setFormState("normal");
56
-
if (data?.publication)
57
-
router.push(`${getBasePublicationURL(data.publication)}/dashboard`);
70
+
if (result.publication)
71
+
router.push(`${getBasePublicationURL(result.publication)}/dashboard`);
58
72
}, 500);
59
73
}}
60
74
>
···
139
153
</Checkbox>
140
154
<hr className="border-border-light" />
141
155
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>
156
+
<div className="flex flex-col gap-2">
157
+
<div className="flex w-full justify-end">
158
+
<ButtonPrimary
159
+
type="submit"
160
+
disabled={
161
+
!nameValue || !domainValue || domainState.status !== "valid"
162
+
}
163
+
>
164
+
{formState === "loading" ? <DotLoader /> : "Create Publication!"}
165
+
</ButtonPrimary>
166
+
</div>
167
+
{oauthError && (
168
+
<OAuthErrorMessage
169
+
error={oauthError}
170
+
className="text-right text-sm text-accent-1"
171
+
/>
172
+
)}
151
173
</div>
152
174
</form>
153
175
);
+4
-2
app/lish/createPub/UpdatePubForm.tsx
+4
-2
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
24
25
25
export const EditPubForm = (props: {
26
26
backToMenuAction: () => void;
···
86
86
setLoadingAction={props.setLoadingAction}
87
87
backToMenuAction={props.backToMenuAction}
88
88
state={"theme"}
89
-
/>
89
+
>
90
+
General Settings
91
+
</PubSettingsHeader>
90
92
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
91
93
<div className="flex items-center justify-between gap-2 ">
92
94
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
+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
);
+64
-16
app/lish/createPub/updatePublication.ts
+64
-16
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,
···
24
28
description: string;
25
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>
+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
+3
-3
components/InteractionsPreview.tsx
+3
-3
components/InteractionsPreview.tsx
···
40
40
<SpeedyLink
41
41
aria-label="Post quotes"
42
42
href={`${props.postUrl}?interactionDrawer=quotes`}
43
-
className="flex flex-row gap-1 text-sm items-center text-accent-contrast!"
43
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
44
44
>
45
45
<QuoteTiny /> {props.quotesCount}
46
46
</SpeedyLink>
···
49
49
<SpeedyLink
50
50
aria-label="Post comments"
51
51
href={`${props.postUrl}?interactionDrawer=comments`}
52
-
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
52
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
53
53
>
54
54
<CommentTiny /> {props.commentsCount}
55
55
</SpeedyLink>
···
93
93
<Popover
94
94
className="p-2! max-w-xs"
95
95
trigger={
96
-
<div className="relative flex gap-1 items-center hover:text-accent-contrast ">
96
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast">
97
97
<TagTiny /> {props.tags.length}
98
98
</div>
99
99
}
-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
}
+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
}
+43
-31
components/PostListing.tsx
+43
-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}
87
100
share
88
101
/>
89
102
</div>
···
114
127
};
115
128
116
129
const PostInfo = (props: { publishedAt: string | undefined }) => {
130
+
let localizedDate = useLocalizedDate(props.publishedAt || "", {
131
+
year: "numeric",
132
+
month: "short",
133
+
day: "numeric",
134
+
});
117
135
return (
118
136
<div className="flex gap-2 items-center shrink-0 self-start">
119
137
{props.publishedAt && (
120
138
<>
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>
139
+
<div className="shrink-0">{localizedDate}</div>
128
140
</>
129
141
)}
130
142
</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
+23
-12
components/ThemeManager/PublicationThemeProvider.tsx
+23
-12
components/ThemeManager/PublicationThemeProvider.tsx
···
2
2
import { useMemo, useState } from "react";
3
3
import { parseColor } from "react-aria-components";
4
4
import { useEntity } from "src/replicache";
5
-
import { getColorContrast } from "./themeUtils";
5
+
import { getColorDifference } 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
···
163
174
let newAccentContrast;
164
175
let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => {
165
176
return (
166
-
getColorContrast(
177
+
getColorDifference(
167
178
colorToString(b, "rgb"),
168
179
colorToString(
169
180
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
170
181
"rgb",
171
182
),
172
183
) -
173
-
getColorContrast(
184
+
getColorDifference(
174
185
colorToString(a, "rgb"),
175
186
colorToString(
176
187
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
···
180
191
);
181
192
});
182
193
if (
183
-
getColorContrast(
194
+
getColorDifference(
184
195
colorToString(sortedAccents[0], "rgb"),
185
196
colorToString(newTheme.primary, "rgb"),
186
-
) < 30 &&
187
-
getColorContrast(
197
+
) < 0.15 &&
198
+
getColorDifference(
188
199
colorToString(sortedAccents[1], "rgb"),
189
200
colorToString(
190
201
showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet,
191
202
"rgb",
192
203
),
193
-
) > 12
204
+
) > 0.08
194
205
) {
195
206
newAccentContrast = sortedAccents[1];
196
207
} else newAccentContrast = sortedAccents[0];
+60
-32
components/ThemeManager/ThemeProvider.tsx
+60
-32
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,
···
20
22
PublicationThemeProvider,
21
23
} from "./PublicationThemeProvider";
22
24
import { PubLeafletPublication } from "lexicons/api";
23
-
import { getColorContrast } from "./themeUtils";
25
+
import { getColorDifference } from "./themeUtils";
24
26
25
27
// define a function to set an Aria Color to a CSS Variable in RGB
26
28
function setCSSVariableToColor(
···
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");
···
70
75
71
76
let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
72
77
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
78
+
79
+
let pageWidth = useEntity(props.entityID, "theme/page-width");
73
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
121
140
//sorting the accents by contrast on background
122
141
let sortedAccents = [accent1, accent2].sort((a, b) => {
123
142
return (
124
-
getColorContrast(
143
+
getColorDifference(
125
144
colorToString(b, "rgb"),
126
145
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
127
146
) -
128
-
getColorContrast(
147
+
getColorDifference(
129
148
colorToString(a, "rgb"),
130
149
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
131
150
)
···
137
156
// then use the not contrasty option
138
157
139
158
if (
140
-
getColorContrast(
159
+
getColorDifference(
141
160
colorToString(sortedAccents[0], "rgb"),
142
161
colorToString(primary, "rgb"),
143
-
) < 30 &&
144
-
getColorContrast(
162
+
) < 0.15 &&
163
+
getColorDifference(
145
164
colorToString(sortedAccents[1], "rgb"),
146
165
colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
147
-
) > 12
166
+
) > 0.08
148
167
) {
149
168
accentContrast = sortedAccents[1];
150
169
} else accentContrast = sortedAccents[0];
···
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
>
···
257
286
bgPage && accent1 && accent2
258
287
? [accent1, accent2].sort((a, b) => {
259
288
return (
260
-
getColorContrast(
289
+
getColorDifference(
261
290
colorToString(b, "rgb"),
262
291
colorToString(bgPage, "rgb"),
263
292
) -
264
-
getColorContrast(
293
+
getColorDifference(
265
294
colorToString(a, "rgb"),
266
295
colorToString(bgPage, "rgb"),
267
296
)
···
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
}
+4
-3
components/ThemeManager/themeUtils.ts
+4
-3
components/ThemeManager/themeUtils.ts
···
1
-
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
1
+
import { parse, ColorSpace, sRGB, distance, OKLab } from "colorjs.io/fn";
2
2
3
3
// define the color defaults for everything
4
4
export const ThemeDefaults = {
···
17
17
};
18
18
19
19
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
20
-
export function getColorContrast(color1: string, color2: string) {
20
+
export function getColorDifference(color1: string, color2: string) {
21
21
ColorSpace.register(sRGB);
22
+
ColorSpace.register(OKLab);
22
23
23
24
let parsedColor1 = parse(`rgb(${color1})`);
24
25
let parsedColor2 = parse(`rgb(${color2})`);
25
26
26
-
return contrastLstar(parsedColor1, parsedColor2);
27
+
return distance(parsedColor1, parsedColor2, "oklab");
27
28
}
+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
+834
-824
lexicons/api/lexicons.ts
+834
-824
lexicons/api/lexicons.ts
···
6
6
Lexicons,
7
7
ValidationError,
8
8
type ValidationResult,
9
-
} from '@atproto/lexicon'
10
-
import { type $Typed, is$typed, maybe$typed } from './util'
9
+
} from "@atproto/lexicon";
10
+
import { type $Typed, is$typed, maybe$typed } from "./util";
11
11
12
12
export const schemaDict = {
13
13
AppBskyActorProfile: {
14
14
lexicon: 1,
15
-
id: 'app.bsky.actor.profile',
15
+
id: "app.bsky.actor.profile",
16
16
defs: {
17
17
main: {
18
-
type: 'record',
19
-
description: 'A declaration of a Bluesky account profile.',
20
-
key: 'literal:self',
18
+
type: "record",
19
+
description: "A declaration of a Bluesky account profile.",
20
+
key: "literal:self",
21
21
record: {
22
-
type: 'object',
22
+
type: "object",
23
23
properties: {
24
24
displayName: {
25
-
type: 'string',
25
+
type: "string",
26
26
maxGraphemes: 64,
27
27
maxLength: 640,
28
28
},
29
29
description: {
30
-
type: 'string',
31
-
description: 'Free-form profile description text.',
30
+
type: "string",
31
+
description: "Free-form profile description text.",
32
32
maxGraphemes: 256,
33
33
maxLength: 2560,
34
34
},
35
35
avatar: {
36
-
type: 'blob',
36
+
type: "blob",
37
37
description:
38
38
"Small image to be displayed next to posts from account. AKA, 'profile picture'",
39
-
accept: ['image/png', 'image/jpeg'],
39
+
accept: ["image/png", "image/jpeg"],
40
40
maxSize: 1000000,
41
41
},
42
42
banner: {
43
-
type: 'blob',
43
+
type: "blob",
44
44
description:
45
-
'Larger horizontal image to display behind profile view.',
46
-
accept: ['image/png', 'image/jpeg'],
45
+
"Larger horizontal image to display behind profile view.",
46
+
accept: ["image/png", "image/jpeg"],
47
47
maxSize: 1000000,
48
48
},
49
49
labels: {
50
-
type: 'union',
50
+
type: "union",
51
51
description:
52
-
'Self-label values, specific to the Bluesky application, on the overall account.',
53
-
refs: ['lex:com.atproto.label.defs#selfLabels'],
52
+
"Self-label values, specific to the Bluesky application, on the overall account.",
53
+
refs: ["lex:com.atproto.label.defs#selfLabels"],
54
54
},
55
55
joinedViaStarterPack: {
56
-
type: 'ref',
57
-
ref: 'lex:com.atproto.repo.strongRef',
56
+
type: "ref",
57
+
ref: "lex:com.atproto.repo.strongRef",
58
58
},
59
59
pinnedPost: {
60
-
type: 'ref',
61
-
ref: 'lex:com.atproto.repo.strongRef',
60
+
type: "ref",
61
+
ref: "lex:com.atproto.repo.strongRef",
62
62
},
63
63
createdAt: {
64
-
type: 'string',
65
-
format: 'datetime',
64
+
type: "string",
65
+
format: "datetime",
66
66
},
67
67
},
68
68
},
···
71
71
},
72
72
ComAtprotoLabelDefs: {
73
73
lexicon: 1,
74
-
id: 'com.atproto.label.defs',
74
+
id: "com.atproto.label.defs",
75
75
defs: {
76
76
label: {
77
-
type: 'object',
77
+
type: "object",
78
78
description:
79
-
'Metadata tag on an atproto resource (eg, repo or record).',
80
-
required: ['src', 'uri', 'val', 'cts'],
79
+
"Metadata tag on an atproto resource (eg, repo or record).",
80
+
required: ["src", "uri", "val", "cts"],
81
81
properties: {
82
82
ver: {
83
-
type: 'integer',
84
-
description: 'The AT Protocol version of the label object.',
83
+
type: "integer",
84
+
description: "The AT Protocol version of the label object.",
85
85
},
86
86
src: {
87
-
type: 'string',
88
-
format: 'did',
89
-
description: 'DID of the actor who created this label.',
87
+
type: "string",
88
+
format: "did",
89
+
description: "DID of the actor who created this label.",
90
90
},
91
91
uri: {
92
-
type: 'string',
93
-
format: 'uri',
92
+
type: "string",
93
+
format: "uri",
94
94
description:
95
-
'AT URI of the record, repository (account), or other resource that this label applies to.',
95
+
"AT URI of the record, repository (account), or other resource that this label applies to.",
96
96
},
97
97
cid: {
98
-
type: 'string',
99
-
format: 'cid',
98
+
type: "string",
99
+
format: "cid",
100
100
description:
101
101
"Optionally, CID specifying the specific version of 'uri' resource this label applies to.",
102
102
},
103
103
val: {
104
-
type: 'string',
104
+
type: "string",
105
105
maxLength: 128,
106
106
description:
107
-
'The short string name of the value or type of this label.',
107
+
"The short string name of the value or type of this label.",
108
108
},
109
109
neg: {
110
-
type: 'boolean',
110
+
type: "boolean",
111
111
description:
112
-
'If true, this is a negation label, overwriting a previous label.',
112
+
"If true, this is a negation label, overwriting a previous label.",
113
113
},
114
114
cts: {
115
-
type: 'string',
116
-
format: 'datetime',
117
-
description: 'Timestamp when this label was created.',
115
+
type: "string",
116
+
format: "datetime",
117
+
description: "Timestamp when this label was created.",
118
118
},
119
119
exp: {
120
-
type: 'string',
121
-
format: 'datetime',
120
+
type: "string",
121
+
format: "datetime",
122
122
description:
123
-
'Timestamp at which this label expires (no longer applies).',
123
+
"Timestamp at which this label expires (no longer applies).",
124
124
},
125
125
sig: {
126
-
type: 'bytes',
127
-
description: 'Signature of dag-cbor encoded label.',
126
+
type: "bytes",
127
+
description: "Signature of dag-cbor encoded label.",
128
128
},
129
129
},
130
130
},
131
131
selfLabels: {
132
-
type: 'object',
132
+
type: "object",
133
133
description:
134
-
'Metadata tags on an atproto record, published by the author within the record.',
135
-
required: ['values'],
134
+
"Metadata tags on an atproto record, published by the author within the record.",
135
+
required: ["values"],
136
136
properties: {
137
137
values: {
138
-
type: 'array',
138
+
type: "array",
139
139
items: {
140
-
type: 'ref',
141
-
ref: 'lex:com.atproto.label.defs#selfLabel',
140
+
type: "ref",
141
+
ref: "lex:com.atproto.label.defs#selfLabel",
142
142
},
143
143
maxLength: 10,
144
144
},
145
145
},
146
146
},
147
147
selfLabel: {
148
-
type: 'object',
148
+
type: "object",
149
149
description:
150
-
'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.',
151
-
required: ['val'],
150
+
"Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
151
+
required: ["val"],
152
152
properties: {
153
153
val: {
154
-
type: 'string',
154
+
type: "string",
155
155
maxLength: 128,
156
156
description:
157
-
'The short string name of the value or type of this label.',
157
+
"The short string name of the value or type of this label.",
158
158
},
159
159
},
160
160
},
161
161
labelValueDefinition: {
162
-
type: 'object',
162
+
type: "object",
163
163
description:
164
-
'Declares a label value and its expected interpretations and behaviors.',
165
-
required: ['identifier', 'severity', 'blurs', 'locales'],
164
+
"Declares a label value and its expected interpretations and behaviors.",
165
+
required: ["identifier", "severity", "blurs", "locales"],
166
166
properties: {
167
167
identifier: {
168
-
type: 'string',
168
+
type: "string",
169
169
description:
170
170
"The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
171
171
maxLength: 100,
172
172
maxGraphemes: 100,
173
173
},
174
174
severity: {
175
-
type: 'string',
175
+
type: "string",
176
176
description:
177
177
"How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
178
-
knownValues: ['inform', 'alert', 'none'],
178
+
knownValues: ["inform", "alert", "none"],
179
179
},
180
180
blurs: {
181
-
type: 'string',
181
+
type: "string",
182
182
description:
183
183
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
184
-
knownValues: ['content', 'media', 'none'],
184
+
knownValues: ["content", "media", "none"],
185
185
},
186
186
defaultSetting: {
187
-
type: 'string',
188
-
description: 'The default setting for this label.',
189
-
knownValues: ['ignore', 'warn', 'hide'],
190
-
default: 'warn',
187
+
type: "string",
188
+
description: "The default setting for this label.",
189
+
knownValues: ["ignore", "warn", "hide"],
190
+
default: "warn",
191
191
},
192
192
adultOnly: {
193
-
type: 'boolean',
193
+
type: "boolean",
194
194
description:
195
-
'Does the user need to have adult content enabled in order to configure this label?',
195
+
"Does the user need to have adult content enabled in order to configure this label?",
196
196
},
197
197
locales: {
198
-
type: 'array',
198
+
type: "array",
199
199
items: {
200
-
type: 'ref',
201
-
ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings',
200
+
type: "ref",
201
+
ref: "lex:com.atproto.label.defs#labelValueDefinitionStrings",
202
202
},
203
203
},
204
204
},
205
205
},
206
206
labelValueDefinitionStrings: {
207
-
type: 'object',
207
+
type: "object",
208
208
description:
209
-
'Strings which describe the label in the UI, localized into a specific language.',
210
-
required: ['lang', 'name', 'description'],
209
+
"Strings which describe the label in the UI, localized into a specific language.",
210
+
required: ["lang", "name", "description"],
211
211
properties: {
212
212
lang: {
213
-
type: 'string',
213
+
type: "string",
214
214
description:
215
-
'The code of the language these strings are written in.',
216
-
format: 'language',
215
+
"The code of the language these strings are written in.",
216
+
format: "language",
217
217
},
218
218
name: {
219
-
type: 'string',
220
-
description: 'A short human-readable name for the label.',
219
+
type: "string",
220
+
description: "A short human-readable name for the label.",
221
221
maxGraphemes: 64,
222
222
maxLength: 640,
223
223
},
224
224
description: {
225
-
type: 'string',
225
+
type: "string",
226
226
description:
227
-
'A longer description of what the label means and why it might be applied.',
227
+
"A longer description of what the label means and why it might be applied.",
228
228
maxGraphemes: 10000,
229
229
maxLength: 100000,
230
230
},
231
231
},
232
232
},
233
233
labelValue: {
234
-
type: 'string',
234
+
type: "string",
235
235
knownValues: [
236
-
'!hide',
237
-
'!no-promote',
238
-
'!warn',
239
-
'!no-unauthenticated',
240
-
'dmca-violation',
241
-
'doxxing',
242
-
'porn',
243
-
'sexual',
244
-
'nudity',
245
-
'nsfl',
246
-
'gore',
236
+
"!hide",
237
+
"!no-promote",
238
+
"!warn",
239
+
"!no-unauthenticated",
240
+
"dmca-violation",
241
+
"doxxing",
242
+
"porn",
243
+
"sexual",
244
+
"nudity",
245
+
"nsfl",
246
+
"gore",
247
247
],
248
248
},
249
249
},
250
250
},
251
251
ComAtprotoRepoApplyWrites: {
252
252
lexicon: 1,
253
-
id: 'com.atproto.repo.applyWrites',
253
+
id: "com.atproto.repo.applyWrites",
254
254
defs: {
255
255
main: {
256
-
type: 'procedure',
256
+
type: "procedure",
257
257
description:
258
-
'Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.',
258
+
"Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.",
259
259
input: {
260
-
encoding: 'application/json',
260
+
encoding: "application/json",
261
261
schema: {
262
-
type: 'object',
263
-
required: ['repo', 'writes'],
262
+
type: "object",
263
+
required: ["repo", "writes"],
264
264
properties: {
265
265
repo: {
266
-
type: 'string',
267
-
format: 'at-identifier',
266
+
type: "string",
267
+
format: "at-identifier",
268
268
description:
269
-
'The handle or DID of the repo (aka, current account).',
269
+
"The handle or DID of the repo (aka, current account).",
270
270
},
271
271
validate: {
272
-
type: 'boolean',
272
+
type: "boolean",
273
273
description:
274
274
"Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.",
275
275
},
276
276
writes: {
277
-
type: 'array',
277
+
type: "array",
278
278
items: {
279
-
type: 'union',
279
+
type: "union",
280
280
refs: [
281
-
'lex:com.atproto.repo.applyWrites#create',
282
-
'lex:com.atproto.repo.applyWrites#update',
283
-
'lex:com.atproto.repo.applyWrites#delete',
281
+
"lex:com.atproto.repo.applyWrites#create",
282
+
"lex:com.atproto.repo.applyWrites#update",
283
+
"lex:com.atproto.repo.applyWrites#delete",
284
284
],
285
285
closed: true,
286
286
},
287
287
},
288
288
swapCommit: {
289
-
type: 'string',
289
+
type: "string",
290
290
description:
291
-
'If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.',
292
-
format: 'cid',
291
+
"If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.",
292
+
format: "cid",
293
293
},
294
294
},
295
295
},
296
296
},
297
297
output: {
298
-
encoding: 'application/json',
298
+
encoding: "application/json",
299
299
schema: {
300
-
type: 'object',
300
+
type: "object",
301
301
required: [],
302
302
properties: {
303
303
commit: {
304
-
type: 'ref',
305
-
ref: 'lex:com.atproto.repo.defs#commitMeta',
304
+
type: "ref",
305
+
ref: "lex:com.atproto.repo.defs#commitMeta",
306
306
},
307
307
results: {
308
-
type: 'array',
308
+
type: "array",
309
309
items: {
310
-
type: 'union',
310
+
type: "union",
311
311
refs: [
312
-
'lex:com.atproto.repo.applyWrites#createResult',
313
-
'lex:com.atproto.repo.applyWrites#updateResult',
314
-
'lex:com.atproto.repo.applyWrites#deleteResult',
312
+
"lex:com.atproto.repo.applyWrites#createResult",
313
+
"lex:com.atproto.repo.applyWrites#updateResult",
314
+
"lex:com.atproto.repo.applyWrites#deleteResult",
315
315
],
316
316
closed: true,
317
317
},
···
321
321
},
322
322
errors: [
323
323
{
324
-
name: 'InvalidSwap',
324
+
name: "InvalidSwap",
325
325
description:
326
326
"Indicates that the 'swapCommit' parameter did not match current commit.",
327
327
},
328
328
],
329
329
},
330
330
create: {
331
-
type: 'object',
332
-
description: 'Operation which creates a new record.',
333
-
required: ['collection', 'value'],
331
+
type: "object",
332
+
description: "Operation which creates a new record.",
333
+
required: ["collection", "value"],
334
334
properties: {
335
335
collection: {
336
-
type: 'string',
337
-
format: 'nsid',
336
+
type: "string",
337
+
format: "nsid",
338
338
},
339
339
rkey: {
340
-
type: 'string',
340
+
type: "string",
341
341
maxLength: 512,
342
-
format: 'record-key',
342
+
format: "record-key",
343
343
description:
344
-
'NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.',
344
+
"NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.",
345
345
},
346
346
value: {
347
-
type: 'unknown',
347
+
type: "unknown",
348
348
},
349
349
},
350
350
},
351
351
update: {
352
-
type: 'object',
353
-
description: 'Operation which updates an existing record.',
354
-
required: ['collection', 'rkey', 'value'],
352
+
type: "object",
353
+
description: "Operation which updates an existing record.",
354
+
required: ["collection", "rkey", "value"],
355
355
properties: {
356
356
collection: {
357
-
type: 'string',
358
-
format: 'nsid',
357
+
type: "string",
358
+
format: "nsid",
359
359
},
360
360
rkey: {
361
-
type: 'string',
362
-
format: 'record-key',
361
+
type: "string",
362
+
format: "record-key",
363
363
},
364
364
value: {
365
-
type: 'unknown',
365
+
type: "unknown",
366
366
},
367
367
},
368
368
},
369
369
delete: {
370
-
type: 'object',
371
-
description: 'Operation which deletes an existing record.',
372
-
required: ['collection', 'rkey'],
370
+
type: "object",
371
+
description: "Operation which deletes an existing record.",
372
+
required: ["collection", "rkey"],
373
373
properties: {
374
374
collection: {
375
-
type: 'string',
376
-
format: 'nsid',
375
+
type: "string",
376
+
format: "nsid",
377
377
},
378
378
rkey: {
379
-
type: 'string',
380
-
format: 'record-key',
379
+
type: "string",
380
+
format: "record-key",
381
381
},
382
382
},
383
383
},
384
384
createResult: {
385
-
type: 'object',
386
-
required: ['uri', 'cid'],
385
+
type: "object",
386
+
required: ["uri", "cid"],
387
387
properties: {
388
388
uri: {
389
-
type: 'string',
390
-
format: 'at-uri',
389
+
type: "string",
390
+
format: "at-uri",
391
391
},
392
392
cid: {
393
-
type: 'string',
394
-
format: 'cid',
393
+
type: "string",
394
+
format: "cid",
395
395
},
396
396
validationStatus: {
397
-
type: 'string',
398
-
knownValues: ['valid', 'unknown'],
397
+
type: "string",
398
+
knownValues: ["valid", "unknown"],
399
399
},
400
400
},
401
401
},
402
402
updateResult: {
403
-
type: 'object',
404
-
required: ['uri', 'cid'],
403
+
type: "object",
404
+
required: ["uri", "cid"],
405
405
properties: {
406
406
uri: {
407
-
type: 'string',
408
-
format: 'at-uri',
407
+
type: "string",
408
+
format: "at-uri",
409
409
},
410
410
cid: {
411
-
type: 'string',
412
-
format: 'cid',
411
+
type: "string",
412
+
format: "cid",
413
413
},
414
414
validationStatus: {
415
-
type: 'string',
416
-
knownValues: ['valid', 'unknown'],
415
+
type: "string",
416
+
knownValues: ["valid", "unknown"],
417
417
},
418
418
},
419
419
},
420
420
deleteResult: {
421
-
type: 'object',
421
+
type: "object",
422
422
required: [],
423
423
properties: {},
424
424
},
···
426
426
},
427
427
ComAtprotoRepoCreateRecord: {
428
428
lexicon: 1,
429
-
id: 'com.atproto.repo.createRecord',
429
+
id: "com.atproto.repo.createRecord",
430
430
defs: {
431
431
main: {
432
-
type: 'procedure',
432
+
type: "procedure",
433
433
description:
434
-
'Create a single new repository record. Requires auth, implemented by PDS.',
434
+
"Create a single new repository record. Requires auth, implemented by PDS.",
435
435
input: {
436
-
encoding: 'application/json',
436
+
encoding: "application/json",
437
437
schema: {
438
-
type: 'object',
439
-
required: ['repo', 'collection', 'record'],
438
+
type: "object",
439
+
required: ["repo", "collection", "record"],
440
440
properties: {
441
441
repo: {
442
-
type: 'string',
443
-
format: 'at-identifier',
442
+
type: "string",
443
+
format: "at-identifier",
444
444
description:
445
-
'The handle or DID of the repo (aka, current account).',
445
+
"The handle or DID of the repo (aka, current account).",
446
446
},
447
447
collection: {
448
-
type: 'string',
449
-
format: 'nsid',
450
-
description: 'The NSID of the record collection.',
448
+
type: "string",
449
+
format: "nsid",
450
+
description: "The NSID of the record collection.",
451
451
},
452
452
rkey: {
453
-
type: 'string',
454
-
format: 'record-key',
455
-
description: 'The Record Key.',
453
+
type: "string",
454
+
format: "record-key",
455
+
description: "The Record Key.",
456
456
maxLength: 512,
457
457
},
458
458
validate: {
459
-
type: 'boolean',
459
+
type: "boolean",
460
460
description:
461
461
"Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.",
462
462
},
463
463
record: {
464
-
type: 'unknown',
465
-
description: 'The record itself. Must contain a $type field.',
464
+
type: "unknown",
465
+
description: "The record itself. Must contain a $type field.",
466
466
},
467
467
swapCommit: {
468
-
type: 'string',
469
-
format: 'cid',
468
+
type: "string",
469
+
format: "cid",
470
470
description:
471
-
'Compare and swap with the previous commit by CID.',
471
+
"Compare and swap with the previous commit by CID.",
472
472
},
473
473
},
474
474
},
475
475
},
476
476
output: {
477
-
encoding: 'application/json',
477
+
encoding: "application/json",
478
478
schema: {
479
-
type: 'object',
480
-
required: ['uri', 'cid'],
479
+
type: "object",
480
+
required: ["uri", "cid"],
481
481
properties: {
482
482
uri: {
483
-
type: 'string',
484
-
format: 'at-uri',
483
+
type: "string",
484
+
format: "at-uri",
485
485
},
486
486
cid: {
487
-
type: 'string',
488
-
format: 'cid',
487
+
type: "string",
488
+
format: "cid",
489
489
},
490
490
commit: {
491
-
type: 'ref',
492
-
ref: 'lex:com.atproto.repo.defs#commitMeta',
491
+
type: "ref",
492
+
ref: "lex:com.atproto.repo.defs#commitMeta",
493
493
},
494
494
validationStatus: {
495
-
type: 'string',
496
-
knownValues: ['valid', 'unknown'],
495
+
type: "string",
496
+
knownValues: ["valid", "unknown"],
497
497
},
498
498
},
499
499
},
500
500
},
501
501
errors: [
502
502
{
503
-
name: 'InvalidSwap',
503
+
name: "InvalidSwap",
504
504
description:
505
505
"Indicates that 'swapCommit' didn't match current repo commit.",
506
506
},
···
510
510
},
511
511
ComAtprotoRepoDefs: {
512
512
lexicon: 1,
513
-
id: 'com.atproto.repo.defs',
513
+
id: "com.atproto.repo.defs",
514
514
defs: {
515
515
commitMeta: {
516
-
type: 'object',
517
-
required: ['cid', 'rev'],
516
+
type: "object",
517
+
required: ["cid", "rev"],
518
518
properties: {
519
519
cid: {
520
-
type: 'string',
521
-
format: 'cid',
520
+
type: "string",
521
+
format: "cid",
522
522
},
523
523
rev: {
524
-
type: 'string',
525
-
format: 'tid',
524
+
type: "string",
525
+
format: "tid",
526
526
},
527
527
},
528
528
},
···
530
530
},
531
531
ComAtprotoRepoDeleteRecord: {
532
532
lexicon: 1,
533
-
id: 'com.atproto.repo.deleteRecord',
533
+
id: "com.atproto.repo.deleteRecord",
534
534
defs: {
535
535
main: {
536
-
type: 'procedure',
536
+
type: "procedure",
537
537
description:
538
538
"Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.",
539
539
input: {
540
-
encoding: 'application/json',
540
+
encoding: "application/json",
541
541
schema: {
542
-
type: 'object',
543
-
required: ['repo', 'collection', 'rkey'],
542
+
type: "object",
543
+
required: ["repo", "collection", "rkey"],
544
544
properties: {
545
545
repo: {
546
-
type: 'string',
547
-
format: 'at-identifier',
546
+
type: "string",
547
+
format: "at-identifier",
548
548
description:
549
-
'The handle or DID of the repo (aka, current account).',
549
+
"The handle or DID of the repo (aka, current account).",
550
550
},
551
551
collection: {
552
-
type: 'string',
553
-
format: 'nsid',
554
-
description: 'The NSID of the record collection.',
552
+
type: "string",
553
+
format: "nsid",
554
+
description: "The NSID of the record collection.",
555
555
},
556
556
rkey: {
557
-
type: 'string',
558
-
format: 'record-key',
559
-
description: 'The Record Key.',
557
+
type: "string",
558
+
format: "record-key",
559
+
description: "The Record Key.",
560
560
},
561
561
swapRecord: {
562
-
type: 'string',
563
-
format: 'cid',
562
+
type: "string",
563
+
format: "cid",
564
564
description:
565
-
'Compare and swap with the previous record by CID.',
565
+
"Compare and swap with the previous record by CID.",
566
566
},
567
567
swapCommit: {
568
-
type: 'string',
569
-
format: 'cid',
568
+
type: "string",
569
+
format: "cid",
570
570
description:
571
-
'Compare and swap with the previous commit by CID.',
571
+
"Compare and swap with the previous commit by CID.",
572
572
},
573
573
},
574
574
},
575
575
},
576
576
output: {
577
-
encoding: 'application/json',
577
+
encoding: "application/json",
578
578
schema: {
579
-
type: 'object',
579
+
type: "object",
580
580
properties: {
581
581
commit: {
582
-
type: 'ref',
583
-
ref: 'lex:com.atproto.repo.defs#commitMeta',
582
+
type: "ref",
583
+
ref: "lex:com.atproto.repo.defs#commitMeta",
584
584
},
585
585
},
586
586
},
587
587
},
588
588
errors: [
589
589
{
590
-
name: 'InvalidSwap',
590
+
name: "InvalidSwap",
591
591
},
592
592
],
593
593
},
···
595
595
},
596
596
ComAtprotoRepoDescribeRepo: {
597
597
lexicon: 1,
598
-
id: 'com.atproto.repo.describeRepo',
598
+
id: "com.atproto.repo.describeRepo",
599
599
defs: {
600
600
main: {
601
-
type: 'query',
601
+
type: "query",
602
602
description:
603
-
'Get information about an account and repository, including the list of collections. Does not require auth.',
603
+
"Get information about an account and repository, including the list of collections. Does not require auth.",
604
604
parameters: {
605
-
type: 'params',
606
-
required: ['repo'],
605
+
type: "params",
606
+
required: ["repo"],
607
607
properties: {
608
608
repo: {
609
-
type: 'string',
610
-
format: 'at-identifier',
611
-
description: 'The handle or DID of the repo.',
609
+
type: "string",
610
+
format: "at-identifier",
611
+
description: "The handle or DID of the repo.",
612
612
},
613
613
},
614
614
},
615
615
output: {
616
-
encoding: 'application/json',
616
+
encoding: "application/json",
617
617
schema: {
618
-
type: 'object',
618
+
type: "object",
619
619
required: [
620
-
'handle',
621
-
'did',
622
-
'didDoc',
623
-
'collections',
624
-
'handleIsCorrect',
620
+
"handle",
621
+
"did",
622
+
"didDoc",
623
+
"collections",
624
+
"handleIsCorrect",
625
625
],
626
626
properties: {
627
627
handle: {
628
-
type: 'string',
629
-
format: 'handle',
628
+
type: "string",
629
+
format: "handle",
630
630
},
631
631
did: {
632
-
type: 'string',
633
-
format: 'did',
632
+
type: "string",
633
+
format: "did",
634
634
},
635
635
didDoc: {
636
-
type: 'unknown',
637
-
description: 'The complete DID document for this account.',
636
+
type: "unknown",
637
+
description: "The complete DID document for this account.",
638
638
},
639
639
collections: {
640
-
type: 'array',
640
+
type: "array",
641
641
description:
642
-
'List of all the collections (NSIDs) for which this repo contains at least one record.',
642
+
"List of all the collections (NSIDs) for which this repo contains at least one record.",
643
643
items: {
644
-
type: 'string',
645
-
format: 'nsid',
644
+
type: "string",
645
+
format: "nsid",
646
646
},
647
647
},
648
648
handleIsCorrect: {
649
-
type: 'boolean',
649
+
type: "boolean",
650
650
description:
651
-
'Indicates if handle is currently valid (resolves bi-directionally)',
651
+
"Indicates if handle is currently valid (resolves bi-directionally)",
652
652
},
653
653
},
654
654
},
···
658
658
},
659
659
ComAtprotoRepoGetRecord: {
660
660
lexicon: 1,
661
-
id: 'com.atproto.repo.getRecord',
661
+
id: "com.atproto.repo.getRecord",
662
662
defs: {
663
663
main: {
664
-
type: 'query',
664
+
type: "query",
665
665
description:
666
-
'Get a single record from a repository. Does not require auth.',
666
+
"Get a single record from a repository. Does not require auth.",
667
667
parameters: {
668
-
type: 'params',
669
-
required: ['repo', 'collection', 'rkey'],
668
+
type: "params",
669
+
required: ["repo", "collection", "rkey"],
670
670
properties: {
671
671
repo: {
672
-
type: 'string',
673
-
format: 'at-identifier',
674
-
description: 'The handle or DID of the repo.',
672
+
type: "string",
673
+
format: "at-identifier",
674
+
description: "The handle or DID of the repo.",
675
675
},
676
676
collection: {
677
-
type: 'string',
678
-
format: 'nsid',
679
-
description: 'The NSID of the record collection.',
677
+
type: "string",
678
+
format: "nsid",
679
+
description: "The NSID of the record collection.",
680
680
},
681
681
rkey: {
682
-
type: 'string',
683
-
description: 'The Record Key.',
684
-
format: 'record-key',
682
+
type: "string",
683
+
description: "The Record Key.",
684
+
format: "record-key",
685
685
},
686
686
cid: {
687
-
type: 'string',
688
-
format: 'cid',
687
+
type: "string",
688
+
format: "cid",
689
689
description:
690
-
'The CID of the version of the record. If not specified, then return the most recent version.',
690
+
"The CID of the version of the record. If not specified, then return the most recent version.",
691
691
},
692
692
},
693
693
},
694
694
output: {
695
-
encoding: 'application/json',
695
+
encoding: "application/json",
696
696
schema: {
697
-
type: 'object',
698
-
required: ['uri', 'value'],
697
+
type: "object",
698
+
required: ["uri", "value"],
699
699
properties: {
700
700
uri: {
701
-
type: 'string',
702
-
format: 'at-uri',
701
+
type: "string",
702
+
format: "at-uri",
703
703
},
704
704
cid: {
705
-
type: 'string',
706
-
format: 'cid',
705
+
type: "string",
706
+
format: "cid",
707
707
},
708
708
value: {
709
-
type: 'unknown',
709
+
type: "unknown",
710
710
},
711
711
},
712
712
},
713
713
},
714
714
errors: [
715
715
{
716
-
name: 'RecordNotFound',
716
+
name: "RecordNotFound",
717
717
},
718
718
],
719
719
},
···
721
721
},
722
722
ComAtprotoRepoImportRepo: {
723
723
lexicon: 1,
724
-
id: 'com.atproto.repo.importRepo',
724
+
id: "com.atproto.repo.importRepo",
725
725
defs: {
726
726
main: {
727
-
type: 'procedure',
727
+
type: "procedure",
728
728
description:
729
-
'Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.',
729
+
"Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.",
730
730
input: {
731
-
encoding: 'application/vnd.ipld.car',
731
+
encoding: "application/vnd.ipld.car",
732
732
},
733
733
},
734
734
},
735
735
},
736
736
ComAtprotoRepoListMissingBlobs: {
737
737
lexicon: 1,
738
-
id: 'com.atproto.repo.listMissingBlobs',
738
+
id: "com.atproto.repo.listMissingBlobs",
739
739
defs: {
740
740
main: {
741
-
type: 'query',
741
+
type: "query",
742
742
description:
743
-
'Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.',
743
+
"Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.",
744
744
parameters: {
745
-
type: 'params',
745
+
type: "params",
746
746
properties: {
747
747
limit: {
748
-
type: 'integer',
748
+
type: "integer",
749
749
minimum: 1,
750
750
maximum: 1000,
751
751
default: 500,
752
752
},
753
753
cursor: {
754
-
type: 'string',
754
+
type: "string",
755
755
},
756
756
},
757
757
},
758
758
output: {
759
-
encoding: 'application/json',
759
+
encoding: "application/json",
760
760
schema: {
761
-
type: 'object',
762
-
required: ['blobs'],
761
+
type: "object",
762
+
required: ["blobs"],
763
763
properties: {
764
764
cursor: {
765
-
type: 'string',
765
+
type: "string",
766
766
},
767
767
blobs: {
768
-
type: 'array',
768
+
type: "array",
769
769
items: {
770
-
type: 'ref',
771
-
ref: 'lex:com.atproto.repo.listMissingBlobs#recordBlob',
770
+
type: "ref",
771
+
ref: "lex:com.atproto.repo.listMissingBlobs#recordBlob",
772
772
},
773
773
},
774
774
},
···
776
776
},
777
777
},
778
778
recordBlob: {
779
-
type: 'object',
780
-
required: ['cid', 'recordUri'],
779
+
type: "object",
780
+
required: ["cid", "recordUri"],
781
781
properties: {
782
782
cid: {
783
-
type: 'string',
784
-
format: 'cid',
783
+
type: "string",
784
+
format: "cid",
785
785
},
786
786
recordUri: {
787
-
type: 'string',
788
-
format: 'at-uri',
787
+
type: "string",
788
+
format: "at-uri",
789
789
},
790
790
},
791
791
},
···
793
793
},
794
794
ComAtprotoRepoListRecords: {
795
795
lexicon: 1,
796
-
id: 'com.atproto.repo.listRecords',
796
+
id: "com.atproto.repo.listRecords",
797
797
defs: {
798
798
main: {
799
-
type: 'query',
799
+
type: "query",
800
800
description:
801
-
'List a range of records in a repository, matching a specific collection. Does not require auth.',
801
+
"List a range of records in a repository, matching a specific collection. Does not require auth.",
802
802
parameters: {
803
-
type: 'params',
804
-
required: ['repo', 'collection'],
803
+
type: "params",
804
+
required: ["repo", "collection"],
805
805
properties: {
806
806
repo: {
807
-
type: 'string',
808
-
format: 'at-identifier',
809
-
description: 'The handle or DID of the repo.',
807
+
type: "string",
808
+
format: "at-identifier",
809
+
description: "The handle or DID of the repo.",
810
810
},
811
811
collection: {
812
-
type: 'string',
813
-
format: 'nsid',
814
-
description: 'The NSID of the record type.',
812
+
type: "string",
813
+
format: "nsid",
814
+
description: "The NSID of the record type.",
815
815
},
816
816
limit: {
817
-
type: 'integer',
817
+
type: "integer",
818
818
minimum: 1,
819
819
maximum: 100,
820
820
default: 50,
821
-
description: 'The number of records to return.',
821
+
description: "The number of records to return.",
822
822
},
823
823
cursor: {
824
-
type: 'string',
824
+
type: "string",
825
825
},
826
826
rkeyStart: {
827
-
type: 'string',
827
+
type: "string",
828
828
description:
829
-
'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)',
829
+
"DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)",
830
830
},
831
831
rkeyEnd: {
832
-
type: 'string',
832
+
type: "string",
833
833
description:
834
-
'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)',
834
+
"DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)",
835
835
},
836
836
reverse: {
837
-
type: 'boolean',
838
-
description: 'Flag to reverse the order of the returned records.',
837
+
type: "boolean",
838
+
description: "Flag to reverse the order of the returned records.",
839
839
},
840
840
},
841
841
},
842
842
output: {
843
-
encoding: 'application/json',
843
+
encoding: "application/json",
844
844
schema: {
845
-
type: 'object',
846
-
required: ['records'],
845
+
type: "object",
846
+
required: ["records"],
847
847
properties: {
848
848
cursor: {
849
-
type: 'string',
849
+
type: "string",
850
850
},
851
851
records: {
852
-
type: 'array',
852
+
type: "array",
853
853
items: {
854
-
type: 'ref',
855
-
ref: 'lex:com.atproto.repo.listRecords#record',
854
+
type: "ref",
855
+
ref: "lex:com.atproto.repo.listRecords#record",
856
856
},
857
857
},
858
858
},
···
860
860
},
861
861
},
862
862
record: {
863
-
type: 'object',
864
-
required: ['uri', 'cid', 'value'],
863
+
type: "object",
864
+
required: ["uri", "cid", "value"],
865
865
properties: {
866
866
uri: {
867
-
type: 'string',
868
-
format: 'at-uri',
867
+
type: "string",
868
+
format: "at-uri",
869
869
},
870
870
cid: {
871
-
type: 'string',
872
-
format: 'cid',
871
+
type: "string",
872
+
format: "cid",
873
873
},
874
874
value: {
875
-
type: 'unknown',
875
+
type: "unknown",
876
876
},
877
877
},
878
878
},
···
880
880
},
881
881
ComAtprotoRepoPutRecord: {
882
882
lexicon: 1,
883
-
id: 'com.atproto.repo.putRecord',
883
+
id: "com.atproto.repo.putRecord",
884
884
defs: {
885
885
main: {
886
-
type: 'procedure',
886
+
type: "procedure",
887
887
description:
888
-
'Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.',
888
+
"Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.",
889
889
input: {
890
-
encoding: 'application/json',
890
+
encoding: "application/json",
891
891
schema: {
892
-
type: 'object',
893
-
required: ['repo', 'collection', 'rkey', 'record'],
894
-
nullable: ['swapRecord'],
892
+
type: "object",
893
+
required: ["repo", "collection", "rkey", "record"],
894
+
nullable: ["swapRecord"],
895
895
properties: {
896
896
repo: {
897
-
type: 'string',
898
-
format: 'at-identifier',
897
+
type: "string",
898
+
format: "at-identifier",
899
899
description:
900
-
'The handle or DID of the repo (aka, current account).',
900
+
"The handle or DID of the repo (aka, current account).",
901
901
},
902
902
collection: {
903
-
type: 'string',
904
-
format: 'nsid',
905
-
description: 'The NSID of the record collection.',
903
+
type: "string",
904
+
format: "nsid",
905
+
description: "The NSID of the record collection.",
906
906
},
907
907
rkey: {
908
-
type: 'string',
909
-
format: 'record-key',
910
-
description: 'The Record Key.',
908
+
type: "string",
909
+
format: "record-key",
910
+
description: "The Record Key.",
911
911
maxLength: 512,
912
912
},
913
913
validate: {
914
-
type: 'boolean',
914
+
type: "boolean",
915
915
description:
916
916
"Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.",
917
917
},
918
918
record: {
919
-
type: 'unknown',
920
-
description: 'The record to write.',
919
+
type: "unknown",
920
+
description: "The record to write.",
921
921
},
922
922
swapRecord: {
923
-
type: 'string',
924
-
format: 'cid',
923
+
type: "string",
924
+
format: "cid",
925
925
description:
926
-
'Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation',
926
+
"Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation",
927
927
},
928
928
swapCommit: {
929
-
type: 'string',
930
-
format: 'cid',
929
+
type: "string",
930
+
format: "cid",
931
931
description:
932
-
'Compare and swap with the previous commit by CID.',
932
+
"Compare and swap with the previous commit by CID.",
933
933
},
934
934
},
935
935
},
936
936
},
937
937
output: {
938
-
encoding: 'application/json',
938
+
encoding: "application/json",
939
939
schema: {
940
-
type: 'object',
941
-
required: ['uri', 'cid'],
940
+
type: "object",
941
+
required: ["uri", "cid"],
942
942
properties: {
943
943
uri: {
944
-
type: 'string',
945
-
format: 'at-uri',
944
+
type: "string",
945
+
format: "at-uri",
946
946
},
947
947
cid: {
948
-
type: 'string',
949
-
format: 'cid',
948
+
type: "string",
949
+
format: "cid",
950
950
},
951
951
commit: {
952
-
type: 'ref',
953
-
ref: 'lex:com.atproto.repo.defs#commitMeta',
952
+
type: "ref",
953
+
ref: "lex:com.atproto.repo.defs#commitMeta",
954
954
},
955
955
validationStatus: {
956
-
type: 'string',
957
-
knownValues: ['valid', 'unknown'],
956
+
type: "string",
957
+
knownValues: ["valid", "unknown"],
958
958
},
959
959
},
960
960
},
961
961
},
962
962
errors: [
963
963
{
964
-
name: 'InvalidSwap',
964
+
name: "InvalidSwap",
965
965
},
966
966
],
967
967
},
···
969
969
},
970
970
ComAtprotoRepoStrongRef: {
971
971
lexicon: 1,
972
-
id: 'com.atproto.repo.strongRef',
973
-
description: 'A URI with a content-hash fingerprint.',
972
+
id: "com.atproto.repo.strongRef",
973
+
description: "A URI with a content-hash fingerprint.",
974
974
defs: {
975
975
main: {
976
-
type: 'object',
977
-
required: ['uri', 'cid'],
976
+
type: "object",
977
+
required: ["uri", "cid"],
978
978
properties: {
979
979
uri: {
980
-
type: 'string',
981
-
format: 'at-uri',
980
+
type: "string",
981
+
format: "at-uri",
982
982
},
983
983
cid: {
984
-
type: 'string',
985
-
format: 'cid',
984
+
type: "string",
985
+
format: "cid",
986
986
},
987
987
},
988
988
},
···
990
990
},
991
991
ComAtprotoRepoUploadBlob: {
992
992
lexicon: 1,
993
-
id: 'com.atproto.repo.uploadBlob',
993
+
id: "com.atproto.repo.uploadBlob",
994
994
defs: {
995
995
main: {
996
-
type: 'procedure',
996
+
type: "procedure",
997
997
description:
998
-
'Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.',
998
+
"Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.",
999
999
input: {
1000
-
encoding: '*/*',
1000
+
encoding: "*/*",
1001
1001
},
1002
1002
output: {
1003
-
encoding: 'application/json',
1003
+
encoding: "application/json",
1004
1004
schema: {
1005
-
type: 'object',
1006
-
required: ['blob'],
1005
+
type: "object",
1006
+
required: ["blob"],
1007
1007
properties: {
1008
1008
blob: {
1009
-
type: 'blob',
1009
+
type: "blob",
1010
1010
},
1011
1011
},
1012
1012
},
···
1016
1016
},
1017
1017
PubLeafletBlocksBlockquote: {
1018
1018
lexicon: 1,
1019
-
id: 'pub.leaflet.blocks.blockquote',
1019
+
id: "pub.leaflet.blocks.blockquote",
1020
1020
defs: {
1021
1021
main: {
1022
-
type: 'object',
1023
-
required: ['plaintext'],
1022
+
type: "object",
1023
+
required: ["plaintext"],
1024
1024
properties: {
1025
1025
plaintext: {
1026
-
type: 'string',
1026
+
type: "string",
1027
1027
},
1028
1028
facets: {
1029
-
type: 'array',
1029
+
type: "array",
1030
1030
items: {
1031
-
type: 'ref',
1032
-
ref: 'lex:pub.leaflet.richtext.facet',
1031
+
type: "ref",
1032
+
ref: "lex:pub.leaflet.richtext.facet",
1033
1033
},
1034
1034
},
1035
1035
},
···
1038
1038
},
1039
1039
PubLeafletBlocksBskyPost: {
1040
1040
lexicon: 1,
1041
-
id: 'pub.leaflet.blocks.bskyPost',
1041
+
id: "pub.leaflet.blocks.bskyPost",
1042
1042
defs: {
1043
1043
main: {
1044
-
type: 'object',
1045
-
required: ['postRef'],
1044
+
type: "object",
1045
+
required: ["postRef"],
1046
1046
properties: {
1047
1047
postRef: {
1048
-
type: 'ref',
1049
-
ref: 'lex:com.atproto.repo.strongRef',
1048
+
type: "ref",
1049
+
ref: "lex:com.atproto.repo.strongRef",
1050
1050
},
1051
1051
},
1052
1052
},
···
1054
1054
},
1055
1055
PubLeafletBlocksButton: {
1056
1056
lexicon: 1,
1057
-
id: 'pub.leaflet.blocks.button',
1057
+
id: "pub.leaflet.blocks.button",
1058
1058
defs: {
1059
1059
main: {
1060
-
type: 'object',
1061
-
required: ['text', 'url'],
1060
+
type: "object",
1061
+
required: ["text", "url"],
1062
1062
properties: {
1063
1063
text: {
1064
-
type: 'string',
1064
+
type: "string",
1065
1065
},
1066
1066
url: {
1067
-
type: 'string',
1068
-
format: 'uri',
1067
+
type: "string",
1068
+
format: "uri",
1069
1069
},
1070
1070
},
1071
1071
},
···
1073
1073
},
1074
1074
PubLeafletBlocksCode: {
1075
1075
lexicon: 1,
1076
-
id: 'pub.leaflet.blocks.code',
1076
+
id: "pub.leaflet.blocks.code",
1077
1077
defs: {
1078
1078
main: {
1079
-
type: 'object',
1080
-
required: ['plaintext'],
1079
+
type: "object",
1080
+
required: ["plaintext"],
1081
1081
properties: {
1082
1082
plaintext: {
1083
-
type: 'string',
1083
+
type: "string",
1084
1084
},
1085
1085
language: {
1086
-
type: 'string',
1086
+
type: "string",
1087
1087
},
1088
1088
syntaxHighlightingTheme: {
1089
-
type: 'string',
1089
+
type: "string",
1090
1090
},
1091
1091
},
1092
1092
},
···
1094
1094
},
1095
1095
PubLeafletBlocksHeader: {
1096
1096
lexicon: 1,
1097
-
id: 'pub.leaflet.blocks.header',
1097
+
id: "pub.leaflet.blocks.header",
1098
1098
defs: {
1099
1099
main: {
1100
-
type: 'object',
1101
-
required: ['plaintext'],
1100
+
type: "object",
1101
+
required: ["plaintext"],
1102
1102
properties: {
1103
1103
level: {
1104
-
type: 'integer',
1104
+
type: "integer",
1105
1105
minimum: 1,
1106
1106
maximum: 6,
1107
1107
},
1108
1108
plaintext: {
1109
-
type: 'string',
1109
+
type: "string",
1110
1110
},
1111
1111
facets: {
1112
-
type: 'array',
1112
+
type: "array",
1113
1113
items: {
1114
-
type: 'ref',
1115
-
ref: 'lex:pub.leaflet.richtext.facet',
1114
+
type: "ref",
1115
+
ref: "lex:pub.leaflet.richtext.facet",
1116
1116
},
1117
1117
},
1118
1118
},
···
1121
1121
},
1122
1122
PubLeafletBlocksHorizontalRule: {
1123
1123
lexicon: 1,
1124
-
id: 'pub.leaflet.blocks.horizontalRule',
1124
+
id: "pub.leaflet.blocks.horizontalRule",
1125
1125
defs: {
1126
1126
main: {
1127
-
type: 'object',
1127
+
type: "object",
1128
1128
required: [],
1129
1129
properties: {},
1130
1130
},
···
1132
1132
},
1133
1133
PubLeafletBlocksIframe: {
1134
1134
lexicon: 1,
1135
-
id: 'pub.leaflet.blocks.iframe',
1135
+
id: "pub.leaflet.blocks.iframe",
1136
1136
defs: {
1137
1137
main: {
1138
-
type: 'object',
1139
-
required: ['url'],
1138
+
type: "object",
1139
+
required: ["url"],
1140
1140
properties: {
1141
1141
url: {
1142
-
type: 'string',
1143
-
format: 'uri',
1142
+
type: "string",
1143
+
format: "uri",
1144
1144
},
1145
1145
height: {
1146
-
type: 'integer',
1146
+
type: "integer",
1147
1147
minimum: 16,
1148
1148
maximum: 1600,
1149
1149
},
···
1153
1153
},
1154
1154
PubLeafletBlocksImage: {
1155
1155
lexicon: 1,
1156
-
id: 'pub.leaflet.blocks.image',
1156
+
id: "pub.leaflet.blocks.image",
1157
1157
defs: {
1158
1158
main: {
1159
-
type: 'object',
1160
-
required: ['image', 'aspectRatio'],
1159
+
type: "object",
1160
+
required: ["image", "aspectRatio"],
1161
1161
properties: {
1162
1162
image: {
1163
-
type: 'blob',
1164
-
accept: ['image/*'],
1163
+
type: "blob",
1164
+
accept: ["image/*"],
1165
1165
maxSize: 1000000,
1166
1166
},
1167
1167
alt: {
1168
-
type: 'string',
1168
+
type: "string",
1169
1169
description:
1170
-
'Alt text description of the image, for accessibility.',
1170
+
"Alt text description of the image, for accessibility.",
1171
1171
},
1172
1172
aspectRatio: {
1173
-
type: 'ref',
1174
-
ref: 'lex:pub.leaflet.blocks.image#aspectRatio',
1173
+
type: "ref",
1174
+
ref: "lex:pub.leaflet.blocks.image#aspectRatio",
1175
1175
},
1176
1176
},
1177
1177
},
1178
1178
aspectRatio: {
1179
-
type: 'object',
1180
-
required: ['width', 'height'],
1179
+
type: "object",
1180
+
required: ["width", "height"],
1181
1181
properties: {
1182
1182
width: {
1183
-
type: 'integer',
1183
+
type: "integer",
1184
1184
},
1185
1185
height: {
1186
-
type: 'integer',
1186
+
type: "integer",
1187
1187
},
1188
1188
},
1189
1189
},
···
1191
1191
},
1192
1192
PubLeafletBlocksMath: {
1193
1193
lexicon: 1,
1194
-
id: 'pub.leaflet.blocks.math',
1194
+
id: "pub.leaflet.blocks.math",
1195
1195
defs: {
1196
1196
main: {
1197
-
type: 'object',
1198
-
required: ['tex'],
1197
+
type: "object",
1198
+
required: ["tex"],
1199
1199
properties: {
1200
1200
tex: {
1201
-
type: 'string',
1201
+
type: "string",
1202
1202
},
1203
1203
},
1204
1204
},
···
1206
1206
},
1207
1207
PubLeafletBlocksPage: {
1208
1208
lexicon: 1,
1209
-
id: 'pub.leaflet.blocks.page',
1209
+
id: "pub.leaflet.blocks.page",
1210
1210
defs: {
1211
1211
main: {
1212
-
type: 'object',
1213
-
required: ['id'],
1212
+
type: "object",
1213
+
required: ["id"],
1214
1214
properties: {
1215
1215
id: {
1216
-
type: 'string',
1216
+
type: "string",
1217
1217
},
1218
1218
},
1219
1219
},
···
1221
1221
},
1222
1222
PubLeafletBlocksPoll: {
1223
1223
lexicon: 1,
1224
-
id: 'pub.leaflet.blocks.poll',
1224
+
id: "pub.leaflet.blocks.poll",
1225
1225
defs: {
1226
1226
main: {
1227
-
type: 'object',
1228
-
required: ['pollRef'],
1227
+
type: "object",
1228
+
required: ["pollRef"],
1229
1229
properties: {
1230
1230
pollRef: {
1231
-
type: 'ref',
1232
-
ref: 'lex:com.atproto.repo.strongRef',
1231
+
type: "ref",
1232
+
ref: "lex:com.atproto.repo.strongRef",
1233
1233
},
1234
1234
},
1235
1235
},
···
1237
1237
},
1238
1238
PubLeafletBlocksText: {
1239
1239
lexicon: 1,
1240
-
id: 'pub.leaflet.blocks.text',
1240
+
id: "pub.leaflet.blocks.text",
1241
1241
defs: {
1242
1242
main: {
1243
-
type: 'object',
1244
-
required: ['plaintext'],
1243
+
type: "object",
1244
+
required: ["plaintext"],
1245
1245
properties: {
1246
1246
plaintext: {
1247
-
type: 'string',
1247
+
type: "string",
1248
1248
},
1249
1249
facets: {
1250
-
type: 'array',
1250
+
type: "array",
1251
1251
items: {
1252
-
type: 'ref',
1253
-
ref: 'lex:pub.leaflet.richtext.facet',
1252
+
type: "ref",
1253
+
ref: "lex:pub.leaflet.richtext.facet",
1254
1254
},
1255
1255
},
1256
1256
},
···
1259
1259
},
1260
1260
PubLeafletBlocksUnorderedList: {
1261
1261
lexicon: 1,
1262
-
id: 'pub.leaflet.blocks.unorderedList',
1262
+
id: "pub.leaflet.blocks.unorderedList",
1263
1263
defs: {
1264
1264
main: {
1265
-
type: 'object',
1266
-
required: ['children'],
1265
+
type: "object",
1266
+
required: ["children"],
1267
1267
properties: {
1268
1268
children: {
1269
-
type: 'array',
1269
+
type: "array",
1270
1270
items: {
1271
-
type: 'ref',
1272
-
ref: 'lex:pub.leaflet.blocks.unorderedList#listItem',
1271
+
type: "ref",
1272
+
ref: "lex:pub.leaflet.blocks.unorderedList#listItem",
1273
1273
},
1274
1274
},
1275
1275
},
1276
1276
},
1277
1277
listItem: {
1278
-
type: 'object',
1279
-
required: ['content'],
1278
+
type: "object",
1279
+
required: ["content"],
1280
1280
properties: {
1281
1281
content: {
1282
-
type: 'union',
1282
+
type: "union",
1283
1283
refs: [
1284
-
'lex:pub.leaflet.blocks.text',
1285
-
'lex:pub.leaflet.blocks.header',
1286
-
'lex:pub.leaflet.blocks.image',
1284
+
"lex:pub.leaflet.blocks.text",
1285
+
"lex:pub.leaflet.blocks.header",
1286
+
"lex:pub.leaflet.blocks.image",
1287
1287
],
1288
1288
},
1289
1289
children: {
1290
-
type: 'array',
1290
+
type: "array",
1291
1291
items: {
1292
-
type: 'ref',
1293
-
ref: 'lex:pub.leaflet.blocks.unorderedList#listItem',
1292
+
type: "ref",
1293
+
ref: "lex:pub.leaflet.blocks.unorderedList#listItem",
1294
1294
},
1295
1295
},
1296
1296
},
···
1299
1299
},
1300
1300
PubLeafletBlocksWebsite: {
1301
1301
lexicon: 1,
1302
-
id: 'pub.leaflet.blocks.website',
1302
+
id: "pub.leaflet.blocks.website",
1303
1303
defs: {
1304
1304
main: {
1305
-
type: 'object',
1306
-
required: ['src'],
1305
+
type: "object",
1306
+
required: ["src"],
1307
1307
properties: {
1308
1308
previewImage: {
1309
-
type: 'blob',
1310
-
accept: ['image/*'],
1309
+
type: "blob",
1310
+
accept: ["image/*"],
1311
1311
maxSize: 1000000,
1312
1312
},
1313
1313
title: {
1314
-
type: 'string',
1314
+
type: "string",
1315
1315
},
1316
1316
description: {
1317
-
type: 'string',
1317
+
type: "string",
1318
1318
},
1319
1319
src: {
1320
-
type: 'string',
1321
-
format: 'uri',
1320
+
type: "string",
1321
+
format: "uri",
1322
1322
},
1323
1323
},
1324
1324
},
···
1326
1326
},
1327
1327
PubLeafletComment: {
1328
1328
lexicon: 1,
1329
-
id: 'pub.leaflet.comment',
1329
+
id: "pub.leaflet.comment",
1330
1330
revision: 1,
1331
-
description: 'A lexicon for comments on documents',
1331
+
description: "A lexicon for comments on documents",
1332
1332
defs: {
1333
1333
main: {
1334
-
type: 'record',
1335
-
key: 'tid',
1336
-
description: 'Record containing a comment',
1334
+
type: "record",
1335
+
key: "tid",
1336
+
description: "Record containing a comment",
1337
1337
record: {
1338
-
type: 'object',
1339
-
required: ['subject', 'plaintext', 'createdAt'],
1338
+
type: "object",
1339
+
required: ["subject", "plaintext", "createdAt"],
1340
1340
properties: {
1341
1341
subject: {
1342
-
type: 'string',
1343
-
format: 'at-uri',
1342
+
type: "string",
1343
+
format: "at-uri",
1344
1344
},
1345
1345
createdAt: {
1346
-
type: 'string',
1347
-
format: 'datetime',
1346
+
type: "string",
1347
+
format: "datetime",
1348
1348
},
1349
1349
reply: {
1350
-
type: 'ref',
1351
-
ref: 'lex:pub.leaflet.comment#replyRef',
1350
+
type: "ref",
1351
+
ref: "lex:pub.leaflet.comment#replyRef",
1352
1352
},
1353
1353
plaintext: {
1354
-
type: 'string',
1354
+
type: "string",
1355
1355
},
1356
1356
facets: {
1357
-
type: 'array',
1357
+
type: "array",
1358
1358
items: {
1359
-
type: 'ref',
1360
-
ref: 'lex:pub.leaflet.richtext.facet',
1359
+
type: "ref",
1360
+
ref: "lex:pub.leaflet.richtext.facet",
1361
1361
},
1362
1362
},
1363
1363
onPage: {
1364
-
type: 'string',
1364
+
type: "string",
1365
1365
},
1366
1366
attachment: {
1367
-
type: 'union',
1368
-
refs: ['lex:pub.leaflet.comment#linearDocumentQuote'],
1367
+
type: "union",
1368
+
refs: ["lex:pub.leaflet.comment#linearDocumentQuote"],
1369
1369
},
1370
1370
},
1371
1371
},
1372
1372
},
1373
1373
linearDocumentQuote: {
1374
-
type: 'object',
1375
-
required: ['document', 'quote'],
1374
+
type: "object",
1375
+
required: ["document", "quote"],
1376
1376
properties: {
1377
1377
document: {
1378
-
type: 'string',
1379
-
format: 'at-uri',
1378
+
type: "string",
1379
+
format: "at-uri",
1380
1380
},
1381
1381
quote: {
1382
-
type: 'ref',
1383
-
ref: 'lex:pub.leaflet.pages.linearDocument#quote',
1382
+
type: "ref",
1383
+
ref: "lex:pub.leaflet.pages.linearDocument#quote",
1384
1384
},
1385
1385
},
1386
1386
},
1387
1387
replyRef: {
1388
-
type: 'object',
1389
-
required: ['parent'],
1388
+
type: "object",
1389
+
required: ["parent"],
1390
1390
properties: {
1391
1391
parent: {
1392
-
type: 'string',
1393
-
format: 'at-uri',
1392
+
type: "string",
1393
+
format: "at-uri",
1394
1394
},
1395
1395
},
1396
1396
},
···
1398
1398
},
1399
1399
PubLeafletDocument: {
1400
1400
lexicon: 1,
1401
-
id: 'pub.leaflet.document',
1401
+
id: "pub.leaflet.document",
1402
1402
revision: 1,
1403
-
description: 'A lexicon for long form rich media documents',
1403
+
description: "A lexicon for long form rich media documents",
1404
1404
defs: {
1405
1405
main: {
1406
-
type: 'record',
1407
-
key: 'tid',
1408
-
description: 'Record containing a document',
1406
+
type: "record",
1407
+
key: "tid",
1408
+
description: "Record containing a document",
1409
1409
record: {
1410
-
type: 'object',
1411
-
required: ['pages', 'author', 'title'],
1410
+
type: "object",
1411
+
required: ["pages", "author", "title"],
1412
1412
properties: {
1413
1413
title: {
1414
-
type: 'string',
1414
+
type: "string",
1415
1415
maxLength: 1280,
1416
1416
maxGraphemes: 128,
1417
1417
},
1418
1418
postRef: {
1419
-
type: 'ref',
1420
-
ref: 'lex:com.atproto.repo.strongRef',
1419
+
type: "ref",
1420
+
ref: "lex:com.atproto.repo.strongRef",
1421
1421
},
1422
1422
description: {
1423
-
type: 'string',
1423
+
type: "string",
1424
1424
maxLength: 3000,
1425
1425
maxGraphemes: 300,
1426
1426
},
1427
1427
publishedAt: {
1428
-
type: 'string',
1429
-
format: 'datetime',
1428
+
type: "string",
1429
+
format: "datetime",
1430
1430
},
1431
1431
publication: {
1432
-
type: 'string',
1433
-
format: 'at-uri',
1432
+
type: "string",
1433
+
format: "at-uri",
1434
1434
},
1435
1435
author: {
1436
-
type: 'string',
1437
-
format: 'at-identifier',
1436
+
type: "string",
1437
+
format: "at-identifier",
1438
1438
},
1439
1439
theme: {
1440
-
type: 'ref',
1441
-
ref: 'lex:pub.leaflet.publication#theme',
1440
+
type: "ref",
1441
+
ref: "lex:pub.leaflet.publication#theme",
1442
1442
},
1443
1443
tags: {
1444
-
type: 'array',
1444
+
type: "array",
1445
1445
items: {
1446
-
type: 'string',
1446
+
type: "string",
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
-
type: 'array',
1456
+
type: "array",
1452
1457
items: {
1453
-
type: 'union',
1458
+
type: "union",
1454
1459
refs: [
1455
-
'lex:pub.leaflet.pages.linearDocument',
1456
-
'lex:pub.leaflet.pages.canvas',
1460
+
"lex:pub.leaflet.pages.linearDocument",
1461
+
"lex:pub.leaflet.pages.canvas",
1457
1462
],
1458
1463
},
1459
1464
},
···
1464
1469
},
1465
1470
PubLeafletGraphSubscription: {
1466
1471
lexicon: 1,
1467
-
id: 'pub.leaflet.graph.subscription',
1472
+
id: "pub.leaflet.graph.subscription",
1468
1473
defs: {
1469
1474
main: {
1470
-
type: 'record',
1471
-
key: 'tid',
1472
-
description: 'Record declaring a subscription to a publication',
1475
+
type: "record",
1476
+
key: "tid",
1477
+
description: "Record declaring a subscription to a publication",
1473
1478
record: {
1474
-
type: 'object',
1475
-
required: ['publication'],
1479
+
type: "object",
1480
+
required: ["publication"],
1476
1481
properties: {
1477
1482
publication: {
1478
-
type: 'string',
1479
-
format: 'at-uri',
1483
+
type: "string",
1484
+
format: "at-uri",
1480
1485
},
1481
1486
},
1482
1487
},
···
1485
1490
},
1486
1491
PubLeafletPagesCanvas: {
1487
1492
lexicon: 1,
1488
-
id: 'pub.leaflet.pages.canvas',
1493
+
id: "pub.leaflet.pages.canvas",
1489
1494
defs: {
1490
1495
main: {
1491
-
type: 'object',
1492
-
required: ['blocks'],
1496
+
type: "object",
1497
+
required: ["blocks"],
1493
1498
properties: {
1494
1499
id: {
1495
-
type: 'string',
1500
+
type: "string",
1496
1501
},
1497
1502
blocks: {
1498
-
type: 'array',
1503
+
type: "array",
1499
1504
items: {
1500
-
type: 'ref',
1501
-
ref: 'lex:pub.leaflet.pages.canvas#block',
1505
+
type: "ref",
1506
+
ref: "lex:pub.leaflet.pages.canvas#block",
1502
1507
},
1503
1508
},
1504
1509
},
1505
1510
},
1506
1511
block: {
1507
-
type: 'object',
1508
-
required: ['block', 'x', 'y', 'width'],
1512
+
type: "object",
1513
+
required: ["block", "x", "y", "width"],
1509
1514
properties: {
1510
1515
block: {
1511
-
type: 'union',
1516
+
type: "union",
1512
1517
refs: [
1513
-
'lex:pub.leaflet.blocks.iframe',
1514
-
'lex:pub.leaflet.blocks.text',
1515
-
'lex:pub.leaflet.blocks.blockquote',
1516
-
'lex:pub.leaflet.blocks.header',
1517
-
'lex:pub.leaflet.blocks.image',
1518
-
'lex:pub.leaflet.blocks.unorderedList',
1519
-
'lex:pub.leaflet.blocks.website',
1520
-
'lex:pub.leaflet.blocks.math',
1521
-
'lex:pub.leaflet.blocks.code',
1522
-
'lex:pub.leaflet.blocks.horizontalRule',
1523
-
'lex:pub.leaflet.blocks.bskyPost',
1524
-
'lex:pub.leaflet.blocks.page',
1525
-
'lex:pub.leaflet.blocks.poll',
1526
-
'lex:pub.leaflet.blocks.button',
1518
+
"lex:pub.leaflet.blocks.iframe",
1519
+
"lex:pub.leaflet.blocks.text",
1520
+
"lex:pub.leaflet.blocks.blockquote",
1521
+
"lex:pub.leaflet.blocks.header",
1522
+
"lex:pub.leaflet.blocks.image",
1523
+
"lex:pub.leaflet.blocks.unorderedList",
1524
+
"lex:pub.leaflet.blocks.website",
1525
+
"lex:pub.leaflet.blocks.math",
1526
+
"lex:pub.leaflet.blocks.code",
1527
+
"lex:pub.leaflet.blocks.horizontalRule",
1528
+
"lex:pub.leaflet.blocks.bskyPost",
1529
+
"lex:pub.leaflet.blocks.page",
1530
+
"lex:pub.leaflet.blocks.poll",
1531
+
"lex:pub.leaflet.blocks.button",
1527
1532
],
1528
1533
},
1529
1534
x: {
1530
-
type: 'integer',
1535
+
type: "integer",
1531
1536
},
1532
1537
y: {
1533
-
type: 'integer',
1538
+
type: "integer",
1534
1539
},
1535
1540
width: {
1536
-
type: 'integer',
1541
+
type: "integer",
1537
1542
},
1538
1543
height: {
1539
-
type: 'integer',
1544
+
type: "integer",
1540
1545
},
1541
1546
rotation: {
1542
-
type: 'integer',
1543
-
description: 'The rotation of the block in degrees',
1547
+
type: "integer",
1548
+
description: "The rotation of the block in degrees",
1544
1549
},
1545
1550
},
1546
1551
},
1547
1552
textAlignLeft: {
1548
-
type: 'token',
1553
+
type: "token",
1549
1554
},
1550
1555
textAlignCenter: {
1551
-
type: 'token',
1556
+
type: "token",
1552
1557
},
1553
1558
textAlignRight: {
1554
-
type: 'token',
1559
+
type: "token",
1555
1560
},
1556
1561
quote: {
1557
-
type: 'object',
1558
-
required: ['start', 'end'],
1562
+
type: "object",
1563
+
required: ["start", "end"],
1559
1564
properties: {
1560
1565
start: {
1561
-
type: 'ref',
1562
-
ref: 'lex:pub.leaflet.pages.canvas#position',
1566
+
type: "ref",
1567
+
ref: "lex:pub.leaflet.pages.canvas#position",
1563
1568
},
1564
1569
end: {
1565
-
type: 'ref',
1566
-
ref: 'lex:pub.leaflet.pages.canvas#position',
1570
+
type: "ref",
1571
+
ref: "lex:pub.leaflet.pages.canvas#position",
1567
1572
},
1568
1573
},
1569
1574
},
1570
1575
position: {
1571
-
type: 'object',
1572
-
required: ['block', 'offset'],
1576
+
type: "object",
1577
+
required: ["block", "offset"],
1573
1578
properties: {
1574
1579
block: {
1575
-
type: 'array',
1580
+
type: "array",
1576
1581
items: {
1577
-
type: 'integer',
1582
+
type: "integer",
1578
1583
},
1579
1584
},
1580
1585
offset: {
1581
-
type: 'integer',
1586
+
type: "integer",
1582
1587
},
1583
1588
},
1584
1589
},
···
1586
1591
},
1587
1592
PubLeafletPagesLinearDocument: {
1588
1593
lexicon: 1,
1589
-
id: 'pub.leaflet.pages.linearDocument',
1594
+
id: "pub.leaflet.pages.linearDocument",
1590
1595
defs: {
1591
1596
main: {
1592
-
type: 'object',
1593
-
required: ['blocks'],
1597
+
type: "object",
1598
+
required: ["blocks"],
1594
1599
properties: {
1595
1600
id: {
1596
-
type: 'string',
1601
+
type: "string",
1597
1602
},
1598
1603
blocks: {
1599
-
type: 'array',
1604
+
type: "array",
1600
1605
items: {
1601
-
type: 'ref',
1602
-
ref: 'lex:pub.leaflet.pages.linearDocument#block',
1606
+
type: "ref",
1607
+
ref: "lex:pub.leaflet.pages.linearDocument#block",
1603
1608
},
1604
1609
},
1605
1610
},
1606
1611
},
1607
1612
block: {
1608
-
type: 'object',
1609
-
required: ['block'],
1613
+
type: "object",
1614
+
required: ["block"],
1610
1615
properties: {
1611
1616
block: {
1612
-
type: 'union',
1617
+
type: "union",
1613
1618
refs: [
1614
-
'lex:pub.leaflet.blocks.iframe',
1615
-
'lex:pub.leaflet.blocks.text',
1616
-
'lex:pub.leaflet.blocks.blockquote',
1617
-
'lex:pub.leaflet.blocks.header',
1618
-
'lex:pub.leaflet.blocks.image',
1619
-
'lex:pub.leaflet.blocks.unorderedList',
1620
-
'lex:pub.leaflet.blocks.website',
1621
-
'lex:pub.leaflet.blocks.math',
1622
-
'lex:pub.leaflet.blocks.code',
1623
-
'lex:pub.leaflet.blocks.horizontalRule',
1624
-
'lex:pub.leaflet.blocks.bskyPost',
1625
-
'lex:pub.leaflet.blocks.page',
1626
-
'lex:pub.leaflet.blocks.poll',
1627
-
'lex:pub.leaflet.blocks.button',
1619
+
"lex:pub.leaflet.blocks.iframe",
1620
+
"lex:pub.leaflet.blocks.text",
1621
+
"lex:pub.leaflet.blocks.blockquote",
1622
+
"lex:pub.leaflet.blocks.header",
1623
+
"lex:pub.leaflet.blocks.image",
1624
+
"lex:pub.leaflet.blocks.unorderedList",
1625
+
"lex:pub.leaflet.blocks.website",
1626
+
"lex:pub.leaflet.blocks.math",
1627
+
"lex:pub.leaflet.blocks.code",
1628
+
"lex:pub.leaflet.blocks.horizontalRule",
1629
+
"lex:pub.leaflet.blocks.bskyPost",
1630
+
"lex:pub.leaflet.blocks.page",
1631
+
"lex:pub.leaflet.blocks.poll",
1632
+
"lex:pub.leaflet.blocks.button",
1628
1633
],
1629
1634
},
1630
1635
alignment: {
1631
-
type: 'string',
1636
+
type: "string",
1632
1637
knownValues: [
1633
-
'lex:pub.leaflet.pages.linearDocument#textAlignLeft',
1634
-
'lex:pub.leaflet.pages.linearDocument#textAlignCenter',
1635
-
'lex:pub.leaflet.pages.linearDocument#textAlignRight',
1636
-
'lex:pub.leaflet.pages.linearDocument#textAlignJustify',
1638
+
"lex:pub.leaflet.pages.linearDocument#textAlignLeft",
1639
+
"lex:pub.leaflet.pages.linearDocument#textAlignCenter",
1640
+
"lex:pub.leaflet.pages.linearDocument#textAlignRight",
1641
+
"lex:pub.leaflet.pages.linearDocument#textAlignJustify",
1637
1642
],
1638
1643
},
1639
1644
},
1640
1645
},
1641
1646
textAlignLeft: {
1642
-
type: 'token',
1647
+
type: "token",
1643
1648
},
1644
1649
textAlignCenter: {
1645
-
type: 'token',
1650
+
type: "token",
1646
1651
},
1647
1652
textAlignRight: {
1648
-
type: 'token',
1653
+
type: "token",
1649
1654
},
1650
1655
textAlignJustify: {
1651
-
type: 'token',
1656
+
type: "token",
1652
1657
},
1653
1658
quote: {
1654
-
type: 'object',
1655
-
required: ['start', 'end'],
1659
+
type: "object",
1660
+
required: ["start", "end"],
1656
1661
properties: {
1657
1662
start: {
1658
-
type: 'ref',
1659
-
ref: 'lex:pub.leaflet.pages.linearDocument#position',
1663
+
type: "ref",
1664
+
ref: "lex:pub.leaflet.pages.linearDocument#position",
1660
1665
},
1661
1666
end: {
1662
-
type: 'ref',
1663
-
ref: 'lex:pub.leaflet.pages.linearDocument#position',
1667
+
type: "ref",
1668
+
ref: "lex:pub.leaflet.pages.linearDocument#position",
1664
1669
},
1665
1670
},
1666
1671
},
1667
1672
position: {
1668
-
type: 'object',
1669
-
required: ['block', 'offset'],
1673
+
type: "object",
1674
+
required: ["block", "offset"],
1670
1675
properties: {
1671
1676
block: {
1672
-
type: 'array',
1677
+
type: "array",
1673
1678
items: {
1674
-
type: 'integer',
1679
+
type: "integer",
1675
1680
},
1676
1681
},
1677
1682
offset: {
1678
-
type: 'integer',
1683
+
type: "integer",
1679
1684
},
1680
1685
},
1681
1686
},
···
1683
1688
},
1684
1689
PubLeafletPollDefinition: {
1685
1690
lexicon: 1,
1686
-
id: 'pub.leaflet.poll.definition',
1691
+
id: "pub.leaflet.poll.definition",
1687
1692
defs: {
1688
1693
main: {
1689
-
type: 'record',
1690
-
key: 'tid',
1691
-
description: 'Record declaring a poll',
1694
+
type: "record",
1695
+
key: "tid",
1696
+
description: "Record declaring a poll",
1692
1697
record: {
1693
-
type: 'object',
1694
-
required: ['name', 'options'],
1698
+
type: "object",
1699
+
required: ["name", "options"],
1695
1700
properties: {
1696
1701
name: {
1697
-
type: 'string',
1702
+
type: "string",
1698
1703
maxLength: 500,
1699
1704
maxGraphemes: 100,
1700
1705
},
1701
1706
options: {
1702
-
type: 'array',
1707
+
type: "array",
1703
1708
items: {
1704
-
type: 'ref',
1705
-
ref: 'lex:pub.leaflet.poll.definition#option',
1709
+
type: "ref",
1710
+
ref: "lex:pub.leaflet.poll.definition#option",
1706
1711
},
1707
1712
},
1708
1713
endDate: {
1709
-
type: 'string',
1710
-
format: 'datetime',
1714
+
type: "string",
1715
+
format: "datetime",
1711
1716
},
1712
1717
},
1713
1718
},
1714
1719
},
1715
1720
option: {
1716
-
type: 'object',
1721
+
type: "object",
1717
1722
properties: {
1718
1723
text: {
1719
-
type: 'string',
1724
+
type: "string",
1720
1725
maxLength: 500,
1721
1726
maxGraphemes: 50,
1722
1727
},
···
1726
1731
},
1727
1732
PubLeafletPollVote: {
1728
1733
lexicon: 1,
1729
-
id: 'pub.leaflet.poll.vote',
1734
+
id: "pub.leaflet.poll.vote",
1730
1735
defs: {
1731
1736
main: {
1732
-
type: 'record',
1733
-
key: 'tid',
1734
-
description: 'Record declaring a vote on a poll',
1737
+
type: "record",
1738
+
key: "tid",
1739
+
description: "Record declaring a vote on a poll",
1735
1740
record: {
1736
-
type: 'object',
1737
-
required: ['poll', 'option'],
1741
+
type: "object",
1742
+
required: ["poll", "option"],
1738
1743
properties: {
1739
1744
poll: {
1740
-
type: 'ref',
1741
-
ref: 'lex:com.atproto.repo.strongRef',
1745
+
type: "ref",
1746
+
ref: "lex:com.atproto.repo.strongRef",
1742
1747
},
1743
1748
option: {
1744
-
type: 'array',
1749
+
type: "array",
1745
1750
items: {
1746
-
type: 'string',
1751
+
type: "string",
1747
1752
},
1748
1753
},
1749
1754
},
···
1753
1758
},
1754
1759
PubLeafletPublication: {
1755
1760
lexicon: 1,
1756
-
id: 'pub.leaflet.publication',
1761
+
id: "pub.leaflet.publication",
1757
1762
defs: {
1758
1763
main: {
1759
-
type: 'record',
1760
-
key: 'tid',
1761
-
description: 'Record declaring a publication',
1764
+
type: "record",
1765
+
key: "tid",
1766
+
description: "Record declaring a publication",
1762
1767
record: {
1763
-
type: 'object',
1764
-
required: ['name'],
1768
+
type: "object",
1769
+
required: ["name"],
1765
1770
properties: {
1766
1771
name: {
1767
-
type: 'string',
1772
+
type: "string",
1768
1773
maxLength: 2000,
1769
1774
},
1770
1775
base_path: {
1771
-
type: 'string',
1776
+
type: "string",
1772
1777
},
1773
1778
description: {
1774
-
type: 'string',
1779
+
type: "string",
1775
1780
maxLength: 2000,
1776
1781
},
1777
1782
icon: {
1778
-
type: 'blob',
1779
-
accept: ['image/*'],
1783
+
type: "blob",
1784
+
accept: ["image/*"],
1780
1785
maxSize: 1000000,
1781
1786
},
1782
1787
theme: {
1783
-
type: 'ref',
1784
-
ref: 'lex:pub.leaflet.publication#theme',
1788
+
type: "ref",
1789
+
ref: "lex:pub.leaflet.publication#theme",
1785
1790
},
1786
1791
preferences: {
1787
-
type: 'ref',
1788
-
ref: 'lex:pub.leaflet.publication#preferences',
1792
+
type: "ref",
1793
+
ref: "lex:pub.leaflet.publication#preferences",
1789
1794
},
1790
1795
},
1791
1796
},
1792
1797
},
1793
1798
preferences: {
1794
-
type: 'object',
1799
+
type: "object",
1795
1800
properties: {
1796
1801
showInDiscover: {
1797
-
type: 'boolean',
1802
+
type: "boolean",
1798
1803
default: true,
1799
1804
},
1800
1805
showComments: {
1801
-
type: 'boolean',
1806
+
type: "boolean",
1802
1807
default: true,
1803
1808
},
1804
1809
},
1805
1810
},
1806
1811
theme: {
1807
-
type: 'object',
1812
+
type: "object",
1808
1813
properties: {
1809
1814
backgroundColor: {
1810
-
type: 'union',
1815
+
type: "union",
1811
1816
refs: [
1812
-
'lex:pub.leaflet.theme.color#rgba',
1813
-
'lex:pub.leaflet.theme.color#rgb',
1817
+
"lex:pub.leaflet.theme.color#rgba",
1818
+
"lex:pub.leaflet.theme.color#rgb",
1814
1819
],
1815
1820
},
1816
1821
backgroundImage: {
1817
-
type: 'ref',
1818
-
ref: 'lex:pub.leaflet.theme.backgroundImage',
1822
+
type: "ref",
1823
+
ref: "lex:pub.leaflet.theme.backgroundImage",
1824
+
},
1825
+
pageWidth: {
1826
+
type: "integer",
1827
+
minimum: 320,
1828
+
maximum: 1200,
1819
1829
},
1820
1830
primary: {
1821
-
type: 'union',
1831
+
type: "union",
1822
1832
refs: [
1823
-
'lex:pub.leaflet.theme.color#rgba',
1824
-
'lex:pub.leaflet.theme.color#rgb',
1833
+
"lex:pub.leaflet.theme.color#rgba",
1834
+
"lex:pub.leaflet.theme.color#rgb",
1825
1835
],
1826
1836
},
1827
1837
pageBackground: {
1828
-
type: 'union',
1838
+
type: "union",
1829
1839
refs: [
1830
-
'lex:pub.leaflet.theme.color#rgba',
1831
-
'lex:pub.leaflet.theme.color#rgb',
1840
+
"lex:pub.leaflet.theme.color#rgba",
1841
+
"lex:pub.leaflet.theme.color#rgb",
1832
1842
],
1833
1843
},
1834
1844
showPageBackground: {
1835
-
type: 'boolean',
1845
+
type: "boolean",
1836
1846
default: false,
1837
1847
},
1838
1848
accentBackground: {
1839
-
type: 'union',
1849
+
type: "union",
1840
1850
refs: [
1841
-
'lex:pub.leaflet.theme.color#rgba',
1842
-
'lex:pub.leaflet.theme.color#rgb',
1851
+
"lex:pub.leaflet.theme.color#rgba",
1852
+
"lex:pub.leaflet.theme.color#rgb",
1843
1853
],
1844
1854
},
1845
1855
accentText: {
1846
-
type: 'union',
1856
+
type: "union",
1847
1857
refs: [
1848
-
'lex:pub.leaflet.theme.color#rgba',
1849
-
'lex:pub.leaflet.theme.color#rgb',
1858
+
"lex:pub.leaflet.theme.color#rgba",
1859
+
"lex:pub.leaflet.theme.color#rgb",
1850
1860
],
1851
1861
},
1852
1862
},
···
1855
1865
},
1856
1866
PubLeafletRichtextFacet: {
1857
1867
lexicon: 1,
1858
-
id: 'pub.leaflet.richtext.facet',
1868
+
id: "pub.leaflet.richtext.facet",
1859
1869
defs: {
1860
1870
main: {
1861
-
type: 'object',
1862
-
description: 'Annotation of a sub-string within rich text.',
1863
-
required: ['index', 'features'],
1871
+
type: "object",
1872
+
description: "Annotation of a sub-string within rich text.",
1873
+
required: ["index", "features"],
1864
1874
properties: {
1865
1875
index: {
1866
-
type: 'ref',
1867
-
ref: 'lex:pub.leaflet.richtext.facet#byteSlice',
1876
+
type: "ref",
1877
+
ref: "lex:pub.leaflet.richtext.facet#byteSlice",
1868
1878
},
1869
1879
features: {
1870
-
type: 'array',
1880
+
type: "array",
1871
1881
items: {
1872
-
type: 'union',
1882
+
type: "union",
1873
1883
refs: [
1874
-
'lex:pub.leaflet.richtext.facet#link',
1875
-
'lex:pub.leaflet.richtext.facet#didMention',
1876
-
'lex:pub.leaflet.richtext.facet#atMention',
1877
-
'lex:pub.leaflet.richtext.facet#code',
1878
-
'lex:pub.leaflet.richtext.facet#highlight',
1879
-
'lex:pub.leaflet.richtext.facet#underline',
1880
-
'lex:pub.leaflet.richtext.facet#strikethrough',
1881
-
'lex:pub.leaflet.richtext.facet#id',
1882
-
'lex:pub.leaflet.richtext.facet#bold',
1883
-
'lex:pub.leaflet.richtext.facet#italic',
1884
+
"lex:pub.leaflet.richtext.facet#link",
1885
+
"lex:pub.leaflet.richtext.facet#didMention",
1886
+
"lex:pub.leaflet.richtext.facet#atMention",
1887
+
"lex:pub.leaflet.richtext.facet#code",
1888
+
"lex:pub.leaflet.richtext.facet#highlight",
1889
+
"lex:pub.leaflet.richtext.facet#underline",
1890
+
"lex:pub.leaflet.richtext.facet#strikethrough",
1891
+
"lex:pub.leaflet.richtext.facet#id",
1892
+
"lex:pub.leaflet.richtext.facet#bold",
1893
+
"lex:pub.leaflet.richtext.facet#italic",
1884
1894
],
1885
1895
},
1886
1896
},
1887
1897
},
1888
1898
},
1889
1899
byteSlice: {
1890
-
type: 'object',
1900
+
type: "object",
1891
1901
description:
1892
-
'Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.',
1893
-
required: ['byteStart', 'byteEnd'],
1902
+
"Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.",
1903
+
required: ["byteStart", "byteEnd"],
1894
1904
properties: {
1895
1905
byteStart: {
1896
-
type: 'integer',
1906
+
type: "integer",
1897
1907
minimum: 0,
1898
1908
},
1899
1909
byteEnd: {
1900
-
type: 'integer',
1910
+
type: "integer",
1901
1911
minimum: 0,
1902
1912
},
1903
1913
},
1904
1914
},
1905
1915
link: {
1906
-
type: 'object',
1916
+
type: "object",
1907
1917
description:
1908
-
'Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.',
1909
-
required: ['uri'],
1918
+
"Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.",
1919
+
required: ["uri"],
1910
1920
properties: {
1911
1921
uri: {
1912
-
type: 'string',
1922
+
type: "string",
1913
1923
},
1914
1924
},
1915
1925
},
1916
1926
didMention: {
1917
-
type: 'object',
1918
-
description: 'Facet feature for mentioning a did.',
1919
-
required: ['did'],
1927
+
type: "object",
1928
+
description: "Facet feature for mentioning a did.",
1929
+
required: ["did"],
1920
1930
properties: {
1921
1931
did: {
1922
-
type: 'string',
1923
-
format: 'did',
1932
+
type: "string",
1933
+
format: "did",
1924
1934
},
1925
1935
},
1926
1936
},
1927
1937
atMention: {
1928
-
type: 'object',
1929
-
description: 'Facet feature for mentioning an AT URI.',
1930
-
required: ['atURI'],
1938
+
type: "object",
1939
+
description: "Facet feature for mentioning an AT URI.",
1940
+
required: ["atURI"],
1931
1941
properties: {
1932
1942
atURI: {
1933
-
type: 'string',
1934
-
format: 'uri',
1943
+
type: "string",
1944
+
format: "uri",
1935
1945
},
1936
1946
},
1937
1947
},
1938
1948
code: {
1939
-
type: 'object',
1940
-
description: 'Facet feature for inline code.',
1949
+
type: "object",
1950
+
description: "Facet feature for inline code.",
1941
1951
required: [],
1942
1952
properties: {},
1943
1953
},
1944
1954
highlight: {
1945
-
type: 'object',
1946
-
description: 'Facet feature for highlighted text.',
1955
+
type: "object",
1956
+
description: "Facet feature for highlighted text.",
1947
1957
required: [],
1948
1958
properties: {},
1949
1959
},
1950
1960
underline: {
1951
-
type: 'object',
1952
-
description: 'Facet feature for underline markup',
1961
+
type: "object",
1962
+
description: "Facet feature for underline markup",
1953
1963
required: [],
1954
1964
properties: {},
1955
1965
},
1956
1966
strikethrough: {
1957
-
type: 'object',
1958
-
description: 'Facet feature for strikethrough markup',
1967
+
type: "object",
1968
+
description: "Facet feature for strikethrough markup",
1959
1969
required: [],
1960
1970
properties: {},
1961
1971
},
1962
1972
id: {
1963
-
type: 'object',
1973
+
type: "object",
1964
1974
description:
1965
-
'Facet feature for an identifier. Used for linking to a segment',
1975
+
"Facet feature for an identifier. Used for linking to a segment",
1966
1976
required: [],
1967
1977
properties: {
1968
1978
id: {
1969
-
type: 'string',
1979
+
type: "string",
1970
1980
},
1971
1981
},
1972
1982
},
1973
1983
bold: {
1974
-
type: 'object',
1975
-
description: 'Facet feature for bold text',
1984
+
type: "object",
1985
+
description: "Facet feature for bold text",
1976
1986
required: [],
1977
1987
properties: {},
1978
1988
},
1979
1989
italic: {
1980
-
type: 'object',
1981
-
description: 'Facet feature for italic text',
1990
+
type: "object",
1991
+
description: "Facet feature for italic text",
1982
1992
required: [],
1983
1993
properties: {},
1984
1994
},
···
1986
1996
},
1987
1997
PubLeafletThemeBackgroundImage: {
1988
1998
lexicon: 1,
1989
-
id: 'pub.leaflet.theme.backgroundImage',
1999
+
id: "pub.leaflet.theme.backgroundImage",
1990
2000
defs: {
1991
2001
main: {
1992
-
type: 'object',
1993
-
required: ['image'],
2002
+
type: "object",
2003
+
required: ["image"],
1994
2004
properties: {
1995
2005
image: {
1996
-
type: 'blob',
1997
-
accept: ['image/*'],
2006
+
type: "blob",
2007
+
accept: ["image/*"],
1998
2008
maxSize: 1000000,
1999
2009
},
2000
2010
width: {
2001
-
type: 'integer',
2011
+
type: "integer",
2002
2012
},
2003
2013
repeat: {
2004
-
type: 'boolean',
2014
+
type: "boolean",
2005
2015
},
2006
2016
},
2007
2017
},
···
2009
2019
},
2010
2020
PubLeafletThemeColor: {
2011
2021
lexicon: 1,
2012
-
id: 'pub.leaflet.theme.color',
2022
+
id: "pub.leaflet.theme.color",
2013
2023
defs: {
2014
2024
rgba: {
2015
-
type: 'object',
2016
-
required: ['r', 'g', 'b', 'a'],
2025
+
type: "object",
2026
+
required: ["r", "g", "b", "a"],
2017
2027
properties: {
2018
2028
r: {
2019
-
type: 'integer',
2029
+
type: "integer",
2020
2030
maximum: 255,
2021
2031
minimum: 0,
2022
2032
},
2023
2033
g: {
2024
-
type: 'integer',
2034
+
type: "integer",
2025
2035
maximum: 255,
2026
2036
minimum: 0,
2027
2037
},
2028
2038
b: {
2029
-
type: 'integer',
2039
+
type: "integer",
2030
2040
maximum: 255,
2031
2041
minimum: 0,
2032
2042
},
2033
2043
a: {
2034
-
type: 'integer',
2044
+
type: "integer",
2035
2045
maximum: 100,
2036
2046
minimum: 0,
2037
2047
},
2038
2048
},
2039
2049
},
2040
2050
rgb: {
2041
-
type: 'object',
2042
-
required: ['r', 'g', 'b'],
2051
+
type: "object",
2052
+
required: ["r", "g", "b"],
2043
2053
properties: {
2044
2054
r: {
2045
-
type: 'integer',
2055
+
type: "integer",
2046
2056
maximum: 255,
2047
2057
minimum: 0,
2048
2058
},
2049
2059
g: {
2050
-
type: 'integer',
2060
+
type: "integer",
2051
2061
maximum: 255,
2052
2062
minimum: 0,
2053
2063
},
2054
2064
b: {
2055
-
type: 'integer',
2065
+
type: "integer",
2056
2066
maximum: 255,
2057
2067
minimum: 0,
2058
2068
},
···
2060
2070
},
2061
2071
},
2062
2072
},
2063
-
} as const satisfies Record<string, LexiconDoc>
2064
-
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
2065
-
export const lexicons: Lexicons = new Lexicons(schemas)
2073
+
} as const satisfies Record<string, LexiconDoc>;
2074
+
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[];
2075
+
export const lexicons: Lexicons = new Lexicons(schemas);
2066
2076
2067
2077
export function validate<T extends { $type: string }>(
2068
2078
v: unknown,
2069
2079
id: string,
2070
2080
hash: string,
2071
2081
requiredType: true,
2072
-
): ValidationResult<T>
2082
+
): ValidationResult<T>;
2073
2083
export function validate<T extends { $type?: string }>(
2074
2084
v: unknown,
2075
2085
id: string,
2076
2086
hash: string,
2077
2087
requiredType?: false,
2078
-
): ValidationResult<T>
2088
+
): ValidationResult<T>;
2079
2089
export function validate(
2080
2090
v: unknown,
2081
2091
id: string,
···
2087
2097
: {
2088
2098
success: false,
2089
2099
error: new ValidationError(
2090
-
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
2100
+
`Must be an object with "${hash === "main" ? id : `${id}#${hash}`}" $type property`,
2091
2101
),
2092
-
}
2102
+
};
2093
2103
}
2094
2104
2095
2105
export const ids = {
2096
-
AppBskyActorProfile: 'app.bsky.actor.profile',
2097
-
ComAtprotoLabelDefs: 'com.atproto.label.defs',
2098
-
ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites',
2099
-
ComAtprotoRepoCreateRecord: 'com.atproto.repo.createRecord',
2100
-
ComAtprotoRepoDefs: 'com.atproto.repo.defs',
2101
-
ComAtprotoRepoDeleteRecord: 'com.atproto.repo.deleteRecord',
2102
-
ComAtprotoRepoDescribeRepo: 'com.atproto.repo.describeRepo',
2103
-
ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord',
2104
-
ComAtprotoRepoImportRepo: 'com.atproto.repo.importRepo',
2105
-
ComAtprotoRepoListMissingBlobs: 'com.atproto.repo.listMissingBlobs',
2106
-
ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords',
2107
-
ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord',
2108
-
ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef',
2109
-
ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob',
2110
-
PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote',
2111
-
PubLeafletBlocksBskyPost: 'pub.leaflet.blocks.bskyPost',
2112
-
PubLeafletBlocksButton: 'pub.leaflet.blocks.button',
2113
-
PubLeafletBlocksCode: 'pub.leaflet.blocks.code',
2114
-
PubLeafletBlocksHeader: 'pub.leaflet.blocks.header',
2115
-
PubLeafletBlocksHorizontalRule: 'pub.leaflet.blocks.horizontalRule',
2116
-
PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe',
2117
-
PubLeafletBlocksImage: 'pub.leaflet.blocks.image',
2118
-
PubLeafletBlocksMath: 'pub.leaflet.blocks.math',
2119
-
PubLeafletBlocksPage: 'pub.leaflet.blocks.page',
2120
-
PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll',
2121
-
PubLeafletBlocksText: 'pub.leaflet.blocks.text',
2122
-
PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList',
2123
-
PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website',
2124
-
PubLeafletComment: 'pub.leaflet.comment',
2125
-
PubLeafletDocument: 'pub.leaflet.document',
2126
-
PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription',
2127
-
PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas',
2128
-
PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument',
2129
-
PubLeafletPollDefinition: 'pub.leaflet.poll.definition',
2130
-
PubLeafletPollVote: 'pub.leaflet.poll.vote',
2131
-
PubLeafletPublication: 'pub.leaflet.publication',
2132
-
PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet',
2133
-
PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage',
2134
-
PubLeafletThemeColor: 'pub.leaflet.theme.color',
2135
-
} as const
2106
+
AppBskyActorProfile: "app.bsky.actor.profile",
2107
+
ComAtprotoLabelDefs: "com.atproto.label.defs",
2108
+
ComAtprotoRepoApplyWrites: "com.atproto.repo.applyWrites",
2109
+
ComAtprotoRepoCreateRecord: "com.atproto.repo.createRecord",
2110
+
ComAtprotoRepoDefs: "com.atproto.repo.defs",
2111
+
ComAtprotoRepoDeleteRecord: "com.atproto.repo.deleteRecord",
2112
+
ComAtprotoRepoDescribeRepo: "com.atproto.repo.describeRepo",
2113
+
ComAtprotoRepoGetRecord: "com.atproto.repo.getRecord",
2114
+
ComAtprotoRepoImportRepo: "com.atproto.repo.importRepo",
2115
+
ComAtprotoRepoListMissingBlobs: "com.atproto.repo.listMissingBlobs",
2116
+
ComAtprotoRepoListRecords: "com.atproto.repo.listRecords",
2117
+
ComAtprotoRepoPutRecord: "com.atproto.repo.putRecord",
2118
+
ComAtprotoRepoStrongRef: "com.atproto.repo.strongRef",
2119
+
ComAtprotoRepoUploadBlob: "com.atproto.repo.uploadBlob",
2120
+
PubLeafletBlocksBlockquote: "pub.leaflet.blocks.blockquote",
2121
+
PubLeafletBlocksBskyPost: "pub.leaflet.blocks.bskyPost",
2122
+
PubLeafletBlocksButton: "pub.leaflet.blocks.button",
2123
+
PubLeafletBlocksCode: "pub.leaflet.blocks.code",
2124
+
PubLeafletBlocksHeader: "pub.leaflet.blocks.header",
2125
+
PubLeafletBlocksHorizontalRule: "pub.leaflet.blocks.horizontalRule",
2126
+
PubLeafletBlocksIframe: "pub.leaflet.blocks.iframe",
2127
+
PubLeafletBlocksImage: "pub.leaflet.blocks.image",
2128
+
PubLeafletBlocksMath: "pub.leaflet.blocks.math",
2129
+
PubLeafletBlocksPage: "pub.leaflet.blocks.page",
2130
+
PubLeafletBlocksPoll: "pub.leaflet.blocks.poll",
2131
+
PubLeafletBlocksText: "pub.leaflet.blocks.text",
2132
+
PubLeafletBlocksUnorderedList: "pub.leaflet.blocks.unorderedList",
2133
+
PubLeafletBlocksWebsite: "pub.leaflet.blocks.website",
2134
+
PubLeafletComment: "pub.leaflet.comment",
2135
+
PubLeafletDocument: "pub.leaflet.document",
2136
+
PubLeafletGraphSubscription: "pub.leaflet.graph.subscription",
2137
+
PubLeafletPagesCanvas: "pub.leaflet.pages.canvas",
2138
+
PubLeafletPagesLinearDocument: "pub.leaflet.pages.linearDocument",
2139
+
PubLeafletPollDefinition: "pub.leaflet.poll.definition",
2140
+
PubLeafletPollVote: "pub.leaflet.poll.vote",
2141
+
PubLeafletPublication: "pub.leaflet.publication",
2142
+
PubLeafletRichtextFacet: "pub.leaflet.richtext.facet",
2143
+
PubLeafletThemeBackgroundImage: "pub.leaflet.theme.backgroundImage",
2144
+
PubLeafletThemeColor: "pub.leaflet.theme.color",
2145
+
} as const;
+1
lexicons/api/types/pub/leaflet/document.ts
+1
lexicons/api/types/pub/leaflet/document.ts
+41
-36
lexicons/api/types/pub/leaflet/publication.ts
+41
-36
lexicons/api/types/pub/leaflet/publication.ts
···
1
1
/**
2
2
* GENERATED CODE - DO NOT MODIFY
3
3
*/
4
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
6
-
import { validate as _validate } from '../../../lexicons'
7
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
-
import type * as PubLeafletThemeColor from './theme/color'
9
-
import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage'
4
+
import { type ValidationResult, BlobRef } from "@atproto/lexicon";
5
+
import { CID } from "multiformats/cid";
6
+
import { validate as _validate } from "../../../lexicons";
7
+
import {
8
+
type $Typed,
9
+
is$typed as _is$typed,
10
+
type OmitKey,
11
+
} from "../../../util";
12
+
import type * as PubLeafletThemeColor from "./theme/color";
13
+
import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage";
10
14
11
15
const is$typed = _is$typed,
12
-
validate = _validate
13
-
const id = 'pub.leaflet.publication'
16
+
validate = _validate;
17
+
const id = "pub.leaflet.publication";
14
18
15
19
export interface Record {
16
-
$type: 'pub.leaflet.publication'
17
-
name: string
18
-
base_path?: string
19
-
description?: string
20
-
icon?: BlobRef
21
-
theme?: Theme
22
-
preferences?: Preferences
23
-
[k: string]: unknown
20
+
$type: "pub.leaflet.publication";
21
+
name: string;
22
+
base_path?: string;
23
+
description?: string;
24
+
icon?: BlobRef;
25
+
theme?: Theme;
26
+
preferences?: Preferences;
27
+
[k: string]: unknown;
24
28
}
25
29
26
-
const hashRecord = 'main'
30
+
const hashRecord = "main";
27
31
28
32
export function isRecord<V>(v: V) {
29
-
return is$typed(v, id, hashRecord)
33
+
return is$typed(v, id, hashRecord);
30
34
}
31
35
32
36
export function validateRecord<V>(v: V) {
33
-
return validate<Record & V>(v, id, hashRecord, true)
37
+
return validate<Record & V>(v, id, hashRecord, true);
34
38
}
35
39
36
40
export interface Preferences {
37
-
$type?: 'pub.leaflet.publication#preferences'
38
-
showInDiscover: boolean
39
-
showComments: boolean
41
+
$type?: "pub.leaflet.publication#preferences";
42
+
showInDiscover: boolean;
43
+
showComments: boolean;
40
44
}
41
45
42
-
const hashPreferences = 'preferences'
46
+
const hashPreferences = "preferences";
43
47
44
48
export function isPreferences<V>(v: V) {
45
-
return is$typed(v, id, hashPreferences)
49
+
return is$typed(v, id, hashPreferences);
46
50
}
47
51
48
52
export function validatePreferences<V>(v: V) {
49
-
return validate<Preferences & V>(v, id, hashPreferences)
53
+
return validate<Preferences & V>(v, id, hashPreferences);
50
54
}
51
55
52
56
export interface Theme {
53
-
$type?: 'pub.leaflet.publication#theme'
57
+
$type?: "pub.leaflet.publication#theme";
54
58
backgroundColor?:
55
59
| $Typed<PubLeafletThemeColor.Rgba>
56
60
| $Typed<PubLeafletThemeColor.Rgb>
57
-
| { $type: string }
58
-
backgroundImage?: PubLeafletThemeBackgroundImage.Main
61
+
| { $type: string };
62
+
backgroundImage?: PubLeafletThemeBackgroundImage.Main;
63
+
pageWidth?: number;
59
64
primary?:
60
65
| $Typed<PubLeafletThemeColor.Rgba>
61
66
| $Typed<PubLeafletThemeColor.Rgb>
62
-
| { $type: string }
67
+
| { $type: string };
63
68
pageBackground?:
64
69
| $Typed<PubLeafletThemeColor.Rgba>
65
70
| $Typed<PubLeafletThemeColor.Rgb>
66
-
| { $type: string }
67
-
showPageBackground: boolean
71
+
| { $type: string };
72
+
showPageBackground: boolean;
68
73
accentBackground?:
69
74
| $Typed<PubLeafletThemeColor.Rgba>
70
75
| $Typed<PubLeafletThemeColor.Rgb>
71
-
| { $type: string }
76
+
| { $type: string };
72
77
accentText?:
73
78
| $Typed<PubLeafletThemeColor.Rgba>
74
79
| $Typed<PubLeafletThemeColor.Rgb>
75
-
| { $type: string }
80
+
| { $type: string };
76
81
}
77
82
78
-
const hashTheme = 'theme'
83
+
const hashTheme = "theme";
79
84
80
85
export function isTheme<V>(v: V) {
81
-
return is$typed(v, id, hashTheme)
86
+
return is$typed(v, id, hashTheme);
82
87
}
83
88
84
89
export function validateTheme<V>(v: V) {
85
-
return validate<Theme & V>(v, id, hashTheme)
90
+
return validate<Theme & V>(v, id, hashTheme);
86
91
}
+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
+8
-7
lexicons/pub/leaflet/publication.json
+8
-7
lexicons/pub/leaflet/publication.json
···
8
8
"description": "Record declaring a publication",
9
9
"record": {
10
10
"type": "object",
11
-
"required": [
12
-
"name"
13
-
],
11
+
"required": ["name"],
14
12
"properties": {
15
13
"name": {
16
14
"type": "string",
···
25
23
},
26
24
"icon": {
27
25
"type": "blob",
28
-
"accept": [
29
-
"image/*"
30
-
],
26
+
"accept": ["image/*"],
31
27
"maxSize": 1000000
32
28
},
33
29
"theme": {
···
68
64
"type": "ref",
69
65
"ref": "pub.leaflet.theme.backgroundImage"
70
66
},
67
+
"pageWidth": {
68
+
"type": "integer",
69
+
"minimum": 0,
70
+
"maximum": 1600
71
+
},
71
72
"primary": {
72
73
"type": "union",
73
74
"refs": [
···
103
104
}
104
105
}
105
106
}
106
-
}
107
+
}
+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: {
+5
lexicons/src/publication.ts
+5
lexicons/src/publication.ts
+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";