@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
package/src/lib/r2.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
|
|
4
|
+
const endpoint = process.env.R2_ENDPOINT;
|
|
5
|
+
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
|
6
|
+
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
|
7
|
+
|
|
8
|
+
if (!endpoint) throw new Error("admin-kit: R2_ENDPOINT env var is required");
|
|
9
|
+
if (!accessKeyId || !secretAccessKey) throw new Error("admin-kit: R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY env vars are required");
|
|
10
|
+
|
|
11
|
+
export const R2_BUCKET = process.env.R2_BUCKET ?? "lipan-ri";
|
|
12
|
+
/** Base URL publik untuk menyajikan objek (r2.dev atau custom domain), tanpa trailing slash. */
|
|
13
|
+
export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? "").replace(/\/$/, "");
|
|
14
|
+
|
|
15
|
+
export const r2 = new S3Client({
|
|
16
|
+
region: "auto",
|
|
17
|
+
endpoint,
|
|
18
|
+
credentials:
|
|
19
|
+
accessKeyId && secretAccessKey
|
|
20
|
+
? { accessKeyId, secretAccessKey }
|
|
21
|
+
: undefined,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resize + kompres gambar lalu unggah ke R2.
|
|
26
|
+
* Apa pun ukuran input, hasil disimpan maksimal 1600px lebar, JPEG q80.
|
|
27
|
+
* Mengembalikan URL publik yang siap disimpan ke kolom `featured_image`.
|
|
28
|
+
*/
|
|
29
|
+
export async function uploadImage(
|
|
30
|
+
input: Buffer,
|
|
31
|
+
/** key/nama objek di bucket, tanpa ekstensi — mis. "berita/slug-artikel" */
|
|
32
|
+
keyBase: string
|
|
33
|
+
): Promise<{ url: string; key: string; size: number }> {
|
|
34
|
+
const optimized = await sharp(input)
|
|
35
|
+
.rotate() // hormati orientasi EXIF
|
|
36
|
+
.resize({ width: 1600, withoutEnlargement: true })
|
|
37
|
+
.jpeg({ quality: 80, mozjpeg: true })
|
|
38
|
+
.toBuffer();
|
|
39
|
+
|
|
40
|
+
const key = `${keyBase.replace(/^\/+/, "")}.jpg`;
|
|
41
|
+
|
|
42
|
+
await r2.send(
|
|
43
|
+
new PutObjectCommand({
|
|
44
|
+
Bucket: R2_BUCKET,
|
|
45
|
+
Key: key,
|
|
46
|
+
Body: optimized,
|
|
47
|
+
ContentType: "image/jpeg",
|
|
48
|
+
CacheControl: "public, max-age=31536000, immutable",
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (!R2_PUBLIC_URL) {
|
|
53
|
+
throw new Error("R2_PUBLIC_URL belum di-set — tidak bisa menyusun URL publik.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { url: `${R2_PUBLIC_URL}/${key}`, key, size: optimized.length };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hapus objek dari R2 berdasarkan URL publiknya. Hanya menghapus objek yang
|
|
61
|
+
* memang berada di bawah R2_PUBLIC_URL — URL eksternal/seed diabaikan dengan
|
|
62
|
+
* aman (return false). Kegagalan jaringan dibiarkan melempar ke pemanggil.
|
|
63
|
+
*/
|
|
64
|
+
export async function deleteObjectByUrl(url: string): Promise<boolean> {
|
|
65
|
+
if (!R2_PUBLIC_URL || !url.startsWith(`${R2_PUBLIC_URL}/`)) return false;
|
|
66
|
+
const key = url.slice(R2_PUBLIC_URL.length + 1);
|
|
67
|
+
if (!key) return false;
|
|
68
|
+
await r2.send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import sanitizeHtmlLib from "sanitize-html";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize editor HTML before persisting (content is rendered via
|
|
5
|
+
* dangerouslySetInnerHTML on the public site).
|
|
6
|
+
*
|
|
7
|
+
* Uses `sanitize-html` (pure JS, htmlparser2 — no jsdom) so it loads in the
|
|
8
|
+
* Vercel serverless runtime; `isomorphic-dompurify`/jsdom failed to load there
|
|
9
|
+
* ("Failed to load external module" → /admin/posts 500).
|
|
10
|
+
*
|
|
11
|
+
* Security posture (unchanged): strict tag allowlist, no `target` (avoids
|
|
12
|
+
* tab-napping), only http(s)/mailto + root-relative URLs, and protocol-relative
|
|
13
|
+
* `//host` URLs are rejected.
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeHtml(dirty: string): string {
|
|
16
|
+
return sanitizeHtmlLib(dirty, {
|
|
17
|
+
allowedTags: [
|
|
18
|
+
"p", "br", "strong", "em", "u", "s", "h2", "h3", "h4",
|
|
19
|
+
"ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
|
|
20
|
+
],
|
|
21
|
+
allowedAttributes: {
|
|
22
|
+
a: ["href", "rel", "title"],
|
|
23
|
+
img: ["src", "alt", "title"],
|
|
24
|
+
},
|
|
25
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
26
|
+
allowedSchemesByTag: { img: ["http", "https"] },
|
|
27
|
+
allowProtocolRelative: false,
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/lib/slug.ts
ADDED
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { redirect } from "next/navigation";
|
|
4
|
+
import { signIn } from "../../auth/index";
|
|
5
|
+
|
|
6
|
+
export type LoginState = { error?: string };
|
|
7
|
+
|
|
8
|
+
export async function loginAction(
|
|
9
|
+
_prev: LoginState,
|
|
10
|
+
formData: FormData,
|
|
11
|
+
): Promise<LoginState> {
|
|
12
|
+
try {
|
|
13
|
+
const result = await signIn("credentials", {
|
|
14
|
+
email: formData.get("email"),
|
|
15
|
+
password: formData.get("password"),
|
|
16
|
+
redirect: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Auth.js returns the redirect URL when redirect:false
|
|
20
|
+
// On failure it returns a URL with ?error= param
|
|
21
|
+
if (typeof result === "string" && result.includes("error=")) {
|
|
22
|
+
return { error: "Email atau password salah." };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
redirect("/admin");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// Catch CredentialsSignin and other auth errors thrown by next-auth
|
|
28
|
+
// NEXT_REDIRECT thrown by redirect() must propagate
|
|
29
|
+
if (
|
|
30
|
+
error instanceof Error &&
|
|
31
|
+
"digest" in error &&
|
|
32
|
+
String((error as { digest: string }).digest).startsWith("NEXT_REDIRECT")
|
|
33
|
+
) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
return { error: "Email atau password salah." };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
import { loginAction, type LoginState } from "./actions";
|
|
5
|
+
import { Button } from "../../components/ui/button";
|
|
6
|
+
import { Input } from "../../components/ui/input";
|
|
7
|
+
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
export default function LoginScreen() {
|
|
11
|
+
const [state, action, pending] = useActionState<LoginState, FormData>(
|
|
12
|
+
loginAction,
|
|
13
|
+
{},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="min-h-screen flex items-center justify-center bg-navy-50 px-4">
|
|
18
|
+
<form
|
|
19
|
+
action={action}
|
|
20
|
+
className="w-full max-w-sm bg-white rounded-2xl shadow-xl ring-1 ring-navy-100 p-8 space-y-4"
|
|
21
|
+
>
|
|
22
|
+
<h1 className="font-heading text-2xl font-bold text-navy-900">
|
|
23
|
+
Masuk Admin
|
|
24
|
+
</h1>
|
|
25
|
+
<div className="space-y-1">
|
|
26
|
+
<label htmlFor="email" className="text-sm font-medium text-navy-900">
|
|
27
|
+
Email
|
|
28
|
+
</label>
|
|
29
|
+
<Input id="email" name="email" type="email" required autoComplete="email" />
|
|
30
|
+
</div>
|
|
31
|
+
<div className="space-y-1">
|
|
32
|
+
<label htmlFor="password" className="text-sm font-medium text-navy-900">
|
|
33
|
+
Password
|
|
34
|
+
</label>
|
|
35
|
+
<Input id="password" name="password" type="password" required autoComplete="current-password" />
|
|
36
|
+
</div>
|
|
37
|
+
{state.error && (
|
|
38
|
+
<p className="text-sm text-red-600" role="alert">
|
|
39
|
+
{state.error}
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
43
|
+
{pending ? "Memproses…" : "Masuk"}
|
|
44
|
+
</Button>
|
|
45
|
+
</form>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { requireUser } from "../../lib/auth-helpers";
|
|
5
|
+
import { uploadImage } from "../../lib/r2";
|
|
6
|
+
import { db } from "../../db/index";
|
|
7
|
+
import { media } from "../../db/schema";
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const OK_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
|
11
|
+
|
|
12
|
+
export async function uploadImageAction(formData: FormData): Promise<{ url?: string; error?: string }> {
|
|
13
|
+
await requireUser();
|
|
14
|
+
const file = formData.get("file");
|
|
15
|
+
if (!(file instanceof File)) return { error: "Tidak ada berkas." };
|
|
16
|
+
if (!OK_TYPES.includes(file.type)) return { error: "Format gambar tidak didukung." };
|
|
17
|
+
if (file.size > MAX_BYTES) return { error: "Ukuran gambar maksimal 8MB." };
|
|
18
|
+
|
|
19
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
20
|
+
const keyBase = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
21
|
+
|
|
22
|
+
let url: string;
|
|
23
|
+
try {
|
|
24
|
+
// sharp (inside uploadImage) throws on data that isn't really an image,
|
|
25
|
+
// e.g. a non-image file sent with a spoofed image MIME type.
|
|
26
|
+
({ url } = await uploadImage(buf, keyBase));
|
|
27
|
+
} catch {
|
|
28
|
+
return { error: "Gambar gagal diproses. Pastikan berkas benar-benar gambar." };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await db.insert(media).values({ url, altText: file.name });
|
|
32
|
+
revalidatePath("/admin/media");
|
|
33
|
+
return { url };
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Generic delete-media logic, extracted from the server action so that the
|
|
9
|
+
* consuming app can inject its own reference checker (posts, banners, etc.).
|
|
10
|
+
*
|
|
11
|
+
* @param formData - FormData containing "id" field
|
|
12
|
+
* @param referenceChecker - async fn that returns the count of references to the given URL
|
|
13
|
+
*/
|
|
14
|
+
export async function handleDeleteMedia(
|
|
15
|
+
formData: FormData,
|
|
16
|
+
referenceChecker: (url: string) => Promise<number>,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
await requireUser();
|
|
19
|
+
const id = Number(formData.get("id"));
|
|
20
|
+
if (!id) return;
|
|
21
|
+
|
|
22
|
+
const row = await getMediaById(id);
|
|
23
|
+
if (!row) return;
|
|
24
|
+
|
|
25
|
+
const refs = await referenceChecker(row.url);
|
|
26
|
+
if (refs > 0) {
|
|
27
|
+
redirect(
|
|
28
|
+
`/admin/media?error=${encodeURIComponent(
|
|
29
|
+
`Gambar masih dipakai oleh ${refs} konten. Lepas dulu dari berita/banner sebelum menghapus.`
|
|
30
|
+
)}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Hapus objek R2 lebih dulu; bila gagal, biarkan melempar agar row DB tetap
|
|
35
|
+
// ada dan operasi bisa diulang (hindari row hilang tapi objek menggantung).
|
|
36
|
+
await deleteObjectByUrl(row.url);
|
|
37
|
+
await deleteMediaRow(id);
|
|
38
|
+
revalidatePath("/admin/media");
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { listMedia } from "../../lib/admin/media";
|
|
2
|
+
import { requireUser } from "../../lib/auth-helpers";
|
|
3
|
+
import { ConfirmDelete } from "../../components/confirm-delete";
|
|
4
|
+
import { GalleryUploader } from "./uploader";
|
|
5
|
+
import { Trash2, ImageOff, AlertCircle } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
export default async function MediaLibraryScreen({
|
|
10
|
+
deleteAction,
|
|
11
|
+
searchParams,
|
|
12
|
+
}: {
|
|
13
|
+
deleteAction: (fd: FormData) => Promise<void>;
|
|
14
|
+
searchParams: Promise<{ error?: string }>;
|
|
15
|
+
}) {
|
|
16
|
+
await requireUser();
|
|
17
|
+
const items = await listMedia();
|
|
18
|
+
const { error } = await searchParams;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="max-w-5xl">
|
|
22
|
+
<div className="mb-6">
|
|
23
|
+
<h1 className="font-heading text-2xl font-bold text-navy-900">Galeri</h1>
|
|
24
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
25
|
+
{items.length} gambar tersimpan
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
{error && (
|
|
30
|
+
<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">
|
|
31
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
32
|
+
{error}
|
|
33
|
+
</p>
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
<GalleryUploader />
|
|
37
|
+
|
|
38
|
+
{items.length === 0 ? (
|
|
39
|
+
<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">
|
|
40
|
+
<ImageOff className="h-8 w-8 text-navy-300" />
|
|
41
|
+
<p className="mt-3 text-sm font-medium text-navy-700">Belum ada gambar</p>
|
|
42
|
+
<p className="text-xs text-muted-foreground">
|
|
43
|
+
Unggah gambar pertama lewat kotak di atas.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
) : (
|
|
47
|
+
<div className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
|
48
|
+
{items.map((m) => (
|
|
49
|
+
<div
|
|
50
|
+
key={m.id}
|
|
51
|
+
className="group relative overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm"
|
|
52
|
+
>
|
|
53
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- R2 URL */}
|
|
54
|
+
<img
|
|
55
|
+
src={m.url}
|
|
56
|
+
alt={m.altText ?? ""}
|
|
57
|
+
className="aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
58
|
+
/>
|
|
59
|
+
<div className="absolute right-2 top-2">
|
|
60
|
+
<ConfirmDelete
|
|
61
|
+
action={deleteAction}
|
|
62
|
+
id={m.id}
|
|
63
|
+
title="Hapus gambar?"
|
|
64
|
+
description="Gambar akan dihapus permanen dari media."
|
|
65
|
+
trigger={
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
aria-label="Hapus gambar"
|
|
69
|
+
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"
|
|
70
|
+
>
|
|
71
|
+
<Trash2 className="h-4 w-4" />
|
|
72
|
+
</button>
|
|
73
|
+
}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
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
|
+
|
|
8
|
+
export function GalleryUploader() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
return (
|
|
11
|
+
<div className="max-w-xl rounded-xl border border-navy-100 bg-white p-5 shadow-sm">
|
|
12
|
+
<div className="mb-3 flex items-center gap-2">
|
|
13
|
+
<Images className="h-4 w-4 text-brand-600" />
|
|
14
|
+
<p className="text-sm font-semibold text-navy-900">Tambah gambar ke galeri</p>
|
|
15
|
+
</div>
|
|
16
|
+
<ImageUpload uploadAction={uploadImageAction} onChange={() => router.refresh()} />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { requireAdmin } from "../../lib/auth-helpers";
|
|
7
|
+
import { isUniqueViolation, isForeignKeyViolation } from "../../lib/db-errors";
|
|
8
|
+
import { createUser, updateUserPassword, updateUserRole, deleteUser } from "../../lib/admin/users";
|
|
9
|
+
|
|
10
|
+
const createSchema = z.object({
|
|
11
|
+
email: z.string().email(),
|
|
12
|
+
name: z.string().min(1),
|
|
13
|
+
password: z.string().min(8),
|
|
14
|
+
role: z.enum(["admin", "editor"]),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export async function createUserAction(formData: FormData) {
|
|
18
|
+
await requireAdmin();
|
|
19
|
+
const parsed = createSchema.safeParse({
|
|
20
|
+
email: formData.get("email"),
|
|
21
|
+
name: formData.get("name"),
|
|
22
|
+
password: formData.get("password"),
|
|
23
|
+
role: formData.get("role"),
|
|
24
|
+
});
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
redirect("/admin/users?error=Data+tidak+valid+(email+benar,+password+min+8+karakter)");
|
|
27
|
+
}
|
|
28
|
+
const { email, name, password, role } = parsed.data;
|
|
29
|
+
try {
|
|
30
|
+
await createUser(email, name, password, role);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (isUniqueViolation(e)) {
|
|
33
|
+
redirect("/admin/users?error=Email+sudah+terdaftar");
|
|
34
|
+
}
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
revalidatePath("/admin/users");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resetPasswordAction(formData: FormData) {
|
|
41
|
+
await requireAdmin();
|
|
42
|
+
const id = Number(formData.get("id"));
|
|
43
|
+
const password = String(formData.get("password") ?? "");
|
|
44
|
+
if (!id || password.length < 8) return;
|
|
45
|
+
await updateUserPassword(id, password);
|
|
46
|
+
revalidatePath("/admin/users");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function setRoleAction(formData: FormData) {
|
|
50
|
+
await requireAdmin();
|
|
51
|
+
const id = Number(formData.get("id"));
|
|
52
|
+
const role = String(formData.get("role") ?? "");
|
|
53
|
+
if (!id || (role !== "admin" && role !== "editor")) return;
|
|
54
|
+
await updateUserRole(id, role);
|
|
55
|
+
revalidatePath("/admin/users");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function deleteUserAction(formData: FormData) {
|
|
59
|
+
const session = await requireAdmin();
|
|
60
|
+
const id = Number(formData.get("id"));
|
|
61
|
+
if (!id || id === Number(session.user.id)) return; // never delete yourself / invalid id
|
|
62
|
+
try {
|
|
63
|
+
await deleteUser(id);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (isForeignKeyViolation(e)) {
|
|
66
|
+
redirect("/admin/users?error=User+ini+masih+menjadi+penulis+berita");
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
revalidatePath("/admin/users");
|
|
71
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { requireAdmin } from "../../lib/auth-helpers";
|
|
2
|
+
import { listUsers } from "../../lib/admin/users";
|
|
3
|
+
import { Button } from "../../components/ui/button";
|
|
4
|
+
import { Input } from "../../components/ui/input";
|
|
5
|
+
import { ConfirmDelete } from "../../components/confirm-delete";
|
|
6
|
+
import { createUserAction, resetPasswordAction, setRoleAction, deleteUserAction } from "./actions";
|
|
7
|
+
import { UserPlus, KeyRound, AlertCircle } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
|
|
11
|
+
const selectClass =
|
|
12
|
+
"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";
|
|
13
|
+
|
|
14
|
+
export default async function UsersScreen({
|
|
15
|
+
searchParams,
|
|
16
|
+
}: {
|
|
17
|
+
searchParams: Promise<{ error?: string }>;
|
|
18
|
+
}) {
|
|
19
|
+
const session = await requireAdmin();
|
|
20
|
+
const rows = await listUsers();
|
|
21
|
+
const { error } = await searchParams;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="max-w-3xl">
|
|
25
|
+
<h1 className="font-heading text-2xl font-bold text-navy-900">User</h1>
|
|
26
|
+
<p className="mt-1 text-sm text-muted-foreground">{rows.length} akun</p>
|
|
27
|
+
|
|
28
|
+
{error && (
|
|
29
|
+
<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">
|
|
30
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
31
|
+
{error}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
<form
|
|
36
|
+
action={createUserAction}
|
|
37
|
+
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"
|
|
38
|
+
>
|
|
39
|
+
<p className="flex items-center gap-2 text-sm font-semibold text-navy-900 sm:col-span-2">
|
|
40
|
+
<UserPlus className="h-4 w-4 text-brand-600" />
|
|
41
|
+
Tambah user baru
|
|
42
|
+
</p>
|
|
43
|
+
<Input name="name" placeholder="Nama" required />
|
|
44
|
+
<Input name="email" type="email" placeholder="Email" required />
|
|
45
|
+
<Input name="password" type="password" placeholder="Password (min 8)" required />
|
|
46
|
+
<select name="role" className={selectClass}>
|
|
47
|
+
<option value="editor">Editor</option>
|
|
48
|
+
<option value="admin">Admin</option>
|
|
49
|
+
</select>
|
|
50
|
+
<Button type="submit" className="sm:col-span-2">
|
|
51
|
+
<UserPlus className="h-4 w-4" />
|
|
52
|
+
Tambah User
|
|
53
|
+
</Button>
|
|
54
|
+
</form>
|
|
55
|
+
|
|
56
|
+
<ul className="mt-6 divide-y divide-navy-50 overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm">
|
|
57
|
+
{rows.map((u) => {
|
|
58
|
+
const isSelf = u.id === Number(session.user.id);
|
|
59
|
+
const isAdmin = (u.role ?? "editor") === "admin";
|
|
60
|
+
return (
|
|
61
|
+
<li key={u.id} className="space-y-3 p-4">
|
|
62
|
+
<div className="flex items-center justify-between gap-3">
|
|
63
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
64
|
+
<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">
|
|
65
|
+
{u.name?.[0] ?? u.email[0]}
|
|
66
|
+
</span>
|
|
67
|
+
<div className="min-w-0">
|
|
68
|
+
<p className="flex items-center gap-2 font-medium text-navy-900">
|
|
69
|
+
<span className="truncate">{u.name}</span>
|
|
70
|
+
{isSelf && (
|
|
71
|
+
<span className="rounded bg-navy-100 px-1.5 py-0.5 text-[10px] font-medium text-navy-500">
|
|
72
|
+
Anda
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</p>
|
|
76
|
+
<p className="truncate text-xs text-muted-foreground">{u.email}</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<span
|
|
80
|
+
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ring-1 ${
|
|
81
|
+
isAdmin
|
|
82
|
+
? "bg-gold-100 text-gold-700 ring-gold-200"
|
|
83
|
+
: "bg-brand-50 text-brand-700 ring-brand-100"
|
|
84
|
+
}`}
|
|
85
|
+
>
|
|
86
|
+
{u.role ?? "editor"}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="flex flex-wrap items-center gap-2 border-t border-navy-50 pt-3">
|
|
91
|
+
<form action={setRoleAction} className="flex items-center gap-2">
|
|
92
|
+
<input type="hidden" name="id" value={u.id} />
|
|
93
|
+
<select name="role" defaultValue={u.role ?? "editor"} className="h-8 rounded-md border border-navy-200 bg-white px-2 text-xs">
|
|
94
|
+
<option value="editor">Editor</option>
|
|
95
|
+
<option value="admin">Admin</option>
|
|
96
|
+
</select>
|
|
97
|
+
<Button size="sm" variant="outline" type="submit">Set Role</Button>
|
|
98
|
+
</form>
|
|
99
|
+
<form action={resetPasswordAction} className="flex items-center gap-2">
|
|
100
|
+
<input type="hidden" name="id" value={u.id} />
|
|
101
|
+
<Input name="password" type="password" placeholder="Password baru" className="h-8 w-36" />
|
|
102
|
+
<Button size="sm" variant="outline" type="submit">
|
|
103
|
+
<KeyRound className="h-3.5 w-3.5" />
|
|
104
|
+
Reset
|
|
105
|
+
</Button>
|
|
106
|
+
</form>
|
|
107
|
+
{!isSelf && (
|
|
108
|
+
<div className="ml-auto">
|
|
109
|
+
<ConfirmDelete
|
|
110
|
+
action={deleteUserAction}
|
|
111
|
+
id={u.id}
|
|
112
|
+
title="Hapus user?"
|
|
113
|
+
description={
|
|
114
|
+
<>
|
|
115
|
+
User <span className="font-medium text-navy-900">{u.email}</span> akan dihapus.
|
|
116
|
+
</>
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { auth } from "../auth/index";
|
|
3
|
+
import { Toaster } from "sonner";
|
|
4
|
+
import { AdminSidebar, type NavItem } from "./sidebar";
|
|
5
|
+
|
|
6
|
+
export async function AdminLayout({
|
|
7
|
+
navItems,
|
|
8
|
+
children,
|
|
9
|
+
logoSrc,
|
|
10
|
+
brandName,
|
|
11
|
+
}: {
|
|
12
|
+
navItems: NavItem[];
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
logoSrc?: string;
|
|
15
|
+
brandName?: string;
|
|
16
|
+
}) {
|
|
17
|
+
const session = await auth();
|
|
18
|
+
|
|
19
|
+
// The only unauthenticated route reachable here is /admin/login: the proxy
|
|
20
|
+
// redirects every other /admin/* to login, and each admin page additionally
|
|
21
|
+
// calls requireUser()/requireAdmin() before touching data.
|
|
22
|
+
// So rendering bare children here (no shell) cannot leak protected content.
|
|
23
|
+
if (!session?.user) {
|
|
24
|
+
return <>{children}</>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex min-h-screen bg-navy-50/60">
|
|
29
|
+
<AdminSidebar role={session.user.role} navItems={navItems} logoSrc={logoSrc} brandName={brandName} />
|
|
30
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
31
|
+
<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">
|
|
32
|
+
<span className="text-sm font-medium text-navy-500">Panel Admin</span>
|
|
33
|
+
<div className="flex items-center gap-2.5">
|
|
34
|
+
<span className="hidden text-sm text-navy-600 sm:inline">
|
|
35
|
+
{session.user.email}
|
|
36
|
+
</span>
|
|
37
|
+
<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">
|
|
38
|
+
{session.user.role}
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
<main className="flex-1 p-6 md:p-8">{children}</main>
|
|
43
|
+
</div>
|
|
44
|
+
<Toaster />
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|