@levo-so/blocks 0.3.25 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { IBlogCategory, IModuleStudioConfig, IPost } from "@levo-so/core";
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
AvatarFallbackV2,
|
|
5
|
+
AvatarGroup,
|
|
6
|
+
AvatarGroupCount,
|
|
7
|
+
AvatarImageV2,
|
|
8
|
+
Box,
|
|
9
|
+
Button,
|
|
10
|
+
Container,
|
|
11
|
+
Heading,
|
|
12
|
+
Icon,
|
|
13
|
+
type ILevoBlockBaseProps,
|
|
14
|
+
type IStudioSource,
|
|
15
|
+
Media,
|
|
16
|
+
Section,
|
|
17
|
+
Typography,
|
|
18
|
+
useBlockContext,
|
|
19
|
+
useContextEngine,
|
|
20
|
+
} from "@levo-so/studio";
|
|
21
|
+
import dayjs from "dayjs";
|
|
22
|
+
|
|
23
|
+
import { ClientOnly } from "../event/ClientOnly";
|
|
24
|
+
import type { IBlogListing4Content } from "./blog-listing-4.schema";
|
|
25
|
+
|
|
26
|
+
const getAuthorsText = (authors: any[]): string => {
|
|
27
|
+
// `authors` is variable-bound and can resolve to null / a non-array at runtime —
|
|
28
|
+
// an unguarded `.map` here throws and blanks the whole studio canvas.
|
|
29
|
+
if (!Array.isArray(authors) || authors.length === 0) return "Anonymous";
|
|
30
|
+
const names = authors.map((a) => [a?.first_name].filter(Boolean).join(" ") || "Anonymous");
|
|
31
|
+
|
|
32
|
+
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
|
33
|
+
// if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]}`;
|
|
34
|
+
|
|
35
|
+
// 4+ authors: show first 3 names then remaining count
|
|
36
|
+
const remaining = names.length - 2;
|
|
37
|
+
return `${names[0]}, ${names[1]} and ${remaining} ${remaining === 1 ? "other" : "others"}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trimmed, lowercased slug for category matching; null for anything that isn't a non-empty
|
|
42
|
+
* string. Both sides are runtime data (page record, hand-typed or AI-filled content), and a
|
|
43
|
+
* throw during render blanks the whole studio canvas.
|
|
44
|
+
*/
|
|
45
|
+
const normalizeSlug = (value: unknown) =>
|
|
46
|
+
typeof value === "string" && value.trim() ? value.trim().toLowerCase() : null;
|
|
47
|
+
|
|
48
|
+
/** */
|
|
49
|
+
interface IBlogMeta {
|
|
50
|
+
total: number;
|
|
51
|
+
pages: number;
|
|
52
|
+
studio_config?: (IModuleStudioConfig & {
|
|
53
|
+
module: IStudioSource["module"];
|
|
54
|
+
})[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const AuthorRenderer: React.FC<{
|
|
58
|
+
index: number;
|
|
59
|
+
authors: any[];
|
|
60
|
+
}> = ({ authors, index }) => {
|
|
61
|
+
if (authors?.length === 1) {
|
|
62
|
+
const authorNameArr = [authors?.[0]?.first_name, authors?.[0]?.last_name].filter(Boolean);
|
|
63
|
+
|
|
64
|
+
const authorName = authorNameArr.join(" ");
|
|
65
|
+
|
|
66
|
+
const authorNameFallback = authorNameArr
|
|
67
|
+
?.map((v) => v?.[0])
|
|
68
|
+
.join("")
|
|
69
|
+
.toUpperCase();
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Box elementKey={`blogs.${index}.blog-author`}>
|
|
73
|
+
<Avatar className="h-8 w-8">
|
|
74
|
+
<AvatarImageV2 src={authors?.[0]?.profile_picture?.location} />
|
|
75
|
+
<AvatarFallbackV2>{authorNameFallback}</AvatarFallbackV2>
|
|
76
|
+
</Avatar>
|
|
77
|
+
<Box elementKey={`blogs.${index}.author-info-wrapper`}>
|
|
78
|
+
<Typography elementKey={`blogs.${index}.author-name`}>
|
|
79
|
+
{authorName ? authorName : "Anonymous"}
|
|
80
|
+
</Typography>
|
|
81
|
+
{/* <Typography elementKey={`blogs.${index}.author-description`}>
|
|
82
|
+
{authors?.[0]?.username}
|
|
83
|
+
</Typography> */}
|
|
84
|
+
</Box>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (authors?.length > 1) {
|
|
90
|
+
const firstThreeAuthors = authors?.slice(0, 2);
|
|
91
|
+
const remainingAuthors = authors?.slice(2);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Box
|
|
95
|
+
elementKey={`blogs.${index}.blog-authors`}
|
|
96
|
+
style={{
|
|
97
|
+
display: "flex",
|
|
98
|
+
gap: "16px",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<AvatarGroup className="">
|
|
103
|
+
{firstThreeAuthors?.map((author, authorIndex) => {
|
|
104
|
+
const authorNameArr = [author?.first_name, author?.last_name].filter(Boolean);
|
|
105
|
+
|
|
106
|
+
const authorName = authorNameArr.join(" ");
|
|
107
|
+
|
|
108
|
+
const authorNameFallback = authorNameArr
|
|
109
|
+
?.map((v) => v?.[0])
|
|
110
|
+
.join("")
|
|
111
|
+
.toUpperCase();
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Avatar key={`blogs.${index}.blog-author.${authorIndex}`} className="h-8 w-8">
|
|
115
|
+
<AvatarImageV2
|
|
116
|
+
src={author?.profile_picture?.location}
|
|
117
|
+
className="object-cover"
|
|
118
|
+
alt={authorName}
|
|
119
|
+
/>
|
|
120
|
+
<AvatarFallbackV2>{authorNameFallback}</AvatarFallbackV2>
|
|
121
|
+
</Avatar>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
{remainingAuthors?.length > 0 && (
|
|
125
|
+
<AvatarGroupCount
|
|
126
|
+
className="h-8 w-8"
|
|
127
|
+
style={{
|
|
128
|
+
background: "var(--color-brand-secondary)",
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
+{remainingAuthors?.length}
|
|
132
|
+
</AvatarGroupCount>
|
|
133
|
+
)}
|
|
134
|
+
</AvatarGroup>
|
|
135
|
+
<Typography
|
|
136
|
+
elementKey={`blogs.${index}.author-name`}
|
|
137
|
+
title={authors?.map((v) => v?.first_name)?.join(", ")}
|
|
138
|
+
>
|
|
139
|
+
{getAuthorsText(authors)}
|
|
140
|
+
</Typography>
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return <Box elementKey={`blogs.${index}.blog-author`} />;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const BlogListing4: React.FC<ILevoBlockBaseProps<IBlogListing4Content>> = ({ content }) => {
|
|
149
|
+
const { response, query, setQuery } = useContextEngine<IPost, IBlogMeta>("blogs");
|
|
150
|
+
const { isStudio, blockData, pageContext } = useBlockContext();
|
|
151
|
+
|
|
152
|
+
// Same page-1 fallback as useContextEngine's default query; Previous never requests below page 1.
|
|
153
|
+
const handleNext = () => setQuery({ page: (query?.page || 1) + 1 });
|
|
154
|
+
const handlePrevious = () => setQuery({ page: Math.max(1, (query?.page || 1) - 1) });
|
|
155
|
+
|
|
156
|
+
const blogStudioConfig = response?.meta?.studio_config?.find(
|
|
157
|
+
(item) => !item.block_instance_id && item?.module === "blog",
|
|
158
|
+
);
|
|
159
|
+
const fallbackConfig = (blockData as any)?.config?.blogs?.[0]?.blog?.action?.value;
|
|
160
|
+
|
|
161
|
+
// Instances saved before the category row existed have no content for it; rendering the
|
|
162
|
+
// empty wrapper would still add the container's row-gap between header and grid.
|
|
163
|
+
const hasCategoryPills = !!content?.["categories-all"] || !!content?.categories?.length;
|
|
164
|
+
|
|
165
|
+
// Set only on a blog category page (`blog_category` page source). Read from page context
|
|
166
|
+
// rather than the URL so the selected pill is right in the server render, with no flip
|
|
167
|
+
// after hydration. Cast: page context values are untyped records keyed by source identifier.
|
|
168
|
+
const currentCategorySlug = normalizeSlug(
|
|
169
|
+
(pageContext?.blog_category as IBlogCategory | undefined)?.slug,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Section elementKey="layout">
|
|
174
|
+
<Container elementKey="container">
|
|
175
|
+
<Box elementKey="header-wrapper">
|
|
176
|
+
<Box elementKey="title-wrapper">
|
|
177
|
+
<Icon elementKey="header-icon" />
|
|
178
|
+
<Box elementKey="header-text-wrapper">
|
|
179
|
+
<Heading elementKey="header" />
|
|
180
|
+
<Typography elementKey="description" />
|
|
181
|
+
</Box>
|
|
182
|
+
</Box>
|
|
183
|
+
<Box elementKey="header-ctas_levoGroup" data-levo_group>
|
|
184
|
+
{content?.["header-ctas"]?.map((v, index: number) => (
|
|
185
|
+
<Button
|
|
186
|
+
key={`header-ctas.${index}`}
|
|
187
|
+
elementKey={`header-ctas.${index}.header-cta`}
|
|
188
|
+
data-levo_group_item
|
|
189
|
+
/>
|
|
190
|
+
))}
|
|
191
|
+
</Box>
|
|
192
|
+
</Box>
|
|
193
|
+
{hasCategoryPills && (
|
|
194
|
+
<Box elementKey="categories-wrapper">
|
|
195
|
+
<Box elementKey="categories_levoGroup" data-levo_group>
|
|
196
|
+
{/* Not a group item, so it stays first and keeps its own all-blogs link even
|
|
197
|
+
when the category pills get populated from data. Selected on every page
|
|
198
|
+
that isn't a category page. */}
|
|
199
|
+
<Button
|
|
200
|
+
elementKey="categories-all"
|
|
201
|
+
data-state={currentCategorySlug ? "inactive" : "active"}
|
|
202
|
+
aria-current={currentCategorySlug ? undefined : "page"}
|
|
203
|
+
/>
|
|
204
|
+
{content?.categories?.map((item, index: number) => {
|
|
205
|
+
const isSelected =
|
|
206
|
+
!!currentCategorySlug && normalizeSlug(item?.slug) === currentCategorySlug;
|
|
207
|
+
return (
|
|
208
|
+
<Button
|
|
209
|
+
key={`categories.${index}`}
|
|
210
|
+
elementKey={`categories.${index}.category`}
|
|
211
|
+
data-levo_group_item
|
|
212
|
+
data-state={isSelected ? "active" : "inactive"}
|
|
213
|
+
aria-current={isSelected ? "page" : undefined}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
})}
|
|
217
|
+
</Box>
|
|
218
|
+
</Box>
|
|
219
|
+
)}
|
|
220
|
+
<Box elementKey="blogs_levoGroup" data-levo_group>
|
|
221
|
+
{response?.data?.map((item, index: number) => (
|
|
222
|
+
<Box
|
|
223
|
+
key={`blogs.${index}`}
|
|
224
|
+
data-levo_group_item
|
|
225
|
+
elementKey={`blogs.${index}.blog`}
|
|
226
|
+
config={
|
|
227
|
+
(isStudio && blogStudioConfig?.preview_url) ||
|
|
228
|
+
(!isStudio && blogStudioConfig?.live_url)
|
|
229
|
+
? {
|
|
230
|
+
action: {
|
|
231
|
+
type: "external_link",
|
|
232
|
+
value:
|
|
233
|
+
isStudio && blogStudioConfig.preview_url
|
|
234
|
+
? blogStudioConfig.preview_url.replace(/\{[^}]+\}/g, item?.slug)
|
|
235
|
+
: !isStudio && blogStudioConfig.live_url
|
|
236
|
+
? blogStudioConfig.live_url.replace(/\{[^}]+\}/g, item?.slug)
|
|
237
|
+
: "",
|
|
238
|
+
options: {},
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
: fallbackConfig
|
|
242
|
+
? {
|
|
243
|
+
action: {
|
|
244
|
+
type: "external_link" as const,
|
|
245
|
+
value: fallbackConfig.replace(/\{[^}]+\}/g, item?.slug),
|
|
246
|
+
options: {},
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
: {}
|
|
250
|
+
}
|
|
251
|
+
>
|
|
252
|
+
<Media
|
|
253
|
+
elementKey={`blogs.${index}.media`}
|
|
254
|
+
alt="Blog Media"
|
|
255
|
+
media={item?.cover_image ?? undefined}
|
|
256
|
+
/>
|
|
257
|
+
<Box elementKey={`blogs.${index}.blog-info-wrapper`}>
|
|
258
|
+
<Box elementKey={`blogs.${index}.blog-content`}>
|
|
259
|
+
<Box elementKey={`blogs.${index}.blog-header-wrapper`}>
|
|
260
|
+
{item?.published_at && dayjs(item?.published_at).isValid() && (
|
|
261
|
+
<Box elementKey={`blogs.${index}.dateWrapper`}>
|
|
262
|
+
<Icon elementKey={`blogs.${index}.dateIcon`} />
|
|
263
|
+
<ClientOnly>
|
|
264
|
+
<Typography elementKey={`blogs.${index}.date`}>
|
|
265
|
+
{dayjs(item?.published_at).format("MMM D, YYYY")}
|
|
266
|
+
</Typography>
|
|
267
|
+
</ClientOnly>
|
|
268
|
+
</Box>
|
|
269
|
+
)}
|
|
270
|
+
<Box elementKey={`blogs.${index}.blog-header`}>
|
|
271
|
+
<Icon elementKey={`blogs.${index}.header-icon`} />
|
|
272
|
+
<Typography elementKey={`blogs.${index}.blog-type`}>
|
|
273
|
+
{item?.categories?.[0]?.name || "Blog"}
|
|
274
|
+
</Typography>
|
|
275
|
+
</Box>
|
|
276
|
+
</Box>
|
|
277
|
+
<Box elementKey={`blogs.${index}.blog-content-wrapper`}>
|
|
278
|
+
<Heading elementKey={`blogs.${index}.title`}>{item?.title}</Heading>
|
|
279
|
+
<Typography elementKey={`blogs.${index}.description`}>
|
|
280
|
+
{item?.summary}
|
|
281
|
+
</Typography>
|
|
282
|
+
</Box>
|
|
283
|
+
<Box elementKey={`blogs.${index}.blog-footer`}>
|
|
284
|
+
<AuthorRenderer authors={item?.authors ?? []} index={index} />
|
|
285
|
+
<Box elementKey={`blogs.${index}.blog-reading-time-wrapper`}>
|
|
286
|
+
<Icon elementKey={`blogs.${index}.reading-time-icon`} />
|
|
287
|
+
<Typography elementKey={`blogs.${index}.reading-time`}>
|
|
288
|
+
{item?.reading_time ? `${item?.reading_time} min read` : "5 min read"}
|
|
289
|
+
</Typography>
|
|
290
|
+
</Box>
|
|
291
|
+
</Box>
|
|
292
|
+
</Box>
|
|
293
|
+
<Button elementKey={`blogs.${index}.cta`} />
|
|
294
|
+
</Box>
|
|
295
|
+
</Box>
|
|
296
|
+
))}
|
|
297
|
+
</Box>
|
|
298
|
+
{response?.meta && (
|
|
299
|
+
<Box elementKey="pagination-wrapper">
|
|
300
|
+
<Button
|
|
301
|
+
elementKey="pagination-left"
|
|
302
|
+
disabled={query?.page === 1}
|
|
303
|
+
onClick={handlePrevious}
|
|
304
|
+
/>
|
|
305
|
+
<Typography elementKey="pagination-text">
|
|
306
|
+
{`${query?.page || 1}/${response?.meta?.pages || 0}`}
|
|
307
|
+
</Typography>
|
|
308
|
+
<Button
|
|
309
|
+
elementKey="pagination-right"
|
|
310
|
+
onClick={handleNext}
|
|
311
|
+
disabled={query?.page === response?.meta?.pages}
|
|
312
|
+
/>
|
|
313
|
+
</Box>
|
|
314
|
+
)}
|
|
315
|
+
</Container>
|
|
316
|
+
</Section>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export default BlogListing4;
|
package/src/blocks/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ const AboutUs1 = lazy(() => import("./about-us/about-us-1"));
|
|
|
5
5
|
const BlogListing1 = lazy(() => import("./blogs/blog-listing-1"));
|
|
6
6
|
const BlogListing2 = lazy(() => import("./blogs/blog-listing-2"));
|
|
7
7
|
const BlogListing3 = lazy(() => import("./blogs/blog-listing-3"));
|
|
8
|
+
const BlogListing4 = lazy(() => import("./blogs/blog-listing-4"));
|
|
8
9
|
const BlogAuthor1 = lazy(() => import("./blogs/blog-author-1"));
|
|
9
10
|
const BlogPost1 = lazy(() => import("./blogs/blog-post-1"));
|
|
10
11
|
const Calendar1 = lazy(() => import("./calendar/calendar-1"));
|
|
@@ -140,6 +141,7 @@ export const blocks: Record<string, React.LazyExoticComponent<React.ComponentTyp
|
|
|
140
141
|
"blog-listing-1:v1": BlogListing1,
|
|
141
142
|
"blog-listing-2:v1": BlogListing2,
|
|
142
143
|
"blog-listing-3:v1": BlogListing3,
|
|
144
|
+
"blog-listing-4:v1": BlogListing4,
|
|
143
145
|
"blog-author-1:v1": BlogAuthor1,
|
|
144
146
|
"blog-post-1:v1": BlogPost1,
|
|
145
147
|
"calendar-1:v1": Calendar1,
|
package/src/schemas/blocks.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { BlogAuthor1 } from "../blocks/blogs/blog-author-1.schema";
|
|
|
6
6
|
import { BlogListing1 } from "../blocks/blogs/blog-listing-1.schema";
|
|
7
7
|
import { BlogListing2 } from "../blocks/blogs/blog-listing-2.schema";
|
|
8
8
|
import { BlogListing3 } from "../blocks/blogs/blog-listing-3.schema";
|
|
9
|
+
import { BlogListing4 } from "../blocks/blogs/blog-listing-4.schema";
|
|
9
10
|
import { BlogPost1 } from "../blocks/blogs/blog-post-1.schema";
|
|
10
11
|
import { Calendar1 } from "../blocks/calendar/calendar-1.schema";
|
|
11
12
|
import { Cards2 } from "../blocks/cards/cards-2.schema";
|
|
@@ -140,6 +141,7 @@ export const BlockList: IBlock[] = [
|
|
|
140
141
|
BlogListing1,
|
|
141
142
|
BlogListing2,
|
|
142
143
|
BlogListing3,
|
|
144
|
+
BlogListing4,
|
|
143
145
|
BlogPost1,
|
|
144
146
|
Calendar1,
|
|
145
147
|
Community1,
|