Compare commits

...

4 Commits

48 changed files with 1561 additions and 888 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",
"name": "Desa Darmasaba",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

@@ -132,7 +132,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={() => {
router.push("/login");
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"

View File

@@ -25,26 +25,40 @@ const searchState = proxy({
searchState.results = [];
return;
}
searchState.loading = true;
try {
const res = await ApiFetch.api.search.findMany.get({
query: {
query: searchState.query,
page: searchState.page,
limit: searchState.limit,
type: searchState.type,
},
});
console.log("Search API Response:", res);
const rawItems = res.data?.data || [];
const parsedItems = structuredClone(rawItems); // ✅ penting!
console.log("✅ Parsed items:", parsedItems);
if (searchState.page === 1) {
searchState.results = parsedItems;
} else {
searchState.results.push(...parsedItems);
}
const res = await ApiFetch.api.search.findMany.get({
query: {
query: searchState.query,
page: searchState.page,
limit: searchState.limit,
type: searchState.type,
},
});
console.log("Search results render:", searchState.results);
if (searchState.page === 1) {
searchState.results = res.data?.data || [];
} else {
searchState.results.push(...(res.data?.data || []));
searchState.nextPage = res.data?.nextPage || null;
} catch (error) {
console.error("Search fetch error:", error);
} finally {
searchState.loading = false;
}
searchState.nextPage = res.data?.nextPage || null;
searchState.loading = false;
},
async next() {

View File

@@ -6,122 +6,83 @@ import { useCallback, 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);
// Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => {
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);
const limit = 9; // ✅ ambil 12 data per page
const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
setLoading(true);
// Using the load function from the component's scope
const loadFn = async () => {
try {
const response = await ApiFetch.api.fileStorage.findMany.get({
query: {
category: 'image',
page: pageNum.toString(),
limit: '10',
...(searchTerm && { search: searchTerm })
}
});
try {
const query: Record<string, string> = {
category: 'image',
page: pageNum.toString(),
limit: limit.toString(),
};
if (searchTerm) query.search = searchTerm;
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);
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([]);
} finally {
setLoading(false);
}
};
loadFn();
} catch (err) {
console.error('Load error:', err);
setFiles([]);
} finally {
setLoading(false);
}
}, []);
// Initial load and URL change handler
// Initial load + update when URL/search changes
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch);
setPage(urlPage);
loadData(urlPage, urlSearch);
};
// Handle search updates from the search bar
const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail;
setSearch(search);
setPage(1); // Reset to first page on new search
setPage(1);
loadData(1, search);
};
// Initial load
handleRouteChange();
// Set up event listeners
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
// Cleanup
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
};
}, [loadData]);
// ✅ Fetch data
// ✅ Update when page/search changes
useEffect(() => {
const fetchFiles = async () => {
setLoading(true);
try {
const query: Record<string, string> = {
category: 'image',
page: page.toString(),
limit: '10',
};
if (search) query.search = search;
loadData(page, search);
}, [page, search, loadData]);
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);
@@ -148,7 +109,14 @@ interface FileItem {
<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%' }}>
<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}
@@ -159,20 +127,18 @@ interface FileItem {
loading="lazy"
/>
</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>
<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>
</Paper>
))}
</SimpleGrid>
@@ -181,4 +147,4 @@ interface FileItem {
</Center>
</Box>
);
}
}

View File

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

View File

