a tool for shared writing and social publishing

wire up tags

+363 -88
+3
actions/publishToPublication.ts
··· 57 57 leaflet_id, 58 58 title, 59 59 description, 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 + 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 + ...(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 + "use server"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export type TagSearchResult = { 5 + name: string; 6 + document_count: number; 7 + }; 8 + 9 + export async function searchTags( 10 + query: string, 11 + ): Promise<TagSearchResult[] | null> { 12 + const searchQuery = query.trim().toLowerCase(); 13 + 14 + // Use raw SQL query to extract and aggregate tags 15 + const { data, error } = await supabaseServerClient.rpc("search_tags", { 16 + search_query: searchQuery, 17 + }); 18 + 19 + if (error) { 20 + console.error("Error searching tags:", error); 21 + return null; 22 + } 23 + 24 + return data; 25 + }
+71
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 1 + "use server"; 2 + 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Json } from "supabase/database.types"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 9 + 10 + export async function getDocumentsByTag( 11 + tag: string, 12 + ): Promise<{ posts: Post[] }> { 13 + // Normalize tag to lowercase for case-insensitive search 14 + const normalizedTag = tag.toLowerCase(); 15 + 16 + // Query documents that have this tag 17 + const { data: documents, error } = await supabaseServerClient 18 + .from("documents") 19 + .select( 20 + `*, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count), 23 + documents_in_publications(publications(*))`, 24 + ) 25 + .contains("data->tags", `["${normalizedTag}"]`) 26 + .order("indexed_at", { ascending: false }) 27 + .limit(50); 28 + 29 + if (error) { 30 + console.error("Error fetching documents by tag:", error); 31 + return { posts: [] }; 32 + } 33 + 34 + const posts = await Promise.all( 35 + documents.map(async (doc) => { 36 + const pub = doc.documents_in_publications[0]?.publications; 37 + 38 + // Skip if document doesn't have a publication 39 + if (!pub) { 40 + return null; 41 + } 42 + 43 + const uri = new AtUri(doc.uri); 44 + const handle = await idResolver.did.resolve(uri.host); 45 + 46 + const post: Post = { 47 + publication: { 48 + href: getPublicationURL(pub), 49 + pubRecord: pub?.record || null, 50 + uri: pub?.uri || "", 51 + }, 52 + author: handle?.alsoKnownAs?.[0] 53 + ? `@${handle.alsoKnownAs[0].slice(5)}` 54 + : null, 55 + documents: { 56 + comments_on_documents: doc.comments_on_documents, 57 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 58 + data: doc.data, 59 + uri: doc.uri, 60 + indexed_at: doc.indexed_at, 61 + }, 62 + }; 63 + return post; 64 + }), 65 + ); 66 + 67 + // Filter out null entries (documents without publications) 68 + return { 69 + posts: posts.filter((p): p is Post => p !== null), 70 + }; 71 + }
+75
app/(home-pages)/tag/[tag]/page.tsx
··· 1 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { Tag } from "components/Tags"; 3 + import { PostListing } from "components/PostListing"; 4 + import { getDocumentsByTag } from "./getDocumentsByTag"; 5 + import { TagTiny } from "components/Icons/TagTiny"; 6 + 7 + export default async function TagPage(props: { 8 + params: Promise<{ tag: string }>; 9 + }) { 10 + const params = await props.params; 11 + const decodedTag = decodeURIComponent(params.tag); 12 + const { posts } = await getDocumentsByTag(decodedTag); 13 + 14 + return ( 15 + <DashboardLayout 16 + id="tag" 17 + cardBorderHidden={false} 18 + currentPage="tag" 19 + defaultTab="default" 20 + actions={null} 21 + tabs={{ 22 + default: { 23 + controls: null, 24 + content: <TagContent tag={decodedTag} posts={posts} />, 25 + }, 26 + }} 27 + /> 28 + ); 29 + } 30 + 31 + const TagContent = (props: { 32 + tag: string; 33 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 34 + }) => { 35 + return ( 36 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 37 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 38 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 39 + </div> 40 + <div className="pt-6 flex flex-col gap-3"> 41 + {props.posts.length === 0 ? ( 42 + <EmptyState tag={props.tag} /> 43 + ) : ( 44 + props.posts.map((post) => ( 45 + <PostListing key={post.documents.uri} {...post} /> 46 + )) 47 + )} 48 + </div> 49 + </div> 50 + ); 51 + }; 52 + 53 + const TagHeader = (props: { tag: string; postCount: number }) => { 54 + return ( 55 + <div className="flex flex-col gap-2 items-center"> 56 + <div className="flex items-center gap-2 text-4xl font-bold text-primary"> 57 + <TagTiny className="scale-150" /> 58 + <h1>{props.tag}</h1> 59 + </div> 60 + <div className="text-tertiary text-sm"> 61 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 62 + </div> 63 + </div> 64 + ); 65 + }; 66 + 67 + const EmptyState = (props: { tag: string }) => { 68 + return ( 69 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 70 + <div className="text-tertiary"> 71 + No posts found with the tag "{props.tag}" 72 + </div> 73 + </div> 74 + ); 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 + 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 - let { permission_token, rootEntity } = useReplicache(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 67 68 let { identity } = useIdentityData(); 68 69 let toaster = useToaster(); 70 + 71 + // Get tags from Replicache state (same as draft editor) 72 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 + const currentTags = Array.isArray(tags) ? tags : []; 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 + 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 + 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 + // Get tags from Replicache state (same as in the draft editor) 71 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 72 + 73 + // Default to empty array if undefined 74 + const currentTags = Array.isArray(tags) ? tags : []; 75 + 76 + // Update tags via the same mutation used in the editor 77 + const handleTagsChange = async (newTags: string[]) => { 78 + await rep?.mutate.updatePublicationDraft({ 79 + tags: newTags, 80 + }); 81 + }; 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 + 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 + <div className="flex flex-col gap-1"> 144 + <h4>Tags</h4> 145 + <TagSelector 146 + selectedTags={currentTags} 147 + setSelectedTags={handleTagsChange} 148 + /> 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 + 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 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 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 + const data = useContext(PostPageContext); 179 + const tags = (data?.data as any)?.tags as string[] | undefined; 180 + const tagCount = tags?.length || 0; 181 + 182 + if (tagCount === 0) return null; 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 - <TagTiny /> XX 189 + <TagTiny /> {tagCount} 184 190 </div> 185 191 } 186 192 > ··· 190 196 }; 191 197 192 198 const TagList = (props: { className?: string }) => { 199 + const data = useContext(PostPageContext); 200 + const tags = (data?.data as any)?.tags as string[] | undefined; 201 + 202 + if (!tags || tags.length === 0) return null; 203 + 193 204 return ( 194 205 <div className="flex gap-1 flex-wrap"> 195 - {Tags.map((tag, index) => ( 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 - 202 - const Tags = [ 203 - "Hello", 204 - "these are", 205 - "some tags", 206 - "and I'm gonna", 207 - "make", 208 - "them super", 209 - "long", 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 + 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 - tagsCount={6} 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 + 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 - {quotes > 0 && ( 167 - <SpeedyLink 168 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 169 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 170 - > 171 - <QuoteTiny /> {quotes} 172 - </SpeedyLink> 173 - )} 174 - {comments > 0 && 175 - record?.preferences?.showComments !== false && ( 176 - <SpeedyLink 177 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 178 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 179 - > 180 - <CommentTiny /> {comments} 181 - </SpeedyLink> 182 - )} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 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 - tagsCount: number; 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 + 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 - {props.tagsCount === 0 ? null : ( 30 + {tagsCount === 0 ? null : ( 29 31 <> 30 - <TagPopover tagsCount={props.tagsCount} /> 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 - const TagPopover = (props: { tagsCount: number }) => { 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 - <TagTiny /> {props.tagsCount} 101 + <TagTiny /> {props.tags.length} 100 102 </button> 101 103 } 102 104 > 103 - <TagList className="text-secondary!" /> 105 + <TagList tags={props.tags} className="text-secondary!" /> 104 106 </Popover> 105 107 ); 106 108 }; 107 109 108 - const TagList = (props: { className?: string }) => { 110 + const TagList = (props: { tags: string[]; className?: string }) => { 109 111 return ( 110 112 <div className="flex gap-1 flex-wrap"> 111 - {Tags.map((tag, index) => ( 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 - 118 - const Tags = [ 119 - "Hello", 120 - "these are", 121 - "some tags", 122 - "and I'm gonna", 123 - "make", 124 - "them super", 125 - "long", 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 - let pubRecord = pub?.publications?.record as PubLeafletPublication.Record; 31 + let pubRecord = pub?.publications?.record as 32 + | PubLeafletPublication.Record 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 - {pubRecord.preferences?.showComments && ( 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 - // Just update the database with tags as the user adds them, no explicit submit button 237 + let { data: pub } = useLeafletPublicationData(); 238 + let { rep } = useReplicache(); 239 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 240 + 241 + // Get tags from Replicache local state or published document 242 + let replicacheTags = useSubscribe(rep, (tx) => 243 + tx.get<string[]>("publication_tags"), 244 + ); 245 + 246 + // Determine which tags to use - prioritize Replicache state 247 + let tags: string[] = []; 248 + if (Array.isArray(replicacheTags)) { 249 + tags = replicacheTags; 250 + } else if (record?.tags && Array.isArray(record.tags)) { 251 + tags = record.tags as string[]; 252 + } 253 + 254 + // Update tags in replicache local state 255 + const handleTagsChange = async (newTags: string[]) => { 256 + // Store tags in replicache for next publish/update 257 + await rep?.mutate.updatePublicationDraft({ 258 + tags: newTags, 259 + }); 260 + }; 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 - <TagTiny /> Add Tags 267 + <TagTiny />{" "} 268 + {tags.length > 0 269 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 270 + : "Add Tags"} 242 271 </div> 243 272 } 244 273 > 245 - <TagSelector /> 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 - let theme = usePubTheme(pubRecord); 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 + 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 - tagsCount={6} 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 - 8 - const Tags: { name: string; tagged: number }[] = [ 9 - { name: "dogs", tagged: 240 }, 10 - { name: "cats", tagged: 12 }, 11 - { name: "fruit enthusiam", tagged: 21 }, 12 - { name: "at proto", tagged: 56 }, 13 - { name: "events in nyc", tagged: 6 }, 14 - { name: "react devs", tagged: 93 }, 15 - { name: "fanfic", tagged: 1743 }, 16 - { name: "pokemon", tagged: 81 }, 17 - ]; 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 - href="/tag" 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 - export const TagSelector = (props: {}) => { 48 - let [selectedTags, setSelectedTags] = useState<string[]>([]); 37 + export const TagSelector = (props: { 38 + selectedTags: string[]; 39 + setSelectedTags: (tags: string[]) => void; 40 + }) => { 49 41 return ( 50 42 <div className="flex flex-col gap-2"> 51 43 <TagSearchInput 52 - selectedTags={selectedTags} 53 - setSelectedTags={setSelectedTags} 44 + selectedTags={props.selectedTags} 45 + setSelectedTags={props.setSelectedTags} 54 46 /> 55 - {selectedTags.length > 0 ? ( 47 + {props.selectedTags.length > 0 ? ( 56 48 <div className="flex flex-wrap gap-2 "> 57 - {selectedTags.map((tag) => ( 49 + {props.selectedTags.map((tag) => ( 58 50 <Tag 51 + key={tag} 59 52 name={tag} 60 53 selected 61 54 onDelete={() => { 62 - setSelectedTags(selectedTags.filter((t) => t !== tag)); 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 + let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]); 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 - const filteredTags = Tags.filter( 87 - (tag) => 88 - tag.name.toLowerCase().includes(tagInputValue.toLowerCase()) && 89 - !props.selectedTags.includes(tag.name), 81 + // Fetch tags whenever the input value changes 82 + useEffect(() => { 83 + let cancelled = false; 84 + setIsSearching(true); 85 + 86 + searchTags(tagInputValue).then((results) => { 87 + if (!cancelled && results) { 88 + setSearchResults(results); 89 + setIsSearching(false); 90 + } 91 + }); 92 + 93 + return () => { 94 + cancelled = true; 95 + }; 96 + }, [tagInputValue]); 97 + 98 + const filteredTags = searchResults.filter( 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 - tagged={tag.tagged} 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 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 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 + 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 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 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 + 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 - title: string; 613 - description: string; 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 614 615 }> = async (args, ctx) => { 615 616 await ctx.runOnServer(async (serverCtx) => { 616 617 console.log("updating"); 617 - await serverCtx.supabase 618 - .from("leaflets_in_publications") 619 - .update({ description: args.description, title: args.title }) 620 - .eq("leaflet", ctx.permission_token_id); 618 + const updates: { 619 + description?: string; 620 + title?: string; 621 + tags?: string[]; 622 + } = {}; 623 + if (args.description !== undefined) updates.description = args.description; 624 + if (args.title !== undefined) updates.title = args.title; 625 + if (args.tags !== undefined) updates.tags = args.tags; 626 + 627 + if (Object.keys(updates).length > 0) { 628 + // First try to update leaflets_in_publications (for publications) 629 + const { data: pubResult } = await serverCtx.supabase 630 + .from("leaflets_in_publications") 631 + .update(updates) 632 + .eq("leaflet", ctx.permission_token_id) 633 + .select("leaflet"); 634 + 635 + // If no rows were updated in leaflets_in_publications, 636 + // try leaflets_to_documents (for standalone documents) 637 + if (!pubResult || pubResult.length === 0) { 638 + await serverCtx.supabase 639 + .from("leaflets_to_documents") 640 + .update(updates) 641 + .eq("leaflet", ctx.permission_token_id); 642 + } 643 + } 621 644 }); 622 645 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 646 + if (args.title !== undefined) 647 + await tx.set("publication_title", args.title); 648 + if (args.description !== undefined) 649 + await tx.set("publication_description", args.description); 650 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 625 651 }); 626 652 }; 627 653