a tool for shared writing and social publishing

updated postListing design

+274 -93
+63 -15
app/(home-pages)/reader/InboxContent.tsx
··· 4 import type { Cursor, Post } from "./getReaderFeed"; 5 import useSWRInfinite from "swr/infinite"; 6 import { getReaderFeed } from "./getReaderFeed"; 7 - import { useEffect, useRef } from "react"; 8 import Link from "next/link"; 9 import { PostListing } from "components/PostListing"; 10 11 export const InboxContent = (props: { 12 posts: Post[]; ··· 60 61 return () => observer.disconnect(); 62 }, [data, size, setSize, isValidating]); 63 64 const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 66 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 67 68 return ( 69 - <div className="flex flex-col gap-3 relative"> 70 - {allPosts.map((p) => ( 71 - <PostListing {...p} key={p.documents.uri} /> 72 - ))} 73 - {/* Trigger element for loading more posts */} 74 - <div 75 - ref={loadMoreRef} 76 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 77 - aria-hidden="true" 78 - /> 79 - {isValidating && ( 80 - <div className="text-center text-tertiary py-4"> 81 - Loading more posts... 82 </div> 83 - )} 84 </div> 85 ); 86 };
··· 4 import type { Cursor, Post } from "./getReaderFeed"; 5 import useSWRInfinite from "swr/infinite"; 6 import { getReaderFeed } from "./getReaderFeed"; 7 + import { useEffect, useRef, useState } from "react"; 8 import Link from "next/link"; 9 import { PostListing } from "components/PostListing"; 10 + import { SortSmall } from "components/Icons/SortSmall"; 11 + import { Input } from "components/Input"; 12 + import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; 13 + import { InteractionDrawer } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 14 15 export const InboxContent = (props: { 16 posts: Post[]; ··· 64 65 return () => observer.disconnect(); 66 }, [data, size, setSize, isValidating]); 67 + let [searchValue, setSearchValue] = useState(""); 68 + let [sort, setSort] = useState<"recent" | "popular">("popular"); 69 70 const allPosts = data ? data.flatMap((page) => page.posts) : []; 71 + const postTitles = allPosts.map((p) => { 72 + p.documents.data?.title; 73 + }); 74 + const filteredPosts = allPosts 75 + .filter((p) => 76 + p.documents.data?.title.toLowerCase().includes(searchValue.toLowerCase()), 77 + ) 78 + .sort( 79 + (a, b) => 80 + new Date(b.documents.data?.publishedAt || 0).getTime() - 81 + new Date(a.documents.data?.publishedAt || 0).getTime(), 82 + ); 83 84 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 85 86 + let hasBackgroundImage = useHasBackgroundImage(); 87 + 88 return ( 89 + <div className="flex flex-row gap-6"> 90 + <div className="flex flex-col gap-6 relative"> 91 + <div className="flex justify-between gap-4 text-tertiary"> 92 + <Input 93 + className={`inboxSearchInput 94 + appearance-none! outline-hidden! 95 + w-full min-w-0 text-primary relative px-1 96 + border rounded-md border-border-light focus-within:border-border 97 + bg-transparent ${hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `} 98 + type="text" 99 + id="inbox-search" 100 + size={1} 101 + placeholder="search posts..." 102 + value={searchValue} 103 + onChange={(e) => { 104 + setSearchValue(e.currentTarget.value); 105 + }} 106 + /> 107 + <button 108 + className="flex gap-1" 109 + onClick={() => { 110 + setSort(sort === "popular" ? "recent" : "popular"); 111 + }} 112 + > 113 + {sort === "popular" ? "Popular" : "Recent"} 114 + <SortSmall /> 115 + </button> 116 </div> 117 + {filteredPosts.map((p) => ( 118 + <PostListing {...p} key={p.documents.uri} /> 119 + ))} 120 + {/* Trigger element for loading more posts */} 121 + <div 122 + ref={loadMoreRef} 123 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 124 + aria-hidden="true" 125 + /> 126 + {isValidating && ( 127 + <div className="text-center text-tertiary py-4"> 128 + Loading more posts... 129 + </div> 130 + )} 131 + </div> 132 </div> 133 ); 134 };
+1
app/(home-pages)/reader/page.tsx
··· 6 7 export default async function Reader(props: {}) { 8 let posts = await getReaderFeed(); 9 return ( 10 <DashboardLayout 11 id="reader"
··· 6 7 export default async function Reader(props: {}) { 8 let posts = await getReaderFeed(); 9 + 10 return ( 11 <DashboardLayout 12 id="reader"
+1 -1
components/ActionBar/NavigationButtons.tsx
··· 53 labelOnMobile={!props.compactOnMobile} 54 icon={<WriterSmall />} 55 label="Write" 56 - className={` w-fit! ${current ? "bg-bg-page! border-border-light!" : ""}`} 57 /> 58 </SpeedyLink> 59 );
··· 53 labelOnMobile={!props.compactOnMobile} 54 icon={<WriterSmall />} 55 label="Write" 56 + className={`${current ? "bg-bg-page! border-border-light!" : ""}`} 57 /> 58 </SpeedyLink> 59 );
+19
components/Icons/ShareTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ShareTiny = (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="M14.294 2.09457C14.4677 2.02691 14.6645 2.06158 14.8048 2.18441C14.9451 2.30734 15.0054 2.4983 14.961 2.67953L12.8145 11.4481C12.7536 11.6967 12.5144 11.8588 12.2608 11.8241L7.56942 11.1766L5.33211 13.7664C5.20836 13.9096 5.01456 13.9711 4.83114 13.9246C4.6477 13.8781 4.50644 13.7316 4.4659 13.5467L3.68368 9.98324L1.212 8.00863C1.07265 7.89707 1.00353 7.71931 1.03035 7.54281C1.05731 7.36628 1.1765 7.21715 1.34285 7.15218L14.294 2.09457ZM4.70028 9.94417L5.12118 11.867L5.8409 10.2899L5.88094 10.2176C5.89632 10.1948 5.9137 10.1732 5.9327 10.1532L8.08407 7.8807L4.70028 9.94417ZM2.51375 7.76742L4.17391 9.09457L10.7677 5.07503C10.9816 4.9446 11.2595 4.99249 11.4171 5.18734C11.5746 5.38222 11.5631 5.66361 11.3907 5.84554L7.32723 10.1346L11.9493 10.7713L13.7598 3.37582L2.51375 7.76742Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1 -1
components/InteractionsPreview.tsx
··· 90 ); 91 }; 92 93 - const TagPopover = (props: { tags: string[] }) => { 94 return ( 95 <Popover 96 className="p-2! max-w-xs"
··· 90 ); 91 }; 92 93 + export const TagPopover = (props: { tags: string[] }) => { 94 return ( 95 <Popover 96 className="p-2! max-w-xs"
+5
components/Pages/useHasBackgroundImage.ts
···
··· 1 + import { useHasBackgroundImageContext } from "components/ThemeManager/ThemeProvider"; 2 + 3 + export function useHasBackgroundImage(entityID?: string | null) { 4 + return useHasBackgroundImageContext(); 5 + }
+159 -60
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 { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 import type { 12 NormalizedDocument, ··· 15 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 16 17 import Link from "next/link"; 18 - import { InteractionPreview } from "./InteractionsPreview"; 19 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 21 export const PostListing = (props: Post) => { 22 let pubRecord = props.publication?.pubRecord as ··· 36 let isStandalone = !pubRecord; 37 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 38 let themeRecord = pubRecord?.theme || postRecord?.theme; 39 let backgroundImage = 40 themeRecord?.backgroundImage?.image?.ref && uri 41 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) ··· 56 let tags = (postRecord?.tags as string[] | undefined) || []; 57 58 // For standalone posts, link directly to the document 59 - let postHref = props.publication 60 ? `${props.publication.href}/${postUri.rkey}` 61 : `/p/${postUri.host}/${postUri.rkey}`; 62 63 return ( 64 - <BaseThemeProvider {...theme} local> 65 - <div 66 - style={{ 67 - backgroundImage: backgroundImage 68 - ? `url(${backgroundImage})` 69 - : undefined, 70 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 71 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 72 - }} 73 - className={`no-underline! flex flex-row gap-2 w-full relative 74 - bg-bg-leaflet 75 - border border-border-light rounded-lg 76 - sm:p-2 p-2 selected-outline 77 - hover:outline-accent-contrast hover:border-accent-contrast 78 - `} 79 - > 80 - <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 81 <div 82 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 83 - style={{ 84 - backgroundColor: showPageBackground 85 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 86 - : "transparent", 87 - }} 88 > 89 - <h3 className="text-primary truncate">{postRecord.title}</h3> 90 91 - <p className="text-secondary italic">{postRecord.description}</p> 92 - <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"> 93 - {props.publication && pubRecord && ( 94 - <PubInfo 95 - href={props.publication.href} 96 - pubRecord={pubRecord} 97 - uri={props.publication.uri} 98 - /> 99 - )} 100 - <div className="flex flex-row justify-between gap-2 items-center w-full"> 101 - <PostInfo publishedAt={postRecord.publishedAt} /> 102 - <InteractionPreview 103 - postUrl={postHref} 104 - quotesCount={quotes} 105 - commentsCount={comments} 106 - tags={tags} 107 - showComments={pubRecord?.preferences?.showComments !== false} 108 - showMentions={pubRecord?.preferences?.showMentions !== false} 109 - share 110 - /> 111 </div> 112 </div> 113 </div> 114 </div> 115 - </BaseThemeProvider> 116 ); 117 }; 118 ··· 123 }) => { 124 return ( 125 <div className="flex flex-col md:w-auto shrink-0 w-full"> 126 - <hr className="md:hidden block border-border-light mb-2" /> 127 <Link 128 href={props.href} 129 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 130 > 131 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 132 {props.pubRecord.name} ··· 135 ); 136 }; 137 138 - const PostInfo = (props: { publishedAt: string | undefined }) => { 139 let localizedDate = useLocalizedDate(props.publishedAt || "", { 140 year: "numeric", 141 month: "short", 142 day: "numeric", 143 }); 144 return ( 145 - <div className="flex gap-2 items-center shrink-0 self-start"> 146 - {props.publishedAt && ( 147 - <> 148 - <div className="shrink-0">{localizedDate}</div> 149 - </> 150 - )} 151 </div> 152 ); 153 };
··· 1 "use client"; 2 import { AtUri } from "@atproto/api"; 3 import { PubIcon } from "components/ActionBar/Publications"; 4 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 5 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 6 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 7 import type { 8 NormalizedDocument, ··· 11 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 12 13 import Link from "next/link"; 14 + import { InteractionPreview, TagPopover } from "./InteractionsPreview"; 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 + import { useSmoker } from "./Toast"; 17 + import { Separator } from "./Layout"; 18 + import { SpeedyLink } from "./SpeedyLink"; 19 + import { CommentTiny } from "./Icons/CommentTiny"; 20 + import { QuoteTiny } from "./Icons/QuoteTiny"; 21 + import { ShareTiny } from "./Icons/ShareTiny"; 22 23 export const PostListing = (props: Post) => { 24 let pubRecord = props.publication?.pubRecord as ··· 38 let isStandalone = !pubRecord; 39 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 40 let themeRecord = pubRecord?.theme || postRecord?.theme; 41 + let el = document?.getElementById(`post-listing-${postUri}`); 42 + 43 + let hasBackgroundImage = 44 + !!themeRecord?.backgroundImage?.image && 45 + el && 46 + Number(window.getComputedStyle(el).getPropertyValue("--bg-page-alpha")) < 47 + 0.7; 48 + 49 let backgroundImage = 50 themeRecord?.backgroundImage?.image?.ref && uri 51 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) ··· 66 let tags = (postRecord?.tags as string[] | undefined) || []; 67 68 // For standalone posts, link directly to the document 69 + let postUrl = props.publication 70 ? `${props.publication.href}/${postUri.rkey}` 71 : `/p/${postUri.host}/${postUri.rkey}`; 72 73 return ( 74 + <div className="postListing flex flex-col gap-1"> 75 + <div className="text-sm text-tertiary flex gap-1 items-center px-1 "> 76 + <div className="flex "> 77 + <div className="sm:w-4 w-4 sm:h-4 h-4 rounded-full bg-test border border-border-light first:ml-0 -ml-2" /> 78 + <div className="sm:w-4 w-4 sm:h-4 h-4 rounded-full bg-test border border-border-light first:ml-0 -ml-2" /> 79 + </div> 80 + others recommend 81 + </div> 82 + <BaseThemeProvider {...theme} local> 83 <div 84 + id={`post-listing-${postUri}`} 85 + className={` 86 + relative 87 + flex flex-col overflow-hidden 88 + selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast 89 + hover:border-accent-contrast 90 + ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} 91 + style={ 92 + hasBackgroundImage 93 + ? { 94 + backgroundImage: backgroundImage 95 + ? `url(${backgroundImage})` 96 + : undefined, 97 + backgroundRepeat: backgroundImageRepeat 98 + ? "repeat" 99 + : "no-repeat", 100 + backgroundSize: backgroundImageRepeat 101 + ? `${backgroundImageSize}px` 102 + : "cover", 103 + } 104 + : {} 105 + } 106 > 107 + <Link 108 + className="h-full w-full absolute top-0 left-0" 109 + href={postUrl} 110 + /> 111 + {postRecord.coverImage && ( 112 + <div className="postListingImage"> 113 + <img 114 + src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} 115 + alt={postRecord.title || ""} 116 + className="w-full h-auto aspect-video rounded" 117 + /> 118 + </div> 119 + )} 120 + <div className="postListingInfo px-3 py-2"> 121 + <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> 122 + {postRecord.title} 123 + </h3> 124 125 + <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> 126 + {postRecord.description} 127 + </p> 128 + <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"> 129 + {props.publication && pubRecord && ( 130 + <PubInfo 131 + href={props.publication.href} 132 + pubRecord={pubRecord} 133 + uri={props.publication.uri} 134 + /> 135 + )} 136 + <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> 137 + <PostDate publishedAt={postRecord.publishedAt} /> 138 + {tags.length === 0 ? null : <TagPopover tags={tags!} />} 139 + </div> 140 </div> 141 </div> 142 </div> 143 + </BaseThemeProvider> 144 + <div className="text-sm flex justify-between text-tertiary"> 145 + <Interactions 146 + postUrl={postUrl} 147 + quotesCount={quotes} 148 + commentsCount={comments} 149 + tags={tags} 150 + showComments={pubRecord?.preferences?.showComments !== false} 151 + showMentions={pubRecord?.preferences?.showMentions !== false} 152 + />{" "} 153 + <Share postUrl={postUrl} /> 154 </div> 155 + </div> 156 ); 157 }; 158 ··· 163 }) => { 164 return ( 165 <div className="flex flex-col md:w-auto shrink-0 w-full"> 166 + <hr className="md:hidden block border-border-light mb-1" /> 167 <Link 168 href={props.href} 169 + className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center md:w-fit relative shrink-0" 170 > 171 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 172 {props.pubRecord.name} ··· 175 ); 176 }; 177 178 + const PostDate = (props: { publishedAt: string | undefined }) => { 179 let localizedDate = useLocalizedDate(props.publishedAt || "", { 180 year: "numeric", 181 month: "short", 182 day: "numeric", 183 }); 184 + if (props.publishedAt) { 185 + return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; 186 + } else return null; 187 + }; 188 + 189 + const Interactions = (props: { 190 + quotesCount: number; 191 + commentsCount: number; 192 + tags?: string[]; 193 + postUrl: string; 194 + showComments: boolean; 195 + showMentions: boolean; 196 + }) => { 197 return ( 198 + <div 199 + className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 200 + > 201 + <div className="postListingsInteractions flex gap-3"> 202 + {!props.showMentions || props.quotesCount === 0 ? null : ( 203 + <SpeedyLink 204 + aria-label="Post quotes" 205 + href={`${props.postUrl}?interactionDrawer=quotes`} 206 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 207 + > 208 + <QuoteTiny /> {props.quotesCount} 209 + </SpeedyLink> 210 + )} 211 + {!props.showComments || props.commentsCount === 0 ? null : ( 212 + <SpeedyLink 213 + aria-label="Post comments" 214 + href={`${props.postUrl}?interactionDrawer=comments`} 215 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 216 + > 217 + <CommentTiny /> {props.commentsCount} 218 + </SpeedyLink> 219 + )} 220 + </div> 221 </div> 222 ); 223 }; 224 + 225 + const Share = (props: { postUrl: string }) => { 226 + let smoker = useSmoker(); 227 + return ( 228 + <button 229 + id={`copy-post-link-${props.postUrl}`} 230 + className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" 231 + onClick={(e) => { 232 + e.stopPropagation(); 233 + e.preventDefault(); 234 + let mouseX = e.clientX; 235 + let mouseY = e.clientY; 236 + 237 + if (!props.postUrl) return; 238 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 239 + 240 + smoker({ 241 + text: <strong>Copied Link!</strong>, 242 + position: { 243 + y: mouseY, 244 + x: mouseX, 245 + }, 246 + }); 247 + }} 248 + > 249 + Share <ShareTiny /> 250 + </button> 251 + ); 252 + };
+25 -16
components/ThemeManager/ThemeProvider.tsx
··· 8 export function useCardBorderHiddenContext() { 9 return useContext(CardBorderHiddenContext); 10 } 11 import { 12 colorToString, 13 useColorAttribute, ··· 79 80 return ( 81 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 82 - <BaseThemeProvider 83 - local={props.local} 84 - bgLeaflet={bgLeaflet} 85 - bgPage={bgPage} 86 - primary={primary} 87 - highlight2={highlight2} 88 - highlight3={highlight3} 89 - highlight1={highlight1?.data.value} 90 - accent1={accent1} 91 - accent2={accent2} 92 - showPageBackground={showPageBackground} 93 - pageWidth={pageWidth?.data.value} 94 - hasBackgroundImage={hasBackgroundImage} 95 - > 96 - {props.children} 97 - </BaseThemeProvider> 98 </CardBorderHiddenContext.Provider> 99 ); 100 }
··· 8 export function useCardBorderHiddenContext() { 9 return useContext(CardBorderHiddenContext); 10 } 11 + 12 + // Context for hasBackgroundImage 13 + export const HasBackgroundImageContext = createContext<boolean>(false); 14 + 15 + export function useHasBackgroundImageContext() { 16 + return useContext(HasBackgroundImageContext); 17 + } 18 import { 19 colorToString, 20 useColorAttribute, ··· 86 87 return ( 88 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 89 + <HasBackgroundImageContext.Provider value={hasBackgroundImage}> 90 + <BaseThemeProvider 91 + local={props.local} 92 + bgLeaflet={bgLeaflet} 93 + bgPage={bgPage} 94 + primary={primary} 95 + highlight2={highlight2} 96 + highlight3={highlight3} 97 + highlight1={highlight1?.data.value} 98 + accent1={accent1} 99 + accent2={accent2} 100 + showPageBackground={showPageBackground} 101 + pageWidth={pageWidth?.data.value} 102 + hasBackgroundImage={hasBackgroundImage} 103 + > 104 + {props.children} 105 + </BaseThemeProvider> 106 + </HasBackgroundImageContext.Provider> 107 </CardBorderHiddenContext.Provider> 108 ); 109 }