feat(berita): add multiple images gallery and YouTube video support

- Update schema: add images relation list and linkVideo field
- API: support multiple image upload and YouTube link in create/update
- Admin create page: add gallery upload (max 10) and YouTube embed preview
- Admin edit page: manage existing/new gallery images and YouTube link
- Admin detail page: display gallery grid and YouTube video embed
- Public detail page: show gallery images and YouTube video with responsive layout
- State: add imageIds[] and linkVideo fields with proper type handling
- Music player: fix seek functionality and ESLint warnings

Breaking changes:
- Prisma schema updated - requires migration
- API create/update endpoints now expect imageIds array and linkVideo string

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-02 16:06:53 +08:00
parent ae3187804e
commit a5bd91b580
12 changed files with 804 additions and 213 deletions

View File

@@ -61,7 +61,8 @@ model FileStorage {
isActive Boolean @default(true) isActive Boolean @default(true)
link String link String
category String // "image" / "document" / "audio" / "other" category String // "image" / "document" / "audio" / "other"
Berita Berita[] Berita Berita[] @relation("BeritaFeaturedImage")
BeritaImages Berita[] @relation("BeritaImages")
PotensiDesa PotensiDesa[] PotensiDesa PotensiDesa[]
Posyandu Posyandu[] Posyandu Posyandu[]
StrukturPPID StrukturPPID[] StrukturPPID StrukturPPID[]
@@ -612,15 +613,19 @@ model Berita {
id String @id @default(cuid()) id String @id @default(cuid())
judul String judul String
deskripsi String deskripsi String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
imageId String? imageId String?
images FileStorage[] @relation("BeritaImages")
content String @db.Text content String @db.Text
linkVideo String? @db.VarChar(500)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id]) kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
kategoriBeritaId String? kategoriBeritaId String?
@@index([kategoriBeritaId])
} }
model KategoriBerita { model KategoriBerita {

View File

@@ -12,6 +12,8 @@ const templateForm = z.object({
content: z.string().min(3, "Content minimal 3 karakter"), content: z.string().min(3, "Content minimal 3 karakter"),
kategoriBeritaId: z.string().nonempty(), kategoriBeritaId: z.string().nonempty(),
imageId: z.string().nonempty(), imageId: z.string().nonempty(),
imageIds: z.array(z.string()),
linkVideo: z.string().optional(),
}); });
// 2. Default value form berita (hindari uncontrolled input) // 2. Default value form berita (hindari uncontrolled input)
@@ -21,6 +23,8 @@ const defaultForm = {
imageId: "", imageId: "",
content: "", content: "",
kategoriBeritaId: "", kategoriBeritaId: "",
imageIds: [] as string[],
linkVideo: "",
}; };
// 4. Berita proxy // 4. Berita proxy
@@ -62,14 +66,7 @@ const berita = proxy({
// State untuk berita utama (hanya 1) // State untuk berita utama (hanya 1)
findMany: { findMany: {
data: null as data: null as any[] | null,
| Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}>[]
| null,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
loading: false, loading: false,
@@ -79,14 +76,14 @@ const berita = proxy({
berita.findMany.loading = true; berita.findMany.loading = true;
berita.findMany.page = page; berita.findMany.page = page;
berita.findMany.search = search; berita.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
if (kategori) query.kategori = kategori; if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.desa.berita["find-many"].get({ query }); const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
berita.findMany.data = res.data.data ?? []; berita.findMany.data = res.data.data ?? [];
berita.findMany.totalPages = res.data.totalPages ?? 1; berita.findMany.totalPages = res.data.totalPages ?? 1;
@@ -103,18 +100,19 @@ const berita = proxy({
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const minDelay = 300; const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0; const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => { setTimeout(() => {
berita.findMany.loading = false; berita.findMany.loading = false;
}, delay); }, delay);
} }
}, },
}, },
findUnique: { findUnique: {
data: null as Prisma.BeritaGetPayload<{ data: null as Prisma.BeritaGetPayload<{
include: { include: {
image: true; image: true;
images: true;
kategoriBerita: true; kategoriBerita: true;
}; };
}> | null, }> | null,
@@ -199,6 +197,8 @@ const berita = proxy({
content: data.content, content: data.content,
kategoriBeritaId: data.kategoriBeritaId || "", kategoriBeritaId: data.kategoriBeritaId || "",
imageId: data.imageId || "", imageId: data.imageId || "",
imageIds: data.images?.map((img: any) => img.id) || [],
linkVideo: data.linkVideo || "",
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -237,6 +237,8 @@ const berita = proxy({
content: this.form.content, content: this.form.content,
kategoriBeritaId: this.form.kategoriBeritaId || null, kategoriBeritaId: this.form.kategoriBeritaId || null,
imageId: this.form.imageId, imageId: this.form.imageId,
imageIds: this.form.imageIds,
linkVideo: this.form.linkVideo,
}), }),
}); });

