nextworks 0.1.0-alpha.11 → 0.1.0-alpha.14
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 +20 -9
- package/dist/kits/auth-core/.nextworks/docs/AUTH_CORE_README.md +3 -3
- package/dist/kits/auth-core/.nextworks/docs/AUTH_QUICKSTART.md +264 -244
- package/dist/kits/auth-core/app/(protected)/settings/profile/profile-form.tsx +120 -114
- package/dist/kits/auth-core/app/api/auth/forgot-password/route.ts +116 -114
- package/dist/kits/auth-core/app/api/auth/reset-password/route.ts +66 -63
- package/dist/kits/auth-core/app/api/auth/send-verify-email/route.ts +1 -1
- package/dist/kits/auth-core/app/api/users/[id]/route.ts +134 -127
- package/dist/kits/auth-core/app/auth/reset-password/page.tsx +186 -187
- package/dist/kits/auth-core/components/auth/dashboard.tsx +25 -2
- package/dist/kits/auth-core/components/auth/forgot-password-form.tsx +90 -90
- package/dist/kits/auth-core/components/auth/login-form.tsx +492 -467
- package/dist/kits/auth-core/components/auth/signup-form.tsx +28 -29
- package/dist/kits/auth-core/lib/auth.ts +46 -15
- package/dist/kits/auth-core/lib/forms/map-errors.ts +37 -11
- package/dist/kits/auth-core/lib/server/result.ts +45 -45
- package/dist/kits/auth-core/lib/validation/forms.ts +1 -2
- package/dist/kits/auth-core/package-deps.json +4 -2
- package/dist/kits/auth-core/types/next-auth.d.ts +1 -1
- package/dist/kits/blocks/.nextworks/docs/BLOCKS_QUICKSTART.md +2 -8
- package/dist/kits/blocks/.nextworks/docs/THEME_GUIDE.md +18 -1
- package/dist/kits/blocks/app/templates/productlaunch/page.tsx +0 -2
- package/dist/kits/blocks/components/sections/FAQ.tsx +0 -1
- package/dist/kits/blocks/components/sections/Newsletter.tsx +2 -2
- package/dist/kits/blocks/components/ui/switch.tsx +78 -78
- package/dist/kits/blocks/components/ui/theme-selector.tsx +1 -1
- package/dist/kits/blocks/lib/themes.ts +1 -0
- package/dist/kits/blocks/package-deps.json +4 -4
- package/dist/kits/data/.nextworks/docs/DATA_QUICKSTART.md +128 -112
- package/dist/kits/data/.nextworks/docs/DATA_README.md +2 -1
- package/dist/kits/data/app/api/posts/[id]/route.ts +83 -83
- package/dist/kits/data/app/api/posts/route.ts +136 -138
- package/dist/kits/data/app/api/seed-demo/route.ts +1 -2
- package/dist/kits/data/app/api/users/[id]/route.ts +29 -17
- package/dist/kits/data/app/api/users/check-email/route.ts +1 -1
- package/dist/kits/data/app/api/users/check-unique/route.ts +30 -27
- package/dist/kits/data/app/api/users/route.ts +0 -2
- package/dist/kits/data/app/examples/demo/create-post-form.tsx +108 -106
- package/dist/kits/data/app/examples/demo/page.tsx +2 -1
- package/dist/kits/data/app/examples/demo/seed-demo-button.tsx +1 -1
- package/dist/kits/data/components/admin/posts-manager.tsx +727 -719
- package/dist/kits/data/components/admin/users-manager.tsx +435 -432
- package/dist/kits/data/lib/server/result.ts +5 -2
- package/dist/kits/data/package-deps.json +1 -1
- package/dist/kits/data/scripts/seed-demo.mjs +1 -2
- package/dist/kits/forms/app/api/wizard/route.ts +76 -71
- package/dist/kits/forms/app/examples/forms/server-action/page.tsx +78 -71
- package/dist/kits/forms/components/hooks/useCheckUnique.ts +85 -79
- package/dist/kits/forms/components/ui/form/form-control.tsx +28 -28
- package/dist/kits/forms/components/ui/form/form-description.tsx +23 -22
- package/dist/kits/forms/components/ui/form/form-item.tsx +21 -21
- package/dist/kits/forms/components/ui/form/form-label.tsx +24 -24
- package/dist/kits/forms/components/ui/form/form-message.tsx +28 -29
- package/dist/kits/forms/components/ui/switch.tsx +78 -78
- package/dist/kits/forms/lib/forms/map-errors.ts +1 -1
- package/dist/kits/forms/lib/validation/forms.ts +1 -2
- package/package.json +1 -1
|
@@ -1,719 +1,727 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useEffect, useMemo, useState, type JSX } from "react";
|
|
4
|
-
import { useSession } from "next-auth/react";
|
|
5
|
-
import { useForm } from "react-hook-form";
|
|
6
|
-
import { zodResolver } from "@hookform/resolvers/zod";
|
|
7
|
-
import { z } from "zod";
|
|
8
|
-
import { cn } from "@/lib/utils";
|
|
9
|
-
import { toast } from "sonner";
|
|
10
|
-
|
|
11
|
-
import { Button } from "@/components/ui/button";
|
|
12
|
-
import { Input } from "@/components/ui/input";
|
|
13
|
-
import { Textarea } from "@/components/ui/textarea";
|
|
14
|
-
import { Card } from "@/components/ui/card";
|
|
15
|
-
import { Switch } from "@/components/ui/switch";
|
|
16
|
-
import {
|
|
17
|
-
Table,
|
|
18
|
-
TableBody,
|
|
19
|
-
TableCell,
|
|
20
|
-
TableHead,
|
|
21
|
-
TableHeader,
|
|
22
|
-
TableRow,
|
|
23
|
-
} from "@/components/ui/table";
|
|
24
|
-
import {
|
|
25
|
-
AlertDialog,
|
|
26
|
-
AlertDialogAction,
|
|
27
|
-
AlertDialogCancel,
|
|
28
|
-
AlertDialogContent,
|
|
29
|
-
AlertDialogDescription,
|
|
30
|
-
AlertDialogFooter,
|
|
31
|
-
AlertDialogHeader,
|
|
32
|
-
AlertDialogTitle,
|
|
33
|
-
AlertDialogTrigger,
|
|
34
|
-
} from "@/components/ui/alert-dialog";
|
|
35
|
-
import { Skeleton } from "@/components/ui/skeleton";
|
|
36
|
-
import { Form } from "@/components/ui/form/form";
|
|
37
|
-
import { FormField } from "@/components/ui/form/form-field";
|
|
38
|
-
import { FormItem } from "@/components/ui/form/form-item";
|
|
39
|
-
import { FormLabel } from "@/components/ui/form/form-label";
|
|
40
|
-
import { FormMessage } from "@/components/ui/form/form-message";
|
|
41
|
-
import { FormControl } from "@/components/ui/form/form-control";
|
|
42
|
-
import { postSchema } from "@/lib/validation/forms";
|
|
43
|
-
import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
|
|
44
|
-
|
|
45
|
-
export interface PostsManagerSlots {
|
|
46
|
-
container?: { className?: string };
|
|
47
|
-
formCard?: { className?: string };
|
|
48
|
-
listCard?: { className?: string };
|
|
49
|
-
leftHeading?: { className?: string };
|
|
50
|
-
rightHeading?: { className?: string };
|
|
51
|
-
form?: { className?: string };
|
|
52
|
-
submitRow?: { className?: string };
|
|
53
|
-
actionsRow?: { className?: string };
|
|
54
|
-
table?: { className?: string };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface PostsManagerProps extends PostsManagerSlots {
|
|
58
|
-
className?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
type Post = {
|
|
62
|
-
id: string;
|
|
63
|
-
title: string;
|
|
64
|
-
content?: string | null;
|
|
65
|
-
authorId: string;
|
|
66
|
-
author?: { name?: string | null; email: string } | null;
|
|
67
|
-
published?: boolean | null;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export function PostsManager({
|
|
71
|
-
className,
|
|
72
|
-
container = {
|
|
73
|
-
className:
|
|
74
|
-
"mx-auto grid w-full max-w-5xl gap-6 p-6 grid-cols-1 lg:[grid-template-columns:2fr_3.6fr]",
|
|
75
|
-
},
|
|
76
|
-
formCard = { className: "p-6" },
|
|
77
|
-
listCard = { className: "p-6" },
|
|
78
|
-
leftHeading = { className: "mb-4 text-lg font-semibold" },
|
|
79
|
-
rightHeading = { className: "mb-3 font-medium" },
|
|
80
|
-
form = { className: "space-y-4" },
|
|
81
|
-
submitRow = { className: "flex items-center gap-3" },
|
|
82
|
-
actionsRow = { className: "flex flex-col gap-2 lg:flex-row" },
|
|
83
|
-
table = { className: "" },
|
|
84
|
-
}: PostsManagerProps): JSX.Element {
|
|
85
|
-
const [posts, setPosts] = useState<Post[]>([]);
|
|
86
|
-
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
87
|
-
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
88
|
-
const [loading, setLoading] = useState(false);
|
|
89
|
-
const [listLoading, setListLoading] = useState(true);
|
|
90
|
-
|
|
91
|
-
// Relaxed schema for this admin form: authorId is optional; the API defaults to the session user id if omitted.
|
|
92
|
-
const postFormSchema = postSchema.extend({
|
|
93
|
-
authorId: postSchema.shape.authorId.optional().or(z.literal("")),
|
|
94
|
-
published: postSchema.shape.published?.optional(),
|
|
95
|
-
});
|
|
96
|
-
type AdminPostFormValues = z.infer<typeof postFormSchema>;
|
|
97
|
-
|
|
98
|
-
const defaultValues = useMemo<AdminPostFormValues>(
|
|
99
|
-
() => ({ title: "", content: "", authorId: "", published: false }),
|
|
100
|
-
[],
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const { data: session } = useSession();
|
|
104
|
-
const sessionUserId = (session?.user as { id?: string } | undefined)?.id;
|
|
105
|
-
const sessionIsAdmin =
|
|
106
|
-
(session?.user as { role?: string } | undefined)?.role === "admin";
|
|
107
|
-
|
|
108
|
-
const formMethods = useForm<AdminPostFormValues>({
|
|
109
|
-
resolver: zodResolver(postFormSchema),
|
|
110
|
-
defaultValues,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const {
|
|
114
|
-
control,
|
|
115
|
-
handleSubmit,
|
|
116
|
-
reset,
|
|
117
|
-
setValue,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const [
|
|
123
|
-
const [
|
|
124
|
-
const [
|
|
125
|
-
const [
|
|
126
|
-
const [
|
|
127
|
-
const [
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const [total, setTotal] = useState<number>(0);
|
|
134
|
-
|
|
135
|
-
const fetchPosts = async (opts?: { page?: number; perPage?: number }) => {
|
|
136
|
-
setListLoading(true);
|
|
137
|
-
try {
|
|
138
|
-
const p = opts?.page ?? page;
|
|
139
|
-
const pp = opts?.perPage ?? perPage;
|
|
140
|
-
const params = new URLSearchParams();
|
|
141
|
-
params.set("page", String(p));
|
|
142
|
-
params.set("perPage", String(pp));
|
|
143
|
-
if (q) params.set("q", q);
|
|
144
|
-
if (sort) params.set("sort", sort);
|
|
145
|
-
// keep explicit sort field and dir for clarity
|
|
146
|
-
params.set("sortField", sortField);
|
|
147
|
-
params.set("sortDir", sortDir);
|
|
148
|
-
// published filter: all | published | draft
|
|
149
|
-
if (publishFilter && publishFilter !== "all") {
|
|
150
|
-
params.set("published", publishFilter);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const res = await fetch(`/api/posts?${params.toString()}`, {
|
|
154
|
-
cache: "no-store",
|
|
155
|
-
});
|
|
156
|
-
const payload = await res.json().catch(() => null);
|
|
157
|
-
const data =
|
|
158
|
-
payload && typeof payload === "object" && "success" in payload
|
|
159
|
-
? (payload.data ?? [])
|
|
160
|
-
: (payload ?? []);
|
|
161
|
-
|
|
162
|
-
// Handle paginated shape { items, total, page, perPage }
|
|
163
|
-
if (data && typeof data === "object" && "items" in data) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (opts?.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
await
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
await
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
<Input
|
|
421
|
-
placeholder="Author ID"
|
|
422
|
-
autoComplete="off"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
<TableCell
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
>
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
<Button
|
|
701
|
-
size="sm"
|
|
702
|
-
variant="ghost"
|
|
703
|
-
onClick={() => setPage((p) => p
|
|
704
|
-
disabled={
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useState, type JSX } from "react";
|
|
4
|
+
import { useSession } from "next-auth/react";
|
|
5
|
+
import { useForm } from "react-hook-form";
|
|
6
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
14
|
+
import { Card } from "@/components/ui/card";
|
|
15
|
+
import { Switch } from "@/components/ui/switch";
|
|
16
|
+
import {
|
|
17
|
+
Table,
|
|
18
|
+
TableBody,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableHeader,
|
|
22
|
+
TableRow,
|
|
23
|
+
} from "@/components/ui/table";
|
|
24
|
+
import {
|
|
25
|
+
AlertDialog,
|
|
26
|
+
AlertDialogAction,
|
|
27
|
+
AlertDialogCancel,
|
|
28
|
+
AlertDialogContent,
|
|
29
|
+
AlertDialogDescription,
|
|
30
|
+
AlertDialogFooter,
|
|
31
|
+
AlertDialogHeader,
|
|
32
|
+
AlertDialogTitle,
|
|
33
|
+
AlertDialogTrigger,
|
|
34
|
+
} from "@/components/ui/alert-dialog";
|
|
35
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
36
|
+
import { Form } from "@/components/ui/form/form";
|
|
37
|
+
import { FormField } from "@/components/ui/form/form-field";
|
|
38
|
+
import { FormItem } from "@/components/ui/form/form-item";
|
|
39
|
+
import { FormLabel } from "@/components/ui/form/form-label";
|
|
40
|
+
import { FormMessage } from "@/components/ui/form/form-message";
|
|
41
|
+
import { FormControl } from "@/components/ui/form/form-control";
|
|
42
|
+
import { postSchema } from "@/lib/validation/forms";
|
|
43
|
+
import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
|
|
44
|
+
|
|
45
|
+
export interface PostsManagerSlots {
|
|
46
|
+
container?: { className?: string };
|
|
47
|
+
formCard?: { className?: string };
|
|
48
|
+
listCard?: { className?: string };
|
|
49
|
+
leftHeading?: { className?: string };
|
|
50
|
+
rightHeading?: { className?: string };
|
|
51
|
+
form?: { className?: string };
|
|
52
|
+
submitRow?: { className?: string };
|
|
53
|
+
actionsRow?: { className?: string };
|
|
54
|
+
table?: { className?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PostsManagerProps extends PostsManagerSlots {
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Post = {
|
|
62
|
+
id: string;
|
|
63
|
+
title: string;
|
|
64
|
+
content?: string | null;
|
|
65
|
+
authorId: string;
|
|
66
|
+
author?: { name?: string | null; email: string } | null;
|
|
67
|
+
published?: boolean | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function PostsManager({
|
|
71
|
+
className,
|
|
72
|
+
container = {
|
|
73
|
+
className:
|
|
74
|
+
"mx-auto grid w-full max-w-5xl gap-6 p-6 grid-cols-1 lg:[grid-template-columns:2fr_3.6fr]",
|
|
75
|
+
},
|
|
76
|
+
formCard = { className: "p-6" },
|
|
77
|
+
listCard = { className: "p-6" },
|
|
78
|
+
leftHeading = { className: "mb-4 text-lg font-semibold" },
|
|
79
|
+
rightHeading = { className: "mb-3 font-medium" },
|
|
80
|
+
form = { className: "space-y-4" },
|
|
81
|
+
submitRow = { className: "flex items-center gap-3" },
|
|
82
|
+
actionsRow = { className: "flex flex-col gap-2 lg:flex-row" },
|
|
83
|
+
table = { className: "" },
|
|
84
|
+
}: PostsManagerProps): JSX.Element {
|
|
85
|
+
const [posts, setPosts] = useState<Post[]>([]);
|
|
86
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
87
|
+
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
88
|
+
const [loading, setLoading] = useState(false);
|
|
89
|
+
const [listLoading, setListLoading] = useState(true);
|
|
90
|
+
|
|
91
|
+
// Relaxed schema for this admin form: authorId is optional; the API defaults to the session user id if omitted.
|
|
92
|
+
const postFormSchema = postSchema.extend({
|
|
93
|
+
authorId: postSchema.shape.authorId.optional().or(z.literal("")),
|
|
94
|
+
published: postSchema.shape.published?.optional(),
|
|
95
|
+
});
|
|
96
|
+
type AdminPostFormValues = z.infer<typeof postFormSchema>;
|
|
97
|
+
|
|
98
|
+
const defaultValues = useMemo<AdminPostFormValues>(
|
|
99
|
+
() => ({ title: "", content: "", authorId: "", published: false }),
|
|
100
|
+
[],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const { data: session } = useSession();
|
|
104
|
+
const sessionUserId = (session?.user as { id?: string } | undefined)?.id;
|
|
105
|
+
const sessionIsAdmin =
|
|
106
|
+
(session?.user as { role?: string } | undefined)?.role === "admin";
|
|
107
|
+
|
|
108
|
+
const formMethods = useForm<AdminPostFormValues>({
|
|
109
|
+
resolver: zodResolver(postFormSchema),
|
|
110
|
+
defaultValues,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const {
|
|
114
|
+
control,
|
|
115
|
+
handleSubmit,
|
|
116
|
+
reset,
|
|
117
|
+
setValue,
|
|
118
|
+
formState: { isSubmitting },
|
|
119
|
+
} = formMethods;
|
|
120
|
+
|
|
121
|
+
const [page, setPage] = useState<number>(1);
|
|
122
|
+
const [perPage, setPerPage] = useState<number>(8);
|
|
123
|
+
const [q, setQ] = useState<string>("");
|
|
124
|
+
const [sort, setSort] = useState<string>("createdAt_desc");
|
|
125
|
+
const [sortField, setSortField] = useState<string>("createdAt");
|
|
126
|
+
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
127
|
+
const [publishFilter, setPublishFilter] = useState<
|
|
128
|
+
"all" | "published" | "draft"
|
|
129
|
+
>("all");
|
|
130
|
+
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
const [total, setTotal] = useState<number>(0);
|
|
134
|
+
|
|
135
|
+
const fetchPosts = async (opts?: { page?: number; perPage?: number }) => {
|
|
136
|
+
setListLoading(true);
|
|
137
|
+
try {
|
|
138
|
+
const p = opts?.page ?? page;
|
|
139
|
+
const pp = opts?.perPage ?? perPage;
|
|
140
|
+
const params = new URLSearchParams();
|
|
141
|
+
params.set("page", String(p));
|
|
142
|
+
params.set("perPage", String(pp));
|
|
143
|
+
if (q) params.set("q", q);
|
|
144
|
+
if (sort) params.set("sort", sort);
|
|
145
|
+
// keep explicit sort field and dir for clarity
|
|
146
|
+
params.set("sortField", sortField);
|
|
147
|
+
params.set("sortDir", sortDir);
|
|
148
|
+
// published filter: all | published | draft
|
|
149
|
+
if (publishFilter && publishFilter !== "all") {
|
|
150
|
+
params.set("published", publishFilter);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const res = await fetch(`/api/posts?${params.toString()}`, {
|
|
154
|
+
cache: "no-store",
|
|
155
|
+
});
|
|
156
|
+
const payload = await res.json().catch(() => null);
|
|
157
|
+
const data =
|
|
158
|
+
payload && typeof payload === "object" && "success" in payload
|
|
159
|
+
? (payload.data ?? [])
|
|
160
|
+
: (payload ?? []);
|
|
161
|
+
|
|
162
|
+
// Handle paginated shape { items, total, page, perPage }
|
|
163
|
+
if (data && typeof data === "object" && "items" in data) {
|
|
164
|
+
const d = data as { items: unknown; total?: unknown; page?: unknown; perPage?: unknown };
|
|
165
|
+
setPosts(Array.isArray(d.items) ? (d.items as Post[]) : []);
|
|
166
|
+
setTotal(typeof d.total === "number" ? d.total : 0);
|
|
167
|
+
if (typeof d.page === "number") setPage(d.page);
|
|
168
|
+
if (typeof d.perPage === "number") setPerPage(d.perPage);
|
|
169
|
+
} else if (Array.isArray(data)) {
|
|
170
|
+
setPosts(data as Post[]);
|
|
171
|
+
setTotal(data.length);
|
|
172
|
+
} else {
|
|
173
|
+
setPosts([]);
|
|
174
|
+
setTotal(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// If caller passed explicit page/perPage, update state
|
|
178
|
+
if (opts?.page) setPage(opts.page);
|
|
179
|
+
if (opts?.perPage) setPerPage(opts.perPage);
|
|
180
|
+
} finally {
|
|
181
|
+
setListLoading(false);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
fetchPosts();
|
|
187
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
+
}, [page, perPage, q, sort, publishFilter]);
|
|
189
|
+
|
|
190
|
+
const createPost = async (values: AdminPostFormValues) => {
|
|
191
|
+
setLoading(true);
|
|
192
|
+
try {
|
|
193
|
+
// Do not send authorId when the user is not an admin; server will infer from session
|
|
194
|
+
const payloadBody = sessionIsAdmin
|
|
195
|
+
? values
|
|
196
|
+
: {
|
|
197
|
+
title: values.title,
|
|
198
|
+
content: values.content,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const res = await fetch("/api/posts", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "Content-Type": "application/json" },
|
|
204
|
+
body: JSON.stringify(payloadBody),
|
|
205
|
+
});
|
|
206
|
+
const payload = await res.json().catch(() => null);
|
|
207
|
+
if (!res.ok || !payload?.success) {
|
|
208
|
+
const msg = payload
|
|
209
|
+
? mapApiErrorsToForm(formMethods, payload)
|
|
210
|
+
: undefined;
|
|
211
|
+
toast.error(
|
|
212
|
+
msg || payload?.message || "Author ID not found or create failed",
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
reset(defaultValues);
|
|
217
|
+
await fetchPosts();
|
|
218
|
+
toast.success("Post created");
|
|
219
|
+
} catch {
|
|
220
|
+
toast.error("Author ID not found or create failed");
|
|
221
|
+
} finally {
|
|
222
|
+
setLoading(false);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const updatePost = async (values: AdminPostFormValues) => {
|
|
227
|
+
if (!selectedId) return;
|
|
228
|
+
setLoading(true);
|
|
229
|
+
try {
|
|
230
|
+
const res = await fetch(`/api/posts/${selectedId}`, {
|
|
231
|
+
method: "PUT",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
title: values.title,
|
|
235
|
+
content: values.content,
|
|
236
|
+
published: values.published,
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
const payload = await res.json().catch(() => null);
|
|
240
|
+
if (!res.ok || !payload?.success) {
|
|
241
|
+
const msg = payload
|
|
242
|
+
? mapApiErrorsToForm(formMethods, payload)
|
|
243
|
+
: undefined;
|
|
244
|
+
toast.error(msg || payload?.message || "Update failed");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
reset(defaultValues);
|
|
248
|
+
setSelectedId(null);
|
|
249
|
+
await fetchPosts();
|
|
250
|
+
toast.success("Post updated");
|
|
251
|
+
} catch {
|
|
252
|
+
toast.error("Update failed");
|
|
253
|
+
} finally {
|
|
254
|
+
setLoading(false);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const confirmDelete = (id: string) => setPendingDeleteId(id);
|
|
259
|
+
|
|
260
|
+
const doDelete = async () => {
|
|
261
|
+
if (!pendingDeleteId) return;
|
|
262
|
+
setLoading(true);
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetch(`/api/posts/${pendingDeleteId}`, {
|
|
265
|
+
method: "DELETE",
|
|
266
|
+
});
|
|
267
|
+
if (!res.ok) throw new Error("Delete failed");
|
|
268
|
+
await fetchPosts();
|
|
269
|
+
toast.success("Post deleted");
|
|
270
|
+
} catch {
|
|
271
|
+
toast.error("Delete failed");
|
|
272
|
+
} finally {
|
|
273
|
+
setLoading(false);
|
|
274
|
+
setPendingDeleteId(null);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const onSelect = (p: Post) => {
|
|
279
|
+
setSelectedId(p.id);
|
|
280
|
+
setValue("title", p.title);
|
|
281
|
+
setValue("content", p.content ?? "");
|
|
282
|
+
setValue("authorId", p.authorId);
|
|
283
|
+
setValue("published", (p.published as boolean) ?? false);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Toggle published state for a post (inline from the list)
|
|
287
|
+
const togglePublish = async (
|
|
288
|
+
id: string,
|
|
289
|
+
newVal: boolean,
|
|
290
|
+
authorId?: string,
|
|
291
|
+
) => {
|
|
292
|
+
// Only allow if current user is admin or the author
|
|
293
|
+
const canToggle = sessionIsAdmin || sessionUserId === authorId;
|
|
294
|
+
if (!canToggle) {
|
|
295
|
+
toast.error("Forbidden");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// show row-level loading state
|
|
300
|
+
setRowLoading((r) => ({ ...r, [id]: true }));
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
// Do NOT optimistically remove or flip the row state; show spinner and refetch after success
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch(`/api/posts/${id}`, {
|
|
308
|
+
method: "PUT",
|
|
309
|
+
headers: { "Content-Type": "application/json" },
|
|
310
|
+
body: JSON.stringify({ published: newVal }),
|
|
311
|
+
});
|
|
312
|
+
const payload = await res.json().catch(() => null);
|
|
313
|
+
if (!res.ok || !payload?.success) {
|
|
314
|
+
throw new Error(payload?.message || "Failed to update");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// After successful update, refetch current page to get canonical state
|
|
318
|
+
await fetchPosts();
|
|
319
|
+
|
|
320
|
+
toast.success("Updated");
|
|
321
|
+
} catch {
|
|
322
|
+
toast.error("Failed to update published");
|
|
323
|
+
} finally {
|
|
324
|
+
setRowLoading((r) => {
|
|
325
|
+
|
|
326
|
+
const copy = { ...r };
|
|
327
|
+
delete copy[id];
|
|
328
|
+
return copy;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const renderTwoLinePreview = (text?: string | null): JSX.Element | string => {
|
|
334
|
+
if (!text) return "";
|
|
335
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
336
|
+
const maxPerLine = 5;
|
|
337
|
+
const maxLines = 2;
|
|
338
|
+
const maxWords = maxPerLine * maxLines; // 10 words
|
|
339
|
+
if (words.length <= maxWords) {
|
|
340
|
+
// If it fits, split into up to two lines for nicer layout
|
|
341
|
+
if (words.length <= maxPerLine) return words.join(" ");
|
|
342
|
+
const first = words.slice(0, maxPerLine).join(" ");
|
|
343
|
+
const second = words.slice(maxPerLine).join(" ");
|
|
344
|
+
return (
|
|
345
|
+
<>
|
|
346
|
+
{first}
|
|
347
|
+
<br />
|
|
348
|
+
{second}
|
|
349
|
+
</>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const first = words.slice(0, maxPerLine).join(" ");
|
|
353
|
+
const second = words.slice(maxPerLine, maxWords).join(" ");
|
|
354
|
+
return (
|
|
355
|
+
<>
|
|
356
|
+
{first}
|
|
357
|
+
<br />
|
|
358
|
+
{second}…
|
|
359
|
+
</>
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<main className={cn(container.className, className)}>
|
|
365
|
+
<Card className={cn(formCard.className)}>
|
|
366
|
+
<h2 className={cn(leftHeading.className)}>Post Manager</h2>
|
|
367
|
+
|
|
368
|
+
<Form<AdminPostFormValues> methods={formMethods}>
|
|
369
|
+
<form
|
|
370
|
+
onSubmit={handleSubmit(selectedId ? updatePost : createPost)}
|
|
371
|
+
className={cn(form.className)}
|
|
372
|
+
>
|
|
373
|
+
<FormField
|
|
374
|
+
control={control}
|
|
375
|
+
name="title"
|
|
376
|
+
render={({ field }) => (
|
|
377
|
+
<FormItem className="space-y-2">
|
|
378
|
+
<FormLabel>Title</FormLabel>
|
|
379
|
+
<FormControl>
|
|
380
|
+
<Input
|
|
381
|
+
placeholder="Post title"
|
|
382
|
+
autoComplete="off"
|
|
383
|
+
{...field}
|
|
384
|
+
/>
|
|
385
|
+
</FormControl>
|
|
386
|
+
<FormMessage />
|
|
387
|
+
</FormItem>
|
|
388
|
+
)}
|
|
389
|
+
/>
|
|
390
|
+
|
|
391
|
+
<FormField
|
|
392
|
+
control={control}
|
|
393
|
+
name="content"
|
|
394
|
+
render={({ field }) => (
|
|
395
|
+
<FormItem className="space-y-2">
|
|
396
|
+
<FormLabel>Content</FormLabel>
|
|
397
|
+
<FormControl>
|
|
398
|
+
<Textarea
|
|
399
|
+
placeholder="Post content (optional)"
|
|
400
|
+
autoComplete="off"
|
|
401
|
+
rows={6}
|
|
402
|
+
{...field}
|
|
403
|
+
/>
|
|
404
|
+
</FormControl>
|
|
405
|
+
<FormMessage />
|
|
406
|
+
</FormItem>
|
|
407
|
+
)}
|
|
408
|
+
/>
|
|
409
|
+
|
|
410
|
+
<FormField
|
|
411
|
+
control={control}
|
|
412
|
+
name="authorId"
|
|
413
|
+
render={({ field }) => (
|
|
414
|
+
<FormItem className="space-y-2">
|
|
415
|
+
<FormLabel>
|
|
416
|
+
Author ID (optional; defaults to your user)
|
|
417
|
+
</FormLabel>
|
|
418
|
+
<FormControl>
|
|
419
|
+
{sessionIsAdmin ? (
|
|
420
|
+
<Input
|
|
421
|
+
placeholder="Author ID"
|
|
422
|
+
autoComplete="off"
|
|
423
|
+
{...field}
|
|
424
|
+
/>
|
|
425
|
+
) : (
|
|
426
|
+
<Input
|
|
427
|
+
placeholder="Author ID"
|
|
428
|
+
autoComplete="off"
|
|
429
|
+
value={sessionUserId ?? ""}
|
|
430
|
+
disabled
|
|
431
|
+
onChange={() => {}}
|
|
432
|
+
/>
|
|
433
|
+
)}
|
|
434
|
+
</FormControl>
|
|
435
|
+
<FormMessage />
|
|
436
|
+
</FormItem>
|
|
437
|
+
)}
|
|
438
|
+
/>
|
|
439
|
+
|
|
440
|
+
<FormField
|
|
441
|
+
control={control}
|
|
442
|
+
name="published"
|
|
443
|
+
render={({ field }) => (
|
|
444
|
+
<FormItem className="flex items-center gap-4">
|
|
445
|
+
<FormLabel className="mr-2 mb-0">Published</FormLabel>
|
|
446
|
+
<FormControl>
|
|
447
|
+
<Switch
|
|
448
|
+
checked={!!field.value}
|
|
449
|
+
onChange={(e) =>
|
|
450
|
+
field.onChange((e.target as HTMLInputElement).checked)
|
|
451
|
+
}
|
|
452
|
+
className="ml-3"
|
|
453
|
+
/>
|
|
454
|
+
</FormControl>
|
|
455
|
+
<FormMessage />
|
|
456
|
+
</FormItem>
|
|
457
|
+
)}
|
|
458
|
+
/>
|
|
459
|
+
|
|
460
|
+
<div className={cn(submitRow.className)}>
|
|
461
|
+
{!selectedId ? (
|
|
462
|
+
<Button type="submit" disabled={isSubmitting || loading}>
|
|
463
|
+
Create Post
|
|
464
|
+
</Button>
|
|
465
|
+
) : (
|
|
466
|
+
<>
|
|
467
|
+
<Button type="submit" disabled={isSubmitting || loading}>
|
|
468
|
+
Update Post
|
|
469
|
+
</Button>
|
|
470
|
+
<Button
|
|
471
|
+
type="button"
|
|
472
|
+
variant="ghost"
|
|
473
|
+
onClick={() => {
|
|
474
|
+
reset(defaultValues);
|
|
475
|
+
setSelectedId(null);
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
Cancel
|
|
479
|
+
</Button>
|
|
480
|
+
</>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
</form>
|
|
484
|
+
</Form>
|
|
485
|
+
</Card>
|
|
486
|
+
|
|
487
|
+
<Card className={cn(listCard.className)}>
|
|
488
|
+
<h3 className={cn(rightHeading.className)}>All Posts</h3>
|
|
489
|
+
|
|
490
|
+
<div className="mb-3">
|
|
491
|
+
{/* Search on its own line to avoid overflowing controls in narrow cards */}
|
|
492
|
+
<div className="mb-2">
|
|
493
|
+
<Input
|
|
494
|
+
placeholder="Search title..."
|
|
495
|
+
value={q}
|
|
496
|
+
onChange={(e) => setQ(e.target.value)}
|
|
497
|
+
className="w-full"
|
|
498
|
+
/>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div className="flex items-center justify-between">
|
|
502
|
+
<div className="flex items-center gap-2">
|
|
503
|
+
<select
|
|
504
|
+
value={publishFilter}
|
|
505
|
+
onChange={(e) => {
|
|
506
|
+
setPublishFilter(
|
|
507
|
+
e.target.value as "all" | "published" | "draft",
|
|
508
|
+
);
|
|
509
|
+
setPage(1);
|
|
510
|
+
}}
|
|
511
|
+
className="rounded-md border px-2 py-1"
|
|
512
|
+
>
|
|
513
|
+
<option value="all">All</option>
|
|
514
|
+
<option value="published">Published</option>
|
|
515
|
+
<option value="draft">Drafts</option>
|
|
516
|
+
</select>
|
|
517
|
+
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={() => {
|
|
521
|
+
// toggle sort by createdAt
|
|
522
|
+
if (sortField === "createdAt") {
|
|
523
|
+
const nd = sortDir === "desc" ? "asc" : "desc";
|
|
524
|
+
setSortDir(nd);
|
|
525
|
+
setSort(`createdAt${nd === "desc" ? "_desc" : ""}`);
|
|
526
|
+
} else {
|
|
527
|
+
setSortField("createdAt");
|
|
528
|
+
setSortDir("desc");
|
|
529
|
+
setSort("createdAt_desc");
|
|
530
|
+
}
|
|
531
|
+
setPage(1);
|
|
532
|
+
}}
|
|
533
|
+
className="inline-flex h-9 items-center gap-2 rounded-md border px-3"
|
|
534
|
+
>
|
|
535
|
+
<span>Newest</span>
|
|
536
|
+
<span
|
|
537
|
+
className={`w-4 text-center text-sm leading-none ${sortField === "createdAt" ? "opacity-100" : "opacity-0"}`}
|
|
538
|
+
>
|
|
539
|
+
{sortDir === "desc" ? "▼" : "▲"}
|
|
540
|
+
</span>
|
|
541
|
+
</button>
|
|
542
|
+
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
onClick={() => {
|
|
546
|
+
// toggle sort by title
|
|
547
|
+
if (sortField === "title") {
|
|
548
|
+
const nd = sortDir === "desc" ? "asc" : "desc";
|
|
549
|
+
setSortDir(nd);
|
|
550
|
+
setSort(`title${nd === "desc" ? "_desc" : ""}`);
|
|
551
|
+
} else {
|
|
552
|
+
setSortField("title");
|
|
553
|
+
setSortDir("asc");
|
|
554
|
+
setSort("title");
|
|
555
|
+
}
|
|
556
|
+
setPage(1);
|
|
557
|
+
}}
|
|
558
|
+
className="inline-flex h-9 items-center gap-2 rounded-md border px-3"
|
|
559
|
+
>
|
|
560
|
+
<span>Title</span>
|
|
561
|
+
<span
|
|
562
|
+
className={`w-4 text-center text-sm leading-none ${sortField === "title" ? "opacity-100" : "opacity-0"}`}
|
|
563
|
+
>
|
|
564
|
+
{sortDir === "asc" ? "▲" : "▼"}
|
|
565
|
+
</span>
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div className="flex items-center gap-2">
|
|
570
|
+
<span className="text-muted-foreground text-sm">
|
|
571
|
+
Showing {Math.min((page - 1) * perPage + 1, total || 0)} -{" "}
|
|
572
|
+
{Math.min(page * perPage, total || 0)} of {total}
|
|
573
|
+
</span>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
<Table className={cn(table.className)}>
|
|
579
|
+
<TableHeader>
|
|
580
|
+
<TableRow>
|
|
581
|
+
<TableHead>Title</TableHead>
|
|
582
|
+
<TableHead>Author</TableHead>
|
|
583
|
+
<TableHead>Content</TableHead>
|
|
584
|
+
<TableHead>Status</TableHead>
|
|
585
|
+
<TableHead className="w-[240px]">Actions</TableHead>
|
|
586
|
+
</TableRow>
|
|
587
|
+
</TableHeader>
|
|
588
|
+
<TableBody>
|
|
589
|
+
{listLoading ? (
|
|
590
|
+
<>
|
|
591
|
+
{[0, 1, 2].map((i) => (
|
|
592
|
+
<TableRow key={i}>
|
|
593
|
+
<TableCell>
|
|
594
|
+
<Skeleton className="h-4 w-56" />
|
|
595
|
+
</TableCell>
|
|
596
|
+
<TableCell />
|
|
597
|
+
<TableCell />
|
|
598
|
+
<TableCell />
|
|
599
|
+
<TableCell />
|
|
600
|
+
</TableRow>
|
|
601
|
+
))}
|
|
602
|
+
</>
|
|
603
|
+
) : (
|
|
604
|
+
<>
|
|
605
|
+
{posts.map((p) => (
|
|
606
|
+
<TableRow key={p.id}>
|
|
607
|
+
<TableCell className="font-medium">{p.title}</TableCell>
|
|
608
|
+
<TableCell>{p.author?.name || p.authorId}</TableCell>
|
|
609
|
+
<TableCell className="max-w-[320px] align-top break-words">
|
|
610
|
+
<div className="max-w-[320px] overflow-hidden text-ellipsis">
|
|
611
|
+
{renderTwoLinePreview(p.content)}
|
|
612
|
+
</div>
|
|
613
|
+
</TableCell>
|
|
614
|
+
<TableCell className="align-middle">
|
|
615
|
+
<div className="flex items-center gap-2">
|
|
616
|
+
<Switch
|
|
617
|
+
checked={!!p.published}
|
|
618
|
+
onChange={(e) =>
|
|
619
|
+
togglePublish(
|
|
620
|
+
p.id,
|
|
621
|
+
(e.target as HTMLInputElement).checked,
|
|
622
|
+
p.authorId,
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
isLoading={!!rowLoading[p.id]}
|
|
626
|
+
disabled={
|
|
627
|
+
!!rowLoading[p.id] ||
|
|
628
|
+
listLoading ||
|
|
629
|
+
!(sessionIsAdmin || sessionUserId === p.authorId)
|
|
630
|
+
}
|
|
631
|
+
/>
|
|
632
|
+
<span
|
|
633
|
+
className={
|
|
634
|
+
p.published
|
|
635
|
+
? "text-green-600"
|
|
636
|
+
: "text-muted-foreground"
|
|
637
|
+
}
|
|
638
|
+
>
|
|
639
|
+
{p.published ? "Published" : "Draft"}
|
|
640
|
+
</span>
|
|
641
|
+
</div>
|
|
642
|
+
</TableCell>
|
|
643
|
+
<TableCell>
|
|
644
|
+
<div className={cn(actionsRow.className)}>
|
|
645
|
+
<Button
|
|
646
|
+
size="sm"
|
|
647
|
+
variant="outline"
|
|
648
|
+
onClick={() => onSelect(p)}
|
|
649
|
+
>
|
|
650
|
+
Edit
|
|
651
|
+
</Button>
|
|
652
|
+
<AlertDialog>
|
|
653
|
+
<AlertDialogTrigger asChild>
|
|
654
|
+
<Button
|
|
655
|
+
size="sm"
|
|
656
|
+
variant="destructive"
|
|
657
|
+
onClick={() => confirmDelete(p.id)}
|
|
658
|
+
>
|
|
659
|
+
Delete
|
|
660
|
+
</Button>
|
|
661
|
+
</AlertDialogTrigger>
|
|
662
|
+
<AlertDialogContent>
|
|
663
|
+
<AlertDialogHeader>
|
|
664
|
+
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
|
665
|
+
<AlertDialogDescription>
|
|
666
|
+
This action cannot be undone. The post will be
|
|
667
|
+
removed permanently.
|
|
668
|
+
</AlertDialogDescription>
|
|
669
|
+
</AlertDialogHeader>
|
|
670
|
+
<AlertDialogFooter>
|
|
671
|
+
<AlertDialogCancel
|
|
672
|
+
onClick={() => setPendingDeleteId(null)}
|
|
673
|
+
>
|
|
674
|
+
Cancel
|
|
675
|
+
</AlertDialogCancel>
|
|
676
|
+
<AlertDialogAction onClick={doDelete}>
|
|
677
|
+
Delete
|
|
678
|
+
</AlertDialogAction>
|
|
679
|
+
</AlertDialogFooter>
|
|
680
|
+
</AlertDialogContent>
|
|
681
|
+
</AlertDialog>
|
|
682
|
+
</div>
|
|
683
|
+
</TableCell>
|
|
684
|
+
</TableRow>
|
|
685
|
+
))}
|
|
686
|
+
{posts.length === 0 && (
|
|
687
|
+
<TableRow>
|
|
688
|
+
<TableCell colSpan={5} className="text-muted-foreground">
|
|
689
|
+
No posts yet.
|
|
690
|
+
</TableCell>
|
|
691
|
+
</TableRow>
|
|
692
|
+
)}
|
|
693
|
+
</>
|
|
694
|
+
)}
|
|
695
|
+
</TableBody>
|
|
696
|
+
</Table>
|
|
697
|
+
|
|
698
|
+
<div className="mt-3 flex items-center justify-between">
|
|
699
|
+
<div>
|
|
700
|
+
<Button
|
|
701
|
+
size="sm"
|
|
702
|
+
variant="ghost"
|
|
703
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
704
|
+
disabled={page <= 1 || listLoading}
|
|
705
|
+
>
|
|
706
|
+
Prev
|
|
707
|
+
</Button>
|
|
708
|
+
<Button
|
|
709
|
+
size="sm"
|
|
710
|
+
variant="ghost"
|
|
711
|
+
onClick={() => setPage((p) => p + 1)}
|
|
712
|
+
disabled={
|
|
713
|
+
listLoading ||
|
|
714
|
+
(total > 0 ? page * perPage >= total : posts.length === 0)
|
|
715
|
+
}
|
|
716
|
+
>
|
|
717
|
+
Next
|
|
718
|
+
</Button>
|
|
719
|
+
</div>
|
|
720
|
+
<div className="text-muted-foreground text-sm">Page {page}</div>
|
|
721
|
+
</div>
|
|
722
|
+
</Card>
|
|
723
|
+
</main>
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export default PostsManager;
|