@k34a/blog 0.0.7 → 0.0.8

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
@@ -1,236 +1,413 @@
1
- # @k34a/forms
1
+ # @k34a/blog
2
2
 
3
- **Dynamic Form Builder & Handler for Next.js and Supabase**
4
- Easily create, render, and manage dynamic forms directly from your admin panel.
5
- Supports form validation, user-friendly UI (powered by Mantine), and built-in submission handling.
3
+ **A complete Articles/Blog Engine for Next.js + Supabase**
4
+ Easily fetch, search, filter, render, and display articles inside your application.
5
+ Includes backend utilities, frontend UI components, filtering, pagination, and SEO-ready article rendering out of the box.
6
6
 
7
- ## Features
7
+ ---
8
8
 
9
- - **Dynamic form rendering** using JSON schema
10
- - **Automatic form validation** with [Zod](https://github.com/colinhacks/zod)
11
- - **Form schema management** from your Supabase database
12
- - **Built-in notification hooks** for success, error, and validation feedback
13
- - **Easy API integration** for form submission
14
- - **Custom callback support** (e.g., Telegram notifications)
9
+ ## Features
15
10
 
16
- ## Installation
11
+ - **Article fetching & server-side rendering** using Supabase
12
+ - **Search, filter & sort** via built-in query schema
13
+ - **Article listing UI components** (cards, filters, pagination)
14
+ - **Full article viewer** with banner, tags, metadata & Supabase-hosted images
15
+ - **Reusable ArticleService** for all backend functionalities
16
+
17
+ ---
18
+
19
+ ## 📦 Installation
17
20
 
18
21
  ```bash
19
- npm install @k34a/forms
22
+ npm install @k34a/blog
20
23
  ```
21
24
 
22
25
  ### Required peer dependencies
23
26
 
24
- Make sure you already have these installed in your project:
25
-
26
27
  ```bash
27
- npm install @mantine/core @mantine/dates @mantine/dropzone @mantine/notifications @supabase/supabase-js @tabler/icons-react react zod
28
+ npm install @mantine/core @tabler/icons-react react @supabase/supabase-js zod
28
29
  ```
29
30
 
30
- **Required versions:**
31
-
32
- | Package | Version |
33
- |----------|----------|
34
- | `@mantine/core` | ≥ 8.0.0 |
35
- | `@mantine/dates` | ≥ 8.0.0 |
36
- | `@mantine/dropzone` | ≥ 8.0.0 |
37
- | `@mantine/notifications` | ≥ 8.0.0 |
31
+ | Package | Version |
32
+ | ----------------------- | -------- |
33
+ | `@mantine/core` | 8.0.0 |
38
34
  | `@supabase/supabase-js` | ≥ 2.52.0 |
39
- | `@tabler/icons-react` | ≥ 3.0.0 |
40
- | `react` | ≥ 19.1.0 |
41
- | `zod` | 4.0.0 |
42
-
35
+ | `@tabler/icons-react` | ≥ 3.0.0 |
36
+ | `react` | ≥ 19.1.0 |
37
+ | `zod` | >= 4.0.0 |
43
38
 
44
- ## ⚙️ API Setup
39
+ ---
45
40
 
46
- You might need to create an **API route** in your application to handle form submissions.
41
+ # ⚙️ Setup (Backend)
47
42
 
48
- Example using Next.js **Route Handlers (`app/api/fill-me/[formType]/route.ts`)**:
43
+ Create an instance of `ArticleService` anywhere in your backend/server code.
44
+ A common location is: `lib/articles.ts`.
49
45
 
50
46
  ```ts
51
- import { adminPanelLink, ORG_ID } from "@/config/config";
52
- import { supabaseAdmin } from "@/lib/db/supabase";
53
- import { sendTelegramMessage } from "@/lib/telegram";
54
- import { FormFillingService } from "@k34a/forms";
55
- import { headers } from "next/headers";
56
- import { NextRequest, NextResponse } from "next/server";
57
-
58
- function isPlainObject(input: unknown): input is Record<string, any> {
59
- return typeof input === "object" && input !== null && !Array.isArray(input);
60
- }
47
+ import { supabaseAdmin } from '@/lib/db/supabase';
48
+ import { ArticleService } from '@k34a/blog';
49
+
50
+ export const articleService = new ArticleService(supabaseAdmin);
51
+ ```
52
+
53
+ > This gives you access to all backend operations including listing, filtering, fetching by slug, resolving descriptions, etc.
54
+
55
+ ---
56
+
57
+ # 📰 Creating an Article Card Component
61
58
 
62
- async function getSourceDetails() {
63
- const hdrs = await headers();
64
- const userAgent = hdrs.get("user-agent") ?? null;
65
- const xff = hdrs.get("x-forwarded-for");
66
- const realIp = hdrs.get("x-real-ip");
67
- const sourceIp = xff?.split(",")[0].trim() ?? realIp ?? null;
68
- return { sourceIp, userAgent };
59
+ Use the built-in types & image resolver:
60
+
61
+ ```tsx
62
+ 'use client';
63
+
64
+ import React from 'react';
65
+ import {
66
+ Card,
67
+ Text,
68
+ Badge,
69
+ Group,
70
+ Button,
71
+ Stack,
72
+ Title,
73
+ Divider,
74
+ } from '@mantine/core';
75
+ import { IconBrandWhatsapp, IconClock, IconTag } from '@tabler/icons-react';
76
+ import Image from '@/components/image';
77
+ import Link from 'next/link';
78
+ import { ngoDetails } from '@/config/config';
79
+ import {
80
+ type ArticleDetailsForListing,
81
+ resolveImageForArticle,
82
+ } from '@k34a/blog';
83
+
84
+ interface Props {
85
+ article: ArticleDetailsForListing & { tags?: string[] };
69
86
  }
70
87
 
71
- export async function POST(
72
- request: NextRequest,
73
- { params }: { params: Promise<{ formType: string }> },
74
- ) {
75
- const { formType } = await params;
76
-
77
- let body: unknown;
78
- try {
79
- body = await request.json();
80
- } catch {
81
- return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
82
- }
83
-
84
- if (!isPlainObject(body)) {
85
- return NextResponse.json(
86
- { error: "Invalid request format. Expected a key-value object." },
87
- { status: 400 }
88
+ export default function ArticleCard({ article }: Props) {
89
+ const {
90
+ id,
91
+ title,
92
+ description,
93
+ slug,
94
+ banner_image,
95
+ created_at,
96
+ tags = [],
97
+ } = article;
98
+
99
+ const articleLink = `/articles/${slug}`;
100
+ const shareUrl = `${ngoDetails.contact.website}/articles/${slug}`;
101
+ const shareText = encodeURIComponent(
102
+ `Check out this article "${title}": ${shareUrl}`
88
103
  );
89
- }
90
-
91
- try {
92
- const service = new FormFillingService(
93
- adminPanelLink,
94
- ORG_ID,
95
- supabaseAdmin,
96
- async (msg) => {
97
- try {
98
- await sendTelegramMessage(msg);
99
- } catch (error) {
100
- console.log(error);
101
- }
102
- },
104
+ const whatsappLink = `https://wa.me/?text=${shareText}`;
105
+
106
+ const formattedDate = new Date(created_at).toLocaleDateString('en-IN', {
107
+ day: 'numeric',
108
+ month: 'short',
109
+ year: 'numeric',
110
+ });
111
+
112
+ return (
113
+ <Card shadow="sm" radius="md" p={0} withBorder maw={350}>
114
+ {banner_image && (
115
+ <Card.Section style={{ position: 'relative' }}>
116
+ <Link href={articleLink}>
117
+ <Image
118
+ src={resolveImageForArticle(
119
+ process.env.NEXT_PUBLIC_SUPABASE_HOSTNAME!,
120
+ id,
121
+ banner_image
122
+ )}
123
+ alt={`${title} banner`}
124
+ width={400}
125
+ height={220}
126
+ style={{
127
+ objectFit: 'cover',
128
+ width: '100%',
129
+ height: 220,
130
+ }}
131
+ />
132
+ </Link>
133
+ </Card.Section>
134
+ )}
135
+
136
+ <Stack gap="xs" p="md">
137
+ <Link href={articleLink}>
138
+ <Title size="lg" order={3} style={{ lineHeight: 1.2 }}>
139
+ {title}
140
+ </Title>
141
+ </Link>
142
+
143
+ <Group gap="xs" c="dimmed" mb={4}>
144
+ <IconClock size={14} />
145
+ <Text size="sm">{formattedDate}</Text>
146
+ </Group>
147
+
148
+ <Text size="sm" lineClamp={3}>
149
+ {description}
150
+ </Text>
151
+
152
+ {tags.length > 0 && (
153
+ <Group gap={6} mt={6}>
154
+ {tags.slice(0, 3).map((tag, idx) => (
155
+ <Badge
156
+ key={idx}
157
+ color="gray"
158
+ variant="light"
159
+ leftSection={<IconTag size={12} />}
160
+ >
161
+ {tag}
162
+ </Badge>
163
+ ))}
164
+ </Group>
165
+ )}
166
+
167
+ <Divider my="sm" />
168
+
169
+ <Group grow>
170
+ <Button
171
+ component="a"
172
+ href={whatsappLink}
173
+ target="_blank"
174
+ rel="noopener noreferrer"
175
+ variant="outline"
176
+ color="green"
177
+ leftSection={<IconBrandWhatsapp size={16} />}
178
+ fullWidth
179
+ size="md"
180
+ >
181
+ Share
182
+ </Button>
183
+
184
+ <Button
185
+ component={Link}
186
+ href={articleLink}
187
+ variant="filled"
188
+ fullWidth
189
+ size="md"
190
+ >
191
+ Read More
192
+ </Button>
193
+ </Group>
194
+ </Stack>
195
+ </Card>
103
196
  );
104
-
105
- const userDetails = await getSourceDetails();
106
- const result = await service.fillForm(
107
- formType,
108
- body,
109
- userDetails.sourceIp ?? "",
110
- userDetails.userAgent ?? "",
111
- );
112
-
113
- return NextResponse.json(result);
114
- } catch (err: any) {
115
- return NextResponse.json({ error: err.message || "Internal Server Error" }, { status: 500 });
116
- }
117
197
  }
