Mengerjakan QC Kak Inno & Kak Ayu Tanggal 16 Oktober

Fix Search
This commit is contained in:
2025-10-17 17:45:56 +08:00
parent 75bf0652b1
commit bbf13c1cf7
19 changed files with 731 additions and 527 deletions

View File

@@ -1,16 +1,4 @@
[ [
{
"id": "cmds8w2q60002vnbe6i8qhkuo",
"name": "Telephone Desa Darmasaba",
"iconUrl": "081239580000",
"imageId": "cmff3nv180003vn6h5jvedidq"
},
{
"id": "cmds8z7u20005vnbegyyvnbk0",
"name": "Email Desa Darmasaba",
"iconUrl": "desadarmasaba@badungkab.go.id",
"imageId": "cmff3ll130001vn6hkhls3f5y"
},
{ {
"id": "cmds9023u0008vnbe3oxmhwyf", "id": "cmds9023u0008vnbe3oxmhwyf",
"name": "Desa Darmasaba", "name": "Desa Darmasaba",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

@@ -6,122 +6,83 @@ import { useCallback, useEffect, useState } from 'react';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
interface FileItem { interface FileItem {
id: string; id: string;
name: string; name: string;
link: string; link: string;
realName: string; realName: string;
createdAt: string | Date; createdAt: string | Date;
category: string; category: string;
path: string; path: string;
mimeType: string; mimeType: string;
} }
export default function FotoContent() { export default function FotoContent() {
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const limit = 9; // ✅ ambil 12 data per page
// Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => { const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
setLoading(true); setLoading(true);
// Using the load function from the component's scope try {
const loadFn = async () => { const query: Record<string, string> = {
try { category: 'image',
const response = await ApiFetch.api.fileStorage.findMany.get({ page: pageNum.toString(),
query: { limit: limit.toString(),
category: 'image', };
page: pageNum.toString(), if (searchTerm) query.search = searchTerm;
limit: '10',
...(searchTerm && { search: searchTerm })
}
});
if (response.status === 200 && response.data) { const response = await ApiFetch.api.fileStorage.findMany.get({ query });
setFiles(response.data.data || []);
setTotalPages(response.data.meta?.totalPages || 1); if (response.status === 200 && response.data) {
} else { setFiles(response.data.data || []);
setFiles([]); setTotalPages(response.data.meta?.totalPages || 1);
} } else {
} catch (err) {
console.error('Load error:', err);
setFiles([]); setFiles([]);
} finally {
setLoading(false);
} }
}; } catch (err) {
console.error('Load error:', err);
loadFn(); setFiles([]);
} finally {
setLoading(false);
}
}, []); }, []);
// Initial load and URL change handler // Initial load + update when URL/search changes
useEffect(() => { useEffect(() => {
const handleRouteChange = () => { const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || ''; const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1'); const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch); setSearch(urlSearch);
setPage(urlPage); setPage(urlPage);
loadData(urlPage, urlSearch); loadData(urlPage, urlSearch);
}; };
// Handle search updates from the search bar
const handleSearchUpdate = (e: Event) => { const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail; const { search } = (e as CustomEvent).detail;
setSearch(search); setSearch(search);
setPage(1); // Reset to first page on new search setPage(1);
loadData(1, search); loadData(1, search);
}; };
// Initial load
handleRouteChange(); handleRouteChange();
// Set up event listeners
window.addEventListener('popstate', handleRouteChange); window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener); window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
// Cleanup
return () => { return () => {
window.removeEventListener('popstate', handleRouteChange); window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener); window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
}; };
}, [loadData]); }, [loadData]);
// ✅ Fetch data // ✅ Update when page/search changes
useEffect(() => { useEffect(() => {
const fetchFiles = async () => { loadData(page, search);
setLoading(true); }, [page, search, loadData]);
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 updateURL = (newSearch: string, newPage: number) => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (newSearch) url.searchParams.set('search', newSearch); if (newSearch) url.searchParams.set('search', newSearch);
@@ -148,7 +109,14 @@ interface FileItem {
<Box pt={20} px={{ base: 'md', md: 100 }}> <Box pt={20} px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, md: 3 }}> <SimpleGrid cols={{ base: 1, md: 3 }}>
{files.map((file) => ( {files.map((file) => (
<Paper key={file.id} mb={50} p="md" radius={26} bg={colors['white-trans-1']} style={{ height: '100%' }}> <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' }}> <Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
<Image <Image
src={file.link} src={file.link}
@@ -159,20 +127,18 @@ interface FileItem {
loading="lazy" loading="lazy"
/> />
</Box> </Box>
<Box> <Stack gap="sm" py={10}>
<Stack gap="sm" py={10}> <Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}> {file.realName || file.name}
{file.realName || file.name} </Text>
</Text> <Text fz="sm" c="dimmed">
<Text fz="sm" c="dimmed"> {new Date(file.createdAt).toLocaleDateString('id-ID', {
{new Date(file.createdAt).toLocaleDateString('id-ID', { day: 'numeric',
day: 'numeric', month: 'long',
month: 'long', year: 'numeric',
year: 'numeric', })}
})} </Text>
</Text> </Stack>
</Stack>
</Box>
</Paper> </Paper>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -181,4 +147,4 @@ interface FileItem {
</Center> </Center>
</Box> </Box>
); );
} }

View File

@@ -146,24 +146,24 @@ function Page() {
<Title order={3}>Ajukan Permohonan</Title> <Title order={3}>Ajukan Permohonan</Title>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Nama</Text>} label={<Text fz="sm" fw="bold">Nama</Text>}
placeholder="masukkan nama" placeholder="Masukkan nama"
onChange={(val) => (stateCreate.create.form.nama = val.target.value)} onChange={(val) => (stateCreate.create.form.nama = val.target.value)}
/> />
<TextInput <TextInput
type="number" type="number"
label={<Text fz="sm" fw="bold">NIK</Text>} label={<Text fz="sm" fw="bold">NIK</Text>}
placeholder="masukkan NIK" placeholder="Masukkan NIK"
onChange={(val) => (stateCreate.create.form.nik = val.target.value)} onChange={(val) => (stateCreate.create.form.nik = val.target.value)}
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Alamat</Text>} label={<Text fz="sm" fw="bold">Alamat</Text>}
placeholder="masukkan alamat" placeholder="Masukkan alamat"
onChange={(val) => (stateCreate.create.form.alamat = val.target.value)} onChange={(val) => (stateCreate.create.form.alamat = val.target.value)}
/> />
<TextInput <TextInput
type="number" type="number"
label={<Text fz="sm" fw="bold">Nomor KK</Text>} label={<Text fz="sm" fw="bold">Nomor KK</Text>}
placeholder="masukkan Nomor KK" placeholder="Masukkan Nomor KK"
onChange={(val) => (stateCreate.create.form.nomorKk = val.target.value)} onChange={(val) => (stateCreate.create.form.nomorKk = val.target.value)}
/> />
<Select <Select

View File

@@ -72,21 +72,21 @@ function Page() {
) )
} }
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Stack pos="relative" bg={colors.Bg} py="xl" gap="22" style={{ overflow: 'auto' }}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 50, lg: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Box px={{ base: 'md', md: 100 }} > <Box px={{ base: 'md', md: 50, lg: 100 }} >
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}> <Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Jumlah Penduduk Usia Kerja Yang Menganggur Jumlah Penduduk Usia Kerja Yang Menganggur
</Text> </Text>
</Box> </Box>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 50, lg: 100 }}>
<Stack gap={'lg'} justify='center'> <Stack gap={'lg'} justify='center'>
<Paper p={'lg'}> <Paper p={'lg'}>
<Text fw={'bold'} fz={'h3'}>Pengangguran Berdasarkan Usia</Text> <Text fw={'bold'} fz={'h3'}>Pengangguran Berdasarkan Usia</Text>
{mounted && donutGrafikNganggurData.length > 0 ? (<Box style={{ width: '100%', height: 'auto', minHeight: 200 }}> {mounted && donutGrafikNganggurData.length > 0 ? (<Box style={{ width: '100%', height: 'auto', minHeight: 200 }}>
<Box w="100%" style={{ maxWidth: 400, margin: "0 auto" }}> <Box w="100%" maw={{ base: '100%', md: 400 }} mx="auto">
<PieChart <PieChart
w="100%" w="100%"
h={250} // lebih kecil biar aman di mobile h={250} // lebih kecil biar aman di mobile
@@ -133,7 +133,7 @@ function Page() {
<Box w="100%" style={{ maxWidth: 400, margin: "0 auto" }}> <Box w="100%" style={{ maxWidth: 400, margin: "0 auto" }}>
<PieChart <PieChart
w="100%" w="100%"
h={250} // lebih kecil biar aman di mobile h="min(250px, 50vh)" // lebih kecil biar aman di mobile
withLabelsLine withLabelsLine
labelsPosition="outside" labelsPosition="outside"
labelsType="percent" labelsType="percent"

View File

@@ -199,7 +199,7 @@ function Page() {
<TableTd ta={'center'}>{item.totalUnemployment}</TableTd> <TableTd ta={'center'}>{item.totalUnemployment}</TableTd>
<TableTd ta={'center'}>{item.educatedUnemployment}</TableTd> <TableTd ta={'center'}>{item.educatedUnemployment}</TableTd>
<TableTd ta={'center'}>{item.uneducatedUnemployment}</TableTd> <TableTd ta={'center'}>{item.uneducatedUnemployment}</TableTd>
<TableTd ta={'center'}>{item.percentageChange}</TableTd> <TableTd ta={'center'}>{item.percentageChange}%</TableTd>
</TableTr> </TableTr>
))} ))}
</TableTbody> </TableTbody>

