diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index a03b4e17..dcf5392d 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -57,6 +57,7 @@ export async function publishToPublication({ leaflet_id, title, description, + tags, entitiesToDelete, }: { root_entity: string; @@ -64,6 +65,7 @@ export async function publishToPublication({ leaflet_id: string; title?: string; description?: string; + tags?: string[]; entitiesToDelete?: string[]; }) { const oauthClient = await createOauthClient(); @@ -143,6 +145,7 @@ export async function publishToPublication({ ...(theme && { theme }), title: title || "Untitled", description: description || "", + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) pages: pages.map((p) => { if (p.type === "canvas") { return { diff --git a/actions/searchTags.ts b/actions/searchTags.ts new file mode 100644 index 00000000..1c2e7916 --- /dev/null +++ b/actions/searchTags.ts @@ -0,0 +1,25 @@ +"use server"; +import { supabaseServerClient } from "supabase/serverClient"; + +export type TagSearchResult = { + name: string; + document_count: number; +}; + +export async function searchTags( + query: string, +): Promise { + const searchQuery = query.trim().toLowerCase(); + + // Use raw SQL query to extract and aggregate tags + const { data, error } = await supabaseServerClient.rpc("search_tags", { + search_query: searchQuery, + }); + + if (error) { + console.error("Error searching tags:", error); + return null; + } + + return data; +} diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts new file mode 100644 index 00000000..cdd9d7c2 --- /dev/null +++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -0,0 +1,71 @@ +"use server"; + +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/api"; +import { Json } from "supabase/database.types"; +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import type { Post } from "app/(home-pages)/reader/getReaderFeed"; + +export async function getDocumentsByTag( + tag: string, +): Promise<{ posts: Post[] }> { + // Normalize tag to lowercase for case-insensitive search + const normalizedTag = tag.toLowerCase(); + + // Query documents that have this tag + const { data: documents, error } = await supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications(publications(*))`, + ) + .contains("data->tags", `["${normalizedTag}"]`) + .order("indexed_at", { ascending: false }) + .limit(50); + + if (error) { + console.error("Error fetching documents by tag:", error); + return { posts: [] }; + } + + const posts = await Promise.all( + documents.map(async (doc) => { + const pub = doc.documents_in_publications[0]?.publications; + + // Skip if document doesn't have a publication + if (!pub) { + return null; + } + + const uri = new AtUri(doc.uri); + const handle = await idResolver.did.resolve(uri.host); + + const post: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: pub?.record || null, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: doc.comments_on_documents, + document_mentions_in_bsky: doc.document_mentions_in_bsky, + data: doc.data, + uri: doc.uri, + indexed_at: doc.indexed_at, + }, + }; + return post; + }), + ); + + // Filter out null entries (documents without publications) + return { + posts: posts.filter((p): p is Post => p !== null), + }; +} diff --git a/app/(home-pages)/tag/[tag]/page.tsx b/app/(home-pages)/tag/[tag]/page.tsx new file mode 100644 index 00000000..53f10da4 --- /dev/null +++ b/app/(home-pages)/tag/[tag]/page.tsx @@ -0,0 +1,75 @@ +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { Tag } from "components/Tags"; +import { PostListing } from "components/PostListing"; +import { getDocumentsByTag } from "./getDocumentsByTag"; +import { TagTiny } from "components/Icons/TagTiny"; + +export default async function TagPage(props: { + params: Promise<{ tag: string }>; +}) { + const params = await props.params; + const decodedTag = decodeURIComponent(params.tag); + const { posts } = await getDocumentsByTag(decodedTag); + + return ( + , + }, + }} + /> + ); +} + +const TagContent = (props: { + tag: string; + posts: Awaited>["posts"]; +}) => { + return ( +
+
+ +
+
+ {props.posts.length === 0 ? ( + + ) : ( + props.posts.map((post) => ( + + )) + )} +
+
+ ); +}; + +const TagHeader = (props: { tag: string; postCount: number }) => { + return ( +
+
+ +

{props.tag}

+
+
+ {props.postCount} {props.postCount === 1 ? "post" : "posts"} +
+
+ ); +}; + +const EmptyState = (props: { tag: string }) => { + return ( +
+
+ No posts found with the tag "{props.tag}" +
+
+ ); +}; diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index 4098d1ff..2ee256fc 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -27,6 +27,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState, useMemo } from "react"; import { useIsMobile } from "src/hooks/isMobile"; import { useReplicache, useEntity } from "src/replicache"; +import { useSubscribe } from "src/replicache/useSubscribe"; import { Json } from "supabase/database.types"; import { useBlocks, @@ -63,10 +64,14 @@ export const PublishButton = (props: { entityID: string }) => { const UpdateButton = () => { let [isLoading, setIsLoading] = useState(false); let { data: pub, mutate } = useLeafletPublicationData(); - let { permission_token, rootEntity } = useReplicache(); + let { permission_token, rootEntity, rep } = useReplicache(); let { identity } = useIdentityData(); let toaster = useToaster(); + // Get tags from Replicache state (same as draft editor) + let tags = useSubscribe(rep, (tx) => tx.get("publication_tags")); + const currentTags = Array.isArray(tags) ? tags : []; + return ( { leaflet_id: permission_token.id, title: pub.title, description: pub.description, + tags: currentTags, }); setIsLoading(false); mutate(); diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 6c03e305..142d81c7 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -13,6 +13,7 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/act import { AtUri } from "@atproto/syntax"; import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; import { useReplicache } from "src/replicache"; +import { useSubscribe } from "src/replicache/useSubscribe"; import { BlueskyPostEditorProsemirror, editorStateToFacetedText, @@ -66,6 +67,19 @@ const PublishPostForm = ( let params = useParams(); let { rep } = useReplicache(); + // Get tags from Replicache state (same as in the draft editor) + let tags = useSubscribe(rep, (tx) => tx.get("publication_tags")); + + // Default to empty array if undefined + const currentTags = Array.isArray(tags) ? tags : []; + + // Update tags via the same mutation used in the editor + const handleTagsChange = async (newTags: string[]) => { + await rep?.mutate.updatePublicationDraft({ + tags: newTags, + }); + }; + async function submit() { if (isLoading) return; setIsLoading(true); @@ -76,6 +90,7 @@ const PublishPostForm = ( leaflet_id: props.leaflet_id, title: props.title, description: props.description, + tags: currentTags, entitiesToDelete: props.entitiesToDelete, }); if (!doc) return; @@ -125,6 +140,13 @@ const PublishPostForm = ( {...props} />
+
+

Tags

+ +
{ + const data = useContext(PostPageContext); + const tags = (data?.data as any)?.tags as string[] | undefined; + const tagCount = tags?.length || 0; + + if (tagCount === 0) return null; + return ( - XX + {tagCount}
} > @@ -190,24 +196,19 @@ const TagPopover = () => { }; const TagList = (props: { className?: string }) => { + const data = useContext(PostPageContext); + const tags = (data?.data as any)?.tags as string[] | undefined; + + if (!tags || tags.length === 0) return null; + return (
- {Tags.map((tag, index) => ( + {tags.map((tag, index) => ( ))}
); }; - -const Tags = [ - "Hello", - "these are", - "some tags", - "and I'm gonna", - "make", - "them super", - "long", -]; export function getQuoteCount(document: PostPageData, pageId?: string) { if (!document) return; return getQuoteCountFromArray(document.quotesAndMentions, pageId); diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index 20641a44..83a5b00a 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -61,6 +61,7 @@ export function PublishedPostsList(props: { let postRecord = doc.documents.data as PubLeafletDocument.Record; let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; let comments = doc.documents.comments_on_documents[0]?.count || 0; + let tags = (postRecord?.tags as string[] | undefined) || []; let postLink = data?.publication ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` @@ -137,7 +138,7 @@ export function PublishedPostsList(props: { diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 76287ba0..6754053a 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -135,6 +135,7 @@ export default async function Publication(props: { record?.preferences?.showComments === false ? 0 : doc.documents.comments_on_documents[0].count || 0; + let tags = (doc_record?.tags as string[] | undefined) || []; return ( @@ -163,23 +164,13 @@ export default async function Publication(props: { )}{" "}

{comments > 0 || quotes > 0 ? "| " : ""} - {quotes > 0 && ( - - {quotes} - - )} - {comments > 0 && - record?.preferences?.showComments !== false && ( - - {comments} - - )} +
diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx index 851daac3..60d5b266 100644 --- a/components/InteractionsPreview.tsx +++ b/components/InteractionsPreview.tsx @@ -11,7 +11,7 @@ import { SpeedyLink } from "./SpeedyLink"; export const InteractionPreview = (props: { quotesCount: number; commentsCount: number; - tagsCount: number; + tags?: string[]; postUrl: string; showComments: boolean | undefined; share?: boolean; @@ -21,13 +21,15 @@ export const InteractionPreview = (props: { props.quotesCount > 0 || (props.showComments !== false && props.commentsCount > 0); + const tagsCount = props.tags?.length || 0; + return (
- {props.tagsCount === 0 ? null : ( + {tagsCount === 0 ? null : ( <> - + {interactionsAvailable || props.share ? ( ) : null} @@ -86,7 +88,7 @@ export const InteractionPreview = (props: { ); }; -const TagPopover = (props: { tagsCount: number }) => { +const TagPopover = (props: { tags: string[] }) => { return ( { aria-label="Post tags" className="relative flex gap-1 items-center " > - {props.tagsCount} + {props.tags.length} } > - + ); }; -const TagList = (props: { className?: string }) => { +const TagList = (props: { tags: string[]; className?: string }) => { return (
- {Tags.map((tag, index) => ( + {props.tags.map((tag, index) => ( ))}
); }; - -const Tags = [ - "Hello", - "these are", - "some tags", - "and I'm gonna", - "make", - "them super", - "long", -]; diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index a7f51efd..ce4ad29b 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -28,7 +28,9 @@ export const PublicationMetadata = () => { tx.get("publication_description"), ); let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let pubRecord = pub?.publications?.record as PubLeafletPublication.Record; + let pubRecord = pub?.publications?.record as + | PubLeafletPublication.Record + | undefined; let publishedAt = record?.publishedAt; if (!pub) return null; @@ -111,7 +113,7 @@ export const PublicationMetadata = () => {
—
- {pubRecord.preferences?.showComments && ( + {pubRecord?.preferences?.showComments && (
—
@@ -232,17 +234,44 @@ export const PublicationMetadataPreview = () => { }; const AddTags = () => { - // Just update the database with tags as the user adds them, no explicit submit button + let { data: pub } = useLeafletPublicationData(); + let { rep } = useReplicache(); + let record = pub?.documents?.data as PubLeafletDocument.Record | null; + + // Get tags from Replicache local state or published document + let replicacheTags = useSubscribe(rep, (tx) => + tx.get("publication_tags"), + ); + + // Determine which tags to use - prioritize Replicache state + let tags: string[] = []; + if (Array.isArray(replicacheTags)) { + tags = replicacheTags; + } else if (record?.tags && Array.isArray(record.tags)) { + tags = record.tags as string[]; + } + + // Update tags in replicache local state + const handleTagsChange = async (newTags: string[]) => { + // Store tags in replicache for next publish/update + await rep?.mutate.updatePublicationDraft({ + tags: newTags, + }); + }; + return ( - Add Tags + {" "} + {tags.length > 0 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` + : "Add Tags"}
} > - + ); }; diff --git a/components/PostListing.tsx b/components/PostListing.tsx index c151eb48..897cc82d 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -20,7 +20,7 @@ export const PostListing = (props: Post) => { let postRecord = props.documents.data as PubLeafletDocument.Record; let postUri = new AtUri(props.documents.uri); - let theme = usePubTheme(pubRecord); + let theme = usePubTheme(pubRecord.theme); let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( pubRecord?.theme?.backgroundImage?.image?.ref, @@ -38,6 +38,7 @@ export const PostListing = (props: Post) => { pubRecord.preferences?.showComments === false ? 0 : props.documents.comments_on_documents?.[0]?.count || 0; + let tags = (postRecord?.tags as string[] | undefined) || []; return ( @@ -86,7 +87,7 @@ export const PostListing = (props: Post) => { postUrl={`${props.publication.href}/${postUri.rkey}`} quotesCount={quotes} commentsCount={comments} - tagsCount={6} + tags={tags} showComments={pubRecord.preferences?.showComments} share /> diff --git a/components/Tags.tsx b/components/Tags.tsx index bcaed365..800b4916 100644 --- a/components/Tags.tsx +++ b/components/Tags.tsx @@ -4,17 +4,7 @@ import { Input } from "components/Input"; import { useState, useRef, useEffect } from "react"; import { Popover } from "components/Popover"; import Link from "next/link"; - -const Tags: { name: string; tagged: number }[] = [ - { name: "dogs", tagged: 240 }, - { name: "cats", tagged: 12 }, - { name: "fruit enthusiam", tagged: 21 }, - { name: "at proto", tagged: 56 }, - { name: "events in nyc", tagged: 6 }, - { name: "react devs", tagged: 93 }, - { name: "fanfic", tagged: 1743 }, - { name: "pokemon", tagged: 81 }, -]; +import { searchTags, type TagSearchResult } from "actions/searchTags"; export const Tag = (props: { name: string; @@ -27,7 +17,7 @@ export const Tag = (props: { 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}`} > {props.name}{" "} @@ -44,22 +34,25 @@ export const Tag = (props: { ); }; -export const TagSelector = (props: {}) => { - let [selectedTags, setSelectedTags] = useState([]); +export const TagSelector = (props: { + selectedTags: string[]; + setSelectedTags: (tags: string[]) => void; +}) => { return (
- {selectedTags.length > 0 ? ( + {props.selectedTags.length > 0 ? (
- {selectedTags.map((tag) => ( + {props.selectedTags.map((tag) => ( { - setSelectedTags(selectedTags.filter((t) => t !== tag)); + props.setSelectedTags(props.selectedTags.filter((t) => t !== tag)); }} /> ))} @@ -78,15 +71,32 @@ export const TagSearchInput = (props: { let [tagInputValue, setTagInputValue] = useState(""); let [isOpen, setIsOpen] = useState(false); let [highlightedIndex, setHighlightedIndex] = useState(0); + let [searchResults, setSearchResults] = useState([]); + let [isSearching, setIsSearching] = useState(false); const placeholderInputRef = useRef(null); let inputWidth = placeholderInputRef.current?.clientWidth; - const filteredTags = Tags.filter( - (tag) => - tag.name.toLowerCase().includes(tagInputValue.toLowerCase()) && - !props.selectedTags.includes(tag.name), + // Fetch tags whenever the input value changes + useEffect(() => { + let cancelled = false; + setIsSearching(true); + + searchTags(tagInputValue).then((results) => { + if (!cancelled && results) { + setSearchResults(results); + setIsSearching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [tagInputValue]); + + const filteredTags = searchResults.filter( + (tag) => !props.selectedTags.includes(tag.name), ); function clearTagInput() { @@ -225,7 +235,7 @@ export const TagSearchInput = (props: { key={tag.name} index={userInputResult ? i + 1 : i} name={tag.name} - tagged={tag.tagged} + tagged={tag.document_count} highlighted={(userInputResult ? i + 1 : i) === highlightedIndex} setHighlightedIndex={setHighlightedIndex} onSelect={() => { diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 1ebf4d50..c57f4855 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1440,6 +1440,13 @@ export const schemaDict = { type: 'ref', ref: 'lex:pub.leaflet.publication#theme', }, + tags: { + type: 'array', + items: { + type: 'string', + maxLength: 50, + }, + }, pages: { type: 'array', items: { diff --git a/lexicons/api/types/pub/leaflet/document.ts b/lexicons/api/types/pub/leaflet/document.ts index 08cfbf1e..e49512f8 100644 --- a/lexicons/api/types/pub/leaflet/document.ts +++ b/lexicons/api/types/pub/leaflet/document.ts @@ -23,6 +23,7 @@ export interface Record { publication?: string author: string theme?: PubLeafletPublication.Theme + tags?: string[] pages: ( | $Typed | $Typed diff --git a/lexicons/pub/leaflet/document.json b/lexicons/pub/leaflet/document.json index 32e338a4..dd3a5736 100644 --- a/lexicons/pub/leaflet/document.json +++ b/lexicons/pub/leaflet/document.json @@ -46,6 +46,13 @@ "type": "ref", "ref": "pub.leaflet.publication#theme" }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50 + } + }, "pages": { "type": "array", "items": { diff --git a/lexicons/src/document.ts b/lexicons/src/document.ts index e743ea97..db493e2e 100644 --- a/lexicons/src/document.ts +++ b/lexicons/src/document.ts @@ -23,6 +23,7 @@ export const PubLeafletDocument: LexiconDoc = { publication: { type: "string", format: "at-uri" }, author: { type: "string", format: "at-identifier" }, theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, + tags: { type: "array", items: { type: "string", maxLength: 50 } }, pages: { type: "array", items: { diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 2978adce..44a15a73 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -609,19 +609,45 @@ const removePollOption: Mutation<{ }; const updatePublicationDraft: Mutation<{ - title: string; - description: string; + title?: string; + description?: string; + tags?: string[]; }> = async (args, ctx) => { await ctx.runOnServer(async (serverCtx) => { console.log("updating"); - await serverCtx.supabase - .from("leaflets_in_publications") - .update({ description: args.description, title: args.title }) - .eq("leaflet", ctx.permission_token_id); + const updates: { + description?: string; + title?: string; + tags?: string[]; + } = {}; + if (args.description !== undefined) updates.description = args.description; + if (args.title !== undefined) updates.title = args.title; + if (args.tags !== undefined) updates.tags = args.tags; + + if (Object.keys(updates).length > 0) { + // First try to update leaflets_in_publications (for publications) + const { data: pubResult } = await serverCtx.supabase + .from("leaflets_in_publications") + .update(updates) + .eq("leaflet", ctx.permission_token_id) + .select("leaflet"); + + // If no rows were updated in leaflets_in_publications, + // try leaflets_to_documents (for standalone documents) + if (!pubResult || pubResult.length === 0) { + await serverCtx.supabase + .from("leaflets_to_documents") + .update(updates) + .eq("leaflet", ctx.permission_token_id); + } + } }); await ctx.runOnClient(async ({ tx }) => { - await tx.set("publication_title", args.title); - await tx.set("publication_description", args.description); + if (args.title !== undefined) + await tx.set("publication_title", args.title); + if (args.description !== undefined) + await tx.set("publication_description", args.description); + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); }); };