118
198
  ```
119
199
 
200
+ ---
201
+
202
+ # 🔍 Creating a Search + Filter + Sort Bar
203
+
204
+ ```tsx
205
+ 'use client';
206
+
207
+ import {
208
+ FilterSearchSortArticles,
209
+ type FilterSearchSortArticlesProps,
210
+ } from '@k34a/blog';
211
+ import { usePathname, useRouter } from 'next/navigation';
212
+
213
+ type Props = Omit<FilterSearchSortArticlesProps, 'navigate'>;
120
214
 
121
- ### Explanation of Methods & Callbacks
215
+ export const FilterSearchSort = (props: Props) => {
216
+ const router = useRouter();
217
+ const pathname = usePathname();
122
218
 
123
- | Function / Callback | Description |
124
- |---------------------|-------------|
125
- | **`isPlainObject()`** | Ensures the incoming JSON body is a plain object (key-value pairs) and not an array or invalid input. |
126
- | **`getSourceDetails()`** | Extracts the client’s IP address and `User-Agent` from headers for logging or analytics. |
127
- | **`FormFillingService`** | Core service from `@k34a/forms` that handles form validation, storage, and notifications. |
128
- | **`fillForm(formType, body, ip, userAgent)`** | Saves and validates the submitted form data. It links the form type to the schema defined in your admin panel. |
129
- | **Telegram Callback (`async (msg) => { ... }`)** | Optional async callback triggered after a successful form submission. You can send notifications to Telegram, Slack, etc. |
219
+ function navigate(query: string) {
220
+ router.push(`${pathname}?${query}`);
221
+ }
130
222
 
223
+ return <FilterSearchSortArticles {...props} navigate={navigate} />;
224
+ };
225
+ ```
131
226
 
132
- ## Creating a Dynamic Form Component
227
+ ---
133
228
 
134
- The simplest way to render a form dynamically is by using the `FormBuilder` component.
229
+ # 📄 Pagination Control
135
230
 
136
231
  ```tsx
