@fullstackdatasolutions/articles 0.1.0 → 0.2.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 CHANGED
@@ -2,6 +2,8 @@
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).
6
+
5
7
  ---
6
8
 
7
9
  ## Quick start
@@ -127,6 +129,7 @@ Article body in Markdown...
127
129
  | `layout` | `ArticlesSection[]` | see below | Ordered sections to render. Omit a key to hide it. |
128
130
  | `theme` | `ArticlesTheme` | — | CSS custom-property overrides for colors and fonts. |
129
131
  | `categoryDescriptions` | `Record<string, string \| CategoryDescription>` | — | Short/long text per category slug. |
132
+ | `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
130
133
  | `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
131
134
 
132
135
  **Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
@@ -155,6 +158,19 @@ All fields are optional. Omitted fields fall back to your Tailwind theme tokens.
155
158
 
156
159
  ---
157
160
 
161
+ ## Hero section
162
+
163
+ Customize the hero heading by passing `title` and `description` to `HeroConfig`. Omit the `hero` field entirely to use built-in defaults.
164
+
165
+ ```ts
166
+ hero: {
167
+ title: 'My App Articles',
168
+ description: 'Insights and updates from our team.',
169
+ }
170
+ ```
171
+
172
+ ---
173
+
158
174
  ## Comments setup
159
175
 
160
176
  ### 1. Enable in config
@@ -291,12 +307,13 @@ This app uses `@fullstackdatasolutions/articles` for the articles section.
291
307
  The package ships its source TypeScript files so Tailwind can scan them.
292
308
  `app/globals.css` contains:
293
309
  @source "./node_modules/@fullstackdatasolutions/articles/src";
294
- If this line is missing, component classes (layout, images, line-clamp) will not render correctly.
310
+ If this line is missing, most component classes will not render correctly. Note: `ArticleCategoryGrid` image containers use inline styles for critical layout so images remain visible, but all other styling still requires this line.
295
311
 
296
312
  ### Rules
297
313
  - Change `config/articles.ts` first; only edit library source if config cannot express the need.
298
314
  - When adding a new article: create `public/articles/[slug]/article.md` with title, excerpt, author, tags.
299
315
  - API routes are copied from the library — update the package version and re-copy if the library API changes.
316
+ - `hero.title` and `hero.description` in `ArticlesConfig` control the hero heading — pass them to customize per app.
300
317
  - Full library docs: `node_modules/@fullstackdatasolutions/articles/README.md`
301
318
  ```
302
319
 
@@ -307,8 +324,8 @@ If this line is missing, component classes (layout, images, line-clamp) will not
307
324
  The package publishes to npm automatically when you push a tag:
308
325
 
309
326
  ```bash
