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)
link String
category String // "image" / "document" / "audio" / "other"
Berita Berita[]
Berita Berita[] @relation("BeritaFeaturedImage")
BeritaImages Berita[] @relation("BeritaImages")
PotensiDesa PotensiDesa[]
Posyandu Posyandu[]
StrukturPPID StrukturPPID[]
@@ -612,15 +613,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 {

View File

@@ -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,
}),
});

View File

@@ -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<string | 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({
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 (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
@@ -219,6 +321,7 @@ function EditBerita() {
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul"
placeholder="Masukkan judul"
@@ -227,6 +330,7 @@ function EditBerita() {
required
/>
{/* Kategori */}
<Select
value={formData.kategoriBeritaId}
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
@@ -241,9 +345,9 @@ function EditBerita() {
clearable
searchable
required
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Singkat
@@ -256,11 +360,10 @@ function EditBerita() {
/>
</Box>
{/* Upload Gambar */}
{/* Featured Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
Gambar Utama (Featured)
</Text>
<Dropzone
onDrop={(files) => {
@@ -274,17 +377,13 @@ function EditBerita() {
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
@@ -292,14 +391,6 @@ function EditBerita() {
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</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>
</Dropzone>
@@ -328,9 +419,7 @@ function EditBerita() {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -338,6 +427,138 @@ function EditBerita() {
)}
</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 */}
<Box>
<Text fz="sm" fw="bold">
@@ -351,9 +572,8 @@ function EditBerita() {
/>
</Box>
{/* Action */}
{/* Action Buttons */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
@@ -363,8 +583,6 @@ function EditBerita() {
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"

View File

@@ -1,7 +1,7 @@
'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 { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
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 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() {
const beritaState = useProxy(stateDashboardBerita);
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 (
<Box px={{ base: 0, md: 'xs' }} py="xs">
@@ -68,71 +85,131 @@ function DetailBerita() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
{/* Kategori */}
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
</Box>
{/* Judul */}
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box>
{/* Deskripsi */}
<Box>
<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>
{/* Gambar Utama (Featured) */}
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Text fz="lg" fw="bold">Gambar Utama</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.judul || 'Gambar Berita'}
w={200}
h={200}
w={{ base: '100%', md: 400 }}
h={300}
radius="md"
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>
{/* 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>
<Text fz="lg" fw="bold">Konten</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
<Paper bg="white" p="md" radius="md" mt="xs">
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
</Paper>
</Box>
{/* Action Button */}
<Group gap="sm">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
{/* Action Buttons */}
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
leftSection={<IconTrash size={20} />}
>
Hapus
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
leftSection={<IconEdit size={20} />}
>
Edit
</Button>
</Group>
</Stack>
</Paper>

View File

@@ -15,26 +15,38 @@ import {
TextInput,
Title,
Loader,
ActionIcon
ActionIcon,
Grid,
Card,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
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 { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
export default function CreateBerita() {
const beritaState = useProxy(stateDashboardBerita);
const router = useRouter();
// Featured image state
const [previewImage, setPreviewImage] = useState<string | 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);
// 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,9 +73,35 @@ export default function CreateBerita() {
kategoriBeritaId: '',
imageId: '',
content: '',
imageIds: [],
linkVideo: '',
};
setPreviewImage(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 () => {
@@ -71,22 +109,22 @@ export default function CreateBerita() {
toast.error('Judul wajib diisi');
return;
}
if (!beritaState.berita.create.form.kategoriBeritaId) {
toast.error('Kategori wajib dipilih');
return;
}
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
toast.error('Deskripsi singkat wajib diisi');
return;
}
if (!file) {
toast.error('Gambar wajib dipilih');
toast.error('Gambar utama wajib dipilih');
return;
}
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
toast.error('Konten wajib diisi');
return;
@@ -94,21 +132,37 @@ export default function CreateBerita() {
try {
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,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
const featuredUploaded = featuredRes.data?.data;
if (!featuredUploaded?.id) {
return toast.error('Gagal mengunggah gambar utama');
}
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();
@@ -122,16 +176,13 @@ export default function CreateBerita() {
}
};
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header dengan tombol kembali */}
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="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">
@@ -148,6 +199,7 @@ export default function CreateBerita() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul"
placeholder="Masukkan judul berita"
@@ -156,6 +208,7 @@ export default function CreateBerita() {
required
/>
{/* Kategori */}
<Select
label="Kategori"
placeholder="Pilih kategori"
@@ -182,6 +235,7 @@ export default function CreateBerita() {
required
/>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi Singkat
@@ -194,9 +248,10 @@ export default function CreateBerita() {
/>
</Box>
{/* Featured Image */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
Gambar Utama (Featured)
</Text>
<Dropzone
onDrop={(files) => {
@@ -232,17 +287,11 @@ export default function CreateBerita() {
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
alt="Preview Gambar Utama"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
@@ -255,9 +304,7 @@ export default function CreateBerita() {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
@@ -265,6 +312,102 @@ export default function CreateBerita() {
)}
</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>
<Text fz="sm" fw="bold" mb={6}>
Konten
@@ -277,6 +420,7 @@ export default function CreateBerita() {
/>
</Box>
{/* Buttons */}
<Group justify="right">
<Button
variant="outline"
@@ -287,8 +431,6 @@ export default function CreateBerita() {
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"

View File

@@ -1,26 +1,33 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.BeritaGetPayload<{
select: {
judul: true;
deskripsi: true;
content: true;
kategoriBeritaId: true;
imageId: true;
};
}>;
type FormCreate = {
judul: string;
deskripsi: string;
content: string;
kategoriBeritaId: string;
imageId: string; // Featured image
imageIds?: string[]; // Multiple images for gallery
linkVideo?: string; // YouTube link
};
async function beritaCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.berita.create({
data: {
data: {
content: body.content,
deskripsi: body.deskripsi,
imageId: body.imageId,
judul: body.judul,
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 },
include: {
image: true,
images: true,
kategoriBerita: true,
},
});

View File

@@ -21,6 +21,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(),
content: t.String(),
kategoriBeritaId: t.Union([t.String(), t.Null()]),
imageIds: t.Array(t.String()),
linkVideo: t.Optional(t.String()),
}),
})
.get("/find-first", beritaFindFirst)
@@ -39,6 +41,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
imageId: t.String(),
content: t.String(),
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 path from "path";
type FormUpdate = Prisma.BeritaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
content: true;
kategoriBeritaId: true;
imageId: true;
};
}>;
type FormUpdate = {
id: string;
judul: string;
deskripsi: string;
content: string;
kategoriBeritaId: string;
imageId: string; // Featured image
imageIds?: string[]; // Multiple images for gallery
linkVideo?: string; // YouTube link
};
async function beritaUpdate(context: Context) {
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 {
judul,
deskripsi,
content,
kategoriBeritaId,
imageId,
} = body;
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
if (!id) {
return new Response(
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({
where: { id },
include: {
image: true,
images: true, // Include gallery images
kategoriBerita: true,
},
});
if (!existing) {
return new Response(
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) {
const oldImage = existing.image;
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({
where: { id },
data: {
judul,
deskripsi,
content,
kategoriBeritaId: kategoriBeritaId || null,
imageId,
data: updateData,
include: {
image: true,
images: true,
kategoriBerita: true,
},
});
return new Response(
JSON.stringify({
success: true,
message: "Berita berhasil diupdate",
data: updated,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error updating berita:", error);
console.error("Error updating berita:", error);
return new Response(
JSON.stringify({
success: false,
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 NewsReader from '@/app/darmasaba/_com/NewsReader';
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 { useEffect, useState } from 'react';
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() {
const params = useParams<{ id: string }>();
@@ -45,13 +78,30 @@ function Page() {
);
}
const data = state.findUnique.data as unknown as BeritaDetail;
return (
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
<Group px={{ base: 'md', md: 100 }}>
<NewsReader />
</Group>
<Container w={{ base: '100%', md: '50%' }}>
<Container w={{ base: '100%', md: '60%' }}>
<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
id="news-title"
order={1}
@@ -59,41 +109,108 @@ function Page() {
c={colors['blue-button']}
fw="bold"
lh={{ base: 1.2, md: 1.25 }}
mb="md"
>
{state.findUnique.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
{data.judul}
</Title>
<Divider my="xs" />
</Box>
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
</Container>
<Box px={{ base: 'md', md: 100 }}>
<Stack gap="xs">
{/* Featured Image */}
{data.image?.link && (
<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
id="news-content"
py={20}
px={{ base: 0, md: 'sm' }}
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.6, md: 1.8 }}
lh={{ base: 1.8, md: 2 }}
ta="justify"
c="dimmed"
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
}}
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>
);
}
export default Page;
export default Page;

View File

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

View File

@@ -50,7 +50,7 @@ const MusicPlayer = () => {
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
// 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
useEffect(() => {
@@ -108,7 +108,6 @@ const MusicPlayer = () => {
// }, [isPlaying]);
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (currentSong && audioRef.current) {
// 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)
}
}, [currentSong?.id]);
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentSong) return;
@@ -157,7 +155,7 @@ const MusicPlayer = () => {
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
}, [currentSong?.id]);
}, [currentSong?.id]); // Intentional: hanya depend on song ID
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);