a tool for shared writing and social publishing

updated postListing design

+274 -93
+63 -15
app/(home-pages)/reader/InboxContent.tsx
··· 4 4 import type { Cursor, Post } from "./getReaderFeed"; 5 5 import useSWRInfinite from "swr/infinite"; 6 6 import { getReaderFeed } from "./getReaderFeed"; 7 - import { useEffect, useRef } from "react"; 7 + import { useEffect, useRef, useState } from "react"; 8 8 import Link from "next/link"; 9 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"; 10 14 11 15 export const InboxContent = (props: { 12 16 posts: Post[]; ··· 60 64 61 65 return () => observer.disconnect(); 62 66 }, [data, size, setSize, isValidating]); 67 + let [searchValue, setSearchValue] = useState(""); 68 + let [sort, setSort] = useState<"recent" | "popular">("popular"); 63 69 64 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 + ); 65 83 66 84 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 67 85 86 + let hasBackgroundImage = useHasBackgroundImage(); 87 + 68 88 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... 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> 82 116 </div> 83 - )} 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> 84 132 </div> 85 133 ); 86 134 };
+1
app/(home-pages)/reader/page.tsx
··· 6 6 7 7 export default async function Reader(props: {}) { 8 8 let posts = await getReaderFeed(); 9 + 9 10 return ( 10 11 <DashboardLayout 11 12 id="reader"
+1 -1
components/ActionBar/NavigationButtons.tsx
··· 53 53 labelOnMobile={!props.compactOnMobile} 54 54 icon={<WriterSmall />} 55 55 label="Write" 56 - className={` w-fit! ${current ? "bg-bg-page! border-border-light!" : ""}`} 56 + className={`${current ? "bg-bg-page! border-border-light!" : ""}`} 57 57 /> 58 58 </SpeedyLink> 59 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 90 ); 91 91 }; 92 92 93 - const TagPopover = (props: { tags: string[] }) => { 93 + export const TagPopover = (props: { tags: string[] }) => { 94 94 return ( 95 95 <Popover 96 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 1 "use client"; 2 2 import { AtUri } from "@atproto/api"; 3 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 4 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 5 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 - import { useSmoker } from "components/Toast"; 10 6 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 7 import type { 12 8 NormalizedDocument, ··· 15 11 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 16 12 17 13 import Link from "next/link"; 18 - import { InteractionPreview } from "./InteractionsPreview"; 14 + import { InteractionPreview, TagPopover } from "./InteractionsPreview"; 19 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"; 20 22 21 23 export const PostListing = (props: Post) => { 22 24 let pubRecord = props.publication?.pubRecord as ··· 36 38 let isStandalone = !pubRecord; 37 39 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 38 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 + 39 49 let backgroundImage = 40 50 themeRecord?.backgroundImage?.image?.ref && uri 41 51 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) ··· 56 66 let tags = (postRecord?.tags as string[] | undefined) || []; 57 67 58 68 // For standalone posts, link directly to the document 59 - let postHref = props.publication 69 + let postUrl = props.publication 60 70 ? `${props.publication.href}/${postUri.rkey}` 61 71 : `/p/${postUri.host}/${postUri.rkey}`; 62 72 63 73 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} /> 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> 81 83 <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 - }} 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 + } 88 106 > 89 - <h3 className="text-primary truncate">{postRecord.title}</h3> 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> 90 124 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 - /> 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> 111 140 </div> 112 141 </div> 113 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} /> 114 154 </div> 115 - </BaseThemeProvider> 155 + </div> 116 156 ); 117 157 }; 118 158 ··· 123 163 }) => { 124 164 return ( 125 165 <div className="flex flex-col md:w-auto shrink-0 w-full"> 126 - <hr className="md:hidden block border-border-light mb-2" /> 166 + <hr className="md:hidden block border-border-light mb-1" /> 127 167 <Link 128 168 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" 169 + className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center md:w-fit relative shrink-0" 130 170 > 131 171 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 132 172 {props.pubRecord.name} ··· 135 175 ); 136 176 }; 137 177 138 - const PostInfo = (props: { publishedAt: string | undefined }) => { 178 + const PostDate = (props: { publishedAt: string | undefined }) => { 139 179 let localizedDate = useLocalizedDate(props.publishedAt || "", { 140 180 year: "numeric", 141 181 month: "short", 142 182 day: "numeric", 143 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 + }) => { 144 197 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 - )} 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> 151 221 </div> 152 222 ); 153 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 8 export function useCardBorderHiddenContext() { 9 9 return useContext(CardBorderHiddenContext); 10 10 } 11 + 12 + // Context for hasBackgroundImage 13 + export const HasBackgroundImageContext = createContext<boolean>(false); 14 + 15 + export function useHasBackgroundImageContext() { 16 + return useContext(HasBackgroundImageContext); 17 + } 11 18 import { 12 19 colorToString, 13 20 useColorAttribute, ··· 79 86 80 87 return ( 81 88 <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> 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> 98 107 </CardBorderHiddenContext.Provider> 99 108 ); 100 109 }