@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.
- package/README.md +14 -0
- package/dist/auth/config.d.ts +3 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/config.js +36 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +46 -0
- package/dist/components/admin/editor.d.ts +5 -0
- package/dist/components/admin/editor.d.ts.map +1 -0
- package/dist/components/admin/editor.js +28 -0
- package/dist/components/admin/image-upload.d.ts +15 -0
- package/dist/components/admin/image-upload.d.ts.map +1 -0
- package/dist/components/admin/image-upload.js +50 -0
- package/dist/components/admin/toast-on-param.d.ts +5 -0
- package/dist/components/admin/toast-on-param.d.ts.map +1 -0
- package/dist/components/admin/toast-on-param.js +31 -0
- package/dist/components/confirm-delete.d.ts +14 -0
- package/dist/components/confirm-delete.d.ts.map +1 -0
- package/dist/components/confirm-delete.js +32 -0
- package/dist/components/ui/button.d.ts +9 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/dialog.d.ts +18 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/input.d.ts +4 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +7 -0
- package/dist/components.d.ts +7 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +6 -0
- package/dist/db/index.d.ts +6 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +8 -0
- package/dist/db/schema.d.ts +202 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/lib/admin/media.d.ts +16 -0
- package/dist/lib/admin/media.d.ts.map +1 -0
- package/dist/lib/admin/media.js +13 -0
- package/dist/lib/admin/users.d.ts +12 -0
- package/dist/lib/admin/users.d.ts.map +1 -0
- package/dist/lib/admin/users.js +24 -0
- package/dist/lib/auth-helpers.d.ts +5 -0
- package/dist/lib/auth-helpers.d.ts.map +1 -0
- package/dist/lib/auth-helpers.js +16 -0
- package/dist/lib/db-errors.d.ts +5 -0
- package/dist/lib/db-errors.d.ts.map +1 -0
- package/dist/lib/db-errors.js +14 -0
- package/dist/lib/r2.d.ts +24 -0
- package/dist/lib/r2.d.ts.map +1 -0
- package/dist/lib/r2.js +59 -0
- package/dist/lib/sanitize.d.ts +14 -0
- package/dist/lib/sanitize.d.ts.map +1 -0
- package/dist/lib/sanitize.js +28 -0
- package/dist/lib/slug.d.ts +2 -0
- package/dist/lib/slug.d.ts.map +1 -0
- package/dist/lib/slug.js +9 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +5 -0
- package/dist/screens/login/actions.d.ts +5 -0
- package/dist/screens/login/actions.d.ts.map +1 -0
- package/dist/screens/login/actions.js +28 -0
- package/dist/screens/login/page.d.ts +3 -0
- package/dist/screens/login/page.d.ts.map +1 -0
- package/dist/screens/login/page.js +11 -0
- package/dist/screens/media/actions.d.ts +5 -0
- package/dist/screens/media/actions.d.ts.map +1 -0
- package/dist/screens/media/actions.js +32 -0
- package/dist/screens/media/lib.d.ts +9 -0
- package/dist/screens/media/lib.d.ts.map +1 -0
- package/dist/screens/media/lib.js +30 -0
- package/dist/screens/media/page.d.ts +8 -0
- package/dist/screens/media/page.d.ts.map +1 -0
- package/dist/screens/media/page.js +13 -0
- package/dist/screens/media/uploader.d.ts +2 -0
- package/dist/screens/media/uploader.d.ts.map +1 -0
- package/dist/screens/media/uploader.js +10 -0
- package/dist/screens/users/actions.d.ts +5 -0
- package/dist/screens/users/actions.d.ts.map +1 -0
- package/dist/screens/users/actions.js +70 -0
- package/dist/screens/users/page.d.ts +7 -0
- package/dist/screens/users/page.d.ts.map +1 -0
- package/dist/screens/users/page.js +22 -0
- package/dist/shell/actions.d.ts +2 -0
- package/dist/shell/actions.d.ts.map +1 -0
- package/dist/shell/actions.js +5 -0
- package/dist/shell/layout.d.ts +9 -0
- package/dist/shell/layout.d.ts.map +1 -0
- package/dist/shell/layout.js +15 -0
- package/dist/shell/sidebar.d.ts +16 -0
- package/dist/shell/sidebar.d.ts.map +1 -0
- package/dist/shell/sidebar.js +19 -0
- package/package.json +148 -0
- package/src/auth/config.ts +36 -0
- package/src/auth/index.ts +49 -0
- package/src/components/admin/editor.tsx +53 -0
- package/src/components/admin/image-upload.tsx +128 -0
- package/src/components/admin/toast-on-param.tsx +47 -0
- package/src/components/confirm-delete.tsx +96 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/dialog.tsx +160 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components.ts +17 -0
- package/src/db/index.ts +8 -0
- package/src/db/schema.ts +23 -0
- package/src/index.ts +7 -0
- package/src/lib/admin/media.ts +16 -0
- package/src/lib/admin/users.ts +31 -0
- package/src/lib/auth-helpers.ts +16 -0
- package/src/lib/db-errors.ts +18 -0
- package/src/lib/r2.ts +70 -0
- package/src/lib/sanitize.ts +29 -0
- package/src/lib/slug.ts +9 -0
- package/src/lib/utils.ts +6 -0
- package/src/screens/login/actions.ts +38 -0
- package/src/screens/login/page.tsx +48 -0
- package/src/screens/media/actions.ts +34 -0
- package/src/screens/media/lib.ts +39 -0
- package/src/screens/media/page.tsx +82 -0
- package/src/screens/media/uploader.tsx +19 -0
- package/src/screens/users/actions.ts +71 -0
- package/src/screens/users/page.tsx +128 -0
- package/src/shell/actions.ts +6 -0
- package/src/shell/layout.tsx +47 -0
- package/src/shell/sidebar.tsx +74 -0
- 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 & 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";
|
package/src/db/index.ts
ADDED
|
@@ -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 });
|
package/src/db/schema.ts
ADDED
|
@@ -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,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
|
+
}
|