a tool for shared writing and social publishing

added profile settings to nav

+95 -614
+18 -12
app/(home-pages)/discover/PubListing.tsx app/(home-pages)/p/[didOrHandle]/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 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 5 6 import { PubIcon } from "components/ActionBar/Publications"; 6 7 import { Separator } from "components/Layout"; 7 8 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 9 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 11 import { timeAgo } from "src/utils/timeAgo"; 11 12 12 - export const PubListing = ( 13 - props: PublicationSubscription & { 14 - resizeHeight?: boolean; 15 - }, 16 - ) => { 13 + export const PubListing = (props: PublicationSubscription) => { 17 14 let record = props.record; 18 15 let theme = usePubTheme(record?.theme); 19 16 let backgroundImage = record?.theme?.backgroundImage?.image?.ref ··· 28 25 if (!record) return null; 29 26 return ( 30 27 <BaseThemeProvider {...theme} local> 31 - <a 32 - href={record.url} 28 + <div 33 29 className={`no-underline! flex flex-row gap-2 34 30 bg-bg-leaflet 35 31 border border-border-light rounded-lg ··· 42 38 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 43 39 }} 44 40 > 41 + <a href={record.url} className="absolute inset-0" /> 45 42 <div 46 - className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg relative z-10 ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 43 + className={`flex w-full flex-col justify-center text-center pt-4 pb-3 px-3 rounded-lg relative z-10 sm:h-[200px] h-full ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 47 44 > 48 45 <div className="mx-auto pb-1"> 49 46 <PubIcon record={record} uri={props.uri} large /> ··· 51 48 52 49 <h4 className="truncate shrink-0 ">{record.name}</h4> 53 50 {record.description && ( 54 - <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 51 + <p className="text-secondary line-clamp-1 min-h-[16px] text-sm overflow-hidden "> 55 52 {record.description} 56 53 </p> 57 54 )} 58 - <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 55 + <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-1"> 59 56 <div className="flex flex-row gap-2 items-center"> 60 57 {props.authorProfile?.handle} 61 58 </div> ··· 67 64 )} 68 65 </p> 69 66 </div> 67 + <div className="w-fit mx-auto mt-3 grow items-end flex"> 68 + <SubscribeWithBluesky 69 + compact 70 + pub_uri={props.uri} 71 + pubName={props.record.name} 72 + subscribers={props.publication_subscriptions || []} 73 + base_url={getPublicationURL({ ...props })} 74 + /> 75 + </div> 70 76 </div> 71 - </a> 77 + </div> 72 78 </BaseThemeProvider> 73 79 ); 74 80 };
-97
app/(home-pages)/discover/SortButtons.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState } from "react"; 4 - import { theme } from "tailwind.config"; 5 - 6 - export default function SortButtons(props: { order: string }) { 7 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 8 - "recentlyUpdated", 9 - ); 10 - 11 - return ( 12 - <div className="flex gap-2 pt-1"> 13 - <Link href="?order=recentlyUpdated"> 14 - <SortButton selected={props.order === "recentlyUpdated"}> 15 - Recently Updated 16 - </SortButton> 17 - </Link> 18 - 19 - <Link href="?order=popular"> 20 - <SortButton selected={props.order === "popular"}>Popular</SortButton> 21 - </Link> 22 - </div> 23 - ); 24 - } 25 - 26 - const SortButton = (props: { 27 - children: React.ReactNode; 28 - selected: boolean; 29 - }) => { 30 - return ( 31 - <div className="relative"> 32 - <button 33 - style={ 34 - props.selected 35 - ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 36 - : {} 37 - } 38 - className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 39 - > 40 - {props.children} 41 - </button> 42 - {props.selected && ( 43 - <> 44 - <div className="absolute top-0 -left-2"> 45 - <GlitterBig /> 46 - </div> 47 - <div className="absolute top-4 left-0"> 48 - <GlitterSmall /> 49 - </div> 50 - <div className="absolute -top-2 -right-1"> 51 - <GlitterSmall /> 52 - </div> 53 - </> 54 - )} 55 - </div> 56 - ); 57 - }; 58 - 59 - const GlitterBig = () => { 60 - return ( 61 - <svg 62 - width="16" 63 - height="17" 64 - viewBox="0 0 16 17" 65 - fill="none" 66 - xmlns="http://www.w3.org/2000/svg" 67 - > 68 - <path 69 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 70 - fill={theme.colors["accent-1"]} 71 - stroke={theme.colors["bg-leaflet"]} 72 - strokeLinecap="round" 73 - strokeLinejoin="round" 74 - /> 75 - </svg> 76 - ); 77 - }; 78 - 79 - const GlitterSmall = () => { 80 - return ( 81 - <svg 82 - width="13" 83 - height="14" 84 - viewBox="0 0 13 14" 85 - fill="none" 86 - xmlns="http://www.w3.org/2000/svg" 87 - > 88 - <path 89 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 90 - fill={theme.colors["accent-1"]} 91 - stroke={theme.colors["bg-leaflet"]} 92 - strokeLinecap="round" 93 - strokeLinejoin="round" 94 - /> 95 - </svg> 96 - ); 97 - };
-195
app/(home-pages)/discover/SortedPublicationList.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState, useEffect, useRef } from "react"; 4 - import { theme } from "tailwind.config"; 5 - import { PubListing } from "./PubListing"; 6 - import useSWRInfinite from "swr/infinite"; 7 - import { getPublications, type Cursor, type Publication } from "./getPublications"; 8 - 9 - export function SortedPublicationList(props: { 10 - publications: Publication[]; 11 - order: string; 12 - nextCursor: Cursor | null; 13 - }) { 14 - let [order, setOrder] = useState(props.order); 15 - 16 - const getKey = ( 17 - pageIndex: number, 18 - previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, 19 - ) => { 20 - // Reached the end 21 - if (previousPageData && !previousPageData.nextCursor) return null; 22 - 23 - // First page, we don't have previousPageData 24 - if (pageIndex === 0) return ["discover-publications", order, null] as const; 25 - 26 - // Add the cursor to the key 27 - return ["discover-publications", order, previousPageData?.nextCursor] as const; 28 - }; 29 - 30 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 31 - getKey, 32 - ([_, orderValue, cursor]) => { 33 - const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; 34 - return getPublications(orderParam, cursor); 35 - }, 36 - { 37 - fallbackData: order === props.order 38 - ? [{ publications: props.publications, nextCursor: props.nextCursor }] 39 - : undefined, 40 - revalidateFirstPage: false, 41 - }, 42 - ); 43 - 44 - const loadMoreRef = useRef<HTMLDivElement>(null); 45 - 46 - // Set up intersection observer to load more when trigger element is visible 47 - useEffect(() => { 48 - const observer = new IntersectionObserver( 49 - (entries) => { 50 - if (entries[0].isIntersecting && !isValidating) { 51 - const hasMore = data && data[data.length - 1]?.nextCursor; 52 - if (hasMore) { 53 - setSize(size + 1); 54 - } 55 - } 56 - }, 57 - { threshold: 0.1 }, 58 - ); 59 - 60 - if (loadMoreRef.current) { 61 - observer.observe(loadMoreRef.current); 62 - } 63 - 64 - return () => observer.disconnect(); 65 - }, [data, size, setSize, isValidating]); 66 - 67 - const allPublications = data ? data.flatMap((page) => page.publications) : []; 68 - 69 - return ( 70 - <div className="discoverHeader flex flex-col items-center "> 71 - <SortButtons 72 - order={order} 73 - setOrder={(o) => { 74 - const url = new URL(window.location.href); 75 - url.searchParams.set("order", o); 76 - window.history.pushState({}, "", url); 77 - setOrder(o); 78 - }} 79 - /> 80 - <div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative"> 81 - {allPublications.map((pub) => ( 82 - <PubListing resizeHeight key={pub.uri} {...pub} /> 83 - ))} 84 - {/* Trigger element for loading more publications */} 85 - <div 86 - ref={loadMoreRef} 87 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 - aria-hidden="true" 89 - /> 90 - {isValidating && ( 91 - <div className="text-center text-tertiary py-4"> 92 - Loading more publications... 93 - </div> 94 - )} 95 - </div> 96 - </div> 97 - ); 98 - } 99 - 100 - export default function SortButtons(props: { 101 - order: string; 102 - setOrder: (order: string) => void; 103 - }) { 104 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 105 - "recentlyUpdated", 106 - ); 107 - 108 - return ( 109 - <div className="flex gap-2 pt-1"> 110 - <SortButton 111 - selected={props.order === "recentlyUpdated"} 112 - onClick={() => props.setOrder("recentlyUpdated")} 113 - > 114 - Recently Updated 115 - </SortButton> 116 - 117 - <SortButton 118 - selected={props.order === "popular"} 119 - onClick={() => props.setOrder("popular")} 120 - > 121 - Popular 122 - </SortButton> 123 - </div> 124 - ); 125 - } 126 - 127 - const SortButton = (props: { 128 - children: React.ReactNode; 129 - onClick: () => void; 130 - selected: boolean; 131 - }) => { 132 - return ( 133 - <div className="relative"> 134 - <button 135 - onClick={props.onClick} 136 - className={`text-sm rounded-md px-[8px] font-bold py-0.5 border ${props.selected ? "border-accent-contrast bg-accent-1 text-accent-2 " : "bg-bg-page text-accent-contrast border-accent-contrast"}`} 137 - > 138 - {props.children} 139 - </button> 140 - {props.selected && ( 141 - <> 142 - <div className="absolute top-0 -left-2"> 143 - <GlitterBig /> 144 - </div> 145 - <div className="absolute top-4 left-0"> 146 - <GlitterSmall /> 147 - </div> 148 - <div className="absolute -top-2 -right-1"> 149 - <GlitterSmall /> 150 - </div> 151 - </> 152 - )} 153 - </div> 154 - ); 155 - }; 156 - 157 - const GlitterBig = () => { 158 - return ( 159 - <svg 160 - width="16" 161 - height="17" 162 - viewBox="0 0 16 17" 163 - fill="none" 164 - xmlns="http://www.w3.org/2000/svg" 165 - > 166 - <path 167 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 168 - fill={theme.colors["accent-1"]} 169 - stroke={theme.colors["bg-leaflet"]} 170 - strokeLinecap="round" 171 - strokeLinejoin="round" 172 - /> 173 - </svg> 174 - ); 175 - }; 176 - 177 - const GlitterSmall = () => { 178 - return ( 179 - <svg 180 - width="13" 181 - height="14" 182 - viewBox="0 0 13 14" 183 - fill="none" 184 - xmlns="http://www.w3.org/2000/svg" 185 - > 186 - <path 187 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 188 - fill={theme.colors["accent-1"]} 189 - stroke={theme.colors["bg-leaflet"]} 190 - strokeLinecap="round" 191 - strokeLinejoin="round" 192 - /> 193 - </svg> 194 - ); 195 - };
-133
app/(home-pages)/discover/getPublications.ts
··· 1 - "use server"; 2 - 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - import { 5 - normalizePublicationRow, 6 - hasValidPublication, 7 - } from "src/utils/normalizeRecords"; 8 - import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 - 10 - export type Cursor = { 11 - sort_date?: string; 12 - count?: number; 13 - uri: string; 14 - }; 15 - 16 - export type Publication = Awaited< 17 - ReturnType<typeof getPublications> 18 - >["publications"][number]; 19 - 20 - export async function getPublications( 21 - order: "recentlyUpdated" | "popular" = "recentlyUpdated", 22 - cursor?: Cursor | null, 23 - ): Promise<{ publications: any[]; nextCursor: Cursor | null }> { 24 - const limit = 25; 25 - 26 - // Fetch all publications with their most recent document 27 - let { data: publications, error } = await supabaseServerClient 28 - .from("publications") 29 - .select( 30 - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 31 - ) 32 - .or( 33 - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 34 - ) 35 - .order("documents(sort_date)", { 36 - referencedTable: "documents_in_publications", 37 - ascending: false, 38 - }) 39 - .limit(1, { referencedTable: "documents_in_publications" }); 40 - 41 - if (error) { 42 - console.error("Error fetching publications:", error); 43 - return { publications: [], nextCursor: null }; 44 - } 45 - 46 - // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 47 - const dedupedPublications = deduplicateByUri(publications || []); 48 - 49 - // Filter out publications without documents 50 - const allPubs = dedupedPublications.filter( 51 - (pub) => pub.documents_in_publications.length > 0, 52 - ); 53 - 54 - // Sort on the server 55 - allPubs.sort((a, b) => { 56 - if (order === "popular") { 57 - const aCount = a.publication_subscriptions[0]?.count || 0; 58 - const bCount = b.publication_subscriptions[0]?.count || 0; 59 - if (bCount !== aCount) { 60 - return bCount - aCount; 61 - } 62 - // Secondary sort by uri for stability 63 - return b.uri.localeCompare(a.uri); 64 - } else { 65 - // recentlyUpdated 66 - const aDate = new Date( 67 - a.documents_in_publications[0]?.documents?.sort_date || 0, 68 - ).getTime(); 69 - const bDate = new Date( 70 - b.documents_in_publications[0]?.documents?.sort_date || 0, 71 - ).getTime(); 72 - if (bDate !== aDate) { 73 - return bDate - aDate; 74 - } 75 - // Secondary sort by uri for stability 76 - return b.uri.localeCompare(a.uri); 77 - } 78 - }); 79 - 80 - // Find cursor position and slice 81 - let startIndex = 0; 82 - if (cursor) { 83 - startIndex = allPubs.findIndex((pub) => { 84 - if (order === "popular") { 85 - const pubCount = pub.publication_subscriptions[0]?.count || 0; 86 - // Find first pub after cursor 87 - return ( 88 - pubCount < (cursor.count || 0) || 89 - (pubCount === cursor.count && pub.uri < cursor.uri) 90 - ); 91 - } else { 92 - const pubDate = pub.documents_in_publications[0]?.documents?.sort_date || ""; 93 - // Find first pub after cursor 94 - return ( 95 - pubDate < (cursor.sort_date || "") || 96 - (pubDate === cursor.sort_date && pub.uri < cursor.uri) 97 - ); 98 - } 99 - }); 100 - // If not found, we're at the end 101 - if (startIndex === -1) { 102 - return { publications: [], nextCursor: null }; 103 - } 104 - } 105 - 106 - // Get the page 107 - const page = allPubs.slice(startIndex, startIndex + limit); 108 - 109 - // Normalize publication records 110 - const normalizedPage = page 111 - .map(normalizePublicationRow) 112 - .filter(hasValidPublication); 113 - 114 - // Create next cursor based on last item in normalizedPage 115 - const lastItem = normalizedPage[normalizedPage.length - 1]; 116 - const nextCursor = 117 - normalizedPage.length > 0 && startIndex + limit < allPubs.length 118 - ? order === "recentlyUpdated" 119 - ? { 120 - sort_date: lastItem.documents_in_publications[0]?.documents?.sort_date, 121 - uri: lastItem.uri, 122 - } 123 - : { 124 - count: lastItem.publication_subscriptions[0]?.count || 0, 125 - uri: lastItem.uri, 126 - } 127 - : null; 128 - 129 - return { 130 - publications: normalizedPage, 131 - nextCursor, 132 - }; 133 - }
-53
app/(home-pages)/discover/page.tsx
··· 1 - import Link from "next/link"; 2 - import { SortedPublicationList } from "./SortedPublicationList"; 3 - import { Metadata } from "next"; 4 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 5 - import { getPublications } from "./getPublications"; 6 - 7 - export const metadata: Metadata = { 8 - title: "Leaflet Discover", 9 - description: "Explore publications on Leaflet ✨ Or make your own!", 10 - }; 11 - 12 - export default async function Discover(props: { 13 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 14 - }) { 15 - let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 16 - 17 - return ( 18 - <DashboardLayout 19 - id="discover" 20 - currentPage="discover" 21 - defaultTab="default" 22 - actions={null} 23 - tabs={{ 24 - default: { 25 - controls: null, 26 - content: <DiscoverContent order={order} />, 27 - }, 28 - }} 29 - /> 30 - ); 31 - } 32 - 33 - const DiscoverContent = async (props: { order: string }) => { 34 - const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 35 - let { publications, nextCursor } = await getPublications(orderValue); 36 - 37 - return ( 38 - <div className="max-w-prose mx-auto w-full"> 39 - <div className="discoverHeader flex flex-col items-center text-center pt-2 px-4"> 40 - <h1>Discover</h1> 41 - <p className="text-lg text-secondary italic mb-2"> 42 - Explore publications on Leaflet ✨ Or{" "} 43 - <Link href="/lish/createPub">make your own</Link>! 44 - </p> 45 - </div> 46 - <SortedPublicationList 47 - publications={publications} 48 - order={props.order} 49 - nextCursor={nextCursor} 50 - /> 51 - </div> 52 - ); 53 - };
+2 -2
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 11 11 ${ 12 12 cardBorderHidden 13 13 ? "" 14 - : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4" 15 15 } 16 16 max-w-prose mx-auto w-full 17 - flex flex-col 17 + flex flex-col pb-3 18 18 text-center 19 19 `} 20 20 >
+1 -1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 85 85 } 86 86 87 87 return ( 88 - <div className="flex flex-col gap-2 text-left relative"> 88 + <div className="flex flex-col gap-2 py-4 text-left relative"> 89 89 {allComments.map((comment) => ( 90 90 <CommentItem key={comment.uri} comment={comment} /> 91 91 ))}
+1 -1
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 2 2 3 3 import { useEffect, useRef } from "react"; 4 4 import useSWRInfinite from "swr/infinite"; 5 - import { PubListing } from "app/(home-pages)/discover/PubListing"; 5 + import { PubListing } from "app/(home-pages)/p/[didOrHandle]/PubListing"; 6 6 import { 7 7 getSubscriptions, 8 8 type PublicationSubscription,
-105
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 1 - "use client"; 2 - import { PubListing } from "app/(home-pages)/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 - import Link from "next/link"; 11 - 12 - export const SubscriptionsContent = (props: { 13 - publications: PublicationSubscription[]; 14 - nextCursor: Cursor | null; 15 - }) => { 16 - const getKey = ( 17 - pageIndex: number, 18 - previousPageData: { 19 - subscriptions: PublicationSubscription[]; 20 - nextCursor: Cursor | null; 21 - } | null, 22 - ) => { 23 - // Reached the end 24 - if (previousPageData && !previousPageData.nextCursor) return null; 25 - 26 - // First page, we don't have previousPageData 27 - if (pageIndex === 0) return ["subscriptions", null] as const; 28 - 29 - // Add the cursor to the key 30 - return ["subscriptions", previousPageData?.nextCursor] as const; 31 - }; 32 - 33 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 - getKey, 35 - ([_, cursor]) => getSubscriptions(null, cursor), 36 - { 37 - fallbackData: [ 38 - { subscriptions: props.publications, nextCursor: props.nextCursor }, 39 - ], 40 - revalidateFirstPage: false, 41 - }, 42 - ); 43 - 44 - const loadMoreRef = useRef<HTMLDivElement>(null); 45 - 46 - // Set up intersection observer to load more when trigger element is visible 47 - useEffect(() => { 48 - const observer = new IntersectionObserver( 49 - (entries) => { 50 - if (entries[0].isIntersecting && !isValidating) { 51 - const hasMore = data && data[data.length - 1]?.nextCursor; 52 - if (hasMore) { 53 - setSize(size + 1); 54 - } 55 - } 56 - }, 57 - { threshold: 0.1 }, 58 - ); 59 - 60 - if (loadMoreRef.current) { 61 - observer.observe(loadMoreRef.current); 62 - } 63 - 64 - return () => observer.disconnect(); 65 - }, [data, size, setSize, isValidating]); 66 - 67 - const allPublications = data 68 - ? data.flatMap((page) => page.subscriptions) 69 - : []; 70 - 71 - if (allPublications.length === 0 && !isValidating) 72 - return <SubscriptionsEmpty />; 73 - 74 - return ( 75 - <div className="relative"> 76 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 77 - {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 78 - </div> 79 - {/* Trigger element for loading more subscriptions */} 80 - <div 81 - ref={loadMoreRef} 82 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 83 - aria-hidden="true" 84 - /> 85 - {isValidating && ( 86 - <div className="text-center text-tertiary py-4"> 87 - Loading more subscriptions... 88 - </div> 89 - )} 90 - </div> 91 - ); 92 - }; 93 - 94 - export const SubscriptionsEmpty = () => { 95 - return ( 96 - <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"> 97 - You haven't subscribed to any publications yet! 98 - <Link href={"/discover"}> 99 - <ButtonPrimary className="mx-auto place-self-center"> 100 - <DiscoverSmall /> Discover Publications 101 - </ButtonPrimary> 102 - </Link> 103 - </div> 104 - ); 105 - };
+6 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 29 29 30 30 let query = supabaseServerClient 31 31 .from("publication_subscriptions") 32 - .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 32 + .select( 33 + `*, publications(*, publication_subscriptions(*), documents_in_publications(*, documents(*)))`, 34 + ) 33 35 .order(`created_at`, { ascending: false }) 34 36 .order(`uri`, { ascending: false }) 35 37 .order("documents(sort_date)", { ··· 51 53 await Promise.all( 52 54 pubs?.map(async (pub) => { 53 55 const normalizedRecord = normalizePublicationRecord( 54 - pub.publications?.record 56 + pub.publications?.record, 55 57 ); 56 58 if (!normalizedRecord) return null; 57 59 let id = await idResolver.did.resolve(pub.publications?.identity_did!); ··· 62 64 ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 63 65 : undefined, 64 66 } as PublicationSubscription; 65 - }) || [] 67 + }) || [], 66 68 ) 67 69 ).filter((sub): sub is PublicationSubscription => sub !== null); 68 - 69 70 const nextCursor = 70 71 pubs && pubs.length > 0 71 72 ? { ··· 83 84 export type PublicationSubscription = { 84 85 authorProfile?: { handle: string }; 85 86 record: NormalizedPublication; 87 + publication_subscriptions: { identity: string }[]; 86 88 uri: string; 87 89 documents_in_publications: { 88 90 documents: { data?: Json; sort_date: string } | null;
+47 -9
app/lish/Subscribe.tsx
··· 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 26 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 27 + import { RSSTiny } from "components/Icons/RSSTiny"; 27 28 28 29 export const SubscribeWithBluesky = (props: { 30 + compact?: boolean; 29 31 pubName: string; 30 32 pub_uri: string; 31 33 base_url: string; ··· 36 38 let [successModalOpen, setSuccessModalOpen] = useState( 37 39 !!searchParams.has("showSubscribeSuccess"), 38 40 ); 41 + let [localSubscribeState, setLocalSubscribeState] = useState< 42 + "subscribed" | "unsubscribed" 43 + >("subscribed"); 39 44 let subscribed = 40 45 identity?.atp_did && 46 + localSubscribeState !== "unsubscribed" && 41 47 props.subscribers.find((s) => s.identity === identity.atp_did); 42 48 43 49 if (successModalOpen) ··· 48 54 /> 49 55 ); 50 56 if (subscribed) { 51 - return <ManageSubscription {...props} />; 57 + return ( 58 + <ManageSubscription 59 + {...props} 60 + onUnsubscribe={() => setLocalSubscribeState("unsubscribed")} 61 + /> 62 + ); 52 63 } 53 64 return ( 54 65 <div className="flex flex-col gap-2 text-center justify-center"> 55 66 <div className="flex flex-row gap-2 place-self-center"> 56 67 <BlueskySubscribeButton 68 + setLocalSubscribeState={() => setLocalSubscribeState("subscribed")} 69 + compact={props.compact} 57 70 pub_uri={props.pub_uri} 58 71 setSuccessModalOpen={setSuccessModalOpen} 59 72 /> ··· 63 76 target="_blank" 64 77 aria-label="Subscribe to RSS" 65 78 > 66 - <RSSSmall className="self-center" aria-hidden /> 79 + {props.compact ? ( 80 + <RSSTiny className="self-center" aria-hidden /> 81 + ) : ( 82 + <RSSSmall className="self-center" aria-hidden /> 83 + )} 67 84 </a> 68 85 </div> 69 86 </div> ··· 74 91 pub_uri: string; 75 92 subscribers: { identity: string }[]; 76 93 base_url: string; 94 + compact?: boolean; 95 + onUnsubscribe?: () => void; 77 96 }) => { 78 97 let toaster = useToaster(); 79 98 let [hasFeed] = useState(false); ··· 83 102 content: "You unsubscribed.", 84 103 type: "success", 85 104 }); 105 + props.onUnsubscribe?.(); 86 106 }, null); 87 107 return ( 88 108 <Popover 89 109 trigger={ 90 - <div className="text-accent-contrast text-sm w-fit"> 110 + <div 111 + className={`text-accent-contrast w-fit ${props.compact ? "text-xs" : "text-sm"}`} 112 + > 91 113 Manage Subscription 92 114 </div> 93 115 } 94 116 > 95 - <div className="max-w-sm flex flex-col gap-1"> 117 + <div 118 + className={`max-w-sm flex flex-col gap-1 ${props.compact && "text-sm"}`} 119 + > 96 120 <h4>Update Options</h4> 97 121 98 122 {!hasFeed && ( ··· 102 126 className=" place-self-center" 103 127 > 104 128 <ButtonPrimary fullWidth compact className="!px-4"> 105 - View Bluesky Custom Feed 129 + Bluesky Custom Feed 106 130 </ButtonPrimary> 107 131 </a> 108 132 )} ··· 121 145 <hr className="border-border-light my-1" /> 122 146 123 147 <form action={unsubscribe}> 124 - <button className="font-bold text-accent-contrast w-max place-self-center"> 125 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 148 + <button className="font-bold w-full text-accent-contrast text-center mx-auto"> 149 + {unsubscribePending ? ( 150 + <DotLoader className="w-fit mx-auto" /> 151 + ) : ( 152 + "Unsubscribe" 153 + )} 126 154 </button> 127 155 </form> 128 156 </div> ··· 133 161 let BlueskySubscribeButton = (props: { 134 162 pub_uri: string; 135 163 setSuccessModalOpen: (open: boolean) => void; 164 + compact?: boolean; 165 + setLocalSubscribeState: () => void; 136 166 }) => { 137 167 let { identity } = useIdentityData(); 138 168 let toaster = useToaster(); ··· 155 185 props.setSuccessModalOpen(true); 156 186 } 157 187 toaster({ content: <div>You're Subscribed!</div>, type: "success" }); 188 + props.setLocalSubscribeState(); 158 189 }, null); 159 190 160 191 let [isClient, setIsClient] = useState(false); ··· 166 197 return ( 167 198 <Popover 168 199 asChild 200 + className="max-w-xs" 169 201 trigger={ 170 - <ButtonPrimary className="place-self-center"> 202 + <ButtonPrimary 203 + compact={props.compact} 204 + className={`place-self-center ${props.compact && "text-sm"}`} 205 + > 171 206 <BlueskyTiny /> Subscribe with Bluesky 172 207 </ButtonPrimary> 173 208 } ··· 190 225 action={subscribe} 191 226 className="place-self-center flex flex-row gap-1" 192 227 > 193 - <ButtonPrimary> 228 + <ButtonPrimary 229 + compact={props.compact} 230 + className={props.compact ? "text-sm" : ""} 231 + > 194 232 {subscribePending ? ( 195 233 <DotLoader /> 196 234 ) : (
+1 -1
components/ActionBar/ProfileButton.tsx
··· 40 40 > 41 41 {record && ( 42 42 <> 43 - <SpeedyLink href={`/p/${record.handle}`}> 43 + <SpeedyLink className="no-underline!" href={`/p/${record.handle}`}> 44 44 <MenuItem onSelect={() => {}}>View Profile</MenuItem> 45 45 </SpeedyLink> 46 46
+18
components/Icons/RSSTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + export const RSSTiny = (props: Props) => { 3 + return ( 4 + <svg 5 + width="16" 6 + height="16" 7 + viewBox="0 0 16 16" 8 + fill="none" 9 + xmlns="http://www.w3.org/2000/svg" 10 + {...props} 11 + > 12 + <path 13 + d="M2.82098 5.7636C6.84291 5.76364 10.2363 8.92669 10.2364 13.179C10.2364 13.8688 9.67713 14.428 8.98738 14.428C8.29764 14.428 7.73841 13.8688 7.73837 13.179C7.7383 10.3543 5.5118 8.26167 2.82098 8.26163C2.13119 8.26163 1.572 7.7024 1.57196 7.01262C1.57196 6.32281 2.13116 5.7636 2.82098 5.7636ZM2.82098 1.57196C9.12441 1.572 14.428 6.52137 14.428 13.179C14.428 13.8688 13.8688 14.428 13.179 14.428C12.4892 14.428 11.93 13.8688 11.93 13.179C11.93 7.94901 7.7933 4.07003 2.82098 4.06999C2.13116 4.06999 1.57196 3.51079 1.57196 2.82098C1.57196 2.13116 2.13116 1.57196 2.82098 1.57196ZM3.93094 10.6066C4.82318 10.6067 5.54639 11.3299 5.54649 12.2221C5.54649 13.1145 4.82325 13.8382 3.93094 13.8383C3.03853 13.8383 2.31478 13.1145 2.31478 12.2221C2.31489 11.3298 3.03859 10.6066 3.93094 10.6066Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+1 -1
components/Popover/index.tsx
··· 42 42 <NestedCardThemeProvider> 43 43 <RadixPopover.Content 44 44 className={` 45 - z-20 bg-bg-page 45 + z-20 relative bg-bg-page 46 46 px-3 py-2 text-primary 47 47 max-w-(--radix-popover-content-available-width) 48 48 max-h-(--radix-popover-content-available-height)