add menu musik
This commit is contained in:
@@ -102,6 +102,9 @@ model FileStorage {
|
|||||||
|
|
||||||
ArtikelKesehatan ArtikelKesehatan[]
|
ArtikelKesehatan ArtikelKesehatan[]
|
||||||
StrukturBumDes StrukturBumDes[]
|
StrukturBumDes StrukturBumDes[]
|
||||||
|
|
||||||
|
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
|
||||||
|
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
|
||||||
}
|
}
|
||||||
|
|
||||||
//========================================= MENU LANDING PAGE ========================================= //
|
//========================================= MENU LANDING PAGE ========================================= //
|
||||||
@@ -2263,3 +2266,25 @@ model UserMenuAccess {
|
|||||||
|
|
||||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
@@ -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;
|
||||||
385
src/app/admin/(dashboard)/desa/musik/[id]/edit/page.tsx
Normal file
385
src/app/admin/(dashboard)/desa/musik/[id]/edit/page.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||||
|
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(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 (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||||
|
<Center>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Judul"
|
||||||
|
placeholder="Masukkan judul lagu"
|
||||||
|
value={musikState.musik.edit.form.judul}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.judul = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Artis"
|
||||||
|
placeholder="Masukkan nama artis"
|
||||||
|
value={musikState.musik.edit.form.artis}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.artis = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<CreateEditor
|
||||||
|
value={musikState.musik.edit.form.deskripsi}
|
||||||
|
onChange={(htmlContent) => {
|
||||||
|
musikState.musik.edit.form.deskripsi = htmlContent;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Durasi"
|
||||||
|
placeholder="Contoh: 3:45"
|
||||||
|
value={musikState.musik.edit.form.durasi}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.durasi = e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Genre"
|
||||||
|
placeholder="Contoh: Pop, Rock, Jazz"
|
||||||
|
value={musikState.musik.edit.form.genre}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.genre = e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun Rilis"
|
||||||
|
placeholder="Contoh: 2024"
|
||||||
|
value={musikState.musik.edit.form.tahunRilis}
|
||||||
|
onChange={(val) => (musikState.musik.edit.form.tahunRilis = val as number | undefined)}
|
||||||
|
min={1900}
|
||||||
|
max={new Date().getFullYear() + 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Cover Image
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk memilih file (maks 5MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{(previewCover || musikState.musik.edit.form.coverImageId) && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewCover || '/api/placeholder/200/200'}
|
||||||
|
alt="Preview Cover"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'contain',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
musikState.musik.edit.form.coverImageId = '';
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{(previewAudio || musikState.musik.edit.form.audioFileId) && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Card p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconMusic size={20} color={colors['blue-button']} />
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{previewAudio || 'File audio tersimpan'}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
musikState.musik.edit.form.audioFileId = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
src/app/admin/(dashboard)/desa/musik/[id]/page.tsx
Normal file
264
src/app/admin/(dashboard)/desa/musik/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
<Stack>
|
||||||
|
<Skeleton height={50} radius="md" />
|
||||||
|
<Skeleton height={400} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||||
|
<Center>
|
||||||
|
<Text c="dimmed">Musik tidak ditemukan</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.push('/admin/desa/musik')}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Detail Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Cover Image */}
|
||||||
|
{data.coverImage && (
|
||||||
|
<Box style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={data.coverImage.link}
|
||||||
|
alt={data.judul}
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 300,
|
||||||
|
objectFit: 'cover',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Judul
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={600}>
|
||||||
|
{data.judul}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Artis
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.artis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{data.deskripsi && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} dangerouslySetInnerHTML={{ __html: data.deskripsi }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="xl">
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Durasi
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.durasi}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{data.genre && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Genre
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.genre}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.tahunRilis && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Tahun Rilis
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.tahunRilis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
{data.audioFile && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Card mt="xs" p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{data.audioFile.realName}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={data.audioFile.link}
|
||||||
|
target="_blank"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Putar
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Group justify="right" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconTrash size={18} />}
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconEdit size={18} />}
|
||||||
|
onClick={() => router.push(`/admin/desa/musik/${id}/edit`)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
title="Konfirmasi Hapus"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>
|
||||||
|
Apakah Anda yakin ingin menghapus musik "{data.judul}"?
|
||||||
|
</Text>
|
||||||
|
<Text c="red" fz="sm">
|
||||||
|
Tindakan ini tidak dapat dibatalkan.
|
||||||
|
</Text>
|
||||||
|
<Group justify="right" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={isDeleting}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
src/app/admin/(dashboard)/desa/musik/create/page.tsx
Normal file
383
src/app/admin/(dashboard)/desa/musik/create/page.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||||
|
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(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 (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Judul"
|
||||||
|
placeholder="Masukkan judul lagu"
|
||||||
|
value={musikState.musik.create.form.judul}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.judul = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Artis"
|
||||||
|
placeholder="Masukkan nama artis"
|
||||||
|
value={musikState.musik.create.form.artis}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.artis = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<CreateEditor
|
||||||
|
value={musikState.musik.create.form.deskripsi}
|
||||||
|
onChange={(htmlContent) => {
|
||||||
|
musikState.musik.create.form.deskripsi = htmlContent;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Durasi"
|
||||||
|
placeholder="Contoh: 3:45"
|
||||||
|
value={musikState.musik.create.form.durasi}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.durasi = e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Genre"
|
||||||
|
placeholder="Contoh: Pop, Rock, Jazz"
|
||||||
|
value={musikState.musik.create.form.genre}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.genre = e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun Rilis"
|
||||||
|
placeholder="Contoh: 2024"
|
||||||
|
value={musikState.musik.create.form.tahunRilis}
|
||||||
|
onChange={(val) => (musikState.musik.create.form.tahunRilis = val as number | undefined)}
|
||||||
|
min={1900}
|
||||||
|
max={new Date().getFullYear() + 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Cover Image
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk memilih file (maks 5MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewCover && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewCover}
|
||||||
|
alt="Preview Cover"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'contain',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewAudio && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Card p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconMusic size={20} color={colors['blue-button']} />
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{previewAudio}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/app/admin/(dashboard)/desa/musik/page.tsx
Normal file
231
src/app/admin/(dashboard)/desa/musik/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title="Musik Desa"
|
||||||
|
placeholder="Cari judul, artis, atau genre..."
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListMusik search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Stack py="md">
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py="md">
|
||||||
|
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Title order={4}>Daftar Musik</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconCircleDashedPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/desa/musik/create')}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table highlightOnHover
|
||||||
|
layout="fixed"
|
||||||
|
withColumnBorders={false} miw={0}>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh w="30%">Judul</TableTh>
|
||||||
|
<TableTh w="20%">Artis</TableTh>
|
||||||
|
<TableTh w="15%">Durasi</TableTh>
|
||||||
|
<TableTh w="15%">Genre</TableTh>
|
||||||
|
<TableTh w="20%">Aksi</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||||
|
{item.judul}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.artis}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.durasi}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.genre || '-'}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/desa/musik/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="sm"
|
||||||
|
h={36}
|
||||||
|
>
|
||||||
|
<IconDeviceImacCog size={18} />
|
||||||
|
<Text ml="xs">Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={5}>
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data musik yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<Stack hiddenFrom="md" gap="sm" mt="sm">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder p="md" radius="md">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
|
||||||
|
Judul
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.45}>
|
||||||
|
{item.judul}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Artis
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.artis}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Durasi
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.durasi}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Genre
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.genre || '-'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/desa/musik/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
h={36}
|
||||||
|
>
|
||||||
|
<IconDeviceImacCog size={18} />
|
||||||
|
<Text ml="xs">Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data musik yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, debouncedSearch);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Musik;
|
||||||
@@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||||||
import Berita from "./berita";
|
import Berita from "./berita";
|
||||||
import Pengumuman from "./pengumuman";
|
import Pengumuman from "./pengumuman";
|
||||||
import ProfileDesa from "./profile/profile_desa";
|
import ProfileDesa from "./profile/profile_desa";
|
||||||
import PotensiDesa from "./potensi";
|
import PotensiDesa from "./potensi";
|
||||||
import GalleryFoto from "./gallery/foto";
|
import GalleryFoto from "./gallery/foto";
|
||||||
import GalleryVideo from "./gallery/video";
|
import GalleryVideo from "./gallery/video";
|
||||||
import LayananDesa from "./layanan";
|
import LayananDesa from "./layanan";
|
||||||
@@ -12,6 +12,7 @@ import KategoriBerita from "./berita/kategori-berita";
|
|||||||
import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
|
import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
|
||||||
import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
||||||
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
||||||
|
import Musik from "./musik";
|
||||||
|
|
||||||
|
|
||||||
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
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(KategoriBerita)
|
||||||
.use(KategoriPengumuman)
|
.use(KategoriPengumuman)
|
||||||
.use(AjukanPermohonan)
|
.use(AjukanPermohonan)
|
||||||
|
.use(Musik)
|
||||||
|
|
||||||
|
|
||||||
export default Desa;
|
export default Desa;
|
||||||
|
|||||||
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
@@ -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;
|
||||||
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
@@ -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;
|
||||||
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
@@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
@@ -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;
|
||||||
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
@@ -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;
|
||||||
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user