tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
a tool for shared writing and social publishing
284
fork
atom
overview
issues
27
pulls
pipelines
updated postListing design
cozylittle.house
4 days ago
55bed223
acbb9604
+274
-93
8 changed files
expand all
collapse all
unified
split
app
(home-pages)
reader
InboxContent.tsx
page.tsx
components
ActionBar
NavigationButtons.tsx
Icons
ShareTiny.tsx
InteractionsPreview.tsx
Pages
useHasBackgroundImage.ts
PostListing.tsx
ThemeManager
ThemeProvider.tsx
+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
7
-
import { useEffect, useRef } from "react";
7
7
+
import { useEffect, useRef, useState } from "react";
8
8
import Link from "next/link";
9
9
import { PostListing } from "components/PostListing";
10
10
+
import { SortSmall } from "components/Icons/SortSmall";
11
11
+
import { Input } from "components/Input";
12
12
+
import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage";
13
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
67
+
let [searchValue, setSearchValue] = useState("");
68
68
+
let [sort, setSort] = useState<"recent" | "popular">("popular");
63
69
64
70
const allPosts = data ? data.flatMap((page) => page.posts) : [];
71
71
+
const postTitles = allPosts.map((p) => {
72
72
+
p.documents.data?.title;
73
73
+
});
74
74
+
const filteredPosts = allPosts
75
75
+
.filter((p) =>
76
76
+
p.documents.data?.title.toLowerCase().includes(searchValue.toLowerCase()),
77
77
+
)
78
78
+
.sort(
79
79
+
(a, b) =>
80
80
+
new Date(b.documents.data?.publishedAt || 0).getTime() -
81
81
+
new Date(a.documents.data?.publishedAt || 0).getTime(),
82
82
+
);
65
83
66
84
if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />;
67
85
86
86
+
let hasBackgroundImage = useHasBackgroundImage();
87
87
+
68
88
return (
69
69
-
<div className="flex flex-col gap-3 relative">
70
70
-
{allPosts.map((p) => (
71
71
-
<PostListing {...p} key={p.documents.uri} />
72
72
-
))}
73
73
-
{/* Trigger element for loading more posts */}
74
74
-
<div
75
75
-
ref={loadMoreRef}
76
76
-
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
77
77
-
aria-hidden="true"
78
78
-
/>
79
79
-
{isValidating && (
80
80
-
<div className="text-center text-tertiary py-4">
81
81
-
Loading more posts...
89
89
+
<div className="flex flex-row gap-6">
90
90
+
<div className="flex flex-col gap-6 relative">
91
91
+
<div className="flex justify-between gap-4 text-tertiary">
92
92
+
<Input
93
93
+
className={`inboxSearchInput
94
94
+
appearance-none! outline-hidden!
95
95
+
w-full min-w-0 text-primary relative px-1
96
96
+
border rounded-md border-border-light focus-within:border-border
97
97
+
bg-transparent ${hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `}
98
98
+
type="text"
99
99
+
id="inbox-search"
100
100
+
size={1}
101
101
+
placeholder="search posts..."
102
102
+
value={searchValue}
103
103
+
onChange={(e) => {
104
104
+
setSearchValue(e.currentTarget.value);
105
105
+
}}
106
106
+
/>
107
107
+
<button
108
108
+
className="flex gap-1"
109
109
+
onClick={() => {
110
110
+
setSort(sort === "popular" ? "recent" : "popular");
111
111
+
}}
112
112
+
>
113
113
+
{sort === "popular" ? "Popular" : "Recent"}
114
114
+
<SortSmall />
115
115
+
</button>
82
116
</div>
83
83
-
)}
117
117
+
{filteredPosts.map((p) => (
118
118
+
<PostListing {...p} key={p.documents.uri} />
119
119
+
))}
120
120
+
{/* Trigger element for loading more posts */}
121
121
+
<div
122
122
+
ref={loadMoreRef}
123
123
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
124
124
+
aria-hidden="true"
125
125
+
/>
126
126
+
{isValidating && (
127
127
+
<div className="text-center text-tertiary py-4">
128
128
+
Loading more posts...
129
129
+
</div>
130
130
+
)}
131
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
+
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
56
-
className={` w-fit! ${current ? "bg-bg-page! border-border-light!" : ""}`}
56
56
+
className={`${current ? "bg-bg-page! border-border-light!" : ""}`}
57
57
/>
58
58
</SpeedyLink>
59
59
);
+19
components/Icons/ShareTiny.tsx
···
1
1
+
import { Props } from "./Props";
2
2
+
3
3
+
export const ShareTiny = (props: Props) => {
4
4
+
return (
5
5
+
<svg
6
6
+
width="16"
7
7
+
height="16"
8
8
+
viewBox="0 0 16 16"
9
9
+
fill="none"
10
10
+
xmlns="http://www.w3.org/2000/svg"
11
11
+
{...props}
12
12
+
>
13
13
+
<path
14
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
15
+
fill="currentColor"
16
16
+
/>
17
17
+
</svg>
18
18
+
);
19
19
+
};
+1
-1
components/InteractionsPreview.tsx
···
90
90
);
91
91
};
92
92
93
93
-
const TagPopover = (props: { tags: string[] }) => {
93
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
1
+
import { useHasBackgroundImageContext } from "components/ThemeManager/ThemeProvider";
2
2
+
3
3
+
export function useHasBackgroundImage(entityID?: string | null) {
4
4
+
return useHasBackgroundImageContext();
5
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
4
-
import { CommentTiny } from "components/Icons/CommentTiny";
5
5
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
6
6
-
import { Separator } from "components/Layout";
7
4
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
8
5
import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
9
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
18
-
import { InteractionPreview } from "./InteractionsPreview";
14
14
+
import { InteractionPreview, TagPopover } from "./InteractionsPreview";
19
15
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16
16
+
import { useSmoker } from "./Toast";
17
17
+
import { Separator } from "./Layout";
18
18
+
import { SpeedyLink } from "./SpeedyLink";
19
19
+
import { CommentTiny } from "./Icons/CommentTiny";
20
20
+
import { QuoteTiny } from "./Icons/QuoteTiny";
21
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
41
+
let el = document?.getElementById(`post-listing-${postUri}`);
42
42
+
43
43
+
let hasBackgroundImage =
44
44
+
!!themeRecord?.backgroundImage?.image &&
45
45
+
el &&
46
46
+
Number(window.getComputedStyle(el).getPropertyValue("--bg-page-alpha")) <
47
47
+
0.7;
48
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
59
-
let postHref = props.publication
69
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
64
-
<BaseThemeProvider {...theme} local>
65
65
-
<div
66
66
-
style={{
67
67
-
backgroundImage: backgroundImage
68
68
-
? `url(${backgroundImage})`
69
69
-
: undefined,
70
70
-
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
71
71
-
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
72
72
-
}}
73
73
-
className={`no-underline! flex flex-row gap-2 w-full relative
74
74
-
bg-bg-leaflet
75
75
-
border border-border-light rounded-lg
76
76
-
sm:p-2 p-2 selected-outline
77
77
-
hover:outline-accent-contrast hover:border-accent-contrast
78
78
-
`}
79
79
-
>
80
80
-
<Link className="h-full w-full absolute top-0 left-0" href={postHref} />
74
74
+
<div className="postListing flex flex-col gap-1">
75
75
+
<div className="text-sm text-tertiary flex gap-1 items-center px-1 ">
76
76
+
<div className="flex ">
77
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
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
79
+
</div>
80
80
+
others recommend
81
81
+
</div>
82
82
+
<BaseThemeProvider {...theme} local>
81
83
<div
82
82
-
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
83
83
-
style={{
84
84
-
backgroundColor: showPageBackground
85
85
-
? "rgba(var(--bg-page), var(--bg-page-alpha))"
86
86
-
: "transparent",
87
87
-
}}
84
84
+
id={`post-listing-${postUri}`}
85
85
+
className={`
86
86
+
relative
87
87
+
flex flex-col overflow-hidden
88
88
+
selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast
89
89
+
hover:border-accent-contrast
90
90
+
${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `}
91
91
+
style={
92
92
+
hasBackgroundImage
93
93
+
? {
94
94
+
backgroundImage: backgroundImage
95
95
+
? `url(${backgroundImage})`
96
96
+
: undefined,
97
97
+
backgroundRepeat: backgroundImageRepeat
98
98
+
? "repeat"
99
99
+
: "no-repeat",
100
100
+
backgroundSize: backgroundImageRepeat
101
101
+
? `${backgroundImageSize}px`
102
102
+
: "cover",
103
103
+
}
104
104
+
: {}
105
105
+
}
88
106
>
89
89
-
<h3 className="text-primary truncate">{postRecord.title}</h3>
107
107
+
<Link
108
108
+
className="h-full w-full absolute top-0 left-0"
109
109
+
href={postUrl}
110
110
+
/>
111
111
+
{postRecord.coverImage && (
112
112
+
<div className="postListingImage">
113
113
+
<img
114
114
+
src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)}
115
115
+
alt={postRecord.title || ""}
116
116
+
className="w-full h-auto aspect-video rounded"
117
117
+
/>
118
118
+
</div>
119
119
+
)}
120
120
+
<div className="postListingInfo px-3 py-2">
121
121
+
<h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base">
122
122
+
{postRecord.title}
123
123
+
</h3>
90
124
91
91
-
<p className="text-secondary italic">{postRecord.description}</p>
92
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
93
-
{props.publication && pubRecord && (
94
94
-
<PubInfo
95
95
-
href={props.publication.href}
96
96
-
pubRecord={pubRecord}
97
97
-
uri={props.publication.uri}
98
98
-
/>
99
99
-
)}
100
100
-
<div className="flex flex-row justify-between gap-2 items-center w-full">
101
101
-
<PostInfo publishedAt={postRecord.publishedAt} />
102
102
-
<InteractionPreview
103
103
-
postUrl={postHref}
104
104
-
quotesCount={quotes}
105
105
-
commentsCount={comments}
106
106
-
tags={tags}
107
107
-
showComments={pubRecord?.preferences?.showComments !== false}
108
108
-
showMentions={pubRecord?.preferences?.showMentions !== false}
109
109
-
share
110
110
-
/>
125
125
+
<p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm">
126
126
+
{postRecord.description}
127
127
+
</p>
128
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
129
+
{props.publication && pubRecord && (
130
130
+
<PubInfo
131
131
+
href={props.publication.href}
132
132
+
pubRecord={pubRecord}
133
133
+
uri={props.publication.uri}
134
134
+
/>
135
135
+
)}
136
136
+
<div className="flex flex-row justify-between gap-2 text-xs items-center w-full">
137
137
+
<PostDate publishedAt={postRecord.publishedAt} />
138
138
+
{tags.length === 0 ? null : <TagPopover tags={tags!} />}
139
139
+
</div>
111
140
</div>
112
141
</div>
113
142
</div>
143
143
+
</BaseThemeProvider>
144
144
+
<div className="text-sm flex justify-between text-tertiary">
145
145
+
<Interactions
146
146
+
postUrl={postUrl}
147
147
+
quotesCount={quotes}
148
148
+
commentsCount={comments}
149
149
+
tags={tags}
150
150
+
showComments={pubRecord?.preferences?.showComments !== false}
151
151
+
showMentions={pubRecord?.preferences?.showMentions !== false}
152
152
+
/>{" "}
153
153
+
<Share postUrl={postUrl} />
114
154
</div>
115
115
-
</BaseThemeProvider>
155
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
126
-
<hr className="md:hidden block border-border-light mb-2" />
166
166
+
<hr className="md:hidden block border-border-light mb-1" />
127
167
<Link
128
168
href={props.href}
129
129
-
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0"
169
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
138
-
const PostInfo = (props: { publishedAt: string | undefined }) => {
178
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
184
+
if (props.publishedAt) {
185
185
+
return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>;
186
186
+
} else return null;
187
187
+
};
188
188
+
189
189
+
const Interactions = (props: {
190
190
+
quotesCount: number;
191
191
+
commentsCount: number;
192
192
+
tags?: string[];
193
193
+
postUrl: string;
194
194
+
showComments: boolean;
195
195
+
showMentions: boolean;
196
196
+
}) => {
144
197
return (
145
145
-
<div className="flex gap-2 items-center shrink-0 self-start">
146
146
-
{props.publishedAt && (
147
147
-
<>
148
148
-
<div className="shrink-0">{localizedDate}</div>
149
149
-
</>
150
150
-
)}
198
198
+
<div
199
199
+
className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`}
200
200
+
>
201
201
+
<div className="postListingsInteractions flex gap-3">
202
202
+
{!props.showMentions || props.quotesCount === 0 ? null : (
203
203
+
<SpeedyLink
204
204
+
aria-label="Post quotes"
205
205
+
href={`${props.postUrl}?interactionDrawer=quotes`}
206
206
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
207
207
+
>
208
208
+
<QuoteTiny /> {props.quotesCount}
209
209
+
</SpeedyLink>
210
210
+
)}
211
211
+
{!props.showComments || props.commentsCount === 0 ? null : (
212
212
+
<SpeedyLink
213
213
+
aria-label="Post comments"
214
214
+
href={`${props.postUrl}?interactionDrawer=comments`}
215
215
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
216
216
+
>
217
217
+
<CommentTiny /> {props.commentsCount}
218
218
+
</SpeedyLink>
219
219
+
)}
220
220
+
</div>
151
221
</div>
152
222
);
153
223
};
224
224
+
225
225
+
const Share = (props: { postUrl: string }) => {
226
226
+
let smoker = useSmoker();
227
227
+
return (
228
228
+
<button
229
229
+
id={`copy-post-link-${props.postUrl}`}
230
230
+
className="flex gap-1 items-center hover:text-accent-contrast relative font-bold"
231
231
+
onClick={(e) => {
232
232
+
e.stopPropagation();
233
233
+
e.preventDefault();
234
234
+
let mouseX = e.clientX;
235
235
+
let mouseY = e.clientY;
236
236
+
237
237
+
if (!props.postUrl) return;
238
238
+
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
239
239
+
240
240
+
smoker({
241
241
+
text: <strong>Copied Link!</strong>,
242
242
+
position: {
243
243
+
y: mouseY,
244
244
+
x: mouseX,
245
245
+
},
246
246
+
});
247
247
+
}}
248
248
+
>
249
249
+
Share <ShareTiny />
250
250
+
</button>
251
251
+
);
252
252
+
};
+25
-16
components/ThemeManager/ThemeProvider.tsx
···
8
8
export function useCardBorderHiddenContext() {
9
9
return useContext(CardBorderHiddenContext);
10
10
}
11
11
+
12
12
+
// Context for hasBackgroundImage
13
13
+
export const HasBackgroundImageContext = createContext<boolean>(false);
14
14
+
15
15
+
export function useHasBackgroundImageContext() {
16
16
+
return useContext(HasBackgroundImageContext);
17
17
+
}
11
18
import {
12
19
colorToString,
13
20
useColorAttribute,
···
79
86
80
87
return (
81
88
<CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
82
82
-
<BaseThemeProvider
83
83
-
local={props.local}
84
84
-
bgLeaflet={bgLeaflet}
85
85
-
bgPage={bgPage}
86
86
-
primary={primary}
87
87
-
highlight2={highlight2}
88
88
-
highlight3={highlight3}
89
89
-
highlight1={highlight1?.data.value}
90
90
-
accent1={accent1}
91
91
-
accent2={accent2}
92
92
-
showPageBackground={showPageBackground}
93
93
-
pageWidth={pageWidth?.data.value}
94
94
-
hasBackgroundImage={hasBackgroundImage}
95
95
-
>
96
96
-
{props.children}
97
97
-
</BaseThemeProvider>
89
89
+
<HasBackgroundImageContext.Provider value={hasBackgroundImage}>
90
90
+
<BaseThemeProvider
91
91
+
local={props.local}
92
92
+
bgLeaflet={bgLeaflet}
93
93
+
bgPage={bgPage}
94
94
+
primary={primary}
95
95
+
highlight2={highlight2}
96
96
+
highlight3={highlight3}
97
97
+
highlight1={highlight1?.data.value}
98
98
+
accent1={accent1}
99
99
+
accent2={accent2}
100
100
+
showPageBackground={showPageBackground}
101
101
+
pageWidth={pageWidth?.data.value}
102
102
+
hasBackgroundImage={hasBackgroundImage}
103
103
+
>
104
104
+
{props.children}
105
105
+
</BaseThemeProvider>
106
106
+
</HasBackgroundImageContext.Provider>
98
107
</CardBorderHiddenContext.Provider>
99
108
);
100
109
}