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 leaflet_id, 60 title, 61 description, 62 entitiesToDelete, 63 }: { 64 root_entity: string; ··· 66 leaflet_id: string; 67 title?: string; 68 description?: string; 69 entitiesToDelete?: string[]; 70 }) { 71 const oauthClient = await createOauthClient(); ··· 145 ...(theme && { theme }), 146 title: title || "Untitled", 147 description: description || "", 148 pages: pages.map((p) => { 149 if (p.type === "canvas") { 150 return {
··· 59 leaflet_id, 60 title, 61 description, 62 + tags, 63 entitiesToDelete, 64 }: { 65 root_entity: string; ··· 67 leaflet_id: string; 68 title?: string; 69 description?: string; 70 + tags?: string[]; 71 entitiesToDelete?: string[]; 72 }) { 73 const oauthClient = await createOauthClient(); ··· 147 ...(theme && { theme }), 148 title: title || "Untitled", 149 description: description || "", 150 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 151 pages: pages.map((p) => { 152 if (p.type === "canvas") { 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 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 import { PubIcon } from "components/ActionBar/Publications"; 5 import { Separator } from "components/Layout"; 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 5 import { PubIcon } from "components/ActionBar/Publications"; 6 import { Separator } from "components/Layout"; 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+7 -192
app/(home-pages)/reader/ReaderContent.tsx
··· 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 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 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 import type { Cursor, Post } from "./getReaderFeed"; 18 import useSWRInfinite from "swr/infinite"; 19 import { getReaderFeed } from "./getReaderFeed"; 20 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 24 25 export const ReaderContent = (props: { 26 posts: Post[]; ··· 28 }) => { 29 const getKey = ( 30 pageIndex: number, 31 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 32 ) => { 33 // Reached the end 34 if (previousPageData && !previousPageData.nextCursor) return null; ··· 40 return ["reader-feed", previousPageData?.nextCursor] as const; 41 }; 42 43 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 44 getKey, 45 ([_, cursor]) => getReaderFeed(cursor), 46 { ··· 79 return ( 80 <div className="flex flex-col gap-3 relative"> 81 {allPosts.map((p) => ( 82 - <Post {...p} key={p.documents.uri} /> 83 ))} 84 {/* Trigger element for loading more posts */} 85 <div ··· 96 ); 97 }; 98 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 export const ReaderEmpty = () => { 274 return ( 275 <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">
··· 1 "use client"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 import type { Cursor, Post } from "./getReaderFeed"; 5 import useSWRInfinite from "swr/infinite"; 6 import { getReaderFeed } from "./getReaderFeed"; 7 import { useEffect, useRef } from "react"; 8 import Link from "next/link"; 9 + import { PostListing } from "components/PostListing"; 10 11 export const ReaderContent = (props: { 12 posts: Post[]; ··· 14 }) => { 15 const getKey = ( 16 pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 ) => { 22 // Reached the end 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 29 return ["reader-feed", previousPageData?.nextCursor] as const; 30 }; 31 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 getKey, 34 ([_, cursor]) => getReaderFeed(cursor), 35 { ··· 68 return ( 69 <div className="flex flex-col gap-3 relative"> 70 {allPosts.map((p) => ( 71 + <PostListing {...p} key={p.documents.uri} /> 72 ))} 73 {/* Trigger element for loading more posts */} 74 <div ··· 85 ); 86 }; 87 88 export const ReaderEmpty = () => { 89 return ( 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 import { useState, useMemo } from "react"; 28 import { useIsMobile } from "src/hooks/isMobile"; 29 import { useReplicache, useEntity } from "src/replicache"; 30 import { Json } from "supabase/database.types"; 31 import { 32 useBlocks, ··· 63 const UpdateButton = () => { 64 let [isLoading, setIsLoading] = useState(false); 65 let { data: pub, mutate } = useLeafletPublicationData(); 66 - let { permission_token, rootEntity } = useReplicache(); 67 let { identity } = useIdentityData(); 68 let toaster = useToaster(); 69 70 return ( 71 <ActionButton ··· 81 leaflet_id: permission_token.id, 82 title: pub.title, 83 description: pub.description, 84 }); 85 setIsLoading(false); 86 mutate();
··· 27 import { useState, useMemo } from "react"; 28 import { useIsMobile } from "src/hooks/isMobile"; 29 import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 31 import { Json } from "supabase/database.types"; 32 import { 33 useBlocks, ··· 64 const UpdateButton = () => { 65 let [isLoading, setIsLoading] = useState(false); 66 let { data: pub, mutate } = useLeafletPublicationData(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 68 let { identity } = useIdentityData(); 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 : []; 74 75 return ( 76 <ActionButton ··· 86 leaflet_id: permission_token.id, 87 title: pub.title, 88 description: pub.description, 89 + tags: currentTags, 90 }); 91 setIsLoading(false); 92 mutate();
+1 -1
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 309 )} 310 <div 311 ref={mountRef} 312 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 313 style={{ 314 wordWrap: "break-word", 315 overflowWrap: "break-word",
··· 309 )} 310 <div 311 ref={mountRef} 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 313 style={{ 314 wordWrap: "break-word", 315 overflowWrap: "break-word",
+143 -83
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 import { 17 BlueskyPostEditorProsemirror, 18 editorStateToFacetedText, 19 } from "./BskyPostEditorProsemirror"; 20 import { EditorState } from "prosemirror-state"; 21 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 import { PubIcon } from "components/ActionBar/Publications"; 23 ··· 31 record?: PubLeafletPublication.Record; 32 posts_in_pub?: number; 33 entitiesToDelete?: string[]; 34 }; 35 36 export function PublishPost(props: Props) { ··· 38 { state: "default" } | { state: "success"; post_url: string } 39 >({ state: "default" }); 40 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 42 {publishState.state === "default" ? ( 43 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 ) : ( ··· 58 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 } & Props, 60 ) => { 61 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 65 let params = useParams(); 66 let { rep } = useReplicache(); 67 68 async function submit() { 69 if (isLoading) return; 70 setIsLoading(true); ··· 75 leaflet_id: props.leaflet_id, 76 title: props.title, 77 description: props.description, 78 entitiesToDelete: props.entitiesToDelete, 79 }); 80 if (!doc) return; ··· 109 submit(); 110 }} 111 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 113 <PublishingTo 114 publication_uri={props.publication_uri} 115 record={props.record} 116 /> 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> 195 </div> 196 <div className="flex justify-between"> 197 <Link 198 className="hover:no-underline! font-bold" ··· 210 </div> 211 </div> 212 </form> 213 </div> 214 ); 215 };
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 + 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 17 import { 18 BlueskyPostEditorProsemirror, 19 editorStateToFacetedText, 20 } from "./BskyPostEditorProsemirror"; 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 25 ··· 33 record?: PubLeafletPublication.Record; 34 posts_in_pub?: number; 35 entitiesToDelete?: string[]; 36 + hasDraft: boolean; 37 }; 38 39 export function PublishPost(props: Props) { ··· 41 { state: "default" } | { state: "success"; post_url: string } 42 >({ state: "default" }); 43 return ( 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"> 45 {publishState.state === "default" ? ( 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 47 ) : ( ··· 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 62 } & Props, 63 ) => { 64 + let editorStateRef = useRef<EditorState | null>(null); 65 + let [charCount, setCharCount] = useState(0); 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 let [isLoading, setIsLoading] = useState(false); 68 let params = useParams(); 69 let { rep } = useReplicache(); 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 + 96 async function submit() { 97 if (isLoading) return; 98 setIsLoading(true); ··· 103 leaflet_id: props.leaflet_id, 104 title: props.title, 105 description: props.description, 106 + tags: currentTags, 107 entitiesToDelete: props.entitiesToDelete, 108 }); 109 if (!doc) return; ··· 138 submit(); 139 }} 140 > 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 142 <PublishingTo 143 publication_uri={props.publication_uri} 144 record={props.record} 145 /> 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 + /> 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 165 <div className="flex justify-between"> 166 <Link 167 className="hover:no-underline! font-bold" ··· 179 </div> 180 </div> 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> 273 </div> 274 ); 275 };
+6
app/[leaflet_id]/publish/page.tsx
··· 99 // If parsing fails, just use empty array 100 } 101 102 return ( 103 <ReplicacheProvider 104 rootEntity={rootEntity} ··· 116 record={publication?.record as PubLeafletPublication.Record | undefined} 117 posts_in_pub={publication?.documents_in_publications[0]?.count} 118 entitiesToDelete={entitiesToDelete} 119 /> 120 </ReplicacheProvider> 121 );
··· 99 // If parsing fails, just use empty array 100 } 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 + 107 return ( 108 <ReplicacheProvider 109 rootEntity={rootEntity} ··· 121 record={publication?.record as PubLeafletPublication.Record | undefined} 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 123 entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 125 /> 126 </ReplicacheProvider> 127 );
+6
app/api/rpc/[command]/pull.ts
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 }[]; 77 let pub_patch = publication_data?.[0] 78 ? [ ··· 85 op: "put", 86 key: "publication_title", 87 value: publication_data[0].title, 88 }, 89 ] 90 : [];
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 + tags: string[]; 77 }[]; 78 let pub_patch = publication_data?.[0] 79 ? [ ··· 86 op: "put", 87 key: "publication_title", 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 94 }, 95 ] 96 : [];
+36 -206
app/lish/Subscribe.tsx
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 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 183 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 pubName: string; 186 pub_uri: string; 187 base_url: string; ··· 208 } 209 return ( 210 <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 <div className="flex flex-row gap-2 place-self-center"> 217 <BlueskySubscribeButton 218 pub_uri={props.pub_uri} ··· 231 ); 232 }; 233 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 237 pub_uri: string; 238 subscribers: { identity: string }[]; 239 base_url: string; ··· 248 }); 249 }, null); 250 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 253 > 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> 267 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 - 280 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 283 target="_blank" 284 - aria-label="Subscribe to RSS" 285 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 288 </ButtonPrimary> 289 </a> 290 291 - <hr className="border-border-light my-1" /> 292 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> 301 ); 302 }; 303 ··· 430 </Dialog.Root> 431 ); 432 };
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 export const SubscribeWithBluesky = (props: { 28 pubName: string; 29 pub_uri: string; 30 base_url: string; ··· 51 } 52 return ( 53 <div className="flex flex-col gap-2 text-center justify-center"> 54 <div className="flex flex-row gap-2 place-self-center"> 55 <BlueskySubscribeButton 56 pub_uri={props.pub_uri} ··· 69 ); 70 }; 71 72 + export const ManageSubscription = (props: { 73 pub_uri: string; 74 subscribers: { identity: string }[]; 75 base_url: string; ··· 84 }); 85 }, null); 86 return ( 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 91 > 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 94 95 + {!hasFeed && ( 96 <a 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 98 target="_blank" 99 + className=" place-self-center" 100 > 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 103 </ButtonPrimary> 104 </a> 105 + )} 106 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> 117 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> 127 ); 128 }; 129 ··· 256 </Dialog.Root> 257 ); 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
-1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 213 <Interactions 214 quotesCount={props.quotesCount || 0} 215 commentsCount={props.commentsCount || 0} 216 - compact 217 showComments={props.preferences.showComments} 218 pageId={props.pageId} 219 />
··· 213 <Interactions 214 quotesCount={props.quotesCount || 0} 215 commentsCount={props.commentsCount || 0} 216 showComments={props.preferences.showComments} 217 pageId={props.pageId} 218 />
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 14 import { prefetchQuotesData } from "./Quotes"; 15 16 export type InteractionState = { 17 drawerOpen: undefined | boolean; ··· 99 export const Interactions = (props: { 100 quotesCount: number; 101 commentsCount: number; 102 - compact?: boolean; 103 className?: string; 104 showComments?: boolean; 105 pageId?: string; 106 }) => { 107 const data = useContext(PostPageContext); 108 const document_uri = data?.uri; 109 if (!document_uri) 110 throw new Error("document_uri not available in PostPageContext"); 111 ··· 117 } 118 }; 119 120 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> 142 {props.showComments === false ? null : ( 143 <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"}`} 145 onClick={() => { 146 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 }} 150 aria-label="Post comments" 151 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 158 </button> 159 )} 160 </div> 161 ); 162 }; 163 164 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 if (!document) return; 166 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 ).length; 200 }
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 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"; 15 import { PostPageData } from "../getPostPageData"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 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"; 22 23 export type InteractionState = { 24 drawerOpen: undefined | boolean; ··· 106 export const Interactions = (props: { 107 quotesCount: number; 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 pageId?: string; 112 }) => { 113 const data = useContext(PostPageContext); 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 116 if (!document_uri) 117 throw new Error("document_uri not available in PostPageContext"); 118 ··· 124 } 125 }; 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 130 return ( 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 + )} 149 {props.showComments === false ? null : ( 150 <button 151 + className="flex gap-2 items-center w-fit" 152 onClick={() => { 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 }} 157 aria-label="Post comments" 158 > 159 + <CommentTiny aria-hidden /> {props.commentsCount} 160 </button> 161 )} 162 </div> 163 ); 164 }; 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 + }; 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 323 if (!document) return; 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 357 ).length; 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 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 getCommentCount, 15 getQuoteCount, 16 Interactions, ··· 47 fullPageScroll, 48 hasPageBackground, 49 } = props; 50 - let { identity } = useIdentityData(); 51 let drawer = useDrawerOpen(document_uri); 52 53 if (!document) return null; ··· 84 did={did} 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 /> 87 - <Interactions 88 pageId={pageId} 89 showComments={preferences.showComments} 90 commentsCount={getCommentCount(document, pageId) || 0} 91 quotesCount={getQuoteCount(document, pageId) || 0} 92 /> 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 - )} 131 </PageWrapper> 132 </> 133 );
··· 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 + ExpandedInteractions, 15 getCommentCount, 16 getQuoteCount, 17 Interactions, ··· 48 fullPageScroll, 49 hasPageBackground, 50 } = props; 51 let drawer = useDrawerOpen(document_uri); 52 53 if (!document) return null; ··· 84 did={did} 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 /> 87 + 88 + <ExpandedInteractions 89 pageId={pageId} 90 showComments={preferences.showComments} 91 commentsCount={getCommentCount(document, pageId) || 0} 92 quotesCount={getQuoteCount(document, pageId) || 0} 93 /> 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 95 </PageWrapper> 96 </> 97 );
+1 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 return ( 60 <div 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}`} 63 > 64 {blocks.map((b, index) => { 65 return (
··· 59 return ( 60 <div 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-4 ${className}`} 63 > 64 {blocks.map((b, index) => { 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 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 20 export function PostHeader(props: { 21 data: PostPageData; ··· 40 41 if (!document?.data) return; 42 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"> 49 {pub && ( 50 <SpeedyLink 51 className="font-bold hover:no-underline text-accent-contrast" ··· 65 <EditTiny className="shrink-0" /> 66 </a> 67 )} 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 - |{" "} 91 <Interactions 92 showComments={props.preferences.showComments} 93 - compact 94 quotesCount={getQuoteCount(document) || 0} 95 commentsCount={getCommentCount(document) || 0} 96 /> 97 - </div> 98 </div> 99 </div> 100 ); 101 - }
··· 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 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"; 21 22 export function PostHeader(props: { 23 data: PostPageData; ··· 42 43 if (!document?.data) return; 44 return ( 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 48 {pub && ( 49 <SpeedyLink 50 className="font-bold hover:no-underline text-accent-contrast" ··· 64 <EditTiny className="shrink-0" /> 65 </a> 66 )} 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 <Interactions 92 showComments={props.preferences.showComments} 93 quotesCount={getQuoteCount(document) || 0} 94 commentsCount={getCommentCount(document) || 0} 95 /> 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} 128 </div> 129 </div> 130 ); 131 + };
+19 -28
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; ··· 28 let { data } = usePublicationData(); 29 let params = useParams(); 30 let { publication } = data!; 31 if (!publication) return null; 32 if (publication.documents_in_publications.length === 0) 33 return ( ··· 55 (l) => doc.documents && l.doc === doc.documents.uri, 56 ); 57 let uri = new AtUri(doc.documents.uri); 58 - let record = doc.documents.data as PubLeafletDocument.Record; 59 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 60 let comments = doc.documents.comments_on_documents[0]?.count || 0; 61 62 let postLink = data?.publication 63 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 81 href={`${getPublicationURL(publication)}/${uri.rkey}`} 82 > 83 <h3 className="text-primary grow leading-snug"> 84 - {record.title} 85 </h3> 86 </a> 87 <div className="flex justify-start align-top flex-row gap-1"> ··· 122 </div> 123 </div> 124 125 - {record.description ? ( 126 <p className="italic text-secondary"> 127 - {record.description} 128 </p> 129 ) : null} 130 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 131 - {record.publishedAt ? ( 132 - <PublishedDate dateString={record.publishedAt} /> 133 ) : 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 - )} 154 </div> 155 </div> 156 </div>
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 23 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; ··· 29 let { data } = usePublicationData(); 30 let params = useParams(); 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 34 if (!publication) return null; 35 if (publication.documents_in_publications.length === 0) 36 return ( ··· 58 (l) => doc.documents && l.doc === doc.documents.uri, 59 ); 60 let uri = new AtUri(doc.documents.uri); 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 65 66 let postLink = data?.publication 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 > 87 <h3 className="text-primary grow leading-snug"> 88 + {postRecord.title} 89 </h3> 90 </a> 91 <div className="flex justify-start align-top flex-row gap-1"> ··· 126 </div> 127 </div> 128 129 + {postRecord.description ? ( 130 <p className="italic text-secondary"> 131 + {postRecord.description} 132 </p> 133 ) : null} 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} /> 137 ) : null} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 145 </div> 146 </div> 147 </div>
+9 -17
app/lish/[did]/[publication]/page.tsx
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 import { LocalizedDate } from "./LocalizedDate"; 18 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 ··· 134 record?.preferences?.showComments === false 135 ? 0 136 : doc.documents.comments_on_documents[0].count || 0; 137 138 return ( 139 <React.Fragment key={doc.documents?.uri}> ··· 162 )}{" "} 163 </p> 164 {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 - )} 182 </div> 183 </div> 184 <hr className="last:hidden border-border-light" />
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 ··· 135 record?.preferences?.showComments === false 136 ? 0 137 : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 139 140 return ( 141 <React.Fragment key={doc.documents?.uri}> ··· 164 )}{" "} 165 </p> 166 {comments > 0 || quotes > 0 ? "| " : ""} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 174 </div> 175 </div> 176 <hr className="last:hidden border-border-light" />
+4 -8
components/ActionBar/Navigation.tsx
··· 24 | "pub" 25 | "discover" 26 | "notifications" 27 - | "looseleafs"; 28 29 export const DesktopNavigation = (props: { 30 currentPage: navPages; ··· 126 }; 127 128 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 if (!props.subs) return; 132 return ( 133 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 <ActionButton 135 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 137 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 142 /> 143 </SpeedyLink> 144 );
··· 24 | "pub" 25 | "discover" 26 | "notifications" 27 + | "looseleafs" 28 + | "tag"; 29 30 export const DesktopNavigation = (props: { 31 currentPage: navPages; ··· 127 }; 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 130 if (!props.subs) return; 131 return ( 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 133 <ActionButton 134 nav 135 + icon={<ReaderUnreadSmall />} 136 label="Reader" 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 138 /> 139 </SpeedyLink> 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 ); 59 }; 60 61 - export const focusElement = (el?: HTMLInputElement | null) => { 62 if (!isIOS()) { 63 el?.focus(); 64 return;
··· 58 ); 59 }; 60 61 + export const focusElement = ( 62 + el?: HTMLInputElement | HTMLTextAreaElement | null, 63 + ) => { 64 if (!isIOS()) { 65 el?.focus(); 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 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 import { useIdentityData } from "components/IdentityProvider"; 17 export const PublicationMetadata = () => { 18 let { rep } = useReplicache(); 19 let { data: pub } = useLeafletPublicationData(); ··· 23 tx.get<string>("publication_description"), 24 ); 25 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 26 let publishedAt = record?.publishedAt; 27 28 if (!pub) return null; ··· 33 if (typeof description !== "string") { 34 description = pub?.description || ""; 35 } 36 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> 94 </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </div> 99 ); 100 }; 101 ··· 178 if (!pub) return null; 179 180 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> 185 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> 194 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> 200 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 205 ); 206 };
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { useRef, useState } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 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"; 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 export const PublicationMetadata = () => { 24 let { rep } = useReplicache(); 25 let { data: pub } = useLeafletPublicationData(); ··· 29 tx.get<string>("publication_description"), 30 ); 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 35 let publishedAt = record?.publishedAt; 36 37 if (!pub) return null; ··· 42 if (typeof description !== "string") { 43 description = pub?.description || ""; 44 } 45 + let tags = true; 46 + 47 return ( 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> 66 </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 + /> 136 ); 137 }; 138 ··· 215 if (!pub) return null; 216 217 return ( 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 + }; 236 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 241 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"} 272 </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 277 ); 278 };
+24 -20
components/Popover.tsx
··· 12 children: React.ReactNode; 13 align?: "start" | "end" | "center"; 14 side?: "top" | "bottom" | "left" | "right"; 15 background?: string; 16 border?: string; 17 className?: string; ··· 20 onOpenAutoFocus?: (e: Event) => void; 21 asChild?: boolean; 22 arrowFill?: string; 23 }) => { 24 let [open, setOpen] = useState(props.open || false); 25 useEffect(() => { ··· 51 `} 52 side={props.side} 53 align={props.align ? props.align : "center"} 54 - sideOffset={4} 55 collisionPadding={16} 56 onOpenAutoFocus={props.onOpenAutoFocus} 57 > 58 {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> 78 </RadixPopover.Content> 79 </NestedCardThemeProvider> 80 </RadixPopover.Portal>
··· 12 children: React.ReactNode; 13 align?: "start" | "end" | "center"; 14 side?: "top" | "bottom" | "left" | "right"; 15 + sideOffset?: number; 16 background?: string; 17 border?: string; 18 className?: string; ··· 21 onOpenAutoFocus?: (e: Event) => void; 22 asChild?: boolean; 23 arrowFill?: string; 24 + noArrow?: boolean; 25 }) => { 26 let [open, setOpen] = useState(props.open || false); 27 useEffect(() => { ··· 53 `} 54 side={props.side} 55 align={props.align ? props.align : "center"} 56 + sideOffset={props.sideOffset ? props.sideOffset : 4} 57 collisionPadding={16} 58 onOpenAutoFocus={props.onOpenAutoFocus} 59 > 60 {props.children} 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 + )} 82 </RadixPopover.Content> 83 </NestedCardThemeProvider> 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 type: 'ref', 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 }, 1443 pages: { 1444 type: 'array', 1445 items: {
··· 1440 type: 'ref', 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 + }, 1450 pages: { 1451 type: 'array', 1452 items: {
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 publication?: string 24 author: string 25 theme?: PubLeafletPublication.Theme 26 pages: ( 27 | $Typed<PubLeafletPagesLinearDocument.Main> 28 | $Typed<PubLeafletPagesCanvas.Main>
··· 23 publication?: string 24 author: string 25 theme?: PubLeafletPublication.Theme 26 + tags?: string[] 27 pages: ( 28 | $Typed<PubLeafletPagesLinearDocument.Main> 29 | $Typed<PubLeafletPagesCanvas.Main>
+7
lexicons/pub/leaflet/document.json
··· 46 "type": "ref", 47 "ref": "pub.leaflet.publication#theme" 48 }, 49 "pages": { 50 "type": "array", 51 "items": {
··· 46 "type": "ref", 47 "ref": "pub.leaflet.publication#theme" 48 }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 + }, 56 "pages": { 57 "type": "array", 58 "items": {
+1
lexicons/src/document.ts
··· 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 pages: { 27 type: "array", 28 items: {
··· 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 pages: { 28 type: "array", 29 items: {
+34 -8
src/replicache/mutations.ts
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 614 }> = async (args, ctx) => { 615 await ctx.runOnServer(async (serverCtx) => { 616 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); 621 }); 622 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 625 }); 626 }; 627
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 615 }> = async (args, ctx) => { 616 await ctx.runOnServer(async (serverCtx) => { 617 console.log("updating"); 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 + } 644 }); 645 await ctx.runOnClient(async ({ tx }) => { 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); 651 }); 652 }; 653