Sinkronisasi UI & API Admin - User Menu Desa, Submenu Gallery
This commit is contained in:
@@ -1,64 +0,0 @@
|
||||
import colors from '@/con/colors';
|
||||
import { SimpleGrid, Box, Paper, Center, Stack, Image, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
image: "/api/img/galeri-1.png",
|
||||
title: "Pendapatan",
|
||||
tanggal: "3 Mar 2025",
|
||||
judul: "Pemasangan Wifi Gratis Di Publik Desa",
|
||||
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image: "/api/img/galeri-2.png",
|
||||
title: "Belanja",
|
||||
tanggal: "4 Mar 2025",
|
||||
judul: "Panen raya Desa Darmasaba",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
image: "/api/img/galeri-3.png",
|
||||
title: "Pembiayaan",
|
||||
tanggal: "5 Mar 2025",
|
||||
judul: "Kegiatan Pembangunan Pelinggih",
|
||||
}
|
||||
]
|
||||
function Foto() {
|
||||
return (
|
||||
<Box pt={20}>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 3,
|
||||
}}>
|
||||
{data.map((v, k) => {
|
||||
return (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p={"md"} radius={26} bg={colors['white-trans-1']} w={{ base: "100%", md: "100%" }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Image src={v.image} alt='' />
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"sm"} py={10}>
|
||||
<Text fz={{ base: "sm", md: "sm" }}>{v.tanggal}</Text>
|
||||
<Text fw={"bold"} fz={{ base: "sm", md: "sm" }}>{v.judul}</Text>
|
||||
<Text ta={"justify"} fz={{ base: "sm", md: "sm" }}>Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Fusce sagittis nec arcu ac ornare. Praesent a porttitor
|
||||
felis. Proin varius ex nisl, in hendrerit odio tristique vel. </Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Foto;
|
||||
@@ -1,64 +0,0 @@
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
video: "https://www.youtube.com/embed/J2uZcZlvL7g?si=3pWy0ho77dW0E2Gt",
|
||||
tanggal: "3 Mar 2025",
|
||||
judul: "MENERIMA KUNJUNGAN STUDI TIRU DARI PEMERINTAH DESA TUA SULAWESI SELATAN",
|
||||
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
video: "https://www.youtube.com/embed/GX4sqS5zAzw?si=rulOAa2Ylbs4_R82",
|
||||
tanggal: "4 Mar 2025",
|
||||
judul: "Sosialisasi Pengelolaan Sampah di SD NO 3 Desa Darmasaba",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
video: "https://www.youtube.com/embed/HCY4H6ODmeA?si=0epW8PAtd6Jum90k",
|
||||
tanggal: "5 Mar 2025",
|
||||
judul: "Posyandu dan Senam Lansia Banjar Gulingan",
|
||||
}
|
||||
]
|
||||
function Video() {
|
||||
return (
|
||||
<Box pt={20}>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 3,
|
||||
}}>
|
||||
{data.map((v, k) => {
|
||||
return (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p={"md"} radius={26} bg={colors['white-trans-1']} w={{ base: "100%", md: "100%" }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Box style={{ maxWidth: "560px", width: "100%", aspectRatio: "16/9" }}>
|
||||
<iframe style={{ borderRadius: "16px" }} width="100%"
|
||||
height="100%"
|
||||
src={v.video} title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ></iframe>
|
||||
</Box>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"sm"} py={10}>
|
||||
<Text fz={{ base: "sm", md: "sm" }}>{v.tanggal}</Text>
|
||||
<Text fw={"bold"} fz={{ base: "sm", md: "sm" }} lineClamp={1}>{v.judul}</Text>
|
||||
<Text ta={"justify"} fz={{ base: "sm", md: "sm" }}>Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Fusce sagittis nec arcu ac ornare. Praesent a porttitor
|
||||
felis. Proin varius ex nisl, in hendrerit odio tristique vel. </Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Video;
|
||||
124
src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx
Normal file
124
src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Box, Container, Grid, GridCol, Stack, Tabs, TabsList, TabsTab, Text } from '@mantine/core';
|
||||
import BackButton from '../../layanan/_com/BackButto';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { SearchBarProps } from './searchBar';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
// Define tabs outside the component to ensure consistency between server and client
|
||||
const TABS = [
|
||||
{
|
||||
label: "Foto",
|
||||
value: "foto",
|
||||
href: "/darmasaba/desa/galery/foto",
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
value: "video",
|
||||
href: "/darmasaba/desa/galery/video",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SearchBar = dynamic<SearchBarProps>(
|
||||
() => import('./searchBar').then(mod => mod.SearchBar),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type HeaderSearchProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function LayoutTabsGalery({ children }: HeaderSearchProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// Set default active tab to empty string to prevent hydration mismatch
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
|
||||
// Set client flag on mount
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Update active tab based on current route - only on client side
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
const currentTab = TABS.find(tab => pathname.includes(tab.value));
|
||||
if (currentTab) {
|
||||
setActiveTab(currentTab.value);
|
||||
} else {
|
||||
// Default to first tab if no match found
|
||||
setActiveTab(TABS[0].value);
|
||||
}
|
||||
}, [pathname, isClient]);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
const tab = TABS.find(tab => tab.value === value);
|
||||
if (tab) {
|
||||
// Only update if we're on the client
|
||||
if (typeof window !== 'undefined') {
|
||||
setActiveTab(value);
|
||||
router.push(tab.href);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
{/* Header */}
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Container size="lg" px="md">
|
||||
<Stack align="center" gap="0">
|
||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
||||
Galeri Kegiatan Desa Darmasaba
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
<Tabs
|
||||
value={isClient ? activeTab : undefined}
|
||||
defaultValue={TABS[0].value}
|
||||
onChange={handleTabChange}
|
||||
color={colors['blue-button']}
|
||||
variant="pills"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Box px={{ base: "md", md: 100 }} py="md" bg={colors['BG-trans']}>
|
||||
<Grid>
|
||||
<GridCol span={{ base: 12, md: 9, lg: 8, xl: 9 }}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
component="button"
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3, lg: 4, xl: 3 }}>
|
||||
<SearchBar />
|
||||
</GridCol>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Container size={'xl'}>
|
||||
{children}
|
||||
</Container>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabsGalery;
|
||||
54
src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx
Normal file
54
src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/app/darmasaba/(pages)/desa/galery/SearchBar.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export type SearchBarProps = {
|
||||
placeholder?: string;
|
||||
searchIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SearchBar({
|
||||
placeholder = "pencarian",
|
||||
searchIcon = <IconSearch size={20} />,
|
||||
}: SearchBarProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get initial search value from URL
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('search') || '');
|
||||
|
||||
// Handle search input change with debounce
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setSearchValue(value);
|
||||
|
||||
// Update URL with debounce
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set('search', value);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
|
||||
// Only update URL if the search value has actually changed
|
||||
if (params.toString() !== searchParams.toString()) {
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
radius="lg"
|
||||
placeholder={placeholder}
|
||||
leftSection={searchIcon}
|
||||
w="100%"
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
151
src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx
Normal file
151
src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string;
|
||||
realName: string;
|
||||
createdAt: string | Date;
|
||||
category: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export default function FotoContent() {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// ✅ Load data function
|
||||
const load = async (pageNum: number, limit: number, searchTerm: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query: Record<string, string> = {
|
||||
category: 'image',
|
||||
page: pageNum.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (searchTerm) query.search = searchTerm;
|
||||
|
||||
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
setFiles(response.data.data || []);
|
||||
setTotalPages(response.data.meta?.totalPages || 1);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load error:', err);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Baca dari URL — AMAN karena ssr: false
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlSearch = urlParams.get('search') || '';
|
||||
setSearch(urlSearch);
|
||||
load(1, 10, urlSearch.trim());
|
||||
}, []);
|
||||
|
||||
// ✅ Fetch data
|
||||
useEffect(() => {
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query: Record<string, string> = {
|
||||
category: 'image',
|
||||
page: page.toString(),
|
||||
limit: '10',
|
||||
};
|
||||
if (search) query.search = search;
|
||||
|
||||
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
setFiles(response.data.data || []);
|
||||
setTotalPages(response.data.meta?.totalPages || 1);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (page > 0) fetchFiles(); // jangan fetch jika page belum valid
|
||||
}, [search, page]);
|
||||
|
||||
// ✅ Update URL
|
||||
const updateURL = (newSearch: string, newPage: number) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (newSearch) url.searchParams.set('search', newSearch);
|
||||
else url.searchParams.delete('search');
|
||||
if (newPage > 1) url.searchParams.set('page', newPage.toString());
|
||||
else url.searchParams.delete('page');
|
||||
window.history.pushState({}, '', url);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
updateURL(search, newPage);
|
||||
};
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
return <Center>Memuat data...</Center>;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <Center>Tidak ada foto ditemukan</Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pt={20} px={{ base: 'md', md: 100 }}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{files.map((file) => (
|
||||
<Paper key={file.id} mb={50} p="md" radius={26} bg={colors['white-trans-1']} style={{ height: '100%' }}>
|
||||
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
|
||||
<Image
|
||||
src={file.link}
|
||||
alt={file.realName || file.name}
|
||||
height={250}
|
||||
width="100%"
|
||||
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap="sm" py={10}>
|
||||
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
|
||||
{file.realName || file.name}
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{new Date(file.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Center mt="xl">
|
||||
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
src/app/darmasaba/(pages)/desa/galery/foto/page.tsx
Normal file
25
src/app/darmasaba/(pages)/desa/galery/foto/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
// ✅ Load komponen tanpa SSR
|
||||
const FotoContent = dynamic(
|
||||
() => import('./Content'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div>Memuat konten...</div>
|
||||
}
|
||||
);
|
||||
|
||||
function PageContent() {
|
||||
return (
|
||||
<Suspense fallback={<div>Memuat...</div>}>
|
||||
<FotoContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageContent />;
|
||||
}
|
||||
9
src/app/darmasaba/(pages)/desa/galery/layout.tsx
Normal file
9
src/app/darmasaba/(pages)/desa/galery/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LayoutTabsGalery from "./_lib/layoutTabs";
|
||||
|
||||
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<LayoutTabsGalery>
|
||||
{children}
|
||||
</LayoutTabsGalery>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Container, Grid, GridCol, Group, Stack, Tabs, TabsList, TabsPanel, TabsTab, Text, TextInput } from '@mantine/core';
|
||||
import { IconPhoto, IconSearch, IconVideo } from '@tabler/icons-react';
|
||||
import BackButton from '../layanan/_com/BackButto';
|
||||
import Foto from './(tabs)/foto';
|
||||
import Video from './(tabs)/video';
|
||||
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
||||
{/* Header */}
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Container size="lg" px="md">
|
||||
<Stack align="center" gap="xs" mb="xl">
|
||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
||||
Galeri Kegiatan Desa Darmasaba
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<Tabs color={colors['blue-button']} defaultValue="foto">
|
||||
<Grid align='center'>
|
||||
<GridCol span={{ base: 12, md: 9, lg: 8, xl: 9 }}>
|
||||
<Group>
|
||||
<TabsList>
|
||||
<TabsTab style={{ color: colors['blue-button'] }} fz={"xl"} value="foto" leftSection={<IconPhoto color={colors['blue-button']} size={45} />}>
|
||||
Foto
|
||||
</TabsTab>
|
||||
<TabsTab style={{ color: colors['blue-button'] }} fz={"xl"} value="video" leftSection={<IconVideo color={colors['blue-button']} size={45} />}>
|
||||
Video
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
</Group>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3, lg: 4, xl: 3 }}>
|
||||
<TextInput
|
||||
w={{ base: "100%", md: "100%" }}
|
||||
radius="lg"
|
||||
placeholder="Cari Berita"
|
||||
leftSection={<IconSearch size={18} />}
|
||||
/>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
<TabsPanel value="foto">
|
||||
<Foto />
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="video">
|
||||
<Video />
|
||||
</TabsPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
125
src/app/darmasaba/(pages)/desa/galery/video/Content.tsx
Normal file
125
src/app/darmasaba/(pages)/desa/galery/video/Content.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Pagination, Paper, SimpleGrid, Spoiler, Stack, Text } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
export default function VideoContent() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [currentSearch, setCurrentSearch] = useState('');
|
||||
const videoState = useSnapshot(stateGallery.video);
|
||||
const {
|
||||
data,
|
||||
page,
|
||||
totalPages,
|
||||
loading,
|
||||
load,
|
||||
} = videoState.findMany;
|
||||
|
||||
// ✅ Baca dari URL hanya di client
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlSearch = urlParams.get('search') || '';
|
||||
setCurrentSearch(urlSearch);
|
||||
load(1, 10, urlSearch.trim());
|
||||
}, []);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
load(newPage, 10, currentSearch.trim());
|
||||
};
|
||||
|
||||
const dataVideo = data || [];
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Text>Memuat Video...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pt={20} px={{ base: 'md', md: 100 }}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{dataVideo.map((v, k) => (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p="md" radius={26} bg={colors['white-trans-1']} w={{ base: '100%', md: '100%' }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Box
|
||||
component="iframe"
|
||||
src={convertToEmbedUrl(v.linkVideo)}
|
||||
width="100%"
|
||||
height={300}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap="sm" py={10}>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{new Date(v.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
<Text fw="bold" fz="sm" lineClamp={1}>
|
||||
{v.name}
|
||||
</Text>
|
||||
<Spoiler
|
||||
showLabel={
|
||||
<Text fw="bold" fz="sm" c={colors['blue-button']}>
|
||||
Show more
|
||||
</Text>
|
||||
}
|
||||
hideLabel={
|
||||
<Text fw="bold" fz="sm" c={colors['blue-button']}>
|
||||
Hide details
|
||||
</Text>
|
||||
}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
>
|
||||
<Text
|
||||
ta="justify"
|
||||
fz="sm"
|
||||
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
|
||||
/>
|
||||
</Spoiler>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={handlePageChange}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Fix: HAPUS SPASI BERLEBIH DI URL
|
||||
function convertToEmbedUrl(youtubeUrl: string): string {
|
||||
try {
|
||||
const url = new URL(youtubeUrl);
|
||||
const videoId = url.searchParams.get('v');
|
||||
if (!videoId) return youtubeUrl;
|
||||
return `https://www.youtube.com/embed/${videoId}`; // ✅ tanpa spasi!
|
||||
} catch (err) {
|
||||
console.error('Error converting YouTube URL to embed:', err);
|
||||
return youtubeUrl;
|
||||
}
|
||||
}
|
||||
12
src/app/darmasaba/(pages)/desa/galery/video/page.tsx
Normal file
12
src/app/darmasaba/(pages)/desa/galery/video/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// ✅ Load komponen tanpa SSR
|
||||
const VideoContent = dynamic(
|
||||
() => import('./Content'),
|
||||
{ ssr: false, loading: () => <div>Memuat...</div> }
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <VideoContent />;
|
||||
}
|
||||
Reference in New Issue
Block a user