diff --git a/next.config.ts b/next.config.ts index 29d246d6..6f02c9d8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -19,7 +19,6 @@ const nextConfig: NextConfig = { }, ]; }, - }; export default nextConfig; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a9168cd..4519bad8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,8 +60,9 @@ model FileStorage { deletedAt DateTime? isActive Boolean @default(true) link String - category String // "image" / "document" / "other" - Berita Berita[] + category String // "image" / "document" / "audio" / "other" + Berita Berita[] @relation("BeritaFeaturedImage") + BeritaImages Berita[] @relation("BeritaImages") PotensiDesa PotensiDesa[] Posyandu Posyandu[] StrukturPPID StrukturPPID[] @@ -102,6 +103,9 @@ model FileStorage { ArtikelKesehatan ArtikelKesehatan[] StrukturBumDes StrukturBumDes[] + + MusikDesaAudio MusikDesa[] @relation("MusikAudioFile") + MusikDesaCover MusikDesa[] @relation("MusikCoverImage") } //========================================= MENU LANDING PAGE ========================================= // @@ -205,16 +209,22 @@ model APBDesItem { kode String // contoh: "4", "4.1", "4.1.2" uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha" anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS) - realisasi Float - selisih Float // realisasi - anggaran - persentase Float - tipe String? // (realisasi / anggaran) * 100 + tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail parentId String? // untuk relasi hierarki parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id]) children APBDesItem[] @relation("APBDesItemParent") apbdesId String apbdes APBDes @relation(fields: [apbdesId], references: [id]) + + // Field kalkulasi (auto-calculated dari realisasi items) + totalRealisasi Float @default(0) // Sum dari semua realisasi + selisih Float @default(0) // totalRealisasi - anggaran + persentase Float @default(0) // (totalRealisasi / anggaran) * 100 + + // Relasi ke realisasi items + realisasiItems RealisasiItem[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -225,6 +235,28 @@ model APBDesItem { @@index([apbdesId]) } +// Model baru untuk multiple realisasi per item +model RealisasiItem { + id String @id @default(cuid()) + kode String? // Kode realisasi, mirip dengan APBDesItem + apbdesItemId String + apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade) + + jumlah Float // Jumlah realisasi dalam Rupiah + tanggal DateTime @db.Date // Tanggal realisasi + keterangan String? @db.Text // Keterangan tambahan (opsional) + buktiFileId String? // FileStorage ID untuk bukti/foto (opsional) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + isActive Boolean @default(true) + + @@index([kode]) + @@index([apbdesItemId]) + @@index([tanggal]) +} + //========================================= PRESTASI DESA ========================================= // model PrestasiDesa { id String @id @default(cuid()) @@ -609,15 +641,19 @@ model Berita { id String @id @default(cuid()) judul String deskripsi String - image FileStorage? @relation(fields: [imageId], references: [id]) + image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id]) imageId String? + images FileStorage[] @relation("BeritaImages") content String @db.Text + linkVideo String? @db.VarChar(500) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime @default(now()) isActive Boolean @default(true) kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id]) kategoriBeritaId String? + + @@index([kategoriBeritaId]) } model KategoriBerita { @@ -2263,3 +2299,25 @@ model UserMenuAccess { @@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali } + +// ========================================= MUSIK DESA ========================================= // +model MusikDesa { + id String @id @default(cuid()) + judul String @db.VarChar(255) + artis String @db.VarChar(255) + deskripsi String? @db.Text + durasi String @db.VarChar(20) // format: "MM:SS" + audioFile FileStorage? @relation("MusikAudioFile", fields: [audioFileId], references: [id]) + audioFileId String? + coverImage FileStorage? @relation("MusikCoverImage", fields: [coverImageId], references: [id]) + coverImageId String? + genre String? @db.VarChar(100) + tahunRilis Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + isActive Boolean @default(true) + + @@index([judul]) + @@index([artis]) +} diff --git a/public/mp3-logo.png b/public/mp3-logo.png new file mode 100644 index 00000000..97b75edf Binary files /dev/null and b/public/mp3-logo.png differ diff --git a/src/app/admin/(dashboard)/_state/desa/berita.ts b/src/app/admin/(dashboard)/_state/desa/berita.ts index 0a7dc17e..b4ba99b8 100644 --- a/src/app/admin/(dashboard)/_state/desa/berita.ts +++ b/src/app/admin/(dashboard)/_state/desa/berita.ts @@ -12,6 +12,8 @@ const templateForm = z.object({ content: z.string().min(3, "Content minimal 3 karakter"), kategoriBeritaId: z.string().nonempty(), imageId: z.string().nonempty(), + imageIds: z.array(z.string()), + linkVideo: z.string().optional(), }); // 2. Default value form berita (hindari uncontrolled input) @@ -21,6 +23,8 @@ const defaultForm = { imageId: "", content: "", kategoriBeritaId: "", + imageIds: [] as string[], + linkVideo: "", }; // 4. Berita proxy @@ -62,14 +66,7 @@ const berita = proxy({ // State untuk berita utama (hanya 1) findMany: { - data: null as - | Prisma.BeritaGetPayload<{ - include: { - image: true; - kategoriBerita: true; - }; - }>[] - | null, + data: null as any[] | null, page: 1, totalPages: 1, loading: false, @@ -79,14 +76,14 @@ const berita = proxy({ berita.findMany.loading = true; berita.findMany.page = page; berita.findMany.search = search; - + try { const query: any = { page, limit }; if (search) query.search = search; if (kategori) query.kategori = kategori; - + const res = await ApiFetch.api.desa.berita["find-many"].get({ query }); - + if (res.status === 200 && res.data?.success) { berita.findMany.data = res.data.data ?? []; berita.findMany.totalPages = res.data.totalPages ?? 1; @@ -103,18 +100,19 @@ const berita = proxy({ const elapsed = Date.now() - startTime; const minDelay = 300; const delay = elapsed < minDelay ? minDelay - elapsed : 0; - + setTimeout(() => { berita.findMany.loading = false; }, delay); } - }, + }, }, findUnique: { data: null as Prisma.BeritaGetPayload<{ include: { image: true; + images: true; kategoriBerita: true; }; }> | null, @@ -199,6 +197,8 @@ const berita = proxy({ content: data.content, kategoriBeritaId: data.kategoriBeritaId || "", imageId: data.imageId || "", + imageIds: data.images?.map((img: any) => img.id) || [], + linkVideo: data.linkVideo || "", }; return data; // Return the loaded data } else { @@ -237,6 +237,8 @@ const berita = proxy({ content: this.form.content, kategoriBeritaId: this.form.kategoriBeritaId || null, imageId: this.form.imageId, + imageIds: this.form.imageIds, + linkVideo: this.form.linkVideo, }), }); diff --git a/src/app/admin/(dashboard)/_state/desa/musik.ts b/src/app/admin/(dashboard)/_state/desa/musik.ts new file mode 100644 index 00000000..391bc269 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/desa/musik.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import ApiFetch from "@/lib/api-fetch"; +import { Prisma } from "@prisma/client"; +import { toast } from "react-toastify"; +import { proxy } from "valtio"; +import { z } from "zod"; + +// 1. Schema validasi dengan Zod +const templateForm = z.object({ + judul: z.string().min(3, "Judul minimal 3 karakter"), + artis: z.string().min(3, "Artis minimal 3 karakter"), + deskripsi: z.string().optional(), + durasi: z.string().min(3, "Durasi minimal 3 karakter"), + audioFileId: z.string().nonempty(), + coverImageId: z.string().nonempty(), + genre: z.string().optional(), + tahunRilis: z.number().optional().or(z.literal(undefined)), +}); + +// 2. Default value form musik +const defaultForm = { + judul: "", + artis: "", + deskripsi: "", + durasi: "", + audioFileId: "", + coverImageId: "", + genre: "", + tahunRilis: undefined as number | undefined, +}; + +// 3. Musik proxy +const musik = proxy({ + create: { + form: { ...defaultForm }, + loading: false, + async create() { + const cek = templateForm.safeParse(musik.create.form); + if (!cek.success) { + const err = `[${cek.error.issues + .map((v) => `${v.path.join(".")}`) + .join("\n")}] required`; + return toast.error(err); + } + + try { + musik.create.loading = true; + const res = await ApiFetch.api.desa.musik["create"].post( + musik.create.form + ); + if (res.status === 200) { + musik.findMany.load(); + return toast.success("Musik berhasil disimpan!"); + } + + return toast.error("Gagal menyimpan musik"); + } catch (error) { + console.log((error as Error).message); + } finally { + musik.create.loading = false; + } + }, + resetForm() { + musik.create.form = { ...defaultForm }; + }, + }, + + findMany: { + data: null as + | Prisma.MusikDesaGetPayload<{ + include: { + audioFile: true; + coverImage: true; + }; + }>[] + | null, + page: 1, + totalPages: 1, + loading: false, + search: "", + load: async (page = 1, limit = 10, search = "", genre = "") => { + const startTime = Date.now(); + musik.findMany.loading = true; + musik.findMany.page = page; + musik.findMany.search = search; + + try { + const query: any = { page, limit }; + if (search) query.search = search; + if (genre) query.genre = genre; + + const res = await ApiFetch.api.desa.musik["find-many"].get({ query }); + + if (res.status === 200 && res.data?.success) { + musik.findMany.data = res.data.data ?? []; + musik.findMany.totalPages = res.data.totalPages ?? 1; + } else { + musik.findMany.data = []; + musik.findMany.totalPages = 1; + } + } catch (err) { + console.error("Gagal fetch musik paginated:", err); + musik.findMany.data = []; + musik.findMany.totalPages = 1; + } finally { + const elapsed = Date.now() - startTime; + const minDelay = 300; + const delay = elapsed < minDelay ? minDelay - elapsed : 0; + + setTimeout(() => { + musik.findMany.loading = false; + }, delay); + } + }, + }, + + findUnique: { + data: null as Prisma.MusikDesaGetPayload<{ + include: { + audioFile: true; + coverImage: true; + }; + }> | null, + loading: false, + async load(id: string) { + try { + musik.findUnique.loading = true; + const res = await fetch(`/api/desa/musik/${id}`); + if (res.ok) { + const data = await res.json(); + musik.findUnique.data = data.data ?? null; + } else { + console.error("Failed to fetch musik:", res.statusText); + musik.findUnique.data = null; + } + } catch (error) { + console.error("Error fetching musik:", error); + musik.findUnique.data = null; + } finally { + musik.findUnique.loading = false; + } + }, + }, + delete: { + loading: false, + async byId(id: string) { + if (!id) return toast.warn("ID tidak valid"); + + try { + musik.delete.loading = true; + + const response = await fetch(`/api/desa/musik/delete/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + const result = await response.json(); + + if (response.ok && result?.success) { + toast.success(result.message || "Musik berhasil dihapus"); + await musik.findMany.load(); + } else { + toast.error(result?.message || "Gagal menghapus musik"); + } + } catch (error) { + console.error("Gagal delete:", error); + toast.error("Terjadi kesalahan saat menghapus musik"); + } finally { + musik.delete.loading = false; + } + }, + }, + edit: { + id: "", + form: { ...defaultForm }, + loading: false, + + async load(id: string) { + if (!id) { + toast.warn("ID tidak valid"); + return null; + } + + try { + const response = await fetch(`/api/desa/musik/${id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result?.success) { + const data = result.data; + this.id = data.id; + this.form = { + judul: data.judul, + artis: data.artis, + deskripsi: data.deskripsi || "", + durasi: data.durasi, + audioFileId: data.audioFileId || "", + coverImageId: data.coverImageId || "", + genre: data.genre || "", + tahunRilis: data.tahunRilis || undefined, + }; + return data; + } else { + throw new Error(result?.message || "Gagal memuat data"); + } + } catch (error) { + console.error("Error loading musik:", error); + toast.error( + error instanceof Error ? error.message : "Gagal memuat data" + ); + return null; + } + }, + + async update() { + const cek = templateForm.safeParse(musik.edit.form); + if (!cek.success) { + const err = `[${cek.error.issues + .map((v) => `${v.path.join(".")}`) + .join("\n")}] required`; + toast.error(err); + return false; + } + + try { + musik.edit.loading = true; + + const response = await fetch(`/api/desa/musik/${this.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + judul: this.form.judul, + artis: this.form.artis, + deskripsi: this.form.deskripsi, + durasi: this.form.durasi, + audioFileId: this.form.audioFileId, + coverImageId: this.form.coverImageId, + genre: this.form.genre, + tahunRilis: this.form.tahunRilis, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const result = await response.json(); + + if (result.success) { + toast.success("Musik berhasil diupdate"); + await musik.findMany.load(); + return true; + } else { + throw new Error(result.message || "Gagal update musik"); + } + } catch (error) { + console.error("Error updating musik:", error); + toast.error( + error instanceof Error + ? error.message + : "Terjadi kesalahan saat update musik" + ); + return false; + } finally { + musik.edit.loading = false; + } + }, + + reset() { + musik.edit.id = ""; + musik.edit.form = { ...defaultForm }; + }, + }, +}); + +// 4. State global +const stateDashboardMusik = proxy({ + musik, +}); + +export default stateDashboardMusik; diff --git a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts index 9b5cb438..7e05b86e 100644 --- a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts +++ b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts @@ -5,20 +5,20 @@ import { toast } from "react-toastify"; import { proxy } from "valtio"; import { z } from "zod"; -// --- Zod Schema --- +// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) --- const ApbdesItemSchema = z.object({ kode: z.string().min(1, "Kode wajib diisi"), uraian: z.string().min(1, "Uraian wajib diisi"), - anggaran: z.number().min(0), - realisasi: z.number().min(0), - selisih: z.number(), - persentase: z.number(), + anggaran: z.number().min(0, "Anggaran tidak boleh negatif"), level: z.number().int().min(1).max(3), tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(), }); const ApbdesFormSchema = z.object({ tahun: z.number().int().min(2000, "Tahun tidak valid"), + name: z.string().optional(), + deskripsi: z.string().optional(), + jumlah: z.string().optional(), imageId: z.string().min(1, "Gambar wajib diunggah"), fileId: z.string().min(1, "File wajib diunggah"), items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"), @@ -27,31 +27,22 @@ const ApbdesFormSchema = z.object({ // --- Default Form --- const defaultApbdesForm = { tahun: new Date().getFullYear(), + name: "", + deskripsi: "", + jumlah: "", imageId: "", fileId: "", items: [] as z.infer[], }; -// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) --- -// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) --- +// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) --- function normalizeItem(item: Partial>): z.infer { - const anggaran = item.anggaran ?? 0; - const realisasi = item.realisasi ?? 0; - - - // ✅ Formula yang benar - const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget - const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran - return { kode: item.kode || "", uraian: item.uraian || "", - anggaran, - realisasi, - selisih, - persentase, + anggaran: item.anggaran ?? 0, level: item.level || 1, - tipe: item.tipe, // biarkan null jika memang null + tipe: item.tipe ?? null, }; } @@ -158,33 +149,37 @@ const apbdes = proxy({ findUnique: { data: null as | Prisma.APBDesGetPayload<{ - include: { image: true; file: true; items: true }; + include: { image: true; file: true; items: { include: { realisasiItems: true } } }; }> | null, loading: false, error: null as string | null, - + async load(id: string) { if (!id || id.trim() === '') { this.data = null; this.error = "ID tidak valid"; return; } - + + // Prevent multiple simultaneous loads + if (this.loading) { + console.log("⚠️ Already loading, skipping..."); + return; + } + this.loading = true; this.error = null; - + try { - // Pastikan URL-nya benar const url = `/api/landingpage/apbdes/${id}`; console.log("🌐 Fetching:", url); - - // Gunakan fetch biasa atau ApiFetch dengan cara yang benar + const response = await fetch(url); const res = await response.json(); - + console.log("📦 Response:", res); - + if (res.success && res.data) { this.data = res.data; } else { @@ -244,15 +239,15 @@ const apbdes = proxy({ this.id = data.id; this.form = { tahun: data.tahun || new Date().getFullYear(), + name: data.name || "", + deskripsi: data.deskripsi || "", + jumlah: data.jumlah || "", imageId: data.imageId || "", fileId: data.fileId || "", items: (data.items || []).map((item: any) => ({ kode: item.kode, uraian: item.uraian, anggaran: item.anggaran, - realisasi: item.realisasi, - selisih: item.selisih, - persentase: item.persentase, level: item.level, tipe: item.tipe || 'pendapatan', })), @@ -317,6 +312,82 @@ const apbdes = proxy({ this.form = { ...defaultApbdesForm }; }, }, + + // ========================================= + // REALISASI STATE MANAGEMENT + // ========================================= + realisasi: { + // Create realisasi + async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) { + try { + const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data); + + if (res.data?.success) { + toast.success("Realisasi berhasil ditambahkan"); + // Reload findUnique untuk update data + const currentId = apbdes.findUnique.data?.id; + if (currentId) { + await apbdes.findUnique.load(currentId); + } + return true; + } else { + toast.error(res.data?.message || "Gagal menambahkan realisasi"); + return false; + } + } catch (error: any) { + console.error("Create realisasi error:", error); + toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi"); + return false; + } + }, + + // Update realisasi + async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) { + try { + const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data); + + if (res.data?.success) { + toast.success("Realisasi berhasil diperbarui"); + // Reload findUnique untuk update data + const currentId = apbdes.findUnique.data?.id; + if (currentId) { + await apbdes.findUnique.load(currentId); + } + return true; + } else { + toast.error(res.data?.message || "Gagal memperbarui realisasi"); + return false; + } + } catch (error: any) { + console.error("Update realisasi error:", error); + toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi"); + return false; + } + }, + + // Delete realisasi + async delete(realisasiId: string) { + try { + const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete(); + + if (res.data?.success) { + toast.success("Realisasi berhasil dihapus"); + // Reload findUnique untuk update data + if (apbdes.findUnique.data) { + await apbdes.findUnique.load(apbdes.findUnique.data.id); + } + return true; + } else { + toast.error(res.data?.message || "Gagal menghapus realisasi"); + return false; + } + } catch (error: any) { + console.error("Delete realisasi error:", error); + toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi"); + return false; + } + }, + }, }); export default apbdes; \ No newline at end of file diff --git a/src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx b/src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx index 414e2749..b98162f5 100644 --- a/src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/desa/berita/list-berita/[id]/edit/page.tsx @@ -9,6 +9,8 @@ import { ActionIcon, Box, Button, + Card, + Grid, Group, Image, Paper, @@ -17,7 +19,7 @@ import { Text, TextInput, Title, - Loader + Loader, } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; import { @@ -25,19 +27,51 @@ import { IconPhoto, IconUpload, IconX, + IconVideo, + IconTrash, } from "@tabler/icons-react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "react-toastify"; import { useProxy } from "valtio/utils"; +import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils'; + +interface ExistingImage { + id: string; + link: string; + name: string; +} + +interface BeritaData { + id: string; + judul: string; + deskripsi: string; + content: string; + kategoriBeritaId: string | null; + imageId: string | null; + image?: { link: string } | null; + images?: ExistingImage[]; + linkVideo?: string | null; +} function EditBerita() { const beritaState = useProxy(stateDashboardBerita); const router = useRouter(); const params = useParams(); + // Featured image state const [previewImage, setPreviewImage] = useState(null); const [file, setFile] = useState(null); + + // Gallery images state + const [existingGalleryImages, setExistingGalleryImages] = useState([]); + const [galleryFiles, setGalleryFiles] = useState([]); + const [galleryPreviews, setGalleryPreviews] = useState([]); + + // YouTube link state + const [youtubeLink, setYoutubeLink] = useState(''); + const [originalYoutubeLink, setOriginalYoutubeLink] = useState(''); + const [formData, setFormData] = useState({ judul: "", deskripsi: "", @@ -48,9 +82,17 @@ function EditBerita() { const [isSubmitting, setIsSubmitting] = useState(false); + const [originalData, setOriginalData] = useState({ + judul: "", + deskripsi: "", + kategoriBeritaId: "", + content: "", + imageId: "", + imageUrl: "" + }); + // Helper function to check if HTML content is empty const isHtmlEmpty = (html: string) => { - // Remove all HTML tags and check if there's any text content const textContent = html.replace(/<[^>]*>/g, '').trim(); return textContent === ''; }; @@ -61,21 +103,12 @@ function EditBerita() { formData.judul?.trim() !== '' && formData.kategoriBeritaId !== '' && !isHtmlEmpty(formData.deskripsi) && - (file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists + (file !== null || originalData.imageId !== '') && !isHtmlEmpty(formData.content) ); }; - const [originalData, setOriginalData] = useState({ - judul: "", - deskripsi: "", - kategoriBeritaId: "", - content: "", - imageId: "", - imageUrl: "" - }); - - // Load kategori + berita + // Load data useEffect(() => { beritaState.kategoriBerita.findMany.load(); @@ -84,7 +117,7 @@ function EditBerita() { if (!id) return; try { - const data = await stateDashboardBerita.berita.edit.load(id); + const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null; if (data) { setFormData({ judul: data.judul || "", @@ -106,6 +139,17 @@ function EditBerita() { if (data?.image?.link) { setPreviewImage(data.image.link); } + + // Load gallery images + if (data?.images && data.images.length > 0) { + setExistingGalleryImages(data.images); + } + + // Load YouTube link + if (data?.linkVideo) { + setYoutubeLink(data.linkVideo); + setOriginalYoutubeLink(data.linkVideo); + } } } catch (error) { console.error("Error loading berita:", error); @@ -120,27 +164,59 @@ function EditBerita() { setFormData((prev) => ({ ...prev, [field]: value })); }; + const handleGalleryDrop = (files: File[]) => { + const maxImages = 10; + const currentCount = existingGalleryImages.length + galleryFiles.length; + const availableSlots = maxImages - currentCount; + + if (availableSlots <= 0) { + toast.warn('Maksimal 10 gambar untuk galeri'); + return; + } + + const newFiles = files.slice(0, availableSlots); + + if (newFiles.length === 0) { + toast.warn('Tidak ada slot tersisa untuk gambar galeri'); + return; + } + + setGalleryFiles([...galleryFiles, ...newFiles]); + + const newPreviews = newFiles.map((f) => URL.createObjectURL(f)); + setGalleryPreviews([...galleryPreviews, ...newPreviews]); + }; + + const removeGalleryImage = (index: number, isExisting: boolean = false) => { + if (isExisting) { + setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index)); + } else { + setGalleryFiles(galleryFiles.filter((_, i) => i !== index)); + setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index)); + } + }; + const handleSubmit = async () => { if (!formData.judul?.trim()) { toast.error('Judul wajib diisi'); return; } - + if (!formData.kategoriBeritaId) { toast.error('Kategori wajib dipilih'); return; } - + if (isHtmlEmpty(formData.deskripsi)) { toast.error('Deskripsi singkat wajib diisi'); return; } - + if (!file && !originalData.imageId) { - toast.error('Gambar wajib dipilih'); + toast.error('Gambar utama wajib dipilih'); return; } - + if (isHtmlEmpty(formData.content)) { toast.error('Konten wajib diisi'); return; @@ -148,12 +224,14 @@ function EditBerita() { try { setIsSubmitting(true); - // Update global state hanya sekali di sini + + // Update global state beritaState.berita.edit.form = { ...beritaState.berita.edit.form, ...formData, }; + // Upload new featured image if changed if (file) { const res = await ApiFetch.api.fileStorage.create.post({ file, @@ -162,12 +240,33 @@ function EditBerita() { const uploaded = res.data?.data; if (!uploaded?.id) { - return toast.error("Gagal upload gambar"); + return toast.error("Gagal upload gambar utama"); } beritaState.berita.edit.form.imageId = uploaded.id; } + // Upload new gallery images + const newGalleryIds: string[] = []; + for (const galleryFile of galleryFiles) { + const galleryRes = await ApiFetch.api.fileStorage.create.post({ + file: galleryFile, + name: galleryFile.name, + }); + const galleryUploaded = galleryRes.data?.data; + if (galleryUploaded?.id) { + newGalleryIds.push(galleryUploaded.id); + } + } + + // Combine existing (not removed) and new gallery images + const remainingExistingIds = existingGalleryImages.map(img => img.id); + beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds]; + + // Set YouTube link + const embedLink = convertYoutubeUrlToEmbed(youtubeLink); + beritaState.berita.edit.form.linkVideo = embedLink || ''; + await beritaState.berita.edit.update(); toast.success("Berita berhasil diperbarui!"); router.push("/admin/desa/berita/list-berita"); @@ -189,9 +288,12 @@ function EditBerita() { }); setPreviewImage(originalData.imageUrl || null); setFile(null); + setYoutubeLink(originalYoutubeLink); toast.info("Form dikembalikan ke data awal"); }; + const embedLink = convertYoutubeUrlToEmbed(youtubeLink); + return ( {/* Header */} @@ -219,6 +321,7 @@ function EditBerita() { style={{ border: "1px solid #e0e0e0" }} > + {/* Judul */} + {/* Kategori */} + {/* Deskripsi */} Deskripsi Singkat @@ -194,9 +248,10 @@ export default function CreateBerita() { /> + {/* Featured Image */} - Gambar Berita + Gambar Utama (Featured) { @@ -232,17 +287,11 @@ export default function CreateBerita() { Preview Gambar - - {/* Tombol hapus (pojok kanan atas) */} @@ -265,6 +312,102 @@ export default function CreateBerita() { )} + {/* Gallery Images */} + + + Galeri Gambar (Opsional - Maksimal 10) + + toast.error('File tidak valid, gunakan format gambar')} + maxSize={5 * 1024 ** 2} + accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} + radius="md" + p="md" + multiple + > + + + + + + + + + + + + + Seret gambar atau klik untuk menambahkan ke galeri + + + + {galleryPreviews.length > 0 && ( + + {galleryPreviews.map((preview, index) => ( + + + {`Gallery + removeGalleryImage(index)} + style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }} + > + + + + + ))} + + )} + + + {/* YouTube Video */} + + + Link Video YouTube (Opsional) + + setYoutubeLink(e.currentTarget.value)} + leftSection={} + rightSection={ + youtubeLink && ( + setYoutubeLink('')} + > + + + ) + } + /> + {embedLink && ( + +