Nico-25 Mei 2025:

Membuat create berita dan gambar
Membuat fungsi tombol di mana bisa menghapus konten sesuai idnya
This commit is contained in:
2025-05-25 11:33:50 +08:00
parent cf6a5422ec
commit 92de697ae0
8 changed files with 470 additions and 87 deletions

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// 1. Schema validasi dengan Zod
const templateForm = z.object({ const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"), judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"), deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
@@ -13,6 +14,16 @@ const templateForm = z.object({
imageId: z.string().nonempty(), imageId: z.string().nonempty(),
}); });
// 2. Default value form berita (hindari uncontrolled input)
const defaultForm = {
judul: "",
deskripsi: "",
imageId: "",
content: "",
kategoriBeritaId: "",
};
// 3. Kategori proxy
const category = proxy({ const category = proxy({
findMany: { findMany: {
data: null as data: null as
@@ -27,19 +38,10 @@ const category = proxy({
}, },
}); });
type BeritaForm = Prisma.BeritaGetPayload<{ // 4. Berita proxy
select: {
judul: true;
deskripsi: true;
imageId: true;
content: true;
kategoriBeritaId: true;
};
}>;
const berita = proxy({ const berita = proxy({
create: { create: {
form: {} as BeritaForm, form: { ...defaultForm }, // ✅ ini kunci fix-nya
loading: false, loading: false,
async create() { async create() {
const cek = templateForm.safeParse(berita.create.form); const cek = templateForm.safeParse(berita.create.form);
@@ -49,6 +51,7 @@ const berita = proxy({
.join("\n")}] required`; .join("\n")}] required`;
return toast.error(err); return toast.error(err);
} }
try { try {
berita.create.loading = true; berita.create.loading = true;
const res = await ApiFetch.api.desa.berita["create"].post( const res = await ApiFetch.api.desa.berita["create"].post(
@@ -56,7 +59,7 @@ const berita = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
berita.findMany.load(); berita.findMany.load();
return toast.success("succes create"); return toast.success("success create");
} }
return toast.error("failed create"); return toast.error("failed create");
@@ -66,25 +69,62 @@ const berita = proxy({
berita.create.loading = false; berita.create.loading = false;
} }
}, },
resetForm() {
berita.create.form = { ...defaultForm };
}, },
},
findMany: { findMany: {
data: null as data: null as
| Prisma.BeritaGetPayload<{ | Prisma.BeritaGetPayload<{
include: { include: {
image: true, image: true;
kategoriBerita: true kategoriBerita: true;
} };
}>[] }>[]
| null, | null,
async load() { async load() {
const res = await ApiFetch.api.desa.berita["find-many"].get(); const res = await ApiFetch.api.desa.berita["find-many"].get();
if (res.status === 200) { if (res.status === 200) {
berita.findMany.data = (res.data?.data as any) ?? []; berita.findMany.data = (res.data?.data ) ?? [];
} }
}, },
}, },
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
berita.delete.loading = true;
const response = await fetch(`/api/desa/berita/delete/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
}); });
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Berita berhasil dihapus");
await berita.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus berita");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus berita");
} finally {
berita.delete.loading = false;
}
},
},
});
// 5. State global
const stateDashboardBerita = proxy({ const stateDashboardBerita = proxy({
category, category,
berita, berita,

View File

@@ -34,6 +34,7 @@ import { useEffect } from 'react';
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
], ],
content: '', content: '',
immediatelyRender: false
}); });
useEffect(() => { useEffect(() => {

View File

@@ -1,16 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { Box, Button, Center, FileInput, Group, Image, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from '@mantine/core'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { ActionIcon, Box, Button, Center, FileInput, Flex, Image, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { IconImageInPicture } from '@tabler/icons-react'; import { IconEdit, IconImageInPicture, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateDashboardBerita from '../../_state/desa/berita'; import stateDashboardBerita from '../../_state/desa/berita';
import { BeritaEditor } from './_com/BeritaEditor'; import { BeritaEditor } from './_com/BeritaEditor';
import colors from '@/con/colors';
import { useState } from 'react';
import { toast } from 'react-toastify';
import ApiFetch from '@/lib/api-fetch';
function Page() { function Page() {
return ( return (
@@ -177,30 +178,30 @@ function BeritaCreate() {
<Box py={10}> <Box py={10}>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}>
<Stack gap={"xs"}> <Stack gap={"xs"}>
<TextInput
value={beritaState.berita.create.form.judul}
onChange={(val) => {
beritaState.berita.create.form.judul = val.target.value;
}}
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>}
placeholder="masukkan judul"
/>
<SelectCategory <SelectCategory
onChange={(val) => { onChange={(val) => {
beritaState.berita.create.form.kategoriBeritaId = val.id; beritaState.berita.create.form.kategoriBeritaId = val.id;
}} }}
/> />
<TextInput
value={beritaState.berita.create.form.judul}
onChange={(val) => {
beritaState.berita.create.form.judul = val.target.value;
}}
label={"Judul"}
placeholder="masukkan judul"
/>
<TextInput <TextInput
value={beritaState.berita.create.form.deskripsi} value={beritaState.berita.create.form.deskripsi}
onChange={(val) => { onChange={(val) => {
beritaState.berita.create.form.deskripsi = val.target.value; beritaState.berita.create.form.deskripsi = val.target.value;
}} }}
label={"Deskripsi"} label={<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>}
placeholder="masukkan deskripsi" placeholder="masukkan deskripsi"
/> />
<FileInput <FileInput
label="Upload Gambar" label={<Text fz={"sm"} fw={"bold"}>Upload Gambar</Text>}
value={file} value={file}
onChange={async (e) => { onChange={async (e) => {
if (!e) return; if (!e) return;
@@ -218,12 +219,13 @@ function BeritaCreate() {
<IconImageInPicture /> <IconImageInPicture />
</Center> </Center>
)} )}
<Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text>
<BeritaEditor <BeritaEditor
showSubmit={false} showSubmit={false}
onEditorReady={(ed) => setEditorInstance(ed)} onEditorReady={(ed) => setEditorInstance(ed)}
/> />
</Box>
<Button onClick={handleSubmit}>Simpan Berita</Button> <Button onClick={handleSubmit}>Simpan Berita</Button>
</Stack> </Stack>
</Paper> </Paper>
@@ -240,6 +242,10 @@ function BeritaList() {
beritaState.berita.findMany.load() beritaState.berita.findMany.load()
}, []) }, [])
const router = useRouter()
if (!beritaState.berita.findMany.data) return <Stack py={10}> if (!beritaState.berita.findMany.data) return <Stack py={10}>
{Array.from({ length: 10 }).map((v, k) => <Skeleton key={k} h={40} />)} {Array.from({ length: 10 }).map((v, k) => <Skeleton key={k} h={40} />)}
</Stack> </Stack>
@@ -252,6 +258,19 @@ function BeritaList() {
{beritaState.berita.findMany.data?.map((item) => ( {beritaState.berita.findMany.data?.map((item) => (
<Paper key={item.id} bg={colors['BG-trans']} p={'md'}> <Paper key={item.id} bg={colors['BG-trans']} p={'md'}>
<Box > <Box >
<Flex justify="flex-end" mt={10}>
<ActionIcon
onClick={() => beritaState.berita.delete.byId(item.id)}
disabled={beritaState.berita.delete.loading}
color={colors['blue-button']} variant='transparent'>
<IconX size={20} />
</ActionIcon>
<ActionIcon onClick={() => {
router.push("/desa/berita/edit");
}} color={colors['blue-button']} variant='transparent'>
<IconEdit size={20} />
</ActionIcon>
</Flex>
<Text fw={"bold"} fz={"sm"}> <Text fw={"bold"} fz={"sm"}>
Kategori Kategori
</Text> </Text>
@@ -267,7 +286,7 @@ function BeritaList() {
<Text fw={"bold"} fz={"sm"}> <Text fw={"bold"} fz={"sm"}>
Gambar Gambar
</Text> </Text>
<Image w={200} src={item.image?.link} alt="gambar" /> <Image w={{ base: 100, md: 150 }} src={item.image?.link} alt="gambar" />
</Box> </Box>
</Paper> </Paper>
))} ))}
@@ -278,30 +297,71 @@ function BeritaList() {
) )
} }
function SelectCategory({ onChange }: { function SelectCategory({
onChange,
}: {
onChange: (value: Prisma.KategoriBeritaGetPayload<{ onChange: (value: Prisma.KategoriBeritaGetPayload<{
select: { select: {
name: true, name: true;
id: true id: true;
} };
}>) => void }>) => void;
}) { }) {
const beritaState = useProxy(stateDashboardBerita) const categoryState = useProxy(stateDashboardBerita.category);
useShallowEffect(() => {
beritaState.category.findMany.load()
}, [])
if (!beritaState.category.findMany.data) return <Skeleton h={40} /> useShallowEffect(() => {
return <Group> categoryState.findMany.load();
<Select placeholder='pilih kategori' label={<Text fz={"sm"} fw={"bold"}>Pilih Kategori</Text>} data={beritaState.category.findMany.data.map((item) => ({ }, []);
value: item.id,
label: item.name if (!categoryState.findMany.data) {
}))} onChange={(v) => { return <Skeleton height={38} />;
const data = beritaState.category.findMany.data?.find((item) => item.id === v)
if (!data) return
onChange(data)
}} />
</Group>
} }
return (
<Select
label={<Text fz={"sm"} fw={"bold"}>Kategori</Text>}
placeholder="Pilih kategori"
data={categoryState.findMany.data.map((item) => ({
label: item.name,
value: item.id,
}))}
onChange={(val) => {
const selected = categoryState.findMany.data?.find((item) => item.id === val);
if (selected) {
onChange(selected);
}
}}
searchable
nothingFoundMessage="Tidak ditemukan"
/>
);
}
// function SelectCategory({ onChange }: {
// onChange: (value: Prisma.KategoriBeritaGetPayload<{
// select: {
// name: true,
// id: true
// }
// }>) => void
// }) {
// const beritaState = useProxy(stateDashboardBerita)
// useShallowEffect(() => {
// beritaState.category.findMany.load()
// }, [])
// if (!beritaState.category.findMany.data) return <Skeleton h={40} />
// return <Group>
// <Select placeholder='pilih kategori' label={<Text fz={"sm"} fw={"bold"}>Pilih Kategori</Text>} data={beritaState.category.findMany.data.map((item) => ({
// value: item.id,
// label: item.name
// }))} onChange={(v) => {
// const data = beritaState.category.findMany.data?.find((item) => item.id === v)
// if (!data) return
// onChange(data)
// }} />
// </Group>
// }
export default Page; export default Page;

View File

@@ -0,0 +1,55 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import fs from "fs/promises";
import path from "path";
const beritaDelete = async (context: Context) => {
const id = context.params?.id as string;
if (!id) {
return {
status: 400,
body: "ID tidak diberikan",
};
}
const berita = await prisma.berita.findUnique({
where: { id },
include: {
image: true,
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
},
});
if (!berita) {
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
// Hapus file gambar dari filesystem jika ada
if (berita.image) {
try {
const filePath = path.join(berita.image.path, berita.image.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: berita.image.id },
});
} catch (err) {
console.error("Gagal hapus file image:", err);
}
}
// Hapus berita dari DB
await prisma.berita.delete({
where: { id },
});
return {
success: true,
message: "Berita dan file terkait berhasil dihapus",
};
};
export default beritaDelete;

View File

@@ -2,6 +2,8 @@ import Elysia, { t } from "elysia";
import kategoriBeritaFindMany from "./category"; import kategoriBeritaFindMany from "./category";
import beritaFindMany from "./find-many"; import beritaFindMany from "./find-many";
import beritaCreate from "./create"; import beritaCreate from "./create";
import beritaDelete from "./del";
import beritaUpdate from "./updt";
const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] }) const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
.get("/category/find-many", kategoriBeritaFindMany) .get("/category/find-many", kategoriBeritaFindMany)
@@ -14,6 +16,16 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
content: t.String(), content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]), kategoriBeritaId: t.Union([t.String(), t.Null()]),
}), }),
})
.delete("/delete/:id", beritaDelete)
.put("/update/:id", beritaUpdate, {
body: t.Object({
judul: t.String(),
deskripsi: t.String(),
imageId: t.String(),
content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]),
}),
}); });
export default Berita; export default Berita;

View File

@@ -0,0 +1,153 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
type FormUpdate = Prisma.BeritaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
content: true;
kategoriBeritaId: true;
imageId: true;
};
}>;
// async function beritaUpdate(context: Context) {
// const body = (await context.body) as FormUpdate;
// const {
// id,
// judul,
// deskripsi,
// content,
// kategoriBeritaId,
// imageId,
// } = body;
// if (!id) {
// return {
// status: 400,
// body: "ID tidak boleh kosong",
// };
// }
// const existing = await prisma.berita.findUnique({
// where: { id },
// include: {
// image: true,
// },
// });
// if (!existing) {
// return {
// status: 404,
// body: "Berita tidak ditemukan",
// };
// }
// // Cek jika imageId diubah
// if (existing.imageId && existing.imageId !== imageId) {
// const oldImage = existing.image;
// if (oldImage) {
// try {
// const filePath = path.join(oldImage.path, oldImage.name);
// await fs.unlink(filePath); // hapus file dari filesystem
// await prisma.fileStorage.delete({
// where: { id: oldImage.id }, // hapus record dari DB
// });
// } catch (err) {
// console.error("Gagal hapus gambar lama:", err);
// }
// }
// }
// const updated = await prisma.berita.update({
// where: { id },
// data: {
// judul,
// deskripsi,
// content,
// kategoriBeritaId,
// imageId,
// },
// });
// return {
// success: true,
// message: "Berita berhasil diupdate (gambar lama juga dihapus jika ada)",
// data: updated,
// };
// }
// export default beritaUpdate;
async function beritaUpdate(context: Context) {
const id = context.params.id as string; // ambil dari URL
const body = (await context.body) as Omit<FormUpdate, "id">;
const {
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
} = body;
if (!id) {
return {
status: 400,
body: "ID tidak boleh kosong",
};
}
const existing = await prisma.berita.findUnique({
where: { id },
include: {
image: true,
},
});
if (!existing) {
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
if (existing.imageId && existing.imageId !== imageId) {
const oldImage = existing.image;
if (oldImage) {
try {
const filePath = path.join(oldImage.path, oldImage.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: oldImage.id },
});
} catch (err) {
console.error("Gagal hapus gambar lama:", err);
}
}
}
const updated = await prisma.berita.update({
where: { id },
data: {
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
},
});
return {
success: true,
message: "Berita berhasil diupdate",
data: updated,
};
}
export default beritaUpdate;

View File

@@ -0,0 +1,60 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import fs from "fs/promises";
import path from "path";
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
const fileStorageDelete = async (context: Context) => {
const { params } = context;
const id = params?.id as string;
if (!id) {
return {
status: 400,
body: "ID file tidak ditemukan",
};
}
if (!UPLOAD_DIR) {
return {
status: 500,
body: "UPLOAD_DIR belum dikonfigurasi",
};
}
// Cek file dari database
const file = await prisma.fileStorage.findUnique({
where: { id },
});
if (!file) {
return {
status: 404,
body: "File tidak ditemukan di database",
};
}
const filePath = path.join(file.path, file.name);
try {
// Hapus file dari filesystem
await fs.unlink(filePath);
} catch (err) {
console.error("Gagal hapus file:", err);
// Tetap lanjutkan hapus dari database meskipun file fisik tidak ditemukan
}
// Hapus dari database
await prisma.fileStorage.delete({
where: { id },
});
return {
message: "File berhasil dihapus",
deletedId: id,
};
};
export default fileStorageDelete;

View File

@@ -2,6 +2,7 @@ import Elysia, { t } from "elysia";
import fileStorageCreate from "./_lib/create"; import fileStorageCreate from "./_lib/create";
import fileStorageFindUnique from "./_lib/findUniq"; import fileStorageFindUnique from "./_lib/findUniq";
import { fileStorageFindMany } from "./_lib/findMany"; import { fileStorageFindMany } from "./_lib/findMany";
import fileStorageDelete from "./_lib/del";
const FileStorage = new Elysia({ const FileStorage = new Elysia({
prefix: "/api/fileStorage", prefix: "/api/fileStorage",
@@ -14,6 +15,7 @@ const FileStorage = new Elysia({
}), }),
}) })
.get("/findUnique/:name", fileStorageFindUnique) .get("/findUnique/:name", fileStorageFindUnique)
.get("/findMany", fileStorageFindMany); .get("/findMany", fileStorageFindMany)
.delete("/delete/:id", fileStorageDelete);
export default FileStorage; export default FileStorage;