a tool for shared writing and social publishing

Feature/tags (#243)

* adjusting a loooot of spacing here and there

* added tag selector to publish page

* endless futzing to get the tag selector working as expected

* fixed a selection indexing issue in tag selector

* added tags to the draft and post metadata

* add tag interaction drawer, trigger from tag buttons

* adjusted postheader and draft headers to include tags

* componentized the interaction previews

* changed tags to use Link rather than button

* added tag page

* wire up tags

* fixes to styling of tag selector popover in draft post header

* a couple more such fixes

* fix fetching tags and simplify result rendering

* refactor interactions into two components

* refactored the footer a bit

* refactored the footer a bit

* revert some stuff in pub listing

* adjustments to postListing and tag page

* fix tag selector when no draft and tweak search

* add hover state to interaction buttons in post listing

---------

Co-authored-by: Jared Pereira <jared@awarm.space>

authored by cozylittle.house

Jared Pereira and committed by
GitHub
ebf323be 8252cf15

+1482 -816
+3
actions/publishToPublication.ts
··· 59 59 leaflet_id, 60 60 title, 61 61 description, 62 + tags, 62 63 entitiesToDelete, 63 64 }: { 64 65 root_entity: string; ··· 66 67 leaflet_id: string; 67 68 title?: string; 68 69 description?: string; 70 + tags?: string[]; 69 71 entitiesToDelete?: string[]; 70 72 }) { 71 73 const oauthClient = await createOauthClient(); ··· 145 147 ...(theme && { theme }), 146 148 title: title || "Untitled", 147 149 description: description || "", 150 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 151 pages: pages.map((p) => { 149 152 if (p.type === "canvas") { 150 153 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 + }
+1
app/(home-pages)/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 4 5 import { PubIcon } from "components/ActionBar/Publications"; 5 6 import { Separator } from "components/Layout"; 6 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+7 -192
app/(home-pages)/reader/ReaderContent.tsx
··· 1 1 "use client"; 2 - import { AtUri } from "@atproto/api"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 2 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 3 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 - import { Separator } from "components/Layout"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { useSmoker } from "components/Toast"; 14 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 - import { Json } from "supabase/database.types"; 17 4 import type { Cursor, Post } from "./getReaderFeed"; 18 5 import useSWRInfinite from "swr/infinite"; 19 6 import { getReaderFeed } from "./getReaderFeed"; 20 7 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 8 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 9 + import { PostListing } from "components/PostListing"; 24 10 25 11 export const ReaderContent = (props: { 26 12 posts: Post[]; ··· 28 14 }) => { 29 15 const getKey = ( 30 16 pageIndex: number, 31 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 32 21 ) => { 33 22 // Reached the end 34 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 40 29 return ["reader-feed", previousPageData?.nextCursor] as const; 41 30 }; 42 31 43 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 44 33 getKey, 45 34 ([_, cursor]) => getReaderFeed(cursor), 46 35 { ··· 79 68 return ( 80 69 <div className="flex flex-col gap-3 relative"> 81 70 {allPosts.map((p) => ( 82 - <Post {...p} key={p.documents.uri} /> 71 + <PostListing {...p} key={p.documents.uri} /> 83 72 ))} 84 73 {/* Trigger element for loading more posts */} 85 74 <div ··· 96 85 ); 97 86 }; 98 87 99 - const Post = (props: Post) => { 100 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 101 - 102 - let postRecord = props.documents.data as PubLeafletDocument.Record; 103 - let postUri = new AtUri(props.documents.uri); 104 - 105 - let theme = usePubTheme(pubRecord?.theme); 106 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 - ? blobRefToSrc( 108 - pubRecord?.theme?.backgroundImage?.image?.ref, 109 - new AtUri(props.publication.uri).host, 110 - ) 111 - : null; 112 - 113 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 114 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 115 - 116 - let showPageBackground = pubRecord.theme?.showPageBackground; 117 - 118 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 119 - let comments = 120 - pubRecord.preferences?.showComments === false 121 - ? 0 122 - : props.documents.comments_on_documents?.[0]?.count || 0; 123 - 124 - return ( 125 - <BaseThemeProvider {...theme} local> 126 - <div 127 - style={{ 128 - backgroundImage: `url(${backgroundImage})`, 129 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 130 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 131 - }} 132 - className={`no-underline! flex flex-row gap-2 w-full relative 133 - bg-bg-leaflet 134 - border border-border-light rounded-lg 135 - sm:p-2 p-2 selected-outline 136 - hover:outline-accent-contrast hover:border-accent-contrast 137 - `} 138 - > 139 - <a 140 - className="h-full w-full absolute top-0 left-0" 141 - href={`${props.publication.href}/${postUri.rkey}`} 142 - /> 143 - <div 144 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 145 - style={{ 146 - backgroundColor: showPageBackground 147 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 148 - : "transparent", 149 - }} 150 - > 151 - <h3 className="text-primary truncate">{postRecord.title}</h3> 152 - 153 - <p className="text-secondary">{postRecord.description}</p> 154 - <div className="flex gap-2 justify-between items-end"> 155 - <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3"> 156 - <PubInfo 157 - href={props.publication.href} 158 - pubRecord={pubRecord} 159 - uri={props.publication.uri} 160 - /> 161 - <Separator classname="h-4 !min-h-0 md:block hidden" /> 162 - <PostInfo 163 - author={props.author || ""} 164 - publishedAt={postRecord.publishedAt} 165 - /> 166 - </div> 167 - 168 - <PostInterations 169 - postUrl={`${props.publication.href}/${postUri.rkey}`} 170 - quotesCount={quotes} 171 - commentsCount={comments} 172 - showComments={pubRecord.preferences?.showComments} 173 - /> 174 - </div> 175 - </div> 176 - </div> 177 - </BaseThemeProvider> 178 - ); 179 - }; 180 - 181 - const PubInfo = (props: { 182 - href: string; 183 - pubRecord: PubLeafletPublication.Record; 184 - uri: string; 185 - }) => { 186 - return ( 187 - <a 188 - href={props.href} 189 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 190 - > 191 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 192 - {props.pubRecord.name} 193 - </a> 194 - ); 195 - }; 196 - 197 - const PostInfo = (props: { 198 - author: string; 199 - publishedAt: string | undefined; 200 - }) => { 201 - const formattedDate = useLocalizedDate( 202 - props.publishedAt || new Date().toISOString(), 203 - { 204 - year: "numeric", 205 - month: "short", 206 - day: "numeric", 207 - }, 208 - ); 209 - 210 - return ( 211 - <div className="flex flex-wrap gap-2 grow items-center shrink-0"> 212 - {props.author} 213 - {props.publishedAt && ( 214 - <> 215 - <Separator classname="h-4 !min-h-0" /> 216 - {formattedDate}{" "} 217 - </> 218 - )} 219 - </div> 220 - ); 221 - }; 222 - 223 - const PostInterations = (props: { 224 - quotesCount: number; 225 - commentsCount: number; 226 - postUrl: string; 227 - showComments: boolean | undefined; 228 - }) => { 229 - let smoker = useSmoker(); 230 - let interactionsAvailable = 231 - props.quotesCount > 0 || 232 - (props.showComments !== false && props.commentsCount > 0); 233 - 234 - return ( 235 - <div className={`flex gap-2 text-tertiary text-sm items-center`}> 236 - {props.quotesCount === 0 ? null : ( 237 - <div className={`flex gap-1 items-center `} aria-label="Post quotes"> 238 - <QuoteTiny aria-hidden /> {props.quotesCount} 239 - </div> 240 - )} 241 - {props.showComments === false || props.commentsCount === 0 ? null : ( 242 - <div className={`flex gap-1 items-center`} aria-label="Post comments"> 243 - <CommentTiny aria-hidden /> {props.commentsCount} 244 - </div> 245 - )} 246 - {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 247 - <button 248 - id={`copy-post-link-${props.postUrl}`} 249 - className="flex gap-1 items-center hover:font-bold relative" 250 - onClick={(e) => { 251 - e.stopPropagation(); 252 - e.preventDefault(); 253 - let mouseX = e.clientX; 254 - let mouseY = e.clientY; 255 - 256 - if (!props.postUrl) return; 257 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 258 - 259 - smoker({ 260 - text: <strong>Copied Link!</strong>, 261 - position: { 262 - y: mouseY, 263 - x: mouseX, 264 - }, 265 - }); 266 - }} 267 - > 268 - Share 269 - </button> 270 - </div> 271 - ); 272 - }; 273 88 export const ReaderEmpty = () => { 274 89 return ( 275 90 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
+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 leading-tight items-center"> 56 + <div className="flex items-center gap-3 text-xl 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();
+1 -1
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 309 309 )} 310 310 <div 311 311 ref={mountRef} 312 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 313 313 style={{ 314 314 wordWrap: "break-word", 315 315 overflowWrap: "break-word",
+143 -83
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 9 + 10 10 import { PubLeafletPublication } from "lexicons/api"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 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, 19 20 } from "./BskyPostEditorProsemirror"; 20 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 21 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 24 import { PubIcon } from "components/ActionBar/Publications"; 23 25 ··· 31 33 record?: PubLeafletPublication.Record; 32 34 posts_in_pub?: number; 33 35 entitiesToDelete?: string[]; 36 + hasDraft: boolean; 34 37 }; 35 38 36 39 export function PublishPost(props: Props) { ··· 38 41 { state: "default" } | { state: "success"; post_url: string } 39 42 >({ state: "default" }); 40 43 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 44 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary"> 42 45 {publishState.state === "default" ? ( 43 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 47 ) : ( ··· 58 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 62 } & Props, 60 63 ) => { 64 + let editorStateRef = useRef<EditorState | null>(null); 65 + let [charCount, setCharCount] = useState(0); 61 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 67 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 65 68 let params = useParams(); 66 69 let { rep } = useReplicache(); 67 70 71 + // For publications with drafts, use Replicache; otherwise use local state 72 + let replicacheTags = useSubscribe(rep, (tx) => 73 + tx.get<string[]>("publication_tags"), 74 + ); 75 + let [localTags, setLocalTags] = useState<string[]>([]); 76 + 77 + // Use Replicache tags only when we have a draft 78 + const hasDraft = props.hasDraft; 79 + const currentTags = hasDraft 80 + ? Array.isArray(replicacheTags) 81 + ? replicacheTags 82 + : [] 83 + : localTags; 84 + 85 + // Update tags via Replicache mutation or local state depending on context 86 + const handleTagsChange = async (newTags: string[]) => { 87 + if (hasDraft) { 88 + await rep?.mutate.updatePublicationDraft({ 89 + tags: newTags, 90 + }); 91 + } else { 92 + setLocalTags(newTags); 93 + } 94 + }; 95 + 68 96 async function submit() { 69 97 if (isLoading) return; 70 98 setIsLoading(true); ··· 75 103 leaflet_id: props.leaflet_id, 76 104 title: props.title, 77 105 description: props.description, 106 + tags: currentTags, 78 107 entitiesToDelete: props.entitiesToDelete, 79 108 }); 80 109 if (!doc) return; ··· 109 138 submit(); 110 139 }} 111 140 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 113 142 <PublishingTo 114 143 publication_uri={props.publication_uri} 115 144 record={props.record} 116 145 /> 117 - <hr className="border-border-light my-1" /> 118 - <Radio 119 - checked={shareOption === "quiet"} 120 - onChange={(e) => { 121 - if (e.target === e.currentTarget) { 122 - setShareOption("quiet"); 123 - } 124 - }} 125 - name="share-options" 126 - id="share-quietly" 127 - value="Share Quietly" 128 - > 129 - <div className="flex flex-col"> 130 - <div className="font-bold">Share Quietly</div> 131 - <div className="text-sm text-tertiary font-normal"> 132 - No one will be notified about this post 133 - </div> 134 - </div> 135 - </Radio> 136 - <Radio 137 - checked={shareOption === "bluesky"} 138 - onChange={(e) => { 139 - if (e.target === e.currentTarget) { 140 - setShareOption("bluesky"); 141 - } 142 - }} 143 - name="share-options" 144 - id="share-bsky" 145 - value="Share on Bluesky" 146 - > 147 - <div className="flex flex-col"> 148 - <div className="font-bold">Share on Bluesky</div> 149 - <div className="text-sm text-tertiary font-normal"> 150 - Pub subscribers will be updated via a custom Bluesky feed 151 - </div> 152 - </div> 153 - </Radio> 154 - 155 - <div 156 - className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 157 - > 158 - <div className="opaque-container p-3 rounded-lg!"> 159 - <div className="flex gap-2"> 160 - <img 161 - className="rounded-full w-[42px] h-[42px] shrink-0" 162 - src={props.profile.avatar} 163 - /> 164 - <div className="flex flex-col w-full"> 165 - <div className="flex gap-2 pb-1"> 166 - <p className="font-bold">{props.profile.displayName}</p> 167 - <p className="text-tertiary">@{props.profile.handle}</p> 168 - </div> 169 - <div className="flex flex-col"> 170 - <BlueskyPostEditorProsemirror 171 - editorStateRef={editorStateRef} 172 - onCharCountChange={setCharCount} 173 - /> 174 - </div> 175 - <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> 176 - <div className="flex flex-col p-2"> 177 - <div className="font-bold">{props.title}</div> 178 - <div className="text-tertiary">{props.description}</div> 179 - {props.record && ( 180 - <> 181 - <hr className="border-border-light mt-2 mb-1" /> 182 - <p className="text-xs text-tertiary"> 183 - {props.record?.base_path} 184 - </p> 185 - </> 186 - )} 187 - </div> 188 - </div> 189 - <div className="text-xs text-secondary italic place-self-end pt-2"> 190 - {charCount}/300 191 - </div> 192 - </div> 193 - </div> 194 - </div> 146 + <hr className="border-border" /> 147 + <ShareOptions 148 + setShareOption={setShareOption} 149 + shareOption={shareOption} 150 + charCount={charCount} 151 + setCharCount={setCharCount} 152 + editorStateRef={editorStateRef} 153 + {...props} 154 + /> 155 + <hr className="border-border " /> 156 + <div className="flex flex-col gap-2"> 157 + <h4>Tags</h4> 158 + <TagSelector 159 + selectedTags={currentTags} 160 + setSelectedTags={handleTagsChange} 161 + /> 195 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 196 165 <div className="flex justify-between"> 197 166 <Link 198 167 className="hover:no-underline! font-bold" ··· 210 179 </div> 211 180 </div> 212 181 </form> 182 + </div> 183 + ); 184 + }; 185 + 186 + const ShareOptions = (props: { 187 + shareOption: "quiet" | "bluesky"; 188 + setShareOption: (option: typeof props.shareOption) => void; 189 + charCount: number; 190 + setCharCount: (c: number) => void; 191 + editorStateRef: React.MutableRefObject<EditorState | null>; 192 + title: string; 193 + profile: ProfileViewDetailed; 194 + description: string; 195 + record?: PubLeafletPublication.Record; 196 + }) => { 197 + return ( 198 + <div className="flex flex-col gap-2"> 199 + <h4>Notifications</h4> 200 + <Radio 201 + checked={props.shareOption === "quiet"} 202 + onChange={(e) => { 203 + if (e.target === e.currentTarget) { 204 + props.setShareOption("quiet"); 205 + } 206 + }} 207 + name="share-options" 208 + id="share-quietly" 209 + value="Share Quietly" 210 + > 211 + <div className="flex flex-col"> 212 + <div className="font-bold">Share Quietly</div> 213 + <div className="text-sm text-tertiary font-normal"> 214 + No one will be notified about this post 215 + </div> 216 + </div> 217 + </Radio> 218 + <Radio 219 + checked={props.shareOption === "bluesky"} 220 + onChange={(e) => { 221 + if (e.target === e.currentTarget) { 222 + props.setShareOption("bluesky"); 223 + } 224 + }} 225 + name="share-options" 226 + id="share-bsky" 227 + value="Share on Bluesky" 228 + > 229 + <div className="flex flex-col"> 230 + <div className="font-bold">Share on Bluesky</div> 231 + <div className="text-sm text-tertiary font-normal"> 232 + Pub subscribers will be updated via a custom Bluesky feed 233 + </div> 234 + </div> 235 + </Radio> 236 + <div 237 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 238 + > 239 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 240 + <div className="flex gap-2"> 241 + <img 242 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 243 + src={props.profile.avatar} 244 + /> 245 + <div className="flex flex-col w-full"> 246 + <div className="flex gap-2 "> 247 + <p className="font-bold">{props.profile.displayName}</p> 248 + <p className="text-tertiary">@{props.profile.handle}</p> 249 + </div> 250 + <div className="flex flex-col"> 251 + <BlueskyPostEditorProsemirror 252 + editorStateRef={props.editorStateRef} 253 + onCharCountChange={props.setCharCount} 254 + /> 255 + </div> 256 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 257 + <div className="flex flex-col p-2"> 258 + <div className="font-bold">{props.title}</div> 259 + <div className="text-tertiary">{props.description}</div> 260 + <hr className="border-border mt-2 mb-1" /> 261 + <p className="text-xs text-tertiary"> 262 + {props.record?.base_path} 263 + </p> 264 + </div> 265 + </div> 266 + <div className="text-xs text-secondary italic place-self-end pt-2"> 267 + {props.charCount}/300 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 213 273 </div> 214 274 ); 215 275 };
+6
app/[leaflet_id]/publish/page.tsx
··· 99 99 // If parsing fails, just use empty array 100 100 } 101 101 102 + // Check if a draft record exists (either in a publication or standalone) 103 + let hasDraft = 104 + data.leaflets_in_publications.length > 0 || 105 + data.leaflets_to_documents.length > 0; 106 + 102 107 return ( 103 108 <ReplicacheProvider 104 109 rootEntity={rootEntity} ··· 116 121 record={publication?.record as PubLeafletPublication.Record | undefined} 117 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 118 123 entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 119 125 /> 120 126 </ReplicacheProvider> 121 127 );
+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 : [];
+36 -206
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 - import { SpeedyLink } from "components/SpeedyLink"; 27 - 28 - type State = 29 - | { state: "email" } 30 - | { state: "code"; token: string } 31 - | { state: "success" }; 32 - export const SubscribeButton = (props: { 33 - compact?: boolean; 34 - publication: string; 35 - }) => { 36 - let { identity, mutate } = useIdentityData(); 37 - let [emailInputValue, setEmailInputValue] = useState(""); 38 - let [codeInputValue, setCodeInputValue] = useState(""); 39 - let [state, setState] = useState<State>({ state: "email" }); 40 - 41 - if (state.state === "email") { 42 - return ( 43 - <div className="flex gap-2"> 44 - <div className="flex relative w-full max-w-sm"> 45 - <Input 46 - type="email" 47 - className="input-with-border pr-[104px]! py-1! grow w-full" 48 - placeholder={ 49 - props.compact ? "subscribe with email..." : "email here..." 50 - } 51 - disabled={!!identity?.email} 52 - value={identity?.email ? identity.email : emailInputValue} 53 - onChange={(e) => { 54 - setEmailInputValue(e.currentTarget.value); 55 - }} 56 - /> 57 - <ButtonPrimary 58 - compact 59 - className="absolute right-1 top-1 outline-0!" 60 - onClick={async () => { 61 - if (identity?.email) { 62 - await subscribeToPublicationWithEmail(props.publication); 63 - //optimistically could add! 64 - await mutate(); 65 - return; 66 - } 67 - let tokenID = await requestAuthEmailToken(emailInputValue); 68 - setState({ state: "code", token: tokenID }); 69 - }} 70 - > 71 - {props.compact ? ( 72 - <ArrowRightTiny className="w-4 h-6" /> 73 - ) : ( 74 - "Subscribe" 75 - )} 76 - </ButtonPrimary> 77 - </div> 78 - {/* <ShareButton /> */} 79 - </div> 80 - ); 81 - } 82 - if (state.state === "code") { 83 - return ( 84 - <div 85 - className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 86 - style={{ 87 - background: 88 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 89 - }} 90 - > 91 - <div className="flex flex-col leading-snug text-secondary"> 92 - <div>Please enter the code we sent to </div> 93 - <div className="italic font-bold">{emailInputValue}</div> 94 - </div> 95 - 96 - <ConfirmCodeInput 97 - publication={props.publication} 98 - token={state.token} 99 - codeInputValue={codeInputValue} 100 - setCodeInputValue={setCodeInputValue} 101 - setState={setState} 102 - /> 103 - 104 - <button 105 - className="text-accent-contrast text-sm mt-1" 106 - onClick={() => { 107 - setState({ state: "email" }); 108 - }} 109 - > 110 - Re-enter Email 111 - </button> 112 - </div> 113 - ); 114 - } 115 - 116 - if (state.state === "success") { 117 - return ( 118 - <div 119 - className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`} 120 - style={{ 121 - background: 122 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 123 - }} 124 - > 125 - <div className="flex gap-2 leading-snug font-bold italic"> 126 - <div>You're subscribed!</div> 127 - {/* <ShareButton /> */} 128 - </div> 129 - </div> 130 - ); 131 - } 132 - }; 133 - 134 - export const ShareButton = () => { 135 - return ( 136 - <button className="text-accent-contrast"> 137 - <ShareSmall /> 138 - </button> 139 - ); 140 - }; 141 - 142 - const ConfirmCodeInput = (props: { 143 - codeInputValue: string; 144 - token: string; 145 - setCodeInputValue: (value: string) => void; 146 - setState: (state: State) => void; 147 - publication: string; 148 - }) => { 149 - let { mutate } = useIdentityData(); 150 - return ( 151 - <div className="relative w-fit mt-2"> 152 - <Input 153 - type="text" 154 - pattern="[0-9]" 155 - className="input-with-border pr-[88px]! py-1! max-w-[156px]" 156 - placeholder="000000" 157 - value={props.codeInputValue} 158 - onChange={(e) => { 159 - props.setCodeInputValue(e.currentTarget.value); 160 - }} 161 - /> 162 - <ButtonPrimary 163 - compact 164 - className="absolute right-1 top-1 outline-0!" 165 - onClick={async () => { 166 - console.log( 167 - await confirmEmailAuthToken(props.token, props.codeInputValue), 168 - ); 169 - 170 - await subscribeToPublicationWithEmail(props.publication); 171 - //optimistically could add! 172 - await mutate(); 173 - props.setState({ state: "success" }); 174 - return; 175 - }} 176 - > 177 - Confirm 178 - </ButtonPrimary> 179 - </div> 180 - ); 181 - }; 182 26 183 27 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 28 pubName: string; 186 29 pub_uri: string; 187 30 base_url: string; ··· 208 51 } 209 52 return ( 210 53 <div className="flex flex-col gap-2 text-center justify-center"> 211 - {props.isPost && ( 212 - <div className="text-sm text-tertiary font-bold"> 213 - Get updates from {props.pubName}! 214 - </div> 215 - )} 216 54 <div className="flex flex-row gap-2 place-self-center"> 217 55 <BlueskySubscribeButton 218 56 pub_uri={props.pub_uri} ··· 231 69 ); 232 70 }; 233 71 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 72 + export const ManageSubscription = (props: { 237 73 pub_uri: string; 238 74 subscribers: { identity: string }[]; 239 75 base_url: string; ··· 248 84 }); 249 85 }, null); 250 86 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 253 91 > 254 - <div className="font-bold text-tertiary text-sm"> 255 - You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 - {props.isPost && ( 257 - <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 - {props.pubName} 259 - </SpeedyLink> 260 - )} 261 - </div> 262 - <Popover 263 - trigger={<div className="text-accent-contrast text-sm">Manage</div>} 264 - > 265 - <div className="max-w-sm flex flex-col gap-1"> 266 - <h4>Update Options</h4> 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 267 94 268 - {!hasFeed && ( 269 - <a 270 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 271 - target="_blank" 272 - className=" place-self-center" 273 - > 274 - <ButtonPrimary fullWidth compact className="!px-4"> 275 - View Bluesky Custom Feed 276 - </ButtonPrimary> 277 - </a> 278 - )} 279 - 95 + {!hasFeed && ( 280 96 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 283 98 target="_blank" 284 - aria-label="Subscribe to RSS" 99 + className=" place-self-center" 285 100 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 288 103 </ButtonPrimary> 289 104 </a> 105 + )} 290 106 291 - <hr className="border-border-light my-1" /> 107 + <a 108 + href={`${props.base_url}/rss`} 109 + className="flex" 110 + target="_blank" 111 + aria-label="Subscribe to RSS" 112 + > 113 + <ButtonPrimary fullWidth compact> 114 + Get RSS 115 + </ButtonPrimary> 116 + </a> 292 117 293 - <form action={unsubscribe}> 294 - <button className="font-bold text-accent-contrast w-max place-self-center"> 295 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 296 - </button> 297 - </form> 298 - </div>{" "} 299 - </Popover> 300 - </div> 118 + <hr className="border-border-light my-1" /> 119 + 120 + <form action={unsubscribe}> 121 + <button className="font-bold text-accent-contrast w-max place-self-center"> 122 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 123 + </button> 124 + </form> 125 + </div> 126 + </Popover> 301 127 ); 302 128 }; 303 129 ··· 430 256 </Dialog.Root> 431 257 ); 432 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
-1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 213 213 <Interactions 214 214 quotesCount={props.quotesCount || 0} 215 215 commentsCount={props.commentsCount || 0} 216 - compact 217 216 showComments={props.preferences.showComments} 218 217 pageId={props.pageId} 219 218 />
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { TagTiny } from "components/Icons/TagTiny"; 13 + import { Tag } from "components/Tags"; 14 + import { Popover } from "components/Popover"; 12 15 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 17 import { prefetchQuotesData } from "./Quotes"; 18 + import { useIdentityData } from "components/IdentityProvider"; 19 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 20 + import { EditTiny } from "components/Icons/EditTiny"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 22 16 23 export type InteractionState = { 17 24 drawerOpen: undefined | boolean; ··· 99 106 export const Interactions = (props: { 100 107 quotesCount: number; 101 108 commentsCount: number; 102 - compact?: boolean; 103 109 className?: string; 104 110 showComments?: boolean; 105 111 pageId?: string; 106 112 }) => { 107 113 const data = useContext(PostPageContext); 108 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 109 116 if (!document_uri) 110 117 throw new Error("document_uri not available in PostPageContext"); 111 118 ··· 117 124 } 118 125 }; 119 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 120 130 return ( 121 - <div 122 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`} 123 - > 124 - <button 125 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 126 - onClick={() => { 127 - if (!drawerOpen || drawer !== "quotes") 128 - openInteractionDrawer("quotes", document_uri, props.pageId); 129 - else setInteractionState(document_uri, { drawerOpen: false }); 130 - }} 131 - onMouseEnter={handleQuotePrefetch} 132 - onTouchStart={handleQuotePrefetch} 133 - aria-label="Post quotes" 134 - > 135 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 136 - {!props.compact && ( 137 - <span 138 - aria-hidden 139 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 140 - )} 141 - </button> 131 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 + 134 + {props.quotesCount > 0 && ( 135 + <button 136 + className="flex w-fit gap-2 items-center" 137 + onClick={() => { 138 + if (!drawerOpen || drawer !== "quotes") 139 + openInteractionDrawer("quotes", document_uri, props.pageId); 140 + else setInteractionState(document_uri, { drawerOpen: false }); 141 + }} 142 + onMouseEnter={handleQuotePrefetch} 143 + onTouchStart={handleQuotePrefetch} 144 + aria-label="Post quotes" 145 + > 146 + <QuoteTiny aria-hidden /> {props.quotesCount} 147 + </button> 148 + )} 142 149 {props.showComments === false ? null : ( 143 150 <button 144 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 151 + className="flex gap-2 items-center w-fit" 145 152 onClick={() => { 146 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 156 }} 150 157 aria-label="Post comments" 151 158 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 159 + <CommentTiny aria-hidden /> {props.commentsCount} 158 160 </button> 159 161 )} 160 162 </div> 161 163 ); 162 164 }; 163 165 166 + export const ExpandedInteractions = (props: { 167 + quotesCount: number; 168 + commentsCount: number; 169 + className?: string; 170 + showComments?: boolean; 171 + pageId?: string; 172 + }) => { 173 + const data = useContext(PostPageContext); 174 + let { identity } = useIdentityData(); 175 + 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + 180 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 181 + 182 + const handleQuotePrefetch = () => { 183 + if (data?.quotesAndMentions) { 184 + prefetchQuotesData(data.quotesAndMentions); 185 + } 186 + }; 187 + let publication = data?.documents_in_publications[0]?.publications; 188 + 189 + const tags = (data?.data as any)?.tags as string[] | undefined; 190 + const tagCount = tags?.length || 0; 191 + 192 + let subscribed = 193 + identity?.atp_did && 194 + publication?.publication_subscriptions && 195 + publication?.publication_subscriptions.find( 196 + (s) => s.identity === identity.atp_did, 197 + ); 198 + 199 + let isAuthor = 200 + identity && 201 + identity.atp_did === 202 + data.documents_in_publications[0]?.publications?.identity_did && 203 + data.leaflets_in_publications[0]; 204 + 205 + return ( 206 + <div 207 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 208 + > 209 + {!subscribed && !isAuthor && publication && publication.record && ( 210 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 211 + <div className="flex flex-col py-4"> 212 + <div className="leading-snug flex flex-col pb-2 text-sm"> 213 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 214 + to get updates in Reader, RSS, or via Bluesky Feed 215 + </div> 216 + <SubscribeWithBluesky 217 + pubName={publication.name} 218 + pub_uri={publication.uri} 219 + base_url={ 220 + (publication.record as PubLeafletPublication.Record) 221 + .base_path || "" 222 + } 223 + subscribers={publication?.publication_subscriptions} 224 + /> 225 + </div> 226 + </div> 227 + )} 228 + {tagCount > 0 && ( 229 + <> 230 + <hr className="border-border-light mb-3" /> 231 + 232 + <TagList tags={tags} className="mb-3" /> 233 + </> 234 + )} 235 + <hr className="border-border-light mb-3 " /> 236 + <div className="flex gap-2 justify-between"> 237 + <div className="flex gap-2"> 238 + {props.quotesCount > 0 && ( 239 + <button 240 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 241 + onClick={() => { 242 + if (!drawerOpen || drawer !== "quotes") 243 + openInteractionDrawer("quotes", document_uri, props.pageId); 244 + else setInteractionState(document_uri, { drawerOpen: false }); 245 + }} 246 + onMouseEnter={handleQuotePrefetch} 247 + onTouchStart={handleQuotePrefetch} 248 + aria-label="Post quotes" 249 + > 250 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 + <span 252 + aria-hidden 253 + >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 254 + </button> 255 + )} 256 + {props.showComments === false ? null : ( 257 + <button 258 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + onClick={() => { 260 + if ( 261 + !drawerOpen || 262 + drawer !== "comments" || 263 + pageId !== props.pageId 264 + ) 265 + openInteractionDrawer("comments", document_uri, props.pageId); 266 + else setInteractionState(document_uri, { drawerOpen: false }); 267 + }} 268 + aria-label="Post comments" 269 + > 270 + <CommentTiny aria-hidden />{" "} 271 + {props.commentsCount > 0 ? ( 272 + <span aria-hidden> 273 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 274 + </span> 275 + ) : ( 276 + "Comment" 277 + )} 278 + </button> 279 + )} 280 + </div> 281 + <EditButton document={data} /> 282 + {subscribed && publication && ( 283 + <ManageSubscription 284 + base_url={getPublicationURL(publication)} 285 + pub_uri={publication.uri} 286 + subscribers={publication.publication_subscriptions} 287 + /> 288 + )} 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + const TagPopover = (props: { 295 + tagCount: number; 296 + tags: string[] | undefined; 297 + }) => { 298 + return ( 299 + <Popover 300 + className="p-2! max-w-xs" 301 + trigger={ 302 + <div className="tags flex gap-1 items-center "> 303 + <TagTiny /> {props.tagCount} 304 + </div> 305 + } 306 + > 307 + <TagList tags={props.tags} className="text-secondary!" /> 308 + </Popover> 309 + ); 310 + }; 311 + 312 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 313 + if (!props.tags) return; 314 + return ( 315 + <div className="flex gap-1 flex-wrap"> 316 + {props.tags.map((tag, index) => ( 317 + <Tag name={tag} key={index} className={props.className} /> 318 + ))} 319 + </div> 320 + ); 321 + }; 164 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 323 if (!document) return; 166 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 357 ).length; 200 358 } 359 + 360 + const EditButton = (props: { document: PostPageData }) => { 361 + let { identity } = useIdentityData(); 362 + if (!props.document) return; 363 + if ( 364 + identity && 365 + identity.atp_did === 366 + props.document.documents_in_publications[0]?.publications?.identity_did && 367 + props.document.leaflets_in_publications[0] 368 + ) 369 + return ( 370 + <a 371 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 372 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 373 + > 374 + <EditTiny /> Edit Post 375 + </a> 376 + ); 377 + return; 378 + };
+4 -40
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 11 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 12 import { EditTiny } from "components/Icons/EditTiny"; 13 13 import { 14 + ExpandedInteractions, 14 15 getCommentCount, 15 16 getQuoteCount, 16 17 Interactions, ··· 47 48 fullPageScroll, 48 49 hasPageBackground, 49 50 } = props; 50 - let { identity } = useIdentityData(); 51 51 let drawer = useDrawerOpen(document_uri); 52 52 53 53 if (!document) return null; ··· 84 84 did={did} 85 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 86 /> 87 - <Interactions 87 + 88 + <ExpandedInteractions 88 89 pageId={pageId} 89 90 showComments={preferences.showComments} 90 91 commentsCount={getCommentCount(document, pageId) || 0} 91 92 quotesCount={getQuoteCount(document, pageId) || 0} 92 93 /> 93 - {!isSubpage && ( 94 - <> 95 - <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 96 - <div className="sm:px-4 px-3"> 97 - {identity && 98 - identity.atp_did === 99 - document.documents_in_publications[0]?.publications 100 - ?.identity_did && 101 - document.leaflets_in_publications[0] ? ( 102 - <a 103 - href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 104 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 105 - > 106 - <EditTiny /> Edit Post 107 - </a> 108 - ) : ( 109 - document.documents_in_publications[0]?.publications && ( 110 - <SubscribeWithBluesky 111 - isPost 112 - base_url={getPublicationURL( 113 - document.documents_in_publications[0].publications, 114 - )} 115 - pub_uri={ 116 - document.documents_in_publications[0].publications.uri 117 - } 118 - subscribers={ 119 - document.documents_in_publications[0].publications 120 - .publication_subscriptions 121 - } 122 - pubName={ 123 - document.documents_in_publications[0].publications.name 124 - } 125 - /> 126 - ) 127 - )} 128 - </div> 129 - </> 130 - )} 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 131 95 </PageWrapper> 132 96 </> 133 97 );
+1 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 59 return ( 60 60 <div 61 61 //The postContent class is important for QuoteHandler 62 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`} 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 63 > 64 64 {blocks.map((b, index) => { 65 65 return (
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
··· 1 - "use client"; 2 - 3 - import { Media } from "components/Media"; 4 - import { 5 - Interactions, 6 - useInteractionState, 7 - } from "../Interactions/Interactions"; 8 - import { useState, useEffect } from "react"; 9 - import { Json } from "supabase/database.types"; 10 - 11 - // export const CollapsedPostHeader = (props: { 12 - // title: string; 13 - // pubIcon?: string; 14 - // quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 - // }) => { 16 - // let [headerVisible, setHeaderVisible] = useState(false); 17 - // let { drawerOpen: open } = useInteractionState(); 18 - 19 - // useEffect(() => { 20 - // let post = window.document.getElementById("post-page"); 21 - 22 - // function handleScroll() { 23 - // let postHeader = window.document 24 - // .getElementById("post-header") 25 - // ?.getBoundingClientRect(); 26 - // if (postHeader && postHeader.bottom <= 0) { 27 - // setHeaderVisible(true); 28 - // } else { 29 - // setHeaderVisible(false); 30 - // } 31 - // } 32 - // post?.addEventListener("scroll", handleScroll); 33 - // return () => { 34 - // post?.removeEventListener("scroll", handleScroll); 35 - // }; 36 - // }, []); 37 - // if (!headerVisible) return; 38 - // if (open) return; 39 - // return ( 40 - // <Media 41 - // mobile 42 - // className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 - // > 44 - // <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 - // <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 - // {props.title} 47 - // </div> 48 - // <div className="flex gap-2 "> 49 - // <Interactions compact quotes={props.quotes.length} /> 50 - // <div 51 - // style={{ 52 - // backgroundRepeat: "no-repeat", 53 - // backgroundPosition: "center", 54 - // backgroundSize: "cover", 55 - // backgroundImage: `url(${props.pubIcon})`, 56 - // }} 57 - // className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 - // /> 59 - // </div> 60 - // </div> 61 - // </Media> 62 - // ); 63 - // };
+62 -32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 16 16 import { EditTiny } from "components/Icons/EditTiny"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 + import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 + import { Separator } from "components/Layout"; 19 21 20 22 export function PostHeader(props: { 21 23 data: PostPageData; ··· 40 42 41 43 if (!document?.data) return; 42 44 return ( 43 - <div 44 - className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" 45 - id="post-header" 46 - > 47 - <div className="pubHeader flex flex-col pb-5"> 48 - <div className="flex justify-between w-full"> 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 49 48 {pub && ( 50 49 <SpeedyLink 51 50 className="font-bold hover:no-underline text-accent-contrast" ··· 65 64 <EditTiny className="shrink-0" /> 66 65 </a> 67 66 )} 68 - </div> 69 - <h2 className="leading-tight">{record.title}</h2> 70 - {record.description ? ( 71 - <p className="italic text-secondary pt-1">{record.description}</p> 72 - ) : null} 73 - 74 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 75 - {profile ? ( 76 - <> 77 - <a 78 - className="text-tertiary" 79 - href={`https://bsky.app/profile/${profile.handle}`} 80 - > 81 - by {profile.displayName || profile.handle} 82 - </a> 83 - </> 84 - ) : null} 85 - {record.publishedAt ? ( 86 - <> 87 - |<p>{formattedDate}</p> 88 - </> 89 - ) : null} 90 - |{" "} 67 + </> 68 + } 69 + postTitle={record.title} 70 + postDescription={record.description} 71 + postInfo={ 72 + <> 73 + <div className="flex flex-row gap-2 items-center"> 74 + {profile ? ( 75 + <> 76 + <a 77 + className="text-tertiary" 78 + href={`https://bsky.app/profile/${profile.handle}`} 79 + > 80 + {profile.displayName || profile.handle} 81 + </a> 82 + </> 83 + ) : null} 84 + {record.publishedAt ? ( 85 + <> 86 + <Separator classname="h-4!" /> 87 + <p>{formattedDate}</p> 88 + </> 89 + ) : null} 90 + </div> 91 91 <Interactions 92 92 showComments={props.preferences.showComments} 93 - compact 94 93 quotesCount={getQuoteCount(document) || 0} 95 94 commentsCount={getCommentCount(document) || 0} 96 95 /> 97 - </div> 96 + </> 97 + } 98 + /> 99 + ); 100 + } 101 + 102 + export const PostHeaderLayout = (props: { 103 + pubLink: React.ReactNode; 104 + postTitle: React.ReactNode | undefined; 105 + postDescription: React.ReactNode | undefined; 106 + postInfo: React.ReactNode; 107 + }) => { 108 + return ( 109 + <div 110 + className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 + id="post-header" 112 + > 113 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 114 + {props.pubLink} 115 + </div> 116 + <h2 117 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 118 + > 119 + {props.postTitle ? props.postTitle : "Untitled"} 120 + </h2> 121 + {props.postDescription ? ( 122 + <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + {props.postDescription} 124 + </p> 125 + ) : null} 126 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 + {props.postInfo} 98 128 </div> 99 129 </div> 100 130 ); 101 - } 131 + };
+19 -28
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 import { EditTiny } from "components/Icons/EditTiny"; 5 5 6 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 20 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 23 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; ··· 28 29 let { data } = usePublicationData(); 29 30 let params = useParams(); 30 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 31 34 if (!publication) return null; 32 35 if (publication.documents_in_publications.length === 0) 33 36 return ( ··· 55 58 (l) => doc.documents && l.doc === doc.documents.uri, 56 59 ); 57 60 let uri = new AtUri(doc.documents.uri); 58 - let record = doc.documents.data as PubLeafletDocument.Record; 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 59 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 60 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 61 65 62 66 let postLink = data?.publication 63 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 81 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 82 86 > 83 87 <h3 className="text-primary grow leading-snug"> 84 - {record.title} 88 + {postRecord.title} 85 89 </h3> 86 90 </a> 87 91 <div className="flex justify-start align-top flex-row gap-1"> ··· 122 126 </div> 123 127 </div> 124 128 125 - {record.description ? ( 129 + {postRecord.description ? ( 126 130 <p className="italic text-secondary"> 127 - {record.description} 131 + {postRecord.description} 128 132 </p> 129 133 ) : null} 130 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 131 - {record.publishedAt ? ( 132 - <PublishedDate dateString={record.publishedAt} /> 134 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 + {postRecord.publishedAt ? ( 136 + <PublishedDate dateString={postRecord.publishedAt} /> 133 137 ) : null} 134 - {(comments > 0 || quotes > 0) && record.publishedAt 135 - ? " | " 136 - : ""} 137 - {quotes > 0 && ( 138 - <SpeedyLink 139 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 140 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 141 - > 142 - <QuoteTiny /> {quotes} 143 - </SpeedyLink> 144 - )} 145 - {comments > 0 && quotes > 0 ? " " : ""} 146 - {comments > 0 && ( 147 - <SpeedyLink 148 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 149 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 150 - > 151 - <CommentTiny /> {comments} 152 - </SpeedyLink> 153 - )} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 154 145 </div> 155 146 </div> 156 147 </div>
+9 -17
app/lish/[did]/[publication]/page.tsx
··· 14 14 import { SpeedyLink } from "components/SpeedyLink"; 15 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 17 18 import { LocalizedDate } from "./LocalizedDate"; 18 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 20 ··· 134 135 record?.preferences?.showComments === false 135 136 ? 0 136 137 : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 137 139 138 140 return ( 139 141 <React.Fragment key={doc.documents?.uri}> ··· 162 164 )}{" "} 163 165 </p> 164 166 {comments > 0 || quotes > 0 ? "| " : ""} 165 - {quotes > 0 && ( 166 - <SpeedyLink 167 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 168 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 169 - > 170 - <QuoteTiny /> {quotes} 171 - </SpeedyLink> 172 - )} 173 - {comments > 0 && 174 - record?.preferences?.showComments !== false && ( 175 - <SpeedyLink 176 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 177 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 178 - > 179 - <CommentTiny /> {comments} 180 - </SpeedyLink> 181 - )} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 182 174 </div> 183 175 </div> 184 176 <hr className="last:hidden border-border-light" />
+4 -8
components/ActionBar/Navigation.tsx
··· 24 24 | "pub" 25 25 | "discover" 26 26 | "notifications" 27 - | "looseleafs"; 27 + | "looseleafs" 28 + | "tag"; 28 29 29 30 export const DesktopNavigation = (props: { 30 31 currentPage: navPages; ··· 126 127 }; 127 128 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 130 if (!props.subs) return; 132 131 return ( 133 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 133 <ActionButton 135 134 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 135 + icon={<ReaderUnreadSmall />} 137 136 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 142 138 /> 143 139 </SpeedyLink> 144 140 );
+19
components/Icons/TagTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const TagTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M3.70775 9.003C3.96622 8.90595 4.25516 9.03656 4.35228 9.29499C4.37448 9.35423 4.38309 9.41497 4.38255 9.47468C4.38208 9.6765 4.25946 9.86621 4.05931 9.94148C3.36545 10.2021 2.74535 10.833 2.42747 11.5479C2.33495 11.7561 2.27242 11.9608 2.239 12.1573C2.15817 12.6374 2.25357 13.069 2.52513 13.3858C2.92043 13.8467 3.51379 14.0403 4.20189 14.0665C4.88917 14.0925 5.59892 13.9482 6.12571 13.8126C7.09158 13.5639 7.81893 13.6157 8.29954 13.9415C8.67856 14.1986 8.83462 14.578 8.8347 14.9298C8.83502 15.0506 8.81652 15.1682 8.78294 15.2764C8.7009 15.5398 8.42049 15.6873 8.15696 15.6055C7.89935 15.5253 7.75386 15.2555 7.82396 14.9971C7.82572 14.9905 7.8258 14.9833 7.82786 14.9766C7.83167 14.9643 7.834 14.9503 7.8347 14.9356C7.83623 14.8847 7.8147 14.823 7.739 14.7716C7.61179 14.6853 7.23586 14.5616 6.37474 14.7833C5.81779 14.9266 4.99695 15.1 4.1638 15.0684C3.33126 15.0368 2.41412 14.7967 1.76536 14.0401C1.30175 13.4992 1.16206 12.8427 1.22728 12.1993C1.23863 12.086 1.25554 11.9732 1.27903 11.8614C1.28235 11.8457 1.28624 11.8302 1.28978 11.8145C1.34221 11.5817 1.41832 11.3539 1.51439 11.1378C1.92539 10.2136 2.72927 9.37064 3.70775 9.003ZM13.8972 7.54695C14.124 7.38948 14.4359 7.44622 14.5935 7.67292C14.7508 7.89954 14.6948 8.21063 14.4685 8.36823L8.65892 12.4044C8.24041 12.695 7.74265 12.8515 7.23314 12.8516H3.9138C3.63794 12.8515 3.41315 12.6274 3.41282 12.3516C3.41282 12.0755 3.63769 11.8517 3.9138 11.8516H7.23216C7.538 11.8516 7.8374 11.7575 8.0886 11.5831L13.8972 7.54695ZM10.1609 0.550851C10.6142 0.235853 11.2372 0.347685 11.5525 0.800851L14.6091 5.19734C14.9239 5.65063 14.8121 6.27369 14.3591 6.58894L7.88841 11.087C7.63297 11.2645 7.32837 11.3586 7.01732 11.3555L4.1804 11.3262C3.76371 11.3218 3.38443 11.1921 3.072 10.9776C3.23822 10.7748 3.43062 10.5959 3.63646 10.4503C3.96958 10.5767 4.35782 10.5421 4.67259 10.3233C5.17899 9.97084 5.30487 9.27438 4.95286 8.76765C4.60048 8.26108 3.90304 8.13639 3.39622 8.48835C3.17656 8.64127 3.02799 8.85895 2.9597 9.09773C2.69658 9.26211 2.45194 9.45783 2.23118 9.67585C2.17892 9.38285 2.19133 9.07163 2.28294 8.76081L3.14818 5.8282C3.24483 5.50092 3.45101 5.21639 3.73118 5.02155L10.1609 0.550851ZM8.76732 3.73835L9.73607 4.91023L8.68626 5.41804L7.79466 6.24323L7.04857 4.91804L6.26634 5.45417L7.22923 6.63386L5.72337 7.40437L6.34739 8.31355L7.60814 7.18464L8.37767 8.53132L9.15989 7.99421L8.17454 6.79792L9.27708 6.25788L10.1179 5.46589L10.8786 6.81452L11.6609 6.27741L10.6745 5.07917L12.1882 4.30476L11.5642 3.39558L10.2976 4.52839L9.54954 3.20124L8.76732 3.73835Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+3 -1
components/Input.tsx
··· 58 58 ); 59 59 }; 60 60 61 - export const focusElement = (el?: HTMLInputElement | null) => { 61 + export const focusElement = ( 62 + el?: HTMLInputElement | HTMLTextAreaElement | null, 63 + ) => { 62 64 if (!isIOS()) { 63 65 el?.focus(); 64 66 return;
+114
components/InteractionsPreview.tsx
··· 1 + "use client"; 2 + import { Separator } from "./Layout"; 3 + import { CommentTiny } from "./Icons/CommentTiny"; 4 + import { QuoteTiny } from "./Icons/QuoteTiny"; 5 + import { useSmoker } from "./Toast"; 6 + import { Tag } from "./Tags"; 7 + import { Popover } from "./Popover"; 8 + import { TagTiny } from "./Icons/TagTiny"; 9 + import { SpeedyLink } from "./SpeedyLink"; 10 + 11 + export const InteractionPreview = (props: { 12 + quotesCount: number; 13 + commentsCount: number; 14 + tags?: string[]; 15 + postUrl: string; 16 + showComments: boolean | undefined; 17 + share?: boolean; 18 + }) => { 19 + let smoker = useSmoker(); 20 + let interactionsAvailable = 21 + props.quotesCount > 0 || 22 + (props.showComments !== false && props.commentsCount > 0); 23 + 24 + const tagsCount = props.tags?.length || 0; 25 + 26 + return ( 27 + <div 28 + className={`flex gap-2 text-tertiary text-sm items-center self-start`} 29 + > 30 + {tagsCount === 0 ? null : ( 31 + <> 32 + <TagPopover tags={props.tags!} /> 33 + {interactionsAvailable || props.share ? ( 34 + <Separator classname="h-4!" /> 35 + ) : null} 36 + </> 37 + )} 38 + 39 + {props.quotesCount === 0 ? null : ( 40 + <SpeedyLink 41 + aria-label="Post quotes" 42 + href={`${props.postUrl}?interactionDrawer=quotes`} 43 + className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 44 + > 45 + <QuoteTiny /> {props.quotesCount} 46 + </SpeedyLink> 47 + )} 48 + {props.showComments === false || props.commentsCount === 0 ? null : ( 49 + <SpeedyLink 50 + aria-label="Post comments" 51 + href={`${props.postUrl}?interactionDrawer=comments`} 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 + > 54 + <CommentTiny /> {props.commentsCount} 55 + </SpeedyLink> 56 + )} 57 + {interactionsAvailable && props.share ? ( 58 + <Separator classname="h-4! !min-h-0" /> 59 + ) : null} 60 + {props.share && ( 61 + <> 62 + <button 63 + id={`copy-post-link-${props.postUrl}`} 64 + className="flex gap-1 items-center hover:text-accent-contrast relative" 65 + onClick={(e) => { 66 + e.stopPropagation(); 67 + e.preventDefault(); 68 + let mouseX = e.clientX; 69 + let mouseY = e.clientY; 70 + 71 + if (!props.postUrl) return; 72 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 73 + 74 + smoker({ 75 + text: <strong>Copied Link!</strong>, 76 + position: { 77 + y: mouseY, 78 + x: mouseX, 79 + }, 80 + }); 81 + }} 82 + > 83 + Share 84 + </button> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + const TagPopover = (props: { tags: string[] }) => { 92 + return ( 93 + <Popover 94 + className="p-2! max-w-xs" 95 + trigger={ 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 97 + <TagTiny /> {props.tags.length} 98 + </div> 99 + } 100 + > 101 + <TagList tags={props.tags} className="text-secondary!" /> 102 + </Popover> 103 + ); 104 + }; 105 + 106 + const TagList = (props: { tags: string[]; className?: string }) => { 107 + return ( 108 + <div className="flex gap-1 flex-wrap"> 109 + {props.tags.map((tag, index) => ( 110 + <Tag name={tag} key={index} className={props.className} /> 111 + ))} 112 + </div> 113 + ); 114 + };
+156 -84
components/Pages/PublicationMetadata.tsx
··· 1 1 import Link from "next/link"; 2 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 3 + import { useRef, useState } from "react"; 4 4 import { useReplicache } from "src/replicache"; 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 9 import { 10 10 getBasePublicationURL, 11 11 getPublicationURL, ··· 13 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 15 import { timeAgo } from "src/utils/timeAgo"; 16 + import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 18 + import { TagTiny } from "components/Icons/TagTiny"; 19 + import { Popover } from "components/Popover"; 20 + import { TagSelector } from "components/Tags"; 16 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 17 23 export const PublicationMetadata = () => { 18 24 let { rep } = useReplicache(); 19 25 let { data: pub } = useLeafletPublicationData(); ··· 23 29 tx.get<string>("publication_description"), 24 30 ); 25 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 26 35 let publishedAt = record?.publishedAt; 27 36 28 37 if (!pub) return null; ··· 33 42 if (typeof description !== "string") { 34 43 description = pub?.description || ""; 35 44 } 45 + let tags = true; 46 + 36 47 return ( 37 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 38 - <div className="flex gap-2"> 39 - {pub.publications && ( 40 - <Link 41 - href={ 42 - identity?.atp_did === pub.publications?.identity_did 43 - ? `${getBasePublicationURL(pub.publications)}/dashboard` 44 - : getPublicationURL(pub.publications) 45 - } 46 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 47 - > 48 - {pub.publications?.name} 49 - </Link> 50 - )} 51 - <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 52 - Editor 53 - </div> 54 - </div> 55 - <TextField 56 - className="text-xl font-bold outline-hidden bg-transparent" 57 - value={title} 58 - onChange={async (newTitle) => { 59 - await rep?.mutate.updatePublicationDraft({ 60 - title: newTitle, 61 - description, 62 - }); 63 - }} 64 - placeholder="Untitled" 65 - /> 66 - <TextField 67 - placeholder="add an optional description..." 68 - className="italic text-secondary outline-hidden bg-transparent" 69 - value={description} 70 - onChange={async (newDescription) => { 71 - await rep?.mutate.updatePublicationDraft({ 72 - title, 73 - description: newDescription, 74 - }); 75 - }} 76 - /> 77 - {pub.doc ? ( 78 - <div className="flex flex-row items-center gap-2 pt-3"> 79 - <p className="text-sm text-tertiary"> 80 - Published {publishedAt && timeAgo(publishedAt)} 81 - </p> 82 - <Separator classname="h-4" /> 83 - <Link 84 - target="_blank" 85 - className="text-sm" 86 - href={ 87 - pub.publications 88 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 89 - : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 90 - } 91 - > 92 - View Post 93 - </Link> 48 + <PostHeaderLayout 49 + pubLink={ 50 + <div className="flex gap-2 items-center"> 51 + {pub.publications && ( 52 + <Link 53 + href={ 54 + identity?.atp_did === pub.publications?.identity_did 55 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 56 + : getPublicationURL(pub.publications) 57 + } 58 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 59 + > 60 + {pub.publications?.name} 61 + </Link> 62 + )} 63 + <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 64 + DRAFT 65 + </div> 94 66 </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </div> 67 + } 68 + postTitle={ 69 + <TextField 70 + className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 71 + value={title} 72 + onChange={async (newTitle) => { 73 + await rep?.mutate.updatePublicationDraft({ 74 + title: newTitle, 75 + description, 76 + }); 77 + }} 78 + placeholder="Untitled" 79 + /> 80 + } 81 + postDescription={ 82 + <TextField 83 + placeholder="add an optional description..." 84 + className="pt-1 italic text-secondary outline-hidden bg-transparent" 85 + value={description} 86 + onChange={async (newDescription) => { 87 + await rep?.mutate.updatePublicationDraft({ 88 + title, 89 + description: newDescription, 90 + }); 91 + }} 92 + /> 93 + } 94 + postInfo={ 95 + <> 96 + {pub.doc ? ( 97 + <div className="flex gap-2 items-center"> 98 + <p className="text-sm text-tertiary"> 99 + Published {publishedAt && timeAgo(publishedAt)} 100 + </p> 101 + 102 + <Link 103 + target="_blank" 104 + className="text-sm" 105 + href={ 106 + pub.publications 107 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 108 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 109 + } 110 + > 111 + View 112 + </Link> 113 + </div> 114 + ) : ( 115 + <p>Draft</p> 116 + )} 117 + <div className="flex gap-2 text-border items-center"> 118 + {tags && ( 119 + <> 120 + <AddTags /> 121 + <Separator classname="h-4!" /> 122 + </> 123 + )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />— 126 + </div> 127 + {pubRecord?.preferences?.showComments && ( 128 + <div className="flex gap-1 items-center"> 129 + <CommentTiny />— 130 + </div> 131 + )} 132 + </div> 133 + </> 134 + } 135 + /> 99 136 ); 100 137 }; 101 138 ··· 178 215 if (!pub) return null; 179 216 180 217 return ( 181 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 182 - <div className="text-accent-contrast font-bold hover:no-underline"> 183 - {pub.publications?.name} 184 - </div> 218 + <PostHeaderLayout 219 + pubLink={ 220 + <div className="text-accent-contrast font-bold hover:no-underline"> 221 + {pub.publications?.name} 222 + </div> 223 + } 224 + postTitle={pub.title} 225 + postDescription={pub.description} 226 + postInfo={ 227 + pub.doc ? ( 228 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 229 + ) : ( 230 + <p>Draft</p> 231 + ) 232 + } 233 + /> 234 + ); 235 + }; 185 236 186 - <div 187 - className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`} 188 - > 189 - {pub.title ? pub.title : "Untitled"} 190 - </div> 191 - <div className="italic text-secondary outline-hidden bg-transparent"> 192 - {pub.description} 193 - </div> 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 194 241 195 - {pub.doc ? ( 196 - <div className="flex flex-row items-center gap-2 pt-3"> 197 - <p className="text-sm text-tertiary"> 198 - Published {publishedAt && timeAgo(publishedAt)} 199 - </p> 242 + // Get tags from Replicache local state or published document 243 + let replicacheTags = useSubscribe(rep, (tx) => 244 + tx.get<string[]>("publication_tags"), 245 + ); 246 + 247 + // Determine which tags to use - prioritize Replicache state 248 + let tags: string[] = []; 249 + if (Array.isArray(replicacheTags)) { 250 + tags = replicacheTags; 251 + } else if (record?.tags && Array.isArray(record.tags)) { 252 + tags = record.tags as string[]; 253 + } 254 + 255 + // Update tags in replicache local state 256 + const handleTagsChange = async (newTags: string[]) => { 257 + // Store tags in replicache for next publish/update 258 + await rep?.mutate.updatePublicationDraft({ 259 + tags: newTags, 260 + }); 261 + }; 262 + 263 + return ( 264 + <Popover 265 + className="p-2! w-full min-w-xs" 266 + trigger={ 267 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 268 + <TagTiny />{" "} 269 + {tags.length > 0 270 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 271 + : "Add Tags"} 200 272 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 205 277 ); 206 278 };
+24 -20
components/Popover.tsx
··· 12 12 children: React.ReactNode; 13 13 align?: "start" | "end" | "center"; 14 14 side?: "top" | "bottom" | "left" | "right"; 15 + sideOffset?: number; 15 16 background?: string; 16 17 border?: string; 17 18 className?: string; ··· 20 21 onOpenAutoFocus?: (e: Event) => void; 21 22 asChild?: boolean; 22 23 arrowFill?: string; 24 + noArrow?: boolean; 23 25 }) => { 24 26 let [open, setOpen] = useState(props.open || false); 25 27 useEffect(() => { ··· 51 53 `} 52 54 side={props.side} 53 55 align={props.align ? props.align : "center"} 54 - sideOffset={4} 56 + sideOffset={props.sideOffset ? props.sideOffset : 4} 55 57 collisionPadding={16} 56 58 onOpenAutoFocus={props.onOpenAutoFocus} 57 59 > 58 60 {props.children} 59 - <RadixPopover.Arrow 60 - asChild 61 - width={16} 62 - height={8} 63 - viewBox="0 0 16 8" 64 - > 65 - <PopoverArrow 66 - arrowFill={ 67 - props.arrowFill 68 - ? props.arrowFill 69 - : props.background 70 - ? props.background 71 - : theme.colors["bg-page"] 72 - } 73 - arrowStroke={ 74 - props.border ? props.border : theme.colors["border"] 75 - } 76 - /> 77 - </RadixPopover.Arrow> 61 + {!props.noArrow && ( 62 + <RadixPopover.Arrow 63 + asChild 64 + width={16} 65 + height={8} 66 + viewBox="0 0 16 8" 67 + > 68 + <PopoverArrow 69 + arrowFill={ 70 + props.arrowFill 71 + ? props.arrowFill 72 + : props.background 73 + ? props.background 74 + : theme.colors["bg-page"] 75 + } 76 + arrowStroke={ 77 + props.border ? props.border : theme.colors["border"] 78 + } 79 + /> 80 + </RadixPopover.Arrow> 81 + )} 78 82 </RadixPopover.Content> 79 83 </NestedCardThemeProvider> 80 84 </RadixPopover.Portal>
+132
components/PostListing.tsx
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { PubIcon } from "components/ActionBar/Publications"; 4 + import { CommentTiny } from "components/Icons/CommentTiny"; 5 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 6 + import { Separator } from "components/Layout"; 7 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 + import { useSmoker } from "components/Toast"; 10 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 + 14 + import Link from "next/link"; 15 + import { InteractionPreview } from "./InteractionsPreview"; 16 + 17 + export const PostListing = (props: Post) => { 18 + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 19 + 20 + let postRecord = props.documents.data as PubLeafletDocument.Record; 21 + let postUri = new AtUri(props.documents.uri); 22 + 23 + let theme = usePubTheme(pubRecord.theme); 24 + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 25 + ? blobRefToSrc( 26 + pubRecord?.theme?.backgroundImage?.image?.ref, 27 + new AtUri(props.publication.uri).host, 28 + ) 29 + : null; 30 + 31 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 32 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 33 + 34 + let showPageBackground = pubRecord.theme?.showPageBackground; 35 + 36 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 37 + let comments = 38 + pubRecord.preferences?.showComments === false 39 + ? 0 40 + : props.documents.comments_on_documents?.[0]?.count || 0; 41 + let tags = (postRecord?.tags as string[] | undefined) || []; 42 + 43 + return ( 44 + <BaseThemeProvider {...theme} local> 45 + <div 46 + style={{ 47 + backgroundImage: `url(${backgroundImage})`, 48 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 49 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 50 + }} 51 + className={`no-underline! flex flex-row gap-2 w-full relative 52 + bg-bg-leaflet 53 + border border-border-light rounded-lg 54 + sm:p-2 p-2 selected-outline 55 + hover:outline-accent-contrast hover:border-accent-contrast 56 + `} 57 + > 58 + <Link 59 + className="h-full w-full absolute top-0 left-0" 60 + href={`${props.publication.href}/${postUri.rkey}`} 61 + /> 62 + <div 63 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 64 + style={{ 65 + backgroundColor: showPageBackground 66 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 67 + : "transparent", 68 + }} 69 + > 70 + <h3 className="text-primary truncate">{postRecord.title}</h3> 71 + 72 + <p className="text-secondary italic">{postRecord.description}</p> 73 + <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 74 + <PubInfo 75 + href={props.publication.href} 76 + pubRecord={pubRecord} 77 + uri={props.publication.uri} 78 + /> 79 + <div className="flex flex-row justify-between gap-2 items-center w-full"> 80 + <PostInfo publishedAt={postRecord.publishedAt} /> 81 + <InteractionPreview 82 + postUrl={`${props.publication.href}/${postUri.rkey}`} 83 + quotesCount={quotes} 84 + commentsCount={comments} 85 + tags={tags} 86 + showComments={pubRecord.preferences?.showComments} 87 + share 88 + /> 89 + </div> 90 + </div> 91 + </div> 92 + </div> 93 + </BaseThemeProvider> 94 + ); 95 + }; 96 + 97 + const PubInfo = (props: { 98 + href: string; 99 + pubRecord: PubLeafletPublication.Record; 100 + uri: string; 101 + }) => { 102 + return ( 103 + <div className="flex flex-col md:w-auto shrink-0 w-full"> 104 + <hr className="md:hidden block border-border-light mb-2" /> 105 + <Link 106 + href={props.href} 107 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 108 + > 109 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 110 + {props.pubRecord.name} 111 + </Link> 112 + </div> 113 + ); 114 + }; 115 + 116 + const PostInfo = (props: { publishedAt: string | undefined }) => { 117 + return ( 118 + <div className="flex gap-2 items-center shrink-0 self-start"> 119 + {props.publishedAt && ( 120 + <> 121 + <div className="shrink-0"> 122 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 123 + year: "numeric", 124 + month: "short", 125 + day: "numeric", 126 + })} 127 + </div> 128 + </> 129 + )} 130 + </div> 131 + ); 132 + };
+296
components/Tags.tsx
··· 1 + "use client"; 2 + import { CloseTiny } from "components/Icons/CloseTiny"; 3 + import { Input } from "components/Input"; 4 + import { useState, useRef } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { Popover } from "components/Popover"; 7 + import Link from "next/link"; 8 + import { searchTags, type TagSearchResult } from "actions/searchTags"; 9 + 10 + export const Tag = (props: { 11 + name: string; 12 + selected?: boolean; 13 + onDelete?: (tag: string) => void; 14 + className?: string; 15 + }) => { 16 + return ( 17 + <div 18 + className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`} 19 + > 20 + <Link 21 + href={`/tag/${encodeURIComponent(props.name)}`} 22 + className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`} 23 + > 24 + {props.name}{" "} 25 + </Link> 26 + {props.selected ? ( 27 + <button 28 + type="button" 29 + onClick={() => (props.onDelete ? props.onDelete(props.name) : null)} 30 + > 31 + <CloseTiny className="scale-75 pr-1 text-accent-2" /> 32 + </button> 33 + ) : null} 34 + </div> 35 + ); 36 + }; 37 + 38 + export const TagSelector = (props: { 39 + selectedTags: string[]; 40 + setSelectedTags: (tags: string[]) => void; 41 + }) => { 42 + return ( 43 + <div className="flex flex-col gap-2 text-primary"> 44 + <TagSearchInput 45 + selectedTags={props.selectedTags} 46 + setSelectedTags={props.setSelectedTags} 47 + /> 48 + {props.selectedTags.length > 0 ? ( 49 + <div className="flex flex-wrap gap-2 "> 50 + {props.selectedTags.map((tag) => ( 51 + <Tag 52 + key={tag} 53 + name={tag} 54 + selected 55 + onDelete={() => { 56 + props.setSelectedTags( 57 + props.selectedTags.filter((t) => t !== tag), 58 + ); 59 + }} 60 + /> 61 + ))} 62 + </div> 63 + ) : ( 64 + <div className="text-tertiary italic text-sm h-6">no tags selected</div> 65 + )} 66 + </div> 67 + ); 68 + }; 69 + 70 + export const TagSearchInput = (props: { 71 + selectedTags: string[]; 72 + setSelectedTags: (tags: string[]) => void; 73 + }) => { 74 + let [tagInputValue, setTagInputValue] = useState(""); 75 + let [isOpen, setIsOpen] = useState(false); 76 + let [highlightedIndex, setHighlightedIndex] = useState(0); 77 + let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]); 78 + let [isSearching, setIsSearching] = useState(false); 79 + 80 + const placeholderInputRef = useRef<HTMLButtonElement | null>(null); 81 + 82 + let inputWidth = placeholderInputRef.current?.clientWidth; 83 + 84 + // Fetch tags whenever the input value changes 85 + useDebouncedEffect( 86 + async () => { 87 + setIsSearching(true); 88 + const results = await searchTags(tagInputValue); 89 + if (results) { 90 + setSearchResults(results); 91 + } 92 + setIsSearching(false); 93 + }, 94 + 300, 95 + [tagInputValue], 96 + ); 97 + 98 + const filteredTags = searchResults 99 + .filter((tag) => !props.selectedTags.includes(tag.name)) 100 + .filter((tag) => 101 + tag.name.toLowerCase().includes(tagInputValue.toLowerCase()), 102 + ); 103 + 104 + const showResults = tagInputValue.length >= 3; 105 + 106 + function clearTagInput() { 107 + setHighlightedIndex(0); 108 + setTagInputValue(""); 109 + } 110 + 111 + function selectTag(tag: string) { 112 + console.log("selected " + tag); 113 + props.setSelectedTags([...props.selectedTags, tag]); 114 + clearTagInput(); 115 + } 116 + 117 + const handleKeyDown = ( 118 + e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>, 119 + ) => { 120 + if (!isOpen) return; 121 + 122 + if (e.key === "ArrowDown") { 123 + e.preventDefault(); 124 + setHighlightedIndex((prev) => 125 + prev < filteredTags.length ? prev + 1 : prev, 126 + ); 127 + } else if (e.key === "ArrowUp") { 128 + e.preventDefault(); 129 + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); 130 + } else if (e.key === "Enter") { 131 + e.preventDefault(); 132 + selectTag( 133 + userInputResult 134 + ? highlightedIndex === 0 135 + ? tagInputValue 136 + : filteredTags[highlightedIndex - 1].name 137 + : filteredTags[highlightedIndex].name, 138 + ); 139 + clearTagInput(); 140 + } else if (e.key === "Escape") { 141 + setIsOpen(false); 142 + } 143 + }; 144 + 145 + const userInputResult = 146 + showResults && 147 + tagInputValue !== "" && 148 + !filteredTags.some((tag) => tag.name === tagInputValue); 149 + 150 + return ( 151 + <div className="relative"> 152 + <Input 153 + className="input-with-border grow w-full outline-none!" 154 + id="placeholder-tag-search-input" 155 + value={tagInputValue} 156 + placeholder="search tags…" 157 + onChange={(e) => { 158 + setTagInputValue(e.target.value); 159 + setIsOpen(true); 160 + setHighlightedIndex(0); 161 + }} 162 + onKeyDown={handleKeyDown} 163 + onFocus={() => { 164 + setIsOpen(true); 165 + document.getElementById("tag-search-input")?.focus(); 166 + }} 167 + /> 168 + <Popover 169 + open={isOpen} 170 + onOpenChange={() => { 171 + setIsOpen(!isOpen); 172 + if (!isOpen) 173 + setTimeout(() => { 174 + document.getElementById("tag-search-input")?.focus(); 175 + }, 100); 176 + }} 177 + className="w-full p-2! min-w-xs text-primary" 178 + sideOffset={-39} 179 + onOpenAutoFocus={(e) => e.preventDefault()} 180 + asChild 181 + trigger={ 182 + <button 183 + ref={placeholderInputRef} 184 + className="absolute left-0 top-0 right-0 h-[30px]" 185 + ></button> 186 + } 187 + noArrow 188 + > 189 + <div className="" style={{ width: `${inputWidth}px` }}> 190 + <Input 191 + className="input-with-border grow w-full mb-2" 192 + id="tag-search-input" 193 + placeholder="search tags…" 194 + value={tagInputValue} 195 + onChange={(e) => { 196 + setTagInputValue(e.target.value); 197 + setIsOpen(true); 198 + setHighlightedIndex(0); 199 + }} 200 + onKeyDown={handleKeyDown} 201 + onFocus={() => { 202 + setIsOpen(true); 203 + }} 204 + /> 205 + {props.selectedTags.length > 0 ? ( 206 + <div className="flex flex-wrap gap-2 pb-[6px]"> 207 + {props.selectedTags.map((tag) => ( 208 + <Tag 209 + key={tag} 210 + name={tag} 211 + selected 212 + onDelete={() => { 213 + props.setSelectedTags( 214 + props.selectedTags.filter((t) => t !== tag), 215 + ); 216 + }} 217 + /> 218 + ))} 219 + </div> 220 + ) : ( 221 + <div className="text-tertiary italic text-sm h-6"> 222 + no tags selected 223 + </div> 224 + )} 225 + <hr className=" mb-[2px] border-border-light" /> 226 + 227 + {showResults ? ( 228 + <> 229 + {userInputResult && ( 230 + <TagResult 231 + key={"userInput"} 232 + index={0} 233 + name={tagInputValue} 234 + tagged={0} 235 + highlighted={0 === highlightedIndex} 236 + setHighlightedIndex={setHighlightedIndex} 237 + onSelect={() => { 238 + selectTag(tagInputValue); 239 + }} 240 + /> 241 + )} 242 + {filteredTags.map((tag, i) => ( 243 + <TagResult 244 + key={tag.name} 245 + index={userInputResult ? i + 1 : i} 246 + name={tag.name} 247 + tagged={tag.document_count} 248 + highlighted={ 249 + (userInputResult ? i + 1 : i) === highlightedIndex 250 + } 251 + setHighlightedIndex={setHighlightedIndex} 252 + onSelect={() => { 253 + selectTag(tag.name); 254 + }} 255 + /> 256 + ))} 257 + </> 258 + ) : ( 259 + <div className="text-tertiary italic text-sm py-1"> 260 + type at least 3 characters to search 261 + </div> 262 + )} 263 + </div> 264 + </Popover> 265 + </div> 266 + ); 267 + }; 268 + 269 + const TagResult = (props: { 270 + name: string; 271 + tagged: number; 272 + onSelect: () => void; 273 + index: number; 274 + highlighted: boolean; 275 + setHighlightedIndex: (i: number) => void; 276 + }) => { 277 + return ( 278 + <div className="-mx-1"> 279 + <button 280 + className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`} 281 + onSelect={(e) => { 282 + e.preventDefault(); 283 + props.onSelect(); 284 + }} 285 + onClick={(e) => { 286 + e.preventDefault(); 287 + props.onSelect(); 288 + }} 289 + onMouseEnter={(e) => props.setHighlightedIndex(props.index)} 290 + > 291 + {props.name} 292 + <div className="text-tertiary text-sm"> {props.tagged}</div> 293 + </button> 294 + </div> 295 + ); 296 + };
+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