@@ -48,7 +48,7 @@ function Page() {
p={10}
mb={50}
h={400}
w={150}
w={Math.max(data.length * 120, 800)} // auto lebar sesuai jumlah data
data={data.map((item) => ({
id: item.id,
Pekerjaan: item.pekerjaan,

View File

@@ -72,21 +72,21 @@ function Page() {
)
}
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}>
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22" style={{ overflow: 'auto' }}>
<Box px={{ base: 'md', md: 50, lg: 100 }}>
<BackButton />
</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"}>
Jumlah Penduduk Usia Kerja Yang Menganggur
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Box px={{ base: "md", md: 50, lg: 100 }}>
<Stack gap={'lg'} justify='center'>
<Paper p={'lg'}>
<Text fw={'bold'} fz={'h3'}>Pengangguran Berdasarkan Usia</Text>
{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
w="100%"
h={250} // lebih kecil biar aman di mobile
@@ -133,7 +133,7 @@ function Page() {
<Box w="100%" style={{ maxWidth: 400, margin: "0 auto" }}>
<PieChart
w="100%"
h={250} // lebih kecil biar aman di mobile
h="min(250px, 50vh)" // lebih kecil biar aman di mobile
withLabelsLine
labelsPosition="outside"
labelsType="percent"

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
'use client'
import { IconKey, IconMapper } from '@/app/admin/(dashboard)/_com/iconMap';
import programKreatifState from '@/app/admin/(dashboard)/_state/inovasi/program-kreatif';
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { Box, Button, Center, Grid, GridCol, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions';
import React, { useState } from 'react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto';
import { IconSearch } from '@tabler/icons-react';
import { IconKey, IconMapper } from '@/app/admin/(dashboard)/_com/iconMap';
// const data = [
// {
@@ -75,17 +75,23 @@ function Page() {
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }} >
<Group justify="space-between" mb="md" align='center'>
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Program Kreatif Desa
</Text>
<TextInput
placeholder="Cari program kreatif..."
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
<Grid align='center'>
<GridCol span={{ base: 12, md: 9 }}>
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Program Kreatif Desa
</Text>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<TextInput
radius={"lg"}
placeholder='Cari Program Kreatif'
value={search}
onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/>
</GridCol>
</Grid>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Stack gap={'lg'} justify='center'>

View File

@@ -50,10 +50,12 @@ function Page() {
</Text>
</Box>
<TextInput
placeholder='Cari kontak darurat, nama, atau nomor...'
leftSection={<IconSearch size={20} />}
radius={"lg"}
placeholder='Cari Kontak Darurat'
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "25%" }}
/>
</Group>
<Box px={{ base: "md", md: 100 }}>
@@ -95,10 +97,12 @@ function Page() {
</Text>
</Box>
<TextInput
placeholder='Cari kontak darurat, nama, atau nomor...'
leftSection={<IconSearch size={20} />}
radius={"lg"}
placeholder='Cari Kontak Darurat'
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/>
</Group>
<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 { DateTimePicker } from '@mantine/dates';
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 { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto';
@@ -56,10 +56,13 @@ function Page() {
<Flex justify="space-between" align="center">
<BackButton />
<TextInput
placeholder="Cari laporan"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
radius={"lg"}
placeholder='Cari Laporan Publik'
value={search}
onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/>
</Flex>
</Box>
<Box px={{ base: 'md', md: 100 }}>

View File

@@ -3,9 +3,9 @@
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { ActionIcon, Anchor, AspectRatio, Badge, Box, Button, Card, Chip, CopyButton, Divider, Grid, Group, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, ThemeIcon, Title, Tooltip } from '@mantine/core';
import { ActionIcon, AspectRatio, Badge, Box, Button, Card, CopyButton, Divider, Grid, Group, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, ThemeIcon, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconBrandWhatsapp, IconCheck, IconCopy, IconDeviceLandlinePhone, IconHeart, IconInfoCircle, IconMail, IconMapPin, IconMoodEmpty, IconSearch, IconStethoscope, IconUser, IconUsersGroup, IconWallet } from '@tabler/icons-react';
import { IconBrandWhatsapp, IconCheck, IconCopy, IconDeviceLandlinePhone, IconHeart, IconInfoCircle, IconMail, IconMapPin, IconMoodEmpty, IconSearch, IconUser } from '@tabler/icons-react';
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import { useProxy } from 'valtio/utils';
@@ -149,11 +149,6 @@ function Page() {
</CopyButton>
</Group>
</Group>
<Group gap="xs" mt="sm" wrap="wrap">
<Chip defaultChecked radius="xl" variant="light" icon={<IconStethoscope size={16} />}>Layanan Medis</Chip>
<Chip radius="xl" variant="light" icon={<IconUsersGroup size={16} />}>Ramah Keluarga</Chip>
<Chip radius="xl" variant="light" icon={<IconWallet size={16} />}>Pembayaran Non-Tunai</Chip>
</Group>
</Stack>
</Card>
</Box>
@@ -210,7 +205,6 @@ function Page() {
<Button variant="light" leftSection={<IconBrandWhatsapp size={18} />} component="a" href={`https://wa.me/${kontak.whatsapp.replace(/\D/g, '')}`} target="_blank" aria-label="Hubungi WhatsApp">WhatsApp</Button>
<Button variant="light" leftSection={<IconMail size={18} />} component="a" href={`mailto:${kontak.email}`} aria-label="Kirim Email">Email</Button>
</Group>
<Anchor target="_blank" underline="hover">Kunjungi situs resmi</Anchor>
</Stack>
</Card>

View File

@@ -121,7 +121,12 @@ function Page() {
</Badge>
</Group>
<Text fz="sm" c="dimmed">
Diposting: {v.createdAt.toLocaleDateString()}
Diposting: {new Date(v.createdAt).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})}
</Text>
<Divider />
<Text fz="sm" lh={1.5} lineClamp={3} truncate="end">

View File

@@ -108,20 +108,23 @@ function Page() {
<Box
style={{
width: '100%',
aspectRatio: '16/9',
borderRadius: '12px',
height: 180, // 🔥 tinggi fix biar semua seragam
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: '#f0f2f5', // fallback kalau gambar loading
}}
>
<Image
src={v.image.link}
src={v.image?.link || '/img/default.png'}
alt={v.name}
fit="cover"
width="100%"
height="100%"
loading="lazy"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
transition: 'transform 0.4s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.05)')}
@@ -129,6 +132,7 @@ function Page() {
/>
</Box>
</Center>
<Stack gap={4} w="100%">
<Text

View File

@@ -0,0 +1,120 @@
'use client';
import colors from '@/con/colors';
import { Button, Center, Flex, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconCalendar, IconInfoCircle, IconPhone } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import posyanduState from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
export default function DetailPosyanduUser() {
const statePosyandu = useProxy(posyanduState);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
statePosyandu.findUnique.load(params?.id as string);
}, []);
if (!statePosyandu.findUnique.data) {
return (
<Stack py="xl" px={{ base: 'md', md: 100 }}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = statePosyandu.findUnique.data;
return (
<Stack pos="relative" bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }} gap="xl">
{/* Tombol Kembali */}
<Group>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}
mb="sm"
c={colors['blue-button']}
>
Kembali
</Button>
</Group>
<Paper
withBorder
p="xl"
radius="lg"
shadow="md"
bg={colors['white-trans-1']}
maw={800}
mx="auto"
>
<Stack gap="md">
{/* Header */}
<Text
ta="center"
fz={{ base: '1.8rem', md: '2.2rem' }}
fw={700}
c={colors['blue-button']}
>
{data.name || 'Posyandu Desa'}
</Text>
{/* Gambar */}
{data.image?.link ? (
<Center>
<Image
src={data.image.link}
alt={`Gambar ${data.name}`}
w="100%"
h={300}
radius="md"
fit="cover"
loading="lazy"
/>
</Center>
) : (
<Center>
<Text fz="sm" c="dimmed">
Tidak ada gambar
</Text>
</Center>
)}
{/* Info utama */}
<Stack gap="sm" mt="md">
<Flex align="flex-start" gap="xs">
<IconPhone size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text fz="sm" c="dimmed">
{data.nomor || 'Nomor tidak tersedia'}
</Text>
</Flex>
<Flex align="flex-start" gap="xs">
<IconCalendar size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz="sm"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.jadwalPelayanan || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
</Flex>
<Flex align="flex-start" gap="xs">
<IconInfoCircle size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz="sm"
c="dimmed"
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
</Flex>
</Stack>
</Stack>
</Paper>
</Stack>
);
}

View File

@@ -1,18 +1,19 @@
'use client'
import posyandustate from "@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu";
import colors from "@/con/colors";
import { Badge, Box, Center, Flex, Group, Image, List, ListItem, Pagination, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, TextInput } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { Badge, Box, Button, Center, Flex, Group, Image, List, ListItem, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import { useDebouncedValue, useShallowEffect } from "@mantine/hooks";
import { IconCalendar, IconInfoCircle, IconPhone, IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import { useProxy } from "valtio/utils";
import BackButton from "../../desa/layanan/_com/BackButto";
import { useDebouncedValue } from "@mantine/hooks";
import { useTransitionRouter } from "next-view-transitions";
export default function Page() {
const state = useProxy(posyandustate);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const router = useTransitionRouter()
const { data, page, totalPages, loading, load } = state.findMany;
@@ -133,33 +134,41 @@ export default function Page() {
loading="lazy"
/>
</Center>
<Flex align="center" gap="xs">
<IconPhone size={18} stroke={1.5} />
<Text fz="sm" c="dimmed">
{v.nomor || "Tidak tersedia"}
</Text>
<Flex align="flex-start" gap="xs">
<IconPhone size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Box>
<Text fz="sm" c="dimmed" lh={1.4}>
{v.nomor || "Tidak tersedia"}
</Text>
</Box>
</Flex>
<Flex align="center" gap="xs">
<IconCalendar size={18} stroke={1.5} />
<Text fz="sm" c="dimmed">
Jadwal:{" "}
<span style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }} />
</Text>
<Flex align="flex-start" gap="xs">
<IconCalendar size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Box>
<Text fz="sm" c="dimmed" lh={1.4}>
<strong>Jadwal:</strong>{" "}
<span
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }}
/>
</Text>
</Box>
</Flex>
<Spoiler
key={`spoiler-${v.id}`}
maxHeight={70}
showLabel="Lihat selengkapnya"
hideLabel="Sembunyikan"
transitionDuration={300}
>
<Flex align="flex-start" gap="xs">
<IconInfoCircle size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz="sm"
lh={1.5}
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
lineClamp={3}
truncate="end"
/>
</Spoiler>
</Flex>
<Button radius="lg" size="md" variant="outline" onClick={() => router.push(`/darmasaba/kesehatan/posyandu/${v.id}`)}>Detail</Button>
</Stack>
</Paper>
))}

View File

@@ -1,5 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
'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 {
Badge,
@@ -23,12 +27,11 @@ import {
} from '@mantine/core';
import { IconArrowRight, IconCalendar } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
export default function Content({ kategori }: { kategori: string }) {
const router = useTransitionRouter();
const [page, setPage] = useState(1);
const [animateKey, setAnimateKey] = useState(0);
const state = useProxy(gotongRoyongState.kegiatanDesa);
const featuredState = useProxy(gotongRoyongState.kegiatanDesa.findFirst);
@@ -37,119 +40,178 @@ export default function Content({ kategori }: { kategori: string }) {
const paginatedNews = state.findMany.data || [];
const totalPages = state.findMany.totalPages || 1;
// Load data
// Load data awal
useEffect(() => {
gotongRoyongState.kegiatanDesa.findFirst.load(kategori);
}, [kategori]);
// Load daftar berita
useEffect(() => {
state.findMany.load(page, 3, '', kategori);
setAnimateKey((prev) => prev + 1); // trigger animasi halus saat page berubah
}, [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 (
<Box py={20}>
<Container size="xl" px={{ base: 'md', md: 'xl' }}>
{/* === Gotong Royong Utama === */}
{featuredState.loading ? (
<Center><Skeleton h={400} /></Center>
) : featured ? (
<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={featured.image?.link}
alt={featured.judul || 'Berita 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">
{featured.kategoriKegiatan?.nama || kategori}
</Badge>
<Title order={2} mb="md">{featured.judul}</Title>
<Text c="dimmed" lineClamp={3} mb="md" dangerouslySetInnerHTML={{ __html: featured.deskripsiLengkap }} />
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
{new Date(featured.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/${kategori}/${featured.id}`)}
>
Baca Selengkapnya
</Button>
</Group>
</Stack>
</GridCol>
</Grid>
</Paper>
</Box>
) : null}
<Transition mounted={!featuredState.loading} transition="fade" duration={250} timingFunction="ease">
{(styles) => (
<div style={styles}>
{featured ? (
<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={featured.image?.link || '/images/placeholder.jpg'}
alt={featured.judul || 'Berita 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">
{featured.kategoriKegiatan?.nama || kategori}
</Badge>
<Title order={2} mb="md">{featured.judul}</Title>
<Text
c="dimmed"
lineClamp={3}
mb="md"
dangerouslySetInnerHTML={{ __html: featured.deskripsiLengkap }}
/>
</div>
<Group justify="apart" mt="auto">
<Group gap="xs">
<IconCalendar size={18} />
<Text size="sm">
{new Date(featured.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/${kategori}/${featured.id}`)
}
>
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}>
<Title order={2} mb="md">Daftar Gotong Royong</Title>
<Divider mb="xl" />
{state.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(3).fill(0).map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Text c="dimmed" ta="center">Belum ada gotong royong di kategori &quot;{kategori}&quot;.</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{paginatedNews.map((item) => (
<Card
key={item.id}
shadow="sm"
p="lg"
radius="md"
withBorder
onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${item.id}`)}
style={{ cursor: 'pointer' }}
>
<Card.Section>
<Image src={item.image?.link} height={200} alt={item.judul} 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>
)}
<AnimatePresence mode="wait">
<motion.div
key={animateKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
>
{state.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(3)
.fill(0)
.map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Center py={50}>
<Stack align="center" gap="sm">
<Title order={3}>Tidak Ada Data</Title>
<Text c="dimmed">Belum ada data gotong royong yang tersedia.</Text>
</Stack>
</Center>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{paginatedNews.map((item) => (
<Card
key={item.id}
shadow="sm"
p="lg"
radius="md"
withBorder
onClick={() => router.push(`/darmasaba/lingkungan/gotong-royong/${kategori}/${item.id}`)}
style={{ cursor: 'pointer' }}
>
<Card.Section>
<Image
src={item.image?.link || '/images/placeholder-small.jpg'}
height={200}
alt={item.judul}
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 */}
<Center mt="xl">
@@ -166,4 +228,4 @@ export default function Content({ kategori }: { kategori: string }) {
</Container>
</Box>
);
}
}

View File

@@ -1,169 +1,277 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'use client';
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 { useTransitionRouter } from 'next-view-transitions';
import { useSearchParams } from 'next/navigation';
import { motion } from 'framer-motion';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
function Page() {
export default function Page() {
const searchParams = useSearchParams();
const router = useTransitionRouter();
const router = useRouter();
// Parameter URL
const search = searchParams.get('search') || '';
const page = parseInt(searchParams.get('page') || '1');
// Gunakan proxy untuk state
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 loadingFeatured = featured.loading;
// Load berita utama (hanya sekali)
// Load featured data once on component mount
useEffect(() => {
if (!featured.data && !loadingFeatured) {
gotongRoyongState.kegiatanDesa.findFirst.load();
}
}, [featured.data, loadingFeatured]);
let mounted = true;
const loadFeatured = async () => {
try {
if (!featured.data && !loadingFeatured) {
await gotongRoyongState.kegiatanDesa.findFirst.load();
}
} catch (error) {
console.error('Error loading featured data:', error);
}
};
if (mounted) {
loadFeatured();
}
return () => {
mounted = false;
};
}, []); // Empty dependency array to run only once on mount
// Load berita terbaru (untuk grid) saat page/search berubah
useEffect(() => {
const limit = 3; // Sesuaikan dengan tampilan grid
state.findMany.load(page, limit, search);
let mounted = true;
const loadData = async () => {
try {
const limit = 3;
await state.findMany.load(page, limit, search);
} catch (error) {
console.error('Error loading data:', error);
}
};
if (mounted) {
loadData();
}
return () => {
mounted = false;
};
}, [page, search]);
// Update URL saat page berubah
const handlePageChange = (newPage: number) => {
const url = new URLSearchParams(searchParams.toString());
if (search) url.set('search', search);
if (newPage > 1) url.set('page', newPage.toString());
else url.delete('page'); // biar page=1 ga muncul di URL
router.replace(`?${url.toString()}`);
else url.delete('page');
// Use push instead of replace to keep browser history
router.push(`?${url.toString()}`, { scroll: false });
};
const featuredData = featured.data;
const paginatedNews = state.findMany.data || [];
const totalPages = state.findMany.totalPages || 1;
return (
<Box py={20}>
<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}
// Animasi transisi halus tapi tetap instant load
const MotionBox = motion(Box as any);
{/* === 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}>
<Title order={2} mb="md">Gotong royong Terbaru</Title>
<Divider mb="xl" />
{loadingGrid ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(3).fill(0).map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Text c="dimmed" ta="center">Tidak ada gotong royong ditemukan.</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{paginatedNews.map((item) => (
<Card
key={item.id}
shadow="sm"
p="lg"
radius="md"
withBorder
>
<Card.Section>
<Image
src={item.image?.link || '/images/placeholder-small.jpg'}
height={200}
alt={item.judul}
fit="cover"
loading="lazy"
/>
</Card.Section>
<Transition mounted={!loadingGrid} transition="fade" duration={200} timingFunction="ease-out">
{(styles) =>
loadingGrid ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(3)
.fill(0)
.map((_, i) => (
<Skeleton key={i} h={300} radius="md" />
))}
</SimpleGrid>
) : paginatedNews.length === 0 ? (
<Text c="dimmed" ta="center">
Tidak ada gotong royong ditemukan.
</Text>
) : (
<Box style={styles}>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{paginatedNews.map((item) => (
<Card key={item.id} shadow="sm" p="lg" radius="md" withBorder>
<Card.Section>
<Image
src={item.image?.link || '/images/placeholder-small.jpg'}
height={200}
alt={item.judul}
fit="cover"
loading="lazy"
/>
</Card.Section>
<Badge color="blue" variant="light" mt="md">
{item.kategoriKegiatan?.nama || 'Gotong royong'}
</Badge>
<Badge color="blue" variant="light" mt="md">
{item.kategoriKegiatan?.nama || 'Gotong royong'}
</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">
<Text size="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</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>
)}
<Flex align="center" 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>
{/* 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">
<Pagination
total={totalPages}
@@ -176,9 +284,6 @@ function Page() {
</Center>
</Box>
</Container>
</Box>
</MotionBox>
);
}
export default Page;

View File

@@ -1,9 +1,9 @@
'use client'
import pengelolaanSampahState from '@/app/admin/(dashboard)/_state/lingkungan/pengelolaan-sampah';
import colors from '@/con/colors';
import { Box, Center, Flex, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { Box, Center, Flex, Group, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { Icon, IconChartLine, IconClipboardTextFilled, IconLeaf, IconRecycle, IconScale, IconSearch, IconTent, IconTrashFilled, IconTrophy, IconTruckFilled } from '@tabler/icons-react';
import { Icon, IconChartLine, IconClipboardTextFilled, IconLeaf, IconRecycle, IconRoute, IconScale, IconSearch, IconTent, IconTrashFilled, IconTrophy, IconTruckFilled } from '@tabler/icons-react';
import React, { useState } from 'react';
import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto';
@@ -122,20 +122,28 @@ function Page() {
<Stack gap="md">
{data2?.map((v, k) => (
<Paper key={k} p="md" withBorder radius="md">
<Text fw="bold" fz="lg">{v.namaTempatMaps}</Text>
<Text c="dimmed" fz="sm" mb="sm">{v.alamat}</Text>
{v.lat && v.lng ? (
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${v.lat},${v.lng}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors['blue-button'], textDecoration: 'none' }}
>
<Text fz="sm">📌 Buka di Google Maps</Text>
</a>
) : (
<Text c="dimmed" fz="sm">Koordinat belum tersedia</Text>
)}
<Group justify='space-between'>
<Box>
<Text fw="bold" fz="lg">{v.namaTempatMaps}</Text>
<Text c="dimmed" fz="sm" mb="sm">{v.alamat}</Text>
</Box>
<Box>
<IconRoute color={colors['blue-button']} size={30} />
<Text fw={"bold"} fz="sm" c={colors['blue-button']}>Rute</Text>
</Box>
</Group>
{v.lat && v.lng ? (
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${v.lat},${v.lng}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors['blue-button'], textDecoration: 'none' }}
>
<Text fz="sm">📌 Lihat Peta Lebih Besar</Text>
</a>
) : (
<Text c="dimmed" fz="sm">Koordinat belum tersedia</Text>
)}
</Paper>
))}
</Stack>

View File

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

View File

@@ -92,7 +92,7 @@ function Page() {
cursor={{ fill: 'var(--mantine-color-gray-1)' }}
/>
<Legend />
<Bar dataKey="jumlah" fill={colors['blue-button']} name="Jumlah Pendidikan" radius={[8, 8, 0, 0]} />
<Bar dataKey="jumlah" fill={colors['blue-button']} name="Jumlah Penduduk dengan Pendidikan" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Paper>

View File

@@ -1,7 +1,7 @@
'use client'
import pendidikanNonFormalState from '@/app/admin/(dashboard)/_state/pendidikan/pendidikan-non-formal';
import colors from '@/con/colors';
import { Box, Paper, SimpleGrid, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
import { Box, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { useProxy } from 'valtio/utils';
import { IconMapPin, IconTarget, IconBook2 } from '@tabler/icons-react';
@@ -59,13 +59,17 @@ function Page() {
withBorder
>
<Stack>
<Tooltip label="Fokus utama program" withArrow>
<Title order={2} fw="bold" c={colors['blue-button']} mb="xs" flex="center">
<IconTarget size={28} style={{ marginRight: 8 }} />
<Group align="center" gap={8} wrap="nowrap">
<Tooltip label="Fokus utama program" withArrow>
<Box display="flex" style={{ alignItems: "center" }}>
<IconTarget color={colors['blue-button']} size={26} />
</Box>
</Tooltip>
<Text fw={700} fz="xl" c={colors['blue-button']}>
{stateTujuanPendidikanNonFormal.findById.data?.judul}
</Title>
</Tooltip>
<Text fz="md" lh={1.7} c="dark" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateTujuanPendidikanNonFormal.findById.data?.deskripsi }} />
</Text>
</Group>
<Text fz="md" lh={1.7} c="dark" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: stateTujuanPendidikanNonFormal.findById.data?.deskripsi }} />
</Stack>
</Paper>
<Paper
@@ -76,13 +80,17 @@ function Page() {
withBorder
>
<Stack>
<Tooltip label="Lokasi pelaksanaan kegiatan" withArrow>
<Title order={2} fw="bold" c={colors['blue-button']} mb="xs" flex="center">
<IconMapPin size={28} style={{ marginRight: 8 }} />
<Group align="center" gap={8} wrap="nowrap">
<Tooltip label="Lokasi pelaksanaan kegiatan" withArrow>
<Box display="flex" style={{ alignItems: "center" }}>
<IconMapPin color={colors['blue-button']} size={26} />
</Box>
</Tooltip>
<Text fw={700} fz="xl" c={colors['blue-button']}>
{stateTempatKegiatan.findById.data?.judul}
</Title>
</Tooltip>
<Text fz="md" style={{wordBreak: "break-word", whiteSpace: "normal"}} lh={1.7} c="dark" dangerouslySetInnerHTML={{ __html: stateTempatKegiatan.findById.data?.deskripsi }} />
</Text>
</Group>
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal" }} lh={1.7} c="dark" dangerouslySetInnerHTML={{ __html: stateTempatKegiatan.findById.data?.deskripsi }} />
</Stack>
</Paper>
</SimpleGrid>
@@ -95,13 +103,17 @@ function Page() {
withBorder
>
<Stack>
<Tooltip label="Ragam jenis program yang tersedia" withArrow>
<Title order={2} fw="bold" c={colors['blue-button']} mb="xs" flex="center">
<IconBook2 size={28} style={{ marginRight: 8 }} />
<Group align="center" gap={8} wrap="nowrap">
<Tooltip label="Ragam jenis program yang tersedia" withArrow>
<Box display="flex" style={{ alignItems: "center" }}>
<IconBook2 color={colors['blue-button']} size={26} />
</Box>
</Tooltip>
<Text fw={700} fz="xl" c={colors['blue-button']}>
{stateJenisProgram.findById.data?.judul}
</Title>
</Tooltip>
<Text fz="md" lh={1.7} c="dark" style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: stateJenisProgram.findById.data?.deskripsi }} />
</Text>
</Group>
<Text fz="md" lh={1.7} c="dark" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: stateJenisProgram.findById.data?.deskripsi }} />
</Stack>
</Paper>
</Box>

View File

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

View File

@@ -32,7 +32,7 @@ export default function JenisInformasiSelector({ onChange }: {
return (
<Group>
<Select
placeholder='pilih jenis informasi'
placeholder='Pilih jenis informasi'
label='Jenis Informasi'
data={data.map((item) => ({
value: item.id,

View File

@@ -28,7 +28,7 @@ function MemperolehInformasi({ onChange }: {
return (
<Group>
<Select
placeholder='pilih cara memperoleh informasi'
placeholder='Pilih cara memperoleh informasi'
label={"Cara Memperoleh Informasi"}
data={data.map((item) => ({
value: item.id,

View File

@@ -26,7 +26,7 @@ function MemperolehSalinan({ onChange }: {
return (
<Group>
<Select
placeholder='pilih cara memperoleh salinan informasi'
placeholder='Pilih cara memperoleh salinan informasi'
label={'Cara Memperoleh Salinan Informasi'}
data={data.map((item) => ({
value: item.id,

View File

@@ -178,7 +178,7 @@ function Page() {
<TextInput
label="Alamat Email"
placeholder="contoh: nama@email.com"
placeholder="Contoh: nama@email.com"
radius="md"
size="md"
type="email"
@@ -190,7 +190,7 @@ function Page() {
<TextInput
label="Nomor Telepon"
placeholder="contoh: 0812-3456-7890"
placeholder="Contoh: 0812-3456-7890"
radius="md"
size="md"
withAsterisk

View File

@@ -51,12 +51,12 @@ function Page() {
<Box key={item.id} px={{ base: "md", md: 100 }}>
<Paper p="xl" bg={colors['white-trans-1']} radius="lg" shadow="xl">
<Box px={{ base: "md", md: 100 }}>
<Flex align="center" gap={40} justify="center">
<Image loading='lazy' src="/darmasaba-icon.png" h={{ base: 70, md: 120 }} alt="Logo Desa" />
<Text fz={{ base: "1.5rem", md: "2rem", lg: "2.5rem", xl: "3rem" }} fw="bold">
Pejabat Pengelola Informasi Publik
<Center>
<Image loading='lazy' src="/darmasaba-icon.png" h={{ base: 70, md: 120 }} w={{ base: 70, md: 120 }} alt="Logo Desa" />
</Center>
<Text ta="center" fz={{ base: "1.2rem", md: "2rem", lg: "2.5rem", xl: "3rem" }} fw="bold">
Pejabat Pengelola Informasi dan Dokumentasi
</Text>
</Flex>
</Box>
<Divider my="lg" />

View File

@@ -233,21 +233,42 @@ function StrukturOrganisasiPPID() {
return (
<Stack align="center" mt="xl">
{/* 🔍 Search + Zoom + Fullscreen controls */}
<Group mb="md" justify="center" gap="sm">
<Group mb="md" justify="center" gap="sm" align="center">
<TextInput
placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<Button variant="light" size="sm" onClick={handleZoomOut}>
<IconZoomOut size={16} />
</Button>
<Button variant="light" size="sm" onClick={resetZoom}>
100%
</Button>
{/* 🔍 Tambahkan indikator zoom di sini */}
{/* Floating Zoom Indicator */}
<Box
bg="#C3D0E8"
c="blue"
px={9}
py={8}
style={{
fontSize: 14,
fontWeight: 600,
borderRadius: '5px',
}}
>
{Math.round(scale * 100)}%
</Box>
<Button variant="light" size="sm" onClick={handleZoomIn}>
<IconZoomIn size={16} />
</Button>
<Button variant="light" size="sm" onClick={resetZoom}>
Reset
</Button>
<Button
variant="light"
size="sm"
@@ -260,6 +281,7 @@ function StrukturOrganisasiPPID() {
</Button>
</Group>
{/* Chart Container */}
<Box
ref={chartContainerRef}

View File

@@ -2,11 +2,11 @@
'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import colors from "@/con/colors";
import { Carousel, CarouselSlide } from "@mantine/carousel";
import { Box, Button, Container, Group, Paper, Stack, Text, useMantineTheme, Skeleton } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import { Box, Button, Container, Group, Paper, Skeleton, Stack, Text, useMantineTheme } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { IconArrowRight, IconAward } from "@tabler/icons-react";
import Autoplay from "embla-carousel-autoplay";
import { IconAward, IconArrowRight } from "@tabler/icons-react";
import { useTransitionRouter } from "next-view-transitions";
import { useEffect, useRef } from "react";
import { useProxy } from "valtio/utils";
@@ -18,7 +18,8 @@ export default function Page() {
<Box px={{ base: "md", md: 100 }}>
<BackButton />
</Box>
<Container w={{ base: "100%", md: "60%" }}>
<Container w={{ base: "100%", md: "90%", lg: "60%" }}>
<Stack align="center" gap="sm">
<Group gap="xs">
<IconAward size={40} color={colors["blue-button"]} />
@@ -37,11 +38,10 @@ export default function Page() {
}
function Slider() {
const height = 500;
const width = 1200;
const theme = useMantineTheme();
const mobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
const autoplay = useRef(Autoplay({ delay: 3000 }));
const tablet = useMediaQuery(`(max-width: ${theme.breakpoints.md})`);
const autoplay = useRef(Autoplay({ delay: 3000, stopOnInteraction: false }));
const state = useProxy(penghargaanState);
const router = useTransitionRouter();
@@ -54,7 +54,7 @@ function Slider() {
if (loading) {
return (
<Group justify="center" py="xl">
<Group justify="center" py="xl" gap="md">
<Skeleton w={300} h={200} radius="lg" />
<Skeleton w={300} h={200} radius="lg" visibleFrom="sm" />
<Skeleton w={300} h={200} radius="lg" visibleFrom="md" />
@@ -74,31 +74,49 @@ function Slider() {
}
const slides = data.map((item) => (
<CarouselSlide key={item.id}>
<Carousel.Slide key={item.id}>
<Paper
h="100%"
radius="lg"
shadow="md"
pos="relative"
style={{
height: "100%",
backgroundImage: `url(${item.image?.link})`,
backgroundSize: "cover",
backgroundPosition: "center",
transition: "transform 0.3s ease, box-shadow 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = "0 8px 20px rgba(0,0,0,0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
}}
>
<Box
pos="absolute"
inset={0}
bg="linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0.3))"
bg="linear-gradient(to top, rgba(0,0,0,0.8), rgba(0,0,0,0.2))"
style={{ borderRadius: 16 }}
/>
<Stack justify="flex-end" h="100%" gap="sm" p="lg" pos="relative">
<Text fz="xl" fw={700} ta="center" c="white">
<Text
fz={{ base: "md", sm: "lg", md: "xl" }}
fw={700}
ta="center"
c="white"
lineClamp={3}
style={{ textShadow: "0 2px 4px rgba(0,0,0,0.6)" }}
>
{item.name}
</Text>
<Group justify="center">
<Button
onClick={() => router.push(`/darmasaba/penghargaan/${item.id}`)}
onClick={() =>
router.push(`/darmasaba/penghargaan/${item.id}`)
}
size="md"
radius="xl"
rightSection={<IconArrowRight size={18} />}
@@ -110,24 +128,83 @@ function Slider() {
</Group>
</Stack>
</Paper>
</CarouselSlide>
</Carousel.Slide>
));
return (
<Carousel
py="xl"
plugins={[autoplay.current]}
onMouseEnter={autoplay.current.stop}
onMouseLeave={autoplay.current.reset}
w={{ base: "100%", sm: "90%", md: "80%", lg: width }}
h={height}
slideSize={{ base: "100%", sm: "50%", md: "33.333333%" }}
slideGap="md"
loop
align="start"
slidesToScroll={mobile ? 1 : 2}
<Box
pos="relative"
w="100%"
mx="auto"
px={{ base: "md", sm: "xl", md: "2rem", lg: "3rem" }}
style={{
maxWidth: 1300,
}}
>
{slides}
</Carousel>
<Carousel
py="xl"
w="100%"
h={{ base: 320, sm: 380, md: 420, lg: 450 }}
slideSize={{
base: "100%", // Mobile: 1
sm: "50%", // Tablet kecil (≥768): 2
md: "50%", // 1024px: tetap 2
lg: "33.333%", // Desktop besar: 3
}}
slideGap={{ base: "md", sm: "md", md: "lg" }}
loop
align="start"
slidesToScroll={mobile ? 1 : tablet ? 2 : 3}
plugins={[autoplay.current]}
onMouseEnter={autoplay.current.stop}
onMouseLeave={autoplay.current.reset}
withControls={data.length > 3}
draggable={data.length > 1}
styles={{
root: {
position: "relative",
},
viewport: {
overflow: "hidden",
},
container: {
alignItems: "stretch",
},
control: {
zIndex: 20,
backgroundColor: "rgba(255,255,255,0.95)",
color: colors["blue-button"],
border: `2px solid ${colors["blue-button"]}`,
width: 46,
height: 46,
borderRadius: "50%",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
transition: "all 0.2s ease",
'&:hover': {
backgroundColor: colors["blue-button"],
color: "white",
transform: "scale(1.1)",
},
'&[data-inactive]': {
opacity: 0,
cursor: 'default',
},
},
controls: {
position: "absolute",
top: mobile ? "70%" : tablet ? "65%" : "60%",
transform: "translateY(-50%)",
width: mobile ? "100%" : tablet ? "calc(100% + 60px)" : "calc(100% + 100px)",
left: mobile ? "0" : tablet ? "-30px" : "-50px",
right: mobile ? "0" : tablet ? "-30px" : "-50px",
padding: "0",
justifyContent: "space-between",
zIndex: 30,
},
}}
>
{slides}
</Carousel>
</Box>
);
}
}

View File

@@ -1,6 +1,63 @@
'use client'
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() {
return (
@@ -46,7 +103,7 @@ function Footer() {
<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>
<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>
</Group>
</Stack>
@@ -64,31 +121,39 @@ function Footer() {
Darmasaba adalah desa budaya yang kaya akan tradisi dan nilai-nilai warisan Bali.
</Text>
<Flex gap="md" mt="sm" c="#F3F2EC">
<ActionIcon variant="subtle" color="white"><IconBrandFacebook size={22} /></ActionIcon>
<ActionIcon variant="subtle" color="white"><IconBrandInstagram size={22} /></ActionIcon>
<ActionIcon variant="subtle" color="white"><IconBrandTwitter size={22} /></ActionIcon>
<ActionIcon variant="subtle" color="white"><IconBrandWhatsapp size={22} /></ActionIcon>
{sosialMedia.map((item) => (
<ActionIcon
key={item.title}
component="a"
href={item.link}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="white"
>
<item.icon size={22} />
</ActionIcon>
))}
</Flex>
</Stack>
</Box>
<Box>
<Stack gap="xs">
<Text c="white" fz="md" fw={700}>Layanan Desa</Text>
<Anchor c="#F3F2EC" fz="xs">Administrasi Kependudukan</Anchor>
<Anchor c="#F3F2EC" fz="xs">Layanan Sosial</Anchor>
<Anchor c="#F3F2EC" fz="xs">Pengaduan Masyarakat</Anchor>
<Anchor c="#F3F2EC" fz="xs">Informasi Publik</Anchor>
{layanandesa.map((item) => (
<Anchor key={item.title} c="#F3F2EC" fz="xs" href={item.link}>{item.title}</Anchor>
))}
</Stack>
</Box>
<Box>
<Stack gap="xs">
<Text c="white" fz="md" fw={700}>Tautan Penting</Text>
<Anchor c="#F3F2EC" fz="xs">Portal Badung</Anchor>
<Anchor c="#F3F2EC" fz="xs">E-Government</Anchor>
<Anchor c="#F3F2EC" fz="xs">Transparansi</Anchor>
<Anchor c="#F3F2EC" fz="xs">Unduhan</Anchor>
{tautanPenting.map((item) => (
<Anchor key={item.title} c="#F3F2EC" fz="xs" href={item.link}>{item.title}</Anchor>
))}
</Stack>
</Box>

View File

@@ -7,24 +7,38 @@ import GlobalSearch from "./globalSearch";
export function NavbarSearch() {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isNavigatingRef = useRef(false);
// Close when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
const target = event.target as HTMLElement;
// Jangan close jika klik di search result item (biar handleSelect yang urus)
if (target.closest('.search-result-item')) {
return;
}
// Close jika klik di luar container
if (containerRef.current && !containerRef.current.contains(target)) {
setIsOpen(false);
stateNav.clear();
}
}
// Add event listener
document.addEventListener('mousedown', handleClickOutside);
return () => {
// Clean up
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Reset navigation flag saat component unmount atau route change
useEffect(() => {
return () => {
isNavigatingRef.current = false;
};
}, []);
return (
<Box
ref={containerRef}

View File

@@ -5,7 +5,7 @@ import stateNav from "@/state/state-nav";
import { ActionIcon, Box, Burger, Group, Image, Paper, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconSquareArrowRight } from "@tabler/icons-react";
import { motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useSnapshot } from "valtio";
import { MenuItem } from "../../../../types/menu-item";
import { NavbarMainMenu } from "./NavbarMainMenu";
@@ -19,14 +19,18 @@ export function Navbar() {
<Paper
radius="0"
className="glass2"
w="100%"
w="100vw"
pos="fixed"
top={0}
style={{ zIndex: 100 }}
>
<NavbarMainMenu listNavbar={navbarListMenu} />
{/* Desktop navbar (muncul mulai 992px ke atas) */}
<Box visibleFrom="md">
<NavbarMainMenu listNavbar={navbarListMenu} />
</Box>
<Box hiddenFrom="sm" bg={colors.grey[2]} px="md" py="sm">
{/* Mobile navbar (muncul di bawah 992px, termasuk iPad Mini) */}
<Box hiddenFrom="md" bg={colors.grey[2]} px="md" py="sm">
<Group justify="space-between" wrap="nowrap">
<ActionIcon
variant="transparent"
@@ -38,10 +42,21 @@ export function Navbar() {
}}
>
<Tooltip label="Go to homepage" position="bottom" withArrow>
<Image src="/darmasaba-icon.png" alt="Village Logo" width={48} height={48} loading="lazy"/>
<Image
src="/darmasaba-icon.png"
alt="Village Logo"
width={48}
height={48}
loading="lazy"
/>
</Tooltip>
</ActionIcon>
<Tooltip label={mobileOpen ? "Close menu" : "Open menu"} position="bottom" withArrow>
<Tooltip
label={mobileOpen ? "Close menu" : "Open menu"}
position="bottom"
withArrow
>
<Burger
opened={mobileOpen}
color={colors["blue-button"]}
@@ -50,12 +65,14 @@ export function Navbar() {
/>
</Tooltip>
</Group>
{mobileOpen && (
<Paper
component={motion.div}
initial={{ x: '100%' }}
bg="white"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
exit={{ x: "100%" }}
transition={{ duration: 0.2 }}
pos="absolute"
left={0}
@@ -63,12 +80,14 @@ export function Navbar() {
top="100%"
m={0}
radius={0}
shadow="md"
>
<NavbarMobile listNavbar={navbarListMenu} />
</Paper>
)}
</Box>
</Paper>
{(item || isSearch) && <Box className="glass" />}
</Box>
);
@@ -76,40 +95,105 @@ export function Navbar() {
function NavbarMobile({ listNavbar }: { listNavbar: MenuItem[] }) {
const router = useRouter();
const pathname = usePathname(); // 👈 untuk cek path aktif
// fungsi bantu: cek apakah path sekarang sama dengan menu / sub-menu
const isActive = (href?: string) => href && pathname.startsWith(href);
return (
<ScrollArea.Autosize mah="calc(100vh - 80px)" offsetScrollbars>
<Stack p="md" gap="xs">
{listNavbar.map((item, k) => (
<Box key={k}>
<Group
justify="space-between"
align="center"
p="xs"
onClick={() => {
if (item.href) {
router.push(item.href);
stateNav.mobileOpen = false;
}
}}
style={{
cursor: item.href ? "pointer" : "default",
opacity: item.href ? 1 : 0.8
}}
>
<Text c="dark.9" fw={600} fz="md">
{item.name}
</Text>
<IconSquareArrowRight size={18} />
</Group>
{item.children && (
<Box pl="md">
<NavbarMobile listNavbar={item.children} />
</Box>
)}
</Box>
))}
<ScrollArea.Autosize
mah="calc(100dvh - 80px)"
type="auto"
offsetScrollbars
>
<Stack p="sm" gap="xs">
{listNavbar.map((item, k) => {
const active = isActive(item.href);
return (
<Box key={k}>
<Paper
shadow={active ? "sm" : "xs"}
radius="md"
p="sm"
withBorder
bg={active ? "blue.0" : "gray.0"}
onClick={() => {
if (item.href) {
router.push(item.href);
stateNav.mobileOpen = false;
}
}}
style={{
cursor: item.href ? "pointer" : "default",
transition: "background 0.15s ease",
borderLeft: active ? "4px solid #1e66f5" : "4px solid transparent",
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Text
fw={active ? 700 : 600}
fz="md"
c={active ? "blue.7" : "dark.9"}
>
{item.name}
</Text>
{item.href && (
<IconSquareArrowRight
size={18}
color={active ? "#1e66f5" : "inherit"}
/>
)}
</Group>
</Paper>
{/* Submenu */}
{item.children && (
<Box pl="md" mt={4}>
{item.children.map((child, j) => {
const childActive = isActive(child.href);
return (
<Group
key={j}
justify="space-between"
align="center"
p="xs"
onClick={() => {
if (child.href) {
router.push(child.href);
stateNav.mobileOpen = false;
}
}}
style={{
cursor: child.href ? "pointer" : "default",
opacity: child.href ? 1 : 0.8,
borderRadius: "0.5rem",
backgroundColor: childActive ? "#e7f0ff" : "transparent",
borderLeft: childActive ? "3px solid #1e66f5" : "3px solid transparent",
transition: "background 0.15s ease",
}}
>
<Text
fz="sm"
fw={childActive ? 600 : 400}
c={childActive ? "blue.7" : "dark.8"}
>
{child.name}
</Text>
<IconSquareArrowRight
size={14}
color={childActive ? "#1e66f5" : "inherit"}
/>
</Group>
);
})}
</Box>
)}
</Box>
);
})}
</Stack>
</ScrollArea.Autosize>
);
}

View File

@@ -50,8 +50,8 @@ export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
<MenuItemCom
key={k}
item={item}
isActive={pathname === item.href ||
(item.children?.some(child => child.href === pathname))}
isActive={item.href && pathname.startsWith(item.href) ||
(item.children?.some(child => child.href && pathname.startsWith(child.href)))}
/>
))}

View File

@@ -38,7 +38,7 @@ export function NavbarSubMenu({ item }: { item: MenuItem[] | null }) {
justify="space-between"
size="lg"
radius="md"
color={pathname === link.href ? 'blue' : 'gray'}
color={link.href && pathname.startsWith(link.href) ? 'blue' : 'gray'}
onClick={() => {
if (link.href) {
router.push(link.href);
@@ -49,12 +49,12 @@ export function NavbarSubMenu({ item }: { item: MenuItem[] | null }) {
rightSection={<IconArrowRight size={18} />}
styles={(theme) => ({
root: {
background: pathname === link.href ? theme.colors.blue[0] : 'transparent',
color: pathname === link.href ? theme.colors.blue[7] : colors['blue-button'],
fontWeight: pathname === link.href ? 600 : 500,
background: link.href && pathname.startsWith(link.href) ? theme.colors.blue[0] : 'transparent',
color: link.href && pathname.startsWith(link.href) ? theme.colors.blue[7] : colors['blue-button'],
fontWeight: link.href && pathname.startsWith(link.href) ? 600 : 500,
transition: "all 0.2s ease",
"&:hover": {
background: pathname === link.href ? theme.colors.blue[1] : theme.colors.gray[0],
background: link.href && pathname.startsWith(link.href) ? theme.colors.blue[1] : theme.colors.gray[0],
}
},
})}

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
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 { useEffect, useState } from 'react';
import { useSnapshot } from 'valtio';
@@ -8,105 +10,175 @@ import getDetailUrl from './searchUrl';
export default function GlobalSearch() {
const snap = useSnapshot(searchState);
const [isOpen, setIsOpen] = useState(false);
const [opened, setOpened] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
// Toggle modal when there's a query
// Buka popover saat ada query
useEffect(() => {
setIsOpen(!!snap.query);
setOpened(!!snap.query);
}, [snap.query]);
// Infinite scroll
// Infinite scroll handler
useEffect(() => {
const handleScroll = () => {
const bottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (bottom && !snap.loading) searchState.next();
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (nearBottom && !snap.loading) searchState.next();
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [snap.loading]);
return (
<Box style={{ position: 'relative', width: '100%' }}>
<TextInput
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
const handleSelect = async (e: React.MouseEvent, item: any) => {
e.preventDefault();
e.stopPropagation();
if (isNavigating) return;
setIsNavigating(true);
try {
// 🔥 pastikan objek udah “dikeluarkan” dari Proxy valtio
const rawItem = JSON.parse(JSON.stringify(item));
// 🔥 pastikan type-nya string murni
const type = String(rawItem.type || '').trim().toLowerCase();
// 🔥 panggil getDetailUrl pakai type yang fix
let url = getDetailUrl({ ...rawItem, type });
// kalau hasil undefined atau default, fallback ke link eksternal
if (!url || url === '/darmasaba') {
if (rawItem.link && rawItem.link.startsWith('http')) {
url = rawItem.link;
}
/>
}
if (!url) {
console.warn('URL tidak ditemukan untuk item:', rawItem);
setIsNavigating(false);
return;
}
console.log('Navigating to:', url);
// tutup popover dulu
setOpened(false);
searchState.query = '';
searchState.results = [];
searchState.loading = false;
// kasih delay biar UI nutup dulu
await new Promise((r) => setTimeout(r, 100));
// navigasi
if (url.startsWith('http')) {
window.location.href = url;
} else {
window.location.href = url;
}
} catch (err) {
console.error('Error saat navigasi:', err);
setIsNavigating(false);
}
};
{/* Modal for search results */}
<Modal
opened={isOpen && !!snap.query}
onClose={() => {
searchState.query = '';
searchState.results = [];
const clearSearch = () => {
searchState.query = '';
searchState.results = [];
searchState.page = 1;
searchState.nextPage = null;
setOpened(false);
setIsNavigating(false);
};
return (
<Box pos="relative">
<Popover
opened={opened && !!snap.query}
onChange={(isOpen) => {
if (!isOpen) clearSearch();
setOpened(isOpen);
}}
withCloseButton={false}
size="lg"
padding={0}
width="target"
position="bottom"
shadow="md"
withinPortal
radius="md"
style={{ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 1000 }}
zIndex={2000}
closeOnClickOutside={true}
closeOnEscape={true}
styles={{
content: { // Changed from 'modal' to 'content'
backgroundColor: 'white',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.5rem',
maxHeight: '400px',
dropdown: {
zIndex: 2000,
borderRadius: 12,
overflow: 'hidden',
},
}}
>
<Box style={{ maxHeight: '400px', overflowY: 'auto' }}>
{snap.results.map((item, i) => (
<Box
key={i}
p="sm"
style={{
borderBottom: '1px solid #eee',
cursor: 'pointer',
transition: 'background 0.2s',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onClick={() => {
const url = getDetailUrl(item);
window.location.href = url;
}}
>
<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>
))}
{snap.loading && (
<Popover.Target>
<TextInput
placeholder="Cari apapun..."
value={snap.query}
onChange={(e) => {
searchState.query = e.currentTarget.value;
debouncedFetch();
}}
radius="xl"
size="md"
rightSection={
snap.query ? (
<IconX
size={16}
style={{ cursor: 'pointer' }}
onClick={clearSearch}
/>
) : undefined
}
/>
</Popover.Target>
<Popover.Dropdown
p={0}
style={{
maxHeight: 350,
overflowY: 'auto',
backgroundColor: '#fff',
border: '1px solid #eee',
}}
>
{[...snap.results].length > 0 ? (
[...snap.results].map((item: any, i: number) => (
<Box
key={i}
p="sm"
className="search-result-item" // Add class untuk prevent close
style={{
borderBottom: '1px solid #f1f1f1',
cursor: isNavigating ? 'wait' : 'pointer',
background: 'white',
transition: 'background 0.2s',
opacity: isNavigating ? 0.6 : 1,
}}
onMouseEnter={(e) => !isNavigating && (e.currentTarget.style.background = '#f9f9f9')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
onClick={(e) => handleSelect(e, item)}
>
<Text size="sm" fw={500} lineClamp={1}>
{item.name ?? item.nama ?? item.namaPasar ?? item.judul ?? '(Tanpa nama)'}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
dari modul: {item.type || '-'}
</Text>
</Box>
))
) : (
<Center py="md">
<Loader size="sm" />
{snap.loading ? <Loader size="sm" /> : <Text fz="sm">Tidak ada hasil</Text>}
</Center>
)}
</Box>
</Modal>
</Popover.Dropdown>
</Popover>
</Box>
);
}

View File

@@ -37,10 +37,10 @@ function Apbdes() {
<Stack p="lg" gap="4rem" bg={colors.Bg}>
<Box>
<Stack gap="sm">
<Text ta={"center"} fz={{ base: '2.4rem', sm: '4rem' }} fw="bold" lh={1.2}>
<Text ta={"center"} fw={"bold"} fz={{ base: "1.8rem", md: "3.4rem" }}>
{textHeading.title}
</Text>
<Text ta={"center"} fz={{ base: '1rem', sm: '1.3rem' }} c="dimmed">
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
{textHeading.des}
</Text>
</Stack>

View File

@@ -23,7 +23,7 @@ function Kepuasan() {
const [donutDataJenisKelamin, setDonutDataJenisKelamin] = useState<ChartDataItem[]>([]);
const [donutDataRating, setDonutDataRating] = useState<ChartDataItem[]>([]);
const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]);
const [barChartData, setBarChartData] = useState<Array<{ month: string; count: number }>>([]);
const [barChartData, setBarChartData] = useState<Array<{ month: string; Responden: number }>>([]);
const [opened, { open, close }] = useDisclosure(false)
const resetForm = () => {
@@ -121,18 +121,18 @@ function Kepuasan() {
// Convert map to array and sort by date
const barData = Array.from(monthYearMap.entries())
.map(([key, count]) => {
.map(([key, Responden]) => {
const [year, month] = key.split('-');
const monthName = new Date(Number(year), Number(month) - 1, 1)
.toLocaleString('id-ID', { month: 'long' });
return {
month: `${monthName} ${year}`,
count,
Responden,
sortKey: parseInt(`${year}${String(month).padStart(2, '0')}`, 10)
};
})
.sort((a, b) => a.sortKey - b.sortKey)
.map(({ month, count }) => ({ month, count }));
.map(({ month, Responden }) => ({ month, Responden }));
setBarChartData(barData);
}
@@ -156,9 +156,9 @@ function Kepuasan() {
<Stack p="sm">
<Container w={{ base: "100%", md: "80%" }} p={"xl"}>
<Center>
<Text ta={"center"} fz={{ base: "2.4rem", md: "3.4rem" }}>Indeks Kepuasan Masyarakat</Text>
<Text fw={"bold"} fz={{ base: "1.8rem", md: "3.4rem" }}>Indeks Kepuasan Masyarakat</Text>
</Center>
<Text fz={{ base: "1.2rem", md: "1.4rem" }} ta={"center"}>Ukur kebahagiaan warga, tingkatkan layanan desa! Dengan partisipasi aktif masyarakat, kami berkomitmen untuk terus memperbaiki layanan agar lebih transparan, efektif, dan sesuai dengan kebutuhan warga. Kepuasan Anda adalah prioritas utama kami dalam membangun desa yang lebih baik!</Text>
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>Ukur kebahagiaan warga, tingkatkan layanan desa! Dengan partisipasi aktif masyarakat, kami berkomitmen untuk terus memperbaiki layanan agar lebih transparan, efektif, dan sesuai dengan kebutuhan warga. Kepuasan Anda adalah prioritas utama kami dalam membangun desa yang lebih baik!</Text>
<Center mt={10}>
<Button
radius={"lg"}
@@ -185,7 +185,7 @@ function Kepuasan() {
h={window.innerWidth < 480 ? 200 : 300}
data={barChartData}
dataKey="month"
series={[{ name: 'count', color: colors['blue-button'] }]}
series={[{ name: 'Responden', color: colors['blue-button'] }]}
tickLine="y"
xAxisLabel="Bulan"
yAxisLabel="Jumlah Responden"
@@ -448,7 +448,7 @@ function Kepuasan() {
h={300}
data={barChartData}
dataKey="month"
series={[{ name: 'count', color: colors['blue-button'] }]}
series={[{ name: 'Responden', color: colors['blue-button'] }]}
tickLine="y"
xAxisLabel="Bulan"
yAxisLabel="Jumlah Responden"

View File

@@ -29,42 +29,42 @@ function ModuleItem({ data }: { data: ProgramInovasiItem }) {
return (
<motion.div whileHover={{ scale: 1.03 }}>
<Paper
onClick={() => router.push(`/darmasaba/program-inovasi/${data.id}`)}
p="lg"
radius="xl"
shadow="sm"
role="button"
tabIndex={0}
className="cursor-pointer transition-all"
bg={isDark ? "dark.6" : "white"}
>
<Center h={160}>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.name}
radius="md"
fit="cover"
h={140}
w="100%"
loading="lazy"
/>
) : (
<Stack align="center" gap="xs">
<IconPhotoOff size={38} stroke={1.5} />
<Text size="sm" c="dimmed">
Belum ada gambar
</Text>
</Stack>
)}
</Center>
<Box mt="md">
<Text fw={600} ta="center" size="md">
{data.name}
</Text>
</Box>
</Paper>
<Paper
onClick={() => router.push(`/darmasaba/program-inovasi/${data.id}`)}
p="lg"
radius="xl"
shadow="sm"
role="button"
tabIndex={0}
className="cursor-pointer transition-all"
bg={isDark ? "dark.6" : "white"}
>
<Center h={160}>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.name}
radius="md"
fit="contain"
h={140}
w="100%"
style={{ objectPosition: "center" }}
/>
) : (
<Stack align="center" gap="xs">
<IconPhotoOff size={38} stroke={1.5} />
<Text size="sm" c="dimmed">
Belum ada gambar
</Text>
</Stack>
)}
</Center>
<Box mt="md">
<Text fw={600} ta="center" size="md">
{data.name}
</Text>
</Box>
</Paper>
</motion.div>
);
}
@@ -110,11 +110,11 @@ function ModuleView() {
viewport: { paddingRight: 8 }, // kasih jarak biar scroll nggak dempet
}}
>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg" mt="lg">
{listImageState.findMany.data?.map((item) => (
<ModuleItem key={item.id} data={item} />
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg" mt="lg">
{listImageState.findMany.data?.map((item) => (
<ModuleItem key={item.id} data={item} />
))}
</SimpleGrid>
</ScrollArea>
);
}

View File

@@ -30,21 +30,41 @@ export default function ProfileView({ data }: ProfileViewProps) {
justify="end"
align="end"
pos="relative"
w={{ base: '100%', md: '40%' }}
px="xl"
w={{
base: '100%', // mobile: full width
xs: '100%', // small mobile
sm: '85%', // tablet: 85%
md: '60%', // laptop: 60%
lg: '55%', // laptop large: 55%
xl: '50%' // extra large (4K): 50%
}}
px={{ base: 'md', sm: 'lg', md: 'xl', xl: '2xl' }}
h={{ base: 'auto', sm: '500px', md: '600px', lg: '650px', xl: '700px' }}
>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.name || 'Foto profil'}
fit="contain"
radius="lg"
loading="lazy"
<Box
pos="relative"
w="100%"
h="100%"
style={{
objectPosition: 'bottom center',
transform: 'translateY(10px)', // sedikit turun biar natural
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
/>
>
<Image
src={data.image.link}
alt={data.name || 'Foto profil'}
fit="contain"
radius="lg"
loading="lazy"
w="100%"
h="100%"
style={{
objectPosition: 'center bottom',
}}
/>
</Box>
) : (
<Stack align="center" gap="xs" w="100%" py="xl">
<IconUserCircle size={96} stroke={1.5} />
@@ -54,36 +74,47 @@ export default function ProfileView({ data }: ProfileViewProps) {
</Stack>
)}
{/* Box nama dan jabatan - sedikit overlap dengan gambar */}
{/* Box nama dan jabatan - responsive positioning */}
<Box
pos="absolute"
bottom={-20} // bikin naik sedikit ke gambar
bottom={{ base: -30, sm: -25, md: -20 }}
right={0}
w="100%"
p={{ base: 'xs', md: 'md' }}
style={{ pointerEvents: 'none' }} // biar ga ganggu klik di gambar
w={{ base: '95%', sm: '100%' }}
px={{ base: 'xs', sm: 'sm', md: 'md' }}
style={{ pointerEvents: 'none' }}
>
<Card
px="lg"
py="sm"
px={{ base: 'md', sm: 'lg' }}
py={{ base: 'xs', sm: 'sm' }}
radius="lg"
withBorder
style={{
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
backdropFilter: 'blur(6px)',
pointerEvents: 'auto',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
}}
>
<Tooltip label="Jabatan Resmi" withArrow>
<Text fz="sm" c="dimmed">
<Text
fz={{ base: 'xs', sm: 'sm' }}
c="dimmed"
lineClamp={1}
>
{data.position || 'Tidak ada jabatan'}
</Text>
</Tooltip>
<Text c={colors['blue-button']} fw={700} fz="xl" mt={4}>
<Text
c={colors['blue-button']}
fw={700}
fz={{ base: 'lg', sm: 'xl' }}
mt={4}
lineClamp={2}
>
{data.name}
</Text>
</Card>
</Box>
</Stack>
);
}
}

View File

@@ -1,26 +1,27 @@
"use client";
import colors from "@/con/colors";
import { Prisma } from "@prisma/client";
import {
Badge,
Box,
Card,
Skeleton,
Center,
Flex,
Grid,
GridCol,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Center,
Tooltip,
Badge,
} from "@mantine/core";
import { Prisma } from "@prisma/client";
import { IconCalendarTime, IconInfoCircle } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import ModuleView from "./ModuleView";
import SosmedView from "./SosmedView";
import ProfileView from "./ProfileView";
import SosmedView from "./SosmedView";
const getDayOfWeek = () => {
const days = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];
@@ -126,17 +127,15 @@ function LandingPage() {
<Card radius="xl" bg={colors.grey[1]} p="lg" shadow="xl">
<Stack gap="xl">
<Flex gap="md" wrap="wrap">
<Group>
<Box bg="white" w={72} h={72} p="sm" style={{ borderRadius: 24 }}>
<Image loading="lazy" src="/darmasaba-icon.png" alt="Logo Darmasaba" fit="contain" />
</Box>
<Box bg="white" w={72} h={72} p="sm" style={{ borderRadius: 24 }}>
<Image loading="lazy" src="/pudak-icon.png" alt="Logo Pudak" fit="contain" />
</Box>
</Group>
<Grid w="100%">
<Grid.Col span={{ base: 3, sm: 2 }}>
<Box bg="white" w={72} h={72} p="sm" style={{ borderRadius: 24 }}>
<Image loading="lazy" src="/darmasaba-icon.png" alt="Logo Darmasaba" fit="contain" />
</Box>
</Grid.Col>
<Grid.Col span={{ base: 9, sm: 10 }}>
<Box bg="white" w={72} h={72} p="sm" style={{ borderRadius: 24 }}>
<Image loading="lazy" src="/pudak-icon.png" alt="Logo Pudak" fit="contain" />
</Box>
</Grid.Col>
<Grid.Col span={12}>
<Paper
bg={colors["blue-button"]}
@@ -199,7 +198,7 @@ function LandingPage() {
)}
<Text ta="center" c={colors.trans.dark[2]}>
Bagikan ide, kritik, atau saran Anda untuk mendukung pembangunan desa.
Bagikan ide, kritik, atau saran Anda untuk mendukung pembangunan desa.
Semua lebih mudah dengan fitur interaktif yang kami sediakan.
</Text>
</Stack>

View File

@@ -31,16 +31,12 @@ function Layanan() {
return (
<Stack pos={"relative"} bg={colors.grey[1]} gap={"42"} py={"xl"}>
<Container w={{ base: "100%", md: "50%" }} p={"xl"}>
<Container w={{ base: "100%", md: "80%" }} p={"xl"} >
<Stack align="center" gap={"0"}>
<Text fz={"3.4rem"} fw={"bold"}>
<Text fw={"bold"} fz={{ base: "1.8rem", md: "3.4rem" }}>
{textHeading.title}
</Text>
<Text
style={{
textAlign: "center",
}}
>
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
{textHeading.des}
</Text>
<Box p={"md"}>

View File

@@ -6,6 +6,7 @@ import {
BackgroundImage,
Box,
Button,
Container,
Divider,
Group,
Loader,
@@ -49,14 +50,14 @@ function Potensi() {
return (
<Stack p="sm" gap="4rem">
<Box>
<Text ta={"center"} fz={{ base: "2.4rem", md: "3.4rem" }} fw={700} c={colors["blue-button"]}>
<Container w={{ base: "100%", md: "80%" }} p={"xl"} >
<Text ta={"center"} fw={"bold"} fz={{ base: "1.8rem", md: "3.4rem" }}>
{textHeading.title}
</Text>
<Text ta={"center"} fz={{ base: "1.4rem", md: "1.6rem" }} c="black">
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
{textHeading.des}
</Text>
</Box>
</Container>
{loading ? (
<Stack align="center" justify="center" h={300}>

View File

@@ -50,7 +50,7 @@ export default function SDGS() {
SDGs Desa
</Title>
</Center>
<Text fz={{ base: "1rem", md: "1.2rem" }} ta="center" c="dimmed" mt="md" maw={820} mx="auto">
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
SDGs Desa merupakan langkah nyata untuk mewujudkan desa yang maju, inklusif, dan berkelanjutan melalui 17 tujuan pembangunan dari pengentasan kemiskinan, pendidikan, kesehatan, kesetaraan gender, hingga pelestarian lingkungan.
</Text>

View File

@@ -1,91 +1,92 @@
const getDetailUrl = (item: { type?: string; id: string | number; [key: string]: unknown }) => {
const { type, id, kategori } = item;
const typeUrlMap: Record<string, string> = {
programinovasi: `/darmasaba/program-inovasi/${id}`,
desaantikorupsi: '/darmasaba/desa-anti-korupsi',
sdgsdesa: '/darmasaba/sdgs-desa',
apbdes: '/darmasaba/apbdes',
prestasidesa: '/darmasaba/prestasi-desa',
pejabatdesa: '/darmasaba/profile/pejabat-desa',
strukturppid: '/darmasaba/ppid/struktur-ppid',
visimisippid: '/darmasaba/ppid/visi-misi',
dasarhukumppid: '/darmasaba/ppid/dasar-hukum',
profileppid: '/darmasaba/ppid/profile',
daftarinformasipublik: '/darmasaba/ppid/daftar-informasi-publik',
perbekeldarmasaba: '/darmasaba/desa/profile',
berita: `/darmasaba/desa/berita/${kategori}/${id}`,
pengumuman: `/darmasaba/desa/pengumuman/${kategori}/${id}`,
sejarahdesa: '/darmasaba/desa/profile',
visimisidesa: '/darmasaba/desa/profile',
lambangdesa: '/darmasaba/desa/profile',
maskotdesa: '/darmasaba/desa/profile',
profilperbekel: '/darmasaba/desa/profile',
potensi: '/darmasaba/desa/potensi-desa',
galleryFoto: '/darmasaba/desa/gallery/foto',
galleryVideo: '/darmasaba/desa/gallery/video',
pelayananSuratKeterangan: '/darmasaba/desa/layanan',
pelayananPerizinanBerusaha: '/darmasaba/desa/layanan',
pelayananTelunjukSaktiDesa: '/darmasaba/desa/layanan',
pelayananPendudukNonPermanent: '/darmasaba/desa/layanan',
penghargaan: '/darmasaba/desa/penghargaan',
posyandu: '/darmasaba/kesehatan/posyandu',
fasilitasKesehatan: '/darmasaba/kesehatan/data-kesehatan-warga',
jadwalKegiatan: '/darmasaba/kesehatan/data-kesehatan-warga',
artikelKesehatan: '/darmasaba/kesehatan/data-kesehatan-warga',
puskesmas: '/darmasaba/kesehatan/puskesmas',
programKesehatan: '/darmasaba/kesehatan/program-kesehatan',
penangananDarurat: '/darmasaba/kesehatan/penanganan-darurat',
kontakDarurat: '/darmasaba/kesehatan/kontak-darurat',
infoWabahPenyakit: '/darmasaba/kesehatan/info-wabah-penyakit',
keamananLingkungan: '/darmasaba/keamanan/keamanan-lingkungan-pecalang-patwal',
polsekTerdekat: '/darmasaba/keamanan/polsek-terdekat',
kontakDaruratKeamanan: '/darmasaba/keamanan/kontak-darurat',
pencegahanKriminalitas: '/darmasaba/keamanan/pencegahan-kriminalitas',
laporanPublik: '/darmasaba/keamanan/laporan-publik',
tipsKeamanan: '/darmasaba/keamanan/tips-keamanan',
pasarDesa: '/darmasaba/ekonomi/pasar-desa',
lowonganKerjaLokal: '/darmasaba/ekonomi/lowongan-kerja-lokal',
strukturOrganisasi: '/darmasaba/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa',
jumlahPendudukUsiaKerjaYangMenganggurUsia: '/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur',
jumlahPendudukUsiaKerjaYangMenganggurPendidikan: '/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur',
jumlahPendudukMiskin: '/darmasaba/ekonomi/jumlah-penduduk-miskin',
programKemiskinan: '/darmasaba/ekonomi/program-kemiskinan',
sektorUnggulanDesa: '/darmasaba/ekonomi/sektor-unggulan-desa',
demografiPekerjaan: '/darmasaba/ekonomi/demografi-pekerjaan',
desaDigital: '/darmasaba/inovasi/desa-digital-smart-village',
programKreatif: '/darmasaba/inovasi/program-kreatif-desa',
kolaborasiInovasi: '/darmasaba/inovasi/kolaborasi-inovasi',
mitraKolaborasi: '/darmasaba/inovasi/kolaborasi-inovasi',
infoTekno: '/darmasaba/inovasi/info-teknologi-tepat-guna',
pengelolaanSampah: '/darmasaba/lingkungan/pengelolaan-sampah-bank-sampah',
keteranganBankSampahTerdekat: '/darmasaba/lingkungan/pengelolaan-sampah-bank-sampah',
programPenghijauan: '/darmasaba/lingkungan/program-penghijauan',
dataLingkunganDesa: '/darmasaba/lingkungan/data-lingkungan-desa',
gotongRoyong: '/darmasaba/lingkungan/gotong-royong',
tujuanEdukasiLingkungan: '/darmasaba/lingkungan/tujuan-edukasi-lingkungan',
materiEdukasiLingkungan: '/darmasaba/lingkungan/materi-edukasi-lingkungan',
contohEdukasiLingkungan: '/darmasaba/lingkungan/contoh-edukasi-lingkungan',
filosofiTriHita: '/darmasaba/lingkungan/filosofi-tri-hita',
bentukKonservasiBerdasarkanAdat: '/darmasaba/lingkungan/bentuk-konservasi-berdasarkan-adat',
nilaiKonservasiAdat: '/darmasaba/lingkungan/nilai-konservasi-adat',
jenjangPendidikan: '/darmasaba/inovasi/jenjang-pendidikan',
lembaga: '/darmasaba/inovasi/lembaga',
siswa: '/darmasaba/inovasi/siswa',
pengajar: '/darmasaba/inovasi/pengajar',
keunggulanProgram: '/darmasaba/inovasi/keunggulan-program',
tujuanProgram: '/darmasaba/inovasi/tujuan-program',
programUnggulan: '/darmasaba/inovasi/program-unggulan',
lokasiJadwalBimbinganBelajarDesa: '/darmasaba/inovasi/lokasi-jadwal-bimbingan-belajar-desa',
fasilitasBimbinganBelajarDesa: '/darmasaba/inovasi/fasilitas-bimbingan-belajar-desa',
tujuanPendidikanNonFormal: '/darmasaba/inovasi/tujuan-pendidikan-non-formal',
tempatKegiatan: '/darmasaba/inovasi/tempat-kegiatan',
jenisProgramYangDiselenggarakan: '/darmasaba/inovasi/jenis-program-yang-diselenggarakan',
dataPerpustakaan: '/darmasaba/inovasi/data-perpustakaan',
dataPendidikan: '/darmasaba/inovasi/data-pendidikan',
const map: Record<string, (id: string | number, kategori?: string) => string> = {
programinovasi: (id) => `/darmasaba/program-inovasi/${id}`,
desaantikorupsi: () => '/darmasaba/desa-anti-korupsi',
sdgsdesa: () => '/darmasaba/sdgs-desa',
apbdes: () => '/darmasaba/apbdes',
prestasidesa: () => '/darmasaba/prestasi-desa',
pejabatdesa: () => '/darmasaba/ppid/profile-ppid',
strukturppid: () => '/darmasaba/ppid/struktur-ppid',
visimisippid: () => '/darmasaba/ppid/visi-misi',
dasarhukumppid: () => '/darmasaba/ppid/dasar-hukum',
profileppid: () => '/darmasaba/ppid/profile',
daftarinformasipublik: () => '/darmasaba/ppid/daftar-informasi-publik',
perbekeldarmasaba: () => '/darmasaba/desa/profile',
berita: (id, kategori) => `/darmasaba/desa/berita/${kategori}/${id}`,
pengumuman: (id, kategori) => `/darmasaba/desa/pengumuman/${kategori}/${id}`,
sejarahdesa: () => '/darmasaba/desa/profile',
visimisidesa: () => '/darmasaba/desa/profile',
lambangdesa: () => '/darmasaba/desa/profile',
maskotdesa: () => '/darmasaba/desa/profile',
profilperbekel: () => '/darmasaba/desa/profile',
potensi: () => '/darmasaba/desa/potensi-desa',
galleryFoto: () => '/darmasaba/desa/gallery/foto',
galleryVideo: () => '/darmasaba/desa/gallery/video',
pelayananSuratKeterangan: () => '/darmasaba/desa/layanan',
pelayananPerizinanBerusaha: () => '/darmasaba/desa/layanan',
pelayananTelunjukSaktiDesa: () => '/darmasaba/desa/layanan',
pelayananPendudukNonPermanent: () => '/darmasaba/desa/layanan',
penghargaan: () => '/darmasaba/desa/penghargaan',
posyandu: (id) => `/darmasaba/kesehatan/posyandu/${id}`,
fasilitasKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
jadwalKegiatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
artikelKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
puskesmas: () => '/darmasaba/kesehatan/puskesmas',
programKesehatan: () => '/darmasaba/kesehatan/program-kesehatan',
penangananDarurat: () => '/darmasaba/kesehatan/penanganan-darurat',
kontakDarurat: () => '/darmasaba/kesehatan/kontak-darurat',
infoWabahPenyakit: () => '/darmasaba/kesehatan/info-wabah-penyakit',
keamananLingkungan: () => '/darmasaba/keamanan/keamanan-lingkungan-pecalang-patwal',
polsekTerdekat: () => '/darmasaba/keamanan/polsek-terdekat',
kontakDaruratKeamanan: () => '/darmasaba/keamanan/kontak-darurat',
pencegahanKriminalitas: () => '/darmasaba/keamanan/pencegahan-kriminalitas',
laporanPublik: () => '/darmasaba/keamanan/laporan-publik',
tipsKeamanan: () => '/darmasaba/keamanan/tips-keamanan',
pasarDesa: () => '/darmasaba/ekonomi/pasar-desa',
lowonganKerjaLokal: () => '/darmasaba/ekonomi/lowongan-kerja-lokal',
strukturOrganisasi: () => '/darmasaba/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa',
jumlahPendudukUsiaKerjaYangMenganggurUsia: () => '/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur',
jumlahPendudukUsiaKerjaYangMenganggurPendidikan: () => '/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur',
jumlahPendudukMiskin: () => '/darmasaba/ekonomi/jumlah-penduduk-miskin',
programKemiskinan: () => '/darmasaba/ekonomi/program-kemiskinan',
sektorUnggulanDesa: () => '/darmasaba/ekonomi/sektor-unggulan-desa',
demografiPekerjaan: () => '/darmasaba/ekonomi/demografi-pekerjaan',
desaDigital: () => '/darmasaba/inovasi/desa-digital-smart-village',
programKreatif: () => '/darmasaba/inovasi/program-kreatif-desa',
kolaborasiInovasi: () => '/darmasaba/inovasi/kolaborasi-inovasi',
mitraKolaborasi: () => '/darmasaba/inovasi/kolaborasi-inovasi',
infoTekno: () => '/darmasaba/inovasi/info-teknologi-tepat-guna',
pengelolaanSampah: () => '/darmasaba/lingkungan/pengelolaan-sampah-bank-sampah',
keteranganBankSampahTerdekat: () => '/darmasaba/lingkungan/pengelolaan-sampah-bank-sampah',
programPenghijauan: () => '/darmasaba/lingkungan/program-penghijauan',
dataLingkunganDesa: () => '/darmasaba/lingkungan/data-lingkungan-desa',
gotongRoyong: (id, kategori) => `/darmasaba/lingkungan/gotong-royong/${kategori}/${id}`,
tujuanEdukasiLingkungan: () => '/darmasaba/lingkungan/edukasi-lingkungan',
materiEdukasiLingkungan: () => '/darmasaba/lingkungan/edukasi-lingkungan',
contohEdukasiLingkungan: () => '/darmasaba/lingkungan/edukasi-lingkungan',
filosofiTriHita: () => '/darmasaba/lingkungan/konservasi-adat-bali',
bentukKonservasiBerdasarkanAdat: () => '/darmasaba/lingkungan/konservasi-adat-bali',
nilaiKonservasiAdat: () => '/darmasaba/lingkungan/konservasi-adat-bali',
jenjangPendidikan: () => '/darmasaba/pendidikan/info-sekolah/semua',
lembaga: () => '/darmasaba/pendidikan/info-sekolah/semua/lembaga',
siswa: () => '/darmasaba/pendidikan/info-sekolah/semua/siswa',
pengajar: () => '/darmasaba/pendidikan/info-sekolah/semua/pengajar',
keunggulanProgram: () => '/darmasaba/pendidikan/beasiswa-desa',
tujuanProgram: () => '/darmasaba/pendidikan/program-pendidikan-anak',
programUnggulan: () => '/darmasaba/pendidikan/program-pendidikan-anak',
lokasiJadwalBimbinganBelajarDesa: () => '/darmasaba/pendidikan/bimbingan-belajar-desa',
fasilitasBimbinganBelajarDesa: () => '/darmasaba/pendidikan/bimbingan-belajar-desa',
tujuanPendidikanNonFormal: () => '/darmasaba/pendidikan/pendidikan-non-formal',
tempatKegiatan: () => '/darmasaba/pendidikan/pendidikan-non-formal',
jenisProgramYangDiselenggarakan: () => '/darmasaba/pendidikan/pendidikan-non-formal',
dataPerpustakaan: () => '/darmasaba/pendidikan/perpustakaan-digital/semua',
dataPendidikan: () => '/darmasaba/pendidikan/data-pendidikan',
};
return typeUrlMap[type || ''] || '/darmasaba';
if (type && map[type]) return map[type](id, kategori as string | undefined);
return '/darmasaba';
};
export default getDetailUrl;

View File

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