@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,32 @@
1
+ "use server";
2
+ import { revalidatePath } from "next/cache";
3
+ import { requireUser } from "../../lib/auth-helpers";
4
+ import { uploadImage } from "../../lib/r2";
5
+ import { db } from "../../db/index";
6
+ import { media } from "../../db/schema";
7
+ const MAX_BYTES = 8 * 1024 * 1024;
8
+ const OK_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
9
+ export async function uploadImageAction(formData) {
10
+ await requireUser();
11
+ const file = formData.get("file");
12
+ if (!(file instanceof File))
13
+ return { error: "Tidak ada berkas." };
14
+ if (!OK_TYPES.includes(file.type))
15
+ return { error: "Format gambar tidak didukung." };
16
+ if (file.size > MAX_BYTES)
17
+ return { error: "Ukuran gambar maksimal 8MB." };
18
+ const buf = Buffer.from(await file.arrayBuffer());
19
+ const keyBase = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
20
+ let url;
21
+ try {
22
+ // sharp (inside uploadImage) throws on data that isn't really an image,
23
+ // e.g. a non-image file sent with a spoofed image MIME type.
24
+ ({ url } = await uploadImage(buf, keyBase));
25
+ }
26
+ catch {
27
+ return { error: "Gambar gagal diproses. Pastikan berkas benar-benar gambar." };
28
+ }
29
+ await db.insert(media).values({ url, altText: file.name });
30
+ revalidatePath("/admin/media");
31
+ return { url };
32
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Generic delete-media logic, extracted from the server action so that the
3
+ * consuming app can inject its own reference checker (posts, banners, etc.).
4
+ *
5
+ * @param formData - FormData containing "id" field
6
+ * @param referenceChecker - async fn that returns the count of references to the given URL
7
+ */
8
+ export declare function handleDeleteMedia(formData: FormData, referenceChecker: (url: string) => Promise<number>): Promise<void>;
9
+ //# sourceMappingURL=lib.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../../src/screens/media/lib.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,QAAQ,EAClB,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC,CAsBf"}
@@ -0,0 +1,30 @@
1
+ import { redirect } from "next/navigation";
2
+ import { requireUser } from "../../lib/auth-helpers";
3
+ import { deleteObjectByUrl } from "../../lib/r2";
4
+ import { getMediaById, deleteMediaRow } from "../../lib/admin/media";
5
+ import { revalidatePath } from "next/cache";
6
+ /**
7
+ * Generic delete-media logic, extracted from the server action so that the
8
+ * consuming app can inject its own reference checker (posts, banners, etc.).
9
+ *
10
+ * @param formData - FormData containing "id" field
11
+ * @param referenceChecker - async fn that returns the count of references to the given URL
12
+ */
13
+ export async function handleDeleteMedia(formData, referenceChecker) {
14
+ await requireUser();
15
+ const id = Number(formData.get("id"));
16
+ if (!id)
17
+ return;
18
+ const row = await getMediaById(id);
19
+ if (!row)
20
+ return;
21
+ const refs = await referenceChecker(row.url);
22
+ if (refs > 0) {
23
+ redirect(`/admin/media?error=${encodeURIComponent(`Gambar masih dipakai oleh ${refs} konten. Lepas dulu dari berita/banner sebelum menghapus.`)}`);
24
+ }
25
+ // Hapus objek R2 lebih dulu; bila gagal, biarkan melempar agar row DB tetap
26
+ // ada dan operasi bisa diulang (hindari row hilang tapi objek menggantung).
27
+ await deleteObjectByUrl(row.url);
28
+ await deleteMediaRow(id);
29
+ revalidatePath("/admin/media");
30
+ }
@@ -0,0 +1,8 @@
1
+ export declare const dynamic = "force-dynamic";
2
+ export default function MediaLibraryScreen({ deleteAction, searchParams, }: {
3
+ deleteAction: (fd: FormData) => Promise<void>;
4
+ searchParams: Promise<{
5
+ error?: string;
6
+ }>;
7
+ }): Promise<import("react").JSX.Element>;
8
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../../src/screens/media/page.tsx"],"names":[],"mappings":"AAMA,eAAO,MAAM,OAAO,kBAAkB,CAAC;AAEvC,wBAA8B,kBAAkB,CAAC,EAC/C,YAAY,EACZ,YAAY,GACb,EAAE;IACD,YAAY,EAAE,CAAC,EAAE,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,YAAY,EAAE,OAAO,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3C,wCAmEA"}
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { listMedia } from "../../lib/admin/media";
3
+ import { requireUser } from "../../lib/auth-helpers";
4
+ import { ConfirmDelete } from "../../components/confirm-delete";
5
+ import { GalleryUploader } from "./uploader";
6
+ import { Trash2, ImageOff, AlertCircle } from "lucide-react";
7
+ export const dynamic = "force-dynamic";
8
+ export default async function MediaLibraryScreen({ deleteAction, searchParams, }) {
9
+ await requireUser();
10
+ const items = await listMedia();
11
+ const { error } = await searchParams;
12
+ return (_jsxs("div", { className: "max-w-5xl", children: [_jsxs("div", { className: "mb-6", children: [_jsx("h1", { className: "font-heading text-2xl font-bold text-navy-900", children: "Galeri" }), _jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [items.length, " gambar tersimpan"] })] }), error && (_jsxs("p", { className: "mb-4 flex items-center gap-1.5 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 ring-1 ring-red-100", role: "alert", children: [_jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }), error] })), _jsx(GalleryUploader, {}), items.length === 0 ? (_jsxs("div", { className: "mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-navy-200 bg-white py-16 text-center", children: [_jsx(ImageOff, { className: "h-8 w-8 text-navy-300" }), _jsx("p", { className: "mt-3 text-sm font-medium text-navy-700", children: "Belum ada gambar" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Unggah gambar pertama lewat kotak di atas." })] })) : (_jsx("div", { className: "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4", children: items.map((m) => (_jsxs("div", { className: "group relative overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm", children: [_jsx("img", { src: m.url, alt: m.altText ?? "", className: "aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-105" }), _jsx("div", { className: "absolute right-2 top-2", children: _jsx(ConfirmDelete, { action: deleteAction, id: m.id, title: "Hapus gambar?", description: "Gambar akan dihapus permanen dari media.", trigger: _jsx("button", { type: "button", "aria-label": "Hapus gambar", className: "flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-navy-600 opacity-100 shadow-sm ring-1 ring-navy-100 backdrop-blur transition-all hover:bg-red-50 hover:text-red-600 sm:opacity-0 sm:group-hover:opacity-100", children: _jsx(Trash2, { className: "h-4 w-4" }) }) }) })] }, m.id))) }))] }));
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare function GalleryUploader(): import("react").JSX.Element;
2
+ //# sourceMappingURL=uploader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploader.d.ts","sourceRoot":"","sources":["../../../src/screens/media/uploader.tsx"],"names":[],"mappings":"AAOA,wBAAgB,eAAe,gCAW9B"}
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRouter } from "next/navigation";
4
+ import { Images } from "lucide-react";
5
+ import { ImageUpload } from "../../components/admin/image-upload";
6
+ import { uploadImageAction } from "./actions";
7
+ export function GalleryUploader() {
8
+ const router = useRouter();
9
+ return (_jsxs("div", { className: "max-w-xl rounded-xl border border-navy-100 bg-white p-5 shadow-sm", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsx(Images, { className: "h-4 w-4 text-brand-600" }), _jsx("p", { className: "text-sm font-semibold text-navy-900", children: "Tambah gambar ke galeri" })] }), _jsx(ImageUpload, { uploadAction: uploadImageAction, onChange: () => router.refresh() })] }));
10
+ }
@@ -0,0 +1,5 @@
1
+ export declare function createUserAction(formData: FormData): Promise<void>;
2
+ export declare function resetPasswordAction(formData: FormData): Promise<void>;
3
+ export declare function setRoleAction(formData: FormData): Promise<void>;
4
+ export declare function deleteUserAction(formData: FormData): Promise<void>;
5
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../../src/screens/users/actions.ts"],"names":[],"mappings":"AAgBA,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,iBAqBxD;AAED,wBAAsB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,iBAO3D;AAED,wBAAsB,aAAa,CAAC,QAAQ,EAAE,QAAQ,iBAOrD;AAED,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,iBAaxD"}
@@ -0,0 +1,70 @@
1
+ "use server";
2
+ import { revalidatePath } from "next/cache";
3
+ import { redirect } from "next/navigation";
4
+ import { z } from "zod";
5
+ import { requireAdmin } from "../../lib/auth-helpers";
6
+ import { isUniqueViolation, isForeignKeyViolation } from "../../lib/db-errors";
7
+ import { createUser, updateUserPassword, updateUserRole, deleteUser } from "../../lib/admin/users";
8
+ const createSchema = z.object({
9
+ email: z.string().email(),
10
+ name: z.string().min(1),
11
+ password: z.string().min(8),
12
+ role: z.enum(["admin", "editor"]),
13
+ });
14
+ export async function createUserAction(formData) {
15
+ await requireAdmin();
16
+ const parsed = createSchema.safeParse({
17
+ email: formData.get("email"),
18
+ name: formData.get("name"),
19
+ password: formData.get("password"),
20
+ role: formData.get("role"),
21
+ });
22
+ if (!parsed.success) {
23
+ redirect("/admin/users?error=Data+tidak+valid+(email+benar,+password+min+8+karakter)");
24
+ }
25
+ const { email, name, password, role } = parsed.data;
26
+ try {
27
+ await createUser(email, name, password, role);
28
+ }
29
+ catch (e) {
30
+ if (isUniqueViolation(e)) {
31
+ redirect("/admin/users?error=Email+sudah+terdaftar");
32
+ }
33
+ throw e;
34
+ }
35
+ revalidatePath("/admin/users");
36
+ }
37
+ export async function resetPasswordAction(formData) {
38
+ await requireAdmin();
39
+ const id = Number(formData.get("id"));
40
+ const password = String(formData.get("password") ?? "");
41
+ if (!id || password.length < 8)
42
+ return;
43
+ await updateUserPassword(id, password);
44
+ revalidatePath("/admin/users");
45
+ }
46
+ export async function setRoleAction(formData) {
47
+ await requireAdmin();
48
+ const id = Number(formData.get("id"));
49
+ const role = String(formData.get("role") ?? "");
50
+ if (!id || (role !== "admin" && role !== "editor"))
51
+ return;
52
+ await updateUserRole(id, role);
53
+ revalidatePath("/admin/users");
54
+ }
55
+ export async function deleteUserAction(formData) {
56
+ const session = await requireAdmin();
57
+ const id = Number(formData.get("id"));
58
+ if (!id || id === Number(session.user.id))
59
+ return; // never delete yourself / invalid id
60
+ try {
61
+ await deleteUser(id);
62
+ }
63
+ catch (e) {
64
+ if (isForeignKeyViolation(e)) {
65
+ redirect("/admin/users?error=User+ini+masih+menjadi+penulis+berita");
66
+ }
67
+ throw e;
68
+ }
69
+ revalidatePath("/admin/users");
70
+ }
@@ -0,0 +1,7 @@
1
+ export declare const dynamic = "force-dynamic";
2
+ export default function UsersScreen({ searchParams, }: {
3
+ searchParams: Promise<{
4
+ error?: string;
5
+ }>;
6
+ }): Promise<import("react").JSX.Element>;
7
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../../src/screens/users/page.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,OAAO,kBAAkB,CAAC;AAKvC,wBAA8B,WAAW,CAAC,EACxC,YAAY,GACb,EAAE;IACD,YAAY,EAAE,OAAO,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3C,wCA8GA"}
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { requireAdmin } from "../../lib/auth-helpers";
3
+ import { listUsers } from "../../lib/admin/users";
4
+ import { Button } from "../../components/ui/button";
5
+ import { Input } from "../../components/ui/input";
6
+ import { ConfirmDelete } from "../../components/confirm-delete";
7
+ import { createUserAction, resetPasswordAction, setRoleAction, deleteUserAction } from "./actions";
8
+ import { UserPlus, KeyRound, AlertCircle } from "lucide-react";
9
+ export const dynamic = "force-dynamic";
10
+ const selectClass = "h-9 rounded-md border border-navy-200 bg-white px-2.5 text-sm text-navy-900 outline-none transition-colors focus:border-brand-400 focus:ring-2 focus:ring-brand-100";
11
+ export default async function UsersScreen({ searchParams, }) {
12
+ const session = await requireAdmin();
13
+ const rows = await listUsers();
14
+ const { error } = await searchParams;
15
+ return (_jsxs("div", { className: "max-w-3xl", children: [_jsx("h1", { className: "font-heading text-2xl font-bold text-navy-900", children: "User" }), _jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [rows.length, " akun"] }), error && (_jsxs("p", { className: "mt-4 flex items-center gap-1.5 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 ring-1 ring-red-100", role: "alert", children: [_jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }), error] })), _jsxs("form", { action: createUserAction, className: "mt-6 grid grid-cols-1 gap-2.5 rounded-xl border border-navy-100 bg-white p-5 shadow-sm sm:grid-cols-2", children: [_jsxs("p", { className: "flex items-center gap-2 text-sm font-semibold text-navy-900 sm:col-span-2", children: [_jsx(UserPlus, { className: "h-4 w-4 text-brand-600" }), "Tambah user baru"] }), _jsx(Input, { name: "name", placeholder: "Nama", required: true }), _jsx(Input, { name: "email", type: "email", placeholder: "Email", required: true }), _jsx(Input, { name: "password", type: "password", placeholder: "Password (min 8)", required: true }), _jsxs("select", { name: "role", className: selectClass, children: [_jsx("option", { value: "editor", children: "Editor" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsxs(Button, { type: "submit", className: "sm:col-span-2", children: [_jsx(UserPlus, { className: "h-4 w-4" }), "Tambah User"] })] }), _jsx("ul", { className: "mt-6 divide-y divide-navy-50 overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm", children: rows.map((u) => {
16
+ const isSelf = u.id === Number(session.user.id);
17
+ const isAdmin = (u.role ?? "editor") === "admin";
18
+ return (_jsxs("li", { className: "space-y-3 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-navy-100 text-sm font-semibold uppercase text-navy-700", children: u.name?.[0] ?? u.email[0] }), _jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "flex items-center gap-2 font-medium text-navy-900", children: [_jsx("span", { className: "truncate", children: u.name }), isSelf && (_jsx("span", { className: "rounded bg-navy-100 px-1.5 py-0.5 text-[10px] font-medium text-navy-500", children: "Anda" }))] }), _jsx("p", { className: "truncate text-xs text-muted-foreground", children: u.email })] })] }), _jsx("span", { className: `shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ring-1 ${isAdmin
19
+ ? "bg-gold-100 text-gold-700 ring-gold-200"
20
+ : "bg-brand-50 text-brand-700 ring-brand-100"}`, children: u.role ?? "editor" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 border-t border-navy-50 pt-3", children: [_jsxs("form", { action: setRoleAction, className: "flex items-center gap-2", children: [_jsx("input", { type: "hidden", name: "id", value: u.id }), _jsxs("select", { name: "role", defaultValue: u.role ?? "editor", className: "h-8 rounded-md border border-navy-200 bg-white px-2 text-xs", children: [_jsx("option", { value: "editor", children: "Editor" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsx(Button, { size: "sm", variant: "outline", type: "submit", children: "Set Role" })] }), _jsxs("form", { action: resetPasswordAction, className: "flex items-center gap-2", children: [_jsx("input", { type: "hidden", name: "id", value: u.id }), _jsx(Input, { name: "password", type: "password", placeholder: "Password baru", className: "h-8 w-36" }), _jsxs(Button, { size: "sm", variant: "outline", type: "submit", children: [_jsx(KeyRound, { className: "h-3.5 w-3.5" }), "Reset"] })] }), !isSelf && (_jsx("div", { className: "ml-auto", children: _jsx(ConfirmDelete, { action: deleteUserAction, id: u.id, title: "Hapus user?", description: _jsxs(_Fragment, { children: ["User ", _jsx("span", { className: "font-medium text-navy-900", children: u.email }), " akan dihapus."] }) }) }))] })] }, u.id));
21
+ }) })] }));
22
+ }
@@ -0,0 +1,2 @@
1
+ export declare function signOutAction(): Promise<void>;
2
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/shell/actions.ts"],"names":[],"mappings":"AAGA,wBAAsB,aAAa,kBAElC"}
@@ -0,0 +1,5 @@
1
+ "use server";
2
+ import { signOut } from "../auth/index";
3
+ export async function signOutAction() {
4
+ await signOut({ redirectTo: "/admin/login" });
5
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+ import { type NavItem } from "./sidebar";
3
+ export declare function AdminLayout({ navItems, children, logoSrc, brandName, }: {
4
+ navItems: NavItem[];
5
+ children: ReactNode;
6
+ logoSrc?: string;
7
+ brandName?: string;
8
+ }): Promise<import("react").JSX.Element>;
9
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../src/shell/layout.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,EAAgB,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAEvD,wBAAsB,WAAW,CAAC,EAChC,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,SAAS,GACV,EAAE;IACD,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,wCA+BA"}
@@ -0,0 +1,15 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { auth } from "../auth/index";
3
+ import { Toaster } from "sonner";
4
+ import { AdminSidebar } from "./sidebar";
5
+ export async function AdminLayout({ navItems, children, logoSrc, brandName, }) {
6
+ const session = await auth();
7
+ // The only unauthenticated route reachable here is /admin/login: the proxy
8
+ // redirects every other /admin/* to login, and each admin page additionally
9
+ // calls requireUser()/requireAdmin() before touching data.
10
+ // So rendering bare children here (no shell) cannot leak protected content.
11
+ if (!session?.user) {
12
+ return _jsx(_Fragment, { children: children });
13
+ }
14
+ return (_jsxs("div", { className: "flex min-h-screen bg-navy-50/60", children: [_jsx(AdminSidebar, { role: session.user.role, navItems: navItems, logoSrc: logoSrc, brandName: brandName }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "sticky top-0 z-10 flex h-16 items-center justify-between border-b border-navy-100 bg-white/80 px-6 backdrop-blur-sm", children: [_jsx("span", { className: "text-sm font-medium text-navy-500", children: "Panel Admin" }), _jsxs("div", { className: "flex items-center gap-2.5", children: [_jsx("span", { className: "hidden text-sm text-navy-600 sm:inline", children: session.user.email }), _jsx("span", { className: "inline-flex items-center rounded-full bg-brand-50 px-2.5 py-1 text-xs font-semibold capitalize text-brand-700 ring-1 ring-brand-100", children: session.user.role })] })] }), _jsx("main", { className: "flex-1 p-6 md:p-8", children: children })] }), _jsx(Toaster, {})] }));
15
+ }
@@ -0,0 +1,16 @@
1
+ import type { ComponentType } from "react";
2
+ export type NavItem = {
3
+ href: string;
4
+ label: string;
5
+ icon: ComponentType<{
6
+ className?: string;
7
+ }>;
8
+ adminOnly?: boolean;
9
+ };
10
+ export declare function AdminSidebar({ role, navItems, logoSrc, brandName }: {
11
+ role: string;
12
+ navItems: NavItem[];
13
+ logoSrc?: string;
14
+ brandName?: string;
15
+ }): import("react").JSX.Element;
16
+ //# sourceMappingURL=sidebar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidebar.d.ts","sourceRoot":"","sources":["../../src/shell/sidebar.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAG3C,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,wBAAgB,YAAY,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAqB,EAAE,SAAmB,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,EAAE,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,+BA0DvK"}
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { LogOut } from "lucide-react";
6
+ import { signOutAction } from "./actions";
7
+ export function AdminSidebar({ role, navItems, logoSrc = "/logo.png", brandName = "Admin" }) {
8
+ const pathname = usePathname();
9
+ return (_jsxs("aside", { className: "flex w-60 shrink-0 flex-col bg-gradient-to-b from-navy-900 to-navy-950 text-navy-100", children: [_jsxs("div", { className: "flex h-16 items-center gap-2.5 border-b border-white/5 px-5", children: [_jsx("img", { src: logoSrc, alt: "", className: "h-8 w-8" }), _jsxs("div", { className: "leading-none", children: [_jsx("p", { className: "font-heading text-sm font-extrabold text-white", children: brandName }), _jsx("p", { className: "mt-1 text-[10px] font-semibold tracking-[0.22em] text-gold-400", children: "ADMIN" })] })] }), _jsx("nav", { className: "flex-1 space-y-1 p-3", children: navItems
10
+ .filter((l) => !l.adminOnly || role === "admin")
11
+ .map((l) => {
12
+ const active = pathname === l.href ||
13
+ (l.href !== "/admin" && pathname.startsWith(l.href));
14
+ const Icon = l.icon;
15
+ return (_jsxs(Link, { href: l.href, className: `relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${active
16
+ ? "bg-white/10 font-medium text-white"
17
+ : "text-navy-200 hover:bg-white/5 hover:text-white"}`, children: [active && (_jsx("span", { className: "absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-gold-400" })), _jsx(Icon, { className: `h-4 w-4 ${active ? "text-gold-400" : "text-navy-300"}` }), l.label] }, l.href));
18
+ }) }), _jsx("div", { className: "border-t border-white/5 p-3", children: _jsx("form", { action: signOutAction, children: _jsxs("button", { type: "submit", className: "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-navy-200 transition-colors hover:bg-white/5 hover:text-white", children: [_jsx(LogOut, { className: "h-4 w-4" }), "Keluar"] }) }) })] }));
19
+ }
package/package.json ADDED
@@ -0,0 +1,148 @@
1
+ {
2
+ "name": "@blawness/admin-kit",
3
+ "version": "0.2.0",
4
+ "description": "Reusable CMS core for Next.js 16 admin panels — auth, media, users, editor, shell",
5
+ "license": "MIT",
6
+ "author": "Blawness",
7
+ "keywords": [
8
+ "nextjs",
9
+ "cms",
10
+ "admin",
11
+ "drizzle-orm",
12
+ "next-auth",
13
+ "tiptap"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/Blawness/admin-kit.git"
18
+ },
19
+ "homepage": "https://github.com/Blawness/admin-kit",
20
+ "private": false,
21
+ "type": "module",
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./components": {
32
+ "types": "./dist/components.d.ts",
33
+ "default": "./dist/components.js"
34
+ },
35
+ "./auth": {
36
+ "types": "./dist/auth/index.d.ts",
37
+ "default": "./dist/auth/index.js"
38
+ },
39
+ "./auth/config": {
40
+ "types": "./dist/auth/config.d.ts",
41
+ "default": "./dist/auth/config.js"
42
+ },
43
+ "./auth-helpers": {
44
+ "types": "./dist/lib/auth-helpers.d.ts",
45
+ "default": "./dist/lib/auth-helpers.js"
46
+ },
47
+ "./db": {
48
+ "types": "./dist/db/index.d.ts",
49
+ "default": "./dist/db/index.js"
50
+ },
51
+ "./schema": {
52
+ "types": "./dist/db/schema.d.ts",
53
+ "default": "./dist/db/schema.js"
54
+ },
55
+ "./admin/media": {
56
+ "types": "./dist/lib/admin/media.d.ts",
57
+ "default": "./dist/lib/admin/media.js"
58
+ },
59
+ "./admin/users": {
60
+ "types": "./dist/lib/admin/users.d.ts",
61
+ "default": "./dist/lib/admin/users.js"
62
+ },
63
+ "./screens/login": {
64
+ "types": "./dist/screens/login/page.d.ts",
65
+ "default": "./dist/screens/login/page.js"
66
+ },
67
+ "./screens/login/actions": {
68
+ "types": "./dist/screens/login/actions.d.ts",
69
+ "default": "./dist/screens/login/actions.js"
70
+ },
71
+ "./screens/media": {
72
+ "types": "./dist/screens/media/page.d.ts",
73
+ "default": "./dist/screens/media/page.js"
74
+ },
75
+ "./screens/media/actions": {
76
+ "types": "./dist/screens/media/actions.d.ts",
77
+ "default": "./dist/screens/media/actions.js"
78
+ },
79
+ "./screens/media/lib": {
80
+ "types": "./dist/screens/media/lib.d.ts",
81
+ "default": "./dist/screens/media/lib.js"
82
+ },
83
+ "./screens/users": {
84
+ "types": "./dist/screens/users/page.d.ts",
85
+ "default": "./dist/screens/users/page.js"
86
+ },
87
+ "./screens/users/actions": {
88
+ "types": "./dist/screens/users/actions.d.ts",
89
+ "default": "./dist/screens/users/actions.js"
90
+ },
91
+ "./shell": {
92
+ "types": "./dist/shell/layout.d.ts",
93
+ "default": "./dist/shell/layout.js"
94
+ },
95
+ "./shell/sidebar": {
96
+ "types": "./dist/shell/sidebar.d.ts",
97
+ "default": "./dist/shell/sidebar.js"
98
+ },
99
+ "./shell/actions": {
100
+ "types": "./dist/shell/actions.d.ts",
101
+ "default": "./dist/shell/actions.js"
102
+ }
103
+ },
104
+ "scripts": {
105
+ "build": "tsc -p tsconfig.json",
106
+ "typecheck": "tsc -p tsconfig.json --noEmit",
107
+ "prepare": "tsc -p tsconfig.json"
108
+ },
109
+ "peerDependencies": {
110
+ "next": "^16.2.0",
111
+ "react": "^19.2.0",
112
+ "react-dom": "^19.2.0",
113
+ "tailwindcss": "^4"
114
+ },
115
+ "pnpm": {
116
+ "onlyBuiltDependencies": [
117
+ "sharp"
118
+ ]
119
+ },
120
+ "dependencies": {
121
+ "@aws-sdk/client-s3": "^3.1058.0",
122
+ "@base-ui/react": "^1.5.0",
123
+ "@tiptap/extension-image": "^3.25.0",
124
+ "@tiptap/extension-link": "^3.25.0",
125
+ "@tiptap/pm": "^3.25.0",
126
+ "@tiptap/react": "^3.25.0",
127
+ "@tiptap/starter-kit": "^3.25.0",
128
+ "bcryptjs": "^3.0.3",
129
+ "class-variance-authority": "^0.7.1",
130
+ "clsx": "^2.1.1",
131
+ "drizzle-orm": "^0.45.2",
132
+ "lucide-react": "^1.16.0",
133
+ "next-auth": "^5.0.0-beta.31",
134
+ "postgres": "^3.4.9",
135
+ "sanitize-html": "^2.13.0",
136
+ "sharp": "^0.34.5",
137
+ "sonner": "^2.0.7",
138
+ "tailwind-merge": "^3.6.0",
139
+ "zod": "^4.4.3"
140
+ },
141
+ "devDependencies": {
142
+ "@types/node": "^25.9.2",
143
+ "@types/react": "^19",
144
+ "@types/react-dom": "^19",
145
+ "@types/sanitize-html": "^2",
146
+ "typescript": "^5"
147
+ }
148
+ }
@@ -0,0 +1,36 @@
1
+ import type { NextAuthConfig } from "next-auth";
2
+
3
+ export const authConfig: NextAuthConfig = {
4
+ pages: { signIn: "/admin/login" },
5
+ session: { strategy: "jwt" },
6
+ trustHost: true,
7
+ providers: [], // real provider added in auth/index.ts (Node runtime)
8
+ callbacks: {
9
+ authorized({ auth, request: { nextUrl } }) {
10
+ const isAdminArea = nextUrl.pathname.startsWith("/admin");
11
+ const isLogin = nextUrl.pathname === "/admin/login";
12
+ // Already-authenticated users have no business on the login page.
13
+ if (isLogin) {
14
+ return auth?.user
15
+ ? Response.redirect(new URL("/admin", nextUrl))
16
+ : true;
17
+ }
18
+ if (isAdminArea) return !!auth?.user;
19
+ return true;
20
+ },
21
+ jwt({ token, user }) {
22
+ if (user) {
23
+ token.id = user.id as string;
24
+ token.role = (user.role as string | undefined) ?? "editor";
25
+ }
26
+ return token;
27
+ },
28
+ session({ session, token }) {
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ const u = session.user as any;
31
+ if (token.id) u.id = token.id;
32
+ u.role = token.role ?? "editor";
33
+ return session;
34
+ },
35
+ },
36
+ };
@@ -0,0 +1,49 @@
1
+ import NextAuth, { type NextAuthResult } from "next-auth";
2
+ import Credentials from "next-auth/providers/credentials";
3
+ import { compare } from "bcryptjs";
4
+ import { z } from "zod";
5
+ import { eq } from "drizzle-orm";
6
+ import { db } from "../db/index";
7
+ import { users } from "../db/schema";
8
+ import { authConfig } from "./config";
9
+
10
+ const credentialsSchema = z.object({
11
+ email: z.string().email(),
12
+ password: z.string().min(1),
13
+ });
14
+
15
+ const _result: NextAuthResult = NextAuth({
16
+ ...authConfig,
17
+ providers: [
18
+ Credentials({
19
+ credentials: { email: {}, password: {} },
20
+ async authorize(raw) {
21
+ const parsed = credentialsSchema.safeParse(raw);
22
+ if (!parsed.success) return null;
23
+ const { email, password } = parsed.data;
24
+
25
+ const [user] = await db
26
+ .select()
27
+ .from(users)
28
+ .where(eq(users.email, email))
29
+ .limit(1);
30
+
31
+ if (!user?.passwordHash) return null;
32
+ const ok = await compare(password, user.passwordHash);
33
+ if (!ok) return null;
34
+
35
+ return {
36
+ id: String(user.id),
37
+ email: user.email,
38
+ name: user.name,
39
+ role: user.role ?? "editor",
40
+ };
41
+ },
42
+ }),
43
+ ],
44
+ });
45
+
46
+ export const handlers: NextAuthResult["handlers"] = _result.handlers;
47
+ export const auth: NextAuthResult["auth"] = _result.auth;
48
+ export const signIn: NextAuthResult["signIn"] = _result.signIn;
49
+ export const signOut: NextAuthResult["signOut"] = _result.signOut;
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useEditor, EditorContent } from "@tiptap/react";
4
+ import StarterKit from "@tiptap/starter-kit";
5
+ import Link from "@tiptap/extension-link";
6
+ import Image from "@tiptap/extension-image";
7
+ import { Button } from "../ui/button";
8
+
9
+ export function Editor({
10
+ value,
11
+ onChange,
12
+ }: {
13
+ value: string;
14
+ onChange: (html: string) => void;
15
+ }) {
16
+ const editor = useEditor({
17
+ extensions: [
18
+ StarterKit.configure({ heading: { levels: [2, 3, 4] } }),
19
+ Link.configure({ openOnClick: false }),
20
+ Image,
21
+ ],
22
+ content: value,
23
+ immediatelyRender: false,
24
+ editorProps: { attributes: { class: "prose prose-blue max-w-none min-h-[300px] focus:outline-none" } },
25
+ onUpdate: ({ editor }) => onChange(editor.getHTML()),
26
+ });
27
+
28
+ if (!editor) return null;
29
+
30
+ return (
31
+ <div className="rounded-md border border-navy-200">
32
+ <div className="flex flex-wrap gap-1 border-b border-navy-100 p-2">
33
+ <ToolbarButton on={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")}>B</ToolbarButton>
34
+ <ToolbarButton on={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")}>I</ToolbarButton>
35
+ <ToolbarButton on={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })}>H2</ToolbarButton>
36
+ <ToolbarButton on={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })}>H3</ToolbarButton>
37
+ <ToolbarButton on={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")}>• List</ToolbarButton>
38
+ <ToolbarButton on={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")}>1. List</ToolbarButton>
39
+ <ToolbarButton on={() => { const url = prompt("URL tautan:"); if (url) editor.chain().focus().setLink({ href: url }).run(); }} active={editor.isActive("link")}>Link</ToolbarButton>
40
+ <ToolbarButton on={() => { const url = prompt("URL gambar:"); if (url) editor.chain().focus().setImage({ src: url }).run(); }} active={false}>Gambar</ToolbarButton>
41
+ </div>
42
+ <EditorContent editor={editor} className="p-3" />
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function ToolbarButton({ on, active, children }: { on: () => void; active: boolean; children: React.ReactNode }) {
48
+ return (
49
+ <Button type="button" variant={active ? "default" : "outline"} size="sm" onClick={on}>
50
+ {children}
51
+ </Button>
52
+ );
53
+ }