137
- "use client";
232
+ 'use client';
138
233
 
139
- import { FormBuilder, FormSchema } from "@k34a/forms";
140
- import z from "zod";
141
- import { notifications } from "@mantine/notifications";
142
- import { ORG_ID } from "@/config/config";
234
+ import { Pagination, Group } from '@mantine/core';
235
+ import { useRouter, usePathname, useSearchParams } from 'next/navigation';
143
236
 
144
- interface FormProps {
145
- schema: z.infer<typeof FormSchema>;
146
- formType: string;
237
+ interface PaginationControlsProps {
238
+ total: number;
239
+ currentPage: number;
240
+ pageSize: number;
147
241
  }
148
242
 
149
- export const FillMe = (props: FormProps) => {
150
- return (
151
- <FormBuilder
152
- mode="fill"
153
- schema={props.schema}
154
- formType={props.formType}
155
- orgId={ORG_ID}
156
- submissionAPIEndPoint={`/api/fill-me/${props.formType}`}
157
- onSuccess={() =>
158
- notifications.show({
159
- title: "All Set!",
160
- message:
161
- "Your details were submitted successfully. Thank you for completing the form!",
162
- color: "green",
163
- })
164
- }
165
- onValidationError={() =>
166
- notifications.show({
167
- title: "Please Review Your Form",
168
- message:
169
- "Some information seems to be missing or incorrect. Check the highlighted fields and try again.",
170
- color: "orange",
171
- })
172
- }
173
- onError={() =>
174
- notifications.show({
175
- title: "Submission Failed",
176
- message:
177
- "Something went wrong while sending your details. Please try again later.",
178
- color: "red",
179
- })
180
- }
181
- />
182
- );
183
- };
243
+ export default function PaginationControls({
244
+ total,
245
+ currentPage,
246
+ pageSize,
247
+ }: PaginationControlsProps) {
248
+ const router = useRouter();
249
+ const pathname = usePathname();
250
+ const searchParams = useSearchParams();
251
+
252
+ const totalPages = Math.ceil(total / pageSize);
253
+
254
+ if (totalPages <= 1) return null;
255
+
256
+ const handlePageChange = (page: number) => {
257
+ const params = new URLSearchParams(searchParams.toString());
258
+ params.set('page', String(page - 1));
259
+ router.push(`${pathname}?${params.toString()}`);
260
+ };
261
+
262
+ return (
263
+ <Group justify="center" mt="xl">
264
+ <Pagination
265
+ total={totalPages}
266
+ value={currentPage + 1}
267
+ onChange={handlePageChange}
268
+ />
269
+ </Group>
270
+ );
271
+ }
184
272
  ```
185
273
 
186
- > 💡 **Tip:** Make sure to set your organization ID (`ORG_ID`) from your admin panel at [k34a.vercel.app](https://k34a.vercel.app).
274
+ ---
187
275
 
276
+ # 📝 Rendering a Single Article Page
188
277
 
189
- ## Fetching the Form Schema from Supabase
278
+ ```tsx
279
+ import { articleService } from '@/lib/db/articles';
280
+ import { Article } from '@k34a/blog';
281
+ import { Metadata } from 'next';
282
+ import { notFound } from 'next/navigation';
190
283
 
191
- You can dynamically fetch a form schema from your Supabase database before rendering the form:
284
+ type PageProps = {
285
+ params: Promise<{ slug: string }>;
286
+ };
287
+
288
+ export async function generateMetadata({
289
+ params,
290
+ }: PageProps): Promise<Metadata> {
291
+ const campaign = await articleService.getBySlug((await params).slug);
292
+
293
+ if (!campaign) {
294
+ return {
295
+ title: 'Not Found',
296
+ description: "The article you're looking for does not exist.",
297
+ };
298
+ }
299
+
300
+ return {
301
+ title: campaign.title,
302
+ description: campaign.description,
303
+ };
304
+ }
305
+
306
+ export default async function ArticlePage({ params }: PageProps) {
307
+ const { slug } = await params;
308
+
309
+ const article = await articleService.getBySlug(slug);
310
+ if (!article) return notFound();
311
+
312
+ const description = (await articleService.getDescription(article.id)) ?? '';
313
+
314
+ return (
315
+ <Article
316
+ config={{
317
+ supabaseHost: process.env.NEXT_PUBLIC_SUPABASE_HOSTNAME!,
318
+ listingPage: '/articles',
319
+ }}
320
+ description={description}
321
+ tags={article.tags}
322
+ title={article.title}
323
+ banner_image={article.banner_image ?? undefined}
324
+ id={article.id}
325
+ />
326
+ );
327
+ }
328
+ ```
329
+
330
+ ---
331
+
332
+ # 📚 Rendering an Article Listing Page
192
333
 
193
334
  ```tsx
194
- import { FormFillingService } from "@k34a/forms";
195
- import { supabaseAdmin } from "@/lib/db/supabase";
196
- import { notFound } from "next/navigation";
197
- import { adminPanelLink, ORG_ID } from "@/config/config";
198
- import Partners from "@/components/partners/partners";
199
-
200
- export default async function PartnersPage() {
201
- let schema;
202
- const formType = "csr_partnership_inquiry";
203
-
204
- try {
205
- schema = await new FormFillingService(
206
- adminPanelLink,
207
- ORG_ID,
208
- supabaseAdmin,
209
- ).getFormSchema(formType);
210
- } catch (err) {
211
- console.error(err);
212
- notFound();
213
- }
214
-
215
- return (
216
- <main>
217
- <Partners schema={schema} formType={formType} />
218
- </main>
219
- );
335
+ import ArticlesCard from '@/components/articles-listing/card';
336
+ import { articleQuerySchema } from '@k34a/blog';
337
+ import { parseQueryWithPerFieldDefaults } from '@/lib/utils/query-params';
338
+ import { Container, SimpleGrid, Stack, Text } from '@mantine/core';
339
+ import { IconSearchOff } from '@tabler/icons-react';
340
+ import { articleService } from '@/lib/db/articles';
341
+ import PaginationControls from '@/components/content-management/pagination-controls';
342
+ import { FilterSearchSort } from '@/components/articles-listing/filter-search-sort';
343
+
344
+ interface Props {
345
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
346
+ }
347
+
348
+ export default async function Page(props: Props) {
349
+ const searchParams = await props.searchParams;
350
+ const params = parseQueryWithPerFieldDefaults(
351
+ articleQuerySchema,
352
+ searchParams
353
+ );
354
+
355
+ const data = await articleService.list(params);
356
+ const tags = await articleService.getTagNames();
357
+
358
+ return (
359
+ <Container size="lg" py="xl">
360
+ <FilterSearchSort {...params} availableTags={tags} />
361
+
362
+ {data.items.length === 0 ? (
363
+ <Stack align="center" py="xl" gap="sm">
364
+ <IconSearchOff size={48} stroke={1.5} color="gray" />
365
+ <Text fw={500} size="lg">
366
+ No articles found
367
+ </Text>
368
+ <Text size="sm" c="dimmed" ta="center" mx="auto" maw={300}>
369
+ We couldn&apos;t find any articles matching your
370
+ filters.
371
+ </Text>
372
+ </Stack>
373
+ ) : (
374
+ <SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
375
+ {data.items.map((article) => (
376
+ <ArticlesCard key={article.id} article={article} />
377
+ ))}
378
+ </SimpleGrid>
379
+ )}
380
+
381
+ <Text size="sm" c="dimmed" mt="md">
382
+ Showing {data.items.length} of {data.total} articles
383
+ </Text>
384
+
385
+ <PaginationControls
386
+ total={data.total}
387
+ currentPage={params.page}
388
+ pageSize={10}
389
+ />
390
+ </Container>
391
+ );
220
392
  }
221
393
  ```
222
394
 
395
+ ---
396
+
397
+ # 📘 Summary
223
398
 
224
- ## Summary
399
+ | Step | Description |
400
+ | ---------------------- | ------------------------------------------------ |
401
+ | Install the package | `npm install @k34a/blog` |
402
+ | Create ArticleService | Backend methods for fetching & listing articles |
403
+ | Build an Article Card | Use `resolveImageForArticle` for Supabase images |
404
+ | Add Search/Filter/Sort | Use `<FilterSearchSortArticles />` |
405
+ | Add Pagination | Connect query params to your router |
406
+ | Render Articles | With `<Article />` viewer |
407
+ | Render Listings | Using built-in schema & utilities |
225
408
 
226
- | Step | Description |
227
- |------|--------------|
228
- | Install the package | `npm install @k34a/forms` |
229
- | Create API handler | Accepts and validates form submissions |
230
- | Use `FormBuilder` | To render and handle forms in your UI |
231
- | Fetch form schemas | From your Supabase database using `FormFillingService` |
232
- | Customize callbacks | (Success, ValidationError, Error) for better UX |
409
+ ---
233
410
 
234
- ## License
411
+ # 📝 License
235
412
 
236
413
  MIT © 2025 — Built with ❤️ by [K34A](https://k34a.vercel.app)
package/dist/cfg.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const PAGE_SIZE = 12;
package/dist/cfg.js ADDED
@@ -0,0 +1,5 @@
1
+ "use client";
2
+ const e = 12;
3
+ export {
4
+ e as PAGE_SIZE
5
+ };
@@ -1,7 +1,7 @@
1
- import { articleSortByVsQuery as _ } from "./search-params.js";
1
+ import { articleSortByVsQuery as p } from "./search-params.js";
2
2
  import "zod";
3
- const p = 12;
4
- class q {
3
+ import { PAGE_SIZE as _ } from "../cfg.js";
4
+ class D {
5
5
  db;
6
6
  constructor(r) {
7
7
  this.db = r;
@@ -52,13 +52,13 @@ class q {
52
52
  * Paginated list of articles with filters and sorting.
53
53
  */
54
54
  async list(r) {
55
- const { page: t, search: a, sortBy: c, tags: s } = r, o = _[c] ?? _.latest;
55
+ const { page: t, search: a, sortBy: c, tags: s } = r, o = p[c] ?? p.latest;
56
56
  let e = this.db.from("articles").select("*", { count: "exact" }).eq("status", "Published");
57
57
  if (a && (e = e.ilike("title", `%${a}%`)), s && s.length > 0) {
58
- const { data: m, error: f } = await this.db.from("tags").select("id").in("name", s);
59
- if (f || !m?.length)
58
+ const { data: d, error: f } = await this.db.from("tags").select("id").in("name", s);
59
+ if (f || !d?.length)
60
60
  return console.error("Error fetching tag IDs:", f?.message), { items: [], total: 0 };
61
- const w = m.map((g) => g.id), { data: u, error: h } = await this.db.from("tag_articles").select("article_id").in("tag_id", w);
61
+ const w = d.map((g) => g.id), { data: u, error: h } = await this.db.from("tag_articles").select("article_id").in("tag_id", w);
62
62
  if (h || !u?.length)
63
63
  return console.error(
64
64
  "Error fetching articles by tags:",
@@ -72,10 +72,10 @@ class q {
72
72
  e = e.in("id", b);
73
73
  }
74
74
  e = e.order(o.column, { ascending: o.ascending });
75
- const i = t * p, n = i + p - 1;
75
+ const i = t * _, n = i + _ - 1;
76
76
  e = e.range(i, n);
77
- const { data: l, error: d, count: E } = await e;
78
- return d ? (console.error("Error fetching articles:", d), { items: [], total: 0 }) : {
77
+ const { data: l, error: m, count: E } = await e;
78
+ return m ? (console.error("Error fetching articles:", m), { items: [], total: 0 }) : {
79
79
  items: l || [],
80
80
  total: E || 0
81
81
  };
@@ -91,5 +91,5 @@ class q {
91
91
  }
92
92
  }
93
93
  export {
94
- q as ArticleService
94
+ D as ArticleService
95
95
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@k34a/blog",
3
3
  "description": "Create and share articles with your audience.",
4
4
  "private": false,
5
- "version": "0.0.7",
5
+ "version": "0.0.8",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": {