View File

@@ -29,29 +29,29 @@ function Page() {
} }
// Add this check before the return statement // Add this check before the return statement
if (data.length === 0) { if (data.length === 0) {
return ( return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap={22}> <Stack pos="relative" bg={colors.Bg} py="xl" gap={22}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
<Text fz={{ base: 'h1', md: '2.5rem' }} c={colors['blue-button']} fw="bold"> <Text fz={{ base: 'h1', md: '2.5rem' }} c={colors['blue-button']} fw="bold">
Sektor Unggulan Desa Darmasaba Sektor Unggulan Desa Darmasaba
</Text> </Text>
<Text c="dimmed" mt="md"> <Text c="dimmed" mt="md">
Data sektor unggulan belum tersedia Data sektor unggulan belum tersedia
</Text> </Text>
</Box> </Box>
</Stack> </Stack>
); );
} }
const chartData = data const chartData = data
.filter(item => item?.name && typeof item.value === 'number') .filter(item => item?.name && typeof item.value === 'number')
.map((item) => ({ .map((item) => ({
id: item.id, id: item.id,
sektor: item.name, sektor: item.name,
Ton: item.value, Ton: item.value,
})); }));
return ( return (
@@ -71,23 +71,34 @@ const chartData = data
return ( return (
<Paper p={'xl'} key={k}> <Paper p={'xl'} key={k}>
<Text fw={'bold'} fz={'h4'}>{v.name}</Text> <Text fw={'bold'} fz={'h4'}>{v.name}</Text>
<Text fz={'h4'} ta={'justify'} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: v.description || '' }} /> <Text fz={'h4'} ta={'justify'} style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.description || '' }} />
</Paper> </Paper>
) )
})} })}
<Paper p={'xl'}> <Box style={{ width: '100%', overflowX: 'auto' }}>
<Text pb={10} fw={'bold'} fz={'h4'}>Statistik Sektor Unggulan Darmasaba</Text> <Paper p="xl">
<BarChart <Text pb={10} fw="bold" fz="h4">Statistik Sektor Unggulan Darmasaba</Text>
p={10} <Box style={{ width: '100%', minWidth: '600px' }}>
h={300} <BarChart
data={chartData} p={10}
dataKey="sektor" h={300}
series={[ data={chartData}
{ name: 'Ton', color: colors['blue-button'] }, dataKey="sektor"
]} series={[
tickLine="y" { name: 'Ton', color: colors['blue-button'] },
/> ]}
</Paper> tickLine="y"
tooltipAnimationDuration={200}
withTooltip
style={{
fontFamily: 'inherit',
}}
xAxisLabel="Sektor"
yAxisLabel="Ton"
/>
</Box>
</Paper>
</Box>
</Stack> </Stack>
</Box> </Box>
</Stack> </Stack>

View File