View File

@@ -9,6 +9,8 @@ import {
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
Card,
Grid,
Group, Group,
Image, Image,
Paper, Paper,
@@ -17,7 +19,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Loader,
} from "@mantine/core"; } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { import {
@@ -25,19 +27,51 @@ import {
IconPhoto, IconPhoto,
IconUpload, IconUpload,
IconX, IconX,
IconVideo,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; 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() { function EditBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
// Gallery images state
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// YouTube link state
const [youtubeLink, setYoutubeLink] = useState('');
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: "", judul: "",
deskripsi: "", deskripsi: "",
@@ -48,9 +82,17 @@ function EditBerita() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Helper function to check if HTML content is empty // Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => { const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim(); const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === ''; return textContent === '';
}; };
@@ -61,21 +103,12 @@ function EditBerita() {
formData.judul?.trim() !== '' && formData.judul?.trim() !== '' &&
formData.kategoriBeritaId !== '' && formData.kategoriBeritaId !== '' &&
!isHtmlEmpty(formData.deskripsi) && !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) !isHtmlEmpty(formData.content)
); );
}; };
const [originalData, setOriginalData] = useState({ // Load data
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita
useEffect(() => { useEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
@@ -84,7 +117,7 @@ function EditBerita() {
if (!id) return; if (!id) return;
try { try {
const data = await stateDashboardBerita.berita.edit.load(id); const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
if (data) { if (data) {
setFormData({ setFormData({
judul: data.judul || "", judul: data.judul || "",
@@ -106,6 +139,17 @@ function EditBerita() {
if (data?.image?.link) { if (data?.image?.link) {
setPreviewImage(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) { } catch (error) {
console.error("Error loading berita:", error); console.error("Error loading berita:", error);
@@ -120,27 +164,59 @@ function EditBerita() {
setFormData((prev) => ({ ...prev, [field]: value })); 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 () => { const handleSubmit = async () => {
if (!formData.judul?.trim()) { if (!formData.judul?.trim()) {
toast.error('Judul wajib diisi'); toast.error('Judul wajib diisi');
return; return;
} }
if (!formData.kategoriBeritaId) { if (!formData.kategoriBeritaId) {
toast.error('Kategori wajib dipilih'); toast.error('Kategori wajib dipilih');
return; return;
} }
if (isHtmlEmpty(formData.deskripsi)) { if (isHtmlEmpty(formData.deskripsi)) {
toast.error('Deskripsi singkat wajib diisi'); toast.error('Deskripsi singkat wajib diisi');
return; return;
} }
if (!file && !originalData.imageId) { if (!file && !originalData.imageId) {
toast.error('Gambar wajib dipilih'); toast.error('Gambar utama wajib dipilih');
return; return;
} }
if (isHtmlEmpty(formData.content)) { if (isHtmlEmpty(formData.content)) {
toast.error('Konten wajib diisi'); toast.error('Konten wajib diisi');
return; return;
@@ -148,12 +224,14 @@ function EditBerita() {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Update global state hanya sekali di sini
// Update global state
beritaState.berita.edit.form = { beritaState.berita.edit.form = {
...beritaState.berita.edit.form, ...beritaState.berita.edit.form,
...formData, ...formData,
}; };
// Upload new featured image if changed
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
@@ -162,12 +240,33 @@ function EditBerita() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar"); return toast.error("Gagal upload gambar utama");
} }
beritaState.berita.edit.form.imageId = uploaded.id; 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(); await beritaState.berita.edit.update();
toast.success("Berita berhasil diperbarui!"); toast.success("Berita berhasil diperbarui!");
router.push("/admin/desa/berita/list-berita"); router.push("/admin/desa/berita/list-berita");
@@ -189,9 +288,12 @@ function EditBerita() {
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
setYoutubeLink(originalYoutubeLink);
toast.info("Form dikembalikan ke data awal"); toast.info("Form dikembalikan ke data awal");
}; };
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */} {/* Header */}
@@ -219,6 +321,7 @@ function EditBerita() {
style={{ border: "1px solid #e0e0e0" }} style={{ border: "1px solid #e0e0e0" }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Judul */}
<TextInput <TextInput
label="Judul" label="Judul"
placeholder="Masukkan judul" placeholder="Masukkan judul"
@@ -227,6 +330,7 @@ function EditBerita() {
required required
/> />
{/* Kategori */}
<Select <Select
value={formData.kategoriBeritaId} value={formData.kategoriBeritaId}
onChange={(val) => handleChange("kategoriBeritaId", val || "")} onChange={(val) => handleChange("kategoriBeritaId", val || "")}
@@ -241,9 +345,9 @@ function EditBerita() {
clearable clearable
searchable searchable
required required
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold"> <Text fz="sm" fw="bold">
Deskripsi Singkat Deskripsi Singkat
@@ -256,11 +360,10 @@ function EditBerita() {
/> />
</Box> </Box>
{/* Featured Image */}
{/* Upload Gambar */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Berita Gambar Utama (Featured)
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
@@ -274,17 +377,13 @@ function EditBerita() {
toast.error("File tidak valid, gunakan format gambar") toast.error("File tidak valid, gunakan format gambar")
} }
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }} accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
p="xl" p="xl"
> >
<Group justify="center" gap="xl" mih={180}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload <IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
@@ -292,14 +391,6 @@ function EditBerita() {
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group> </Group>
</Dropzone> </Dropzone>
@@ -328,9 +419,7 @@ function EditBerita() {
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
}} }}
style={{ style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
@@ -338,6 +427,138 @@ function EditBerita() {
)} )}
</Box> </Box>
{/* Gallery Images */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Galeri Gambar (Opsional - Maksimal 10)
</Text>
<Dropzone
onDrop={handleGalleryDrop}
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="md"
multiple
>
<Group justify="center" gap="xl" mih={120}>
<Dropzone.Accept>
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={40} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={40} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="xs" color="dimmed">
Seret gambar untuk menambahkan ke galeri
</Text>
</Dropzone>
{/* Existing Gallery Images */}
{existingGalleryImages.length > 0 && (
<Box mt="sm">
<Text fz="xs" fw="bold" mb={6} c="dimmed">
Gambar Existing ({existingGalleryImages.length})
</Text>
<Grid gutter="sm">
{existingGalleryImages.map((img, index) => (
<Grid.Col span={4} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index, true)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* New Gallery Images */}
{galleryPreviews.length > 0 && (
<Box mt="sm">
<Text fz="xs" fw="bold" mb={6} c="dimmed">
Gambar Baru ({galleryPreviews.length})
</Text>
<Grid gutter="sm">
{galleryPreviews.map((preview, index) => (
<Grid.Col span={4} key={index}>
<Card p="xs" radius="md" withBorder>
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index, false)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
</Box>
{/* YouTube Video */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Link Video YouTube (Opsional)
</Text>
<TextInput
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeLink}
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
leftSection={<IconVideo size={18} />}
rightSection={
youtubeLink && (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setYoutubeLink('')}
>
<IconX size={18} />
</ActionIcon>
)
}
/>
{embedLink && (
<Box mt="sm" pos="relative">
<iframe
style={{
borderRadius: 10,
width: '100%',
height: 250,
border: '1px solid #ddd',
}}
src={embedLink}
title="Preview Video"
allowFullScreen
/>
</Box>
)}
</Box>
{/* Konten */} {/* Konten */}
<Box> <Box>
<Text fz="sm" fw="bold"> <Text fz="sm" fw="bold">
@@ -351,9 +572,8 @@ function EditBerita() {
/> />
</Box> </Box>
{/* Action */} {/* Action Buttons */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */}
<Button <Button
variant="outline" variant="outline"
color="gray" color="gray"
@@ -363,8 +583,6 @@ function EditBerita() {
> >
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
@@ -10,6 +10,23 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
interface ExistingImage {
id: string;
link: string;
name: string;
}
interface BeritaDetail {
id: string;
judul: string;
deskripsi: string;
content: string;
image?: { link: string } | null;
images?: ExistingImage[];
linkVideo?: string | null;
kategoriBerita?: { name: string } | null;
}
function DetailBerita() { function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
@@ -38,7 +55,7 @@ function DetailBerita() {
); );
} }
const data = beritaState.berita.findUnique.data; const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box px={{ base: 0, md: 'xs' }} py="xs">
@@ -68,71 +85,131 @@ function DetailBerita() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
{/* Kategori */}
<Box> <Box>
<Text fz="lg" fw="bold">Kategori</Text> <Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text> <Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
</Box> </Box>
{/* Judul */}
<Box> <Box>
<Text fz="lg" fw="bold">Judul</Text> <Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text> <Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box> </Box>
{/* Deskripsi */}
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} /> <Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box> </Box>
{/* Gambar Utama (Featured) */}
<Box> <Box>
<Text fz="lg" fw="bold">Gambar</Text> <Text fz="lg" fw="bold">Gambar Utama</Text>
{data.image?.link ? ( {data.image?.link ? (
<Image <Image
src={data.image.link} src={data.image.link}
alt={data.judul || 'Gambar Berita'} alt={data.judul || 'Gambar Berita'}
w={200} w={{ base: '100%', md: 400 }}
h={200} h={300}
radius="md" radius="md"
fit="cover" fit="cover"
loading='lazy' loading="lazy"
/> />
) : ( ) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text> <Text fz="sm" c="dimmed">Tidak ada gambar utama</Text>
)} )}
</Box> </Box>
{/* Gallery Images */}
{data.images && data.images.length > 0 && (
<Box>
<Group gap="xs" mb="sm">
<Text fz="lg" fw="bold">Galeri Gambar</Text>
<Badge color="blue" variant="light">
{data.images.length}
</Badge>
</Group>
<Grid gutter="md">
{data.images.map((img, index) => (
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image
src={img.link}
alt={img.name || `Gallery ${index + 1}`}
h={150}
radius="sm"
fit="cover"
loading="lazy"
/>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* YouTube Video */}
{data.linkVideo && (
<Box>
<Group gap="xs" mb="sm">
<Text fz="lg" fw="bold">Video YouTube</Text>
<IconVideo size={20} color={colors['blue-button']} />
</Group>
<AspectRatio ratio={16 / 9} mah={400}>
<iframe
src={data.linkVideo}
title="YouTube Video"
allowFullScreen
style={{ borderRadius: 10, border: '1px solid #ddd' }}
/>
</AspectRatio>
</Box>
)}
{/* Konten */}
<Box> <Box>
<Text fz="lg" fw="bold">Konten</Text> <Text fz="lg" fw="bold">Konten</Text>
<Text <Paper bg="white" p="md" radius="md" mt="xs">
fz="md" <Text
c="dimmed" fz="md"
dangerouslySetInnerHTML={{ __html: data.content || '-' }} c="dimmed"
/> dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
</Paper>
</Box> </Box>
{/* Action Button */} {/* Action Buttons */}
<Group gap="sm"> <Group gap="sm" mt="md">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> leftSection={<IconTrash size={20} />}
<IconTrash size={20} /> >
</Button> Hapus
</Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)} onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> leftSection={<IconEdit size={20} />}
<IconEdit size={20} /> >
</Button> Edit
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -15,26 +15,38 @@ import {
TextInput, TextInput,
Title, Title,
Loader, Loader,
ActionIcon ActionIcon,
Grid,
Card,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
export default function CreateBerita() { export default function CreateBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter();
// Gallery images state
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// YouTube link state
const [youtubeLink, setYoutubeLink] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty // Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => { const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim(); const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === ''; return textContent === '';
}; };
@@ -61,9 +73,35 @@ export default function CreateBerita() {
kategoriBeritaId: '', kategoriBeritaId: '',
imageId: '', imageId: '',
content: '', content: '',
imageIds: [],
linkVideo: '',
}; };
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
setGalleryFiles([]);
setGalleryPreviews([]);
setYoutubeLink('');
};
const handleGalleryDrop = (files: File[]) => {
const newFiles = files.filter(
(_, index) => galleryFiles.length + index < 10 // Max 10 images
);
if (newFiles.length === 0) {
toast.warn('Maksimal 10 gambar untuk galeri');
return;
}
setGalleryFiles([...galleryFiles, ...newFiles]);
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
};
const removeGalleryImage = (index: number) => {
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -71,22 +109,22 @@ export default function CreateBerita() {
toast.error('Judul wajib diisi'); toast.error('Judul wajib diisi');
return; return;
} }
if (!beritaState.berita.create.form.kategoriBeritaId) { if (!beritaState.berita.create.form.kategoriBeritaId) {
toast.error('Kategori wajib dipilih'); toast.error('Kategori wajib dipilih');
return; return;
} }
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) { if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
toast.error('Deskripsi singkat wajib diisi'); toast.error('Deskripsi singkat wajib diisi');
return; return;
} }
if (!file) { if (!file) {
toast.error('Gambar wajib dipilih'); toast.error('Gambar utama wajib dipilih');
return; return;
} }
if (isHtmlEmpty(beritaState.berita.create.form.content)) { if (isHtmlEmpty(beritaState.berita.create.form.content)) {
toast.error('Konten wajib diisi'); toast.error('Konten wajib diisi');
return; return;
@@ -94,21 +132,37 @@ export default function CreateBerita() {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({ // Upload featured image
const featuredRes = await ApiFetch.api.fileStorage.create.post({
file, file,
name: file.name, name: file.name,
}); });
const featuredUploaded = featuredRes.data?.data;
const uploaded = res.data?.data; if (!featuredUploaded?.id) {
if (!uploaded?.id) { return toast.error('Gagal mengunggah gambar utama');
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
} }
beritaState.berita.create.form.imageId = featuredUploaded.id;
beritaState.berita.create.form.imageId = uploaded.id; // Upload gallery images
const galleryIds: 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) {
galleryIds.push(galleryUploaded.id);
}
}
beritaState.berita.create.form.imageIds = galleryIds;
// Set YouTube link if provided
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
if (embedLink) {
beritaState.berita.create.form.linkVideo = embedLink;
}
await beritaState.berita.create.create(); await beritaState.berita.create.create();
@@ -122,16 +176,13 @@ export default function CreateBerita() {
} }
}; };
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
@@ -148,6 +199,7 @@ export default function CreateBerita() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Judul */}
<TextInput <TextInput
label="Judul" label="Judul"
placeholder="Masukkan judul berita" placeholder="Masukkan judul berita"
@@ -156,6 +208,7 @@ export default function CreateBerita() {
required required
/> />
{/* Kategori */}
<Select <Select
label="Kategori" label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
@@ -182,6 +235,7 @@ export default function CreateBerita() {
required required
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Deskripsi Singkat Deskripsi Singkat
@@ -194,9 +248,10 @@ export default function CreateBerita() {
/> />
</Box> </Box>
{/* Featured Image */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Berita Gambar Utama (Featured)
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
@@ -232,17 +287,11 @@ export default function CreateBerita() {
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar Utama"
radius="md" radius="md"
style={{ style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy" loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"
@@ -255,9 +304,7 @@ export default function CreateBerita() {
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
}} }}
style={{ style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
> >
<IconX size={14} /> <IconX size={14} />
</ActionIcon> </ActionIcon>
@@ -265,6 +312,102 @@ export default function CreateBerita() {
)} )}
</Box> </Box>
{/* Gallery Images */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Galeri Gambar (Opsional - Maksimal 10)
</Text>
<Dropzone
onDrop={handleGalleryDrop}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="md"
multiple
>
<Group justify="center" gap="xl" mih={120}>
<Dropzone.Accept>
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="xs" color="dimmed">
Seret gambar atau klik untuk menambahkan ke galeri
</Text>
</Dropzone>
{galleryPreviews.length > 0 && (
<Grid mt="sm" gutter="sm">
{galleryPreviews.map((preview, index) => (
<Grid.Col span={4} key={index}>
<Card p="xs" radius="md" withBorder>
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => removeGalleryImage(index)}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconTrash size={14} />
</ActionIcon>
</Card>
</Grid.Col>
))}
</Grid>
)}
</Box>
{/* YouTube Video */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Link Video YouTube (Opsional)
</Text>
<TextInput
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeLink}
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
leftSection={<IconVideo size={18} />}
rightSection={
youtubeLink && (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setYoutubeLink('')}
>
<IconX size={18} />
</ActionIcon>
)
}
/>
{embedLink && (
<Box mt="sm" pos="relative">
<iframe
style={{
borderRadius: 10,
width: '100%',
height: 250,
border: '1px solid #ddd',
}}
src={embedLink}
title="Preview Video"
allowFullScreen
/>
</Box>
)}
</Box>
{/* Konten */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Konten Konten
@@ -277,6 +420,7 @@ export default function CreateBerita() {
/> />
</Box> </Box>
{/* Buttons */}
<Group justify="right"> <Group justify="right">
<Button <Button
variant="outline" variant="outline"
@@ -287,8 +431,6 @@ export default function CreateBerita() {
> >
Reset Reset
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,26 +1,33 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = Prisma.BeritaGetPayload<{ type FormCreate = {
select: { judul: string;
judul: true; deskripsi: string;
deskripsi: true; content: string;
content: true; kategoriBeritaId: string;
kategoriBeritaId: true; imageId: string; // Featured image
imageId: true; imageIds?: string[]; // Multiple images for gallery
}; linkVideo?: string; // YouTube link
}>; };
async function beritaCreate(context: Context) { async function beritaCreate(context: Context) {
const body = context.body as FormCreate; const body = context.body as FormCreate;
await prisma.berita.create({ await prisma.berita.create({
data: { data: {
content: body.content, content: body.content,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
imageId: body.imageId, imageId: body.imageId,
judul: body.judul, judul: body.judul,
kategoriBeritaId: body.kategoriBeritaId, kategoriBeritaId: body.kategoriBeritaId,
// Connect multiple images if provided
linkVideo: body.linkVideo,
images: body.imageIds && body.imageIds.length > 0
? {
connect: body.imageIds.map((id) => ({ id })),
}
: undefined,
}, },
}); });

View File

@@ -28,6 +28,7 @@ export default async function handler(
where: { id }, where: { id },
include: { include: {
image: true, image: true,
images: true,
kategoriBerita: true, kategoriBerita: true,
}, },
}); });

View File

@@ -21,6 +21,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(), imageId: t.String(),
content: t.String(), content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]), kategoriBeritaId: t.Union([t.String(), t.Null()]),
imageIds: t.Array(t.String()),
linkVideo: t.Optional(t.String()),
}), }),
}) })
.get("/find-first", beritaFindFirst) .get("/find-first", beritaFindFirst)
@@ -39,6 +41,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(), imageId: t.String(),
content: t.String(), content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]), kategoriBeritaId: t.Union([t.String(), t.Null()]),
imageIds: t.Array(t.String()),
linkVideo: t.Optional(t.String()),
}), }),
} }
); );

View File

@@ -4,52 +4,48 @@ import { Prisma } from "@prisma/client";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
type FormUpdate = Prisma.BeritaGetPayload<{ type FormUpdate = {
select: { id: string;
id: true; judul: string;
judul: true; deskripsi: string;
deskripsi: true; content: string;
content: true; kategoriBeritaId: string;
kategoriBeritaId: true; imageId: string; // Featured image
imageId: true; imageIds?: string[]; // Multiple images for gallery
}; linkVideo?: string; // YouTube link
}>; };
async function beritaUpdate(context: Context) { async function beritaUpdate(context: Context) {
try { try {
const id = context.params?.id as string; // ambil dari URL const id = context.params?.id as string;
const body = (await context.body) as Omit<FormUpdate, "id">; const body = (await context.body) as Omit<FormUpdate, "id">;
const { const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
} = body;
if (!id) { if (!id) {
return new Response( return new Response(
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
{ status: 400, headers: { 'Content-Type': 'application/json' } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
const existing = await prisma.berita.findUnique({ const existing = await prisma.berita.findUnique({
where: { id }, where: { id },
include: { include: {
image: true, image: true,
images: true, // Include gallery images
kategoriBerita: true, kategoriBerita: true,
}, },
}); });
if (!existing) { if (!existing) {
return new Response( return new Response(
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }), JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
{ status: 404, headers: { 'Content-Type': 'application/json' } } { status: 404, headers: { "Content-Type": "application/json" } },
); );
} }
// Delete old featured image if changed
if (existing.imageId && existing.imageId !== imageId) { if (existing.imageId && existing.imageId !== imageId) {
const oldImage = existing.image; const oldImage = existing.image;
if (oldImage) { if (oldImage) {
@@ -64,35 +60,60 @@ async function beritaUpdate(context: Context) {
} }
} }
} }
// Build update data
const updateData: Prisma.BeritaUpdateInput = {
judul,
deskripsi,
content,
kategoriBerita: kategoriBeritaId ? { connect: { id: kategoriBeritaId } } : { disconnect: true },
image: imageId ? { connect: { id: imageId } } : { disconnect: true },
linkVideo,
};
// Handle multiple images update
if (imageIds !== undefined) {
// Disconnect all existing images first
updateData.images = {
set: [],
};
// Connect new images if provided
if (imageIds.length > 0) {
updateData.images = {
...updateData.images,
connect: imageIds.map((id) => ({ id })),
};
}
}
const updated = await prisma.berita.update({ const updated = await prisma.berita.update({
where: { id }, where: { id },
data: { data: updateData,
judul, include: {
deskripsi, image: true,
content, images: true,
kategoriBeritaId: kategoriBeritaId || null, kategoriBerita: true,
imageId,
}, },
}); });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: "Berita berhasil diupdate", message: "Berita berhasil diupdate",
data: updated, data: updated,
}), }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { status: 200, headers: { "Content-Type": "application/json" } },
); );
} catch (error) { } catch (error) {
console.error("Error updating berita:", error); console.error("Error updating berita:", error);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
message: "Terjadi kesalahan saat mengupdate berita", message: "Terjadi kesalahan saat mengupdate berita",
}), }),
{ status: 500, headers: { 'Content-Type': 'application/json' } } { status: 500, headers: { "Content-Type": "application/json" } },
); );
} }
} }
export default beritaUpdate; export default beritaUpdate;

