@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 +360 -183
- package/dist/cfg.d.ts +1 -0
- package/dist/cfg.js +5 -0
- package/dist/services/articles.js +11 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,236 +1,413 @@
|
|
|
1
|
-
# @k34a/
|
|
1
|
+
# @k34a/blog
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
Easily
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
---
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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/
|
|
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 @
|
|
28
|
+
npm install @mantine/core @tabler/icons-react react @supabase/supabase-js zod
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
|
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`
|
|
40
|
-
| `react`
|
|
41
|
-
| `zod`
|
|
42
|
-
|
|
35
|
+
| `@tabler/icons-react` | ≥ 3.0.0 |
|
|
36
|
+
| `react` | ≥ 19.1.0 |
|
|
37
|
+
| `zod` | >= 4.0.0 |
|
|
43
38
|
|
|
44
|
-
|
|
39
|
+
---
|
|
45
40
|
|
|
46
|
-
|
|
41
|
+
# ⚙️ Setup (Backend)
|
|
47
42
|
|
|
48
|
-
|
|
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 {
|
|
52
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
215
|
+
export const FilterSearchSort = (props: Props) => {
|
|
216
|
+
const router = useRouter();
|
|
217
|
+
const pathname = usePathname();
|
|
122
218
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
227
|
+
---
|
|
133
228
|
|
|
134
|
-
|
|
229
|
+
# 📄 Pagination Control
|
|
135
230
|
|
|
136
231
|
```tsx
|
|
137
|
-
|
|
232
|
+
'use client';
|
|
138
233
|
|
|
139
|
-
import {
|
|
140
|
-
import
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
237
|
+
interface PaginationControlsProps {
|
|
238
|
+
total: number;
|
|
239
|
+
currentPage: number;
|
|
240
|
+
pageSize: number;
|
|
147
241
|
}
|
|
148
242
|
|
|
149
|
-
export
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
274
|
+
---
|
|
187
275
|
|
|
276
|
+
# 📝 Rendering a Single Article Page
|
|
188
277
|
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
import {
|
|
196
|
-
import {
|
|
197
|
-
import {
|
|
198
|
-
import
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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'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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { articleSortByVsQuery as
|
|
1
|
+
import { articleSortByVsQuery as p } from "./search-params.js";
|
|
2
2
|
import "zod";
|
|
3
|
-
|
|
4
|
-
class
|
|
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 =
|
|
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:
|
|
59
|
-
if (f || !
|
|
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 =
|
|
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 *
|
|
75
|
+
const i = t * _, n = i + _ - 1;
|
|
76
76
|
e = e.range(i, n);
|
|
77
|
-
const { data: l, error:
|
|
78
|
-
return
|
|
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
|
-
|
|
94
|
+
D as ArticleService
|
|
95
95
|
};
|