a tool for shared writing and social publishing

Merge branch 'main' into feature/profiles

+2194 -1434
+4 -4
actions/publishToPublication.ts
··· 32 32 import { scanIndexLocal } from "src/replicache/utils"; 33 33 import type { Fact } from "src/replicache"; 34 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 36 import { ids } from "lexicons/api/lexicons"; 40 37 import { BlobRef } from "@atproto/lexicon"; 41 38 import { AtUri } from "@atproto/syntax"; ··· 59 56 leaflet_id, 60 57 title, 61 58 description, 59 + tags, 62 60 entitiesToDelete, 63 61 }: { 64 62 root_entity: string; ··· 66 64 leaflet_id: string; 67 65 title?: string; 68 66 description?: string; 67 + tags?: string[]; 69 68 entitiesToDelete?: string[]; 70 69 }) { 71 70 const oauthClient = await createOauthClient(); ··· 145 144 ...(theme && { theme }), 146 145 title: title || "Untitled", 147 146 description: description || "", 147 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 148 pages: pages.map((p) => { 149 149 if (p.type === "canvas") { 150 150 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 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 11 import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types"; 13 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 15 import { pool } from "supabase/pool"; 16 16 17 17 let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 4 5 import { PubIcon } from "components/ActionBar/Publications"; 5 6 import { Separator } from "components/Layout"; 6 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+6 -4
app/(home-pages)/home/HomeLayout.tsx
··· 289 289 !!leaflet.leaflets_to_documents?.find((l) => l.document); 290 290 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 291 291 let docs = !leaflet.leaflets_in_publications?.length && !archived; 292 - // If no filters are active, show all 292 + 293 + // If no filters are active, show everything that is not archived 293 294 if ( 294 295 !filter.drafts && 295 296 !filter.published && ··· 298 299 ) 299 300 return archived === false || archived === null || archived == undefined; 300 301 302 + //if a filter is on, return itemsd of that filter that are also NOT archived 301 303 return ( 302 - (filter.drafts && drafts) || 303 - (filter.published && published) || 304 - (filter.docs && docs) || 304 + (filter.drafts && drafts && !archived) || 305 + (filter.published && published && !archived) || 306 + (filter.docs && docs && !archived) || 305 307 (filter.archived && archived) 306 308 ); 307 309 },
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 108 108 await archivePost(tokenId); 109 109 toaster({ 110 110 content: ( 111 - <div className="font-bold flex gap-2"> 111 + <div className="font-bold flex gap-2 items-center"> 112 112 Archived {itemType}! 113 113 <ButtonTertiary 114 114 className="underline text-accent-2!"
+7 -192
app/(home-pages)/reader/ReaderContent.tsx
··· 1 1 "use client"; 2 - import { AtUri } from "@atproto/api"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 2 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 3 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 - import { Separator } from "components/Layout"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { useSmoker } from "components/Toast"; 14 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 - import { Json } from "supabase/database.types"; 17 4 import type { Cursor, Post } from "./getReaderFeed"; 18 5 import useSWRInfinite from "swr/infinite"; 19 6 import { getReaderFeed } from "./getReaderFeed"; 20 7 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 8 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 9 + import { PostListing } from "components/PostListing"; 24 10 25 11 export const ReaderContent = (props: { 26 12 posts: Post[]; ··· 28 14 }) => { 29 15 const getKey = ( 30 16 pageIndex: number, 31 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 32 21 ) => { 33 22 // Reached the end 34 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 40 29 return ["reader-feed", previousPageData?.nextCursor] as const; 41 30 }; 42 31 43 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 44 33 getKey, 45 34 ([_, cursor]) => getReaderFeed(cursor), 46 35 { ··· 79 68 return ( 80 69 <div className="flex flex-col gap-3 relative"> 81 70 {allPosts.map((p) => ( 82 - <Post {...p} key={p.documents.uri} /> 71 + <PostListing {...p} key={p.documents.uri} /> 83 72 ))} 84 73 {/* Trigger element for loading more posts */} 85 74 <div ··· 96 85 ); 97 86 }; 98 87 99 - const Post = (props: Post) => { 100 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 101 - 102 - let postRecord = props.documents.data as PubLeafletDocument.Record; 103 - let postUri = new AtUri(props.documents.uri); 104 - 105 - let theme = usePubTheme(pubRecord?.theme); 106 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 - ? blobRefToSrc( 108 - pubRecord?.theme?.backgroundImage?.image?.ref, 109 - new AtUri(props.publication.uri).host, 110 - ) 111 - : null; 112 - 113 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 114 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 115 - 116 - let showPageBackground = pubRecord.theme?.showPageBackground; 117 - 118 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 119 - let comments = 120 - pubRecord.preferences?.showComments === false 121 - ? 0 122 - : props.documents.comments_on_documents?.[0]?.count || 0; 123 - 124 - return ( 125 - <BaseThemeProvider {...theme} local> 126 - <div 127 - style={{ 128 - backgroundImage: `url(${backgroundImage})`, 129 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 130 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 131 - }} 132 - className={`no-underline! flex flex-row gap-2 w-full relative 133 - bg-bg-leaflet 134 - border border-border-light rounded-lg 135 - sm:p-2 p-2 selected-outline 136 - hover:outline-accent-contrast hover:border-accent-contrast 137 - `} 138 - > 139 - <a 140 - className="h-full w-full absolute top-0 left-0" 141 - href={`${props.publication.href}/${postUri.rkey}`} 142 - /> 143 - <div 144 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 145 - style={{ 146 - backgroundColor: showPageBackground 147 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 148 - : "transparent", 149 - }} 150 - > 151 - <h3 className="text-primary truncate">{postRecord.title}</h3> 152 - 153 - <p className="text-secondary">{postRecord.description}</p> 154 - <div className="flex gap-2 justify-between items-end"> 155 - <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3"> 156 - <PubInfo 157 - href={props.publication.href} 158 - pubRecord={pubRecord} 159 - uri={props.publication.uri} 160 - /> 161 - <Separator classname="h-4 !min-h-0 md:block hidden" /> 162 - <PostInfo 163 - author={props.author || ""} 164 - publishedAt={postRecord.publishedAt} 165 - /> 166 - </div> 167 - 168 - <PostInterations 169 - postUrl={`${props.publication.href}/${postUri.rkey}`} 170 - quotesCount={quotes} 171 - commentsCount={comments} 172 - showComments={pubRecord.preferences?.showComments} 173 - /> 174 - </div> 175 - </div> 176 - </div> 177 - </BaseThemeProvider> 178 - ); 179 - }; 180 - 181 - const PubInfo = (props: { 182 - href: string; 183 - pubRecord: PubLeafletPublication.Record; 184 - uri: string; 185 - }) => { 186 - return ( 187 - <a 188 - href={props.href} 189 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 190 - > 191 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 192 - {props.pubRecord.name} 193 - </a> 194 - ); 195 - }; 196 - 197 - const PostInfo = (props: { 198 - author: string; 199 - publishedAt: string | undefined; 200 - }) => { 201 - const formattedDate = useLocalizedDate( 202 - props.publishedAt || new Date().toISOString(), 203 - { 204 - year: "numeric", 205 - month: "short", 206 - day: "numeric", 207 - }, 208 - ); 209 - 210 - return ( 211 - <div className="flex flex-wrap gap-2 grow items-center shrink-0"> 212 - {props.author} 213 - {props.publishedAt && ( 214 - <> 215 - <Separator classname="h-4 !min-h-0" /> 216 - {formattedDate}{" "} 217 - </> 218 - )} 219 - </div> 220 - ); 221 - }; 222 - 223 - const PostInterations = (props: { 224 - quotesCount: number; 225 - commentsCount: number; 226 - postUrl: string; 227 - showComments: boolean | undefined; 228 - }) => { 229 - let smoker = useSmoker(); 230 - let interactionsAvailable = 231 - props.quotesCount > 0 || 232 - (props.showComments !== false && props.commentsCount > 0); 233 - 234 - return ( 235 - <div className={`flex gap-2 text-tertiary text-sm items-center`}> 236 - {props.quotesCount === 0 ? null : ( 237 - <div className={`flex gap-1 items-center `} aria-label="Post quotes"> 238 - <QuoteTiny aria-hidden /> {props.quotesCount} 239 - </div> 240 - )} 241 - {props.showComments === false || props.commentsCount === 0 ? null : ( 242 - <div className={`flex gap-1 items-center`} aria-label="Post comments"> 243 - <CommentTiny aria-hidden /> {props.commentsCount} 244 - </div> 245 - )} 246 - {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 247 - <button 248 - id={`copy-post-link-${props.postUrl}`} 249 - className="flex gap-1 items-center hover:font-bold relative" 250 - onClick={(e) => { 251 - e.stopPropagation(); 252 - e.preventDefault(); 253 - let mouseX = e.clientX; 254 - let mouseY = e.clientY; 255 - 256 - if (!props.postUrl) return; 257 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 258 - 259 - smoker({ 260 - text: <strong>Copied Link!</strong>, 261 - position: { 262 - y: mouseY, 263 - x: mouseX, 264 - }, 265 - }); 266 - }} 267 - > 268 - Share 269 - </button> 270 - </div> 271 - ); 272 - }; 273 88 export const ReaderEmpty = () => { 274 89 return ( 275 90 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
+71
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 1 + "use server"; 2 + 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Json } from "supabase/database.types"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 9 + 10 + export async function getDocumentsByTag( 11 + tag: string, 12 + ): Promise<{ posts: Post[] }> { 13 + // Normalize tag to lowercase for case-insensitive search 14 + const normalizedTag = tag.toLowerCase(); 15 + 16 + // Query documents that have this tag 17 + const { data: documents, error } = await supabaseServerClient 18 + .from("documents") 19 + .select( 20 + `*, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count), 23 + documents_in_publications(publications(*))`, 24 + ) 25 + .contains("data->tags", `["${normalizedTag}"]`) 26 + .order("indexed_at", { ascending: false }) 27 + .limit(50); 28 + 29 + if (error) { 30 + console.error("Error fetching documents by tag:", error); 31 + return { posts: [] }; 32 + } 33 + 34 + const posts = await Promise.all( 35 + documents.map(async (doc) => { 36 + const pub = doc.documents_in_publications[0]?.publications; 37 + 38 + // Skip if document doesn't have a publication 39 + if (!pub) { 40 + return null; 41 + } 42 + 43 + const uri = new AtUri(doc.uri); 44 + const handle = await idResolver.did.resolve(uri.host); 45 + 46 + const post: Post = { 47 + publication: { 48 + href: getPublicationURL(pub), 49 + pubRecord: pub?.record || null, 50 + uri: pub?.uri || "", 51 + }, 52 + author: handle?.alsoKnownAs?.[0] 53 + ? `@${handle.alsoKnownAs[0].slice(5)}` 54 + : null, 55 + documents: { 56 + comments_on_documents: doc.comments_on_documents, 57 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 58 + data: doc.data, 59 + uri: doc.uri, 60 + indexed_at: doc.indexed_at, 61 + }, 62 + }; 63 + return post; 64 + }), 65 + ); 66 + 67 + // Filter out null entries (documents without publications) 68 + return { 69 + posts: posts.filter((p): p is Post => p !== null), 70 + }; 71 + }
+75
app/(home-pages)/tag/[tag]/page.tsx
··· 1 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { Tag } from "components/Tags"; 3 + import { PostListing } from "components/PostListing"; 4 + import { getDocumentsByTag } from "./getDocumentsByTag"; 5 + import { TagTiny } from "components/Icons/TagTiny"; 6 + 7 + export default async function TagPage(props: { 8 + params: Promise<{ tag: string }>; 9 + }) { 10 + const params = await props.params; 11 + const decodedTag = decodeURIComponent(params.tag); 12 + const { posts } = await getDocumentsByTag(decodedTag); 13 + 14 + return ( 15 + <DashboardLayout 16 + id="tag" 17 + cardBorderHidden={false} 18 + currentPage="tag" 19 + defaultTab="default" 20 + actions={null} 21 + tabs={{ 22 + default: { 23 + controls: null, 24 + content: <TagContent tag={decodedTag} posts={posts} />, 25 + }, 26 + }} 27 + /> 28 + ); 29 + } 30 + 31 + const TagContent = (props: { 32 + tag: string; 33 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 34 + }) => { 35 + return ( 36 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 37 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 38 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 39 + </div> 40 + <div className="pt-6 flex flex-col gap-3"> 41 + {props.posts.length === 0 ? ( 42 + <EmptyState tag={props.tag} /> 43 + ) : ( 44 + props.posts.map((post) => ( 45 + <PostListing key={post.documents.uri} {...post} /> 46 + )) 47 + )} 48 + </div> 49 + </div> 50 + ); 51 + }; 52 + 53 + const TagHeader = (props: { tag: string; postCount: number }) => { 54 + return ( 55 + <div className="flex flex-col leading-tight items-center"> 56 + <div className="flex items-center gap-3 text-xl font-bold text-primary"> 57 + <TagTiny className="scale-150" /> 58 + <h1>{props.tag}</h1> 59 + </div> 60 + <div className="text-tertiary text-sm"> 61 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 62 + </div> 63 + </div> 64 + ); 65 + }; 66 + 67 + const EmptyState = (props: { tag: string }) => { 68 + return ( 69 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 70 + <div className="text-tertiary"> 71 + No posts found with the tag "{props.tag}" 72 + </div> 73 + </div> 74 + ); 75 + };
+8 -3
app/[leaflet_id]/actions/PublishButton.tsx
··· 27 27 import { useState, useMemo } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; 29 29 import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 30 31 import { Json } from "supabase/database.types"; 31 32 import { 32 33 useBlocks, ··· 34 35 } from "src/hooks/queries/useBlocks"; 35 36 import * as Y from "yjs"; 36 37 import * as base64 from "base64-js"; 37 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 38 39 import { BlueskyLogin } from "app/login/LoginForm"; 39 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 40 41 import { AddTiny } from "components/Icons/AddTiny"; ··· 63 64 const UpdateButton = () => { 64 65 let [isLoading, setIsLoading] = useState(false); 65 66 let { data: pub, mutate } = useLeafletPublicationData(); 66 - let { permission_token, rootEntity } = useReplicache(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 67 68 let { identity } = useIdentityData(); 68 69 let toaster = useToaster(); 69 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 + 70 75 return ( 71 76 <ActionButton 72 77 primary ··· 81 86 leaflet_id: permission_token.id, 82 87 title: pub.title, 83 88 description: pub.description, 89 + tags: currentTags, 84 90 }); 85 91 setIsLoading(false); 86 92 mutate(); ··· 108 114 let { identity } = useIdentityData(); 109 115 let { permission_token } = useReplicache(); 110 116 let query = useSearchParams(); 111 - console.log(query.get("publish")); 112 117 let [open, setOpen] = useState(query.get("publish") !== null); 113 118 114 119 let isMobile = useIsMobile();
+1 -1
app/[leaflet_id]/page.tsx
··· 4 4 5 5 import type { Fact } from "src/replicache"; 6 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 8 import { Leaflet } from "./Leaflet"; 9 9 import { scanIndexLocal } from "src/replicache/utils"; 10 10 import { getRSVPData } from "actions/getRSVPData";
+50 -19
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 148 148 const pos = view.state.selection.from; 149 149 setMentionInsertPos(pos); 150 150 const coords = view.coordsAtPos(pos - 1); 151 - setMentionCoords({ 152 - top: coords.bottom + window.scrollY, 153 - left: coords.left + window.scrollX, 154 - }); 151 + 152 + // Get coordinates relative to the positioned parent container 153 + const editorEl = view.dom; 154 + const container = editorEl.closest(".relative") as HTMLElement | null; 155 + 156 + if (container) { 157 + const containerRect = container.getBoundingClientRect(); 158 + setMentionCoords({ 159 + top: coords.bottom - containerRect.top, 160 + left: coords.left - containerRect.left, 161 + }); 162 + } else { 163 + setMentionCoords({ 164 + top: coords.bottom, 165 + left: coords.left, 166 + }); 167 + } 155 168 setMentionOpen(true); 156 169 }, []); 157 170 158 171 const handleMentionSelect = useCallback( 159 172 (mention: Mention) => { 160 - if (mention.type !== "did") return; 161 173 if (!viewRef.current || mentionInsertPos === null) return; 162 174 const view = viewRef.current; 163 175 const from = mentionInsertPos - 1; ··· 167 179 // Delete the @ symbol 168 180 tr.delete(from, to); 169 181 170 - // Insert @handle 171 - const mentionText = "@" + mention.handle; 172 - tr.insertText(mentionText, from); 173 - 174 - // Apply mention mark 175 - tr.addMark( 176 - from, 177 - from + mentionText.length, 178 - bskyPostSchema.marks.mention.create({ did: mention.did }), 179 - ); 180 - 181 - // Add a space after the mention 182 - tr.insertText(" ", from + mentionText.length); 182 + if (mention.type === "did") { 183 + // Insert @handle with mention mark 184 + const mentionText = "@" + mention.handle; 185 + tr.insertText(mentionText, from); 186 + tr.addMark( 187 + from, 188 + from + mentionText.length, 189 + bskyPostSchema.marks.mention.create({ did: mention.did }), 190 + ); 191 + tr.insertText(" ", from + mentionText.length); 192 + } else if (mention.type === "publication") { 193 + // Insert publication name as a link 194 + const linkText = mention.name; 195 + tr.insertText(linkText, from); 196 + tr.addMark( 197 + from, 198 + from + linkText.length, 199 + bskyPostSchema.marks.link.create({ href: mention.url }), 200 + ); 201 + tr.insertText(" ", from + linkText.length); 202 + } else if (mention.type === "post") { 203 + // Insert post title as a link 204 + const linkText = mention.title; 205 + tr.insertText(linkText, from); 206 + tr.addMark( 207 + from, 208 + from + linkText.length, 209 + bskyPostSchema.marks.link.create({ href: mention.url }), 210 + ); 211 + tr.insertText(" ", from + linkText.length); 212 + } 183 213 184 214 view.dispatch(tr); 185 215 view.focus(); ··· 270 300 view={viewRef} 271 301 onSelect={handleMentionSelect} 272 302 coords={mentionCoords} 303 + placeholder="Search people..." 273 304 /> 274 305 {editorState?.doc.textContent.length === 0 && ( 275 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> ··· 278 309 )} 279 310 <div 280 311 ref={mountRef} 281 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 282 313 style={{ 283 314 wordWrap: "break-word", 284 315 overflowWrap: "break-word",
+143 -83
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 9 + 10 10 import { PubLeafletPublication } from "lexicons/api"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 16 17 import { 17 18 BlueskyPostEditorProsemirror, 18 19 editorStateToFacetedText, 19 20 } from "./BskyPostEditorProsemirror"; 20 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 21 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 24 import { PubIcon } from "components/ActionBar/Publications"; 23 25 ··· 31 33 record?: PubLeafletPublication.Record; 32 34 posts_in_pub?: number; 33 35 entitiesToDelete?: string[]; 36 + hasDraft: boolean; 34 37 }; 35 38 36 39 export function PublishPost(props: Props) { ··· 38 41 { state: "default" } | { state: "success"; post_url: string } 39 42 >({ state: "default" }); 40 43 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 44 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary"> 42 45 {publishState.state === "default" ? ( 43 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 47 ) : ( ··· 58 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 62 } & Props, 60 63 ) => { 64 + let editorStateRef = useRef<EditorState | null>(null); 65 + let [charCount, setCharCount] = useState(0); 61 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 67 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 65 68 let params = useParams(); 66 69 let { rep } = useReplicache(); 67 70 71 + // For publications with drafts, use Replicache; otherwise use local state 72 + let replicacheTags = useSubscribe(rep, (tx) => 73 + tx.get<string[]>("publication_tags"), 74 + ); 75 + let [localTags, setLocalTags] = useState<string[]>([]); 76 + 77 + // Use Replicache tags only when we have a draft 78 + const hasDraft = props.hasDraft; 79 + const currentTags = hasDraft 80 + ? Array.isArray(replicacheTags) 81 + ? replicacheTags 82 + : [] 83 + : localTags; 84 + 85 + // Update tags via Replicache mutation or local state depending on context 86 + const handleTagsChange = async (newTags: string[]) => { 87 + if (hasDraft) { 88 + await rep?.mutate.updatePublicationDraft({ 89 + tags: newTags, 90 + }); 91 + } else { 92 + setLocalTags(newTags); 93 + } 94 + }; 95 + 68 96 async function submit() { 69 97 if (isLoading) return; 70 98 setIsLoading(true); ··· 75 103 leaflet_id: props.leaflet_id, 76 104 title: props.title, 77 105 description: props.description, 106 + tags: currentTags, 78 107 entitiesToDelete: props.entitiesToDelete, 79 108 }); 80 109 if (!doc) return; ··· 109 138 submit(); 110 139 }} 111 140 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 113 142 <PublishingTo 114 143 publication_uri={props.publication_uri} 115 144 record={props.record} 116 145 /> 117 - <hr className="border-border-light my-1" /> 118 - <Radio 119 - checked={shareOption === "quiet"} 120 - onChange={(e) => { 121 - if (e.target === e.currentTarget) { 122 - setShareOption("quiet"); 123 - } 124 - }} 125 - name="share-options" 126 - id="share-quietly" 127 - value="Share Quietly" 128 - > 129 - <div className="flex flex-col"> 130 - <div className="font-bold">Share Quietly</div> 131 - <div className="text-sm text-tertiary font-normal"> 132 - No one will be notified about this post 133 - </div> 134 - </div> 135 - </Radio> 136 - <Radio 137 - checked={shareOption === "bluesky"} 138 - onChange={(e) => { 139 - if (e.target === e.currentTarget) { 140 - setShareOption("bluesky"); 141 - } 142 - }} 143 - name="share-options" 144 - id="share-bsky" 145 - value="Share on Bluesky" 146 - > 147 - <div className="flex flex-col"> 148 - <div className="font-bold">Share on Bluesky</div> 149 - <div className="text-sm text-tertiary font-normal"> 150 - Pub subscribers will be updated via a custom Bluesky feed 151 - </div> 152 - </div> 153 - </Radio> 154 - 155 - <div 156 - className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 157 - > 158 - <div className="opaque-container p-3 rounded-lg!"> 159 - <div className="flex gap-2"> 160 - <img 161 - className="rounded-full w-[42px] h-[42px] shrink-0" 162 - src={props.profile.avatar} 163 - /> 164 - <div className="flex flex-col w-full"> 165 - <div className="flex gap-2 pb-1"> 166 - <p className="font-bold">{props.profile.displayName}</p> 167 - <p className="text-tertiary">@{props.profile.handle}</p> 168 - </div> 169 - <div className="flex flex-col"> 170 - <BlueskyPostEditorProsemirror 171 - editorStateRef={editorStateRef} 172 - onCharCountChange={setCharCount} 173 - /> 174 - </div> 175 - <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> 176 - <div className="flex flex-col p-2"> 177 - <div className="font-bold">{props.title}</div> 178 - <div className="text-tertiary">{props.description}</div> 179 - {props.record && ( 180 - <> 181 - <hr className="border-border-light mt-2 mb-1" /> 182 - <p className="text-xs text-tertiary"> 183 - {props.record?.base_path} 184 - </p> 185 - </> 186 - )} 187 - </div> 188 - </div> 189 - <div className="text-xs text-secondary italic place-self-end pt-2"> 190 - {charCount}/300 191 - </div> 192 - </div> 193 - </div> 194 - </div> 146 + <hr className="border-border" /> 147 + <ShareOptions 148 + setShareOption={setShareOption} 149 + shareOption={shareOption} 150 + charCount={charCount} 151 + setCharCount={setCharCount} 152 + editorStateRef={editorStateRef} 153 + {...props} 154 + /> 155 + <hr className="border-border " /> 156 + <div className="flex flex-col gap-2"> 157 + <h4>Tags</h4> 158 + <TagSelector 159 + selectedTags={currentTags} 160 + setSelectedTags={handleTagsChange} 161 + /> 195 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 196 165 <div className="flex justify-between"> 197 166 <Link 198 167 className="hover:no-underline! font-bold" ··· 210 179 </div> 211 180 </div> 212 181 </form> 182 + </div> 183 + ); 184 + }; 185 + 186 + const ShareOptions = (props: { 187 + shareOption: "quiet" | "bluesky"; 188 + setShareOption: (option: typeof props.shareOption) => void; 189 + charCount: number; 190 + setCharCount: (c: number) => void; 191 + editorStateRef: React.MutableRefObject<EditorState | null>; 192 + title: string; 193 + profile: ProfileViewDetailed; 194 + description: string; 195 + record?: PubLeafletPublication.Record; 196 + }) => { 197 + return ( 198 + <div className="flex flex-col gap-2"> 199 + <h4>Notifications</h4> 200 + <Radio 201 + checked={props.shareOption === "quiet"} 202 + onChange={(e) => { 203 + if (e.target === e.currentTarget) { 204 + props.setShareOption("quiet"); 205 + } 206 + }} 207 + name="share-options" 208 + id="share-quietly" 209 + value="Share Quietly" 210 + > 211 + <div className="flex flex-col"> 212 + <div className="font-bold">Share Quietly</div> 213 + <div className="text-sm text-tertiary font-normal"> 214 + No one will be notified about this post 215 + </div> 216 + </div> 217 + </Radio> 218 + <Radio 219 + checked={props.shareOption === "bluesky"} 220 + onChange={(e) => { 221 + if (e.target === e.currentTarget) { 222 + props.setShareOption("bluesky"); 223 + } 224 + }} 225 + name="share-options" 226 + id="share-bsky" 227 + value="Share on Bluesky" 228 + > 229 + <div className="flex flex-col"> 230 + <div className="font-bold">Share on Bluesky</div> 231 + <div className="text-sm text-tertiary font-normal"> 232 + Pub subscribers will be updated via a custom Bluesky feed 233 + </div> 234 + </div> 235 + </Radio> 236 + <div 237 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 238 + > 239 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 240 + <div className="flex gap-2"> 241 + <img 242 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 243 + src={props.profile.avatar} 244 + /> 245 + <div className="flex flex-col w-full"> 246 + <div className="flex gap-2 "> 247 + <p className="font-bold">{props.profile.displayName}</p> 248 + <p className="text-tertiary">@{props.profile.handle}</p> 249 + </div> 250 + <div className="flex flex-col"> 251 + <BlueskyPostEditorProsemirror 252 + editorStateRef={props.editorStateRef} 253 + onCharCountChange={props.setCharCount} 254 + /> 255 + </div> 256 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 257 + <div className="flex flex-col p-2"> 258 + <div className="font-bold">{props.title}</div> 259 + <div className="text-tertiary">{props.description}</div> 260 + <hr className="border-border mt-2 mb-1" /> 261 + <p className="text-xs text-tertiary"> 262 + {props.record?.base_path} 263 + </p> 264 + </div> 265 + </div> 266 + <div className="text-xs text-secondary italic place-self-end pt-2"> 267 + {props.charCount}/300 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 213 273 </div> 214 274 ); 215 275 };
+6
app/[leaflet_id]/publish/page.tsx
··· 99 99 // If parsing fails, just use empty array 100 100 } 101 101 102 + // Check if a draft record exists (either in a publication or standalone) 103 + let hasDraft = 104 + data.leaflets_in_publications.length > 0 || 105 + data.leaflets_to_documents.length > 0; 106 + 102 107 return ( 103 108 <ReplicacheProvider 104 109 rootEntity={rootEntity} ··· 116 121 record={publication?.record as PubLeafletPublication.Record | undefined} 117 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 118 123 entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 119 125 /> 120 126 </ReplicacheProvider> 121 127 );
+1 -1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 5 5 import type { Env } from "./route"; 6 6 import { scanIndexLocal } from "src/replicache/utils"; 7 7 import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 9 import { applyUpdate, Doc } from "yjs"; 10 10 11 11 export const getFactsFromHomeLeaflets = makeRoute({
+6
app/api/rpc/[command]/pull.ts
··· 73 73 let publication_data = data.publications as { 74 74 description: string; 75 75 title: string; 76 + tags: string[]; 76 77 }[]; 77 78 let pub_patch = publication_data?.[0] 78 79 ? [ ··· 85 86 op: "put", 86 87 key: "publication_title", 87 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 88 94 }, 89 95 ] 90 96 : [];
+16 -5
app/api/rpc/[command]/search_publication_documents.ts
··· 1 + import { AtUri } from "@atproto/api"; 1 2 import { z } from "zod"; 2 3 import { makeRoute } from "../lib"; 3 4 import type { Env } from "./route"; 5 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 6 5 7 export type SearchPublicationDocumentsReturnType = Awaited< 6 8 ReturnType<(typeof search_publication_documents)["handler"]> ··· 18 20 { supabase }: Pick<Env, "supabase">, 19 21 ) => { 20 22 // Get documents in the publication, filtering by title using JSON operator 23 + // Also join with publications to get the record for URL construction 21 24 const { data: documents, error } = await supabase 22 25 .from("documents_in_publications") 23 - .select("document, documents!inner(uri, data)") 26 + .select( 27 + "document, documents!inner(uri, data), publications!inner(uri, record)", 28 + ) 24 29 .eq("publication", publication_uri) 25 30 .ilike("documents.data->>title", `%${query}%`) 26 31 .limit(limit); ··· 31 36 ); 32 37 } 33 38 34 - const result = documents.map((d) => ({ 35 - uri: d.documents.uri, 36 - title: (d.documents.data as { title?: string })?.title || "Untitled", 37 - })); 39 + const result = documents.map((d) => { 40 + const docUri = new AtUri(d.documents.uri); 41 + const pubUrl = getPublicationURL(d.publications); 42 + 43 + return { 44 + uri: d.documents.uri, 45 + title: (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: `${pubUrl}/${docUri.rkey}`, 47 + }; 48 + }); 38 49 39 50 return { result: { documents: result } }; 40 51 },
+10 -8
app/api/rpc/[command]/search_publication_names.ts
··· 1 1 import { z } from "zod"; 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 5 5 6 export type SearchPublicationNamesReturnType = Awaited< 6 7 ReturnType<(typeof search_publication_names)["handler"]> ··· 12 13 query: z.string(), 13 14 limit: z.number().optional().default(10), 14 15 }), 15 - handler: async ( 16 - { query, limit }, 17 - { supabase }: Pick<Env, "supabase">, 18 - ) => { 16 + handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 19 17 // Search publications by name in record (case-insensitive partial match) 20 18 const { data: publications, error } = await supabase 21 19 .from("publications") ··· 27 25 throw new Error(`Failed to search publications: ${error.message}`); 28 26 } 29 27 30 - const result = publications.map((p) => ({ 31 - uri: p.uri, 32 - name: (p.record as { name?: string })?.name || "Untitled", 33 - })); 28 + const result = publications.map((p) => { 29 + const record = p.record as { name?: string }; 30 + return { 31 + uri: p.uri, 32 + name: record.name || "Untitled", 33 + url: getPublicationURL(p), 34 + }; 35 + }); 34 36 35 37 return { result: { publications: result } }; 36 38 },
+5
app/globals.css
··· 211 211 212 212 /* END GLOBAL STYLING */ 213 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 214 219 button:hover { 215 220 cursor: pointer; 216 221 }
+36 -206
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 - import { SpeedyLink } from "components/SpeedyLink"; 27 - 28 - type State = 29 - | { state: "email" } 30 - | { state: "code"; token: string } 31 - | { state: "success" }; 32 - export const SubscribeButton = (props: { 33 - compact?: boolean; 34 - publication: string; 35 - }) => { 36 - let { identity, mutate } = useIdentityData(); 37 - let [emailInputValue, setEmailInputValue] = useState(""); 38 - let [codeInputValue, setCodeInputValue] = useState(""); 39 - let [state, setState] = useState<State>({ state: "email" }); 40 - 41 - if (state.state === "email") { 42 - return ( 43 - <div className="flex gap-2"> 44 - <div className="flex relative w-full max-w-sm"> 45 - <Input 46 - type="email" 47 - className="input-with-border pr-[104px]! py-1! grow w-full" 48 - placeholder={ 49 - props.compact ? "subscribe with email..." : "email here..." 50 - } 51 - disabled={!!identity?.email} 52 - value={identity?.email ? identity.email : emailInputValue} 53 - onChange={(e) => { 54 - setEmailInputValue(e.currentTarget.value); 55 - }} 56 - /> 57 - <ButtonPrimary 58 - compact 59 - className="absolute right-1 top-1 outline-0!" 60 - onClick={async () => { 61 - if (identity?.email) { 62 - await subscribeToPublicationWithEmail(props.publication); 63 - //optimistically could add! 64 - await mutate(); 65 - return; 66 - } 67 - let tokenID = await requestAuthEmailToken(emailInputValue); 68 - setState({ state: "code", token: tokenID }); 69 - }} 70 - > 71 - {props.compact ? ( 72 - <ArrowRightTiny className="w-4 h-6" /> 73 - ) : ( 74 - "Subscribe" 75 - )} 76 - </ButtonPrimary> 77 - </div> 78 - {/* <ShareButton /> */} 79 - </div> 80 - ); 81 - } 82 - if (state.state === "code") { 83 - return ( 84 - <div 85 - className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 86 - style={{ 87 - background: 88 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 89 - }} 90 - > 91 - <div className="flex flex-col leading-snug text-secondary"> 92 - <div>Please enter the code we sent to </div> 93 - <div className="italic font-bold">{emailInputValue}</div> 94 - </div> 95 - 96 - <ConfirmCodeInput 97 - publication={props.publication} 98 - token={state.token} 99 - codeInputValue={codeInputValue} 100 - setCodeInputValue={setCodeInputValue} 101 - setState={setState} 102 - /> 103 - 104 - <button 105 - className="text-accent-contrast text-sm mt-1" 106 - onClick={() => { 107 - setState({ state: "email" }); 108 - }} 109 - > 110 - Re-enter Email 111 - </button> 112 - </div> 113 - ); 114 - } 115 - 116 - if (state.state === "success") { 117 - return ( 118 - <div 119 - className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`} 120 - style={{ 121 - background: 122 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 123 - }} 124 - > 125 - <div className="flex gap-2 leading-snug font-bold italic"> 126 - <div>You're subscribed!</div> 127 - {/* <ShareButton /> */} 128 - </div> 129 - </div> 130 - ); 131 - } 132 - }; 133 - 134 - export const ShareButton = () => { 135 - return ( 136 - <button className="text-accent-contrast"> 137 - <ShareSmall /> 138 - </button> 139 - ); 140 - }; 141 - 142 - const ConfirmCodeInput = (props: { 143 - codeInputValue: string; 144 - token: string; 145 - setCodeInputValue: (value: string) => void; 146 - setState: (state: State) => void; 147 - publication: string; 148 - }) => { 149 - let { mutate } = useIdentityData(); 150 - return ( 151 - <div className="relative w-fit mt-2"> 152 - <Input 153 - type="text" 154 - pattern="[0-9]" 155 - className="input-with-border pr-[88px]! py-1! max-w-[156px]" 156 - placeholder="000000" 157 - value={props.codeInputValue} 158 - onChange={(e) => { 159 - props.setCodeInputValue(e.currentTarget.value); 160 - }} 161 - /> 162 - <ButtonPrimary 163 - compact 164 - className="absolute right-1 top-1 outline-0!" 165 - onClick={async () => { 166 - console.log( 167 - await confirmEmailAuthToken(props.token, props.codeInputValue), 168 - ); 169 - 170 - await subscribeToPublicationWithEmail(props.publication); 171 - //optimistically could add! 172 - await mutate(); 173 - props.setState({ state: "success" }); 174 - return; 175 - }} 176 - > 177 - Confirm 178 - </ButtonPrimary> 179 - </div> 180 - ); 181 - }; 182 26 183 27 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 28 pubName: string; 186 29 pub_uri: string; 187 30 base_url: string; ··· 208 51 } 209 52 return ( 210 53 <div className="flex flex-col gap-2 text-center justify-center"> 211 - {props.isPost && ( 212 - <div className="text-sm text-tertiary font-bold"> 213 - Get updates from {props.pubName}! 214 - </div> 215 - )} 216 54 <div className="flex flex-row gap-2 place-self-center"> 217 55 <BlueskySubscribeButton 218 56 pub_uri={props.pub_uri} ··· 231 69 ); 232 70 }; 233 71 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 72 + export const ManageSubscription = (props: { 237 73 pub_uri: string; 238 74 subscribers: { identity: string }[]; 239 75 base_url: string; ··· 248 84 }); 249 85 }, null); 250 86 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 253 91 > 254 - <div className="font-bold text-tertiary text-sm"> 255 - You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 - {props.isPost && ( 257 - <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 - {props.pubName} 259 - </SpeedyLink> 260 - )} 261 - </div> 262 - <Popover 263 - trigger={<div className="text-accent-contrast text-sm">Manage</div>} 264 - > 265 - <div className="max-w-sm flex flex-col gap-1"> 266 - <h4>Update Options</h4> 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 267 94 268 - {!hasFeed && ( 269 - <a 270 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 271 - target="_blank" 272 - className=" place-self-center" 273 - > 274 - <ButtonPrimary fullWidth compact className="!px-4"> 275 - View Bluesky Custom Feed 276 - </ButtonPrimary> 277 - </a> 278 - )} 279 - 95 + {!hasFeed && ( 280 96 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 283 98 target="_blank" 284 - aria-label="Subscribe to RSS" 99 + className=" place-self-center" 285 100 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 288 103 </ButtonPrimary> 289 104 </a> 105 + )} 290 106 291 - <hr className="border-border-light my-1" /> 107 + <a 108 + href={`${props.base_url}/rss`} 109 + className="flex" 110 + target="_blank" 111 + aria-label="Subscribe to RSS" 112 + > 113 + <ButtonPrimary fullWidth compact> 114 + Get RSS 115 + </ButtonPrimary> 116 + </a> 292 117 293 - <form action={unsubscribe}> 294 - <button className="font-bold text-accent-contrast w-max place-self-center"> 295 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 296 - </button> 297 - </form> 298 - </div>{" "} 299 - </Popover> 300 - </div> 118 + <hr className="border-border-light my-1" /> 119 + 120 + <form action={unsubscribe}> 121 + <button className="font-bold text-accent-contrast w-max place-self-center"> 122 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 123 + </button> 124 + </form> 125 + </div> 126 + </Popover> 301 127 ); 302 128 }; 303 129 ··· 430 256 </Dialog.Root> 431 257 ); 432 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
+6 -5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PollData } from "./fetchPollData"; 24 24 import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 25 26 26 27 export function CanvasPage({ 27 28 blocks, ··· 206 207 quotesCount: number | undefined; 207 208 commentsCount: number | undefined; 208 209 }) => { 210 + let isMobile = useIsMobile(); 209 211 return ( 210 - <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 212 + <div className="flex flex-row gap-3 items-center absolute top-3 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 211 213 <Interactions 212 214 quotesCount={props.quotesCount || 0} 213 215 commentsCount={props.commentsCount || 0} 214 - compact 215 216 showComments={props.preferences.showComments} 216 217 pageId={props.pageId} 217 218 /> ··· 219 220 <> 220 221 <Separator classname="h-5" /> 221 222 <Popover 222 - side="left" 223 - align="start" 224 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 223 + side="bottom" 224 + align="end" 225 + className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`} 225 226 trigger={<InfoSmall />} 226 227 > 227 228 <PostHeader
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { TagTiny } from "components/Icons/TagTiny"; 13 + import { Tag } from "components/Tags"; 14 + import { Popover } from "components/Popover"; 12 15 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 17 import { prefetchQuotesData } from "./Quotes"; 18 + import { useIdentityData } from "components/IdentityProvider"; 19 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 20 + import { EditTiny } from "components/Icons/EditTiny"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 22 16 23 export type InteractionState = { 17 24 drawerOpen: undefined | boolean; ··· 99 106 export const Interactions = (props: { 100 107 quotesCount: number; 101 108 commentsCount: number; 102 - compact?: boolean; 103 109 className?: string; 104 110 showComments?: boolean; 105 111 pageId?: string; 106 112 }) => { 107 113 const data = useContext(PostPageContext); 108 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 109 116 if (!document_uri) 110 117 throw new Error("document_uri not available in PostPageContext"); 111 118 ··· 117 124 } 118 125 }; 119 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 120 130 return ( 121 - <div 122 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`} 123 - > 124 - <button 125 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 126 - onClick={() => { 127 - if (!drawerOpen || drawer !== "quotes") 128 - openInteractionDrawer("quotes", document_uri, props.pageId); 129 - else setInteractionState(document_uri, { drawerOpen: false }); 130 - }} 131 - onMouseEnter={handleQuotePrefetch} 132 - onTouchStart={handleQuotePrefetch} 133 - aria-label="Post quotes" 134 - > 135 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 136 - {!props.compact && ( 137 - <span 138 - aria-hidden 139 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 140 - )} 141 - </button> 131 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 + 134 + {props.quotesCount > 0 && ( 135 + <button 136 + className="flex w-fit gap-2 items-center" 137 + onClick={() => { 138 + if (!drawerOpen || drawer !== "quotes") 139 + openInteractionDrawer("quotes", document_uri, props.pageId); 140 + else setInteractionState(document_uri, { drawerOpen: false }); 141 + }} 142 + onMouseEnter={handleQuotePrefetch} 143 + onTouchStart={handleQuotePrefetch} 144 + aria-label="Post quotes" 145 + > 146 + <QuoteTiny aria-hidden /> {props.quotesCount} 147 + </button> 148 + )} 142 149 {props.showComments === false ? null : ( 143 150 <button 144 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 151 + className="flex gap-2 items-center w-fit" 145 152 onClick={() => { 146 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 156 }} 150 157 aria-label="Post comments" 151 158 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 159 + <CommentTiny aria-hidden /> {props.commentsCount} 158 160 </button> 159 161 )} 160 162 </div> 161 163 ); 162 164 }; 163 165 166 + export const ExpandedInteractions = (props: { 167 + quotesCount: number; 168 + commentsCount: number; 169 + className?: string; 170 + showComments?: boolean; 171 + pageId?: string; 172 + }) => { 173 + const data = useContext(PostPageContext); 174 + let { identity } = useIdentityData(); 175 + 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + 180 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 181 + 182 + const handleQuotePrefetch = () => { 183 + if (data?.quotesAndMentions) { 184 + prefetchQuotesData(data.quotesAndMentions); 185 + } 186 + }; 187 + let publication = data?.documents_in_publications[0]?.publications; 188 + 189 + const tags = (data?.data as any)?.tags as string[] | undefined; 190 + const tagCount = tags?.length || 0; 191 + 192 + let subscribed = 193 + identity?.atp_did && 194 + publication?.publication_subscriptions && 195 + publication?.publication_subscriptions.find( 196 + (s) => s.identity === identity.atp_did, 197 + ); 198 + 199 + let isAuthor = 200 + identity && 201 + identity.atp_did === 202 + data.documents_in_publications[0]?.publications?.identity_did && 203 + data.leaflets_in_publications[0]; 204 + 205 + return ( 206 + <div 207 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 208 + > 209 + {!subscribed && !isAuthor && publication && publication.record && ( 210 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 211 + <div className="flex flex-col py-4"> 212 + <div className="leading-snug flex flex-col pb-2 text-sm"> 213 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 214 + to get updates in Reader, RSS, or via Bluesky Feed 215 + </div> 216 + <SubscribeWithBluesky 217 + pubName={publication.name} 218 + pub_uri={publication.uri} 219 + base_url={ 220 + (publication.record as PubLeafletPublication.Record) 221 + .base_path || "" 222 + } 223 + subscribers={publication?.publication_subscriptions} 224 + /> 225 + </div> 226 + </div> 227 + )} 228 + {tagCount > 0 && ( 229 + <> 230 + <hr className="border-border-light mb-3" /> 231 + 232 + <TagList tags={tags} className="mb-3" /> 233 + </> 234 + )} 235 + <hr className="border-border-light mb-3 " /> 236 + <div className="flex gap-2 justify-between"> 237 + <div className="flex gap-2"> 238 + {props.quotesCount > 0 && ( 239 + <button 240 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 241 + onClick={() => { 242 + if (!drawerOpen || drawer !== "quotes") 243 + openInteractionDrawer("quotes", document_uri, props.pageId); 244 + else setInteractionState(document_uri, { drawerOpen: false }); 245 + }} 246 + onMouseEnter={handleQuotePrefetch} 247 + onTouchStart={handleQuotePrefetch} 248 + aria-label="Post quotes" 249 + > 250 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 + <span 252 + aria-hidden 253 + >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 254 + </button> 255 + )} 256 + {props.showComments === false ? null : ( 257 + <button 258 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + onClick={() => { 260 + if ( 261 + !drawerOpen || 262 + drawer !== "comments" || 263 + pageId !== props.pageId 264 + ) 265 + openInteractionDrawer("comments", document_uri, props.pageId); 266 + else setInteractionState(document_uri, { drawerOpen: false }); 267 + }} 268 + aria-label="Post comments" 269 + > 270 + <CommentTiny aria-hidden />{" "} 271 + {props.commentsCount > 0 ? ( 272 + <span aria-hidden> 273 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 274 + </span> 275 + ) : ( 276 + "Comment" 277 + )} 278 + </button> 279 + )} 280 + </div> 281 + <EditButton document={data} /> 282 + {subscribed && publication && ( 283 + <ManageSubscription 284 + base_url={getPublicationURL(publication)} 285 + pub_uri={publication.uri} 286 + subscribers={publication.publication_subscriptions} 287 + /> 288 + )} 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + const TagPopover = (props: { 295 + tagCount: number; 296 + tags: string[] | undefined; 297 + }) => { 298 + return ( 299 + <Popover 300 + className="p-2! max-w-xs" 301 + trigger={ 302 + <div className="tags flex gap-1 items-center "> 303 + <TagTiny /> {props.tagCount} 304 + </div> 305 + } 306 + > 307 + <TagList tags={props.tags} className="text-secondary!" /> 308 + </Popover> 309 + ); 310 + }; 311 + 312 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 313 + if (!props.tags) return; 314 + return ( 315 + <div className="flex gap-1 flex-wrap"> 316 + {props.tags.map((tag, index) => ( 317 + <Tag name={tag} key={index} className={props.className} /> 318 + ))} 319 + </div> 320 + ); 321 + }; 164 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 323 if (!document) return; 166 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 357 ).length; 200 358 } 359 + 360 + const EditButton = (props: { document: PostPageData }) => { 361 + let { identity } = useIdentityData(); 362 + if (!props.document) return; 363 + if ( 364 + identity && 365 + identity.atp_did === 366 + props.document.documents_in_publications[0]?.publications?.identity_did && 367 + props.document.leaflets_in_publications[0] 368 + ) 369 + return ( 370 + <a 371 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 372 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 373 + > 374 + <EditTiny /> Edit Post 375 + </a> 376 + ); 377 + return; 378 + };
+4 -40
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 11 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 12 import { EditTiny } from "components/Icons/EditTiny"; 13 13 import { 14 + ExpandedInteractions, 14 15 getCommentCount, 15 16 getQuoteCount, 16 17 Interactions, ··· 47 48 fullPageScroll, 48 49 hasPageBackground, 49 50 } = props; 50 - let { identity } = useIdentityData(); 51 51 let drawer = useDrawerOpen(document_uri); 52 52 53 53 if (!document) return null; ··· 84 84 did={did} 85 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 86 /> 87 - <Interactions 87 + 88 + <ExpandedInteractions 88 89 pageId={pageId} 89 90 showComments={preferences.showComments} 90 91 commentsCount={getCommentCount(document, pageId) || 0} 91 92 quotesCount={getQuoteCount(document, pageId) || 0} 92 93 /> 93 - {!isSubpage && ( 94 - <> 95 - <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 96 - <div className="sm:px-4 px-3"> 97 - {identity && 98 - identity.atp_did === 99 - document.documents_in_publications[0]?.publications 100 - ?.identity_did && 101 - document.leaflets_in_publications[0] ? ( 102 - <a 103 - href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 104 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 105 - > 106 - <EditTiny /> Edit Post 107 - </a> 108 - ) : ( 109 - document.documents_in_publications[0]?.publications && ( 110 - <SubscribeWithBluesky 111 - isPost 112 - base_url={getPublicationURL( 113 - document.documents_in_publications[0].publications, 114 - )} 115 - pub_uri={ 116 - document.documents_in_publications[0].publications.uri 117 - } 118 - subscribers={ 119 - document.documents_in_publications[0].publications 120 - .publication_subscriptions 121 - } 122 - pubName={ 123 - document.documents_in_publications[0].publications.name 124 - } 125 - /> 126 - ) 127 - )} 128 - </div> 129 - </> 130 - )} 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 131 95 </PageWrapper> 132 96 </> 133 97 );
+1 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 59 return ( 60 60 <div 61 61 //The postContent class is important for QuoteHandler 62 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`} 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 63 > 64 64 {blocks.map((b, index) => { 65 65 return (
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
··· 1 - "use client"; 2 - 3 - import { Media } from "components/Media"; 4 - import { 5 - Interactions, 6 - useInteractionState, 7 - } from "../Interactions/Interactions"; 8 - import { useState, useEffect } from "react"; 9 - import { Json } from "supabase/database.types"; 10 - 11 - // export const CollapsedPostHeader = (props: { 12 - // title: string; 13 - // pubIcon?: string; 14 - // quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 - // }) => { 16 - // let [headerVisible, setHeaderVisible] = useState(false); 17 - // let { drawerOpen: open } = useInteractionState(); 18 - 19 - // useEffect(() => { 20 - // let post = window.document.getElementById("post-page"); 21 - 22 - // function handleScroll() { 23 - // let postHeader = window.document 24 - // .getElementById("post-header") 25 - // ?.getBoundingClientRect(); 26 - // if (postHeader && postHeader.bottom <= 0) { 27 - // setHeaderVisible(true); 28 - // } else { 29 - // setHeaderVisible(false); 30 - // } 31 - // } 32 - // post?.addEventListener("scroll", handleScroll); 33 - // return () => { 34 - // post?.removeEventListener("scroll", handleScroll); 35 - // }; 36 - // }, []); 37 - // if (!headerVisible) return; 38 - // if (open) return; 39 - // return ( 40 - // <Media 41 - // mobile 42 - // className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 - // > 44 - // <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 - // <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 - // {props.title} 47 - // </div> 48 - // <div className="flex gap-2 "> 49 - // <Interactions compact quotes={props.quotes.length} /> 50 - // <div 51 - // style={{ 52 - // backgroundRepeat: "no-repeat", 53 - // backgroundPosition: "center", 54 - // backgroundSize: "cover", 55 - // backgroundImage: `url(${props.pubIcon})`, 56 - // }} 57 - // className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 - // /> 59 - // </div> 60 - // </div> 61 - // </Media> 62 - // ); 63 - // };
+62 -32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 16 16 import { EditTiny } from "components/Icons/EditTiny"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 + import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 + import { Separator } from "components/Layout"; 19 21 20 22 export function PostHeader(props: { 21 23 data: PostPageData; ··· 40 42 41 43 if (!document?.data) return; 42 44 return ( 43 - <div 44 - className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" 45 - id="post-header" 46 - > 47 - <div className="pubHeader flex flex-col pb-5"> 48 - <div className="flex justify-between w-full"> 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 49 48 {pub && ( 50 49 <SpeedyLink 51 50 className="font-bold hover:no-underline text-accent-contrast" ··· 65 64 <EditTiny className="shrink-0" /> 66 65 </a> 67 66 )} 68 - </div> 69 - <h2 className="">{record.title}</h2> 70 - {record.description ? ( 71 - <p className="italic text-secondary">{record.description}</p> 72 - ) : null} 73 - 74 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 75 - {profile ? ( 76 - <> 77 - <a 78 - className="text-tertiary" 79 - href={`https://bsky.app/profile/${profile.handle}`} 80 - > 81 - by {profile.displayName || profile.handle} 82 - </a> 83 - </> 84 - ) : null} 85 - {record.publishedAt ? ( 86 - <> 87 - |<p>{formattedDate}</p> 88 - </> 89 - ) : null} 90 - |{" "} 67 + </> 68 + } 69 + postTitle={record.title} 70 + postDescription={record.description} 71 + postInfo={ 72 + <> 73 + <div className="flex flex-row gap-2 items-center"> 74 + {profile ? ( 75 + <> 76 + <a 77 + className="text-tertiary" 78 + href={`https://bsky.app/profile/${profile.handle}`} 79 + > 80 + {profile.displayName || profile.handle} 81 + </a> 82 + </> 83 + ) : null} 84 + {record.publishedAt ? ( 85 + <> 86 + <Separator classname="h-4!" /> 87 + <p>{formattedDate}</p> 88 + </> 89 + ) : null} 90 + </div> 91 91 <Interactions 92 92 showComments={props.preferences.showComments} 93 - compact 94 93 quotesCount={getQuoteCount(document) || 0} 95 94 commentsCount={getCommentCount(document) || 0} 96 95 /> 97 - </div> 96 + </> 97 + } 98 + /> 99 + ); 100 + } 101 + 102 + export const PostHeaderLayout = (props: { 103 + pubLink: React.ReactNode; 104 + postTitle: React.ReactNode | undefined; 105 + postDescription: React.ReactNode | undefined; 106 + postInfo: React.ReactNode; 107 + }) => { 108 + return ( 109 + <div 110 + className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 + id="post-header" 112 + > 113 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 114 + {props.pubLink} 115 + </div> 116 + <h2 117 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 118 + > 119 + {props.postTitle ? props.postTitle : "Untitled"} 120 + </h2> 121 + {props.postDescription ? ( 122 + <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + {props.postDescription} 124 + </p> 125 + ) : null} 126 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 + {props.postInfo} 98 128 </div> 99 129 </div> 100 130 ); 101 - } 131 + };
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 25 25 26 26 return { 27 27 icons: { 28 + icon: { 29 + url: 30 + process.env.NODE_ENV === "development" 31 + ? `/lish/${did}/${params.publication}/icon` 32 + : "/icon", 33 + sizes: "32x32", 34 + type: "image/png", 35 + }, 28 36 other: { 29 37 rel: "alternate", 30 38 url: document.uri,
+19 -28
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 import { EditTiny } from "components/Icons/EditTiny"; 5 5 6 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 20 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 23 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; ··· 28 29 let { data } = usePublicationData(); 29 30 let params = useParams(); 30 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 31 34 if (!publication) return null; 32 35 if (publication.documents_in_publications.length === 0) 33 36 return ( ··· 55 58 (l) => doc.documents && l.doc === doc.documents.uri, 56 59 ); 57 60 let uri = new AtUri(doc.documents.uri); 58 - let record = doc.documents.data as PubLeafletDocument.Record; 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 59 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 60 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 61 65 62 66 let postLink = data?.publication 63 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 81 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 82 86 > 83 87 <h3 className="text-primary grow leading-snug"> 84 - {record.title} 88 + {postRecord.title} 85 89 </h3> 86 90 </a> 87 91 <div className="flex justify-start align-top flex-row gap-1"> ··· 122 126 </div> 123 127 </div> 124 128 125 - {record.description ? ( 129 + {postRecord.description ? ( 126 130 <p className="italic text-secondary"> 127 - {record.description} 131 + {postRecord.description} 128 132 </p> 129 133 ) : null} 130 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 131 - {record.publishedAt ? ( 132 - <PublishedDate dateString={record.publishedAt} /> 134 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 + {postRecord.publishedAt ? ( 136 + <PublishedDate dateString={postRecord.publishedAt} /> 133 137 ) : null} 134 - {(comments > 0 || quotes > 0) && record.publishedAt 135 - ? " | " 136 - : ""} 137 - {quotes > 0 && ( 138 - <SpeedyLink 139 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 140 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 141 - > 142 - <QuoteTiny /> {quotes} 143 - </SpeedyLink> 144 - )} 145 - {comments > 0 && quotes > 0 ? " " : ""} 146 - {comments > 0 && ( 147 - <SpeedyLink 148 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 149 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 150 - > 151 - <CommentTiny /> {comments} 152 - </SpeedyLink> 153 - )} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 154 145 </div> 155 146 </div> 156 147 </div>
+8 -9
app/lish/[did]/[publication]/icon.ts app/lish/[did]/[publication]/icon/route.ts
··· 8 8 9 9 let idResolver = new IdResolver(); 10 10 11 - export const size = { 12 - width: 32, 13 - height: 32, 14 - }; 11 + export const dynamic = "force-dynamic"; 15 12 16 - export const contentType = "image/png"; 17 - export default async function Icon(props: { 18 - params: Promise<{ did: string; publication: string }>; 19 - }) { 13 + export async function GET( 14 + request: NextRequest, 15 + props: { params: Promise<{ did: string; publication: string }> }, 16 + ) { 17 + console.log("are we getting here?"); 20 18 const params = await props.params; 21 19 try { 22 20 let did = decodeURIComponent(params.did); ··· 45 43 46 44 let identity = await idResolver.did.resolve(did); 47 45 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 48 - if (!service) return null; 46 + if (!service) return redirect("/icon.png"); 49 47 let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 50 48 const response = await fetch( 51 49 `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, ··· 63 61 }, 64 62 }); 65 63 } catch (e) { 64 + console.log(e); 66 65 return redirect("/icon.png"); 67 66 } 68 67 }
+8
app/lish/[did]/[publication]/layout.tsx
··· 47 47 title: pubRecord?.name || "Untitled Publication", 48 48 description: pubRecord?.description || "", 49 49 icons: { 50 + icon: { 51 + url: 52 + process.env.NODE_ENV === "development" 53 + ? `/lish/${did}/${publication_name}/icon` 54 + : "/icon", 55 + sizes: "32x32", 56 + type: "image/png", 57 + }, 50 58 other: { 51 59 rel: "alternate", 52 60 url: publication.uri,
+9 -17
app/lish/[did]/[publication]/page.tsx
··· 14 14 import { SpeedyLink } from "components/SpeedyLink"; 15 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 17 18 import { LocalizedDate } from "./LocalizedDate"; 18 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 20 ··· 134 135 record?.preferences?.showComments === false 135 136 ? 0 136 137 : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 137 139 138 140 return ( 139 141 <React.Fragment key={doc.documents?.uri}> ··· 162 164 )}{" "} 163 165 </p> 164 166 {comments > 0 || quotes > 0 ? "| " : ""} 165 - {quotes > 0 && ( 166 - <SpeedyLink 167 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 168 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 169 - > 170 - <QuoteTiny /> {quotes} 171 - </SpeedyLink> 172 - )} 173 - {comments > 0 && 174 - record?.preferences?.showComments !== false && ( 175 - <SpeedyLink 176 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 177 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 178 - > 179 - <CommentTiny /> {comments} 180 - </SpeedyLink> 181 - )} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 182 174 </div> 183 175 </div> 184 176 <hr className="last:hidden border-border-light" />
+1 -1
components/ActionBar/ActionButton.tsx
··· 3 3 import { useContext, useEffect } from "react"; 4 4 import { SidebarContext } from "./Sidebar"; 5 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 7 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9
+4 -8
components/ActionBar/Navigation.tsx
··· 24 24 | "pub" 25 25 | "discover" 26 26 | "notifications" 27 - | "looseleafs"; 27 + | "looseleafs" 28 + | "tag"; 28 29 29 30 export const DesktopNavigation = (props: { 30 31 currentPage: navPages; ··· 126 127 }; 127 128 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 130 if (!props.subs) return; 132 131 return ( 133 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 133 <ActionButton 135 134 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 135 + icon={<ReaderUnreadSmall />} 137 136 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 142 138 /> 143 139 </SpeedyLink> 144 140 );
+5 -3
components/ActionBar/Publications.tsx
··· 23 23 currentPubUri: string | undefined; 24 24 }) => { 25 25 let { identity } = useIdentityData(); 26 - let looseleaves = identity?.permission_token_on_homepage.find( 27 - (f) => f.permission_tokens.leaflets_to_documents, 26 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 27 + (f) => 28 + f.permission_tokens.leaflets_to_documents && 29 + f.permission_tokens.leaflets_to_documents[0]?.document, 28 30 ); 29 31 30 32 // don't show pub list button if not logged in or no pub list ··· 34 36 35 37 return ( 36 38 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 37 - {looseleaves && ( 39 + {hasLooseleafs && ( 38 40 <> 39 41 <SpeedyLink 40 42 href={`/looseleafs`}
+3 -3
components/Blocks/BlockCommands.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 4 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { v7 } from "uuid"; 7 7 import { Replicache } from "replicache"; 8 8 import { useEditorStates } from "src/state/useEditorState"; 9 9 import { elementId } from "src/utils/elementId"; 10 10 import { UndoManager } from "src/undoManager"; 11 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
+2 -120
components/Blocks/DeleteBlock.tsx
··· 1 - import { 2 - Fact, 3 - ReplicacheMutators, 4 - useEntity, 5 - useReplicache, 6 - } from "src/replicache"; 7 - import { Replicache } from "replicache"; 8 - import { useUIState } from "src/useUIState"; 9 - import { scanIndex } from "src/replicache/utils"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { focusBlock } from "src/utils/focusBlock"; 1 + import { Fact, useReplicache } from "src/replicache"; 12 2 import { ButtonPrimary } from "components/Buttons"; 13 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 14 5 15 6 export const AreYouSure = (props: { 16 7 entityID: string[] | string; ··· 82 73 ); 83 74 }; 84 75 85 - export async function deleteBlock( 86 - entities: string[], 87 - rep: Replicache<ReplicacheMutators>, 88 - ) { 89 - // get what pagess we need to close as a result of deleting this block 90 - let pagesToClose = [] as string[]; 91 - for (let entity of entities) { 92 - let [type] = await rep.query((tx) => 93 - scanIndex(tx).eav(entity, "block/type"), 94 - ); 95 - if (type.data.value === "card") { 96 - let [childPages] = await rep?.query( 97 - (tx) => scanIndex(tx).eav(entity, "block/card") || [], 98 - ); 99 - pagesToClose = [childPages?.data.value]; 100 - } 101 - if (type.data.value === "mailbox") { 102 - let [archive] = await rep?.query( 103 - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 104 - ); 105 - let [draft] = await rep?.query( 106 - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 107 - ); 108 - pagesToClose = [archive?.data.value, draft?.data.value]; 109 - } 110 - } 111 - 112 - // the next and previous blocks in the block list 113 - // if the focused thing is a page and not a block, return 114 - let focusedBlock = useUIState.getState().focusedEntity; 115 - let parent = 116 - focusedBlock?.entityType === "page" 117 - ? focusedBlock.entityID 118 - : focusedBlock?.parent; 119 - 120 - if (parent) { 121 - let parentType = await rep?.query((tx) => 122 - scanIndex(tx).eav(parent, "page/type"), 123 - ); 124 - if (parentType[0]?.data.value === "canvas") { 125 - useUIState 126 - .getState() 127 - .setFocusedBlock({ entityType: "page", entityID: parent }); 128 - useUIState.getState().setSelectedBlocks([]); 129 - } else { 130 - let siblings = 131 - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 132 - 133 - let selectedBlocks = useUIState.getState().selectedBlocks; 134 - let firstSelected = selectedBlocks[0]; 135 - let lastSelected = selectedBlocks[entities.length - 1]; 136 - 137 - let prevBlock = 138 - siblings?.[ 139 - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 140 - ]; 141 - let prevBlockType = await rep?.query((tx) => 142 - scanIndex(tx).eav(prevBlock?.value, "block/type"), 143 - ); 144 - 145 - let nextBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === lastSelected.value) + 1 148 - ]; 149 - let nextBlockType = await rep?.query((tx) => 150 - scanIndex(tx).eav(nextBlock?.value, "block/type"), 151 - ); 152 - 153 - if (prevBlock) { 154 - useUIState.getState().setSelectedBlock({ 155 - value: prevBlock.value, 156 - parent: prevBlock.parent, 157 - }); 158 - 159 - focusBlock( 160 - { 161 - value: prevBlock.value, 162 - type: prevBlockType?.[0].data.value, 163 - parent: prevBlock.parent, 164 - }, 165 - { type: "end" }, 166 - ); 167 - } else { 168 - useUIState.getState().setSelectedBlock({ 169 - value: nextBlock.value, 170 - parent: nextBlock.parent, 171 - }); 172 - 173 - focusBlock( 174 - { 175 - value: nextBlock.value, 176 - type: nextBlockType?.[0]?.data.value, 177 - parent: nextBlock.parent, 178 - }, 179 - { type: "start" }, 180 - ); 181 - } 182 - } 183 - } 184 - 185 - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 186 - await Promise.all( 187 - entities.map((entity) => 188 - rep?.mutate.removeBlock({ 189 - blockEntity: entity, 190 - }), 191 - ), 192 - ); 193 - }
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 12 13 import { isUrl } from "src/utils/isURL"; 13 14 import { elementId } from "src/utils/elementId"; 14 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 12 + import { focusPage } from "src/utils/focusPage"; 13 13 import { v7 } from "uuid"; 14 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { useEntity, useReplicache } from "src/replicache"; 7 7 import { useUIState } from "src/useUIState"; 8 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
+5 -11
components/Blocks/PollBlock.tsx components/Blocks/PollBlock/index.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockProps } from "../Block"; 3 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 4 import { useCallback, useEffect, useState } from "react"; 5 - import { focusElement, Input } from "components/Input"; 5 + import { Input } from "components/Input"; 6 + import { focusElement } from "src/utils/focusElement"; 6 7 import { Separator } from "components/Layout"; 7 8 import { useEntitySetContext } from "components/EntitySetProvider"; 8 9 import { theme } from "tailwind.config"; ··· 13 14 usePollData, 14 15 } from "components/PageSWRDataProvider"; 15 16 import { voteOnPoll } from "actions/pollActions"; 16 - import { create } from "zustand"; 17 17 import { elementId } from "src/utils/elementId"; 18 18 import { CheckTiny } from "components/Icons/CheckTiny"; 19 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 - import { PublicationPollBlock } from "./PublicationPollBlock"; 21 - 22 - export let usePollBlockUIState = create( 23 - () => 24 - ({}) as { 25 - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 26 - }, 27 - ); 20 + import { PublicationPollBlock } from "../PublicationPollBlock"; 21 + import { usePollBlockUIState } from "./pollBlockState"; 28 22 29 23 export const PollBlock = (props: BlockProps) => { 30 24 let { data: pub } = useLeafletPublicationData();
+8
components/Blocks/PollBlock/pollBlockState.ts
··· 1 + import { create } from "zustand"; 2 + 3 + export let usePollBlockUIState = create( 4 + () => 5 + ({}) as { 6 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 7 + }, 8 + );
+2 -1
components/Blocks/PublicationPollBlock.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 2 import { BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 - import { focusElement, AsyncValueInput } from "components/Input"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 import { useEntitySetContext } from "components/EntitySetProvider"; 6 7 import { useEntity, useReplicache } from "src/replicache"; 7 8 import { v7 } from "uuid";
+1 -39
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 5 5 import * as base64 from "base64-js"; 6 6 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 7 import { AtMentionLink } from "components/AtMentionLink"; 8 + import { Delta } from "src/utils/yjsFragmentToString"; 8 9 9 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 10 11 export function RenderYJSFragment({ ··· 131 132 } 132 133 }; 133 134 134 - export type Delta = { 135 - insert: string; 136 - attributes?: { 137 - strong?: {}; 138 - code?: {}; 139 - em?: {}; 140 - underline?: {}; 141 - strikethrough?: {}; 142 - highlight?: { color: string }; 143 - link?: { href: string }; 144 - }; 145 - }; 146 - 147 135 function attributesToStyle(d: Delta) { 148 136 let props = { 149 137 style: {}, ··· 174 162 return props; 175 163 } 176 164 177 - export function YJSFragmentToString( 178 - node: XmlElement | XmlText | XmlHook, 179 - ): string { 180 - if (node.constructor === XmlElement) { 181 - // Handle hard_break nodes specially 182 - if (node.nodeName === "hard_break") { 183 - return "\n"; 184 - } 185 - // Handle inline mention nodes 186 - if (node.nodeName === "didMention" || node.nodeName === "atMention") { 187 - return node.getAttribute("text") || ""; 188 - } 189 - return node 190 - .toArray() 191 - .map((f) => YJSFragmentToString(f)) 192 - .join(""); 193 - } 194 - if (node.constructor === XmlText) { 195 - return (node.toDelta() as Delta[]) 196 - .map((d) => { 197 - return d.insert; 198 - }) 199 - .join(""); 200 - } 201 - return ""; 202 - }
+1 -1
components/Blocks/TextBlock/keymap.ts
··· 17 17 import { schema } from "./schema"; 18 18 import { useUIState } from "src/useUIState"; 19 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 20 + import { focusPage } from "src/utils/focusPage"; 21 21 import { v7 } from "uuid"; 22 22 import { scanIndex } from "src/replicache/utils"; 23 23 import { indent, outdent } from "src/utils/list-operations";
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 16 import { entities } from "drizzle/schema"; 17 17 import { scanIndex } from "src/replicache/utils"; 18 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 2 import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block";
+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 + };
+1 -34
components/Input.tsx
··· 2 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 6 7 export const Input = ( 7 8 props: { ··· 56 57 }} 57 58 /> 58 59 ); 59 - }; 60 - 61 - export const focusElement = (el?: HTMLInputElement | null) => { 62 - if (!isIOS()) { 63 - el?.focus(); 64 - return; 65 - } 66 - 67 - let fakeInput = document.createElement("input"); 68 - fakeInput.setAttribute("type", "text"); 69 - fakeInput.style.position = "fixed"; 70 - fakeInput.style.height = "0px"; 71 - fakeInput.style.width = "0px"; 72 - fakeInput.style.fontSize = "16px"; // disable auto zoom 73 - document.body.appendChild(fakeInput); 74 - fakeInput.focus(); 75 - setTimeout(() => { 76 - if (!el) return; 77 - el.style.transform = "translateY(-2000px)"; 78 - el?.focus(); 79 - fakeInput.remove(); 80 - el.value = " "; 81 - el.setSelectionRange(1, 1); 82 - requestAnimationFrame(() => { 83 - if (el) { 84 - el.style.transform = ""; 85 - } 86 - }); 87 - setTimeout(() => { 88 - if (!el) return; 89 - el.value = ""; 90 - el.setSelectionRange(0, 0); 91 - }, 50); 92 - }, 20); 93 60 }; 94 61 95 62 export const InputWithLabel = (
+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 + };
+1 -1
components/Layout.tsx
··· 3 3 import { theme } from "tailwind.config"; 4 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 7 import { useState } from "react"; 8 8 9 9 export const Separator = (props: { classname?: string }) => {
+71 -68
components/Mention.tsx
··· 18 18 view: React.RefObject<EditorView | null>; 19 19 onSelect: (mention: Mention) => void; 20 20 coords: { top: number; left: number } | null; 21 + placeholder?: string; 21 22 }) { 22 23 const [searchQuery, setSearchQuery] = useState(""); 23 24 const [noResults, setNoResults] = useState(false); ··· 207 208 placeholder={ 208 209 scope.type === "publication" 209 210 ? "Search posts..." 210 - : "Search people & publications..." 211 + : props.placeholder ?? "Search people & publications..." 211 212 } 212 213 className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 213 214 /> ··· 219 220 No results found 220 221 </div> 221 222 )} 222 - <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 223 - {sortedSuggestions.map((result, index) => { 224 - const prevResult = sortedSuggestions[index - 1]; 225 - const showHeader = 226 - index === 0 || 227 - (prevResult && prevResult.type !== result.type); 223 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 224 + {sortedSuggestions.map((result, index) => { 225 + const prevResult = sortedSuggestions[index - 1]; 226 + const showHeader = 227 + index === 0 || 228 + (prevResult && prevResult.type !== result.type); 228 229 229 - return ( 230 - <Fragment 231 - key={result.type === "did" ? result.did : result.uri} 232 - > 233 - {showHeader && ( 234 - <> 235 - {index > 0 && ( 236 - <hr className="border-border-light mx-1 my-1" /> 237 - )} 238 - <div className="text-xs text-tertiary font-bold pt-1 px-2"> 239 - {getHeader(result.type, scope)} 240 - </div> 241 - </> 242 - )} 243 - {result.type === "did" ? ( 244 - <DidResult 245 - onClick={() => { 246 - props.onSelect(result); 247 - props.onOpenChange(false); 248 - }} 249 - onMouseDown={(e) => e.preventDefault()} 250 - displayName={result.displayName} 251 - handle={result.handle} 252 - avatar={result.avatar} 253 - selected={index === suggestionIndex} 254 - /> 255 - ) : result.type === "publication" ? ( 256 - <PublicationResult 257 - onClick={() => { 258 - props.onSelect(result); 259 - props.onOpenChange(false); 260 - }} 261 - onMouseDown={(e) => e.preventDefault()} 262 - pubName={result.name} 263 - uri={result.uri} 264 - selected={index === suggestionIndex} 265 - onPostsClick={() => { 266 - handleScopeChange({ 267 - type: "publication", 268 - uri: result.uri, 269 - name: result.name, 270 - }); 271 - }} 272 - /> 273 - ) : ( 274 - <PostResult 275 - onClick={() => { 276 - props.onSelect(result); 277 - props.onOpenChange(false); 278 - }} 279 - onMouseDown={(e) => e.preventDefault()} 280 - title={result.title} 281 - selected={index === suggestionIndex} 282 - /> 283 - )} 284 - </Fragment> 285 - ); 286 - })} 287 - </ul> 230 + return ( 231 + <Fragment 232 + key={result.type === "did" ? result.did : result.uri} 233 + > 234 + {showHeader && ( 235 + <> 236 + {index > 0 && ( 237 + <hr className="border-border-light mx-1 my-1" /> 238 + )} 239 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 240 + {getHeader(result.type, scope)} 241 + </div> 242 + </> 243 + )} 244 + {result.type === "did" ? ( 245 + <DidResult 246 + onClick={() => { 247 + props.onSelect(result); 248 + props.onOpenChange(false); 249 + }} 250 + onMouseDown={(e) => e.preventDefault()} 251 + displayName={result.displayName} 252 + handle={result.handle} 253 + avatar={result.avatar} 254 + selected={index === suggestionIndex} 255 + /> 256 + ) : result.type === "publication" ? ( 257 + <PublicationResult 258 + onClick={() => { 259 + props.onSelect(result); 260 + props.onOpenChange(false); 261 + }} 262 + onMouseDown={(e) => e.preventDefault()} 263 + pubName={result.name} 264 + uri={result.uri} 265 + selected={index === suggestionIndex} 266 + onPostsClick={() => { 267 + handleScopeChange({ 268 + type: "publication", 269 + uri: result.uri, 270 + name: result.name, 271 + }); 272 + }} 273 + /> 274 + ) : ( 275 + <PostResult 276 + onClick={() => { 277 + props.onSelect(result); 278 + props.onOpenChange(false); 279 + }} 280 + onMouseDown={(e) => e.preventDefault()} 281 + title={result.title} 282 + selected={index === suggestionIndex} 283 + /> 284 + )} 285 + </Fragment> 286 + ); 287 + })} 288 + </ul> 288 289 </div> 289 290 </Popover.Content> 290 291 </Popover.Portal> ··· 456 457 displayName?: string; 457 458 avatar?: string; 458 459 } 459 - | { type: "publication"; uri: string; name: string } 460 - | { type: "post"; uri: string; title: string }; 460 + | { type: "publication"; uri: string; name: string; url: string } 461 + | { type: "post"; uri: string; title: string; url: string }; 461 462 462 463 export type MentionScope = 463 464 | { type: "default" } ··· 492 493 type: "post" as const, 493 494 uri: d.uri, 494 495 title: d.title, 496 + url: d.url, 495 497 })), 496 498 ); 497 499 } else { ··· 516 518 type: "publication" as const, 517 519 uri: p.uri, 518 520 name: p.name, 521 + url: p.url, 519 522 })), 520 523 ]); 521 524 }
+1 -1
components/Pages/Page.tsx
··· 12 12 import { Blocks } from "components/Blocks"; 13 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 15 + import { focusPage } from "src/utils/focusPage"; 16 16 import { PageOptions } from "./PageOptions"; 17 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+156 -84
components/Pages/PublicationMetadata.tsx
··· 1 1 import Link from "next/link"; 2 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 3 + import { useRef, useState } from "react"; 4 4 import { useReplicache } from "src/replicache"; 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 9 import { 10 10 getBasePublicationURL, 11 11 getPublicationURL, ··· 13 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 15 import { timeAgo } from "src/utils/timeAgo"; 16 + import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 18 + import { TagTiny } from "components/Icons/TagTiny"; 19 + import { Popover } from "components/Popover"; 20 + import { TagSelector } from "components/Tags"; 16 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 17 23 export const PublicationMetadata = () => { 18 24 let { rep } = useReplicache(); 19 25 let { data: pub } = useLeafletPublicationData(); ··· 23 29 tx.get<string>("publication_description"), 24 30 ); 25 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 26 35 let publishedAt = record?.publishedAt; 27 36 28 37 if (!pub) return null; ··· 33 42 if (typeof description !== "string") { 34 43 description = pub?.description || ""; 35 44 } 45 + let tags = true; 46 + 36 47 return ( 37 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 38 - <div className="flex gap-2"> 39 - {pub.publications && ( 40 - <Link 41 - href={ 42 - identity?.atp_did === pub.publications?.identity_did 43 - ? `${getBasePublicationURL(pub.publications)}/dashboard` 44 - : getPublicationURL(pub.publications) 45 - } 46 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 47 - > 48 - {pub.publications?.name} 49 - </Link> 50 - )} 51 - <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 52 - Editor 53 - </div> 54 - </div> 55 - <TextField 56 - className="text-xl font-bold outline-hidden bg-transparent" 57 - value={title} 58 - onChange={async (newTitle) => { 59 - await rep?.mutate.updatePublicationDraft({ 60 - title: newTitle, 61 - description, 62 - }); 63 - }} 64 - placeholder="Untitled" 65 - /> 66 - <TextField 67 - placeholder="add an optional description..." 68 - className="italic text-secondary outline-hidden bg-transparent" 69 - value={description} 70 - onChange={async (newDescription) => { 71 - await rep?.mutate.updatePublicationDraft({ 72 - title, 73 - description: newDescription, 74 - }); 75 - }} 76 - /> 77 - {pub.doc ? ( 78 - <div className="flex flex-row items-center gap-2 pt-3"> 79 - <p className="text-sm text-tertiary"> 80 - Published {publishedAt && timeAgo(publishedAt)} 81 - </p> 82 - <Separator classname="h-4" /> 83 - <Link 84 - target="_blank" 85 - className="text-sm" 86 - href={ 87 - pub.publications 88 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 89 - : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 90 - } 91 - > 92 - View Post 93 - </Link> 48 + <PostHeaderLayout 49 + pubLink={ 50 + <div className="flex gap-2 items-center"> 51 + {pub.publications && ( 52 + <Link 53 + href={ 54 + identity?.atp_did === pub.publications?.identity_did 55 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 56 + : getPublicationURL(pub.publications) 57 + } 58 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 59 + > 60 + {pub.publications?.name} 61 + </Link> 62 + )} 63 + <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 64 + DRAFT 65 + </div> 94 66 </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </div> 67 + } 68 + postTitle={ 69 + <TextField 70 + className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 71 + value={title} 72 + onChange={async (newTitle) => { 73 + await rep?.mutate.updatePublicationDraft({ 74 + title: newTitle, 75 + description, 76 + }); 77 + }} 78 + placeholder="Untitled" 79 + /> 80 + } 81 + postDescription={ 82 + <TextField 83 + placeholder="add an optional description..." 84 + className="pt-1 italic text-secondary outline-hidden bg-transparent" 85 + value={description} 86 + onChange={async (newDescription) => { 87 + await rep?.mutate.updatePublicationDraft({ 88 + title, 89 + description: newDescription, 90 + }); 91 + }} 92 + /> 93 + } 94 + postInfo={ 95 + <> 96 + {pub.doc ? ( 97 + <div className="flex gap-2 items-center"> 98 + <p className="text-sm text-tertiary"> 99 + Published {publishedAt && timeAgo(publishedAt)} 100 + </p> 101 + 102 + <Link 103 + target="_blank" 104 + className="text-sm" 105 + href={ 106 + pub.publications 107 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 108 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 109 + } 110 + > 111 + View 112 + </Link> 113 + </div> 114 + ) : ( 115 + <p>Draft</p> 116 + )} 117 + <div className="flex gap-2 text-border items-center"> 118 + {tags && ( 119 + <> 120 + <AddTags /> 121 + <Separator classname="h-4!" /> 122 + </> 123 + )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />— 126 + </div> 127 + {pubRecord?.preferences?.showComments && ( 128 + <div className="flex gap-1 items-center"> 129 + <CommentTiny />— 130 + </div> 131 + )} 132 + </div> 133 + </> 134 + } 135 + /> 99 136 ); 100 137 }; 101 138 ··· 178 215 if (!pub) return null; 179 216 180 217 return ( 181 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 182 - <div className="text-accent-contrast font-bold hover:no-underline"> 183 - {pub.publications?.name} 184 - </div> 218 + <PostHeaderLayout 219 + pubLink={ 220 + <div className="text-accent-contrast font-bold hover:no-underline"> 221 + {pub.publications?.name} 222 + </div> 223 + } 224 + postTitle={pub.title} 225 + postDescription={pub.description} 226 + postInfo={ 227 + pub.doc ? ( 228 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 229 + ) : ( 230 + <p>Draft</p> 231 + ) 232 + } 233 + /> 234 + ); 235 + }; 185 236 186 - <div 187 - className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`} 188 - > 189 - {pub.title ? pub.title : "Untitled"} 190 - </div> 191 - <div className="italic text-secondary outline-hidden bg-transparent"> 192 - {pub.description} 193 - </div> 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 194 241 195 - {pub.doc ? ( 196 - <div className="flex flex-row items-center gap-2 pt-3"> 197 - <p className="text-sm text-tertiary"> 198 - Published {publishedAt && timeAgo(publishedAt)} 199 - </p> 242 + // Get tags from Replicache local state or published document 243 + let replicacheTags = useSubscribe(rep, (tx) => 244 + tx.get<string[]>("publication_tags"), 245 + ); 246 + 247 + // Determine which tags to use - prioritize Replicache state 248 + let tags: string[] = []; 249 + if (Array.isArray(replicacheTags)) { 250 + tags = replicacheTags; 251 + } else if (record?.tags && Array.isArray(record.tags)) { 252 + tags = record.tags as string[]; 253 + } 254 + 255 + // Update tags in replicache local state 256 + const handleTagsChange = async (newTags: string[]) => { 257 + // Store tags in replicache for next publish/update 258 + await rep?.mutate.updatePublicationDraft({ 259 + tags: newTags, 260 + }); 261 + }; 262 + 263 + return ( 264 + <Popover 265 + className="p-2! w-full min-w-xs" 266 + trigger={ 267 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 268 + <TagTiny />{" "} 269 + {tags.length > 0 270 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 271 + : "Add Tags"} 200 272 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 205 277 ); 206 278 };
+2 -75
components/Pages/index.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { useSearchParams } from "next/navigation"; 6 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 7 + import { useEntity } from "src/replicache"; 9 8 10 - import { Replicache } from "replicache"; 11 - import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; 12 - 13 - import { scanIndex } from "src/replicache/utils"; 14 - import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 15 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 16 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 55 ); 63 56 } 64 57 65 - export async function focusPage( 66 - pageID: string, 67 - rep: Replicache<ReplicacheMutators>, 68 - focusFirstBlock?: "focusFirstBlock", 69 - ) { 70 - // if this page is already focused, 71 - let focusedBlock = useUIState.getState().focusedEntity; 72 - // else set this page as focused 73 - useUIState.setState(() => ({ 74 - focusedEntity: { 75 - entityType: "page", 76 - entityID: pageID, 77 - }, 78 - })); 79 - 80 - setTimeout(async () => { 81 - //scroll to page 82 - 83 - scrollIntoViewIfNeeded( 84 - document.getElementById(elementId.page(pageID).container), 85 - false, 86 - "smooth", 87 - ); 88 - 89 - // if we asked that the function focus the first block, focus the first block 90 - if (focusFirstBlock === "focusFirstBlock") { 91 - let firstBlock = await rep.query(async (tx) => { 92 - let type = await scanIndex(tx).eav(pageID, "page/type"); 93 - let blocks = await scanIndex(tx).eav( 94 - pageID, 95 - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 96 - ); 97 - 98 - let firstBlock = blocks[0]; 99 - 100 - if (!firstBlock) { 101 - return null; 102 - } 103 - 104 - let blockType = ( 105 - await tx 106 - .scan< 107 - Fact<"block/type"> 108 - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 109 - .toArray() 110 - )[0]; 111 - 112 - if (!blockType) return null; 113 - 114 - return { 115 - value: firstBlock.data.value, 116 - type: blockType.data.value, 117 - parent: firstBlock.entity, 118 - position: firstBlock.data.position, 119 - }; 120 - }); 121 - 122 - if (firstBlock) { 123 - setTimeout(() => { 124 - focusBlock(firstBlock, { type: "start" }); 125 - }, 500); 126 - } 127 - } 128 - }, 50); 129 - } 130 - 131 - export const blurPage = () => { 58 + const blurPage = () => { 132 59 useUIState.setState(() => ({ 133 60 focusedEntity: null, 134 61 selectedBlocks: [],
+28 -25
components/Popover.tsx components/Popover/index.tsx
··· 1 1 "use client"; 2 2 import * as RadixPopover from "@radix-ui/react-popover"; 3 3 import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { createContext, useEffect, useState } from "react"; 6 - import { PopoverArrow } from "./Icons/PopoverArrow"; 7 - 8 - export const PopoverOpenContext = createContext(false); 4 + import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider"; 5 + import { useEffect, useState } from "react"; 6 + import { PopoverArrow } from "../Icons/PopoverArrow"; 7 + import { PopoverOpenContext } from "./PopoverContext"; 9 8 export const Popover = (props: { 10 9 trigger: React.ReactNode; 11 10 disabled?: boolean; 12 11 children: React.ReactNode; 13 12 align?: "start" | "end" | "center"; 14 13 side?: "top" | "bottom" | "left" | "right"; 14 + sideOffset?: number; 15 15 background?: string; 16 16 border?: string; 17 17 className?: string; ··· 20 20 onOpenAutoFocus?: (e: Event) => void; 21 21 asChild?: boolean; 22 22 arrowFill?: string; 23 + noArrow?: boolean; 23 24 }) => { 24 25 let [open, setOpen] = useState(props.open || false); 25 26 useEffect(() => { ··· 51 52 `} 52 53 side={props.side} 53 54 align={props.align ? props.align : "center"} 54 - sideOffset={4} 55 + sideOffset={props.sideOffset ? props.sideOffset : 4} 55 56 collisionPadding={16} 56 57 onOpenAutoFocus={props.onOpenAutoFocus} 57 58 > 58 59 {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> 60 + {!props.noArrow && ( 61 + <RadixPopover.Arrow 62 + asChild 63 + width={16} 64 + height={8} 65 + viewBox="0 0 16 8" 66 + > 67 + <PopoverArrow 68 + arrowFill={ 69 + props.arrowFill 70 + ? props.arrowFill 71 + : props.background 72 + ? props.background 73 + : theme.colors["bg-page"] 74 + } 75 + arrowStroke={ 76 + props.border ? props.border : theme.colors["border"] 77 + } 78 + /> 79 + </RadixPopover.Arrow> 80 + )} 78 81 </RadixPopover.Content> 79 82 </NestedCardThemeProvider> 80 83 </RadixPopover.Portal>
+3
components/Popover/PopoverContext.ts
··· 1 + import { createContext } from "react"; 2 + 3 + export const PopoverOpenContext = createContext(false);
+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 + };
+7 -53
components/SelectionManager.tsx components/SelectionManager/index.tsx
··· 1 1 "use client"; 2 2 import { useEffect, useRef, useState } from "react"; 3 - import { create } from "zustand"; 4 - import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 + import { useReplicache } from "src/replicache"; 5 4 import { useUIState } from "src/useUIState"; 6 5 import { scanIndex } from "src/replicache/utils"; 7 6 import { focusBlock } from "src/utils/focusBlock"; 8 7 import { useEditorStates } from "src/state/useEditorState"; 9 - import { useEntitySetContext } from "./EntitySetProvider"; 8 + import { useEntitySetContext } from "../EntitySetProvider"; 10 9 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { v7 } from "uuid"; 12 10 import { indent, outdent, outdentFull } from "src/utils/list-operations"; 13 11 import { addShortcut, Shortcut } from "src/shortcuts"; 14 - import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 15 12 import { elementId } from "src/utils/elementId"; 16 13 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 17 14 import { copySelection } from "src/utils/copySelection"; 18 - import { isTextBlock } from "src/utils/isTextBlock"; 19 15 import { useIsMobile } from "src/hooks/isMobile"; 20 - import { deleteBlock } from "./Blocks/DeleteBlock"; 21 - import { Replicache } from "replicache"; 22 - import { schema } from "./Blocks/TextBlock/schema"; 23 - import { TextSelection } from "prosemirror-state"; 16 + import { deleteBlock } from "src/utils/deleteBlock"; 17 + import { schema } from "../Blocks/TextBlock/schema"; 24 18 import { MarkType } from "prosemirror-model"; 25 - export const useSelectingMouse = create(() => ({ 26 - start: null as null | string, 27 - })); 19 + import { useSelectingMouse, getSortedSelection } from "./selectionState"; 28 20 29 21 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 30 22 // How does this relate to *when dragging* ? ··· 632 624 endOffset: number; 633 625 direction: "forward" | "backward"; 634 626 }; 635 - export function saveSelection() { 627 + function saveSelection() { 636 628 let selection = window.getSelection(); 637 629 if (selection && selection.rangeCount > 0) { 638 630 let ranges: SavedRange[] = []; ··· 655 647 return []; 656 648 } 657 649 658 - export function restoreSelection(savedRanges: SavedRange[]) { 650 + function restoreSelection(savedRanges: SavedRange[]) { 659 651 if (savedRanges && savedRanges.length > 0) { 660 652 let selection = window.getSelection(); 661 653 if (!selection) return; ··· 693 685 return null; 694 686 } 695 687 696 - export const getSortedSelection = async ( 697 - rep: Replicache<ReplicacheMutators>, 698 - ) => { 699 - let selectedBlocks = useUIState.getState().selectedBlocks; 700 - let foldedBlocks = useUIState.getState().foldedBlocks; 701 - if (!selectedBlocks[0]) return [[], []]; 702 - let siblings = 703 - (await rep?.query((tx) => 704 - getBlocksWithType(tx, selectedBlocks[0].parent), 705 - )) || []; 706 - let sortedBlocks = siblings.filter((s) => { 707 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 708 - return selected; 709 - }); 710 - let sortedBlocksWithChildren = siblings.filter((s) => { 711 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 712 - if (s.listData && !selected) { 713 - //Select the children of folded list blocks (in order to copy them) 714 - return s.listData.path.find( 715 - (p) => 716 - selectedBlocks.find((sb) => sb.value === p.entity) && 717 - foldedBlocks.includes(p.entity), 718 - ); 719 - } 720 - return selected; 721 - }); 722 - return [ 723 - sortedBlocks, 724 - siblings.filter( 725 - (f) => 726 - !f.listData || 727 - !f.listData.path.find( 728 - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 729 - ), 730 - ), 731 - sortedBlocksWithChildren, 732 - ]; 733 - }; 734 688 735 689 function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 736 690 let everyBlockHasMark = blocks.reduce((acc, block) => {
+48
components/SelectionManager/selectionState.ts
··· 1 + import { create } from "zustand"; 2 + import { Replicache } from "replicache"; 3 + import { ReplicacheMutators } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + 7 + export const useSelectingMouse = create(() => ({ 8 + start: null as null | string, 9 + })); 10 + 11 + export const getSortedSelection = async ( 12 + rep: Replicache<ReplicacheMutators>, 13 + ) => { 14 + let selectedBlocks = useUIState.getState().selectedBlocks; 15 + let foldedBlocks = useUIState.getState().foldedBlocks; 16 + if (!selectedBlocks[0]) return [[], []]; 17 + let siblings = 18 + (await rep?.query((tx) => 19 + getBlocksWithType(tx, selectedBlocks[0].parent), 20 + )) || []; 21 + let sortedBlocks = siblings.filter((s) => { 22 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 23 + return selected; 24 + }); 25 + let sortedBlocksWithChildren = siblings.filter((s) => { 26 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 27 + if (s.listData && !selected) { 28 + //Select the children of folded list blocks (in order to copy them) 29 + return s.listData.path.find( 30 + (p) => 31 + selectedBlocks.find((sb) => sb.value === p.entity) && 32 + foldedBlocks.includes(p.entity), 33 + ); 34 + } 35 + return selected; 36 + }); 37 + return [ 38 + sortedBlocks, 39 + siblings.filter( 40 + (f) => 41 + !f.listData || 42 + !f.listData.path.find( 43 + (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 44 + ), 45 + ), 46 + sortedBlocksWithChildren, 47 + ]; 48 + };
+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={`https://leaflet.pub/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 + };
+4 -2
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 2 import { useMemo, useState } from "react"; 3 3 import { parseColor } from "react-aria-components"; 4 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 5 + import { getColorContrast } from "./themeUtils"; 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 84 84 <div 85 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 88 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 92 }}
+1 -40
components/ThemeManager/ThemeProvider.tsx
··· 5 5 CSSProperties, 6 6 useContext, 7 7 useEffect, 8 - useMemo, 9 - useState, 10 8 } from "react"; 11 9 import { 12 10 colorToString, ··· 14 12 useColorAttributeNullable, 15 13 } from "./useColorAttribute"; 16 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 15 19 16 import { useEntity } from "src/replicache"; 20 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 20 PublicationThemeProvider, 24 21 } from "./PublicationThemeProvider"; 25 22 import { PubLeafletPublication } from "lexicons/api"; 26 - 27 - type CSSVariables = { 28 - "--bg-leaflet": string; 29 - "--bg-page": string; 30 - "--primary": string; 31 - "--accent-1": string; 32 - "--accent-2": string; 33 - "--accent-contrast": string; 34 - "--highlight-1": string; 35 - "--highlight-2": string; 36 - "--highlight-3": string; 37 - }; 38 - 39 - // define the color defaults for everything 40 - export const ThemeDefaults = { 41 - "theme/page-background": "#FDFCFA", 42 - "theme/card-background": "#FFFFFF", 43 - "theme/primary": "#272727", 44 - "theme/highlight-1": "#FFFFFF", 45 - "theme/highlight-2": "#EDD280", 46 - "theme/highlight-3": "#FFCDC3", 47 - 48 - //everywhere else, accent-background = accent-1 and accent-text = accent-2. 49 - // we just need to create a migration pipeline before we can change this 50 - "theme/accent-text": "#FFFFFF", 51 - "theme/accent-background": "#0000FF", 52 - "theme/accent-contrast": "#0000FF", 53 - }; 23 + import { getColorContrast } from "./themeUtils"; 54 24 55 25 // define a function to set an Aria Color to a CSS Variable in RGB 56 26 function setCSSVariableToColor( ··· 368 338 ); 369 339 }; 370 340 371 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 372 - export function getColorContrast(color1: string, color2: string) { 373 - ColorSpace.register(sRGB); 374 - 375 - let parsedColor1 = parse(`rgb(${color1})`); 376 - let parsedColor2 = parse(`rgb(${color2})`); 377 - 378 - return contrastLstar(parsedColor1, parsedColor2); 379 - }
+27
components/ThemeManager/themeUtils.ts
··· 1 + import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 + 3 + // define the color defaults for everything 4 + export const ThemeDefaults = { 5 + "theme/page-background": "#FDFCFA", 6 + "theme/card-background": "#FFFFFF", 7 + "theme/primary": "#272727", 8 + "theme/highlight-1": "#FFFFFF", 9 + "theme/highlight-2": "#EDD280", 10 + "theme/highlight-3": "#FFCDC3", 11 + 12 + //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 + // we just need to create a migration pipeline before we can change this 14 + "theme/accent-text": "#FFFFFF", 15 + "theme/accent-background": "#0000FF", 16 + "theme/accent-contrast": "#0000FF", 17 + }; 18 + 19 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorContrast(color1: string, color2: string) { 21 + ColorSpace.register(sRGB); 22 + 23 + let parsedColor1 = parse(`rgb(${color1})`); 24 + let parsedColor2 = parse(`rgb(${color2})`); 25 + 26 + return contrastLstar(parsedColor1, parsedColor2); 27 + }
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 2 2 import { Color, parseColor } from "react-aria-components"; 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 6 7 7 export function useColorAttribute( 8 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 2 import { ToolbarButton } from "."; 3 3 import { Separator, ShortcutKey } from "components/Layout"; 4 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 5 import { useUIState } from "src/useUIState"; 7 6 import { LockBlockButton } from "./LockBlockButton"; 8 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 11 12 12 export const BlockToolbar = (props: { 13 13 setToolbarState: ( ··· 66 66 67 67 const MoveBlockButtons = () => { 68 68 let { rep } = useReplicache(); 69 - const getSortedSelection = async () => { 70 - let selectedBlocks = useUIState.getState().selectedBlocks; 71 - let siblings = 72 - (await rep?.query((tx) => 73 - getBlocksWithType(tx, selectedBlocks[0].parent), 74 - )) || []; 75 - let sortedBlocks = siblings.filter((s) => 76 - selectedBlocks.find((sb) => sb.value === s.value), 77 - ); 78 - return [sortedBlocks, siblings]; 79 - }; 80 69 return ( 81 70 <> 82 71 <ToolbarButton 83 72 hiddenOnCanvas 84 73 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 86 76 if (sortedBlocks.length > 1) return; 87 77 let block = sortedBlocks[0]; 88 78 let previousBlock = ··· 139 129 <ToolbarButton 140 130 hiddenOnCanvas 141 131 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 143 134 if (sortedBlocks.length > 1) return; 144 135 let block = sortedBlocks[0]; 145 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 8 import { LockBlockButton } from "./LockBlockButton"; 9 9 import { Props } from "components/Icons/Props"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 13 import { TextToolbar } from "./TextToolbar"; 14 14 import { BlockToolbar } from "./BlockToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 17 18 import { TooltipButton } from "components/Buttons"; 18 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 8 import { useEntity, useReplicache } from "src/replicache"; 9 9 import * as Y from "yjs"; 10 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 13 import { focusBlock } from "src/utils/focusBlock"; 14 14 import { useIsMobile } from "src/hooks/isMobile";
+7
lexicons/api/lexicons.ts
··· 1440 1440 type: 'ref', 1441 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 1442 }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 + }, 1443 1450 pages: { 1444 1451 type: 'array', 1445 1452 items: {
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 23 publication?: string 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 + tags?: string[] 26 27 pages: ( 27 28 | $Typed<PubLeafletPagesLinearDocument.Main> 28 29 | $Typed<PubLeafletPagesCanvas.Main>
+7
lexicons/pub/leaflet/document.json
··· 46 46 "type": "ref", 47 47 "ref": "pub.leaflet.publication#theme" 48 48 }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 + }, 49 56 "pages": { 50 57 "type": "array", 51 58 "items": {
+1
lexicons/src/document.ts
··· 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 26 27 pages: { 27 28 type: "array", 28 29 items: {
+3 -1
src/hooks/useLocalizedDate.ts
··· 28 28 29 29 // On initial page load, use header timezone. After hydration, use system timezone 30 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 31 + ? timezone || "UTC" 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 33 35 34 36 // Apply timezone if available 35 37 if (effectiveTimezone) {
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 6 useEffect(() => { 7 7 if (!ref.current || !key) return; 8 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 12 13 13 14 const listener = () => { 14 15 if (!ref.current?.scrollTop) return;
+98 -74
src/notifications.ts
··· 85 85 ) 86 86 .in("uri", commentUris); 87 87 88 - return commentNotifications.map((notification) => ({ 89 - id: notification.id, 90 - recipient: notification.recipient, 91 - created_at: notification.created_at, 92 - type: "comment" as const, 93 - comment_uri: notification.data.comment_uri, 94 - parentData: notification.data.parent_uri 95 - ? comments?.find((c) => c.uri === notification.data.parent_uri)! 96 - : undefined, 97 - commentData: comments?.find( 98 - (c) => c.uri === notification.data.comment_uri, 99 - )!, 100 - })); 88 + return commentNotifications 89 + .map((notification) => { 90 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 91 + if (!commentData) return null; 92 + return { 93 + id: notification.id, 94 + recipient: notification.recipient, 95 + created_at: notification.created_at, 96 + type: "comment" as const, 97 + comment_uri: notification.data.comment_uri, 98 + parentData: notification.data.parent_uri 99 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 + : undefined, 101 + commentData, 102 + }; 103 + }) 104 + .filter((n) => n !== null); 101 105 } 102 106 103 107 export type HydratedSubscribeNotification = Awaited< ··· 125 129 .select("*, identities(bsky_profiles(*)), publications(*)") 126 130 .in("uri", subscriptionUris); 127 131 128 - return subscribeNotifications.map((notification) => ({ 129 - id: notification.id, 130 - recipient: notification.recipient, 131 - created_at: notification.created_at, 132 - type: "subscribe" as const, 133 - subscription_uri: notification.data.subscription_uri, 134 - subscriptionData: subscriptions?.find( 135 - (s) => s.uri === notification.data.subscription_uri, 136 - )!, 137 - })); 132 + return subscribeNotifications 133 + .map((notification) => { 134 + const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 135 + if (!subscriptionData) return null; 136 + return { 137 + id: notification.id, 138 + recipient: notification.recipient, 139 + created_at: notification.created_at, 140 + type: "subscribe" as const, 141 + subscription_uri: notification.data.subscription_uri, 142 + subscriptionData, 143 + }; 144 + }) 145 + .filter((n) => n !== null); 138 146 } 139 147 140 148 export type HydratedQuoteNotification = Awaited< ··· 165 173 .select("*, documents_in_publications(publications(*))") 166 174 .in("uri", documentUris); 167 175 168 - return quoteNotifications.map((notification) => ({ 169 - id: notification.id, 170 - recipient: notification.recipient, 171 - created_at: notification.created_at, 172 - type: "quote" as const, 173 - bsky_post_uri: notification.data.bsky_post_uri, 174 - document_uri: notification.data.document_uri, 175 - bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!, 176 - document: documents?.find((d) => d.uri === notification.data.document_uri)!, 177 - })); 176 + return quoteNotifications 177 + .map((notification) => { 178 + const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 179 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 180 + if (!bskyPost || !document) return null; 181 + return { 182 + id: notification.id, 183 + recipient: notification.recipient, 184 + created_at: notification.created_at, 185 + type: "quote" as const, 186 + bsky_post_uri: notification.data.bsky_post_uri, 187 + document_uri: notification.data.document_uri, 188 + bskyPost, 189 + document, 190 + }; 191 + }) 192 + .filter((n) => n !== null); 178 193 } 179 194 180 195 export type HydratedMentionNotification = Awaited< ··· 242 257 : Promise.resolve({ data: [] }), 243 258 ]); 244 259 245 - return mentionNotifications.map((notification) => { 246 - const mentionedUri = notification.data.mention_type !== "did" 247 - ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 248 - : undefined; 260 + return mentionNotifications 261 + .map((notification) => { 262 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 263 + if (!document) return null; 249 264 250 - const documentCreatorDid = new AtUri(notification.data.document_uri).host; 251 - const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 265 + const mentionedUri = notification.data.mention_type !== "did" 266 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 267 + : undefined; 252 268 253 - return { 254 - id: notification.id, 255 - recipient: notification.recipient, 256 - created_at: notification.created_at, 257 - type: "mention" as const, 258 - document_uri: notification.data.document_uri, 259 - mention_type: notification.data.mention_type, 260 - mentioned_uri: mentionedUri, 261 - document: documents?.find((d) => d.uri === notification.data.document_uri)!, 262 - documentCreatorHandle, 263 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 264 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 265 - }; 266 - }); 269 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 + 272 + return { 273 + id: notification.id, 274 + recipient: notification.recipient, 275 + created_at: notification.created_at, 276 + type: "mention" as const, 277 + document_uri: notification.data.document_uri, 278 + mention_type: notification.data.mention_type, 279 + mentioned_uri: mentionedUri, 280 + document, 281 + documentCreatorHandle, 282 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 284 + }; 285 + }) 286 + .filter((n) => n !== null); 267 287 } 268 288 269 289 export type HydratedCommentMentionNotification = Awaited< ··· 333 353 : Promise.resolve({ data: [] }), 334 354 ]); 335 355 336 - return commentMentionNotifications.map((notification) => { 337 - const mentionedUri = notification.data.mention_type !== "did" 338 - ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 339 - : undefined; 356 + return commentMentionNotifications 357 + .map((notification) => { 358 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 359 + if (!commentData) return null; 340 360 341 - const commenterDid = new AtUri(notification.data.comment_uri).host; 342 - const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 343 - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 361 + const mentionedUri = notification.data.mention_type !== "did" 362 + ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 363 + : undefined; 344 364 345 - return { 346 - id: notification.id, 347 - recipient: notification.recipient, 348 - created_at: notification.created_at, 349 - type: "comment_mention" as const, 350 - comment_uri: notification.data.comment_uri, 351 - mention_type: notification.data.mention_type, 352 - mentioned_uri: mentionedUri, 353 - commentData: commentData!, 354 - commenterHandle, 355 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 356 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 357 - }; 358 - }); 365 + const commenterDid = new AtUri(notification.data.comment_uri).host; 366 + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 + 368 + return { 369 + id: notification.id, 370 + recipient: notification.recipient, 371 + created_at: notification.created_at, 372 + type: "comment_mention" as const, 373 + comment_uri: notification.data.comment_uri, 374 + mention_type: notification.data.mention_type, 375 + mentioned_uri: mentionedUri, 376 + commentData, 377 + commenterHandle, 378 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 380 + }; 381 + }) 382 + .filter((n) => n !== null); 359 383 } 360 384 361 385 export async function pingIdentityToUpdateNotification(did: string) {
+34 -8
src/replicache/mutations.ts
··· 609 609 }; 610 610 611 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 614 615 }> = async (args, ctx) => { 615 616 await ctx.runOnServer(async (serverCtx) => { 616 617 console.log("updating"); 617 - await serverCtx.supabase 618 - .from("leaflets_in_publications") 619 - .update({ description: args.description, title: args.title }) 620 - .eq("leaflet", ctx.permission_token_id); 618 + const updates: { 619 + description?: string; 620 + title?: string; 621 + tags?: string[]; 622 + } = {}; 623 + if (args.description !== undefined) updates.description = args.description; 624 + if (args.title !== undefined) updates.title = args.title; 625 + if (args.tags !== undefined) updates.tags = args.tags; 626 + 627 + if (Object.keys(updates).length > 0) { 628 + // First try to update leaflets_in_publications (for publications) 629 + const { data: pubResult } = await serverCtx.supabase 630 + .from("leaflets_in_publications") 631 + .update(updates) 632 + .eq("leaflet", ctx.permission_token_id) 633 + .select("leaflet"); 634 + 635 + // If no rows were updated in leaflets_in_publications, 636 + // try leaflets_to_documents (for standalone documents) 637 + if (!pubResult || pubResult.length === 0) { 638 + await serverCtx.supabase 639 + .from("leaflets_to_documents") 640 + .update(updates) 641 + .eq("leaflet", ctx.permission_token_id); 642 + } 643 + } 621 644 }); 622 645 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 646 + if (args.title !== undefined) 647 + await tx.set("publication_title", args.title); 648 + if (args.description !== undefined) 649 + await tx.set("publication_description", args.description); 650 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 625 651 }); 626 652 }; 627 653
+116
src/utils/deleteBlock.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + 8 + export async function deleteBlock( 9 + entities: string[], 10 + rep: Replicache<ReplicacheMutators>, 11 + ) { 12 + // get what pagess we need to close as a result of deleting this block 13 + let pagesToClose = [] as string[]; 14 + for (let entity of entities) { 15 + let [type] = await rep.query((tx) => 16 + scanIndex(tx).eav(entity, "block/type"), 17 + ); 18 + if (type.data.value === "card") { 19 + let [childPages] = await rep?.query( 20 + (tx) => scanIndex(tx).eav(entity, "block/card") || [], 21 + ); 22 + pagesToClose = [childPages?.data.value]; 23 + } 24 + if (type.data.value === "mailbox") { 25 + let [archive] = await rep?.query( 26 + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 27 + ); 28 + let [draft] = await rep?.query( 29 + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 30 + ); 31 + pagesToClose = [archive?.data.value, draft?.data.value]; 32 + } 33 + } 34 + 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 37 + let focusedBlock = useUIState.getState().focusedEntity; 38 + let parent = 39 + focusedBlock?.entityType === "page" 40 + ? focusedBlock.entityID 41 + : focusedBlock?.parent; 42 + 43 + if (parent) { 44 + let parentType = await rep?.query((tx) => 45 + scanIndex(tx).eav(parent, "page/type"), 46 + ); 47 + if (parentType[0]?.data.value === "canvas") { 48 + useUIState 49 + .getState() 50 + .setFocusedBlock({ entityType: "page", entityID: parent }); 51 + useUIState.getState().setSelectedBlocks([]); 52 + } else { 53 + let siblings = 54 + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 + 56 + let selectedBlocks = useUIState.getState().selectedBlocks; 57 + let firstSelected = selectedBlocks[0]; 58 + let lastSelected = selectedBlocks[entities.length - 1]; 59 + 60 + let prevBlock = 61 + siblings?.[ 62 + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 63 + ]; 64 + let prevBlockType = await rep?.query((tx) => 65 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 66 + ); 67 + 68 + let nextBlock = 69 + siblings?.[ 70 + siblings.findIndex((s) => s.value === lastSelected.value) + 1 71 + ]; 72 + let nextBlockType = await rep?.query((tx) => 73 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 74 + ); 75 + 76 + if (prevBlock) { 77 + useUIState.getState().setSelectedBlock({ 78 + value: prevBlock.value, 79 + parent: prevBlock.parent, 80 + }); 81 + 82 + focusBlock( 83 + { 84 + value: prevBlock.value, 85 + type: prevBlockType?.[0].data.value, 86 + parent: prevBlock.parent, 87 + }, 88 + { type: "end" }, 89 + ); 90 + } else { 91 + useUIState.getState().setSelectedBlock({ 92 + value: nextBlock.value, 93 + parent: nextBlock.parent, 94 + }); 95 + 96 + focusBlock( 97 + { 98 + value: nextBlock.value, 99 + type: nextBlockType?.[0]?.data.value, 100 + parent: nextBlock.parent, 101 + }, 102 + { type: "start" }, 103 + ); 104 + } 105 + } 106 + } 107 + 108 + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 + await Promise.all( 110 + entities.map((entity) => 111 + rep?.mutate.removeBlock({ 112 + blockEntity: entity, 113 + }), 114 + ), 115 + ); 116 + }
+37
src/utils/focusElement.ts
··· 1 + import { isIOS } from "src/utils/isDevice"; 2 + 3 + export const focusElement = ( 4 + el?: HTMLInputElement | HTMLTextAreaElement | null, 5 + ) => { 6 + if (!isIOS()) { 7 + el?.focus(); 8 + return; 9 + } 10 + 11 + let fakeInput = document.createElement("input"); 12 + fakeInput.setAttribute("type", "text"); 13 + fakeInput.style.position = "fixed"; 14 + fakeInput.style.height = "0px"; 15 + fakeInput.style.width = "0px"; 16 + fakeInput.style.fontSize = "16px"; // disable auto zoom 17 + document.body.appendChild(fakeInput); 18 + fakeInput.focus(); 19 + setTimeout(() => { 20 + if (!el) return; 21 + el.style.transform = "translateY(-2000px)"; 22 + el?.focus(); 23 + fakeInput.remove(); 24 + el.value = " "; 25 + el.setSelectionRange(1, 1); 26 + requestAnimationFrame(() => { 27 + if (el) { 28 + el.style.transform = ""; 29 + } 30 + }); 31 + setTimeout(() => { 32 + if (!el) return; 33 + el.value = ""; 34 + el.setSelectionRange(0, 0); 35 + }, 50); 36 + }, 20); 37 + };
+73
src/utils/focusPage.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export async function focusPage( 10 + pageID: string, 11 + rep: Replicache<ReplicacheMutators>, 12 + focusFirstBlock?: "focusFirstBlock", 13 + ) { 14 + // if this page is already focused, 15 + let focusedBlock = useUIState.getState().focusedEntity; 16 + // else set this page as focused 17 + useUIState.setState(() => ({ 18 + focusedEntity: { 19 + entityType: "page", 20 + entityID: pageID, 21 + }, 22 + })); 23 + 24 + setTimeout(async () => { 25 + //scroll to page 26 + 27 + scrollIntoViewIfNeeded( 28 + document.getElementById(elementId.page(pageID).container), 29 + false, 30 + "smooth", 31 + ); 32 + 33 + // if we asked that the function focus the first block, focus the first block 34 + if (focusFirstBlock === "focusFirstBlock") { 35 + let firstBlock = await rep.query(async (tx) => { 36 + let type = await scanIndex(tx).eav(pageID, "page/type"); 37 + let blocks = await scanIndex(tx).eav( 38 + pageID, 39 + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 40 + ); 41 + 42 + let firstBlock = blocks[0]; 43 + 44 + if (!firstBlock) { 45 + return null; 46 + } 47 + 48 + let blockType = ( 49 + await tx 50 + .scan< 51 + Fact<"block/type"> 52 + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 53 + .toArray() 54 + )[0]; 55 + 56 + if (!blockType) return null; 57 + 58 + return { 59 + value: firstBlock.data.value, 60 + type: blockType.data.value, 61 + parent: firstBlock.entity, 62 + position: firstBlock.data.position, 63 + }; 64 + }); 65 + 66 + if (firstBlock) { 67 + setTimeout(() => { 68 + focusBlock(firstBlock, { type: "start" }); 69 + }, 500); 70 + } 71 + } 72 + }, 50); 73 + }
+6 -1
src/utils/getMicroLinkOgImage.ts
··· 17 17 hostname = "leaflet.pub"; 18 18 } 19 19 let full_path = `${protocol}://${hostname}${path}`; 20 - return getWebpageImage(full_path, options); 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 21 24 } 22 25 23 26 export async function getWebpageImage( 24 27 url: string, 25 28 options?: { 29 + setJavaScriptEnabled?: boolean; 26 30 width?: number; 27 31 height?: number; 28 32 deviceScaleFactor?: number; ··· 39 43 }, 40 44 body: JSON.stringify({ 41 45 url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 42 47 scrollPage: true, 43 48 addStyleTag: [ 44 49 {
+41
src/utils/yjsFragmentToString.ts
··· 1 + import { XmlElement, XmlText, XmlHook } from "yjs"; 2 + 3 + export type Delta = { 4 + insert: string; 5 + attributes?: { 6 + strong?: {}; 7 + code?: {}; 8 + em?: {}; 9 + underline?: {}; 10 + strikethrough?: {}; 11 + highlight?: { color: string }; 12 + link?: { href: string }; 13 + }; 14 + }; 15 + 16 + export function YJSFragmentToString( 17 + node: XmlElement | XmlText | XmlHook, 18 + ): string { 19 + if (node.constructor === XmlElement) { 20 + // Handle hard_break nodes specially 21 + if (node.nodeName === "hard_break") { 22 + return "\n"; 23 + } 24 + // Handle inline mention nodes 25 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 26 + return node.getAttribute("text") || ""; 27 + } 28 + return node 29 + .toArray() 30 + .map((f) => YJSFragmentToString(f)) 31 + .join(""); 32 + } 33 + if (node.constructor === XmlText) { 34 + return (node.toDelta() as Delta[]) 35 + .map((d) => { 36 + return d.insert; 37 + }) 38 + .join(""); 39 + } 40 + return ""; 41 + }