View File

@@ -3,10 +3,43 @@
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader'; import NewsReader from '@/app/darmasaba/_com/NewsReader';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text, Title } from '@mantine/core'; import {
Box,
Center,
Container,
Group,
Image,
Skeleton,
Stack,
Text,
Title,
Grid,
Card,
AspectRatio,
Badge,
Divider,
} from '@mantine/core';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { IconVideo } from '@tabler/icons-react';
interface ExistingImage {
id: string;
link: string;
name: string;
}
interface BeritaDetail {
id: string;
judul: string;
deskripsi: string;
content: string;
image?: { link: string } | null;
images?: ExistingImage[];
linkVideo?: string | null;
kategoriBerita?: { name: string } | null;
}
function Page() { function Page() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -45,13 +78,30 @@ function Page() {
); );
} }
const data = state.findUnique.data as unknown as BeritaDetail;
return ( return (
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}> <Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
<Group px={{ base: 'md', md: 100 }}> <Group px={{ base: 'md', md: 100 }}>
<NewsReader /> <NewsReader />
</Group> </Group>
<Container w={{ base: '100%', md: '50%' }}>
<Container w={{ base: '100%', md: '60%' }}>
<Box pb={20}> <Box pb={20}>
{/* Kategori Badge */}
{data.kategoriBerita?.name && (
<Badge
color={colors['blue-button']}
variant="light"
size="lg"
mb="md"
style={{ textTransform: 'uppercase' }}
>
{data.kategoriBerita.name}
</Badge>
)}
{/* Judul */}
<Title <Title
id="news-title" id="news-title"
order={1} order={1}
@@ -59,41 +109,108 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
fw="bold" fw="bold"
lh={{ base: 1.2, md: 1.25 }} lh={{ base: 1.2, md: 1.25 }}
mb="md"
> >
{state.findUnique.data.judul} {data.judul}
</Title>
<Title
order={2}
ta="center"
fw="bold"
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.3, md: 1.35 }}
>
Informasi dan Pelayanan Administrasi Digital
</Title> </Title>
<Divider my="xs" />
</Box> </Box>
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
</Container> {/* Featured Image */}
<Box px={{ base: 'md', md: 100 }}> {data.image?.link && (
<Stack gap="xs"> <Image
src={data.image.link}
alt={data.judul}
w="100%"
h={{ base: 300, md: 400 }}
radius="md"
loading="lazy"
fit="cover"
/>
)}
{/* Content */}
<Box mt="xl">
<Title order={3} c={colors['blue-button']} mb="md">
Deskripsi Berita
</Title>
<Text <Text
id="news-content" id="news-content"
py={20} py={20}
px={{ base: 0, md: 'sm' }}
fz={{ base: 'sm', md: 'md' }} fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.8 }} lh={{ base: 1.8, md: 2 }}
ta="justify" ta="justify"
c="dimmed"
style={{ style={{
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',
}} }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: state.findUnique.data.content || '', __html: data.content || '',
}} }}
/> />
</Stack> </Box>
</Box>
{/* Gallery Images */}
{data.images && data.images.length > 0 && (
<Box mt="xl">
<Group gap="xs" mb="md">
<Title order={3} c={colors['blue-button']}>
Galeri Foto
</Title>
<Badge color={colors['blue-button']} variant="light">
{data.images.length}
</Badge>
</Group>
<Grid gutter="md">
{data.images.map((img, index) => (
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
<Card p="xs" radius="md" withBorder>
<Image
src={img.link}
alt={img.name || `Foto ${index + 1}`}
h={180}
radius="sm"
fit="cover"
loading="lazy"
/>
</Card>
</Grid.Col>
))}
</Grid>
</Box>
)}
{/* YouTube Video */}
{data.linkVideo && (
<Box mt="xl">
<Group gap="xs" mb="md">
<Title order={3} c={colors['blue-button']}>
Video
</Title>
<IconVideo size={24} color={colors['blue-button']} />
</Group>
<AspectRatio ratio={16 / 9} mah={500}>
<iframe
src={data.linkVideo}
title="YouTube Video"
allowFullScreen
style={{
borderRadius: 12,
border: '1px solid #e0e0e0',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
/>
</AspectRatio>
</Box>
)}
</Container>
</Stack> </Stack>
); );
} }
export default Page; export default Page;

