tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
wire up tags
awarm.space
3 months ago
46a7d8ad
fe8a06e3
+363
-88
19 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
searchTags.ts
app
(home-pages)
tag
[tag]
getDocumentsByTag.ts
page.tsx
[leaflet_id]
actions
PublishButton.tsx
publish
PublishPost.tsx
api
rpc
[command]
pull.ts
lish
[did]
[publication]
[rkey]
Interactions
Interactions.tsx
dashboard
PublishedPostsLists.tsx
page.tsx
components
InteractionsPreview.tsx
Pages
PublicationMetadata.tsx
PostListing.tsx
Tags.tsx
lexicons
api
lexicons.ts
types
pub
leaflet
document.ts
pub
leaflet
document.json
src
document.ts
src
replicache
mutations.ts
+3
actions/publishToPublication.ts
···
57
57
leaflet_id,
58
58
title,
59
59
description,
60
60
+
tags,
60
61
entitiesToDelete,
61
62
}: {
62
63
root_entity: string;
···
64
65
leaflet_id: string;
65
66
title?: string;
66
67
description?: string;
68
68
+
tags?: string[];
67
69
entitiesToDelete?: string[];
68
70
}) {
69
71
const oauthClient = await createOauthClient();
···
143
145
...(theme && { theme }),
144
146
title: title || "Untitled",
145
147
description: description || "",
148
148
+
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
146
149
pages: pages.map((p) => {
147
150
if (p.type === "canvas") {
148
151
return {
+25
actions/searchTags.ts
···
1
1
+
"use server";
2
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
3
+
4
4
+
export type TagSearchResult = {
5
5
+
name: string;
6
6
+
document_count: number;
7
7
+
};
8
8
+
9
9
+
export async function searchTags(
10
10
+
query: string,
11
11
+
): Promise<TagSearchResult[] | null> {
12
12
+
const searchQuery = query.trim().toLowerCase();
13
13
+
14
14
+
// Use raw SQL query to extract and aggregate tags
15
15
+
const { data, error } = await supabaseServerClient.rpc("search_tags", {
16
16
+
search_query: searchQuery,
17
17
+
});
18
18
+
19
19
+
if (error) {
20
20
+
console.error("Error searching tags:", error);
21
21
+
return null;
22
22
+
}
23
23
+
24
24
+
return data;
25
25
+
}
+71
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
5
+
import { AtUri } from "@atproto/api";
6
6
+
import { Json } from "supabase/database.types";
7
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
8
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
9
9
+
10
10
+
export async function getDocumentsByTag(
11
11
+
tag: string,
12
12
+
): Promise<{ posts: Post[] }> {
13
13
+
// Normalize tag to lowercase for case-insensitive search
14
14
+
const normalizedTag = tag.toLowerCase();
15
15
+
16
16
+
// Query documents that have this tag
17
17
+
const { data: documents, error } = await supabaseServerClient
18
18
+
.from("documents")
19
19
+
.select(
20
20
+
`*,
21
21
+
comments_on_documents(count),
22
22
+
document_mentions_in_bsky(count),
23
23
+
documents_in_publications(publications(*))`,
24
24
+
)
25
25
+
.contains("data->tags", `["${normalizedTag}"]`)
26
26
+
.order("indexed_at", { ascending: false })
27
27
+
.limit(50);
28
28
+
29
29
+
if (error) {
30
30
+
console.error("Error fetching documents by tag:", error);
31
31
+
return { posts: [] };
32
32
+
}
33
33
+
34
34
+
const posts = await Promise.all(
35
35
+
documents.map(async (doc) => {
36
36
+
const pub = doc.documents_in_publications[0]?.publications;
37
37
+
38
38
+
// Skip if document doesn't have a publication
39
39
+
if (!pub) {
40
40
+
return null;
41
41
+
}
42
42
+
43
43
+
const uri = new AtUri(doc.uri);
44
44
+
const handle = await idResolver.did.resolve(uri.host);
45
45
+
46
46
+
const post: Post = {
47
47
+
publication: {
48
48
+
href: getPublicationURL(pub),
49
49
+
pubRecord: pub?.record || null,
50
50
+
uri: pub?.uri || "",
51
51
+
},
52
52
+
author: handle?.alsoKnownAs?.[0]
53
53
+
? `@${handle.alsoKnownAs[0].slice(5)}`
54
54
+
: null,
55
55
+
documents: {
56
56
+
comments_on_documents: doc.comments_on_documents,
57
57
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
58
58
+
data: doc.data,
59
59
+
uri: doc.uri,
60
60
+
indexed_at: doc.indexed_at,
61
61
+
},
62
62
+
};
63
63
+
return post;
64
64
+
}),
65
65
+
);
66
66
+
67
67
+
// Filter out null entries (documents without publications)
68
68
+
return {
69
69
+
posts: posts.filter((p): p is Post => p !== null),
70
70
+
};
71
71
+
}
+75
app/(home-pages)/tag/[tag]/page.tsx
···
1
1
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
2
2
+
import { Tag } from "components/Tags";
3
3
+
import { PostListing } from "components/PostListing";
4
4
+
import { getDocumentsByTag } from "./getDocumentsByTag";
5
5
+
import { TagTiny } from "components/Icons/TagTiny";
6
6
+
7
7
+
export default async function TagPage(props: {
8
8
+
params: Promise<{ tag: string }>;
9
9
+
}) {
10
10
+
const params = await props.params;
11
11
+
const decodedTag = decodeURIComponent(params.tag);
12
12
+
const { posts } = await getDocumentsByTag(decodedTag);
13
13
+
14
14
+
return (
15
15
+
<DashboardLayout
16
16
+
id="tag"
17
17
+
cardBorderHidden={false}
18
18
+
currentPage="tag"
19
19
+
defaultTab="default"
20
20
+
actions={null}
21
21
+
tabs={{
22
22
+
default: {
23
23
+
controls: null,
24
24
+
content: <TagContent tag={decodedTag} posts={posts} />,
25
25
+
},
26
26
+
}}
27
27
+
/>
28
28
+
);
29
29
+
}
30
30
+
31
31
+
const TagContent = (props: {
32
32
+
tag: string;
33
33
+
posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"];
34
34
+
}) => {
35
35
+
return (
36
36
+
<div className="max-w-prose mx-auto w-full grow shrink-0">
37
37
+
<div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4">
38
38
+
<TagHeader tag={props.tag} postCount={props.posts.length} />
39
39
+
</div>
40
40
+
<div className="pt-6 flex flex-col gap-3">
41
41
+
{props.posts.length === 0 ? (
42
42
+
<EmptyState tag={props.tag} />
43
43
+
) : (
44
44
+
props.posts.map((post) => (
45
45
+
<PostListing key={post.documents.uri} {...post} />
46
46
+
))
47
47
+
)}
48
48
+
</div>
49
49
+
</div>
50
50
+
);
51
51
+
};
52
52
+
53
53
+
const TagHeader = (props: { tag: string; postCount: number }) => {
54
54
+
return (
55
55
+
<div className="flex flex-col gap-2 items-center">
56
56
+
<div className="flex items-center gap-2 text-4xl font-bold text-primary">
57
57
+
<TagTiny className="scale-150" />
58
58
+
<h1>{props.tag}</h1>
59
59
+
</div>
60
60
+
<div className="text-tertiary text-sm">
61
61
+
{props.postCount} {props.postCount === 1 ? "post" : "posts"}
62
62
+
</div>
63
63
+
</div>
64
64
+
);
65
65
+
};
66
66
+
67
67
+
const EmptyState = (props: { tag: string }) => {
68
68
+
return (
69
69
+
<div className="flex flex-col gap-2 items-center justify-center p-8 text-center">
70
70
+
<div className="text-tertiary">
71
71
+
No posts found with the tag "{props.tag}"
72
72
+
</div>
73
73
+
</div>
74
74
+
);
75
75
+
};
+7
-1
app/[leaflet_id]/actions/PublishButton.tsx
···
27
27
import { useState, useMemo } from "react";
28
28
import { useIsMobile } from "src/hooks/isMobile";
29
29
import { useReplicache, useEntity } from "src/replicache";
30
30
+
import { useSubscribe } from "src/replicache/useSubscribe";
30
31
import { Json } from "supabase/database.types";
31
32
import {
32
33
useBlocks,
···
63
64
const UpdateButton = () => {
64
65
let [isLoading, setIsLoading] = useState(false);
65
66
let { data: pub, mutate } = useLeafletPublicationData();
66
66
-
let { permission_token, rootEntity } = useReplicache();
67
67
+
let { permission_token, rootEntity, rep } = useReplicache();
67
68
let { identity } = useIdentityData();
68
69
let toaster = useToaster();
70
70
+
71
71
+
// Get tags from Replicache state (same as draft editor)
72
72
+
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73
73
+
const currentTags = Array.isArray(tags) ? tags : [];
69
74
70
75
return (
71
76
<ActionButton
···
81
86
leaflet_id: permission_token.id,
82
87
title: pub.title,
83
88
description: pub.description,
89
89
+
tags: currentTags,
84
90
});
85
91
setIsLoading(false);
86
92
mutate();
+22
app/[leaflet_id]/publish/PublishPost.tsx
···
13
13
import { AtUri } from "@atproto/syntax";
14
14
import { PublishIllustration } from "./PublishIllustration/PublishIllustration";
15
15
import { useReplicache } from "src/replicache";
16
16
+
import { useSubscribe } from "src/replicache/useSubscribe";
16
17
import {
17
18
BlueskyPostEditorProsemirror,
18
19
editorStateToFacetedText,
···
66
67
let params = useParams();
67
68
let { rep } = useReplicache();
68
69
70
70
+
// Get tags from Replicache state (same as in the draft editor)
71
71
+
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
72
72
+
73
73
+
// Default to empty array if undefined
74
74
+
const currentTags = Array.isArray(tags) ? tags : [];
75
75
+
76
76
+
// Update tags via the same mutation used in the editor
77
77
+
const handleTagsChange = async (newTags: string[]) => {
78
78
+
await rep?.mutate.updatePublicationDraft({
79
79
+
tags: newTags,
80
80
+
});
81
81
+
};
82
82
+
69
83
async function submit() {
70
84
if (isLoading) return;
71
85
setIsLoading(true);
···
76
90
leaflet_id: props.leaflet_id,
77
91
title: props.title,
78
92
description: props.description,
93
93
+
tags: currentTags,
79
94
entitiesToDelete: props.entitiesToDelete,
80
95
});
81
96
if (!doc) return;
···
125
140
{...props}
126
141
/>
127
142
<hr className="border-border-light " />
143
143
+
<div className="flex flex-col gap-1">
144
144
+
<h4>Tags</h4>
145
145
+
<TagSelector
146
146
+
selectedTags={currentTags}
147
147
+
setSelectedTags={handleTagsChange}
148
148
+
/>
149
149
+
</div>
128
150
<div className="flex justify-between">
129
151
<Link
130
152
className="hover:no-underline! font-bold"
+6
app/api/rpc/[command]/pull.ts
···
73
73
let publication_data = data.publications as {
74
74
description: string;
75
75
title: string;
76
76
+
tags: string[];
76
77
}[];
77
78
let pub_patch = publication_data?.[0]
78
79
? [
···
85
86
op: "put",
86
87
key: "publication_title",
87
88
value: publication_data[0].title,
89
89
+
},
90
90
+
{
91
91
+
op: "put",
92
92
+
key: "publication_tags",
93
93
+
value: publication_data[0].tags || [],
88
94
},
89
95
]
90
96
: [];
+13
-12
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
175
175
};
176
176
177
177
const TagPopover = () => {
178
178
+
const data = useContext(PostPageContext);
179
179
+
const tags = (data?.data as any)?.tags as string[] | undefined;
180
180
+
const tagCount = tags?.length || 0;
181
181
+
182
182
+
if (tagCount === 0) return null;
183
183
+
178
184
return (
179
185
<Popover
180
186
className="p-2! max-w-xs"
181
187
trigger={
182
188
<div className="flex gap-1 items-center ">
183
183
-
<TagTiny /> XX
189
189
+
<TagTiny /> {tagCount}
184
190
</div>
185
191
}
186
192
>
···
190
196
};
191
197
192
198
const TagList = (props: { className?: string }) => {
199
199
+
const data = useContext(PostPageContext);
200
200
+
const tags = (data?.data as any)?.tags as string[] | undefined;
201
201
+
202
202
+
if (!tags || tags.length === 0) return null;
203
203
+
193
204
return (
194
205
<div className="flex gap-1 flex-wrap">
195
195
-
{Tags.map((tag, index) => (
206
206
+
{tags.map((tag, index) => (
196
207
<Tag name={tag} key={index} className={props.className} />
197
208
))}
198
209
</div>
199
210
);
200
211
};
201
201
-
202
202
-
const Tags = [
203
203
-
"Hello",
204
204
-
"these are",
205
205
-
"some tags",
206
206
-
"and I'm gonna",
207
207
-
"make",
208
208
-
"them super",
209
209
-
"long",
210
210
-
];
211
212
export function getQuoteCount(document: PostPageData, pageId?: string) {
212
213
if (!document) return;
213
214
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
+2
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
···
61
61
let postRecord = doc.documents.data as PubLeafletDocument.Record;
62
62
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
63
63
let comments = doc.documents.comments_on_documents[0]?.count || 0;
64
64
+
let tags = (postRecord?.tags as string[] | undefined) || [];
64
65
65
66
let postLink = data?.publication
66
67
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
···
137
138
<InteractionPreview
138
139
quotesCount={quotes}
139
140
commentsCount={comments}
140
140
-
tagsCount={6}
141
141
+
tags={tags}
141
142
showComments={pubRecord?.preferences?.showComments}
142
143
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
143
144
/>
+8
-17
app/lish/[did]/[publication]/page.tsx
···
135
135
record?.preferences?.showComments === false
136
136
? 0
137
137
: doc.documents.comments_on_documents[0].count || 0;
138
138
+
let tags = (doc_record?.tags as string[] | undefined) || [];
138
139
139
140
return (
140
141
<React.Fragment key={doc.documents?.uri}>
···
163
164
)}{" "}
164
165
</p>
165
166
{comments > 0 || quotes > 0 ? "| " : ""}
166
166
-
{quotes > 0 && (
167
167
-
<SpeedyLink
168
168
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
169
169
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
170
170
-
>
171
171
-
<QuoteTiny /> {quotes}
172
172
-
</SpeedyLink>
173
173
-
)}
174
174
-
{comments > 0 &&
175
175
-
record?.preferences?.showComments !== false && (
176
176
-
<SpeedyLink
177
177
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
178
178
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
179
179
-
>
180
180
-
<CommentTiny /> {comments}
181
181
-
</SpeedyLink>
182
182
-
)}
167
167
+
<InteractionPreview
168
168
+
quotesCount={quotes}
169
169
+
commentsCount={comments}
170
170
+
tags={tags}
171
171
+
postUrl=""
172
172
+
showComments={record?.preferences?.showComments}
173
173
+
/>
183
174
</div>
184
175
</div>
185
176
<hr className="last:hidden border-border-light" />
+10
-18
components/InteractionsPreview.tsx
···
11
11
export const InteractionPreview = (props: {
12
12
quotesCount: number;
13
13
commentsCount: number;
14
14
-
tagsCount: number;
14
14
+
tags?: string[];
15
15
postUrl: string;
16
16
showComments: boolean | undefined;
17
17
share?: boolean;
···
20
20
let interactionsAvailable =
21
21
props.quotesCount > 0 ||
22
22
(props.showComments !== false && props.commentsCount > 0);
23
23
+
24
24
+
const tagsCount = props.tags?.length || 0;
23
25
24
26
return (
25
27
<div
26
28
className={`flex gap-2 text-tertiary text-sm items-center self-start`}
27
29
>
28
28
-
{props.tagsCount === 0 ? null : (
30
30
+
{tagsCount === 0 ? null : (
29
31
<>
30
30
-
<TagPopover tagsCount={props.tagsCount} />
32
32
+
<TagPopover tags={props.tags!} />
31
33
{interactionsAvailable || props.share ? (
32
34
<Separator classname="h-4" />
33
35
) : null}
···
86
88
);
87
89
};
88
90
89
89
-
const TagPopover = (props: { tagsCount: number }) => {
91
91
+
const TagPopover = (props: { tags: string[] }) => {
90
92
return (
91
93
<Popover
92
94
className="p-2! max-w-xs"
···
96
98
aria-label="Post tags"
97
99
className="relative flex gap-1 items-center "
98
100
>
99
99
-
<TagTiny /> {props.tagsCount}
101
101
+
<TagTiny /> {props.tags.length}
100
102
</button>
101
103
}
102
104
>
103
103
-
<TagList className="text-secondary!" />
105
105
+
<TagList tags={props.tags} className="text-secondary!" />
104
106
</Popover>
105
107
);
106
108
};
107
109
108
108
-
const TagList = (props: { className?: string }) => {
110
110
+
const TagList = (props: { tags: string[]; className?: string }) => {
109
111
return (
110
112
<div className="flex gap-1 flex-wrap">
111
111
-
{Tags.map((tag, index) => (
113
113
+
{props.tags.map((tag, index) => (
112
114
<Tag name={tag} key={index} className={props.className} />
113
115
))}
114
116
</div>
115
117
);
116
118
};
117
117
-
118
118
-
const Tags = [
119
119
-
"Hello",
120
120
-
"these are",
121
121
-
"some tags",
122
122
-
"and I'm gonna",
123
123
-
"make",
124
124
-
"them super",
125
125
-
"long",
126
126
-
];
+34
-5
components/Pages/PublicationMetadata.tsx
···
28
28
tx.get<string>("publication_description"),
29
29
);
30
30
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
31
31
-
let pubRecord = pub?.publications?.record as PubLeafletPublication.Record;
31
31
+
let pubRecord = pub?.publications?.record as
32
32
+
| PubLeafletPublication.Record
33
33
+
| undefined;
32
34
let publishedAt = record?.publishedAt;
33
35
34
36
if (!pub) return null;
···
111
113
<div className="flex gap-1 items-center">
112
114
<QuoteTiny />—
113
115
</div>
114
114
-
{pubRecord.preferences?.showComments && (
116
116
+
{pubRecord?.preferences?.showComments && (
115
117
<div className="flex gap-1 items-center">
116
118
<CommentTiny />—
117
119
</div>
···
232
234
};
233
235
234
236
const AddTags = () => {
235
235
-
// Just update the database with tags as the user adds them, no explicit submit button
237
237
+
let { data: pub } = useLeafletPublicationData();
238
238
+
let { rep } = useReplicache();
239
239
+
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
240
240
+
241
241
+
// Get tags from Replicache local state or published document
242
242
+
let replicacheTags = useSubscribe(rep, (tx) =>
243
243
+
tx.get<string[]>("publication_tags"),
244
244
+
);
245
245
+
246
246
+
// Determine which tags to use - prioritize Replicache state
247
247
+
let tags: string[] = [];
248
248
+
if (Array.isArray(replicacheTags)) {
249
249
+
tags = replicacheTags;
250
250
+
} else if (record?.tags && Array.isArray(record.tags)) {
251
251
+
tags = record.tags as string[];
252
252
+
}
253
253
+
254
254
+
// Update tags in replicache local state
255
255
+
const handleTagsChange = async (newTags: string[]) => {
256
256
+
// Store tags in replicache for next publish/update
257
257
+
await rep?.mutate.updatePublicationDraft({
258
258
+
tags: newTags,
259
259
+
});
260
260
+
};
261
261
+
236
262
return (
237
263
<Popover
238
264
className="p-2! w-[1000px] max-w-sm"
239
265
trigger={
240
266
<div className="flex gap-1 hover:underline text-sm items-center text-tertiary">
241
241
-
<TagTiny /> Add Tags
267
267
+
<TagTiny />{" "}
268
268
+
{tags.length > 0
269
269
+
? `${tags.length} Tag${tags.length === 1 ? "" : "s"}`
270
270
+
: "Add Tags"}
242
271
</div>
243
272
}
244
273
>
245
245
-
<TagSelector />
274
274
+
<TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
246
275
</Popover>
247
276
);
248
277
};
+3
-2
components/PostListing.tsx
···
20
20
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
21
let postUri = new AtUri(props.documents.uri);
22
22
23
23
-
let theme = usePubTheme(pubRecord);
23
23
+
let theme = usePubTheme(pubRecord.theme);
24
24
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
25
25
? blobRefToSrc(
26
26
pubRecord?.theme?.backgroundImage?.image?.ref,
···
38
38
pubRecord.preferences?.showComments === false
39
39
? 0
40
40
: props.documents.comments_on_documents?.[0]?.count || 0;
41
41
+
let tags = (postRecord?.tags as string[] | undefined) || [];
41
42
42
43
return (
43
44
<BaseThemeProvider {...theme} local>
···
86
87
postUrl={`${props.publication.href}/${postUri.rkey}`}
87
88
quotesCount={quotes}
88
89
commentsCount={comments}
89
89
-
tagsCount={6}
90
90
+
tags={tags}
90
91
showComments={pubRecord.preferences?.showComments}
91
92
share
92
93
/>
+34
-24
components/Tags.tsx
···
4
4
import { useState, useRef, useEffect } from "react";
5
5
import { Popover } from "components/Popover";
6
6
import Link from "next/link";
7
7
-
8
8
-
const Tags: { name: string; tagged: number }[] = [
9
9
-
{ name: "dogs", tagged: 240 },
10
10
-
{ name: "cats", tagged: 12 },
11
11
-
{ name: "fruit enthusiam", tagged: 21 },
12
12
-
{ name: "at proto", tagged: 56 },
13
13
-
{ name: "events in nyc", tagged: 6 },
14
14
-
{ name: "react devs", tagged: 93 },
15
15
-
{ name: "fanfic", tagged: 1743 },
16
16
-
{ name: "pokemon", tagged: 81 },
17
17
-
];
7
7
+
import { searchTags, type TagSearchResult } from "actions/searchTags";
18
8
19
9
export const Tag = (props: {
20
10
name: string;
···
27
17
className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 text-accent-2 border-accent-1 font-bold" : "bg-bg-page text-tertiary border-border"} ${props.className}`}
28
18
>
29
19
<Link
30
30
-
href="/tag"
20
20
+
href={`/tag/${encodeURIComponent(props.name)}`}
31
21
className={`px-1 py-0.5 text-tertiary hover:no-underline!`}
32
22
>
33
23
{props.name}{" "}
···
44
34
);
45
35
};
46
36
47
47
-
export const TagSelector = (props: {}) => {
48
48
-
let [selectedTags, setSelectedTags] = useState<string[]>([]);
37
37
+
export const TagSelector = (props: {
38
38
+
selectedTags: string[];
39
39
+
setSelectedTags: (tags: string[]) => void;
40
40
+
}) => {
49
41
return (
50
42
<div className="flex flex-col gap-2">
51
43
<TagSearchInput
52
52
-
selectedTags={selectedTags}
53
53
-
setSelectedTags={setSelectedTags}
44
44
+
selectedTags={props.selectedTags}
45
45
+
setSelectedTags={props.setSelectedTags}
54
46
/>
55
55
-
{selectedTags.length > 0 ? (
47
47
+
{props.selectedTags.length > 0 ? (
56
48
<div className="flex flex-wrap gap-2 ">
57
57
-
{selectedTags.map((tag) => (
49
49
+
{props.selectedTags.map((tag) => (
58
50
<Tag
51
51
+
key={tag}
59
52
name={tag}
60
53
selected
61
54
onDelete={() => {
62
62
-
setSelectedTags(selectedTags.filter((t) => t !== tag));
55
55
+
props.setSelectedTags(props.selectedTags.filter((t) => t !== tag));
63
56
}}
64
57
/>
65
58
))}
···
78
71
let [tagInputValue, setTagInputValue] = useState("");
79
72
let [isOpen, setIsOpen] = useState(false);
80
73
let [highlightedIndex, setHighlightedIndex] = useState(0);
74
74
+
let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]);
75
75
+
let [isSearching, setIsSearching] = useState(false);
81
76
82
77
const placeholderInputRef = useRef<HTMLButtonElement | null>(null);
83
78
84
79
let inputWidth = placeholderInputRef.current?.clientWidth;
85
80
86
86
-
const filteredTags = Tags.filter(
87
87
-
(tag) =>
88
88
-
tag.name.toLowerCase().includes(tagInputValue.toLowerCase()) &&
89
89
-
!props.selectedTags.includes(tag.name),
81
81
+
// Fetch tags whenever the input value changes
82
82
+
useEffect(() => {
83
83
+
let cancelled = false;
84
84
+
setIsSearching(true);
85
85
+
86
86
+
searchTags(tagInputValue).then((results) => {
87
87
+
if (!cancelled && results) {
88
88
+
setSearchResults(results);
89
89
+
setIsSearching(false);
90
90
+
}
91
91
+
});
92
92
+
93
93
+
return () => {
94
94
+
cancelled = true;
95
95
+
};
96
96
+
}, [tagInputValue]);
97
97
+
98
98
+
const filteredTags = searchResults.filter(
99
99
+
(tag) => !props.selectedTags.includes(tag.name),
90
100
);
91
101
92
102
function clearTagInput() {
···
225
235
key={tag.name}
226
236
index={userInputResult ? i + 1 : i}
227
237
name={tag.name}
228
228
-
tagged={tag.tagged}
238
238
+
tagged={tag.document_count}
229
239
highlighted={(userInputResult ? i + 1 : i) === highlightedIndex}
230
240
setHighlightedIndex={setHighlightedIndex}
231
241
onSelect={() => {
+7
lexicons/api/lexicons.ts
···
1440
1440
type: 'ref',
1441
1441
ref: 'lex:pub.leaflet.publication#theme',
1442
1442
},
1443
1443
+
tags: {
1444
1444
+
type: 'array',
1445
1445
+
items: {
1446
1446
+
type: 'string',
1447
1447
+
maxLength: 50,
1448
1448
+
},
1449
1449
+
},
1443
1450
pages: {
1444
1451
type: 'array',
1445
1452
items: {
+1
lexicons/api/types/pub/leaflet/document.ts
···
23
23
publication?: string
24
24
author: string
25
25
theme?: PubLeafletPublication.Theme
26
26
+
tags?: string[]
26
27
pages: (
27
28
| $Typed<PubLeafletPagesLinearDocument.Main>
28
29
| $Typed<PubLeafletPagesCanvas.Main>
+7
lexicons/pub/leaflet/document.json
···
46
46
"type": "ref",
47
47
"ref": "pub.leaflet.publication#theme"
48
48
},
49
49
+
"tags": {
50
50
+
"type": "array",
51
51
+
"items": {
52
52
+
"type": "string",
53
53
+
"maxLength": 50
54
54
+
}
55
55
+
},
49
56
"pages": {
50
57
"type": "array",
51
58
"items": {
+1
lexicons/src/document.ts
···
23
23
publication: { type: "string", format: "at-uri" },
24
24
author: { type: "string", format: "at-identifier" },
25
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
26
+
tags: { type: "array", items: { type: "string", maxLength: 50 } },
26
27
pages: {
27
28
type: "array",
28
29
items: {
+34
-8
src/replicache/mutations.ts
···
609
609
};
610
610
611
611
const updatePublicationDraft: Mutation<{
612
612
-
title: string;
613
613
-
description: string;
612
612
+
title?: string;
613
613
+
description?: string;
614
614
+
tags?: string[];
614
615
}> = async (args, ctx) => {
615
616
await ctx.runOnServer(async (serverCtx) => {
616
617
console.log("updating");
617
617
-
await serverCtx.supabase
618
618
-
.from("leaflets_in_publications")
619
619
-
.update({ description: args.description, title: args.title })
620
620
-
.eq("leaflet", ctx.permission_token_id);
618
618
+
const updates: {
619
619
+
description?: string;
620
620
+
title?: string;
621
621
+
tags?: string[];
622
622
+
} = {};
623
623
+
if (args.description !== undefined) updates.description = args.description;
624
624
+
if (args.title !== undefined) updates.title = args.title;
625
625
+
if (args.tags !== undefined) updates.tags = args.tags;
626
626
+
627
627
+
if (Object.keys(updates).length > 0) {
628
628
+
// First try to update leaflets_in_publications (for publications)
629
629
+
const { data: pubResult } = await serverCtx.supabase
630
630
+
.from("leaflets_in_publications")
631
631
+
.update(updates)
632
632
+
.eq("leaflet", ctx.permission_token_id)
633
633
+
.select("leaflet");
634
634
+
635
635
+
// If no rows were updated in leaflets_in_publications,
636
636
+
// try leaflets_to_documents (for standalone documents)
637
637
+
if (!pubResult || pubResult.length === 0) {
638
638
+
await serverCtx.supabase
639
639
+
.from("leaflets_to_documents")
640
640
+
.update(updates)
641
641
+
.eq("leaflet", ctx.permission_token_id);
642
642
+
}
643
643
+
}
621
644
});
622
645
await ctx.runOnClient(async ({ tx }) => {
623
623
-
await tx.set("publication_title", args.title);
624
624
-
await tx.set("publication_description", args.description);
646
646
+
if (args.title !== undefined)
647
647
+
await tx.set("publication_title", args.title);
648
648
+
if (args.description !== undefined)
649
649
+
await tx.set("publication_description", args.description);
650
650
+
if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
625
651
});
626
652
};
627
653