a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feature/reader (#224)

* started the reader

* pulling in the right data, styling adjustments

* changed styles for pub listing, added stuff to SubscriptionContent

* added empty states and fixed some issues

* moved discover from reader header to nav sidebar

* added comment and quote counts to post listing, cleaned up some other stuff

* fixed little alignment issue thing

* mostly removing console logs

* some pageheader color and spacing fixes as well as getting share button to work in reader

* shenanigans making a silly separator show sometimes and not others

* thing i did that seemed smart was actually dumb, reversing lol

* wip start to paginate followers

* add infinite scrolling feed

* make subscriptions infinite scrolling as well

* use shared idresolver instance

* use compound cursors and fix subscriptions pagination

* fix getting last updated date for pubs

* make handles consistent

* store tab state in search params

* fix compound cursor logic

* remove unread state from reader and text from discover

* make external links a's

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by

Jared Pereira
celine
and committed by
GitHub
973cbd6f de514143

+956 -120
+1 -1
actions/getIdentityData.ts
··· 16 16 identities( 17 17 *, 18 18 bsky_profiles(*), 19 - subscribers_to_publications(*), 19 + publication_subscriptions(*), 20 20 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 21 21 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 22 22 permission_token_on_homepage(
-1
app/api/oauth/[route]/route.ts
··· 121 121 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 122 122 if (action?.action === "subscribe") { 123 123 let result = await subscribeToPublication(action.publication); 124 - console.log(result); 125 124 if (result.hasFeed === false) 126 125 url.searchParams.set("showSubscribeSuccess", "true"); 127 126 }
+28 -26
app/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 + import { PublicationSubscription } from "app/reader/getSubscriptions"; 4 + import { PubIcon } from "components/ActionBar/Publications"; 5 + import { Separator } from "components/Layout"; 3 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 4 7 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 7 10 import { timeAgo } from "src/utils/timeAgo"; 8 11 import { Json } from "supabase/database.types"; 9 12 10 - export const PubListing = (props: { 11 - record: Json; 12 - uri: string; 13 - documents_in_publications: { 14 - indexed_at: string; 15 - documents: { data: Json } | null; 16 - }[]; 17 - }) => { 13 + export const PubListing = ( 14 + props: PublicationSubscription & { 15 + resizeHeight?: boolean; 16 + }, 17 + ) => { 18 18 let record = props.record as PubLeafletPublication.Record; 19 19 let theme = usePubTheme(record); 20 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref ··· 43 43 hover:outline-accent-contrast hover:border-accent-contrast`} 44 44 > 45 45 <div 46 - style={{ 47 - backgroundRepeat: "no-repeat", 48 - backgroundPosition: "center", 49 - backgroundSize: "cover", 50 - backgroundImage: record?.icon 51 - ? `url(${blobRefToSrc(record.icon?.ref, new AtUri(props.uri).host)})` 52 - : undefined, 53 - }} 54 - className={`w-6 h-6 rounded-full bg-accent-1 text-accent-2 flex place-content-center leading-snug font-bold text-center shrink-0 ${record.theme?.showPageBackground ? "mt-[6px]" : "mt-0.5"}`} 55 - > 56 - {!record?.icon ? record.name.slice(0, 1).toLocaleUpperCase() : null} 57 - </div> 58 - <div 59 - className={`flex w-full flex-col ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] px-2 py-1 rounded-lg" : ""}`} 46 + className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 60 47 > 61 - <h3>{record.name}</h3> 62 - <p className="text-secondary">{record.description}</p> 63 - <div className="flex gap-1 items-center text-sm text-tertiary pt-2 "> 48 + <div className="mx-auto pb-1"> 49 + <PubIcon record={record} uri={props.uri} large /> 50 + </div> 51 + 52 + <h4 className="truncate shrink-0 ">{record.name}</h4> 53 + {record.description && ( 54 + <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 55 + {record.description} 56 + </p> 57 + )} 58 + <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 59 + <div className="flex flex-row gap-2 items-center"> 60 + {props.authorProfile?.handle} 61 + </div> 64 62 <p> 65 - Updated {timeAgo(props.documents_in_publications[0].indexed_at)} 63 + Updated{" "} 64 + {timeAgo( 65 + props.documents_in_publications?.[0]?.documents?.indexed_at || 66 + "", 67 + )} 66 68 </p> 67 69 </div> 68 70 </div>
+1 -1
app/discover/SortedPublicationList.tsx
··· 39 39 ); 40 40 return bDate.getTime() - aDate.getTime(); 41 41 }) 42 - .map((pub) => <PubListing key={pub.uri} {...pub} />)} 42 + .map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)} 43 43 </div> 44 44 </div> 45 45 );
+1 -5
app/discover/page.tsx
··· 40 40 <div className="w-full h-full mx-auto bg-[#FDFCFA]"> 41 41 <DashboardLayout 42 42 id="discover" 43 - hasBackgroundImage={false} 43 + cardBorderHidden={false} 44 44 currentPage="discover" 45 45 defaultTab="default" 46 46 actions={null} ··· 62 62 <div className="max-w-prose mx-auto w-full"> 63 63 <div className="discoverHeader flex flex-col items-center text-center pt-2 px-4"> 64 64 <h1>Discover</h1> 65 - <p className="text-lg text-secondary italic mb-2"> 66 - Explore publications on Leaflet ✨ Or{" "} 67 - <Link href="/lish/createPub">make your own</Link>! 68 - </p> 69 65 </div> 70 66 <SortedPublicationList publications={publications} order={props.order} /> 71 67 </div>
+6 -6
app/home/HomeEmpty/HomeEmpty.tsx
··· 66 66 export const PublicationBanner = (props: { small?: boolean }) => { 67 67 return ( 68 68 <div 69 - className={`accent-container flex sm:py-2 gap-4 items-center ${props.small ? "items-start p-2 text-sm font-normal" : "items-center p-4"}`} 69 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 70 70 > 71 71 {props.small ? ( 72 - <PublishSmall className="shrink-0" /> 72 + <PublishSmall className="shrink-0 text-accent-contrast" /> 73 73 ) : ( 74 74 <div className="w-[64px] mx-auto"> 75 75 <PubListEmptyIllo /> 76 76 </div> 77 77 )} 78 - <div className="grow"> 78 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 79 79 <Link href={"/lish/createPub"} className="font-bold"> 80 80 Start a Publication 81 81 </Link>{" "} ··· 88 88 export const DiscoverBanner = (props: { small?: boolean }) => { 89 89 return ( 90 90 <div 91 - className={`accent-container flex sm:py-2 gap-4 items-center ${props.small ? "items-start p-2 text-sm font-normal" : "items-center p-4"}`} 91 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 92 92 > 93 93 {props.small ? ( 94 - <DiscoverSmall className="shrink-0" /> 94 + <DiscoverSmall className="shrink-0 text-accent-contrast" /> 95 95 ) : ( 96 96 <div className="w-[64px] mx-auto"> 97 97 <DiscoverIllo /> 98 98 </div> 99 99 )} 100 - <div className="grow"> 100 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 101 101 <Link href={"/discover"} className="font-bold"> 102 102 Explore Publications 103 103 </Link>{" "}
+1 -2
app/home/HomeLayout.tsx
··· 95 95 return ( 96 96 <DashboardLayout 97 97 id="home" 98 - hasBackgroundImage={hasBackgroundImage} 98 + cardBorderHidden={cardBorderHidden} 99 99 currentPage="home" 100 100 defaultTab="home" 101 101 actions={<Actions />} ··· 192 192 {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 193 193 0 && <PublicationBanner small />} 194 194 <DiscoverBanner small /> 195 - <div className="spacer h-8 w-full bg-transparent shrink-0 " /> 196 195 </> 197 196 ); 198 197 }
+18 -14
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 44 44 autoFocus?: boolean; 45 45 }) { 46 46 let mountRef = useRef<HTMLPreElement | null>(null); 47 - let { commentBox: { quote } } = useInteractionState(props.doc_uri); 47 + let { 48 + commentBox: { quote }, 49 + } = useInteractionState(props.doc_uri); 48 50 let [loading, setLoading] = useState(false); 49 - 51 + 50 52 const handleSubmit = async () => { 51 53 if (loading || !view.current) return; 52 - 54 + 53 55 setLoading(true); 54 56 let currentState = view.current.state; 55 57 let [plaintext, facets] = docToFacetedText(currentState.doc); ··· 108 110 "Mod-z": undo, 109 111 "Mod-y": redo, 110 112 "Shift-Mod-z": redo, 111 - "Ctrl-Enter": () => { handleSubmit(); return true; }, 112 - "Meta-Enter": () => { handleSubmit(); return true; }, 113 + "Ctrl-Enter": () => { 114 + handleSubmit(); 115 + return true; 116 + }, 117 + "Meta-Enter": () => { 118 + handleSubmit(); 119 + return true; 120 + }, 113 121 }), 114 122 keymap(baseKeymap), 115 123 autolink({ ··· 158 166 let xml = new DOMParser().parseFromString(html, "text/html"); 159 167 text = xml.textContent || ""; 160 168 } 161 - console.log("URL: " + window.location.toString()); 162 - console.log("TEXT: " + text, text?.includes(QUOTE_PARAM)); 163 169 if ( 164 170 text?.includes(QUOTE_PARAM) && 165 171 text.includes(window.location.toString()) ··· 190 196 } 191 197 }, 192 198 dispatchTransaction(tr) { 193 - console.log("dispatching?"); 194 199 let newState = this.state.apply(tr); 195 200 setEditorState(newState); 196 201 view.current?.updateState(newState); ··· 207 212 view.current = null; 208 213 }; 209 214 }, []); 210 - 215 + 211 216 return ( 212 217 <div className=" flex flex-col"> 213 218 {quote && ( ··· 216 221 <button 217 222 className="text-border absolute -top-3 right-1 bg-bg-page p-1 rounded-full" 218 223 onClick={() => 219 - setInteractionState(props.doc_uri, { commentBox: { quote: null } }) 224 + setInteractionState(props.doc_uri, { 225 + commentBox: { quote: null }, 226 + }) 220 227 } 221 228 > 222 229 <CloseFillTiny /> ··· 251 258 view={view} 252 259 /> 253 260 </div> 254 - <ButtonPrimary 255 - compact 256 - onClick={handleSubmit} 257 - > 261 + <ButtonPrimary compact onClick={handleSubmit}> 258 262 {loading ? <DotLoader /> : <ShareSmall />} 259 263 </ButtonPrimary> 260 264 </div>
-1
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 20 20 let highlights = activeHighlight ? [activeHighlight] : []; 21 21 let decodedQuote = quote ? decodeQuotePosition(quote as string) : null; 22 22 if (decodedQuote) highlights.push(decodedQuote); 23 - console.log(highlights); 24 23 return highlights 25 24 .map((quotePosition) => { 26 25 if (!quotePosition) return null;
+1 -1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 39 return ( 40 40 <DashboardLayout 41 41 id={publication.uri} 42 - hasBackgroundImage={!!record?.theme?.backgroundImage} 42 + cardBorderHidden={!!record.theme?.showPageBackground} 43 43 defaultTab="Drafts" 44 44 tabs={{ 45 45 Drafts: {
-1
app/lish/[did]/[publication]/dashboard/page.tsx
··· 75 75 </PublicationSWRDataProvider> 76 76 ); 77 77 } catch (e) { 78 - console.log(e); 79 78 return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 80 79 } 81 80 }
-2
app/lish/[did]/[publication]/icon.ts
··· 46 46 47 47 let identity = await idResolver.did.resolve(did); 48 48 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 49 - console.log(identity); 50 49 if (!service) return null; 51 50 let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 52 51 const response = await fetch( ··· 56 55 let resizedImage = await sharp(await blob.arrayBuffer()) 57 56 .resize({ width: 32, height: 32 }) 58 57 .toBuffer(); 59 - console.log("fetched favicon!"); 60 58 return new Response(new Uint8Array(resizedImage), { 61 59 headers: { 62 60 "Content-Type": "image/png",
-1
app/lish/subscribeToPublication.ts
··· 74 74 } 75 75 76 76 export async function unsubscribeToPublication(publication: string) { 77 - console.log("calling unsubscribe!"); 78 77 const oauthClient = await createOauthClient(); 79 78 let identity = await getIdentityData(); 80 79 if (!identity || !identity.atp_did) return;
+279
app/reader/ReaderContent.tsx
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { PubIcon } from "components/ActionBar/Publications"; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 + import { Separator } from "components/Layout"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 + import { useSmoker } from "components/Toast"; 14 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 + import { Json } from "supabase/database.types"; 17 + import type { Cursor, Post } from "./getReaderFeed"; 18 + import useSWRInfinite from "swr/infinite"; 19 + import { getReaderFeed } from "./getReaderFeed"; 20 + import { useEffect, useRef } from "react"; 21 + import { useRouter } from "next/navigation"; 22 + 23 + export const ReaderContent = (props: { 24 + root_entity: string; 25 + posts: Post[]; 26 + nextCursor: Cursor | null; 27 + }) => { 28 + const getKey = ( 29 + pageIndex: number, 30 + previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 31 + ) => { 32 + // Reached the end 33 + if (previousPageData && !previousPageData.nextCursor) return null; 34 + 35 + // First page, we don't have previousPageData 36 + if (pageIndex === 0) return ["reader-feed", null] as const; 37 + 38 + // Add the cursor to the key 39 + return ["reader-feed", previousPageData?.nextCursor] as const; 40 + }; 41 + 42 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 43 + getKey, 44 + ([_, cursor]) => getReaderFeed(cursor), 45 + { 46 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 47 + revalidateFirstPage: false, 48 + }, 49 + ); 50 + 51 + const loadMoreRef = useRef<HTMLDivElement>(null); 52 + 53 + // Set up intersection observer to load more when trigger element is visible 54 + useEffect(() => { 55 + const observer = new IntersectionObserver( 56 + (entries) => { 57 + if (entries[0].isIntersecting && !isValidating) { 58 + const hasMore = data && data[data.length - 1]?.nextCursor; 59 + if (hasMore) { 60 + setSize(size + 1); 61 + } 62 + } 63 + }, 64 + { threshold: 0.1 }, 65 + ); 66 + 67 + if (loadMoreRef.current) { 68 + observer.observe(loadMoreRef.current); 69 + } 70 + 71 + return () => observer.disconnect(); 72 + }, [data, size, setSize, isValidating]); 73 + 74 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 75 + 76 + if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 77 + 78 + return ( 79 + <div className="flex flex-col gap-3 relative"> 80 + {allPosts.map((p) => ( 81 + <Post {...p} key={p.documents.uri} /> 82 + ))} 83 + {/* Trigger element for loading more posts */} 84 + <div 85 + ref={loadMoreRef} 86 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 87 + aria-hidden="true" 88 + /> 89 + {isValidating && ( 90 + <div className="text-center text-tertiary py-4"> 91 + Loading more posts... 92 + </div> 93 + )} 94 + </div> 95 + ); 96 + }; 97 + 98 + const Post = (props: Post) => { 99 + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 100 + 101 + let postRecord = props.documents.data as PubLeafletDocument.Record; 102 + let postUri = new AtUri(props.documents.uri); 103 + 104 + let theme = usePubTheme(pubRecord); 105 + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 106 + ? blobRefToSrc( 107 + pubRecord?.theme?.backgroundImage?.image?.ref, 108 + new AtUri(props.publication.uri).host, 109 + ) 110 + : null; 111 + 112 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 113 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 114 + 115 + let showPageBackground = pubRecord.theme?.showPageBackground; 116 + 117 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 118 + let comments = 119 + pubRecord.preferences?.showComments === false 120 + ? 0 121 + : props.documents.comments_on_documents?.[0]?.count || 0; 122 + 123 + return ( 124 + <BaseThemeProvider {...theme} local> 125 + <div 126 + style={{ 127 + backgroundImage: `url(${backgroundImage})`, 128 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 129 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 130 + }} 131 + className={`no-underline! flex flex-row gap-2 w-full relative 132 + bg-bg-leaflet 133 + border border-border-light rounded-lg 134 + sm:p-2 p-2 selected-outline 135 + hover:outline-accent-contrast hover:border-accent-contrast 136 + `} 137 + > 138 + <a 139 + className="h-full w-full absolute top-0 left-0" 140 + href={`${props.publication.href}/${postUri.rkey}`} 141 + /> 142 + <div 143 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 144 + style={{ 145 + backgroundColor: showPageBackground 146 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 147 + : "transparent", 148 + }} 149 + > 150 + <h3 className="text-primary truncate">{postRecord.title}</h3> 151 + 152 + <p className="text-secondary">{postRecord.description}</p> 153 + <div className="flex justify-between items-end"> 154 + <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-center justify-start pt-1 md:pt-3"> 155 + <PubInfo 156 + href={props.publication.href} 157 + pubRecord={pubRecord} 158 + uri={props.publication.uri} 159 + /> 160 + <Separator classname="h-4 !min-h-0 md:block hidden" /> 161 + <PostInfo 162 + author={props.author || ""} 163 + publishedAt={postRecord.publishedAt} 164 + /> 165 + </div> 166 + 167 + <PostInterations 168 + postUrl={`${props.publication.href}/${postUri.rkey}`} 169 + quotesCount={quotes} 170 + commentsCount={comments} 171 + showComments={pubRecord.preferences?.showComments} 172 + /> 173 + </div> 174 + </div> 175 + </div> 176 + </BaseThemeProvider> 177 + ); 178 + }; 179 + 180 + const PubInfo = (props: { 181 + href: string; 182 + pubRecord: PubLeafletPublication.Record; 183 + uri: string; 184 + }) => { 185 + return ( 186 + <a 187 + href={props.href} 188 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 189 + > 190 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 191 + {props.pubRecord.name} 192 + </a> 193 + ); 194 + }; 195 + 196 + const PostInfo = (props: { 197 + author: string; 198 + publishedAt: string | undefined; 199 + }) => { 200 + return ( 201 + <div className="flex gap-2 grow items-center shrink-0"> 202 + {props.author} 203 + {props.publishedAt && ( 204 + <> 205 + <Separator classname="h-4 !min-h-0" /> 206 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 207 + year: "numeric", 208 + month: "short", 209 + day: "numeric", 210 + })}{" "} 211 + </> 212 + )} 213 + </div> 214 + ); 215 + }; 216 + 217 + const PostInterations = (props: { 218 + quotesCount: number; 219 + commentsCount: number; 220 + postUrl: string; 221 + showComments: boolean | undefined; 222 + }) => { 223 + let smoker = useSmoker(); 224 + let interactionsAvailable = 225 + props.quotesCount > 0 || 226 + (props.showComments !== false && props.commentsCount > 0); 227 + 228 + return ( 229 + <div className={`flex gap-2 text-tertiary text-sm items-center`}> 230 + {props.quotesCount === 0 ? null : ( 231 + <div className={`flex gap-1 items-center `}> 232 + <span className="sr-only">Post quotes</span> 233 + <QuoteTiny aria-hidden /> {props.quotesCount} 234 + </div> 235 + )} 236 + {props.showComments === false || props.commentsCount === 0 ? null : ( 237 + <div className={`flex gap-1 items-center`}> 238 + <span className="sr-only">Post comments</span> 239 + <CommentTiny aria-hidden /> {props.commentsCount} 240 + </div> 241 + )} 242 + {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 243 + <button 244 + id={`copy-post-link-${props.postUrl}`} 245 + className="flex gap-1 items-center hover:font-bold relative" 246 + onClick={(e) => { 247 + e.stopPropagation(); 248 + e.preventDefault(); 249 + let mouseX = e.clientX; 250 + let mouseY = e.clientY; 251 + 252 + if (!props.postUrl) return; 253 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 254 + 255 + smoker({ 256 + text: <strong>Copied Link!</strong>, 257 + position: { 258 + y: mouseY, 259 + x: mouseX, 260 + }, 261 + }); 262 + }} 263 + > 264 + Share 265 + </button> 266 + </div> 267 + ); 268 + }; 269 + const ReaderEmpty = () => { 270 + return ( 271 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary"> 272 + Nothing to read yet… <br /> 273 + Subscribe to publications and find their posts here! 274 + <ButtonPrimary className="mx-auto place-self-center"> 275 + <DiscoverSmall /> Discover Publications 276 + </ButtonPrimary> 277 + </div> 278 + ); 279 + };
+102
app/reader/SubscriptionsContent.tsx
··· 1 + "use client"; 2 + import { PubListing } from "app/discover/PubListing"; 3 + import { ButtonPrimary } from "components/Buttons"; 4 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 + import { Json } from "supabase/database.types"; 6 + import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + import { Cursor } from "./getReaderFeed"; 10 + 11 + export const SubscriptionsContent = (props: { 12 + publications: PublicationSubscription[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + subscriptions: PublicationSubscription[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["subscriptions", null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["subscriptions", previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, cursor]) => getSubscriptions(cursor), 35 + { 36 + fallbackData: [ 37 + { subscriptions: props.publications, nextCursor: props.nextCursor }, 38 + ], 39 + revalidateFirstPage: false, 40 + }, 41 + ); 42 + 43 + const loadMoreRef = useRef<HTMLDivElement>(null); 44 + 45 + // Set up intersection observer to load more when trigger element is visible 46 + useEffect(() => { 47 + const observer = new IntersectionObserver( 48 + (entries) => { 49 + if (entries[0].isIntersecting && !isValidating) { 50 + const hasMore = data && data[data.length - 1]?.nextCursor; 51 + if (hasMore) { 52 + setSize(size + 1); 53 + } 54 + } 55 + }, 56 + { threshold: 0.1 }, 57 + ); 58 + 59 + if (loadMoreRef.current) { 60 + observer.observe(loadMoreRef.current); 61 + } 62 + 63 + return () => observer.disconnect(); 64 + }, [data, size, setSize, isValidating]); 65 + 66 + const allPublications = data 67 + ? data.flatMap((page) => page.subscriptions) 68 + : []; 69 + 70 + if (allPublications.length === 0 && !isValidating) 71 + return <SubscriptionsEmpty />; 72 + 73 + return ( 74 + <div className="relative"> 75 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 76 + {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 77 + </div> 78 + {/* Trigger element for loading more subscriptions */} 79 + <div 80 + ref={loadMoreRef} 81 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 82 + aria-hidden="true" 83 + /> 84 + {isValidating && ( 85 + <div className="text-center text-tertiary py-4"> 86 + Loading more subscriptions... 87 + </div> 88 + )} 89 + </div> 90 + ); 91 + }; 92 + 93 + const SubscriptionsEmpty = () => { 94 + return ( 95 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary"> 96 + You haven't subscribed to any publications yet! 97 + <ButtonPrimary className="mx-auto place-self-center"> 98 + <DiscoverSmall /> Discover Publications 99 + </ButtonPrimary> 100 + </div> 101 + ); 102 + };
+106
app/reader/getReaderFeed.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { IdResolver } from "@atproto/identity"; 7 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 + import Client from "ioredis"; 9 + import { AtUri } from "@atproto/api"; 10 + import { Json } from "supabase/database.types"; 11 + import { idResolver } from "./idResolver"; 12 + 13 + export type Cursor = { 14 + timestamp: string; 15 + uri: string; 16 + }; 17 + 18 + export async function getReaderFeed( 19 + cursor?: Cursor | null, 20 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 + let auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; 23 + let query = supabaseServerClient 24 + .from("documents") 25 + .select( 26 + `*, 27 + comments_on_documents(count), 28 + document_mentions_in_bsky(count), 29 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 30 + ) 31 + .eq( 32 + "documents_in_publications.publications.publication_subscriptions.identity", 33 + auth_res.atp_did, 34 + ) 35 + .order("indexed_at", { ascending: false }) 36 + .order("uri", { ascending: false }) 37 + .limit(25); 38 + if (cursor) { 39 + query = query.or( 40 + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 41 + ); 42 + } 43 + let { data: feed, error } = await query; 44 + 45 + let posts = await Promise.all( 46 + feed?.map(async (post) => { 47 + let pub = post.documents_in_publications[0].publications!; 48 + let uri = new AtUri(post.uri); 49 + let handle = await idResolver.did.resolve(uri.host); 50 + let p: Post = { 51 + publication: { 52 + href: getPublicationURL(pub), 53 + pubRecord: pub?.record || null, 54 + uri: pub?.uri || "", 55 + }, 56 + author: handle?.alsoKnownAs?.[0] 57 + ? `@${handle.alsoKnownAs[0].slice(5)}` 58 + : null, 59 + documents: { 60 + comments_on_documents: post.comments_on_documents, 61 + document_mentions_in_bsky: post.document_mentions_in_bsky, 62 + data: post.data, 63 + uri: post.uri, 64 + indexed_at: post.indexed_at, 65 + }, 66 + }; 67 + return p; 68 + }) || [], 69 + ); 70 + const nextCursor = 71 + posts.length > 0 72 + ? { 73 + timestamp: posts[posts.length - 1].documents.indexed_at, 74 + uri: posts[posts.length - 1].documents.uri, 75 + } 76 + : null; 77 + 78 + return { 79 + posts, 80 + nextCursor, 81 + }; 82 + } 83 + 84 + export type Post = { 85 + author: string | null; 86 + publication: { 87 + href: string; 88 + pubRecord: Json; 89 + uri: string; 90 + }; 91 + documents: { 92 + data: Json; 93 + uri: string; 94 + indexed_at: string; 95 + comments_on_documents: 96 + | { 97 + count: number; 98 + }[] 99 + | undefined; 100 + document_mentions_in_bsky: 101 + | { 102 + count: number; 103 + }[] 104 + | undefined; 105 + }; 106 + };
+70
app/reader/getSubscriptions.ts
··· 1 + "use server"; 2 + 3 + import { AtpAgent } from "@atproto/api"; 4 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { Json } from "supabase/database.types"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { idResolver } from "./idResolver"; 9 + import { Cursor } from "./getReaderFeed"; 10 + 11 + export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 + nextCursor: null | Cursor; 13 + subscriptions: PublicationSubscription[]; 14 + }> { 15 + let auth_res = await getIdentityData(); 16 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 + let query = supabaseServerClient 18 + .from("publication_subscriptions") 19 + .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 20 + .order(`created_at`, { ascending: false }) 21 + .order(`uri`, { ascending: false }) 22 + .order("indexed_at", { 23 + ascending: false, 24 + referencedTable: "publications.documents_in_publications", 25 + }) 26 + .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 + .limit(25) 28 + .eq("identity", auth_res.atp_did); 29 + 30 + if (cursor) { 31 + query = query.or( 32 + `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 33 + ); 34 + } 35 + let { data: pubs, error } = await query; 36 + 37 + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 38 + pubs?.map(async (pub) => { 39 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 40 + return { 41 + ...pub.publications!, 42 + authorProfile: id?.alsoKnownAs?.[0] 43 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 44 + : undefined, 45 + }; 46 + }) || [], 47 + ); 48 + 49 + const nextCursor = 50 + pubs && pubs.length > 0 51 + ? { 52 + timestamp: pubs[pubs.length - 1].created_at, 53 + uri: pubs[pubs.length - 1].uri, 54 + } 55 + : null; 56 + 57 + return { 58 + subscriptions: hydratedSubscriptions, 59 + nextCursor, 60 + }; 61 + } 62 + 63 + export type PublicationSubscription = { 64 + authorProfile?: { handle: string }; 65 + record: Json; 66 + uri: string; 67 + documents_in_publications: { 68 + documents: { data?: Json; indexed_at: string } | null; 69 + }[]; 70 + };
+78
app/reader/idResolver.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 + import Client from "ioredis"; 4 + // Create Redis client for DID caching 5 + let redisClient: Client | null = null; 6 + if (process.env.REDIS_URL) { 7 + redisClient = new Client(process.env.REDIS_URL); 8 + } 9 + 10 + // Redis-based DID cache implementation 11 + class RedisDidCache implements DidCache { 12 + private staleTTL: number; 13 + private maxTTL: number; 14 + 15 + constructor( 16 + private client: Client, 17 + staleTTL = 60 * 60, // 1 hour 18 + maxTTL = 60 * 60 * 24, // 24 hours 19 + ) { 20 + this.staleTTL = staleTTL; 21 + this.maxTTL = maxTTL; 22 + } 23 + 24 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 25 + const cacheVal = { 26 + doc, 27 + updatedAt: Date.now(), 28 + }; 29 + await this.client.setex( 30 + `did:${did}`, 31 + this.maxTTL, 32 + JSON.stringify(cacheVal), 33 + ); 34 + } 35 + 36 + async checkCache(did: string): Promise<CacheResult | null> { 37 + const cached = await this.client.get(`did:${did}`); 38 + if (!cached) return null; 39 + 40 + const { doc, updatedAt } = JSON.parse(cached); 41 + const now = Date.now(); 42 + const age = now - updatedAt; 43 + 44 + return { 45 + did, 46 + doc, 47 + updatedAt, 48 + stale: age > this.staleTTL * 1000, 49 + expired: age > this.maxTTL * 1000, 50 + }; 51 + } 52 + 53 + async refreshCache( 54 + did: string, 55 + getDoc: () => Promise<DidDocument | null>, 56 + ): Promise<void> { 57 + const doc = await getDoc(); 58 + if (doc) { 59 + await this.cacheDid(did, doc); 60 + } 61 + } 62 + 63 + async clearEntry(did: string): Promise<void> { 64 + await this.client.del(`did:${did}`); 65 + } 66 + 67 + async clear(): Promise<void> { 68 + const keys = await this.client.keys("did:*"); 69 + if (keys.length > 0) { 70 + await this.client.del(...keys); 71 + } 72 + } 73 + } 74 + 75 + // Create IdResolver with Redis-based DID cache 76 + export const idResolver = new IdResolver({ 77 + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 + });
+91
app/reader/page.tsx
··· 1 + import { cookies } from "next/headers"; 2 + import { Fact, ReplicacheProvider } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { 5 + ThemeBackgroundProvider, 6 + ThemeProvider, 7 + } from "components/ThemeManager/ThemeProvider"; 8 + import { EntitySetProvider } from "components/EntitySetProvider"; 9 + import { getIdentityData } from "actions/getIdentityData"; 10 + import { supabaseServerClient } from "supabase/serverClient"; 11 + 12 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 13 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 14 + import { ReaderContent } from "./ReaderContent"; 15 + import { SubscriptionsContent } from "./SubscriptionsContent"; 16 + import { getReaderFeed } from "./getReaderFeed"; 17 + import { getSubscriptions } from "./getSubscriptions"; 18 + 19 + export default async function Reader(props: {}) { 20 + let cookieStore = await cookies(); 21 + let auth_res = await getIdentityData(); 22 + let identity: string | undefined; 23 + let permission_token = auth_res?.home_leaflet; 24 + if (!permission_token) 25 + return ( 26 + <NotFoundLayout> 27 + <p className="font-bold">Sorry, we can't find this page!</p> 28 + <p> 29 + This may be a glitch on our end. If the issue persists please{" "} 30 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 31 + </p> 32 + </NotFoundLayout> 33 + ); 34 + let [homeLeafletFacts] = await Promise.all([ 35 + supabaseServerClient.rpc("get_facts", { 36 + root: permission_token.root_entity, 37 + }), 38 + ]); 39 + let initialFacts = 40 + (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 41 + let root_entity = permission_token.root_entity; 42 + 43 + if (!auth_res?.atp_did) return; 44 + let posts = await getReaderFeed(); 45 + let publications = await getSubscriptions(); 46 + return ( 47 + <ReplicacheProvider 48 + rootEntity={root_entity} 49 + token={permission_token} 50 + name={root_entity} 51 + initialFacts={initialFacts} 52 + > 53 + <EntitySetProvider 54 + set={permission_token.permission_token_rights[0].entity_set} 55 + > 56 + <ThemeProvider entityID={root_entity}> 57 + <ThemeBackgroundProvider entityID={root_entity}> 58 + <DashboardLayout 59 + id="reader" 60 + cardBorderHidden={false} 61 + currentPage="reader" 62 + defaultTab="Read" 63 + actions={null} 64 + tabs={{ 65 + Read: { 66 + controls: null, 67 + content: ( 68 + <ReaderContent 69 + root_entity={root_entity} 70 + nextCursor={posts.nextCursor} 71 + posts={posts.posts} 72 + /> 73 + ), 74 + }, 75 + Subscriptions: { 76 + controls: null, 77 + content: ( 78 + <SubscriptionsContent 79 + publications={publications.subscriptions} 80 + nextCursor={publications.nextCursor} 81 + /> 82 + ), 83 + }, 84 + }} 85 + /> 86 + </ThemeBackgroundProvider> 87 + </ThemeProvider> 88 + </EntitySetProvider> 89 + </ReplicacheProvider> 90 + ); 91 + }
-4
appview/bumpCursor.js
··· 7 7 } else { 8 8 let newCursor = (cursor + 300 * 60 * 60 * 12).toString(); 9 9 writeFileSync(cursorFile, (cursor + 300 * 60 * 60 * 12).toString()); 10 - console.log(` 11 - old cursor: ${cursor} 12 - new cursor: ${newCursor} 13 - `); 14 10 }
+29 -17
components/ActionBar/Navigation.tsx
··· 7 7 import { PublicationButtons } from "./Publications"; 8 8 import { Popover } from "components/Popover"; 9 9 import { MenuSmall } from "components/Icons/MenuSmall"; 10 + import { 11 + ReaderReadSmall, 12 + ReaderUnreadSmall, 13 + } from "components/Icons/ReaderSmall"; 10 14 11 15 export type navPages = "home" | "reader" | "pub" | "discover"; 12 16 ··· 14 18 currentPage: navPages; 15 19 publication?: string; 16 20 }) => { 17 - let unreadNotifications = true; 18 - 19 21 return ( 20 22 <div className="flex flex-col gap-4"> 21 23 <Sidebar alwaysOpen> ··· 89 91 return ( 90 92 <> 91 93 <HomeButton current={props.currentPage === "home"} /> 92 - <ReaderButton current={props.currentPage === "discover"} /> 94 + <ReaderButton 95 + current={props.currentPage === "reader"} 96 + subs={identity?.publication_subscriptions?.length !== 0} 97 + /> 98 + <DiscoverButton current={props.currentPage === "discover"} /> 99 + 93 100 <hr className="border-border-light my-1" /> 94 101 <PublicationButtons currentPubUri={thisPublication?.uri} /> 95 102 </> ··· 109 116 ); 110 117 }; 111 118 112 - const ReaderButton = (props: { current?: boolean }) => { 113 - // let readerUnreads = true; 114 - // let subs = false; 119 + const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 120 + let readerUnreads = false; 115 121 116 - // if (subs) 117 - // return ( 118 - // <ActionButton 119 - // nav 120 - // icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 121 - // label="Reader" 122 - // className={` 123 - // ${readerUnreads ? "text-accent-contrast! border-accent-contrast" : props.current ? "bg-border-light! border-border" : ""} 124 - // `} 125 - // /> 126 - // ); 122 + if (!props.subs) return; 123 + return ( 124 + <Link href={"/reader"} className="hover:no-underline!"> 125 + <ActionButton 126 + nav 127 + icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 128 + label="Reader" 129 + className={` 130 + ${readerUnreads && "text-accent-contrast!"} 131 + ${props.current && "border-accent-contrast!"} 132 + `} 133 + /> 134 + </Link> 135 + ); 136 + }; 137 + 138 + const DiscoverButton = (props: { current?: boolean }) => { 127 139 return ( 128 140 <Link href={"/discover"} className="hover:no-underline!"> 129 141 <ActionButton
+10 -4
components/ActionBar/Publications.tsx
··· 24 24 {identity.publications?.map((d) => { 25 25 // console.log("thisURI : " + d.uri); 26 26 // console.log("currentURI : " + props.currentPubUri); 27 - console.log(d.uri === props.currentPubUri); 28 27 29 28 return ( 30 29 <PublicationOption ··· 94 93 export const PubIcon = (props: { 95 94 record: PubLeafletPublication.Record; 96 95 uri: string; 96 + small?: boolean; 97 + large?: boolean; 98 + className?: string; 97 99 }) => { 98 100 if (!props.record) return; 101 + 102 + let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 99 103 100 104 return props.record.icon ? ( 101 105 <div ··· 105 109 backgroundSize: "cover", 106 110 backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]})`, 107 111 }} 108 - className="w-6 h-6 rounded-full" 112 + className={`${iconSizeClassName} ${props.className}`} 109 113 /> 110 114 ) : ( 111 - <div className="w-6 h-6 rounded-full bg-accent-1 relative"> 112 - <div className="font-bold text-sm absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2"> 115 + <div className={`${iconSizeClassName} bg-accent-1 relative`}> 116 + <div 117 + className={`${props.small ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 118 + > 113 119 {props.record?.name.slice(0, 1)} 114 120 </div> 115 121 </div>
+1 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 32 32 node.toArray().map((node, index) => { 33 33 if (node.constructor === XmlText) { 34 34 let deltas = node.toDelta() as Delta[]; 35 - if (deltas.length === 0) return <br />; 35 + if (deltas.length === 0) return <br key={index} />; 36 36 return ( 37 37 <Fragment key={index}> 38 38 {deltas.map((d, index) => {
+19
components/Icons/ExternalLinkTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ExternalLinkTiny = (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="M4.94336 2.26074C5.22793 2.3192 5.44238 2.57118 5.44238 2.87305C5.44227 3.17482 5.22788 3.42693 4.94336 3.48535L4.81738 3.49805H4.37305C3.61366 3.49805 2.99805 4.11366 2.99805 4.87305V11.627C2.99818 12.3862 3.61374 13.002 4.37305 13.002H11.127C11.8863 13.002 12.5018 12.3862 12.502 11.627V11.1055C12.5021 10.7605 12.7819 10.4805 13.127 10.4805C13.472 10.4805 13.7518 10.7605 13.752 11.1055V11.627C13.7518 13.0766 12.5766 14.252 11.127 14.252H4.37305C2.92338 14.252 1.74818 13.0766 1.74805 11.627V4.87305C1.74805 3.4233 2.9233 2.24805 4.37305 2.24805H4.81738L4.94336 2.26074ZM13.127 1.74805C13.7482 1.74809 14.252 2.25176 14.252 2.87305V8.04199C14.2518 8.66317 13.7482 9.16694 13.127 9.16699C12.5058 9.16693 12.0021 8.66316 12.002 8.04199V5.58887L7.25488 10.3359L7.16895 10.4141C6.7271 10.774 6.07483 10.7476 5.66309 10.3359C5.22426 9.89664 5.22413 9.18433 5.66309 8.74512L10.4102 3.99805H7.95703C7.33606 3.99774 6.83216 3.49406 6.83203 2.87305C6.83203 2.25192 7.33597 1.74836 7.95703 1.74805H13.127Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+12 -10
components/PageHeader.tsx
··· 3 3 4 4 export const Header = (props: { 5 5 children: React.ReactNode; 6 - hasBackgroundImage: boolean; 6 + cardBorderHidden: boolean; 7 7 }) => { 8 8 let [scrollPos, setScrollPos] = useState(0); 9 9 ··· 22 22 } 23 23 }, []); 24 24 25 - let headerBGColor = props.hasBackgroundImage 26 - ? "var(--bg-page)" 27 - : "var(--bg-leaflet)"; 25 + let headerBGColor = props.cardBorderHidden 26 + ? "var(--bg-leaflet)" 27 + : "var(--bg-page)"; 28 28 29 29 return ( 30 30 <div ··· 54 54 style={ 55 55 scrollPos < 20 56 56 ? { 57 - backgroundColor: `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`, 58 - paddingLeft: props.hasBackgroundImage 59 - ? "8px" 60 - : `calc(${scrollPos / 20}*8px)`, 61 - paddingRight: props.hasBackgroundImage 57 + backgroundColor: props.cardBorderHidden 58 + ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 + : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 + paddingLeft: props.cardBorderHidden 61 + ? "4px" 62 + : `calc(${scrollPos / 20}*4px)`, 63 + paddingRight: props.cardBorderHidden 62 64 ? "8px" 63 65 : `calc(${scrollPos / 20}*8px)`, 64 66 } 65 67 : { 66 68 backgroundColor: `rgb(${headerBGColor})`, 67 - paddingLeft: "8px", 69 + paddingLeft: "4px", 68 70 paddingRight: "8px", 69 71 } 70 72 }
+49 -15
components/PageLayouts/DashboardLayout.tsx
··· 1 1 "use client"; 2 - import { useState, createContext, useContext } from "react"; 2 + import { useState, createContext, useContext, useEffect } from "react"; 3 + import { useSearchParams } from "next/navigation"; 3 4 import { Header } from "../PageHeader"; 4 5 import { Footer } from "components/ActionBar/Footer"; 5 6 import { Sidebar } from "components/ActionBar/Sidebar"; ··· 20 21 import { SearchTiny } from "components/Icons/SearchTiny"; 21 22 import { InterfaceState, useIdentityData } from "components/IdentityProvider"; 22 23 import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; 24 + import Link from "next/link"; 25 + import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 26 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 23 27 24 28 export type DashboardState = { 25 29 display?: "grid" | "list"; ··· 117 121 118 122 export function DashboardLayout< 119 123 T extends { 120 - [name: string]: { content: React.ReactNode; controls: React.ReactNode }; 124 + [name: string]: { 125 + content: React.ReactNode; 126 + controls: React.ReactNode; 127 + }; 121 128 }, 122 129 >(props: { 123 130 id: string; 124 - hasBackgroundImage: boolean; 131 + cardBorderHidden: boolean; 125 132 tabs: T; 126 133 defaultTab: keyof T; 127 134 currentPage: navPages; 128 135 publication?: string; 129 136 actions: React.ReactNode; 130 137 }) { 131 - let [tab, setTab] = useState(props.defaultTab); 138 + const searchParams = useSearchParams(); 139 + const tabParam = searchParams.get("tab"); 140 + 141 + // Initialize tab from search param if valid, otherwise use default 142 + const initialTab = tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 143 + let [tab, setTab] = useState<keyof T>(initialTab); 144 + 145 + // Custom setter that updates both state and URL 146 + const setTabWithUrl = (newTab: keyof T) => { 147 + setTab(newTab); 148 + const params = new URLSearchParams(searchParams.toString()); 149 + params.set("tab", newTab as string); 150 + const newUrl = `${window.location.pathname}?${params.toString()}`; 151 + window.history.replaceState(null, "", newUrl); 152 + }; 153 + 132 154 let { content, controls } = props.tabs[tab]; 155 + let { ref } = usePreserveScroll<HTMLDivElement>( 156 + `dashboard-${props.id}-${tab as string}`, 157 + ); 133 158 134 159 let [headerState, setHeaderState] = useState<"default" | "controls">( 135 160 "default", ··· 150 175 </MediaContents> 151 176 <div 152 177 className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `} 178 + ref={ref} 153 179 id="home-content" 154 180 > 155 181 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 156 182 <> 157 - <Header hasBackgroundImage={props.hasBackgroundImage}> 183 + <Header cardBorderHidden={props.cardBorderHidden}> 158 184 {headerState === "default" ? ( 159 185 <> 160 186 {Object.keys(props.tabs).length > 1 && ( 161 187 <div className="pubDashTabs flex flex-row gap-1"> 162 - {Object.keys(props.tabs).map((t) => ( 163 - <Tab 164 - key={t} 165 - name={t} 166 - selected={t === tab} 167 - onSelect={() => setTab(t)} 168 - /> 169 - ))} 188 + {Object.keys(props.tabs).map((t) => { 189 + return ( 190 + <Tab 191 + key={t} 192 + name={t} 193 + selected={t === tab} 194 + onSelect={() => setTabWithUrl(t)} 195 + /> 196 + ); 197 + })} 170 198 </div> 171 199 )} 172 200 {props.publication && ( ··· 326 354 ); 327 355 }; 328 356 329 - function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 357 + function Tab(props: { 358 + name: string; 359 + selected: boolean; 360 + onSelect: () => void; 361 + href?: string; 362 + }) { 330 363 return ( 331 364 <div 332 - className={`pubTabs px-1 py-0 rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 365 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 333 366 onClick={() => props.onSelect()} 334 367 > 335 368 {props.name} 369 + {props.href && <ExternalLinkTiny />} 336 370 </div> 337 371 ); 338 372 }
-1
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 67 67 if (file) { 68 68 const reader = new FileReader(); 69 69 reader.onload = (e) => { 70 - console.log("loaded!", props.bgImage); 71 70 props.setBgImage({ 72 71 src: e.target?.result as string, 73 72 file,
-1
components/ThemeManager/PubThemeSetter.tsx
··· 62 62 onSubmit={async (e) => { 63 63 e.preventDefault(); 64 64 if (!pub) return; 65 - console.log(image); 66 65 setLoading(true); 67 66 let result = await updatePublicationTheme({ 68 67 uri: pub.uri,
-1
components/utils/AutosizeTextarea.tsx
··· 19 19 let { noWrap, ...rest } = props; 20 20 useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); 21 21 22 - console.log({ noWrap }); 23 22 return ( 24 23 <div 25 24 className={`${styles["grow-wrap"]} ${props.className} ${noWrap ? styles["no-wrap"] : ""}`}
+1 -1
feeds/index.ts
··· 33 33 34 34 let { data: publications } = await supabaseServerClient 35 35 .from("publication_subscriptions") 36 - .select(`publications(documents_in_publications(documents(*)))`) 36 + .select(`publications(*, documents_in_publications(documents(*)))`) 37 37 .eq("identity", auth); 38 38 39 39 const allPosts = (publications || [])
+30
lexicons/src/blocks.ts
··· 177 177 }, 178 178 }; 179 179 180 + export const PubLeafletBlocksOrderedList: LexiconDoc = { 181 + lexicon: 1, 182 + id: "pub.leaflet.blocks.orderedList", 183 + defs: { 184 + main: { 185 + type: "object", 186 + required: ["children"], 187 + properties: { 188 + startIndex: { type: "integer" }, 189 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 190 + }, 191 + }, 192 + listItem: { 193 + type: "object", 194 + required: ["content"], 195 + properties: { 196 + content: { 197 + type: "union", 198 + refs: [ 199 + PubLeafletBlocksText, 200 + PubLeafletBlocksHeader, 201 + PubLeafletBlocksImage, 202 + ].map((l) => l.id), 203 + }, 204 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 205 + }, 206 + }, 207 + }, 208 + }; 209 + 180 210 export const PubLeafletBlocksUnorderedList: LexiconDoc = { 181 211 lexicon: 1, 182 212 id: "pub.leaflet.blocks.unorderedList",
+22
src/hooks/usePreserveScroll.ts
··· 1 + import { useRef, useEffect } from "react"; 2 + 3 + let scrollPositions: { [key: string]: number } = {}; 4 + export function usePreserveScroll<T extends HTMLElement>(key: string | null) { 5 + let ref = useRef<T | null>(null); 6 + useEffect(() => { 7 + if (!ref.current || !key) return; 8 + 9 + window.requestAnimationFrame(() => { 10 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 + }); 12 + 13 + const listener = () => { 14 + if (!ref.current?.scrollTop) return; 15 + scrollPositions[key] = ref.current.scrollTop; 16 + }; 17 + 18 + ref.current.addEventListener("scroll", listener); 19 + return () => ref.current?.removeEventListener("scroll", listener); 20 + }, [key, ref.current]); 21 + return { ref }; 22 + }
-1
src/utils/addLinkBlock.ts
··· 59 59 } 60 60 61 61 if (data.data.links?.player?.[0]) { 62 - console.log(data.data.links.player); 63 62 let embed = data.data.links?.player?.[0]; 64 63 await rep.mutate.assertFact([ 65 64 {
-1
src/utils/focusBlock.ts
··· 85 85 top: nextBlockViewClientRect.top + 12, 86 86 left: Math.max(position.left, nextBlockViewClientRect.left), 87 87 }); 88 - console.log(pos); 89 88 break; 90 89 } 91 90 case "bottom": {
-1
src/utils/getBlocksAsHTML.tsx
··· 187 187 let [alignment] = await scanIndex(tx).eav(b.value, "block/text-alignment"); 188 188 let toHtml = BlockTypeToHTML[b.type]; 189 189 let element = await toHtml(b, tx, alignment?.data.value); 190 - console.log(element); 191 190 return renderToStaticMarkup(element); 192 191 }