View File

@@ -8,8 +8,7 @@ export function useAudioProgress(
audioRef: React.RefObject<HTMLAudioElement>, audioRef: React.RefObject<HTMLAudioElement>,
isPlaying: boolean, isPlaying: boolean,
setCurrentTime: (time: number) => void, setCurrentTime: (time: number) => void,
isSeekingRef: React.RefObject<boolean>, isSeekingRef: React.RefObject<boolean>
lastSeekTimeRef?: React.RefObject<number>
) { ) {
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const lastTimeRef = useRef<number>(0); const lastTimeRef = useRef<number>(0);

View File

@@ -50,7 +50,7 @@ const MusicPlayer = () => {
const lastSeekTimeRef = useRef<number>(0); // Track last seek time const lastSeekTimeRef = useRef<number>(0); // Track last seek time
// Smooth progress update dengan requestAnimationFrame // Smooth progress update dengan requestAnimationFrame
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef, lastSeekTimeRef); useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
// Fetch musik data from API // Fetch musik data from API
useEffect(() => { useEffect(() => {
@@ -108,7 +108,6 @@ const MusicPlayer = () => {
// }, [isPlaying]); // }, [isPlaying]);
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah) // Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (currentSong && audioRef.current) { if (currentSong && audioRef.current) {
// Cek apakah ini benar-benar lagu baru // Cek apakah ini benar-benar lagu baru
@@ -136,10 +135,9 @@ const MusicPlayer = () => {
} }
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0) // Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
} }
}, [currentSong?.id]); }, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
// Sync duration dari audio element jika berbeda signifikan (> 1 detik) // Sync duration dari audio element jika berbeda signifikan (> 1 detik)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || !currentSong) return; if (!audio || !currentSong) return;
@@ -157,7 +155,7 @@ const MusicPlayer = () => {
audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata); return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
}, [currentSong?.id]); }, [currentSong?.id]); // Intentional: hanya depend on song ID
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);