diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index ec6a22c6..d4016a95 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -59,6 +59,7 @@ export async function publishToPublication({ leaflet_id, title, description, + tags, entitiesToDelete, }: { root_entity: string; @@ -66,6 +67,7 @@ export async function publishToPublication({ leaflet_id: string; title?: string; description?: string; + tags?: string[]; entitiesToDelete?: string[]; }) { const oauthClient = await createOauthClient(); @@ -145,6 +147,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)/discover/PubListing.tsx b/app/(home-pages)/discover/PubListing.tsx index 285a36cd..54dab387 100644 --- a/app/(home-pages)/discover/PubListing.tsx +++ b/app/(home-pages)/discover/PubListing.tsx @@ -1,6 +1,7 @@ "use client"; import { AtUri } from "@atproto/syntax"; import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; import { PubIcon } from "components/ActionBar/Publications"; import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; diff --git a/app/(home-pages)/reader/ReaderContent.tsx b/app/(home-pages)/reader/ReaderContent.tsx index 53b6edb5..e73b3df9 100644 --- a/app/(home-pages)/reader/ReaderContent.tsx +++ b/app/(home-pages)/reader/ReaderContent.tsx @@ -1,26 +1,12 @@ "use client"; -import { AtUri } from "@atproto/api"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { PubIcon } from "components/ActionBar/Publications"; import { ButtonPrimary } from "components/Buttons"; -import { CommentTiny } from "components/Icons/CommentTiny"; import { DiscoverSmall } from "components/Icons/DiscoverSmall"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { Separator } from "components/Layout"; -import { SpeedyLink } from "components/SpeedyLink"; -import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; -import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { useSmoker } from "components/Toast"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { Json } from "supabase/database.types"; import type { Cursor, Post } from "./getReaderFeed"; import useSWRInfinite from "swr/infinite"; import { getReaderFeed } from "./getReaderFeed"; import { useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; import Link from "next/link"; -import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { PostListing } from "components/PostListing"; export const ReaderContent = (props: { posts: Post[]; @@ -28,7 +14,10 @@ export const ReaderContent = (props: { }) => { const getKey = ( pageIndex: number, - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, + previousPageData: { + posts: Post[]; + nextCursor: Cursor | null; + } | null, ) => { // Reached the end if (previousPageData && !previousPageData.nextCursor) return null; @@ -40,7 +29,7 @@ export const ReaderContent = (props: { return ["reader-feed", previousPageData?.nextCursor] as const; }; - const { data, error, size, setSize, isValidating } = useSWRInfinite( + const { data, size, setSize, isValidating } = useSWRInfinite( getKey, ([_, cursor]) => getReaderFeed(cursor), { @@ -79,7 +68,7 @@ export const ReaderContent = (props: { return (
{allPosts.map((p) => ( - + ))} {/* Trigger element for loading more posts */}
{ - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; - - let postRecord = props.documents.data as PubLeafletDocument.Record; - let postUri = new AtUri(props.documents.uri); - - let theme = usePubTheme(pubRecord?.theme); - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref - ? blobRefToSrc( - pubRecord?.theme?.backgroundImage?.image?.ref, - new AtUri(props.publication.uri).host, - ) - : null; - - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; - - let showPageBackground = pubRecord.theme?.showPageBackground; - - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; - let comments = - pubRecord.preferences?.showComments === false - ? 0 - : props.documents.comments_on_documents?.[0]?.count || 0; - - return ( - - - - ); -}; - -const PubInfo = (props: { - href: string; - pubRecord: PubLeafletPublication.Record; - uri: string; -}) => { - return ( - - - {props.pubRecord.name} - - ); -}; - -const PostInfo = (props: { - author: string; - publishedAt: string | undefined; -}) => { - const formattedDate = useLocalizedDate( - props.publishedAt || new Date().toISOString(), - { - year: "numeric", - month: "short", - day: "numeric", - }, - ); - - return ( -
- {props.author} - {props.publishedAt && ( - <> - - {formattedDate}{" "} - - )} -
- ); -}; - -const PostInterations = (props: { - quotesCount: number; - commentsCount: number; - postUrl: string; - showComments: boolean | undefined; -}) => { - let smoker = useSmoker(); - let interactionsAvailable = - props.quotesCount > 0 || - (props.showComments !== false && props.commentsCount > 0); - - return ( -
- {props.quotesCount === 0 ? null : ( -
- {props.quotesCount} -
- )} - {props.showComments === false || props.commentsCount === 0 ? null : ( -
- {props.commentsCount} -
- )} - {interactionsAvailable && } - -
- ); -}; export const ReaderEmpty = () => { return (
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..a7ecd215 --- /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 43d5dd54..fc8be6e9 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/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 6496c57b..02985291 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -309,7 +309,7 @@ export function BlueskyPostEditorProsemirror(props: { )}
({ state: "default" }); return ( -
+
{publishState.state === "default" ? ( ) : ( @@ -58,13 +61,38 @@ const PublishPostForm = ( setPublishState: (s: { state: "success"; post_url: string }) => void; } & Props, ) => { - let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); let editorStateRef = useRef(null); - let [isLoading, setIsLoading] = useState(false); let [charCount, setCharCount] = useState(0); + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); + let [isLoading, setIsLoading] = useState(false); let params = useParams(); let { rep } = useReplicache(); + // For publications with drafts, use Replicache; otherwise use local state + let replicacheTags = useSubscribe(rep, (tx) => + tx.get("publication_tags"), + ); + let [localTags, setLocalTags] = useState([]); + + // Use Replicache tags only when we have a draft + const hasDraft = props.hasDraft; + const currentTags = hasDraft + ? Array.isArray(replicacheTags) + ? replicacheTags + : [] + : localTags; + + // Update tags via Replicache mutation or local state depending on context + const handleTagsChange = async (newTags: string[]) => { + if (hasDraft) { + await rep?.mutate.updatePublicationDraft({ + tags: newTags, + }); + } else { + setLocalTags(newTags); + } + }; + async function submit() { if (isLoading) return; setIsLoading(true); @@ -75,6 +103,7 @@ const PublishPostForm = ( leaflet_id: props.leaflet_id, title: props.title, description: props.description, + tags: currentTags, entitiesToDelete: props.entitiesToDelete, }); if (!doc) return; @@ -109,90 +138,30 @@ const PublishPostForm = ( submit(); }} > -
+
-
- { - if (e.target === e.currentTarget) { - setShareOption("quiet"); - } - }} - name="share-options" - id="share-quietly" - value="Share Quietly" - > -
-
Share Quietly
-
- No one will be notified about this post -
-
-
- { - if (e.target === e.currentTarget) { - setShareOption("bluesky"); - } - }} - name="share-options" - id="share-bsky" - value="Share on Bluesky" - > -
-
Share on Bluesky
-
- Pub subscribers will be updated via a custom Bluesky feed -
-
-
- -
-
-
- -
-
-

{props.profile.displayName}

-

@{props.profile.handle}

-
-
- -
-
-
-
{props.title}
-
{props.description}
- {props.record && ( - <> -
-

- {props.record?.base_path} -

- - )} -
-
-
- {charCount}/300 -
-
-
-
+
+ +
+
+

Tags

+
+
+
void; + charCount: number; + setCharCount: (c: number) => void; + editorStateRef: React.MutableRefObject; + title: string; + profile: ProfileViewDetailed; + description: string; + record?: PubLeafletPublication.Record; +}) => { + return ( +
+

Notifications

+ { + if (e.target === e.currentTarget) { + props.setShareOption("quiet"); + } + }} + name="share-options" + id="share-quietly" + value="Share Quietly" + > +
+
Share Quietly
+
+ No one will be notified about this post +
+
+
+ { + if (e.target === e.currentTarget) { + props.setShareOption("bluesky"); + } + }} + name="share-options" + id="share-bsky" + value="Share on Bluesky" + > +
+
Share on Bluesky
+
+ Pub subscribers will be updated via a custom Bluesky feed +
+
+
+
+
+
+ +
+
+

{props.profile.displayName}

+

@{props.profile.handle}

+
+
+ +
+
+
+
{props.title}
+
{props.description}
+
+

+ {props.record?.base_path} +

+
+
+
+ {props.charCount}/300 +
+
+
+
+
+
+ ); +}; + const PublishingTo = (props: { publication_uri?: string; record?: PubLeafletPublication.Record; diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 9ee5917c..42690741 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -99,6 +99,11 @@ export default async function PublishLeafletPage(props: Props) { // If parsing fails, just use empty array } + // Check if a draft record exists (either in a publication or standalone) + let hasDraft = + data.leaflets_in_publications.length > 0 || + data.leaflets_to_documents.length > 0; + return ( ); diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts index 49fb7aa4..b07a1741 100644 --- a/app/api/rpc/[command]/pull.ts +++ b/app/api/rpc/[command]/pull.ts @@ -73,6 +73,7 @@ export const pull = makeRoute({ let publication_data = data.publications as { description: string; title: string; + tags: string[]; }[]; let pub_patch = publication_data?.[0] ? [ @@ -86,6 +87,11 @@ export const pull = makeRoute({ key: "publication_title", value: publication_data[0].title, }, + { + op: "put", + key: "publication_tags", + value: publication_data[0].tags || [], + }, ] : []; diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 001ff6f8..695bd6da 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -23,165 +23,8 @@ import { addFeed } from "./addFeed"; import { useSearchParams } from "next/navigation"; import LoginForm from "app/login/LoginForm"; import { RSSSmall } from "components/Icons/RSSSmall"; -import { SpeedyLink } from "components/SpeedyLink"; - -type State = - | { state: "email" } - | { state: "code"; token: string } - | { state: "success" }; -export const SubscribeButton = (props: { - compact?: boolean; - publication: string; -}) => { - let { identity, mutate } = useIdentityData(); - let [emailInputValue, setEmailInputValue] = useState(""); - let [codeInputValue, setCodeInputValue] = useState(""); - let [state, setState] = useState({ state: "email" }); - - if (state.state === "email") { - return ( -
-
- { - setEmailInputValue(e.currentTarget.value); - }} - /> - { - if (identity?.email) { - await subscribeToPublicationWithEmail(props.publication); - //optimistically could add! - await mutate(); - return; - } - let tokenID = await requestAuthEmailToken(emailInputValue); - setState({ state: "code", token: tokenID }); - }} - > - {props.compact ? ( - - ) : ( - "Subscribe" - )} - -
- {/* */} -
- ); - } - if (state.state === "code") { - return ( -
-
-
Please enter the code we sent to
-
{emailInputValue}
-
- - - - -
- ); - } - - if (state.state === "success") { - return ( -
-
-
You're subscribed!
- {/* */} -
-
- ); - } -}; - -export const ShareButton = () => { - return ( - - ); -}; - -const ConfirmCodeInput = (props: { - codeInputValue: string; - token: string; - setCodeInputValue: (value: string) => void; - setState: (state: State) => void; - publication: string; -}) => { - let { mutate } = useIdentityData(); - return ( -
- { - props.setCodeInputValue(e.currentTarget.value); - }} - /> - { - console.log( - await confirmEmailAuthToken(props.token, props.codeInputValue), - ); - - await subscribeToPublicationWithEmail(props.publication); - //optimistically could add! - await mutate(); - props.setState({ state: "success" }); - return; - }} - > - Confirm - -
- ); -}; export const SubscribeWithBluesky = (props: { - isPost?: boolean; pubName: string; pub_uri: string; base_url: string; @@ -208,11 +51,6 @@ export const SubscribeWithBluesky = (props: { } return (
- {props.isPost && ( -
- Get updates from {props.pubName}! -
- )}
Manage Subscription
+ } > -
- You're Subscribed{props.isPost ? ` to ` : "!"} - {props.isPost && ( - - {props.pubName} - - )} -
- Manage
} - > -
-

Update Options

- - {!hasFeed && ( - - - View Bluesky Custom Feed - - - )} +
+

Update Options

+ {!hasFeed && ( - - Get RSS + + View Bluesky Custom Feed + )} -
+ + + Get RSS + + -
- -
-
{" "} - -
+
+ +
+ +
+
+ ); }; @@ -430,3 +256,7 @@ const SubscribeSuccessModal = ({ ); }; + +export const SubscribeOnPost = () => { + return
; +}; diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index ef32af60..41c92b6b 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -213,7 +213,6 @@ const CanvasMetadata = (props: { diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index ad84750f..f0eeb41a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -9,9 +9,16 @@ import { decodeQuotePosition, QuotePosition } from "../quotePosition"; import { useContext } from "react"; import { PostPageContext } from "../PostPageContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; +import { TagTiny } from "components/Icons/TagTiny"; +import { Tag } from "components/Tags"; +import { Popover } from "components/Popover"; import { PostPageData } from "../getPostPageData"; -import { PubLeafletComment } from "lexicons/api"; +import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; import { prefetchQuotesData } from "./Quotes"; +import { useIdentityData } from "components/IdentityProvider"; +import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; +import { EditTiny } from "components/Icons/EditTiny"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; export type InteractionState = { drawerOpen: undefined | boolean; @@ -99,13 +106,13 @@ export function openInteractionDrawer( export const Interactions = (props: { quotesCount: number; commentsCount: number; - compact?: boolean; className?: string; showComments?: boolean; pageId?: string; }) => { const data = useContext(PostPageContext); const document_uri = data?.uri; + let { identity } = useIdentityData(); if (!document_uri) throw new Error("document_uri not available in PostPageContext"); @@ -117,31 +124,31 @@ export const Interactions = (props: { } }; + const tags = (data?.data as any)?.tags as string[] | undefined; + const tagCount = tags?.length || 0; + return ( -
- +
+ {tagCount > 0 && } + + {props.quotesCount > 0 && ( + + )} {props.showComments === false ? null : ( )}
); }; +export const ExpandedInteractions = (props: { + quotesCount: number; + commentsCount: number; + className?: string; + showComments?: boolean; + pageId?: string; +}) => { + const data = useContext(PostPageContext); + let { identity } = useIdentityData(); + + const document_uri = data?.uri; + if (!document_uri) + throw new Error("document_uri not available in PostPageContext"); + + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); + + const handleQuotePrefetch = () => { + if (data?.quotesAndMentions) { + prefetchQuotesData(data.quotesAndMentions); + } + }; + let publication = data?.documents_in_publications[0]?.publications; + + const tags = (data?.data as any)?.tags as string[] | undefined; + const tagCount = tags?.length || 0; + + let subscribed = + identity?.atp_did && + publication?.publication_subscriptions && + publication?.publication_subscriptions.find( + (s) => s.identity === identity.atp_did, + ); + + let isAuthor = + identity && + identity.atp_did === + data.documents_in_publications[0]?.publications?.identity_did && + data.leaflets_in_publications[0]; + + return ( +
+ {!subscribed && !isAuthor && publication && publication.record && ( +
+
+
+
Subscribe to {publication.name}
{" "} + to get updates in Reader, RSS, or via Bluesky Feed +
+ +
+
+ )} + {tagCount > 0 && ( + <> +
+ + + + )} +
+
+
+ {props.quotesCount > 0 && ( + + )} + {props.showComments === false ? null : ( + + )} +
+ + {subscribed && publication && ( + + )} +
+
+ ); +}; + +const TagPopover = (props: { + tagCount: number; + tags: string[] | undefined; +}) => { + return ( + + {props.tagCount} +
+ } + > + + + ); +}; + +const TagList = (props: { className?: string; tags: string[] | undefined }) => { + if (!props.tags) return; + return ( +
+ {props.tags.map((tag, index) => ( + + ))} +
+ ); +}; export function getQuoteCount(document: PostPageData, pageId?: string) { if (!document) return; return getQuoteCountFromArray(document.quotesAndMentions, pageId); @@ -198,3 +356,23 @@ export function getCommentCount(document: PostPageData, pageId?: string) { (c) => !(c.record as PubLeafletComment.Record)?.onPage, ).length; } + +const EditButton = (props: { document: PostPageData }) => { + let { identity } = useIdentityData(); + if (!props.document) return; + if ( + identity && + identity.atp_did === + props.document.documents_in_publications[0]?.publications?.identity_did && + props.document.leaflets_in_publications[0] + ) + return ( + + Edit Post + + ); + return; +}; diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 89995b47..c5fe24c0 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -11,6 +11,7 @@ import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { SubscribeWithBluesky } from "app/lish/Subscribe"; import { EditTiny } from "components/Icons/EditTiny"; import { + ExpandedInteractions, getCommentCount, getQuoteCount, Interactions, @@ -47,7 +48,6 @@ export function LinearDocumentPage({ fullPageScroll, hasPageBackground, } = props; - let { identity } = useIdentityData(); let drawer = useDrawerOpen(document_uri); if (!document) return null; @@ -84,50 +84,14 @@ export function LinearDocumentPage({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} /> - - {!isSubpage && ( - <> -
-
- {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications - ?.identity_did && - document.leaflets_in_publications[0] ? ( - - Edit Post - - ) : ( - document.documents_in_publications[0]?.publications && ( - - ) - )} -
- - )} + {!hasPageBackground &&
} ); diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index dc480c6b..8944e53b 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -59,7 +59,7 @@ export function PostContent({ return (
{blocks.map((b, index) => { return ( diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx deleted file mode 100644 index 327db410..00000000 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { Media } from "components/Media"; -import { - Interactions, - useInteractionState, -} from "../Interactions/Interactions"; -import { useState, useEffect } from "react"; -import { Json } from "supabase/database.types"; - -// export const CollapsedPostHeader = (props: { -// title: string; -// pubIcon?: string; -// quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; -// }) => { -// let [headerVisible, setHeaderVisible] = useState(false); -// let { drawerOpen: open } = useInteractionState(); - -// useEffect(() => { -// let post = window.document.getElementById("post-page"); - -// function handleScroll() { -// let postHeader = window.document -// .getElementById("post-header") -// ?.getBoundingClientRect(); -// if (postHeader && postHeader.bottom <= 0) { -// setHeaderVisible(true); -// } else { -// setHeaderVisible(false); -// } -// } -// post?.addEventListener("scroll", handleScroll); -// return () => { -// post?.removeEventListener("scroll", handleScroll); -// }; -// }, []); -// if (!headerVisible) return; -// if (open) return; -// return ( -// -//
-//
-// {props.title} -//
-//
-// -//
-//
-//
-// -// ); -// }; diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index fbc5a153..6dcfadd1 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -16,6 +16,8 @@ import { useIdentityData } from "components/IdentityProvider"; import { EditTiny } from "components/Icons/EditTiny"; import { SpeedyLink } from "components/SpeedyLink"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; +import { Separator } from "components/Layout"; export function PostHeader(props: { data: PostPageData; @@ -40,12 +42,9 @@ export function PostHeader(props: { if (!document?.data) return; return ( -
-
-
+ {pub && ( )} -
-

{record.title}

- {record.description ? ( -

{record.description}

- ) : null} - -
- {profile ? ( - <> - - by {profile.displayName || profile.handle} - - - ) : null} - {record.publishedAt ? ( - <> - |

{formattedDate}

- - ) : null} - |{" "} + + } + postTitle={record.title} + postDescription={record.description} + postInfo={ + <> +
+ {profile ? ( + <> + + {profile.displayName || profile.handle} + + + ) : null} + {record.publishedAt ? ( + <> + +

{formattedDate}

+ + ) : null} +
-
+ + } + /> + ); +} + +export const PostHeaderLayout = (props: { + pubLink: React.ReactNode; + postTitle: React.ReactNode | undefined; + postDescription: React.ReactNode | undefined; + postInfo: React.ReactNode; +}) => { + return ( +
+
+ {props.pubLink} +
+

+ {props.postTitle ? props.postTitle : "Untitled"} +

+ {props.postDescription ? ( +

+ {props.postDescription} +

+ ) : null} +
+ {props.postInfo}
); -} +}; diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index cacd36e8..83a5b00a 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -1,6 +1,6 @@ "use client"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument } from "lexicons/api"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { EditTiny } from "components/Icons/EditTiny"; import { usePublicationData } from "./PublicationSWRProvider"; @@ -17,6 +17,7 @@ import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; import { SpeedyLink } from "components/SpeedyLink"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; +import { InteractionPreview } from "components/InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; @@ -28,6 +29,8 @@ export function PublishedPostsList(props: { let { data } = usePublicationData(); let params = useParams(); let { publication } = data!; + let pubRecord = publication?.record as PubLeafletPublication.Record; + if (!publication) return null; if (publication.documents_in_publications.length === 0) return ( @@ -55,9 +58,10 @@ export function PublishedPostsList(props: { (l) => doc.documents && l.doc === doc.documents.uri, ); let uri = new AtUri(doc.documents.uri); - let record = doc.documents.data as PubLeafletDocument.Record; + 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}` @@ -81,7 +85,7 @@ export function PublishedPostsList(props: { href={`${getPublicationURL(publication)}/${uri.rkey}`} >

- {record.title} + {postRecord.title}

@@ -122,35 +126,22 @@ export function PublishedPostsList(props: {
- {record.description ? ( + {postRecord.description ? (

- {record.description} + {postRecord.description}

) : null} -
- {record.publishedAt ? ( - +
+ {postRecord.publishedAt ? ( + ) : null} - {(comments > 0 || quotes > 0) && record.publishedAt - ? " | " - : ""} - {quotes > 0 && ( - - {quotes} - - )} - {comments > 0 && quotes > 0 ? " " : ""} - {comments > 0 && ( - - {comments} - - )} +
diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index cf5d50af..6754053a 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -14,6 +14,7 @@ import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { SpeedyLink } from "components/SpeedyLink"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; +import { InteractionPreview } from "components/InteractionsPreview"; import { LocalizedDate } from "./LocalizedDate"; import { PublicationHomeLayout } from "./PublicationHomeLayout"; @@ -134,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 ( @@ -162,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/ActionBar/Navigation.tsx b/components/ActionBar/Navigation.tsx index 2a753b48..4336c9a3 100644 --- a/components/ActionBar/Navigation.tsx +++ b/components/ActionBar/Navigation.tsx @@ -24,7 +24,8 @@ export type navPages = | "pub" | "discover" | "notifications" - | "looseleafs"; + | "looseleafs" + | "tag"; export const DesktopNavigation = (props: { currentPage: navPages; @@ -126,19 +127,14 @@ const HomeButton = (props: { current?: boolean }) => { }; const ReaderButton = (props: { current?: boolean; subs: boolean }) => { - let readerUnreads = false; - if (!props.subs) return; return ( : } + icon={} label="Reader" - className={` - ${readerUnreads && "text-accent-contrast!"} - ${props.current && "border-accent-contrast!"} - `} + className={props.current ? "bg-bg-page! border-border-light!" : ""} /> ); diff --git a/components/Icons/TagTiny.tsx b/components/Icons/TagTiny.tsx new file mode 100644 index 00000000..090448d6 --- /dev/null +++ b/components/Icons/TagTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const TagTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Input.tsx b/components/Input.tsx index 5666bb01..3d689695 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -58,7 +58,9 @@ export const AsyncValueInput = ( ); }; -export const focusElement = (el?: HTMLInputElement | null) => { +export const focusElement = ( + el?: HTMLInputElement | HTMLTextAreaElement | null, +) => { if (!isIOS()) { el?.focus(); return; diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx new file mode 100644 index 00000000..511bce28 --- /dev/null +++ b/components/InteractionsPreview.tsx @@ -0,0 +1,114 @@ +"use client"; +import { Separator } from "./Layout"; +import { CommentTiny } from "./Icons/CommentTiny"; +import { QuoteTiny } from "./Icons/QuoteTiny"; +import { useSmoker } from "./Toast"; +import { Tag } from "./Tags"; +import { Popover } from "./Popover"; +import { TagTiny } from "./Icons/TagTiny"; +import { SpeedyLink } from "./SpeedyLink"; + +export const InteractionPreview = (props: { + quotesCount: number; + commentsCount: number; + tags?: string[]; + postUrl: string; + showComments: boolean | undefined; + share?: boolean; +}) => { + let smoker = useSmoker(); + let interactionsAvailable = + props.quotesCount > 0 || + (props.showComments !== false && props.commentsCount > 0); + + const tagsCount = props.tags?.length || 0; + + return ( +
+ {tagsCount === 0 ? null : ( + <> + + {interactionsAvailable || props.share ? ( + + ) : null} + + )} + + {props.quotesCount === 0 ? null : ( + + {props.quotesCount} + + )} + {props.showComments === false || props.commentsCount === 0 ? null : ( + + {props.commentsCount} + + )} + {interactionsAvailable && props.share ? ( + + ) : null} + {props.share && ( + <> + + + )} +
+ ); +}; + +const TagPopover = (props: { tags: string[] }) => { + return ( + + {props.tags.length} +
+ } + > + + + ); +}; + +const TagList = (props: { tags: string[]; className?: string }) => { + return ( +
+ {props.tags.map((tag, index) => ( + + ))} +
+ ); +}; diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index 40b2f932..88396bf2 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -1,11 +1,11 @@ import Link from "next/link"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { useReplicache } from "src/replicache"; import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; import { Separator } from "components/Layout"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument } from "lexicons/api"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { getBasePublicationURL, getPublicationURL, @@ -13,7 +13,13 @@ import { import { useSubscribe } from "src/replicache/useSubscribe"; import { useEntitySetContext } from "components/EntitySetProvider"; import { timeAgo } from "src/utils/timeAgo"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { TagTiny } from "components/Icons/TagTiny"; +import { Popover } from "components/Popover"; +import { TagSelector } from "components/Tags"; import { useIdentityData } from "components/IdentityProvider"; +import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; export const PublicationMetadata = () => { let { rep } = useReplicache(); let { data: pub } = useLeafletPublicationData(); @@ -23,6 +29,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 + | undefined; let publishedAt = record?.publishedAt; if (!pub) return null; @@ -33,69 +42,97 @@ export const PublicationMetadata = () => { if (typeof description !== "string") { description = pub?.description || ""; } + let tags = true; + return ( -
-
- {pub.publications && ( - - {pub.publications?.name} - - )} -
- Editor -
-
- { - await rep?.mutate.updatePublicationDraft({ - title: newTitle, - description, - }); - }} - placeholder="Untitled" - /> - { - await rep?.mutate.updatePublicationDraft({ - title, - description: newDescription, - }); - }} - /> - {pub.doc ? ( -
-

- Published {publishedAt && timeAgo(publishedAt)} -

- - - View Post - + + {pub.publications && ( + + {pub.publications?.name} + + )} +
+ DRAFT +
- ) : ( -

Draft

- )} -
+ } + postTitle={ + { + await rep?.mutate.updatePublicationDraft({ + title: newTitle, + description, + }); + }} + placeholder="Untitled" + /> + } + postDescription={ + { + await rep?.mutate.updatePublicationDraft({ + title, + description: newDescription, + }); + }} + /> + } + postInfo={ + <> + {pub.doc ? ( +
+

+ Published {publishedAt && timeAgo(publishedAt)} +

+ + + View + +
+ ) : ( +

Draft

+ )} +
+ {tags && ( + <> + + + + )} +
+ — +
+ {pubRecord?.preferences?.showComments && ( +
+ — +
+ )} +
+ + } + /> ); }; @@ -178,29 +215,64 @@ export const PublicationMetadataPreview = () => { if (!pub) return null; return ( -
-
- {pub.publications?.name} -
+ + {pub.publications?.name} +
+ } + postTitle={pub.title} + postDescription={pub.description} + postInfo={ + pub.doc ? ( +

Published {publishedAt && timeAgo(publishedAt)}

+ ) : ( +

Draft

+ ) + } + /> + ); +}; -
- {pub.title ? pub.title : "Untitled"} -
-
- {pub.description} -
+const AddTags = () => { + 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"), + ); - {pub.doc ? ( -
-

- Published {publishedAt && timeAgo(publishedAt)} -

+ // 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 ( + + {" "} + {tags.length > 0 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` + : "Add Tags"}
- ) : ( -

Draft

- )} -
+ } + > + + ); }; diff --git a/components/Popover.tsx b/components/Popover.tsx index 3bc9654a..6a607624 100644 --- a/components/Popover.tsx +++ b/components/Popover.tsx @@ -12,6 +12,7 @@ export const Popover = (props: { children: React.ReactNode; align?: "start" | "end" | "center"; side?: "top" | "bottom" | "left" | "right"; + sideOffset?: number; background?: string; border?: string; className?: string; @@ -20,6 +21,7 @@ export const Popover = (props: { onOpenAutoFocus?: (e: Event) => void; asChild?: boolean; arrowFill?: string; + noArrow?: boolean; }) => { let [open, setOpen] = useState(props.open || false); useEffect(() => { @@ -51,30 +53,32 @@ export const Popover = (props: { `} side={props.side} align={props.align ? props.align : "center"} - sideOffset={4} + sideOffset={props.sideOffset ? props.sideOffset : 4} collisionPadding={16} onOpenAutoFocus={props.onOpenAutoFocus} > {props.children} - - - + {!props.noArrow && ( + + + + )} diff --git a/components/PostListing.tsx b/components/PostListing.tsx new file mode 100644 index 00000000..9a1f0d6b --- /dev/null +++ b/components/PostListing.tsx @@ -0,0 +1,132 @@ +"use client"; +import { AtUri } from "@atproto/api"; +import { PubIcon } from "components/ActionBar/Publications"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { Separator } from "components/Layout"; +import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; +import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; +import { useSmoker } from "components/Toast"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import type { Post } from "app/(home-pages)/reader/getReaderFeed"; + +import Link from "next/link"; +import { InteractionPreview } from "./InteractionsPreview"; + +export const PostListing = (props: Post) => { + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; + + let postRecord = props.documents.data as PubLeafletDocument.Record; + let postUri = new AtUri(props.documents.uri); + + let theme = usePubTheme(pubRecord.theme); + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref + ? blobRefToSrc( + pubRecord?.theme?.backgroundImage?.image?.ref, + new AtUri(props.publication.uri).host, + ) + : null; + + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; + + let showPageBackground = pubRecord.theme?.showPageBackground; + + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; + let comments = + pubRecord.preferences?.showComments === false + ? 0 + : props.documents.comments_on_documents?.[0]?.count || 0; + let tags = (postRecord?.tags as string[] | undefined) || []; + + return ( + +
+ +
+

{postRecord.title}

+ +

{postRecord.description}

+
+ +
+ + +
+
+
+
+
+ ); +}; + +const PubInfo = (props: { + href: string; + pubRecord: PubLeafletPublication.Record; + uri: string; +}) => { + return ( +
+
+ + + {props.pubRecord.name} + +
+ ); +}; + +const PostInfo = (props: { publishedAt: string | undefined }) => { + return ( +
+ {props.publishedAt && ( + <> +
+ {new Date(props.publishedAt).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+ + )} +
+ ); +}; diff --git a/components/Tags.tsx b/components/Tags.tsx new file mode 100644 index 00000000..4783cdb1 --- /dev/null +++ b/components/Tags.tsx @@ -0,0 +1,296 @@ +"use client"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { Input } from "components/Input"; +import { useState, useRef } from "react"; +import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { Popover } from "components/Popover"; +import Link from "next/link"; +import { searchTags, type TagSearchResult } from "actions/searchTags"; + +export const Tag = (props: { + name: string; + selected?: boolean; + onDelete?: (tag: string) => void; + className?: string; +}) => { + return ( +
+ + {props.name}{" "} + + {props.selected ? ( + + ) : null} +
+ ); +}; + +export const TagSelector = (props: { + selectedTags: string[]; + setSelectedTags: (tags: string[]) => void; +}) => { + return ( +
+ + {props.selectedTags.length > 0 ? ( +
+ {props.selectedTags.map((tag) => ( + { + props.setSelectedTags( + props.selectedTags.filter((t) => t !== tag), + ); + }} + /> + ))} +
+ ) : ( +
no tags selected
+ )} +
+ ); +}; + +export const TagSearchInput = (props: { + selectedTags: string[]; + setSelectedTags: (tags: string[]) => void; +}) => { + 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; + + // Fetch tags whenever the input value changes + useDebouncedEffect( + async () => { + setIsSearching(true); + const results = await searchTags(tagInputValue); + if (results) { + setSearchResults(results); + } + setIsSearching(false); + }, + 300, + [tagInputValue], + ); + + const filteredTags = searchResults + .filter((tag) => !props.selectedTags.includes(tag.name)) + .filter((tag) => + tag.name.toLowerCase().includes(tagInputValue.toLowerCase()), + ); + + const showResults = tagInputValue.length >= 3; + + function clearTagInput() { + setHighlightedIndex(0); + setTagInputValue(""); + } + + function selectTag(tag: string) { + console.log("selected " + tag); + props.setSelectedTags([...props.selectedTags, tag]); + clearTagInput(); + } + + const handleKeyDown = ( + e: React.KeyboardEvent, + ) => { + if (!isOpen) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredTags.length ? prev + 1 : prev, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + selectTag( + userInputResult + ? highlightedIndex === 0 + ? tagInputValue + : filteredTags[highlightedIndex - 1].name + : filteredTags[highlightedIndex].name, + ); + clearTagInput(); + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + const userInputResult = + showResults && + tagInputValue !== "" && + !filteredTags.some((tag) => tag.name === tagInputValue); + + return ( +
+ { + setTagInputValue(e.target.value); + setIsOpen(true); + setHighlightedIndex(0); + }} + onKeyDown={handleKeyDown} + onFocus={() => { + setIsOpen(true); + document.getElementById("tag-search-input")?.focus(); + }} + /> + { + setIsOpen(!isOpen); + if (!isOpen) + setTimeout(() => { + document.getElementById("tag-search-input")?.focus(); + }, 100); + }} + className="w-full p-2! min-w-xs text-primary" + sideOffset={-39} + onOpenAutoFocus={(e) => e.preventDefault()} + asChild + trigger={ + + } + noArrow + > +
+ { + setTagInputValue(e.target.value); + setIsOpen(true); + setHighlightedIndex(0); + }} + onKeyDown={handleKeyDown} + onFocus={() => { + setIsOpen(true); + }} + /> + {props.selectedTags.length > 0 ? ( +
+ {props.selectedTags.map((tag) => ( + { + props.setSelectedTags( + props.selectedTags.filter((t) => t !== tag), + ); + }} + /> + ))} +
+ ) : ( +
+ no tags selected +
+ )} +
+ + {showResults ? ( + <> + {userInputResult && ( + { + selectTag(tagInputValue); + }} + /> + )} + {filteredTags.map((tag, i) => ( + { + selectTag(tag.name); + }} + /> + ))} + + ) : ( +
+ type at least 3 characters to search +
+ )} +
+
+
+ ); +}; + +const TagResult = (props: { + name: string; + tagged: number; + onSelect: () => void; + index: number; + highlighted: boolean; + setHighlightedIndex: (i: number) => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index c0b24dd8..69e9312b 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); }); };