@founderhq/next-blog 0.9.0
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.
- package/README.md +169 -0
- package/dist/cli/core.d.ts +61 -0
- package/dist/cli/core.d.ts.map +1 -0
- package/dist/cli/core.js +1983 -0
- package/dist/cli/core.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +390 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/components.d.ts +57 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +281 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +29 -0
- package/dist/registry.js.map +1 -0
- package/dist/server.d.ts +159 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +681 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +21 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +56 -0
- package/dist/utils.js.map +1 -0
- package/package.json +88 -0
- package/src/styles.css +264 -0
- package/src/templates/blog-components.tsx.txt +603 -0
- package/src/templates/blog.css.txt +264 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type FormEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import {
|
|
14
|
+
articlePath,
|
|
15
|
+
normalizeRoutePrefix,
|
|
16
|
+
type BlogArticle,
|
|
17
|
+
type BlogAuthor,
|
|
18
|
+
type BlogCta,
|
|
19
|
+
type BlogNewsletter,
|
|
20
|
+
type BlogPagination,
|
|
21
|
+
type BlogSite,
|
|
22
|
+
type BlogTocItem,
|
|
23
|
+
} from "@founderhq/next-blog";
|
|
24
|
+
|
|
25
|
+
// Editable FounderHQ blog template. Change the markup/classes to match your site.
|
|
26
|
+
function cx(...values: Array<string | false | null | undefined>) {
|
|
27
|
+
return values.filter(Boolean).join(" ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function joinPath(base: string, segment: string) {
|
|
31
|
+
return `${base.replace(/\/+$/, "")}/${segment.replace(/^\/+/, "")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TOC_RAIL = 8;
|
|
35
|
+
|
|
36
|
+
function getLineOffset(level: number) {
|
|
37
|
+
if (level <= 2) return TOC_RAIL;
|
|
38
|
+
if (level === 3) return TOC_RAIL + 8;
|
|
39
|
+
return TOC_RAIL + 16;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getItemOffset(level: number) {
|
|
43
|
+
if (level <= 2) return TOC_RAIL + 12;
|
|
44
|
+
if (level === 3) return TOC_RAIL + 24;
|
|
45
|
+
return TOC_RAIL + 36;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
|
|
49
|
+
let node = element?.parentElement ?? null;
|
|
50
|
+
while (node) {
|
|
51
|
+
const overflowY = getComputedStyle(node).overflowY;
|
|
52
|
+
if (overflowY === "auto" || overflowY === "scroll") return node;
|
|
53
|
+
node = node.parentElement;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function escapeSelectorValue(value: string) {
|
|
59
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
60
|
+
return CSS.escape(value);
|
|
61
|
+
}
|
|
62
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveHeadingElement(id: string): HTMLElement | null {
|
|
66
|
+
return (
|
|
67
|
+
document.getElementById(id) ??
|
|
68
|
+
document.querySelector<HTMLElement>(
|
|
69
|
+
`[data-toc-id="${escapeSelectorValue(id)}"]`,
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type TocPosition = [top: number, bottom: number, x: number];
|
|
75
|
+
|
|
76
|
+
type TocRail = {
|
|
77
|
+
width: number;
|
|
78
|
+
height: number;
|
|
79
|
+
d: string;
|
|
80
|
+
positions: TocPosition[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function defaultTocSelect(item: BlogTocItem) {
|
|
84
|
+
resolveHeadingElement(item.id)?.scrollIntoView({
|
|
85
|
+
block: "start",
|
|
86
|
+
behavior: "smooth",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function BlogToc({
|
|
91
|
+
items,
|
|
92
|
+
onSelect = defaultTocSelect,
|
|
93
|
+
className,
|
|
94
|
+
}: {
|
|
95
|
+
items: BlogTocItem[];
|
|
96
|
+
onSelect?: (item: BlogTocItem) => void;
|
|
97
|
+
className?: string;
|
|
98
|
+
}) {
|
|
99
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
const [rail, setRail] = useState<TocRail | null>(null);
|
|
101
|
+
const [activeIds, setActiveIds] = useState<ReadonlySet<string>>(
|
|
102
|
+
() => new Set(),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const measure = useCallback(() => {
|
|
106
|
+
const container = containerRef.current;
|
|
107
|
+
if (!container || container.clientHeight === 0) return;
|
|
108
|
+
if (items.length === 0) {
|
|
109
|
+
setRail(null);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rows = container.querySelectorAll<HTMLElement>("[data-toc-item]");
|
|
114
|
+
let width = 0;
|
|
115
|
+
let height = 0;
|
|
116
|
+
let d = "";
|
|
117
|
+
const positions: TocPosition[] = [];
|
|
118
|
+
|
|
119
|
+
items.forEach((item, index) => {
|
|
120
|
+
const row = rows[index];
|
|
121
|
+
if (!row) return;
|
|
122
|
+
const styles = getComputedStyle(row);
|
|
123
|
+
const x = getLineOffset(item.level) + 0.5;
|
|
124
|
+
const top = row.offsetTop + parseFloat(styles.paddingTop);
|
|
125
|
+
const bottom =
|
|
126
|
+
row.offsetTop + row.clientHeight - parseFloat(styles.paddingBottom);
|
|
127
|
+
|
|
128
|
+
width = Math.max(width, x + 8);
|
|
129
|
+
height = Math.max(height, bottom);
|
|
130
|
+
|
|
131
|
+
if (positions.length === 0) {
|
|
132
|
+
d += `M${x} ${top} L${x} ${bottom}`;
|
|
133
|
+
} else {
|
|
134
|
+
const [, previousBottom, previousX] = positions[positions.length - 1];
|
|
135
|
+
d += ` C ${previousX} ${top - 4} ${x} ${previousBottom + 4} ${x} ${top} L${x} ${bottom}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
positions.push([top, bottom, x]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
setRail({ width, height, d, positions });
|
|
142
|
+
}, [items]);
|
|
143
|
+
|
|
144
|
+
const measureRef = useRef(measure);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
measureRef.current = measure;
|
|
147
|
+
}, [measure]);
|
|
148
|
+
|
|
149
|
+
const layoutSignature = useMemo(
|
|
150
|
+
() => items.map((item) => `${item.id}:${item.level}`).join("/"),
|
|
151
|
+
[items],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const container = containerRef.current;
|
|
156
|
+
if (!container) return;
|
|
157
|
+
const run = () => measureRef.current();
|
|
158
|
+
if (typeof ResizeObserver === "undefined") {
|
|
159
|
+
run();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const observer = new ResizeObserver(run);
|
|
163
|
+
observer.observe(container);
|
|
164
|
+
run();
|
|
165
|
+
return () => observer.disconnect();
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
measureRef.current();
|
|
170
|
+
}, [layoutSignature]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const headings = items
|
|
174
|
+
.map((item) => ({ id: item.id, el: resolveHeadingElement(item.id) }))
|
|
175
|
+
.filter(
|
|
176
|
+
(entry): entry is { id: string; el: HTMLElement } => entry.el !== null,
|
|
177
|
+
);
|
|
178
|
+
const scroller =
|
|
179
|
+
headings.length > 0 ? getScrollParent(headings[0].el) : null;
|
|
180
|
+
const idByElement = new Map<Element, string>(
|
|
181
|
+
headings.map((entry) => [entry.el, entry.id]),
|
|
182
|
+
);
|
|
183
|
+
const visible = new Set<string>();
|
|
184
|
+
|
|
185
|
+
const recompute = () => {
|
|
186
|
+
if (visible.size > 0) {
|
|
187
|
+
setActiveIds(new Set(visible));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const top = scroller ? scroller.getBoundingClientRect().top : 0;
|
|
191
|
+
let nearestId: string | null = null;
|
|
192
|
+
let nearestGap = Number.POSITIVE_INFINITY;
|
|
193
|
+
for (const heading of headings) {
|
|
194
|
+
const gap = top - heading.el.getBoundingClientRect().top;
|
|
195
|
+
if (gap >= 0 && gap < nearestGap) {
|
|
196
|
+
nearestGap = gap;
|
|
197
|
+
nearestId = heading.id;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
setActiveIds(nearestId ? new Set([nearestId]) : new Set());
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (headings.length === 0) {
|
|
204
|
+
recompute();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const observer = new IntersectionObserver(
|
|
209
|
+
(entries) => {
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const id = idByElement.get(entry.target);
|
|
212
|
+
if (!id) continue;
|
|
213
|
+
if (entry.isIntersecting) visible.add(id);
|
|
214
|
+
else visible.delete(id);
|
|
215
|
+
}
|
|
216
|
+
recompute();
|
|
217
|
+
},
|
|
218
|
+
{ root: scroller, threshold: 0 },
|
|
219
|
+
);
|
|
220
|
+
for (const heading of headings) observer.observe(heading.el);
|
|
221
|
+
recompute();
|
|
222
|
+
return () => observer.disconnect();
|
|
223
|
+
}, [items]);
|
|
224
|
+
|
|
225
|
+
const active = useMemo(() => {
|
|
226
|
+
const useFallback = activeIds.size === 0;
|
|
227
|
+
let start = -1;
|
|
228
|
+
let end = -1;
|
|
229
|
+
items.forEach((item, index) => {
|
|
230
|
+
const on = useFallback ? Boolean(item.isActive) : activeIds.has(item.id);
|
|
231
|
+
if (!on) return;
|
|
232
|
+
if (start === -1) start = index;
|
|
233
|
+
end = index;
|
|
234
|
+
});
|
|
235
|
+
return { start, end };
|
|
236
|
+
}, [items, activeIds]);
|
|
237
|
+
|
|
238
|
+
const activeStart = active.start;
|
|
239
|
+
const activeEnd = active.end;
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const container = containerRef.current;
|
|
242
|
+
if (!container || activeStart === -1) return;
|
|
243
|
+
const scroller = getScrollParent(container);
|
|
244
|
+
if (!scroller || scroller.scrollHeight <= scroller.clientHeight) return;
|
|
245
|
+
|
|
246
|
+
const rows = container.querySelectorAll<HTMLElement>("[data-toc-item]");
|
|
247
|
+
const startRow = rows[activeStart];
|
|
248
|
+
const endRow = rows[activeEnd] ?? startRow;
|
|
249
|
+
if (!startRow) return;
|
|
250
|
+
|
|
251
|
+
const margin = 36;
|
|
252
|
+
const view = scroller.getBoundingClientRect();
|
|
253
|
+
const top = startRow.getBoundingClientRect().top;
|
|
254
|
+
const bottom = endRow.getBoundingClientRect().bottom;
|
|
255
|
+
|
|
256
|
+
if (top < view.top + margin) {
|
|
257
|
+
scroller.scrollTop -= view.top + margin - top;
|
|
258
|
+
} else if (bottom > view.bottom - margin) {
|
|
259
|
+
scroller.scrollTop += bottom - (view.bottom - margin);
|
|
260
|
+
}
|
|
261
|
+
}, [activeStart, activeEnd]);
|
|
262
|
+
|
|
263
|
+
const hasThumb =
|
|
264
|
+
rail !== null && active.start !== -1 && rail.positions[active.start] != null;
|
|
265
|
+
const trackTop = hasThumb ? rail.positions[active.start][0] : 0;
|
|
266
|
+
const trackBottom = hasThumb ? rail.positions[active.end][1] : 0;
|
|
267
|
+
|
|
268
|
+
if (items.length === 0) return null;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
ref={containerRef}
|
|
273
|
+
role="navigation"
|
|
274
|
+
className={cx("fhq-blog-toc", className)}
|
|
275
|
+
aria-label="Article outline"
|
|
276
|
+
>
|
|
277
|
+
{rail ? (
|
|
278
|
+
<div
|
|
279
|
+
aria-hidden
|
|
280
|
+
className="fhq-blog-toc__rail"
|
|
281
|
+
style={{ width: rail.width, height: rail.height }}
|
|
282
|
+
>
|
|
283
|
+
<svg
|
|
284
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
285
|
+
viewBox={`0 0 ${rail.width} ${rail.height}`}
|
|
286
|
+
className="fhq-blog-toc__rail-svg"
|
|
287
|
+
style={{ width: rail.width, height: rail.height }}
|
|
288
|
+
>
|
|
289
|
+
<path d={rail.d} fill="none" strokeWidth="1" />
|
|
290
|
+
</svg>
|
|
291
|
+
<svg
|
|
292
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
293
|
+
viewBox={`0 0 ${rail.width} ${rail.height}`}
|
|
294
|
+
className="fhq-blog-toc__rail-svg fhq-blog-toc__rail-svg--active"
|
|
295
|
+
style={{
|
|
296
|
+
width: rail.width,
|
|
297
|
+
height: rail.height,
|
|
298
|
+
opacity: hasThumb ? 1 : 0,
|
|
299
|
+
clipPath: `polygon(0 ${trackTop}px, 100% ${trackTop}px, 100% ${trackBottom}px, 0 ${trackBottom}px)`,
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
<path d={rail.d} fill="none" strokeWidth="1" />
|
|
303
|
+
</svg>
|
|
304
|
+
</div>
|
|
305
|
+
) : null}
|
|
306
|
+
{items.map((item, index) => (
|
|
307
|
+
<button
|
|
308
|
+
key={`${item.id}-${item.pos ?? index}`}
|
|
309
|
+
type="button"
|
|
310
|
+
data-toc-item
|
|
311
|
+
data-active={
|
|
312
|
+
index >= active.start && index <= active.end ? "true" : undefined
|
|
313
|
+
}
|
|
314
|
+
onClick={() => onSelect(item)}
|
|
315
|
+
style={{ paddingInlineStart: getItemOffset(item.level) }}
|
|
316
|
+
className="fhq-blog-toc__item"
|
|
317
|
+
>
|
|
318
|
+
<span>{item.text}</span>
|
|
319
|
+
</button>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function BlogImage({
|
|
326
|
+
image,
|
|
327
|
+
priority = false,
|
|
328
|
+
className,
|
|
329
|
+
}: {
|
|
330
|
+
image: NonNullable<BlogArticle["featuredImage"]>;
|
|
331
|
+
priority?: boolean;
|
|
332
|
+
className?: string;
|
|
333
|
+
}) {
|
|
334
|
+
const style = image.aspectRatio
|
|
335
|
+
? ({ aspectRatio: image.aspectRatio } as CSSProperties)
|
|
336
|
+
: undefined;
|
|
337
|
+
return (
|
|
338
|
+
<figure className={cx("fhq-blog-image", className)} style={style}>
|
|
339
|
+
<img
|
|
340
|
+
src={image.url}
|
|
341
|
+
alt={image.alt}
|
|
342
|
+
width={image.width ?? undefined}
|
|
343
|
+
height={image.height ?? undefined}
|
|
344
|
+
loading={priority ? "eager" : "lazy"}
|
|
345
|
+
/>
|
|
346
|
+
{image.caption ? <figcaption>{image.caption}</figcaption> : null}
|
|
347
|
+
</figure>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function AuthorByline({ article }: { article: BlogArticle }) {
|
|
352
|
+
if (!article.author) return null;
|
|
353
|
+
return (
|
|
354
|
+
<div className="fhq-blog-byline">
|
|
355
|
+
{article.author.avatar ? (
|
|
356
|
+
<img src={article.author.avatar.url} alt="" width="40" height="40" />
|
|
357
|
+
) : null}
|
|
358
|
+
<div>
|
|
359
|
+
<div className="fhq-blog-byline__name">{article.author.name}</div>
|
|
360
|
+
{article.author.title ? (
|
|
361
|
+
<div className="fhq-blog-byline__title">{article.author.title}</div>
|
|
362
|
+
) : null}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function BlogCtaBlock({ cta }: { cta: BlogCta }) {
|
|
369
|
+
return (
|
|
370
|
+
<aside className={cx("fhq-blog-cta", cta.style && `is-${cta.style}`)}>
|
|
371
|
+
{cta.eyebrow ? <p className="fhq-blog-eyebrow">{cta.eyebrow}</p> : null}
|
|
372
|
+
<h2>{cta.heading}</h2>
|
|
373
|
+
{cta.body ? <p>{cta.body}</p> : null}
|
|
374
|
+
<a href={cta.buttonUrl}>{cta.buttonLabel}</a>
|
|
375
|
+
</aside>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function BlogNewsletterForm({
|
|
380
|
+
newsletter,
|
|
381
|
+
onSubmit,
|
|
382
|
+
}: {
|
|
383
|
+
newsletter: BlogNewsletter;
|
|
384
|
+
onSubmit?: (email: string, sourceTag: string | null) => Promise<void> | void;
|
|
385
|
+
}) {
|
|
386
|
+
const [email, setEmail] = useState("");
|
|
387
|
+
const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">(
|
|
388
|
+
"idle",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
392
|
+
event.preventDefault();
|
|
393
|
+
if (!email.trim()) return;
|
|
394
|
+
setStatus("submitting");
|
|
395
|
+
try {
|
|
396
|
+
await onSubmit?.(email.trim(), newsletter.sourceTag ?? null);
|
|
397
|
+
setStatus("done");
|
|
398
|
+
setEmail("");
|
|
399
|
+
} catch {
|
|
400
|
+
setStatus("error");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<form className="fhq-blog-newsletter" onSubmit={submit}>
|
|
406
|
+
<h2>{newsletter.heading}</h2>
|
|
407
|
+
{newsletter.body ? <p>{newsletter.body}</p> : null}
|
|
408
|
+
<div className="fhq-blog-newsletter__row">
|
|
409
|
+
<input
|
|
410
|
+
type="email"
|
|
411
|
+
value={email}
|
|
412
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
413
|
+
placeholder="you@example.com"
|
|
414
|
+
required
|
|
415
|
+
/>
|
|
416
|
+
<button type="submit" disabled={status === "submitting"}>
|
|
417
|
+
{newsletter.buttonLabel ?? "Subscribe"}
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
{status === "done" ? <p>You're subscribed.</p> : null}
|
|
421
|
+
{status === "error" ? <p>Something went wrong.</p> : null}
|
|
422
|
+
</form>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function BlogArticleLayout({
|
|
427
|
+
article,
|
|
428
|
+
site,
|
|
429
|
+
children,
|
|
430
|
+
sidebar,
|
|
431
|
+
}: {
|
|
432
|
+
article: BlogArticle;
|
|
433
|
+
site: BlogSite;
|
|
434
|
+
children?: ReactNode;
|
|
435
|
+
sidebar?: ReactNode;
|
|
436
|
+
}) {
|
|
437
|
+
const toc = article.toc ?? [];
|
|
438
|
+
return (
|
|
439
|
+
<main className="fhq-blog-article">
|
|
440
|
+
<article>
|
|
441
|
+
<a className="fhq-blog-back" href={normalizeRoutePrefix(site.routePrefix)}>
|
|
442
|
+
Blog
|
|
443
|
+
</a>
|
|
444
|
+
<h1>{article.title}</h1>
|
|
445
|
+
<AuthorByline article={article} />
|
|
446
|
+
{article.featuredImage ? (
|
|
447
|
+
<BlogImage image={article.featuredImage} priority />
|
|
448
|
+
) : null}
|
|
449
|
+
<div className="fhq-blog-layout">
|
|
450
|
+
<div className="fhq-blog-prose">
|
|
451
|
+
{children ?? (
|
|
452
|
+
<div
|
|
453
|
+
dangerouslySetInnerHTML={{ __html: article.contentHtml ?? "" }}
|
|
454
|
+
/>
|
|
455
|
+
)}
|
|
456
|
+
</div>
|
|
457
|
+
<aside className="fhq-blog-sidebar">
|
|
458
|
+
{sidebar ?? <BlogToc items={toc} />}
|
|
459
|
+
</aside>
|
|
460
|
+
</div>
|
|
461
|
+
</article>
|
|
462
|
+
</main>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function BlogIndex({
|
|
467
|
+
articles,
|
|
468
|
+
site,
|
|
469
|
+
className,
|
|
470
|
+
searchAction,
|
|
471
|
+
searchInitialQuery,
|
|
472
|
+
title,
|
|
473
|
+
description,
|
|
474
|
+
pagination,
|
|
475
|
+
}: {
|
|
476
|
+
articles: BlogArticle[];
|
|
477
|
+
site: BlogSite;
|
|
478
|
+
className?: string;
|
|
479
|
+
searchAction?: string;
|
|
480
|
+
searchInitialQuery?: string;
|
|
481
|
+
title?: string;
|
|
482
|
+
description?: string | null;
|
|
483
|
+
pagination?: BlogPagination | null;
|
|
484
|
+
}) {
|
|
485
|
+
return (
|
|
486
|
+
<main className={cx("fhq-blog-index", className)}>
|
|
487
|
+
<header>
|
|
488
|
+
<h1>{title ?? `${site.siteName} Blog`}</h1>
|
|
489
|
+
{description ? <p>{description}</p> : null}
|
|
490
|
+
<BlogSearchBox
|
|
491
|
+
initialQuery={searchInitialQuery}
|
|
492
|
+
action={searchAction ?? joinPath(normalizeRoutePrefix(site.routePrefix), "search")}
|
|
493
|
+
/>
|
|
494
|
+
</header>
|
|
495
|
+
<div className="fhq-blog-grid">
|
|
496
|
+
{articles.map((article) => (
|
|
497
|
+
<article className="fhq-blog-card" key={article.slug}>
|
|
498
|
+
{article.featuredImage ? <BlogImage image={article.featuredImage} /> : null}
|
|
499
|
+
<h2>
|
|
500
|
+
<a href={articlePath(article, site)}>{article.title}</a>
|
|
501
|
+
</h2>
|
|
502
|
+
{article.excerpt ? <p>{article.excerpt}</p> : null}
|
|
503
|
+
</article>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
<BlogPaginationLinks pagination={pagination} />
|
|
507
|
+
</main>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function BlogPaginationLinks({
|
|
512
|
+
pagination,
|
|
513
|
+
}: {
|
|
514
|
+
pagination?: BlogPagination | null;
|
|
515
|
+
}) {
|
|
516
|
+
if (!pagination || pagination.totalPages <= 1) return null;
|
|
517
|
+
return (
|
|
518
|
+
<nav className="fhq-blog-pagination" aria-label="Blog pagination">
|
|
519
|
+
{pagination.prevHref ? <a href={pagination.prevHref}>Previous</a> : <span />}
|
|
520
|
+
<span>
|
|
521
|
+
Page {pagination.page} of {pagination.totalPages}
|
|
522
|
+
</span>
|
|
523
|
+
{pagination.nextHref ? <a href={pagination.nextHref}>Next</a> : <span />}
|
|
524
|
+
</nav>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function BlogDirectory({
|
|
529
|
+
title,
|
|
530
|
+
description,
|
|
531
|
+
items,
|
|
532
|
+
}: {
|
|
533
|
+
title: string;
|
|
534
|
+
description?: string | null;
|
|
535
|
+
items: Array<{
|
|
536
|
+
title: string;
|
|
537
|
+
href: string;
|
|
538
|
+
description?: string | null;
|
|
539
|
+
image?: BlogAuthor["avatar"] | null;
|
|
540
|
+
}>;
|
|
541
|
+
}) {
|
|
542
|
+
return (
|
|
543
|
+
<main className="fhq-blog-index">
|
|
544
|
+
<header>
|
|
545
|
+
<h1>{title}</h1>
|
|
546
|
+
{description ? <p>{description}</p> : null}
|
|
547
|
+
</header>
|
|
548
|
+
<div className="fhq-blog-grid">
|
|
549
|
+
{items.map((item) => (
|
|
550
|
+
<article className="fhq-blog-card" key={item.href}>
|
|
551
|
+
{item.image ? <BlogImage image={item.image} /> : null}
|
|
552
|
+
<h2>
|
|
553
|
+
<a href={item.href}>{item.title}</a>
|
|
554
|
+
</h2>
|
|
555
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
556
|
+
</article>
|
|
557
|
+
))}
|
|
558
|
+
</div>
|
|
559
|
+
</main>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function BlogSearchBox({
|
|
564
|
+
initialQuery = "",
|
|
565
|
+
action = "/blog/search",
|
|
566
|
+
onSearch,
|
|
567
|
+
}: {
|
|
568
|
+
initialQuery?: string;
|
|
569
|
+
action?: string;
|
|
570
|
+
onSearch?: (query: string) => void;
|
|
571
|
+
}) {
|
|
572
|
+
const [query, setQuery] = useState(initialQuery);
|
|
573
|
+
const canSubmit = useMemo(() => query.trim().length > 0, [query]);
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<form
|
|
577
|
+
className="fhq-blog-search"
|
|
578
|
+
action={action}
|
|
579
|
+
method="get"
|
|
580
|
+
onSubmit={(event) => {
|
|
581
|
+
if (!canSubmit) {
|
|
582
|
+
event.preventDefault();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (onSearch) {
|
|
586
|
+
event.preventDefault();
|
|
587
|
+
onSearch(query.trim());
|
|
588
|
+
}
|
|
589
|
+
}}
|
|
590
|
+
>
|
|
591
|
+
<input
|
|
592
|
+
name="q"
|
|
593
|
+
type="search"
|
|
594
|
+
value={query}
|
|
595
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
596
|
+
placeholder="Search articles"
|
|
597
|
+
/>
|
|
598
|
+
<button type="submit" disabled={!canSubmit}>
|
|
599
|
+
Search
|
|
600
|
+
</button>
|
|
601
|
+
</form>
|
|
602
|
+
);
|
|
603
|
+
}
|