@fullstackdatasolutions/articles 0.6.0 → 0.7.1
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 +71 -12
- package/dist/index.cjs +68 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +66 -50
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +70 -18
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +69 -17
- package/dist/server.js.map +1 -1
- package/package.json +34 -20
- package/src/ArticleBackLink.tsx +32 -0
- package/src/ArticleContent.tsx +4 -7
- package/src/ArticleSocialShare.tsx +2 -7
- package/src/__tests__/ArticleBackLink.test.tsx +89 -0
- package/src/__tests__/ArticleContent.test.tsx +72 -28
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -12
- package/src/__tests__/articlesConfig.test.ts +57 -0
- package/src/__tests__/markdown.test.ts +278 -0
- package/src/__tests__/renderMdx.test.tsx +280 -0
- package/src/__tests__/server-articles.test.ts +310 -1
- package/src/articlesConfig.ts +2 -0
- package/src/index.ts +1 -0
- package/src/markdown.ts +8 -6
- package/src/renderMdx.tsx +47 -0
- package/src/server-articles.ts +1 -2
package/README.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
A self-contained Next.js articles library providing components, server utilities, SEO helpers, and a comments system. Drop it into any Next.js + Tailwind CSS v4 app.
|
|
4
4
|
|
|
5
|
-
Built by [Full Stack Data Solutions](https://fullstackdatasolutions.com).
|
|
5
|
+
Built by [Full Stack Data Solutions](https://fullstackdatasolutions.com) for solopreneurs and founders who want a practical articles system without wiring the same SEO, RSS, sitemap, MDX, and content plumbing from scratch.
|
|
6
|
+
|
|
7
|
+
## Support and consulting
|
|
8
|
+
|
|
9
|
+
If this package saves you time, you can support ongoing maintenance by buying the developer a coffee: [buymeacoffee.com/andrewblase](https://buymeacoffee.com/andrewblase).
|
|
10
|
+
|
|
11
|
+
Need help adding articles, RSS, sitemap, SEO, or MDX workflows to a Next.js app? Contact [Full Stack Data Solutions](https://fullstackdatasolutions.com).
|
|
6
12
|
|
|
7
13
|
---
|
|
8
14
|
|
|
@@ -23,12 +29,21 @@ In Tailwind CSS v4, you add a `@source` directive to your global CSS file — **
|
|
|
23
29
|
```css
|
|
24
30
|
/* app/globals.css */
|
|
25
31
|
@import "tailwindcss";
|
|
32
|
+
@plugin "@tailwindcss/typography";
|
|
26
33
|
|
|
27
34
|
@source "./node_modules/@fullstackdatasolutions/articles/src";
|
|
28
35
|
```
|
|
29
36
|
|
|
30
37
|
The package components use Tailwind semantic tokens (`bg-card`, `text-muted-foreground`, `border-border`, etc.) that shadcn/ui and v0-generated apps already define — so the styling integrates automatically with your existing theme.
|
|
31
38
|
|
|
39
|
+
Install `@tailwindcss/typography` if not already present:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install @tailwindcss/typography
|
|
43
|
+
# or
|
|
44
|
+
pnpm add @tailwindcss/typography
|
|
45
|
+
```
|
|
46
|
+
|
|
32
47
|
### 3. Add TypeScript path aliases
|
|
33
48
|
|
|
34
49
|
If you're using TypeScript and want IDE autocomplete without a pre-built `dist/`, add these paths to `tsconfig.json`. This is **required in a monorepo** (where the package is a workspace dep) and optional when installed from npm (the published `dist/` files work without it).
|
|
@@ -71,7 +86,7 @@ export default function Page() {
|
|
|
71
86
|
return <ArticlesPage config={siteConfig} />
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
// app/articles/[slug]/page.tsx
|
|
89
|
+
// app/articles/[...slug]/page.tsx
|
|
75
90
|
import { notFound } from 'next/navigation'
|
|
76
91
|
import {
|
|
77
92
|
ArticleDetailHero,
|
|
@@ -86,18 +101,26 @@ import {
|
|
|
86
101
|
ArticleContent,
|
|
87
102
|
ArticleTOC,
|
|
88
103
|
generateArticleMetadata,
|
|
104
|
+
generateArticleStaticParams,
|
|
89
105
|
getAdjacentArticles,
|
|
90
106
|
getArticleMetadata,
|
|
91
107
|
} from '@fullstackdatasolutions/articles/server'
|
|
92
108
|
import { siteConfig } from '@/config/articles'
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
type ArticlePageProps = Readonly<{ params: Promise<{ slug: string[] }> }>
|
|
110
|
+
function normalizeSlug(slug: string[]): string {
|
|
111
|
+
return slug.join('/')
|
|
112
|
+
}
|
|
113
|
+
export function generateStaticParams(): { slug: string[] }[] {
|
|
114
|
+
return generateArticleStaticParams().map((params) => ({ slug: params.slug.split('/') }))
|
|
115
|
+
}
|
|
95
116
|
export async function generateMetadata({ params }: ArticlePageProps) {
|
|
96
|
-
const { slug } = await params
|
|
117
|
+
const { slug: slugSegments } = await params
|
|
118
|
+
const slug = normalizeSlug(slugSegments)
|
|
97
119
|
return generateArticleMetadata(slug, siteConfig)
|
|
98
120
|
}
|
|
99
121
|
export default async function Page({ params }: ArticlePageProps) {
|
|
100
|
-
const { slug } = await params
|
|
122
|
+
const { slug: slugSegments } = await params
|
|
123
|
+
const slug = normalizeSlug(slugSegments)
|
|
101
124
|
const article = await getArticleMetadata(slug)
|
|
102
125
|
if (!article) notFound()
|
|
103
126
|
const { previous, next } = await getAdjacentArticles(slug)
|
|
@@ -173,7 +196,7 @@ tags: [campaigns, strategy]
|
|
|
173
196
|
Article body in Markdown...
|
|
174
197
|
```
|
|
175
198
|
|
|
176
|
-
Article directories can
|
|
199
|
+
Article directories can be nested to any depth below `public/articles`. For example, `public/articles/game-system/article-name/article.mdx` is discovered as the slug `game-system/article-name`, and `public/articles/tov/subdirectory/article-name/article.md` is discovered as `tov/subdirectory/article-name`. Use a catch-all Next.js route such as `app/articles/[...slug]/page.tsx` and join the slug segments before calling the server helpers.
|
|
177
200
|
|
|
178
201
|
---
|
|
179
202
|
|
|
@@ -191,6 +214,7 @@ Article directories can also be nested below grouping folders. For example, `pub
|
|
|
191
214
|
| `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
|
|
192
215
|
| `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
|
|
193
216
|
| `showToc` | `boolean` | `true` | Show the table of contents on article detail pages. Set to `false` to hide on all articles. |
|
|
217
|
+
| `showBackToArticles` | `boolean` | `true` | Show the back-navigation link on article detail pages. Set to `false` to hide `ArticleBackLink`. |
|
|
194
218
|
| `description` | `string` | — | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
|
|
195
219
|
|
|
196
220
|
**Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
|
|
@@ -327,6 +351,36 @@ import { ArticleTOC } from '@fullstackdatasolutions/articles/server'
|
|
|
327
351
|
|
|
328
352
|
---
|
|
329
353
|
|
|
354
|
+
## ArticleBackLink
|
|
355
|
+
|
|
356
|
+
`ArticleBackLink` renders a back-navigation link on article detail pages. Returns `null` when `config.showBackToArticles === false`, so it is safe to render unconditionally.
|
|
357
|
+
|
|
358
|
+
```tsx
|
|
359
|
+
import { ArticleBackLink } from '@fullstackdatasolutions/articles'
|
|
360
|
+
|
|
361
|
+
<ArticleBackLink config={siteConfig} />
|
|
362
|
+
|
|
363
|
+
// With custom props:
|
|
364
|
+
<ArticleBackLink
|
|
365
|
+
config={siteConfig}
|
|
366
|
+
href="/blog"
|
|
367
|
+
label="Back to Blog"
|
|
368
|
+
className="custom-class"
|
|
369
|
+
/>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
| Prop | Type | Required | Description |
|
|
373
|
+
|---|---|---|---|
|
|
374
|
+
| `config` | `ArticlesConfig` | yes | Site config. Must include `showBackToArticles?: boolean` from ArticlesConfig. |
|
|
375
|
+
| `href` | `string` | no | Link destination. Defaults to `/articles`. |
|
|
376
|
+
| `label` | `string` | no | Link text. Defaults to `Back to Articles`. |
|
|
377
|
+
| `className` | `string` | no | Optional CSS class to override default styling. |
|
|
378
|
+
|
|
379
|
+
- Returns `null` when `config.showBackToArticles === false`
|
|
380
|
+
- Gate used in article detail pages: `config.showBackToArticles !== false`
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
330
384
|
## ScrollToTop
|
|
331
385
|
|
|
332
386
|
`ScrollToTop` is a fixed-position floating button in the bottom-right corner that appears when the user scrolls past 300px, and smooth-scrolls back to the top on click. Client component (`'use client'`). No props required - fully self-contained, safe to place anywhere in your layout.
|
|
@@ -394,10 +448,12 @@ export const metadata: Metadata = generateArticlesIndexMetadata(siteConfig)
|
|
|
394
448
|
### Article and category metadata
|
|
395
449
|
|
|
396
450
|
```ts
|
|
397
|
-
// app/articles/[slug]/page.tsx
|
|
451
|
+
// app/articles/[...slug]/page.tsx
|
|
398
452
|
import { generateArticleMetadata } from '@fullstackdatasolutions/articles/server'
|
|
399
|
-
export const generateMetadata = ({ params }) =>
|
|
400
|
-
|
|
453
|
+
export const generateMetadata = async ({ params }) => {
|
|
454
|
+
const { slug } = await params
|
|
455
|
+
return generateArticleMetadata(slug.join('/'), siteConfig)
|
|
456
|
+
}
|
|
401
457
|
// Produces: title, description, full OpenGraph, Twitter Card, canonical URL
|
|
402
458
|
|
|
403
459
|
// app/articles/category/[category]/page.tsx
|
|
@@ -574,6 +630,7 @@ And point the Tailwind `@source` at the workspace path:
|
|
|
574
630
|
|
|
575
631
|
```css
|
|
576
632
|
/* app/globals.css */
|
|
633
|
+
@plugin "@tailwindcss/typography";
|
|
577
634
|
@source "../../packages/articles/src";
|
|
578
635
|
```
|
|
579
636
|
|
|
@@ -599,9 +656,11 @@ This app uses `@fullstackdatasolutions/articles` for the articles section.
|
|
|
599
656
|
|
|
600
657
|
### CSS (Tailwind v4)
|
|
601
658
|
The package ships its source TypeScript files so Tailwind can scan them.
|
|
602
|
-
`app/globals.css`
|
|
659
|
+
`app/globals.css` must contain:
|
|
660
|
+
@plugin "@tailwindcss/typography";
|
|
603
661
|
@source "./node_modules/@fullstackdatasolutions/articles/src";
|
|
604
|
-
If
|
|
662
|
+
If the `@source` line is missing, most component classes will not render correctly. Note: `ArticleCategoryGrid` images, `CategoryArticlesPage` hero, and `ArticleDetailHero` image use inline styles for critical layout so they render correctly without this line. All other styling still requires it.
|
|
663
|
+
The `@plugin "@tailwindcss/typography"` line is required for article body prose styling. Install with `pnpm add @tailwindcss/typography`.
|
|
605
664
|
|
|
606
665
|
### Components
|
|
607
666
|
- `ArticleTOC` — server component imported from `@fullstackdatasolutions/articles/server`; renders `article.toc[]` as a nav block. Controlled by `siteConfig.showToc` (defaults `true`). Returns `null` when empty.
|
package/dist/index.cjs
CHANGED
|
@@ -69,6 +69,7 @@ var __async = (__this, __arguments, generator) => {
|
|
|
69
69
|
// src/index.ts
|
|
70
70
|
var src_exports = {};
|
|
71
71
|
__export(src_exports, {
|
|
72
|
+
ArticleBackLink: () => ArticleBackLink,
|
|
72
73
|
ArticleCard: () => ArticleCard,
|
|
73
74
|
ArticleCategoryGrid: () => ArticleCategoryGrid,
|
|
74
75
|
ArticleDetailHero: () => ArticleDetailHero,
|
|
@@ -1025,19 +1026,10 @@ function ArticleSocialShare({ title, url, excerpt, shareMessage }) {
|
|
|
1025
1026
|
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lucide_react6.MessageCircle, { className: "h-4 w-4" }),
|
|
1026
1027
|
"Telegram"
|
|
1027
1028
|
] }),
|
|
1028
|
-
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
1029
|
-
"
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
onClick: () => {
|
|
1033
|
-
globalThis.location.href = shareLinks.email;
|
|
1034
|
-
},
|
|
1035
|
-
children: [
|
|
1036
|
-
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lucide_react6.Mail, { className: "h-4 w-4" }),
|
|
1037
|
-
"Email"
|
|
1038
|
-
]
|
|
1039
|
-
}
|
|
1040
|
-
),
|
|
1029
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("a", { className: btnClass, href: shareLinks.email, children: [
|
|
1030
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lucide_react6.Mail, { className: "h-4 w-4" }),
|
|
1031
|
+
"Email"
|
|
1032
|
+
] }),
|
|
1041
1033
|
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("button", { className: btnClass, onClick: copyToClipboard, children: [
|
|
1042
1034
|
copied ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lucide_react6.Check, { className: "h-4 w-4" }) : /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lucide_react6.Copy, { className: "h-4 w-4" }),
|
|
1043
1035
|
copied ? "Copied!" : "Copy Link"
|
|
@@ -1088,18 +1080,42 @@ function ArticleNavigation({ previous, next, basePath }) {
|
|
|
1088
1080
|
] }) });
|
|
1089
1081
|
}
|
|
1090
1082
|
|
|
1091
|
-
// src/
|
|
1083
|
+
// src/ArticleBackLink.tsx
|
|
1084
|
+
var import_link8 = __toESM(require("next/link"), 1);
|
|
1085
|
+
var import_lucide_react8 = require("lucide-react");
|
|
1092
1086
|
var import_jsx_runtime14 = require("react/jsx-runtime");
|
|
1087
|
+
function ArticleBackLink({
|
|
1088
|
+
config,
|
|
1089
|
+
href = "/articles",
|
|
1090
|
+
label = "Back to Articles",
|
|
1091
|
+
className
|
|
1092
|
+
}) {
|
|
1093
|
+
if (config.showBackToArticles === false) return null;
|
|
1094
|
+
return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
|
|
1095
|
+
import_link8.default,
|
|
1096
|
+
{
|
|
1097
|
+
href,
|
|
1098
|
+
className: className != null ? className : "inline-flex items-center gap-2 mb-6 -ml-3 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors",
|
|
1099
|
+
children: [
|
|
1100
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react8.ArrowLeft, { className: "h-4 w-4" }),
|
|
1101
|
+
label
|
|
1102
|
+
]
|
|
1103
|
+
}
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/ArticleTOC.tsx
|
|
1108
|
+
var import_jsx_runtime15 = require("react/jsx-runtime");
|
|
1093
1109
|
function ArticleTOC({ toc, className }) {
|
|
1094
1110
|
if (!toc.length) return null;
|
|
1095
|
-
return /* @__PURE__ */ (0,
|
|
1111
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
|
|
1096
1112
|
"nav",
|
|
1097
1113
|
{
|
|
1098
1114
|
"aria-label": "Table of contents",
|
|
1099
1115
|
className: `mb-8 rounded-lg border border-border bg-muted/40 px-6 py-4 ${className != null ? className : ""}`,
|
|
1100
1116
|
children: [
|
|
1101
|
-
/* @__PURE__ */ (0,
|
|
1102
|
-
/* @__PURE__ */ (0,
|
|
1117
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "On this page" }),
|
|
1118
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "space-y-1 text-sm", children: toc.map((item) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { style: { paddingLeft: `${Math.max(0, item.depth - 2) * 1}rem` }, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1103
1119
|
"a",
|
|
1104
1120
|
{
|
|
1105
1121
|
href: `#${item.id}`,
|
|
@@ -1114,8 +1130,8 @@ function ArticleTOC({ toc, className }) {
|
|
|
1114
1130
|
|
|
1115
1131
|
// src/ScrollToTop.tsx
|
|
1116
1132
|
var import_react5 = require("react");
|
|
1117
|
-
var
|
|
1118
|
-
var
|
|
1133
|
+
var import_lucide_react9 = require("lucide-react");
|
|
1134
|
+
var import_jsx_runtime16 = require("react/jsx-runtime");
|
|
1119
1135
|
function ScrollToTop() {
|
|
1120
1136
|
const [visible, setVisible] = (0, import_react5.useState)(false);
|
|
1121
1137
|
(0, import_react5.useEffect)(() => {
|
|
@@ -1124,13 +1140,13 @@ function ScrollToTop() {
|
|
|
1124
1140
|
return () => globalThis.removeEventListener("scroll", onScroll);
|
|
1125
1141
|
}, []);
|
|
1126
1142
|
if (!visible) return null;
|
|
1127
|
-
return /* @__PURE__ */ (0,
|
|
1143
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1128
1144
|
"button",
|
|
1129
1145
|
{
|
|
1130
1146
|
"aria-label": "Scroll to top",
|
|
1131
1147
|
onClick: () => globalThis.scrollTo({ top: 0, behavior: "smooth" }),
|
|
1132
1148
|
className: "fixed bottom-8 right-8 z-50 rounded-full bg-primary p-2 shadow-md text-primary-foreground hover:bg-primary/90 transition-colors",
|
|
1133
|
-
children: /* @__PURE__ */ (0,
|
|
1149
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react9.ArrowUp, { className: "h-5 w-5" })
|
|
1134
1150
|
}
|
|
1135
1151
|
);
|
|
1136
1152
|
}
|
|
@@ -1141,7 +1157,7 @@ var import_react9 = require("react");
|
|
|
1141
1157
|
|
|
1142
1158
|
// src/CommentForm.tsx
|
|
1143
1159
|
var import_react6 = require("react");
|
|
1144
|
-
var
|
|
1160
|
+
var import_jsx_runtime17 = require("react/jsx-runtime");
|
|
1145
1161
|
var MAX_LENGTH = 2e3;
|
|
1146
1162
|
var WARN_THRESHOLD = 1800;
|
|
1147
1163
|
function submitLabel(submitting, parentId) {
|
|
@@ -1180,8 +1196,8 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1180
1196
|
}
|
|
1181
1197
|
});
|
|
1182
1198
|
}
|
|
1183
|
-
return /* @__PURE__ */ (0,
|
|
1184
|
-
/* @__PURE__ */ (0,
|
|
1199
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-2", children: [
|
|
1200
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1185
1201
|
"textarea",
|
|
1186
1202
|
{
|
|
1187
1203
|
value: body,
|
|
@@ -1194,13 +1210,13 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1194
1210
|
"aria-label": parentId ? "Reply text" : "Comment text"
|
|
1195
1211
|
}
|
|
1196
1212
|
),
|
|
1197
|
-
remaining <= MAX_LENGTH - WARN_THRESHOLD && /* @__PURE__ */ (0,
|
|
1213
|
+
remaining <= MAX_LENGTH - WARN_THRESHOLD && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("p", { className: `text-xs ${remaining < 0 ? "text-destructive" : "text-muted-foreground"}`, children: [
|
|
1198
1214
|
remaining,
|
|
1199
1215
|
" characters remaining"
|
|
1200
1216
|
] }),
|
|
1201
|
-
error && /* @__PURE__ */ (0,
|
|
1202
|
-
/* @__PURE__ */ (0,
|
|
1203
|
-
/* @__PURE__ */ (0,
|
|
1217
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "text-xs text-destructive", children: error }),
|
|
1218
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex gap-2", children: [
|
|
1219
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1204
1220
|
"button",
|
|
1205
1221
|
{
|
|
1206
1222
|
type: "submit",
|
|
@@ -1209,7 +1225,7 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1209
1225
|
children: submitLabel(submitting, parentId)
|
|
1210
1226
|
}
|
|
1211
1227
|
),
|
|
1212
|
-
onCancel && /* @__PURE__ */ (0,
|
|
1228
|
+
onCancel && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1213
1229
|
"button",
|
|
1214
1230
|
{
|
|
1215
1231
|
type: "button",
|
|
@@ -1224,7 +1240,7 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1224
1240
|
|
|
1225
1241
|
// src/CommentItem.tsx
|
|
1226
1242
|
var import_react7 = require("react");
|
|
1227
|
-
var
|
|
1243
|
+
var import_jsx_runtime18 = require("react/jsx-runtime");
|
|
1228
1244
|
function relativeTime(iso) {
|
|
1229
1245
|
const diff = Date.now() - new Date(iso).getTime();
|
|
1230
1246
|
const minutes = Math.floor(diff / 6e4);
|
|
@@ -1262,15 +1278,15 @@ function CommentItem({
|
|
|
1262
1278
|
}
|
|
1263
1279
|
});
|
|
1264
1280
|
}
|
|
1265
|
-
return /* @__PURE__ */ (0,
|
|
1266
|
-
/* @__PURE__ */ (0,
|
|
1267
|
-
/* @__PURE__ */ (0,
|
|
1268
|
-
/* @__PURE__ */ (0,
|
|
1269
|
-
/* @__PURE__ */ (0,
|
|
1281
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: `${isReply ? "ml-8 border-l-2 border-border pl-4" : ""}`, children: [
|
|
1282
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "rounded-lg bg-muted/40 p-3 space-y-1", children: comment.isDeleted ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "text-sm italic text-muted-foreground", children: "Comment removed" }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(import_jsx_runtime18.Fragment, { children: [
|
|
1283
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1284
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "text-sm font-medium text-foreground", children: comment.authorName }),
|
|
1285
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "text-xs text-muted-foreground", children: relativeTime(comment.createdAt) })
|
|
1270
1286
|
] }),
|
|
1271
|
-
/* @__PURE__ */ (0,
|
|
1272
|
-
/* @__PURE__ */ (0,
|
|
1273
|
-
canReply && /* @__PURE__ */ (0,
|
|
1287
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "text-sm text-foreground whitespace-pre-wrap break-words", children: comment.body }),
|
|
1288
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex gap-3 pt-1", children: [
|
|
1289
|
+
canReply && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1274
1290
|
"button",
|
|
1275
1291
|
{
|
|
1276
1292
|
onClick: () => setShowReplyForm((v) => !v),
|
|
@@ -1278,7 +1294,7 @@ function CommentItem({
|
|
|
1278
1294
|
children: showReplyForm ? "Cancel" : "Reply"
|
|
1279
1295
|
}
|
|
1280
1296
|
),
|
|
1281
|
-
canDelete && /* @__PURE__ */ (0,
|
|
1297
|
+
canDelete && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1282
1298
|
"button",
|
|
1283
1299
|
{
|
|
1284
1300
|
onClick: handleDelete,
|
|
@@ -1289,7 +1305,7 @@ function CommentItem({
|
|
|
1289
1305
|
)
|
|
1290
1306
|
] })
|
|
1291
1307
|
] }) }),
|
|
1292
|
-
showReplyForm && /* @__PURE__ */ (0,
|
|
1308
|
+
showReplyForm && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "mt-2 ml-2", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1293
1309
|
CommentForm,
|
|
1294
1310
|
{
|
|
1295
1311
|
articleSlug,
|
|
@@ -1301,7 +1317,7 @@ function CommentItem({
|
|
|
1301
1317
|
onCancel: () => setShowReplyForm(false)
|
|
1302
1318
|
}
|
|
1303
1319
|
) }),
|
|
1304
|
-
replies.length > 0 && /* @__PURE__ */ (0,
|
|
1320
|
+
replies.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "mt-2 space-y-2", children: replies.map((reply) => /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1305
1321
|
CommentItem,
|
|
1306
1322
|
{
|
|
1307
1323
|
comment: __spreadProps(__spreadValues({}, reply), { replies: [] }),
|
|
@@ -1316,7 +1332,7 @@ function CommentItem({
|
|
|
1316
1332
|
}
|
|
1317
1333
|
|
|
1318
1334
|
// src/CommentThread.tsx
|
|
1319
|
-
var
|
|
1335
|
+
var import_jsx_runtime19 = require("react/jsx-runtime");
|
|
1320
1336
|
function CommentThread({
|
|
1321
1337
|
comments,
|
|
1322
1338
|
articleSlug,
|
|
@@ -1324,9 +1340,9 @@ function CommentThread({
|
|
|
1324
1340
|
onRefresh
|
|
1325
1341
|
}) {
|
|
1326
1342
|
if (comments.length === 0) {
|
|
1327
|
-
return /* @__PURE__ */ (0,
|
|
1343
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { className: "text-sm text-muted-foreground text-center py-6", children: "No comments yet. Be the first to share your thoughts." });
|
|
1328
1344
|
}
|
|
1329
|
-
return /* @__PURE__ */ (0,
|
|
1345
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "space-y-4", children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
|
|
1330
1346
|
CommentItem,
|
|
1331
1347
|
{
|
|
1332
1348
|
comment,
|
|
@@ -1339,7 +1355,7 @@ function CommentThread({
|
|
|
1339
1355
|
}
|
|
1340
1356
|
|
|
1341
1357
|
// src/CommentsSection.tsx
|
|
1342
|
-
var
|
|
1358
|
+
var import_jsx_runtime20 = require("react/jsx-runtime");
|
|
1343
1359
|
function CommentsSection({ articleSlug, config }) {
|
|
1344
1360
|
var _a, _b, _c, _d, _e, _f;
|
|
1345
1361
|
const { data: session, status } = (0, import_react8.useSession)();
|
|
@@ -1371,10 +1387,10 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1371
1387
|
if (!enabled) return null;
|
|
1372
1388
|
const count = comments.length;
|
|
1373
1389
|
const label = count === 1 ? "1 comment" : `${count} comments`;
|
|
1374
|
-
return /* @__PURE__ */ (0,
|
|
1375
|
-
/* @__PURE__ */ (0,
|
|
1376
|
-
status === "authenticated" ? /* @__PURE__ */ (0,
|
|
1377
|
-
/* @__PURE__ */ (0,
|
|
1390
|
+
return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("section", { "aria-label": "Comments", className: "mt-12 pt-8 border-t border-border space-y-6", children: [
|
|
1391
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("h2", { className: "text-xl font-semibold text-foreground", children: loading ? "Comments" : label }),
|
|
1392
|
+
status === "authenticated" ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(CommentForm, { articleSlug, onSuccess: fetchComments }) : /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("p", { className: "text-sm text-muted-foreground", children: [
|
|
1393
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1378
1394
|
"button",
|
|
1379
1395
|
{
|
|
1380
1396
|
onClick: () => (0, import_react8.signIn)(),
|
|
@@ -1385,8 +1401,8 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1385
1401
|
" ",
|
|
1386
1402
|
"to join the discussion."
|
|
1387
1403
|
] }),
|
|
1388
|
-
fetchError && /* @__PURE__ */ (0,
|
|
1389
|
-
loading ? /* @__PURE__ */ (0,
|
|
1404
|
+
fetchError && /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("p", { className: "text-sm text-destructive", children: fetchError }),
|
|
1405
|
+
loading ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("p", { className: "text-sm text-muted-foreground", children: "Loading comments\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1390
1406
|
CommentThread,
|
|
1391
1407
|
{
|
|
1392
1408
|
comments,
|
|
@@ -1399,6 +1415,7 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1399
1415
|
}
|
|
1400
1416
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1401
1417
|
0 && (module.exports = {
|
|
1418
|
+
ArticleBackLink,
|
|
1402
1419
|
ArticleCard,
|
|
1403
1420
|
ArticleCategoryGrid,
|
|
1404
1421
|
ArticleDetailHero,
|