@blawness/admin-kit 0.2.0

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.
Files changed (131) hide show
  1. package/README.md +14 -0
  2. package/dist/auth/config.d.ts +3 -0
  3. package/dist/auth/config.d.ts.map +1 -0
  4. package/dist/auth/config.js +36 -0
  5. package/dist/auth/index.d.ts +6 -0
  6. package/dist/auth/index.d.ts.map +1 -0
  7. package/dist/auth/index.js +46 -0
  8. package/dist/components/admin/editor.d.ts +5 -0
  9. package/dist/components/admin/editor.d.ts.map +1 -0
  10. package/dist/components/admin/editor.js +28 -0
  11. package/dist/components/admin/image-upload.d.ts +15 -0
  12. package/dist/components/admin/image-upload.d.ts.map +1 -0
  13. package/dist/components/admin/image-upload.js +50 -0
  14. package/dist/components/admin/toast-on-param.d.ts +5 -0
  15. package/dist/components/admin/toast-on-param.d.ts.map +1 -0
  16. package/dist/components/admin/toast-on-param.js +31 -0
  17. package/dist/components/confirm-delete.d.ts +14 -0
  18. package/dist/components/confirm-delete.d.ts.map +1 -0
  19. package/dist/components/confirm-delete.js +32 -0
  20. package/dist/components/ui/button.d.ts +9 -0
  21. package/dist/components/ui/button.d.ts.map +1 -0
  22. package/dist/components/ui/button.js +34 -0
  23. package/dist/components/ui/dialog.d.ts +18 -0
  24. package/dist/components/ui/dialog.d.ts.map +1 -0
  25. package/dist/components/ui/dialog.js +37 -0
  26. package/dist/components/ui/input.d.ts +4 -0
  27. package/dist/components/ui/input.d.ts.map +1 -0
  28. package/dist/components/ui/input.js +7 -0
  29. package/dist/components.d.ts +7 -0
  30. package/dist/components.d.ts.map +1 -0
  31. package/dist/components.js +6 -0
  32. package/dist/db/index.d.ts +6 -0
  33. package/dist/db/index.d.ts.map +1 -0
  34. package/dist/db/index.js +8 -0
  35. package/dist/db/schema.d.ts +202 -0
  36. package/dist/db/schema.d.ts.map +1 -0
  37. package/dist/db/schema.js +16 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +7 -0
  41. package/dist/lib/admin/media.d.ts +16 -0
  42. package/dist/lib/admin/media.d.ts.map +1 -0
  43. package/dist/lib/admin/media.js +13 -0
  44. package/dist/lib/admin/users.d.ts +12 -0
  45. package/dist/lib/admin/users.d.ts.map +1 -0
  46. package/dist/lib/admin/users.js +24 -0
  47. package/dist/lib/auth-helpers.d.ts +5 -0
  48. package/dist/lib/auth-helpers.d.ts.map +1 -0
  49. package/dist/lib/auth-helpers.js +16 -0
  50. package/dist/lib/db-errors.d.ts +5 -0
  51. package/dist/lib/db-errors.d.ts.map +1 -0
  52. package/dist/lib/db-errors.js +14 -0
  53. package/dist/lib/r2.d.ts +24 -0
  54. package/dist/lib/r2.d.ts.map +1 -0
  55. package/dist/lib/r2.js +59 -0
  56. package/dist/lib/sanitize.d.ts +14 -0
  57. package/dist/lib/sanitize.d.ts.map +1 -0
  58. package/dist/lib/sanitize.js +28 -0
  59. package/dist/lib/slug.d.ts +2 -0
  60. package/dist/lib/slug.d.ts.map +1 -0
  61. package/dist/lib/slug.js +9 -0
  62. package/dist/lib/utils.d.ts +3 -0
  63. package/dist/lib/utils.d.ts.map +1 -0
  64. package/dist/lib/utils.js +5 -0
  65. package/dist/screens/login/actions.d.ts +5 -0
  66. package/dist/screens/login/actions.d.ts.map +1 -0
  67. package/dist/screens/login/actions.js +28 -0
  68. package/dist/screens/login/page.d.ts +3 -0
  69. package/dist/screens/login/page.d.ts.map +1 -0
  70. package/dist/screens/login/page.js +11 -0
  71. package/dist/screens/media/actions.d.ts +5 -0
  72. package/dist/screens/media/actions.d.ts.map +1 -0
  73. package/dist/screens/media/actions.js +32 -0
  74. package/dist/screens/media/lib.d.ts +9 -0
  75. package/dist/screens/media/lib.d.ts.map +1 -0
  76. package/dist/screens/media/lib.js +30 -0
  77. package/dist/screens/media/page.d.ts +8 -0
  78. package/dist/screens/media/page.d.ts.map +1 -0
  79. package/dist/screens/media/page.js +13 -0
  80. package/dist/screens/media/uploader.d.ts +2 -0
  81. package/dist/screens/media/uploader.d.ts.map +1 -0
  82. package/dist/screens/media/uploader.js +10 -0
  83. package/dist/screens/users/actions.d.ts +5 -0
  84. package/dist/screens/users/actions.d.ts.map +1 -0
  85. package/dist/screens/users/actions.js +70 -0
  86. package/dist/screens/users/page.d.ts +7 -0
  87. package/dist/screens/users/page.d.ts.map +1 -0
  88. package/dist/screens/users/page.js +22 -0
  89. package/dist/shell/actions.d.ts +2 -0
  90. package/dist/shell/actions.d.ts.map +1 -0
  91. package/dist/shell/actions.js +5 -0
  92. package/dist/shell/layout.d.ts +9 -0
  93. package/dist/shell/layout.d.ts.map +1 -0
  94. package/dist/shell/layout.js +15 -0
  95. package/dist/shell/sidebar.d.ts +16 -0
  96. package/dist/shell/sidebar.d.ts.map +1 -0
  97. package/dist/shell/sidebar.js +19 -0
  98. package/package.json +148 -0
  99. package/src/auth/config.ts +36 -0
  100. package/src/auth/index.ts +49 -0
  101. package/src/components/admin/editor.tsx +53 -0
  102. package/src/components/admin/image-upload.tsx +128 -0
  103. package/src/components/admin/toast-on-param.tsx +47 -0
  104. package/src/components/confirm-delete.tsx +96 -0
  105. package/src/components/ui/button.tsx +58 -0
  106. package/src/components/ui/dialog.tsx +160 -0
  107. package/src/components/ui/input.tsx +20 -0
  108. package/src/components.ts +17 -0
  109. package/src/db/index.ts +8 -0
  110. package/src/db/schema.ts +23 -0
  111. package/src/index.ts +7 -0
  112. package/src/lib/admin/media.ts +16 -0
  113. package/src/lib/admin/users.ts +31 -0
  114. package/src/lib/auth-helpers.ts +16 -0
  115. package/src/lib/db-errors.ts +18 -0
  116. package/src/lib/r2.ts +70 -0
  117. package/src/lib/sanitize.ts +29 -0
  118. package/src/lib/slug.ts +9 -0
  119. package/src/lib/utils.ts +6 -0
  120. package/src/screens/login/actions.ts +38 -0
  121. package/src/screens/login/page.tsx +48 -0
  122. package/src/screens/media/actions.ts +34 -0
  123. package/src/screens/media/lib.ts +39 -0
  124. package/src/screens/media/page.tsx +82 -0
  125. package/src/screens/media/uploader.tsx +19 -0
  126. package/src/screens/users/actions.ts +71 -0
  127. package/src/screens/users/page.tsx +128 -0
  128. package/src/shell/actions.ts +6 -0
  129. package/src/shell/layout.tsx +47 -0
  130. package/src/shell/sidebar.tsx +74 -0
  131. package/src/types/next-auth.d.ts +20 -0
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, useTransition } from "react";
4
+ import { UploadCloud, Loader2, AlertCircle } from "lucide-react";
5
+
6
+ // Mirror the server action (src/app/admin/media/actions.ts) — keep in sync.
7
+ const OK_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
8
+ const MAX_BYTES = 8 * 1024 * 1024;
9
+
10
+ /**
11
+ * Clear drag-and-drop / click-to-upload zone backed by the R2 upload action.
12
+ * Used for both a post's featured image (with `value`) and the gallery (no
13
+ * value, parent refreshes on change).
14
+ */
15
+ export function ImageUpload({
16
+ value,
17
+ onChange,
18
+ label = "gambar",
19
+ uploadAction,
20
+ }: {
21
+ value?: string | null;
22
+ onChange: (url: string) => void;
23
+ label?: string;
24
+ uploadAction: (formData: FormData) => Promise<{ url?: string; error?: string }>;
25
+ }) {
26
+ const [pending, start] = useTransition();
27
+ const [error, setError] = useState<string>();
28
+ const [dragging, setDragging] = useState(false);
29
+ const inputRef = useRef<HTMLInputElement>(null);
30
+
31
+ function upload(file: File | undefined) {
32
+ if (!file) return;
33
+ if (!OK_TYPES.includes(file.type)) {
34
+ setError("Format tidak didukung — gunakan JPG, PNG, WebP, atau GIF.");
35
+ return;
36
+ }
37
+ if (file.size > MAX_BYTES) {
38
+ setError("Ukuran gambar maksimal 8MB.");
39
+ return;
40
+ }
41
+ const fd = new FormData();
42
+ fd.set("file", file);
43
+ setError(undefined);
44
+ start(async () => {
45
+ const res = await uploadAction(fd);
46
+ if (res.error) setError(res.error);
47
+ else if (res.url) onChange(res.url);
48
+ });
49
+ }
50
+
51
+ return (
52
+ <div className="space-y-2">
53
+ <button
54
+ type="button"
55
+ onClick={() => inputRef.current?.click()}
56
+ onDragOver={(e) => {
57
+ e.preventDefault();
58
+ setDragging(true);
59
+ }}
60
+ onDragLeave={() => setDragging(false)}
61
+ onDrop={(e) => {
62
+ e.preventDefault();
63
+ setDragging(false);
64
+ upload(e.dataTransfer.files?.[0]);
65
+ }}
66
+ disabled={pending}
67
+ className={`group relative flex w-full flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-colors ${
68
+ dragging
69
+ ? "border-brand-500 bg-brand-50"
70
+ : "border-navy-200 bg-navy-50/40 hover:border-brand-400 hover:bg-brand-50/60"
71
+ } ${pending ? "pointer-events-none opacity-80" : "cursor-pointer"}`}
72
+ >
73
+ {value ? (
74
+ <>
75
+ {/* eslint-disable-next-line @next/next/no-img-element -- preview from R2 */}
76
+ <img
77
+ src={value}
78
+ alt=""
79
+ className="h-28 w-28 rounded-lg object-cover object-top ring-1 ring-navy-200 shadow-sm"
80
+ />
81
+ <span className="text-sm font-medium text-navy-700">
82
+ Klik untuk mengganti {label}
83
+ </span>
84
+ {pending && (
85
+ <span className="absolute inset-0 grid place-items-center rounded-xl bg-white/70">
86
+ <Loader2 className="h-6 w-6 animate-spin text-brand-600" />
87
+ </span>
88
+ )}
89
+ </>
90
+ ) : (
91
+ <>
92
+ <span className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-100 text-brand-600 transition-transform group-hover:scale-105">
93
+ {pending ? (
94
+ <Loader2 className="h-6 w-6 animate-spin" />
95
+ ) : (
96
+ <UploadCloud className="h-6 w-6" />
97
+ )}
98
+ </span>
99
+ <span>
100
+ <span className="block text-sm font-semibold text-navy-900">
101
+ {pending ? "Mengunggah…" : `Klik untuk unggah ${label}`}
102
+ </span>
103
+ <span className="mt-0.5 block text-xs text-muted-foreground">
104
+ atau seret &amp; lepas di sini · JPG, PNG, WebP, GIF (maks 8MB)
105
+ </span>
106
+ </span>
107
+ </>
108
+ )}
109
+ </button>
110
+
111
+ <input
112
+ ref={inputRef}
113
+ type="file"
114
+ accept="image/*"
115
+ className="hidden"
116
+ onChange={(e) => upload(e.target.files?.[0])}
117
+ disabled={pending}
118
+ />
119
+
120
+ {error && (
121
+ <p className="flex items-center gap-1.5 text-sm text-red-600">
122
+ <AlertCircle className="h-4 w-4 shrink-0" />
123
+ {error}
124
+ </p>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { Suspense, useEffect, useRef } from "react";
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
+ import { toast } from "sonner";
6
+
7
+ function ToastOnParamInner({
8
+ param,
9
+ messages,
10
+ }: {
11
+ param: string;
12
+ messages: Record<string, string>;
13
+ }) {
14
+ const searchParams = useSearchParams();
15
+ const router = useRouter();
16
+ const pathname = usePathname();
17
+ const fired = useRef(false);
18
+
19
+ useEffect(() => {
20
+ if (fired.current) return;
21
+ const value = searchParams.get(param);
22
+ if (!value) return;
23
+ fired.current = true;
24
+
25
+ const message = messages[value];
26
+ if (message) toast.success(message);
27
+
28
+ // Bersihkan param dari URL agar tidak terpicu ulang saat refresh.
29
+ const next = new URLSearchParams(searchParams.toString());
30
+ next.delete(param);
31
+ const qs = next.toString();
32
+ router.replace(qs ? `${pathname}?${qs}` : pathname);
33
+ }, [param, messages, searchParams, router, pathname]);
34
+
35
+ return null;
36
+ }
37
+
38
+ export function ToastOnParam(props: {
39
+ param: string;
40
+ messages: Record<string, string>;
41
+ }) {
42
+ return (
43
+ <Suspense fallback={null}>
44
+ <ToastOnParamInner {...props} />
45
+ </Suspense>
46
+ );
47
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition, type ReactElement, type ReactNode } from "react";
4
+ import { Loader2, Trash2 } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { Button } from "./ui/button";
7
+ import {
8
+ Dialog,
9
+ DialogClose,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger,
16
+ } from "./ui/dialog";
17
+
18
+ export function ConfirmDelete({
19
+ action,
20
+ id,
21
+ title = "Hapus item ini?",
22
+ description = "Tindakan ini tidak dapat dibatalkan.",
23
+ confirmLabel = "Hapus",
24
+ successMessage = "Berhasil dihapus.",
25
+ trigger,
26
+ }: {
27
+ /** Server action that receives FormData with an `id` field. */
28
+ action: (formData: FormData) => Promise<void>;
29
+ id: number;
30
+ title?: string;
31
+ description?: ReactNode;
32
+ confirmLabel?: string;
33
+ /** Toast yang muncul setelah penghapusan berhasil. */
34
+ successMessage?: string;
35
+ /** Custom trigger element. Defaults to an outline icon button. */
36
+ trigger?: ReactElement;
37
+ }) {
38
+ const [open, setOpen] = useState(false);
39
+ const [pending, start] = useTransition();
40
+
41
+ function handleConfirm() {
42
+ start(async () => {
43
+ const fd = new FormData();
44
+ fd.set("id", String(id));
45
+ let succeeded = false;
46
+ try {
47
+ await action(fd);
48
+ // Hanya tercapai bila action selesai tanpa melempar (mis. redirect
49
+ // pada konflik akan throw, sehingga toast sukses tidak muncul).
50
+ succeeded = true;
51
+ } finally {
52
+ // Tutup dialog baik saat sukses maupun saat action me-redirect
53
+ // (mis. media yang masih dipakai → halaman menampilkan pesan error).
54
+ setOpen(false);
55
+ }
56
+ if (succeeded) toast.success(successMessage);
57
+ });
58
+ }
59
+
60
+ return (
61
+ <Dialog open={open} onOpenChange={setOpen}>
62
+ <DialogTrigger
63
+ render={
64
+ trigger ?? (
65
+ <Button size="sm" variant="outline" aria-label="Hapus">
66
+ <Trash2 className="h-3.5 w-3.5" />
67
+ </Button>
68
+ )
69
+ }
70
+ />
71
+ <DialogContent showCloseButton={false}>
72
+ <DialogHeader>
73
+ <DialogTitle>{title}</DialogTitle>
74
+ <DialogDescription>{description}</DialogDescription>
75
+ </DialogHeader>
76
+ <DialogFooter>
77
+ <DialogClose render={<Button variant="outline" disabled={pending} />}>
78
+ Batal
79
+ </DialogClose>
80
+ <Button
81
+ variant="destructive"
82
+ onClick={handleConfirm}
83
+ disabled={pending}
84
+ >
85
+ {pending ? (
86
+ <Loader2 className="h-4 w-4 animate-spin" />
87
+ ) : (
88
+ <Trash2 className="h-4 w-4" />
89
+ )}
90
+ {confirmLabel}
91
+ </Button>
92
+ </DialogFooter>
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }
@@ -0,0 +1,58 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
28
+ icon: "size-8",
29
+ "icon-xs":
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ "icon-sm":
32
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
+ "icon-lg": "size-9",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function Button({
44
+ className,
45
+ variant = "default",
46
+ size = "default",
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,160 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
5
+
6
+ import { cn } from "../../lib/utils"
7
+ import { Button } from "./button"
8
+ import { XIcon } from "lucide-react"
9
+
10
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
11
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
12
+ }
13
+
14
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
15
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
16
+ }
17
+
18
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
19
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
20
+ }
21
+
22
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
23
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
24
+ }
25
+
26
+ function DialogOverlay({
27
+ className,
28
+ ...props
29
+ }: DialogPrimitive.Backdrop.Props) {
30
+ return (
31
+ <DialogPrimitive.Backdrop
32
+ data-slot="dialog-overlay"
33
+ className={cn(
34
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
35
+ className
36
+ )}
37
+ {...props}
38
+ />
39
+ )
40
+ }
41
+
42
+ function DialogContent({
43
+ className,
44
+ children,
45
+ showCloseButton = true,
46
+ ...props
47
+ }: DialogPrimitive.Popup.Props & {
48
+ showCloseButton?: boolean
49
+ }) {
50
+ return (
51
+ <DialogPortal>
52
+ <DialogOverlay />
53
+ <DialogPrimitive.Popup
54
+ data-slot="dialog-content"
55
+ className={cn(
56
+ "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
57
+ className
58
+ )}
59
+ {...props}
60
+ >
61
+ {children}
62
+ {showCloseButton && (
63
+ <DialogPrimitive.Close
64
+ data-slot="dialog-close"
65
+ render={
66
+ <Button
67
+ variant="ghost"
68
+ className="absolute top-2 right-2"
69
+ size="icon-sm"
70
+ />
71
+ }
72
+ >
73
+ <XIcon
74
+ />
75
+ <span className="sr-only">Close</span>
76
+ </DialogPrimitive.Close>
77
+ )}
78
+ </DialogPrimitive.Popup>
79
+ </DialogPortal>
80
+ )
81
+ }
82
+
83
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84
+ return (
85
+ <div
86
+ data-slot="dialog-header"
87
+ className={cn("flex flex-col gap-2", className)}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function DialogFooter({
94
+ className,
95
+ showCloseButton = false,
96
+ children,
97
+ ...props
98
+ }: React.ComponentProps<"div"> & {
99
+ showCloseButton?: boolean
100
+ }) {
101
+ return (
102
+ <div
103
+ data-slot="dialog-footer"
104
+ className={cn(
105
+ "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
106
+ className
107
+ )}
108
+ {...props}
109
+ >
110
+ {children}
111
+ {showCloseButton && (
112
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
113
+ Close
114
+ </DialogPrimitive.Close>
115
+ )}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
121
+ return (
122
+ <DialogPrimitive.Title
123
+ data-slot="dialog-title"
124
+ className={cn(
125
+ "font-heading text-base leading-none font-medium",
126
+ className
127
+ )}
128
+ {...props}
129
+ />
130
+ )
131
+ }
132
+
133
+ function DialogDescription({
134
+ className,
135
+ ...props
136
+ }: DialogPrimitive.Description.Props) {
137
+ return (
138
+ <DialogPrimitive.Description
139
+ data-slot="dialog-description"
140
+ className={cn(
141
+ "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
142
+ className
143
+ )}
144
+ {...props}
145
+ />
146
+ )
147
+ }
148
+
149
+ export {
150
+ Dialog,
151
+ DialogClose,
152
+ DialogContent,
153
+ DialogDescription,
154
+ DialogFooter,
155
+ DialogHeader,
156
+ DialogOverlay,
157
+ DialogPortal,
158
+ DialogTitle,
159
+ DialogTrigger,
160
+ }
@@ -0,0 +1,20 @@
1
+ import * as React from "react"
2
+ import { Input as InputPrimitive } from "@base-ui/react/input"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
7
+ return (
8
+ <InputPrimitive
9
+ type={type}
10
+ data-slot="input"
11
+ className={cn(
12
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Input }
@@ -0,0 +1,17 @@
1
+ export { ConfirmDelete } from "./components/confirm-delete";
2
+ export { Button, buttonVariants } from "./components/ui/button";
3
+ export {
4
+ Dialog,
5
+ DialogClose,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogOverlay,
11
+ DialogPortal,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "./components/ui/dialog";
15
+ export * from "./components/admin/editor";
16
+ export * from "./components/admin/image-upload";
17
+ export * from "./components/admin/toast-on-param";
@@ -0,0 +1,8 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema";
4
+
5
+ const connectionString = process.env.DATABASE_URL;
6
+ if (!connectionString) throw new Error("admin-kit: DATABASE_URL env var is required");
7
+ const client = postgres(connectionString, { prepare: false });
8
+ export const db = drizzle(client, { schema });
@@ -0,0 +1,23 @@
1
+ import {
2
+ pgTable,
3
+ serial,
4
+ text,
5
+ timestamp,
6
+ } from "drizzle-orm/pg-core";
7
+
8
+ export const users = pgTable("users", {
9
+ id: serial("id").primaryKey(),
10
+ email: text("email").notNull().unique(),
11
+ name: text("name").notNull(),
12
+ passwordHash: text("password_hash"),
13
+ role: text("role").default("editor"),
14
+ createdAt: timestamp("created_at").defaultNow(),
15
+ });
16
+
17
+ export const media = pgTable("media", {
18
+ id: serial("id").primaryKey(),
19
+ url: text("url").notNull(),
20
+ altText: text("alt_text"),
21
+ album: text("album"),
22
+ uploadedAt: timestamp("uploaded_at").defaultNow(),
23
+ });
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { cn } from "./lib/utils";
2
+ export { slugify } from "./lib/slug";
3
+ export { sanitizeHtml } from "./lib/sanitize";
4
+ export * from "./lib/db-errors";
5
+ export * from "./lib/r2";
6
+ export * from "./db/schema";
7
+ export { db } from "./db/index";
@@ -0,0 +1,16 @@
1
+ import { db } from "../../db/index";
2
+ import { media } from "../../db/schema";
3
+ import { desc, eq } from "drizzle-orm";
4
+
5
+ export async function listMedia() {
6
+ return db.select().from(media).orderBy(desc(media.uploadedAt));
7
+ }
8
+
9
+ export async function getMediaById(id: number) {
10
+ const [row] = await db.select().from(media).where(eq(media.id, id)).limit(1);
11
+ return row ?? null;
12
+ }
13
+
14
+ export async function deleteMediaRow(id: number) {
15
+ await db.delete(media).where(eq(media.id, id));
16
+ }
@@ -0,0 +1,31 @@
1
+ import { db } from "../../db/index";
2
+ import { users } from "../../db/schema";
3
+ import { asc, eq } from "drizzle-orm";
4
+ import { hash } from "bcryptjs";
5
+
6
+ export async function listUsers() {
7
+ return db
8
+ .select({ id: users.id, email: users.email, name: users.name, role: users.role })
9
+ .from(users)
10
+ .orderBy(asc(users.email));
11
+ }
12
+
13
+ export type UserRole = "admin" | "editor";
14
+
15
+ export async function createUser(email: string, name: string, password: string, role: UserRole) {
16
+ const passwordHash = await hash(password, 12);
17
+ await db.insert(users).values({ email, name, passwordHash, role });
18
+ }
19
+
20
+ export async function updateUserPassword(id: number, password: string) {
21
+ const passwordHash = await hash(password, 12);
22
+ await db.update(users).set({ passwordHash }).where(eq(users.id, id));
23
+ }
24
+
25
+ export async function updateUserRole(id: number, role: UserRole) {
26
+ await db.update(users).set({ role }).where(eq(users.id, id));
27
+ }
28
+
29
+ export async function deleteUser(id: number) {
30
+ await db.delete(users).where(eq(users.id, id));
31
+ }
@@ -0,0 +1,16 @@
1
+ import { redirect } from "next/navigation";
2
+ import { auth } from "../auth/index";
3
+
4
+ /** Any authenticated user (admin or editor). Returns the session. */
5
+ export async function requireUser() {
6
+ const session = await auth();
7
+ if (!session?.user) redirect("/admin/login");
8
+ return session;
9
+ }
10
+
11
+ /** Admin only. Editors are sent to the dashboard. */
12
+ export async function requireAdmin() {
13
+ const session = await requireUser();
14
+ if (session.user.role !== "admin") redirect("/admin");
15
+ return session;
16
+ }
@@ -0,0 +1,18 @@
1
+ function hasSqlState(error: unknown, code: string): boolean {
2
+ return (
3
+ typeof error === "object" &&
4
+ error !== null &&
5
+ "code" in error &&
6
+ (error as { code?: string }).code === code
7
+ );
8
+ }
9
+
10
+ /** True when a Postgres error is a unique-constraint violation (SQLSTATE 23505). */
11
+ export function isUniqueViolation(error: unknown): boolean {
12
+ return hasSqlState(error, "23505");
13
+ }
14
+
15
+ /** True when a Postgres error is a foreign-key violation (SQLSTATE 23503). */
16
+ export function isForeignKeyViolation(error: unknown): boolean {
17
+ return hasSqlState(error, "23503");
18
+ }