add menu musik

This commit is contained in:
2026-02-26 21:32:33 +08:00
parent a152eaf984
commit 55ea3c473a
13 changed files with 1927 additions and 2 deletions

View 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;

View 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>
);
}

View 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 &quot;{data.judul}&quot;?
</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>
);
}

View 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>
);
}

View 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;