@@ -50,10 +50,12 @@ function Page() {
</Text> </Text>
</Box> </Box>
<TextInput <TextInput
placeholder='Cari kontak darurat, nama, atau nomor...' radius={"lg"}
leftSection={<IconSearch size={20} />} placeholder='Cari Kontak Darurat'
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "25%" }}
/> />
</Group> </Group>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>
@@ -95,10 +97,12 @@ function Page() {
</Text> </Text>
</Box> </Box>
<TextInput <TextInput
placeholder='Cari kontak darurat, nama, atau nomor...' radius={"lg"}
leftSection={<IconSearch size={20} />} placeholder='Cari Kontak Darurat'
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/> />
</Group> </Group>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>

View File

@@ -4,7 +4,7 @@ import colors from '@/con/colors';
import { Box, Button, Center, ColorSwatch, Flex, Group, Modal, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Center, ColorSwatch, Flex, Group, Modal, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates'; import { DateTimePicker } from '@mantine/dates';
import { useDebouncedValue, useDisclosure, useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useDisclosure, useShallowEffect } from '@mantine/hooks';
import { IconArrowRight, IconPlus } from '@tabler/icons-react'; import { IconArrowRight, IconPlus, IconSearch } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
@@ -56,10 +56,13 @@ function Page() {
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<BackButton /> <BackButton />
<TextInput <TextInput
placeholder="Cari laporan" radius={"lg"}
value={search} placeholder='Cari Laporan Publik'
onChange={(e) => setSearch(e.currentTarget.value)} value={search}
/> onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/>
</Flex> </Flex>
</Box> </Box>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>

View File

@@ -1,5 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { Transition } from '@mantine/core';
import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong'; import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong';
import { import {
Badge, Badge,
@@ -23,12 +27,11 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowRight, IconCalendar } from '@tabler/icons-react'; import { IconArrowRight, IconCalendar } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions'; import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
export default function Content({ kategori }: { kategori: string }) { export default function Content({ kategori }: { kategori: string }) {
const router = useTransitionRouter(); const router = useTransitionRouter();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [animateKey, setAnimateKey] = useState(0);
const state = useProxy(gotongRoyongState.kegiatanDesa); const state = useProxy(gotongRoyongState.kegiatanDesa);
const featuredState = useProxy(gotongRoyongState.kegiatanDesa.findFirst); const featuredState = useProxy(gotongRoyongState.kegiatanDesa.findFirst);
@@ -37,119 +40,178 @@ export default function Content({ kategori }: { kategori: string }) {
const paginatedNews = state.findMany.data || []; const paginatedNews = state.findMany.data || [];
const totalPages = state.findMany.totalPages || 1; const totalPages = state.findMany.totalPages || 1;
// Load data // Load data awal
useEffect(() => { useEffect(() => {
gotongRoyongState.kegiatanDesa.findFirst.load(kategori); gotongRoyongState.kegiatanDesa.findFirst.load(kategori);
}, [kategori]); }, [kategori]);
// Load daftar berita
useEffect(() => { useEffect(() => {
state.findMany.load(page, 3, '', kategori); state.findMany.load(page, 3, '', kategori);
setAnimateKey((prev) => prev + 1); // trigger animasi halus saat page berubah
}, [page, kategori]); }, [page, kategori]);
// Tampilan kosong
if (!featuredState.loading && !featured) {
return (
<Center py={100}>
<Stack align="center" gap="sm">
<Title order={3}>Belum Ada Data Gotong Royong</Title>
<Text c="dimmed">Tidak ada data gotong royong yang tersedia saat ini.</Text>
</Stack>
</Center>
);
}
return ( return (
<Box py={20}> <Box py={20}>
<Container size="xl" px={{ base: 'md', md: 'xl' }}> <Container size="xl" px={{ base: 'md', md: 'xl' }}>
{/* === Gotong Royong Utama === */} {/* === Gotong Royong Utama === */}
{featuredState.loading ? ( <Transition mounted={!featuredState.loading} transition="fade" duration={250} timingFunction="ease">
<Center><Skeleton h={400} /></Center> {(styles) => (
) : featured ? ( <div style={styles}>
<Box mb={50}> {featured ? (
<Text fz="h2" fw={700} mb="md">Gotong Royong Utama</Text> <Box mb={50}>
<Paper shadow="md" radius="md" withBorder> <Text fz="h2" fw={700} mb="md">Gotong Royong Utama</Text>
<Grid gutter={0}> <Paper shadow="md" radius="md" withBorder>
<GridCol span={{ base: 12, md: 6 }}> <Grid gutter={0}>
<Image <GridCol span={{ base: 12, md: 6 }}>
src={featured.image?.link} <Image
alt={featured.judul || 'Berita Utama'} src={featured.image?.link || '/images/placeholder.jpg'}
height={400} alt={featured.judul || 'Berita Utama'}
fit="cover" height={400}
radius="md" fit="cover"
style={{ borderBottomRightRadius: 0, borderTopRightRadius: 0 }} radius="md"
loading="lazy" style={{ borderBottomRightRadius: 0, borderTopRightRadius: 0 }}
/> loading="lazy"
</GridCol> />
<GridCol span={{ base: 12, md: 6 }} p="xl"> </GridCol>
<Stack h="100%" justify="space-between"> <GridCol span={{ base: 12, md: 6 }} p="xl">
<div> <Stack h="100%" justify="space-between">
<Badge color="blue" variant="light" mb="md"> <div>
{featured.kategoriKegiatan?.nama || kategori} <Badge color="blue" variant="light" mb="md">
</Badge> {featured.kategoriKegiatan?.nama || kategori}
<Title order={2} mb="md">{featured.judul}</Title> </Badge>
<Text c="dimmed" lineClamp={3} mb="md" dangerouslySetInnerHTML={{ __html: featured.deskripsiLengkap }} /> <Title order={2} mb="md">{featured.judul}</Title>
</div> <Text
<Group justify="apart" mt="auto"> c="dimmed"
<Group gap="xs"> lineClamp={3}
<IconCalendar size={18} /> mb="md"
<Text size="sm"> dangerouslySetInnerHTML={{ __html: featured.deskripsiLengkap }}
{new Date(featured.createdAt).toLocaleDateString('id-ID', { />
day: 'numeric', </div>
month: 'long', <Group justify="apart" mt="auto">
year: 'numeric', <Group gap="xs">
})} <IconCalendar size={18} />
</Text> <Text size="sm">
</Group> {new Date(featured.createdAt).toLocaleDateString('id-ID', {
<Button day: 'numeric',
variant="light" month: 'long',
rightSection={<IconArrowRight size={16} />} year: 'numeric',
onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${featured.id}`)} })}
> </Text>
Baca Selengkapnya </Group>
</Button> <Button
</Group> variant="light"
</Stack> rightSection={<IconArrowRight size={16} />}
</GridCol> onClick={() =>
</Grid> router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${featured.id}`)
</Paper> }
</Box> >
) : null} Baca Selengkapnya
</Button>
</Group>
</Stack>
</GridCol>
</Grid>
</Paper>
</Box>
) : (
<Skeleton h={400} radius="md" />
)}
</div>
)}
</Transition>
{/* === Daftar Gotong Royong === */} {/* === Daftar Gotong Royong (Pagination + Fade-in Halus) === */}
<Box mt={50}> <Box mt={50}>
<Title order={2} mb="md">Daftar Gotong Royong</Title> <Title order={2} mb="md">Daftar Gotong Royong</Title>
<Divider mb="xl" /> <Divider mb="xl" />
{state.findMany.loading ? ( <AnimatePresence mode="wait">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl"> <motion.div
{Array(3).fill(0).map((_, i) => ( key={animateKey}
<Skeleton key={i} h={300} radius="md" /> initial={{ opacity: 0, y: 10 }}
))} animate={{ opacity: 1, y: 0 }}
</SimpleGrid> exit={{ opacity: 0, y: -10 }}
) : paginatedNews.length === 0 ? ( transition={{ duration: 0.25, ease: 'easeInOut' }}
<Text c="dimmed" ta="center">Belum ada gotong royong di kategori &quot;{kategori}&quot;.</Text> >
) : ( {state.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{paginatedNews.map((item) => ( {Array(3)
<Card .fill(0)
key={item.id} .map((_, i) => (
shadow="sm" <Skeleton key={i} h={300} radius="md" />
p="lg" ))}
radius="md" </SimpleGrid>
withBorder ) : paginatedNews.length === 0 ? (
onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${item.id}`)} <Center py={50}>
style={{ cursor: 'pointer' }} <Stack align="center" gap="sm">
> <Title order={3}>Tidak Ada Data</Title>
<Card.Section> <Text c="dimmed">Belum ada data gotong royong yang tersedia.</Text>
<Image src={item.image?.link} height={200} alt={item.judul} fit="cover" loading="lazy"/> </Stack>
</Card.Section> </Center>
<Badge color="blue" variant="light" mt="md"> ) : (
{item.kategoriKegiatan?.nama || kategori} <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
</Badge> {paginatedNews.map((item) => (
<Text fw={600} size="lg" mt="sm" lineClamp={2}>{item.judul}</Text> <Card
<Text size="sm" c="dimmed" lineClamp={3} style={{wordBreak: "break-word", whiteSpace: "normal"}} mt="xs" dangerouslySetInnerHTML={{ __html: item.deskripsiLengkap }} /> key={item.id}
<Group justify="apart" mt="md" gap="xs"> shadow="sm"
<Text size="xs" c="dimmed"> p="lg"
{new Date(item.createdAt).toLocaleDateString('id-ID', { radius="md"
day: 'numeric', withBorder
month: 'short', onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${item.id}`)}
year: 'numeric', style={{ cursor: 'pointer' }}
})} >
</Text> <Card.Section>
<Badge color="gray" variant="outline">Baca Selengkapnya</Badge> <Image
</Group> src={item.image?.link || '/images/placeholder-small.jpg'}
</Card> height={200}
))} alt={item.judul}
</SimpleGrid> fit="cover"
)} loading="lazy"
/>
</Card.Section>
<Badge color="blue" variant="light" mt="md">
{item.kategoriKegiatan?.nama || kategori}
</Badge>
<Text fw={600} size="lg" mt="sm" lineClamp={2}>
{item.judul}
</Text>
<Text
size="sm"
c="dimmed"
lineClamp={3}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
mt="xs"
dangerouslySetInnerHTML={{ __html: item.deskripsiLengkap }}
/>
<Group justify="apart" mt="md" gap="xs">
<Text size="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
<Badge color="gray" variant="outline">Baca Selengkapnya</Badge>
</Group>
</Card>
))}
</SimpleGrid>
)}
</motion.div>
</AnimatePresence>
{/* Pagination */} {/* Pagination */}
<Center mt="xl"> <Center mt="xl">
@@ -166,4 +228,4 @@ export default function Content({ kategori }: { kategori: string }) {
</Container> </Container>
</Box> </Box>
); );
} }

