diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a9168cd..91e4a850 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,6 +102,9 @@ model FileStorage { ArtikelKesehatan ArtikelKesehatan[] StrukturBumDes StrukturBumDes[] + + MusikDesaAudio MusikDesa[] @relation("MusikAudioFile") + MusikDesaCover MusikDesa[] @relation("MusikCoverImage") } //========================================= MENU LANDING PAGE ========================================= // @@ -2263,3 +2266,25 @@ model UserMenuAccess { @@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali } + +// ========================================= MUSIK DESA ========================================= // +model MusikDesa { + id String @id @default(cuid()) + judul String @db.VarChar(255) + artis String @db.VarChar(255) + deskripsi String? @db.Text + durasi String @db.VarChar(20) // format: "MM:SS" + audioFile FileStorage? @relation("MusikAudioFile", fields: [audioFileId], references: [id]) + audioFileId String? + coverImage FileStorage? @relation("MusikCoverImage", fields: [coverImageId], references: [id]) + coverImageId String? + genre String? @db.VarChar(100) + tahunRilis Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + isActive Boolean @default(true) + + @@index([judul]) + @@index([artis]) +} diff --git a/src/app/admin/(dashboard)/_state/desa/musik.ts b/src/app/admin/(dashboard)/_state/desa/musik.ts new file mode 100644 index 00000000..391bc269 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/musik.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import ApiFetch from "@/lib/api-fetch"; +import { Prisma } from "@prisma/client"; +import { toast } from "react-toastify"; +import { proxy } from "valtio"; +import { z } from "zod"; + +// 1. Schema validasi dengan Zod +const templateForm = z.object({ + judul: z.string().min(3, "Judul minimal 3 karakter"), + artis: z.string().min(3, "Artis minimal 3 karakter"), + deskripsi: z.string().optional(), + durasi: z.string().min(3, "Durasi minimal 3 karakter"), + audioFileId: z.string().nonempty(), + coverImageId: z.string().nonempty(), + genre: z.string().optional(), + tahunRilis: z.number().optional().or(z.literal(undefined)), +}); + +// 2. Default value form musik +const defaultForm = { + judul: "", + artis: "", + deskripsi: "", + durasi: "", + audioFileId: "", + coverImageId: "", + genre: "", + tahunRilis: undefined as number | undefined, +}; + +// 3. Musik proxy +const musik = proxy({ + create: { + form: { ...defaultForm }, + loading: false, + async create() { + const cek = templateForm.safeParse(musik.create.form); + if (!cek.success) { + const err = `[${cek.error.issues + .map((v) => `${v.path.join(".")}`) + .join("\n")}] required`; + return toast.error(err); + } + + try { + musik.create.loading = true; + const res = await ApiFetch.api.desa.musik["create"].post( + musik.create.form + ); + if (res.status === 200) { + musik.findMany.load(); + return toast.success("Musik berhasil disimpan!"); + } + + return toast.error("Gagal menyimpan musik"); + } catch (error) { + console.log((error as Error).message); + } finally { + musik.create.loading = false; + } + }, + resetForm() { + musik.create.form = { ...defaultForm }; + }, + }, + + findMany: { + data: null as + | Prisma.MusikDesaGetPayload<{ + include: { + audioFile: true; + coverImage: true; + }; + }>[] + | null, + page: 1, + totalPages: 1, + loading: false, + search: "", + load: async (page = 1, limit = 10, search = "", genre = "") => { + const startTime = Date.now(); + musik.findMany.loading = true; + musik.findMany.page = page; + musik.findMany.search = search; + + try { + const query: any = { page, limit }; + if (search) query.search = search; + if (genre) query.genre = genre; + + const res = await ApiFetch.api.desa.musik["find-many"].get({ query }); + + if (res.status === 200 && res.data?.success) { + musik.findMany.data = res.data.data ?? []; + musik.findMany.totalPages = res.data.totalPages ?? 1; + } else { + musik.findMany.data = []; + musik.findMany.totalPages = 1; + } + } catch (err) { + console.error("Gagal fetch musik paginated:", err); + musik.findMany.data = []; + musik.findMany.totalPages = 1; + } finally { + const elapsed = Date.now() - startTime; + const minDelay = 300; + const delay = elapsed < minDelay ? minDelay - elapsed : 0; + + setTimeout(() => { + musik.findMany.loading = false; + }, delay); + } + }, + }, + + findUnique: { + data: null as Prisma.MusikDesaGetPayload<{ + include: { + audioFile: true; + coverImage: true; + }; + }> | null, + loading: false, + async load(id: string) { + try { + musik.findUnique.loading = true; + const res = await fetch(`/api/desa/musik/${id}`); + if (res.ok) { + const data = await res.json(); + musik.findUnique.data = data.data ?? null; + } else { + console.error("Failed to fetch musik:", res.statusText); + musik.findUnique.data = null; + } + } catch (error) { + console.error("Error fetching musik:", error); + musik.findUnique.data = null; + } finally { + musik.findUnique.loading = false; + } + }, + }, + delete: { + loading: false, + async byId(id: string) { + if (!id) return toast.warn("ID tidak valid"); + + try { + musik.delete.loading = true; + + const response = await fetch(`/api/desa/musik/delete/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + const result = await response.json(); + + if (response.ok && result?.success) { + toast.success(result.message || "Musik berhasil dihapus"); + await musik.findMany.load(); + } else { + toast.error(result?.message || "Gagal menghapus musik"); + } + } catch (error) { + console.error("Gagal delete:", error); + toast.error("Terjadi kesalahan saat menghapus musik"); + } finally { + musik.delete.loading = false; + } + }, + }, + edit: { + id: "", + form: { ...defaultForm }, + loading: false, + + async load(id: string) { + if (!id) { + toast.warn("ID tidak valid"); + return null; + } + + try { + const response = await fetch(`/api/desa/musik/${id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result?.success) { + const data = result.data; + this.id = data.id; + this.form = { + judul: data.judul, + artis: data.artis, + deskripsi: data.deskripsi || "", + durasi: data.durasi, + audioFileId: data.audioFileId || "", + coverImageId: data.coverImageId || "", + genre: data.genre || "", + tahunRilis: data.tahunRilis || undefined, + }; + return data; + } else { + throw new Error(result?.message || "Gagal memuat data"); + } + } catch (error) { + console.error("Error loading musik:", error); + toast.error( + error instanceof Error ? error.message : "Gagal memuat data" + ); + return null; + } + }, + + async update() { + const cek = templateForm.safeParse(musik.edit.form); + if (!cek.success) { + const err = `[${cek.error.issues + .map((v) => `${v.path.join(".")}`) + .join("\n")}] required`; + toast.error(err); + return false; + } + + try { + musik.edit.loading = true; + + const response = await fetch(`/api/desa/musik/${this.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + judul: this.form.judul, + artis: this.form.artis, + deskripsi: this.form.deskripsi, + durasi: this.form.durasi, + audioFileId: this.form.audioFileId, + coverImageId: this.form.coverImageId, + genre: this.form.genre, + tahunRilis: this.form.tahunRilis, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const result = await response.json(); + + if (result.success) { + toast.success("Musik berhasil diupdate"); + await musik.findMany.load(); + return true; + } else { + throw new Error(result.message || "Gagal update musik"); + } + } catch (error) { + console.error("Error updating musik:", error); + toast.error( + error instanceof Error + ? error.message + : "Terjadi kesalahan saat update musik" + ); + return false; + } finally { + musik.edit.loading = false; + } + }, + + reset() { + musik.edit.id = ""; + musik.edit.form = { ...defaultForm }; + }, + }, +}); + +// 4. State global +const stateDashboardMusik = proxy({ + musik, +}); + +export default stateDashboardMusik; diff --git a/src/app/admin/(dashboard)/desa/musik/[id]/edit/page.tsx b/src/app/admin/(dashboard)/desa/musik/[id]/edit/page.tsx new file mode 100644 index 00000000..06890b1e --- /dev/null +++ b/src/app/admin/(dashboard)/desa/musik/[id]/edit/page.tsx @@ -0,0 +1,385 @@ +'use client' +import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; +import stateDashboardMusik from '@/app/admin/(dashboard)/_state/desa/musik'; +import colors from '@/con/colors'; +import ApiFetch from '@/lib/api-fetch'; +import { + Box, + Button, + Card, + Center, + Group, + Image, + Paper, + Stack, + Text, + TextInput, + Title, + Loader, + ActionIcon, + NumberInput +} from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { useShallowEffect } from '@mantine/hooks'; +import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react'; +import { useRouter, useParams } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { useProxy } from 'valtio/utils'; + +export default function EditMusik() { + const musikState = useProxy(stateDashboardMusik); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [previewCover, setPreviewCover] = useState(null); + const [coverFile, setCoverFile] = useState(null); + const [previewAudio, setPreviewAudio] = useState(null); + const [audioFile, setAudioFile] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useShallowEffect(() => { + if (id) { + musikState.musik.edit.load(id).then(() => setIsLoading(false)); + } + }, [id]); + + const isFormValid = () => { + return ( + musikState.musik.edit.form.judul?.trim() !== '' && + musikState.musik.edit.form.artis?.trim() !== '' && + musikState.musik.edit.form.durasi?.trim() !== '' && + (coverFile !== null || musikState.musik.edit.form.coverImageId !== '') && + (audioFile !== null || musikState.musik.edit.form.audioFileId !== '') + ); + }; + + const resetForm = () => { + musikState.musik.edit.reset(); + setPreviewCover(null); + setCoverFile(null); + setPreviewAudio(null); + setAudioFile(null); + }; + + const handleSubmit = async () => { + if (!musikState.musik.edit.form.judul?.trim()) { + toast.error('Judul wajib diisi'); + return; + } + + if (!musikState.musik.edit.form.artis?.trim()) { + toast.error('Artis wajib diisi'); + return; + } + + if (!musikState.musik.edit.form.durasi?.trim()) { + toast.error('Durasi wajib diisi'); + return; + } + + try { + setIsSubmitting(true); + + // Upload cover image if new file selected + if (coverFile) { + const res = await ApiFetch.api.fileStorage.create.post({ + file: coverFile, + name: coverFile.name, + }); + + const uploaded = res.data?.data; + if (!uploaded?.id) { + return toast.error('Gagal mengunggah cover, silakan coba lagi'); + } + + musikState.musik.edit.form.coverImageId = uploaded.id; + } + + // Upload audio file if new file selected + if (audioFile) { + const res = await ApiFetch.api.fileStorage.create.post({ + file: audioFile, + name: audioFile.name, + }); + + const uploaded = res.data?.data; + if (!uploaded?.id) { + return toast.error('Gagal mengunggah audio, silakan coba lagi'); + } + + musikState.musik.edit.form.audioFileId = uploaded.id; + } + + await musikState.musik.edit.update(); + + resetForm(); + router.push('/admin/desa/musik'); + } catch (error) { + console.error('Error updating musik:', error); + toast.error('Terjadi kesalahan saat mengupdate musik'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + + {/* Header dengan tombol kembali */} + + + + Edit Musik + + + + + + (musikState.musik.edit.form.judul = e.target.value)} + required + /> + + (musikState.musik.edit.form.artis = e.target.value)} + required + /> + + + + Deskripsi + + { + musikState.musik.edit.form.deskripsi = htmlContent; + }} + /> + + + + (musikState.musik.edit.form.durasi = e.target.value)} + required + style={{ flex: 1 }} + /> + + (musikState.musik.edit.form.genre = e.target.value)} + style={{ flex: 1 }} + /> + + + (musikState.musik.edit.form.tahunRilis = val as number | undefined)} + min={1900} + max={new Date().getFullYear() + 1} + /> + + {/* Cover Image */} + + + Cover Image + + { + const selectedFile = files[0]; + if (selectedFile) { + setCoverFile(selectedFile); + setPreviewCover(URL.createObjectURL(selectedFile)); + } + }} + onReject={() => toast.error('File tidak valid, gunakan format gambar')} + maxSize={5 * 1024 ** 2} + accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} + radius="md" + p="xl" + > + + + + + + + + + + + + + Seret gambar atau klik untuk memilih file (maks 5MB) + + + + {(previewCover || musikState.musik.edit.form.coverImageId) && ( + + Preview Cover + + { + setPreviewCover(null); + setCoverFile(null); + musikState.musik.edit.form.coverImageId = ''; + }} + style={{ + boxShadow: '0 2px 6px rgba(0,0,0,0.15)', + }} + > + + + + )} + + + {/* Audio File */} + + + File Audio + + { + const selectedFile = files[0]; + if (selectedFile) { + setAudioFile(selectedFile); + setPreviewAudio(selectedFile.name); + } + }} + onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')} + maxSize={50 * 1024 ** 2} + accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }} + radius="md" + p="xl" + > + + + + + + + + + + + + + Seret file audio atau klik untuk memilih file (maks 50MB) + + + + {(previewAudio || musikState.musik.edit.form.audioFileId) && ( + + + + + + {previewAudio || 'File audio tersimpan'} + + { + setPreviewAudio(null); + setAudioFile(null); + musikState.musik.edit.form.audioFileId = ''; + }} + > + + + + + + )} + + + + + + + + + + + ); +} diff --git a/src/app/admin/(dashboard)/desa/musik/[id]/page.tsx b/src/app/admin/(dashboard)/desa/musik/[id]/page.tsx new file mode 100644 index 00000000..cb501b47 --- /dev/null +++ b/src/app/admin/(dashboard)/desa/musik/[id]/page.tsx @@ -0,0 +1,264 @@ +'use client' +import colors from '@/con/colors'; +import { + Box, + Button, + Card, + Center, + Group, + Image, + Modal, + Paper, + Skeleton, + Stack, + Text, + Title +} from '@mantine/core'; +import { useShallowEffect } from '@mantine/hooks'; +import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useProxy } from 'valtio/utils'; +import stateDashboardMusik from '../../../_state/desa/musik'; + +export default function DetailMusik() { + const musikState = useProxy(stateDashboardMusik); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { data, loading, load } = musikState.musik.findUnique; + + useShallowEffect(() => { + if (id) { + load(id); + } + }, [id]); + + if (loading || !data) { + return ( + + + + + + + ); + } + + if (!data) { + return ( + +
+ Musik tidak ditemukan +
+
+ ); + } + + const handleDelete = async () => { + try { + setIsDeleting(true); + await musikState.musik.delete.byId(id); + setShowDeleteModal(false); + router.push('/admin/desa/musik'); + } catch (error) { + console.error('Error deleting musik:', error); + } finally { + setIsDeleting(false); + } + }; + + return ( + + {/* Header dengan tombol kembali */} + + + + Detail Musik + + + + + + {/* Cover Image */} + {data.coverImage && ( + + {data.judul} + + )} + + {/* Info Section */} + + + + Judul + + + {data.judul} + + + + + + Artis + + + {data.artis} + + + + {data.deskripsi && ( + + + Deskripsi + + + + )} + + + + + Durasi + + + {data.durasi} + + + + {data.genre && ( + + + Genre + + + {data.genre} + + + )} + + {data.tahunRilis && ( + + + Tahun Rilis + + + {data.tahunRilis} + + + )} + + + {/* Audio File */} + {data.audioFile && ( + + + File Audio + + + + + {data.audioFile.realName} + + + + + + )} + + + {/* Action Buttons */} + + + + + + + + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + title="Konfirmasi Hapus" + centered + > + + + Apakah Anda yakin ingin menghapus musik "{data.judul}"? + + + Tindakan ini tidak dapat dibatalkan. + + + + + + + + + ); +} diff --git a/src/app/admin/(dashboard)/desa/musik/create/page.tsx b/src/app/admin/(dashboard)/desa/musik/create/page.tsx new file mode 100644 index 00000000..13c6cd0c --- /dev/null +++ b/src/app/admin/(dashboard)/desa/musik/create/page.tsx @@ -0,0 +1,383 @@ +'use client' +import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; +import stateDashboardMusik from '@/app/admin/(dashboard)/_state/desa/musik'; +import colors from '@/con/colors'; +import ApiFetch from '@/lib/api-fetch'; +import { + Box, + Button, + Card, + Group, + Image, + Paper, + Stack, + Text, + TextInput, + Title, + Loader, + ActionIcon, + NumberInput +} from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { useShallowEffect } from '@mantine/hooks'; +import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { useProxy } from 'valtio/utils'; + +export default function CreateMusik() { + const musikState = useProxy(stateDashboardMusik); + const [previewCover, setPreviewCover] = useState(null); + const [coverFile, setCoverFile] = useState(null); + const [previewAudio, setPreviewAudio] = useState(null); + const [audioFile, setAudioFile] = useState(null); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isFormValid = () => { + return ( + musikState.musik.create.form.judul?.trim() !== '' && + musikState.musik.create.form.artis?.trim() !== '' && + musikState.musik.create.form.durasi?.trim() !== '' && + audioFile !== null && + coverFile !== null + ); + }; + + useShallowEffect(() => { + return () => { + musikState.musik.create.resetForm(); + }; + }, []); + + const resetForm = () => { + musikState.musik.create.form = { + judul: '', + artis: '', + deskripsi: '', + durasi: '', + audioFileId: '', + coverImageId: '', + genre: '', + tahunRilis: undefined, + }; + setPreviewCover(null); + setCoverFile(null); + setPreviewAudio(null); + setAudioFile(null); + }; + + const handleSubmit = async () => { + if (!musikState.musik.create.form.judul?.trim()) { + toast.error('Judul wajib diisi'); + return; + } + + if (!musikState.musik.create.form.artis?.trim()) { + toast.error('Artis wajib diisi'); + return; + } + + if (!musikState.musik.create.form.durasi?.trim()) { + toast.error('Durasi wajib diisi'); + return; + } + + if (!coverFile) { + toast.error('Cover image wajib dipilih'); + return; + } + + if (!audioFile) { + toast.error('File audio wajib dipilih'); + return; + } + + try { + setIsSubmitting(true); + + // Upload cover image + const coverRes = await ApiFetch.api.fileStorage.create.post({ + file: coverFile, + name: coverFile.name, + }); + + const coverUploaded = coverRes.data?.data; + if (!coverUploaded?.id) { + return toast.error('Gagal mengunggah cover, silakan coba lagi'); + } + + musikState.musik.create.form.coverImageId = coverUploaded.id; + + // Upload audio file + const audioRes = await ApiFetch.api.fileStorage.create.post({ + file: audioFile, + name: audioFile.name, + }); + + const audioUploaded = audioRes.data?.data; + if (!audioUploaded?.id) { + return toast.error('Gagal mengunggah audio, silakan coba lagi'); + } + + musikState.musik.create.form.audioFileId = audioUploaded.id; + + await musikState.musik.create.create(); + + resetForm(); + router.push('/admin/desa/musik'); + } catch (error) { + console.error('Error creating musik:', error); + toast.error('Terjadi kesalahan saat membuat musik'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {/* Header dengan tombol kembali */} + + + + Tambah Musik + + + + + + (musikState.musik.create.form.judul = e.target.value)} + required + /> + + (musikState.musik.create.form.artis = e.target.value)} + required + /> + + + + Deskripsi + + { + musikState.musik.create.form.deskripsi = htmlContent; + }} + /> + + + + (musikState.musik.create.form.durasi = e.target.value)} + required + style={{ flex: 1 }} + /> + + (musikState.musik.create.form.genre = e.target.value)} + style={{ flex: 1 }} + /> + + + (musikState.musik.create.form.tahunRilis = val as number | undefined)} + min={1900} + max={new Date().getFullYear() + 1} + /> + + {/* Cover Image */} + + + Cover Image + + { + const selectedFile = files[0]; + if (selectedFile) { + setCoverFile(selectedFile); + setPreviewCover(URL.createObjectURL(selectedFile)); + } + }} + onReject={() => toast.error('File tidak valid, gunakan format gambar')} + maxSize={5 * 1024 ** 2} + accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} + radius="md" + p="xl" + > + + + + + + + + + + + + + Seret gambar atau klik untuk memilih file (maks 5MB) + + + + {previewCover && ( + + Preview Cover + + { + setPreviewCover(null); + setCoverFile(null); + }} + style={{ + boxShadow: '0 2px 6px rgba(0,0,0,0.15)', + }} + > + + + + )} + + + {/* Audio File */} + + + File Audio + + { + const selectedFile = files[0]; + if (selectedFile) { + setAudioFile(selectedFile); + setPreviewAudio(selectedFile.name); + } + }} + onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')} + maxSize={50 * 1024 ** 2} + accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }} + radius="md" + p="xl" + > + + + + + + + + + + + + + Seret file audio atau klik untuk memilih file (maks 50MB) + + + + {previewAudio && ( + + + + + + {previewAudio} + + { + setPreviewAudio(null); + setAudioFile(null); + }} + > + + + + + + )} + + + + + + + + + + + ); +} diff --git a/src/app/admin/(dashboard)/desa/musik/page.tsx b/src/app/admin/(dashboard)/desa/musik/page.tsx new file mode 100644 index 00000000..b87005b4 --- /dev/null +++ b/src/app/admin/(dashboard)/desa/musik/page.tsx @@ -0,0 +1,231 @@ +'use client' +import colors from '@/con/colors'; +import { + Box, + Button, + Center, + Group, + Pagination, + Paper, + Skeleton, + Stack, + Table, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + Text, + Title +} from '@mantine/core'; +import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; +import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useProxy } from 'valtio/utils'; +import HeaderSearch from '../../_com/header'; +import stateDashboardMusik from '../../_state/desa/musik'; + + +function Musik() { + const [search, setSearch] = useState(""); + return ( + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + + ); +} + +function ListMusik({ search }: { search: string }) { + const musikState = useProxy(stateDashboardMusik); + const router = useRouter(); + const [debouncedSearch] = useDebouncedValue(search, 1000); + + const { data, page, totalPages, loading, load } = musikState.musik.findMany; + + useShallowEffect(() => { + load(page, 10, debouncedSearch); + }, [page, debouncedSearch]); + + if (loading || !data) { + return ( + + + + ); + } + + const filteredData = data || []; + + return ( + + + + Daftar Musik + + + + {/* Desktop Table */} + + + + + Judul + Artis + Durasi + Genre + Aksi + + + + {filteredData.length > 0 ? ( + filteredData.map((item) => ( + + + + {item.judul} + + + + + {item.artis} + + + + + {item.durasi} + + + + + {item.genre || '-'} + + + + + + + )) + ) : ( + + +
+ + Tidak ada data musik yang cocok + +
+
+
+ )} +
+
+
+ + {/* Mobile Cards */} + + {filteredData.length > 0 ? ( + filteredData.map((item) => ( + + + + Judul + + + {item.judul} + + + + Artis + + + {item.artis} + + + + Durasi + + + {item.durasi} + + + + Genre + + + {item.genre || '-'} + + + + + + )) + ) : ( +
+ + Tidak ada data musik yang cocok + +
+ )} +
+
+ +
+ { + load(newPage, 10, debouncedSearch); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + total={totalPages} + mt="md" + mb="md" + color="blue" + radius="md" + /> +
+
+ ); +} + +export default Musik; diff --git a/src/app/api/[[...slugs]]/_lib/desa/index.ts b/src/app/api/[[...slugs]]/_lib/desa/index.ts index 91cc78ad..73e8b354 100644 --- a/src/app/api/[[...slugs]]/_lib/desa/index.ts +++ b/src/app/api/[[...slugs]]/_lib/desa/index.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import Berita from "./berita"; import Pengumuman from "./pengumuman"; import ProfileDesa from "./profile/profile_desa"; -import PotensiDesa from "./potensi"; +import PotensiDesa from "./potensi"; import GalleryFoto from "./gallery/foto"; import GalleryVideo from "./gallery/video"; import LayananDesa from "./layanan"; @@ -12,6 +12,7 @@ import KategoriBerita from "./berita/kategori-berita"; import KategoriPengumuman from "./pengumuman/kategori-pengumuman"; import MantanPerbekel from "./profile/profile-mantan-perbekel"; import AjukanPermohonan from "./layanan/ajukan_permohonan"; +import Musik from "./musik"; const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] }) @@ -28,6 +29,7 @@ const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] }) .use(KategoriBerita) .use(KategoriPengumuman) .use(AjukanPermohonan) - + .use(Musik) + export default Desa; diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/create.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/create.ts new file mode 100644 index 00000000..4a61af41 --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/create.ts @@ -0,0 +1,37 @@ +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; + +type FormCreate = { + judul: string; + artis: string; + deskripsi?: string; + durasi: string; + audioFileId: string; + coverImageId: string; + genre?: string; + tahunRilis?: number | null; +}; + +async function musikCreate(context: Context) { + const body = context.body as FormCreate; + + await prisma.musikDesa.create({ + data: { + judul: body.judul, + artis: body.artis, + deskripsi: body.deskripsi, + durasi: body.durasi, + audioFileId: body.audioFileId, + coverImageId: body.coverImageId, + genre: body.genre, + tahunRilis: body.tahunRilis, + }, + }); + + return { + success: true, + message: "Sukses menambahkan musik", + }; +} + +export default musikCreate; diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/del.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/del.ts new file mode 100644 index 00000000..1235986f --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/del.ts @@ -0,0 +1,54 @@ +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; +import path from "path"; + +const musikDelete = async (context: Context) => { + const { id } = context.params as { id: string }; + + const musik = await prisma.musikDesa.findUnique({ + where: { id }, + include: { audioFile: true, coverImage: true }, + }); + + if (!musik) return { status: 404, body: "Musik tidak ditemukan" }; + + // 1. HAPUS MUSIK DULU + await prisma.musikDesa.delete({ where: { id } }); + + // 2. HAPUS FILE AUDIO (jika ada) + if (musik.audioFile) { + try { + const fs = await import("fs/promises"); + const filePath = path.join(musik.audioFile.path, musik.audioFile.name); + await fs.unlink(filePath); + + await prisma.fileStorage.delete({ + where: { id: musik.audioFile.id }, + }); + } catch (error) { + console.error("Error deleting audio file:", error); + } + } + + // 3. HAPUS FILE COVER (jika ada) + if (musik.coverImage) { + try { + const fs = await import("fs/promises"); + const filePath = path.join(musik.coverImage.path, musik.coverImage.name); + await fs.unlink(filePath); + + await prisma.fileStorage.delete({ + where: { id: musik.coverImage.id }, + }); + } catch (error) { + console.error("Error deleting cover image:", error); + } + } + + return { + success: true, + message: "Musik dan file terkait berhasil dihapus", + }; +}; + +export default musikDelete; diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts new file mode 100644 index 00000000..8ebc92c7 --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts @@ -0,0 +1,66 @@ +import prisma from "@/lib/prisma"; + +export default async function findMusikById(request: Request) { + try { + const url = new URL(request.url); + const id = url.pathname.split("/").pop(); + + if (!id) { + return new Response( + JSON.stringify({ + success: false, + message: "ID tidak valid", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const data = await prisma.musikDesa.findUnique({ + where: { id }, + include: { + audioFile: true, + coverImage: true, + }, + }); + + if (!data) { + return new Response( + JSON.stringify({ + success: false, + message: "Musik tidak ditemukan", + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response( + JSON.stringify({ + success: true, + message: "Success fetch musik by ID", + data, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (e) { + console.error("Error fetching musik by ID:", e); + return new Response( + JSON.stringify({ + success: false, + message: "Gagal mengambil musik: " + (e instanceof Error ? e.message : 'Unknown error'), + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts new file mode 100644 index 00000000..23ef1d27 --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// /api/desa/musik/find-many.ts +import prisma from "@/lib/prisma"; +import { Context } from "elysia"; + +async function musikFindMany(context: Context) { + // Ambil parameter dari query + const page = Number(context.query.page) || 1; + const limit = Number(context.query.limit) || 10; + const search = (context.query.search as string) || ''; + const genre = (context.query.genre as string) || ''; + const skip = (page - 1) * limit; + + // Buat where clause + const where: any = { isActive: true }; + + // Filter berdasarkan genre (jika ada) + if (genre) { + where.genre = { + equals: genre, + mode: 'insensitive' + }; + } + + // Tambahkan pencarian (jika ada) + if (search) { + where.OR = [ + { judul: { contains: search, mode: 'insensitive' } }, + { artis: { contains: search, mode: 'insensitive' } }, + { deskripsi: { contains: search, mode: 'insensitive' } }, + { genre: { contains: search, mode: 'insensitive' } } + ]; + } + + try { + // Ambil data dan total count secara paralel + const [data, total] = await Promise.all([ + prisma.musikDesa.findMany({ + where, + include: { + audioFile: true, + coverImage: true, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.musikDesa.count({ where }), + ]); + + return { + success: true, + message: "Berhasil ambil data musik dengan pagination", + data, + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }; + } catch (e) { + console.error("Error di findMany paginated:", e); + return { + success: false, + message: "Gagal mengambil data musik", + }; + } +} + +export default musikFindMany; diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/index.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/index.ts new file mode 100644 index 00000000..df61a335 --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/index.ts @@ -0,0 +1,47 @@ +import Elysia, { t } from "elysia"; +import musikFindMany from "./find-many"; +import musikCreate from "./create"; +import musikDelete from "./del"; +import musikUpdate from "./updt"; +import findMusikById from "./find-by-id"; + +const Musik = new Elysia({ prefix: "/musik", tags: ["Desa/Musik"] }) + .get("/find-many", musikFindMany) + .get("/:id", async (context) => { + const response = await findMusikById(new Request(context.request)); + return response; + }) + .post("/create", musikCreate, { + body: t.Object({ + judul: t.String(), + artis: t.String(), + deskripsi: t.Optional(t.String()), + durasi: t.String(), + audioFileId: t.String(), + coverImageId: t.String(), + genre: t.Optional(t.String()), + tahunRilis: t.Optional(t.Number()), + }), + }) + .delete("/delete/:id", musikDelete) + .put( + "/:id", + async (context) => { + const response = await musikUpdate(context); + return response; + }, + { + body: t.Object({ + judul: t.String(), + artis: t.String(), + deskripsi: t.Optional(t.String()), + durasi: t.String(), + audioFileId: t.String(), + coverImageId: t.String(), + genre: t.Optional(t.String()), + tahunRilis: t.Optional(t.Number()), + }), + } + ); + +export default Musik; diff --git a/src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts b/src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts new file mode 100644 index 00000000..42d7647e --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts @@ -0,0 +1,65 @@ +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; + +type FormUpdate = { + judul: string; + artis: string; + deskripsi?: string; + durasi: string; + audioFileId: string; + coverImageId: string; + genre?: string; + tahunRilis?: number | null; +}; + +async function musikUpdate(context: Context) { + const { id } = context.params as { id: string }; + const body = context.body as FormUpdate; + + try { + const existing = await prisma.musikDesa.findUnique({ + where: { id }, + }); + + if (!existing) { + return { + status: 404, + body: { + success: false, + message: "Musik tidak ditemukan", + }, + }; + } + + const updated = await prisma.musikDesa.update({ + where: { id }, + data: { + judul: body.judul, + artis: body.artis, + deskripsi: body.deskripsi, + durasi: body.durasi, + audioFileId: body.audioFileId, + coverImageId: body.coverImageId, + genre: body.genre, + tahunRilis: body.tahunRilis, + }, + }); + + return { + success: true, + message: "Musik berhasil diupdate", + data: updated, + }; + } catch (error) { + console.error("Error updating musik:", error); + return { + status: 500, + body: { + success: false, + message: "Terjadi kesalahan saat mengupdate musik", + }, + }; + } +} + +export default musikUpdate;