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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user