310
- git tag articles-v0.2.0
311
- git push origin articles-v0.2.0
327
+ git tag articles-v0.2.1
328
+ git push origin articles-v0.2.1
312
329
  ```
313
330
 
314
331
  GitHub Actions runs typecheck → test → build → `npm publish --access public`. Requires an `NPM_TOKEN` secret in the repo settings.
package/dist/index.cjs CHANGED
@@ -102,8 +102,7 @@ function ArticleCategoryGrid({ categories, pageSize = 8 }) {
102
102
  (0, import_react.useEffect)(() => {
103
103
  setVisibleCount(pageSize);
104
104
  }, [categories, pageSize]);
105
- if (categories.length === 0)
106
- return null;
105
+ if (categories.length === 0) return null;
107
106
  const visible = categories.slice(0, visibleCount);
108
107
  const hasMore = visibleCount < categories.length;
109
108
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("section", { className: "py-16 bg-background", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", children: [
@@ -111,26 +110,37 @@ function ArticleCategoryGrid({ categories, pageSize = 8 }) {
111
110
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { className: "text-3xl font-bold text-foreground mb-4", children: "Browse by Category" }),
112
111
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-lg text-muted-foreground", children: "Explore our articles by topic to find the insights most relevant to your interests." })
113
112
  ] }),
114
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "grid grid-cols-2 sm:grid-cols-4 gap-6", children: visible.map((cat) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_link.default, { href: `/articles/category/${cat.slug}`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "group overflow-hidden rounded-xl border border-border shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer bg-card", children: [
115
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "relative h-52 overflow-hidden", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
116
- import_image.default,
117
- {
118
- src: cat.featuredImage,
119
- alt: cat.name,
120
- fill: true,
121
- sizes: "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
122
- className: "object-cover group-hover:scale-105 transition-transform duration-300"
123
- }
124
- ) }),
125
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "p-4 text-center", children: [
126
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { className: "font-semibold text-foreground text-base", children: cat.name }),
127
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "text-sm text-muted-foreground mt-1", children: [
128
- cat.count,
129
- " ",
130
- cat.count === 1 ? "article" : "articles"
131
- ] })
132
- ] })
133
- ] }) }, cat.slug)) }),
113
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
114
+ "div",
115
+ {
116
+ className: "sm:grid-cols-4",
117
+ style: {
118
+ display: "grid",
119
+ gridTemplateColumns: "repeat(1, minmax(0, 1fr))",
120
+ gap: "1.5rem"
121
+ },
122
+ children: visible.map((cat) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_link.default, { href: `/articles/category/${cat.slug}`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "group overflow-hidden rounded-xl border border-border shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer bg-card", children: [
123
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "overflow-hidden", style: { position: "relative", height: "13rem" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
124
+ import_image.default,
125
+ {
126
+ src: cat.featuredImage,
127
+ alt: cat.name,
128
+ fill: true,
129
+ sizes: "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
130
+ className: "object-cover group-hover:scale-105 transition-transform duration-300"
131
+ }
132
+ ) }),
133
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "p-4 text-center", children: [
134
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { className: "font-semibold text-foreground text-base", children: cat.name }),
135
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "text-sm text-muted-foreground mt-1", children: [
136
+ cat.count,
137
+ " ",
138
+ cat.count === 1 ? "article" : "articles"
139
+ ] })
140
+ ] })
141
+ ] }) }, cat.slug))
142
+ }
143
+ ),
134
144
  hasMore && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-10 text-center", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
135
145
  "button",
136
146
  {
@@ -248,10 +258,15 @@ function ArticleSearchBar({
248
258
  // src/ArticlesHero.tsx
249
259
  var import_link2 = __toESM(require("next/link"), 1);
250
260
  var import_jsx_runtime4 = require("react/jsx-runtime");
251
- function ArticlesHero() {
261
+ var DEFAULT_TITLE = "Vox Populus Insights";
262
+ var DEFAULT_DESCRIPTION = "Expert analysis, campaign strategies, and voter engagement insights from the frontlines of democracy.";
263
+ function ArticlesHero({
264
+ title = DEFAULT_TITLE,
265
+ description = DEFAULT_DESCRIPTION
266
+ }) {
252
267
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("section", { className: "bg-linear-to-r from-gray-50 to-gray-100 py-16 md:py-24", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "max-w-3xl mx-auto text-center", children: [
253
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h1", { className: "text-4xl md:text-5xl font-bold text-foreground mb-6", children: "Vox Populus Insights" }),
254
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "text-xl text-muted-foreground mb-8", children: "Expert analysis, campaign strategies, and voter engagement insights from the frontlines of democracy." }),
268
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h1", { className: "text-4xl md:text-5xl font-bold text-foreground mb-6", children: title }),
269
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "text-xl text-muted-foreground mb-8", children: description }),
255
270
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "flex flex-col sm:flex-row gap-4 justify-center", children: [
256
271
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
257
272
  import_link2.default,
@@ -475,8 +490,7 @@ function useArticles() {
475
490
  setLoading(true);
476
491
  const url = query ? `/api/articles?q=${encodeURIComponent(query)}` : "/api/articles";
477
492
  const response = yield fetch(url);
478
- if (!response.ok)
479
- throw new Error("Failed to fetch articles");
493
+ if (!response.ok) throw new Error("Failed to fetch articles");
480
494
  const data = yield response.json();
481
495
  setArticles(data.articles);
482
496
  if (!(query == null ? void 0 : query.trim())) {
@@ -512,8 +526,7 @@ function useArticles() {
512
526
  const handleSearch = (0, import_react3.useCallback)(
513
527
  (query) => {
514
528
  setSearchQuery(query);
515
- if (debounceRef.current)
516
- clearTimeout(debounceRef.current);
529
+ if (debounceRef.current) clearTimeout(debounceRef.current);
517
530
  if (query === "") {
518
531
  fetchArticles("");
519
532
  } else if (query.length >= 3) {
@@ -524,8 +537,7 @@ function useArticles() {
524
537
  );
525
538
  (0, import_react3.useEffect)(
526
539
  () => () => {
527
- if (debounceRef.current)
528
- clearTimeout(debounceRef.current);
540
+ if (debounceRef.current) clearTimeout(debounceRef.current);
529
541
  },
530
542
  []
531
543
  );
@@ -535,36 +547,24 @@ function useArticles() {
535
547
  // src/ArticlesPage.tsx
536
548
  var import_jsx_runtime9 = require("react/jsx-runtime");
537
549
  function buildThemeVars(theme) {
538
- if (!theme)
539
- return {};
550
+ if (!theme) return {};
540
551
  const vars = {};
541
- if (theme.fontFamily)
542
- vars["--articles-font-family"] = theme.fontFamily;
543
- if (theme.headerColor)
544
- vars["--articles-header-color"] = theme.headerColor;
545
- if (theme.textColor)
546
- vars["--articles-text-color"] = theme.textColor;
547
- if (theme.backgroundColor)
548
- vars["--articles-bg-color"] = theme.backgroundColor;
549
- if (theme.linkColor)
550
- vars["--articles-link-color"] = theme.linkColor;
551
- if (theme.linkHoverColor)
552
- vars["--articles-link-hover-color"] = theme.linkHoverColor;
553
- if (theme.linkTextDecoration)
554
- vars["--articles-link-decoration"] = theme.linkTextDecoration;
555
- if (theme.headerFontWeight)
556
- vars["--articles-header-font-weight"] = String(theme.headerFontWeight);
557
- if (theme.headerFontSize)
558
- vars["--articles-header-font-size"] = theme.headerFontSize;
559
- if (theme.bodyFontSize)
560
- vars["--articles-body-font-size"] = theme.bodyFontSize;
561
- if (theme.lineHeight)
562
- vars["--articles-line-height"] = theme.lineHeight;
563
- if (theme.borderRadius)
564
- vars["--articles-border-radius"] = theme.borderRadius;
552
+ if (theme.fontFamily) vars["--articles-font-family"] = theme.fontFamily;
553
+ if (theme.headerColor) vars["--articles-header-color"] = theme.headerColor;
554
+ if (theme.textColor) vars["--articles-text-color"] = theme.textColor;
555
+ if (theme.backgroundColor) vars["--articles-bg-color"] = theme.backgroundColor;
556
+ if (theme.linkColor) vars["--articles-link-color"] = theme.linkColor;
557
+ if (theme.linkHoverColor) vars["--articles-link-hover-color"] = theme.linkHoverColor;
558
+ if (theme.linkTextDecoration) vars["--articles-link-decoration"] = theme.linkTextDecoration;
559
+ if (theme.headerFontWeight) vars["--articles-header-font-weight"] = String(theme.headerFontWeight);
560
+ if (theme.headerFontSize) vars["--articles-header-font-size"] = theme.headerFontSize;
561
+ if (theme.bodyFontSize) vars["--articles-body-font-size"] = theme.bodyFontSize;
562
+ if (theme.lineHeight) vars["--articles-line-height"] = theme.lineHeight;
563
+ if (theme.borderRadius) vars["--articles-border-radius"] = theme.borderRadius;
565
564
  return vars;
566
565
  }
567
- function renderSection(section, ctx) {
566
+ function renderSection(section, ctx, config) {
567
+ var _a, _b;
568
568
  const {
569
569
  articles,
570
570
  displayedArticles,
@@ -578,7 +578,14 @@ function renderSection(section, ctx) {
578
578
  } = ctx;
579
579
  switch (section) {
580
580
  case "hero":
581
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ArticlesHero, {}, "hero");
581
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
582
+ ArticlesHero,
583
+ {
584
+ title: (_a = config.hero) == null ? void 0 : _a.title,
585
+ description: (_b = config.hero) == null ? void 0 : _b.description
586
+ },
587
+ "hero"
588
+ );
582
589
  case "search":
583
590
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
584
591
  ArticleSearchBar,
@@ -633,6 +640,7 @@ function renderSection(section, ctx) {
633
640
  },
634
641
  "categories"
635
642
  );
643
+ // 'newsletter' is intentionally omitted — inject it via the layout prop in the consuming app
636
644
  default:
637
645
  return null;
638
646
  }
@@ -649,7 +657,7 @@ function ArticlesPage({ config }) {
649
657
  const displayedArticles = state.searchQuery ? state.articles : articlesWithoutFeatured;
650
658
  const ctx = __spreadProps(__spreadValues({}, state), { displayedArticles, pageSize, categoriesPageSize });
651
659
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: themeVars, children: [
652
- layout.map((section) => renderSection(section, ctx)),
660
+ layout.map((section) => renderSection(section, ctx, config)),
653
661
  state.articles.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
654
662
  CollectionPageSchema,
655
663
  {
@@ -669,16 +677,13 @@ var import_jsx_runtime10 = require("react/jsx-runtime");
669
677
  function getCategoryDescription(config, slug, categoryName) {
670
678
  var _a;
671
679
  const raw = (_a = config.categoryDescriptions) == null ? void 0 : _a[slug];
672
- if (!raw)
673
- return { short: `Browse all articles in ${categoryName}.` };
674
- if (typeof raw === "string")
675
- return { short: raw };
680
+ if (!raw) return { short: `Browse all articles in ${categoryName}.` };
681
+ if (typeof raw === "string") return { short: raw };
676
682
  return raw;
677
683
  }
678
684
  function CategoryArticlesPage({ category, articles, config }) {
679
685
  var _a;
680
- if (articles.length === 0)
681
- return null;
686
+ if (articles.length === 0) return null;
682
687
  const categoryName = articles[0].category;
683
688
  const heroImage = articles[0].featuredImage;
684
689
  const description = getCategoryDescription(config, category, categoryName);
@@ -739,8 +744,7 @@ var import_jsx_runtime11 = require("react/jsx-runtime");
739
744
  var MAX_LENGTH = 2e3;
740
745
  var WARN_THRESHOLD = 1800;
741
746
  function submitLabel(submitting, parentId) {
742
- if (submitting)
743
- return "Posting\u2026";
747
+ if (submitting) return "Posting\u2026";
744
748
  return parentId ? "Reply" : "Post comment";
745
749
  }
746
750
  function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
@@ -752,8 +756,7 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
752
756
  return __async(this, null, function* () {
753
757
  var _a;
754
758
  e.preventDefault();
755
- if (!body.trim() || submitting)
756
- return;
759
+ if (!body.trim() || submitting) return;
757
760
  setSubmitting(true);
758
761
  setError(null);
759
762
  try {
@@ -824,19 +827,14 @@ var import_jsx_runtime12 = require("react/jsx-runtime");
824
827
  function relativeTime(iso) {
825
828
  const diff = Date.now() - new Date(iso).getTime();
826
829
  const minutes = Math.floor(diff / 6e4);
827
- if (minutes < 1)
828
- return "just now";
829
- if (minutes < 60)
830
- return `${minutes}m ago`;
830
+ if (minutes < 1) return "just now";
831
+ if (minutes < 60) return `${minutes}m ago`;
831
832
  const hours = Math.floor(minutes / 60);
832
- if (hours < 24)
833
- return `${hours}h ago`;
833
+ if (hours < 24) return `${hours}h ago`;
834
834
  const days = Math.floor(hours / 24);
835
- if (days < 30)
836
- return `${days}d ago`;
835
+ if (days < 30) return `${days}d ago`;
837
836
  const months = Math.floor(days / 30);
838
- if (months < 12)
839
- return `${months}mo ago`;
837
+ if (months < 12) return `${months}mo ago`;
840
838
  return `${Math.floor(months / 12)}y ago`;
841
839
  }
842
840
  function CommentItem({
@@ -853,8 +851,7 @@ function CommentItem({
853
851
  const canDelete = !!currentUserId && currentUserId === comment.authorId && !comment.isDeleted;
854
852
  function handleDelete() {
855
853
  return __async(this, null, function* () {
856
- if (deleting)
857
- return;
854
+ if (deleting) return;
858
855
  setDeleting(true);
859
856
  try {
860
857
  yield fetch(`/api/articles/${articleSlug}/comments/${comment.id}`, { method: "DELETE" });
@@ -956,8 +953,7 @@ function CommentsSection({ articleSlug, config }) {
956
953
  setFetchError(null);
957
954
  try {
958
955
  const res = yield fetch(`/api/articles/${articleSlug}/comments`);
959
- if (!res.ok)
960
- throw new Error("Failed to load comments");
956
+ if (!res.ok) throw new Error("Failed to load comments");
961
957
  const data = yield res.json();
962
958
  setComments(data.comments);
963
959
  } catch (e) {
@@ -971,8 +967,7 @@ function CommentsSection({ articleSlug, config }) {
971
967
  fetchComments().catch(() => void 0);
972
968
  }
973
969
  }, [fetchComments, enabled]);
974
- if (!enabled)
975
- return null;
970
+ if (!enabled) return null;
976
971
  const count = comments.length;
977
972
  const label = count === 1 ? "1 comment" : `${count} comments`;
978
973
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("section", { "aria-label": "Comments", className: "mt-12 pt-8 border-t border-border space-y-6", children: [