View File

@@ -1,47 +1,64 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong'; import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong';
import { Badge, Box, Button, Card, Center, Container, Divider, Flex, Grid, GridCol, Group, Image, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core'; import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Flex,
Grid,
GridCol,
Group,
Image,
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
Transition,
} from '@mantine/core';
import { IconArrowRight, IconCalendar } from '@tabler/icons-react'; import { IconArrowRight, IconCalendar } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions'; import { motion } from 'framer-motion';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useTransitionRouter(); const router = useRouter();
// Parameter URL
const search = searchParams.get('search') || ''; const search = searchParams.get('search') || '';
const page = parseInt(searchParams.get('page') || '1'); const page = parseInt(searchParams.get('page') || '1');
// Gunakan proxy untuk state
const state = useProxy(gotongRoyongState.kegiatanDesa); const state = useProxy(gotongRoyongState.kegiatanDesa);
const featured = useProxy(gotongRoyongState.kegiatanDesa.findFirst); // ✅ Berita utama const featured = useProxy(gotongRoyongState.kegiatanDesa.findFirst);
const loadingGrid = state.findMany.loading; const loadingGrid = state.findMany.loading;
const loadingFeatured = featured.loading; const loadingFeatured = featured.loading;
// Load berita utama (hanya sekali)
useEffect(() => { useEffect(() => {
if (!featured.data && !loadingFeatured) { if (!featured.data && !loadingFeatured) {
gotongRoyongState.kegiatanDesa.findFirst.load(); gotongRoyongState.kegiatanDesa.findFirst.load();
} }
}, [featured.data, loadingFeatured]); }, [featured.data, loadingFeatured]);
// Load berita terbaru (untuk grid) saat page/search berubah
useEffect(() => { useEffect(() => {
const limit = 3; // Sesuaikan dengan tampilan grid const limit = 3;
state.findMany.load(page, limit, search); state.findMany.load(page, limit, search);
}, [page, search]); }, [page, search]);
// Update URL saat page berubah
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
const url = new URLSearchParams(searchParams.toString()); const url = new URLSearchParams(searchParams.toString());
if (search) url.set('search', search); if (search) url.set('search', search);
if (newPage > 1) url.set('page', newPage.toString()); if (newPage > 1) url.set('page', newPage.toString());
else url.delete('page'); // biar page=1 ga muncul di URL else url.delete('page');
router.replace(`?${url.toString()}`); router.replace(`?${url.toString()}`);
}; };
@@ -49,121 +66,177 @@ function Page() {
const paginatedNews = state.findMany.data || []; const paginatedNews = state.findMany.data || [];
const totalPages = state.findMany.totalPages || 1; const totalPages = state.findMany.totalPages || 1;
return ( // Animasi transisi halus tapi tetap instant load
<Box py={20}> const MotionBox = motion(Box as any);
<Container size="xl" px={{ base: "md", md: "xl" }}>
{/* === Gotong royong Utama (Tetap) === */}
{loadingFeatured ? (
<Center><Skeleton h={400} /></Center>
) : featuredData ? (
<Box mb={50}>
<Text fz="h2" fw={700} mb="md">Gotong royong Utama</Text>
<Paper shadow="md" radius="md" withBorder>
<Grid gutter={0}>
<GridCol span={{ base: 12, md: 6 }}>
<Image
src={featuredData.image?.link || '/images/placeholder.jpg'}
alt={featuredData.judul || 'Gotong royong Utama'}
height={400}
fit="cover"
radius="md"
style={{ borderBottomRightRadius: 0, borderTopRightRadius: 0 }}
loading="lazy"
/>
</GridCol>
<GridCol span={{ base: 12, md: 6 }} p="xl">
<Stack h="100%" justify="space-between">
<div>
<Badge color="blue" variant="light" mb="md">
{featuredData.kategoriKegiatan?.nama || 'Gotong royong'}
</Badge>
<Title order={2} mb="md">{featuredData.judul}</Title>
<Text c="dimmed" lineClamp={3} mb="md" dangerouslySetInnerHTML={{ __html: featuredData.deskripsiSingkat }} />
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
{new Date(featuredData.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Text>
</Group>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${featuredData.kategoriKegiatan?.nama}/${featuredData.id}`)}
>
Baca Selengkapnya
</Button>
</Group>
</Stack>
</GridCol>
</Grid>
</Paper>
</Box>
) : null}
{/* === Gotong royong Terbaru (Berubah Saat Pagination) === */} // fallback kosong
if (!loadingGrid && !loadingFeatured && paginatedNews.length === 0) {
return (
<Container size="xl" py={80} ta="center">
<Title order={2} mb="md">Belum Ada Data Gotong Royong</Title>
<Text c="dimmed">Tidak ada data gotong royong yang tersedia saat ini.</Text>
</Container>
);
}
return (
<MotionBox
key={`${page}-${search}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
py={20}
>
<Container size="xl" px={{ base: 'md', md: 'xl' }}>
{/* === Gotong Royong Utama === */}
<Transition mounted={!loadingFeatured} transition="fade" duration={200} timingFunction="ease-out">
{(styles) =>
featuredData ? (
<Box mb={50} style={styles}>
<Text fz="h2" fw={700} mb="md">Gotong royong Utama</Text>
<Paper shadow="md" radius="md" withBorder>
<Grid gutter={0}>
<GridCol span={{ base: 12, md: 6 }}>
<Image
src={featuredData.image?.link || '/images/placeholder.jpg'}
alt={featuredData.judul || 'Gotong royong Utama'}
height={400}
fit="cover"
radius="md"
style={{ borderBottomRightRadius: 0, borderTopRightRadius: 0 }}
loading="lazy"
/>
</GridCol>
<GridCol span={{ base: 12, md: 6 }} p="xl">
<Stack h="100%" justify="space-between">
<div>
<Badge color="blue" variant="light" mb="md">
{featuredData.kategoriKegiatan?.nama || 'Gotong royong'}
</Badge>
<Title order={2} mb="md">{featuredData.judul}</Title>
<Text
c="dimmed"
lineClamp={3}
mb="md"
dangerouslySetInnerHTML={{ __html: featuredData.deskripsiSingkat }}
/>
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
{new Date(featuredData.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Group>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() =>
router.push(
`/darmasaba/lingkungan/gotong-royong/${featuredData.kategoriKegiatan?.nama}/${featuredData.id}`
)
}
>
Baca Selengkapnya
</Button>
</Group>
</Stack>
</GridCol>
</Grid>
</Paper>
</Box>
) : (
<Skeleton h={400} radius="md" mb="xl" />
)
}
</Transition>
{/* === Gotong royong Terbaru === */}
<Box mt={50}> <Box mt={50}>
<Title order={2} mb="md">Gotong royong Terbaru</Title> <Title order={2} mb="md">Gotong royong Terbaru</Title>
<Divider mb="xl" /> <Divider mb="xl" />
{loadingGrid ? ( <Transition mounted={!loadingGrid} transition="fade" duration={200} timingFunction="ease-out">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl"> {(styles) =>
{Array(3).fill(0).map((_, i) => ( loadingGrid ? (
<Skeleton key={i} h={300} radius="md" /> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
))} {Array(3)
</SimpleGrid> .fill(0)
) : paginatedNews.length === 0 ? ( .map((_, i) => (
<Text c="dimmed" ta="center">Tidak ada gotong royong ditemukan.</Text> <Skeleton key={i} h={300} radius="md" />
) : ( ))}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl"> </SimpleGrid>
{paginatedNews.map((item) => ( ) : paginatedNews.length === 0 ? (
<Card <Text c="dimmed" ta="center">
key={item.id} Tidak ada gotong royong ditemukan.
shadow="sm" </Text>
p="lg" ) : (
radius="md" <Box style={styles}>
withBorder <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
> {paginatedNews.map((item) => (
<Card.Section> <Card key={item.id} shadow="sm" p="lg" radius="md" withBorder>
<Image <Card.Section>
src={item.image?.link || '/images/placeholder-small.jpg'} <Image
height={200} src={item.image?.link || '/images/placeholder-small.jpg'}
alt={item.judul} height={200}
fit="cover" alt={item.judul}
loading="lazy" fit="cover"
/> loading="lazy"
</Card.Section> />
</Card.Section>
<Badge color="blue" variant="light" mt="md"> <Badge color="blue" variant="light" mt="md">
{item.kategoriKegiatan?.nama || 'Gotong royong'} {item.kategoriKegiatan?.nama || 'Gotong royong'}
</Badge> </Badge>
<Text fw={600} size="lg" mt="sm" lineClamp={2}>{item.judul}</Text> <Text fw={600} size="lg" mt="sm" lineClamp={2}>
{item.judul}
</Text>
<Text size="sm" c="dimmed" lineClamp={3} mt="xs" dangerouslySetInnerHTML={{ __html: item.deskripsiSingkat }} /> <Text
size="sm"
c="dimmed"
lineClamp={3}
mt="xs"
dangerouslySetInnerHTML={{ __html: item.deskripsiSingkat }}
/>
<Flex align="center" justify="apart" mt="md" gap="xs"> <Flex align="center" justify="apart" mt="md" gap="xs">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', { {new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
year: 'numeric' year: 'numeric',
})} })}
</Text> </Text>
<Button p="xs" variant="light" rightSection={<IconArrowRight size={16} />} onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${item.kategoriKegiatan?.nama}/${item.id}`)}>Baca Selengkapnya</Button>
</Flex>
</Card>
))}
</SimpleGrid>
)}
{/* Pagination hanya untuk berita terbaru */} <Button
p="xs"
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() =>
router.push(
`/darmasaba/lingkungan/gotong-royong/${item.kategoriKegiatan?.nama}/${item.id}`
)
}
>
Baca Selengkapnya
</Button>
</Flex>
</Card>
))}
</SimpleGrid>
</Box>
)
}
</Transition>
{/* Pagination */}
<Center mt="xl"> <Center mt="xl">
<Pagination <Pagination
total={totalPages} total={totalPages}
@@ -176,9 +249,6 @@ function Page() {
</Center> </Center>
</Box> </Box>
</Container> </Container>
</Box> </MotionBox>
); );
} }
export default Page;

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import stateBimbinganBelajarDesa from '@/app/admin/(dashboard)/_state/pendidikan/bimbingan-belajar-desa'; import stateBimbinganBelajarDesa from '@/app/admin/(dashboard)/_state/pendidikan/bimbingan-belajar-desa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Paper, SimpleGrid, Skeleton, Stack, Text, Title, Tooltip, Divider, Badge } from '@mantine/core'; import { Box, Paper, SimpleGrid, Skeleton, Stack, Text, Title, Tooltip, Divider, Badge, Group } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { IconMapPin, IconCalendarTime, IconBook2 } from '@tabler/icons-react'; import { IconMapPin, IconCalendarTime, IconBook2 } from '@tabler/icons-react';
@@ -49,46 +49,46 @@ function Page() {
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="xl"> <SimpleGrid cols={{ base: 1, md: 3 }} spacing="xl">
<Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}> <Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}>
<Stack gap="sm"> <Stack gap="sm">
<Box> <Group>
<Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateTujuanProgram.findById.data?.judul}
</Badge>
<Tooltip label="Gambaran manfaat utama program" position="top-start" withArrow> <Tooltip label="Gambaran manfaat utama program" position="top-start" withArrow>
<Box> <Box>
<IconBook2 size={36} stroke={1.5} color={colors['blue-button']} /> <IconBook2 size={36} stroke={1.5} color={colors['blue-button']} />
</Box> </Box>
</Tooltip> </Tooltip>
</Box> <Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateTujuanProgram.findById.data?.judul}
</Badge>
</Group>
<Text fz="md" style={{wordBreak: "break-word", whiteSpace: "normal"}} lh={1.6} dangerouslySetInnerHTML={{ __html: stateTujuanProgram.findById.data?.deskripsi }} /> <Text fz="md" style={{wordBreak: "break-word", whiteSpace: "normal"}} lh={1.6} dangerouslySetInnerHTML={{ __html: stateTujuanProgram.findById.data?.deskripsi }} />
</Stack> </Stack>
</Paper> </Paper>
<Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}> <Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}>
<Stack gap="sm"> <Stack gap="sm">
<Box> <Group>
<Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateLokasiDanJadwal.findById.data?.judul}
</Badge>
<Tooltip label="Tempat dan waktu pelaksanaan" position="top-start" withArrow> <Tooltip label="Tempat dan waktu pelaksanaan" position="top-start" withArrow>
<Box> <Box>
<IconMapPin size={36} stroke={1.5} color={colors['blue-button']} /> <IconMapPin size={36} stroke={1.5} color={colors['blue-button']} />
</Box> </Box>
</Tooltip> </Tooltip>
</Box> <Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateLokasiDanJadwal.findById.data?.judul}
</Badge>
</Group>
<Text fz="md" lh={1.6} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateLokasiDanJadwal.findById.data?.deskripsi }} /> <Text fz="md" lh={1.6} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateLokasiDanJadwal.findById.data?.deskripsi }} />
</Stack> </Stack>
</Paper> </Paper>
<Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}> <Paper p="xl" radius="lg" shadow="md" withBorder bg={colors['white-trans-1']}>
<Stack gap="sm"> <Stack gap="sm">
<Box> <Group>
<Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateFasilitas.findById.data?.judul}
</Badge>
<Tooltip label="Sarana yang disediakan untuk peserta" position="top-start" withArrow> <Tooltip label="Sarana yang disediakan untuk peserta" position="top-start" withArrow>
<Box> <Box>
<IconCalendarTime size={36} stroke={1.5} color={colors['blue-button']} /> <IconCalendarTime size={36} stroke={1.5} color={colors['blue-button']} />
</Box> </Box>
</Tooltip> </Tooltip>
</Box> <Badge variant="gradient" gradient={{ from: colors['blue-button'], to: 'cyan' }} size="lg" radius="sm" mb="sm">
{stateFasilitas.findById.data?.judul}
</Badge>
</Group>
<Text fz="md" lh={1.6} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateFasilitas.findById.data?.deskripsi }} /> <Text fz="md" lh={1.6} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateFasilitas.findById.data?.deskripsi }} />
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -56,7 +56,7 @@ export default function Content() {
try { try {
await state.dataPerpustakaan.findMany.load( await state.dataPerpustakaan.findMany.load(
currentPage, currentPage,
10, 3,
searchQuery, searchQuery,
'' ''
); );

View File

@@ -1,6 +1,63 @@
'use client' 'use client'
import { ActionIcon, Anchor, Box, Button, Center, Container, Divider, Flex, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core'; import { ActionIcon, Anchor, Box, Button, Center, Container, Divider, Flex, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
import { IconAt, IconBrandFacebook, IconBrandInstagram, IconBrandTwitter, IconBrandWhatsapp } from '@tabler/icons-react'; import { IconAt, IconBrandFacebook, IconBrandInstagram, IconBrandTiktok, IconBrandYoutube } from '@tabler/icons-react';
const sosialMedia = [
{
title: "Facebook",
link: "https://www.facebook.com/DarmasabaDesaku",
icon: IconBrandFacebook,
},
{
title: "Instagram",
link: "https://www.instagram.com/ddarmasaba/",
icon: IconBrandInstagram,
},
{
title: "Youtube",
link: "https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg",
icon: IconBrandYoutube,
},
{
title: "Tiktok",
link: "https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc",
icon: IconBrandTiktok,
},
]
const layanandesa = [
{
title: "Administrasi Kependudukan",
link: "/darmasaba/desa/layanan/",
},
{
title: "Layanan Sosial",
link: "/darmasaba/ekonomi/program-kemiskinan",
},
{
title: "Pengaduan Masyarakat",
link: "/darmasaba/keamanan/laporan-publik",
},
{
title: "Informasi Publik",
link: "/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba",
},
]
const tautanPenting = [
{
title: "Portal Badung",
link: "/darmasaba/desa/berita/semua",
},
{
title: "E-Government",
link: "/darmasaba/inovasi/desa-digital-smart-village",
},
{
title: "Transparansi",
link: "/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba",
}
]
function Footer() { function Footer() {
return ( return (
@@ -46,7 +103,7 @@ function Footer() {
<Group justify="apart" align="center" mt="lg"> <Group justify="apart" align="center" mt="lg">
<Text c="#F3F2EC" ta="center" fz="md" fw={700} style={{ fontStyle: 'italic' }}>&quot;Desa Kuat, Warga Sejahtera!&quot;</Text> <Text c="#F3F2EC" ta="center" fz="md" fw={700} style={{ fontStyle: 'italic' }}>&quot;Desa Kuat, Warga Sejahtera!&quot;</Text>
<ActionIcon size={80} radius="xl" variant="transparent"> <ActionIcon size={80} radius="xl" variant="transparent">
<Image src="/chatbot-removebg-preview.png" alt="Logo Desa" width={80} height={80} loading="lazy"/> <Image src="/chatbot-removebg-preview.png" alt="Logo Desa" width={80} height={80} loading="lazy" />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Stack> </Stack>
@@ -64,31 +121,39 @@ function Footer() {
Darmasaba adalah desa budaya yang kaya akan tradisi dan nilai-nilai warisan Bali. Darmasaba adalah desa budaya yang kaya akan tradisi dan nilai-nilai warisan Bali.
</Text> </Text>
<Flex gap="md" mt="sm" c="#F3F2EC"> <Flex gap="md" mt="sm" c="#F3F2EC">
<ActionIcon variant="subtle" color="white"><IconBrandFacebook size={22} /></ActionIcon> {sosialMedia.map((item) => (
<ActionIcon variant="subtle" color="white"><IconBrandInstagram size={22} /></ActionIcon> <ActionIcon
<ActionIcon variant="subtle" color="white"><IconBrandTwitter size={22} /></ActionIcon> key={item.title}
<ActionIcon variant="subtle" color="white"><IconBrandWhatsapp size={22} /></ActionIcon> component="a"
href={item.link}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="white"
>
<item.icon size={22} />
</ActionIcon>
))}
</Flex> </Flex>
</Stack> </Stack>
</Box> </Box>
<Box> <Box>
<Stack gap="xs"> <Stack gap="xs">
<Text c="white" fz="md" fw={700}>Layanan Desa</Text> <Text c="white" fz="md" fw={700}>Layanan Desa</Text>
<Anchor c="#F3F2EC" fz="xs">Administrasi Kependudukan</Anchor> {layanandesa.map((item) => (
<Anchor c="#F3F2EC" fz="xs">Layanan Sosial</Anchor> <Anchor key={item.title} c="#F3F2EC" fz="xs" href={item.link}>{item.title}</Anchor>
<Anchor c="#F3F2EC" fz="xs">Pengaduan Masyarakat</Anchor> ))}
<Anchor c="#F3F2EC" fz="xs">Informasi Publik</Anchor>
</Stack> </Stack>
</Box> </Box>
<Box> <Box>
<Stack gap="xs"> <Stack gap="xs">
<Text c="white" fz="md" fw={700}>Tautan Penting</Text> <Text c="white" fz="md" fw={700}>Tautan Penting</Text>
<Anchor c="#F3F2EC" fz="xs">Portal Badung</Anchor> {tautanPenting.map((item) => (
<Anchor c="#F3F2EC" fz="xs">E-Government</Anchor> <Anchor key={item.title} c="#F3F2EC" fz="xs" href={item.link}>{item.title}</Anchor>
<Anchor c="#F3F2EC" fz="xs">Transparansi</Anchor> ))}
<Anchor c="#F3F2EC" fz="xs">Unduhan</Anchor>
</Stack> </Stack>
</Box> </Box>

View File

@@ -11,16 +11,20 @@ export function NavbarSearch() {
// Close when clicking outside // Close when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { const target = event.target as HTMLElement;
// Only close if clicking outside both the search input and results
if (
containerRef.current &&
!containerRef.current.contains(target) &&
!target.closest('.search-result-item') // Add a class to your search result items
) {
setIsOpen(false); setIsOpen(false);
stateNav.clear(); stateNav.clear();
} }
} }
// Add event listener
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => { return () => {
// Clean up
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, []); }, []);

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'; 'use client';
import searchState, { debouncedFetch } from '@/app/api/[[...slugs]]/_lib/search/searchState'; import searchState, { debouncedFetch } from '@/app/api/[[...slugs]]/_lib/search/searchState';
import { Box, Center, Loader, Modal, Text, TextInput } from '@mantine/core'; import { Box, Center, Loader, Popover, Text, TextInput } from '@mantine/core';
import { IconX } from '@tabler/icons-react'; import { IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@@ -8,14 +9,14 @@ import getDetailUrl from './searchUrl';
export default function GlobalSearch() { export default function GlobalSearch() {
const snap = useSnapshot(searchState); const snap = useSnapshot(searchState);
const [isOpen, setIsOpen] = useState(false); const [opened, setOpened] = useState(false);
// Toggle modal when there's a query // buka popover saat ada query
useEffect(() => { useEffect(() => {
setIsOpen(!!snap.query); setOpened(!!snap.query);
}, [snap.query]); }, [snap.query]);
// Infinite scroll // infinite scroll
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
const bottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200; const bottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
@@ -25,88 +26,119 @@ export default function GlobalSearch() {
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, [snap.loading]); }, [snap.loading]);
return ( const handleSelect = async (e: React.MouseEvent, item: any) => {
<Box style={{ position: 'relative', width: '100%' }}> e.stopPropagation();
<TextInput e.preventDefault();
placeholder="Cari apapun..."
value={snap.query}
onChange={(e) => {
searchState.query = e.currentTarget.value;
debouncedFetch();
}}
radius="xl"
rightSection={
snap.query ? (
<IconX
size={16}
style={{ cursor: 'pointer' }}
onClick={() => {
searchState.query = '';
searchState.results = [];
}}
/>
) : undefined
}
/>
{/* Modal for search results */} const url = getDetailUrl(item);
<Modal if (!url) return;
opened={isOpen && !!snap.query}
onClose={() => { // Immediately close the search dropdown
searchState.query = ''; setOpened(false);
searchState.results = []; searchState.results = []; // Clear results immediately
searchState.loading = false;
// Use window.location for navigation to ensure full page reload
window.location.href = url;
};
return (
<Box pos="relative">
<Popover
opened={opened && !!snap.query}
onChange={(isOpen) => {
if (!isOpen) {
// Clear search state when popover is closed
searchState.query = '';
searchState.results = [];
searchState.page = 1;
searchState.nextPage = null;
}
setOpened(isOpen);
}} }}
withCloseButton={false} width="target"
size="lg" position="bottom"
padding={0} shadow="md"
withinPortal
radius="md" radius="md"
style={{ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 1000 }} zIndex={1000} // Add this line to ensure it appears above other elements
styles={{ styles={{
content: { // Changed from 'modal' to 'content' dropdown: {
backgroundColor: 'white', zIndex: 1000, // Add this to ensure the dropdown appears above other elements
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.5rem',
maxHeight: '400px',
overflow: 'hidden',
}, },
}} }}
> >
<Box style={{ maxHeight: '400px', overflowY: 'auto' }}> <Popover.Target>
{snap.results.map((item, i) => ( <TextInput
<Box placeholder="Cari apapun..."
key={i} value={snap.query}
p="sm" onChange={(e) => {
style={{ searchState.query = e.currentTarget.value;
borderBottom: '1px solid #eee', debouncedFetch();
cursor: 'pointer', }}
transition: 'background 0.2s', radius="xl"
overflow: 'hidden', size="md"
textOverflow: 'ellipsis', rightSection={
whiteSpace: 'nowrap', snap.query ? (
maxWidth: '100%', <IconX
}} size={16}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')} style={{ cursor: 'pointer' }}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} onClick={() => {
onClick={() => { searchState.query = '';
const url = getDetailUrl(item); searchState.results = [];
window.location.href = url; searchState.page = 1;
}} searchState.nextPage = null;
> setOpened(false);
<Text size="sm" fw={500}> }}
{item.judul || item.namaPasar || item.nama || item.name} />
</Text> ) : undefined
<Text size="xs" c="dimmed"> }
dari modul: {item.type} />
</Text> </Popover.Target>
</Box>
))} <Popover.Dropdown
{snap.loading && ( p={0}
style={{
maxHeight: 350,
overflowY: 'auto',
borderRadius: 12,
zIndex: 1000, // Add this line to ensure dropdown stays above other elements
position: 'relative', // Add this to contain child elements
}}
>
{snap.results.length > 0 ? (
snap.results.map((item, i) => (
<Box
key={i}
p="sm"
className="search-result-item" // Add this class
style={{
borderBottom: '1px solid #eee',
cursor: 'pointer',
transition: 'background 0.2s',
position: 'relative', // Add this
zIndex: 1, // Add this to ensure proper stacking context
backgroundColor: 'white', // Ensure background is set
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f7f7f7')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onClick={(e) => handleSelect(e, item)} // Pass the event here
>
<Text size="sm" fw={500}>
{item.judul || item.namaPasar || item.nama || item.name}
</Text>
<Text size="xs" c="dimmed">
dari modul: {item.type}
</Text>
</Box>
))
) : (
<Center py="md"> <Center py="md">
<Loader size="sm" /> {snap.loading ? <Loader size="sm" /> : <Text fz="sm">Tidak ada hasil</Text>}
</Center> </Center>
)} )}
</Box> </Popover.Dropdown>
</Modal> </Popover>
</Box> </Box>
); );
} }

View File

@@ -42,7 +42,6 @@ export default function ProfileView({ data }: ProfileViewProps) {
loading="lazy" loading="lazy"
style={{ style={{
objectPosition: 'bottom center', objectPosition: 'bottom center',
transform: 'translateY(10px)', // sedikit turun biar natural
}} }}
/> />
) : ( ) : (

View File

@@ -62,26 +62,26 @@ const getDetailUrl = (item: { type?: string; id: string | number; [key: string]:
programPenghijauan: '/darmasaba/lingkungan/program-penghijauan', programPenghijauan: '/darmasaba/lingkungan/program-penghijauan',
dataLingkunganDesa: '/darmasaba/lingkungan/data-lingkungan-desa', dataLingkunganDesa: '/darmasaba/lingkungan/data-lingkungan-desa',
gotongRoyong: '/darmasaba/lingkungan/gotong-royong', gotongRoyong: '/darmasaba/lingkungan/gotong-royong',
tujuanEdukasiLingkungan: '/darmasaba/lingkungan/tujuan-edukasi-lingkungan', tujuanEdukasiLingkungan: '/darmasaba/lingkungan/edukasi-lingkungan',
materiEdukasiLingkungan: '/darmasaba/lingkungan/materi-edukasi-lingkungan', materiEdukasiLingkungan: '/darmasaba/lingkungan/edukasi-lingkungan',
contohEdukasiLingkungan: '/darmasaba/lingkungan/contoh-edukasi-lingkungan', contohEdukasiLingkungan: '/darmasaba/lingkungan/edukasi-lingkungan',
filosofiTriHita: '/darmasaba/lingkungan/filosofi-tri-hita', filosofiTriHita: '/darmasaba/lingkungan/konservasi-adat-bali',
bentukKonservasiBerdasarkanAdat: '/darmasaba/lingkungan/bentuk-konservasi-berdasarkan-adat', bentukKonservasiBerdasarkanAdat: '/darmasaba/lingkungan/konservasi-adat-bali',
nilaiKonservasiAdat: '/darmasaba/lingkungan/nilai-konservasi-adat', nilaiKonservasiAdat: '/darmasaba/lingkungan/konservasi-adat-bali',
jenjangPendidikan: '/darmasaba/inovasi/jenjang-pendidikan', jenjangPendidikan: '/darmasaba/pendidikan/info-sekolah/semua',
lembaga: '/darmasaba/inovasi/lembaga', lembaga: '/darmasaba/pendidikan/info-sekolah/semua/lembaga',
siswa: '/darmasaba/inovasi/siswa', siswa: '/darmasaba/pendidikan/info-sekolah/semua/siswa',
pengajar: '/darmasaba/inovasi/pengajar', pengajar: '/darmasaba/pendidikan/info-sekolah/semua/pengajar',
keunggulanProgram: '/darmasaba/inovasi/keunggulan-program', keunggulanProgram: '/darmasaba/pendidikan/beasiswa-desa',
tujuanProgram: '/darmasaba/inovasi/tujuan-program', tujuanProgram: '/darmasaba/pendidikan/program-pendidikan-anak',
programUnggulan: '/darmasaba/inovasi/program-unggulan', programUnggulan: '/darmasaba/pendidikan/program-pendidikan-anak',
lokasiJadwalBimbinganBelajarDesa: '/darmasaba/inovasi/lokasi-jadwal-bimbingan-belajar-desa', lokasiJadwalBimbinganBelajarDesa: '/darmasaba/pendidikan/bimbingan-belajar-desa',
fasilitasBimbinganBelajarDesa: '/darmasaba/inovasi/fasilitas-bimbingan-belajar-desa', fasilitasBimbinganBelajarDesa: '/darmasaba/pendidikan/bimbingan-belajar-desa',
tujuanPendidikanNonFormal: '/darmasaba/inovasi/tujuan-pendidikan-non-formal', tujuanPendidikanNonFormal: '/darmasaba/pendidikan/pendidikan-non-formal',
tempatKegiatan: '/darmasaba/inovasi/tempat-kegiatan', tempatKegiatan: '/darmasaba/pendidikan/pendidikan-non-formal',
jenisProgramYangDiselenggarakan: '/darmasaba/inovasi/jenis-program-yang-diselenggarakan', jenisProgramYangDiselenggarakan: '/darmasaba/pendidikan/pendidikan-non-formal',
dataPerpustakaan: '/darmasaba/inovasi/data-perpustakaan', dataPerpustakaan: '/darmasaba/pendidikan/perpustakaan-digital/semua',
dataPendidikan: '/darmasaba/inovasi/data-pendidikan', dataPendidikan: '/darmasaba/pendidikan/data-pendidikan',
}; };

View File

@@ -11,7 +11,7 @@
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
position: fixed; position: fixed;
z-index: 1; z-index: 50;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
} }