Fix QC Keano FrontEnd
Fix QC Kak Ayu Admin 29 Okt
This commit is contained in:
@@ -12,6 +12,7 @@ import BackButton from '../layanan/_com/BackButto';
|
||||
function Page() {
|
||||
const router = useTransitionRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
const state = useProxy(potensiDesaState)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,20 +89,55 @@ function Page() {
|
||||
src={v.image?.link || ''}
|
||||
h={360}
|
||||
radius="xl"
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(v.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
>
|
||||
{/* Overlay with smooth transition */}
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
bg="linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.7) 100%)"
|
||||
bg={hoveredId === v.id
|
||||
? "linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.75) 100%)"
|
||||
: "linear-gradient(180deg, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.15) 100%)"
|
||||
}
|
||||
style={{
|
||||
transition: 'background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack justify="space-between" h="100%" gap="md" p="lg" pos="relative">
|
||||
{/* Kategori badge - always visible */}
|
||||
<Group>
|
||||
<Paper radius="lg" py={6} px={12} shadow="md" withBorder bg="rgba(255,255,255,0.85)">
|
||||
<Paper
|
||||
radius="lg"
|
||||
py={6}
|
||||
px={12}
|
||||
shadow="md"
|
||||
withBorder
|
||||
bg="rgba(255,255,255,0.9)"
|
||||
style={{
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<Text fz="sm" fw={600}>{v.kategori?.nama}</Text>
|
||||
</Paper>
|
||||
</Group>
|
||||
<Box>
|
||||
|
||||
{/* Nama potensi - visible on hover */}
|
||||
<Box
|
||||
style={{
|
||||
opacity: hoveredId === v.id ? 1 : 0,
|
||||
transform: hoveredId === v.id ? 'translateY(0)' : 'translateY(10px)',
|
||||
transition: 'all 0.3s ease',
|
||||
pointerEvents: hoveredId === v.id ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fw={800}
|
||||
c="white"
|
||||
@@ -113,18 +149,28 @@ function Page() {
|
||||
{v.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
radius="xl"
|
||||
size="md"
|
||||
leftSection={<IconEye size={18} />}
|
||||
bg={colors["blue-button"]}
|
||||
variant="gradient"
|
||||
gradient={{ from: colors["blue-button"], to: "#4dabf7", deg: 45 }}
|
||||
onClick={() => router.push(`/darmasaba/desa/potensi/${v.id}`)}
|
||||
>
|
||||
Lihat Detail
|
||||
</Button>
|
||||
|
||||
{/* Button - visible on hover */}
|
||||
<Group
|
||||
justify="center"
|
||||
style={{
|
||||
opacity: hoveredId === v.id ? 1 : 0,
|
||||
transform: hoveredId === v.id ? 'translateY(0)' : 'translateY(10px)',
|
||||
transition: 'all 0.3s ease',
|
||||
pointerEvents: hoveredId === v.id ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
radius="xl"
|
||||
size="md"
|
||||
leftSection={<IconEye size={18} />}
|
||||
bg={colors["blue-button"]}
|
||||
variant="gradient"
|
||||
gradient={{ from: colors["blue-button"], to: "#4dabf7", deg: 45 }}
|
||||
onClick={() => router.push(`/darmasaba/desa/potensi/${v.id}`)}
|
||||
>
|
||||
Lihat Detail
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</BackgroundImage>
|
||||
@@ -147,4 +193,4 @@ function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default Page;
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
import desaDigitalState from '@/app/admin/(dashboard)/_state/inovasi/desa-digital';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import BackButton from '../../../desa/layanan/_com/BackButto';
|
||||
|
||||
function DetailDesaDigitalUser() {
|
||||
const stateDesaDigital = useProxy(desaDigitalState);
|
||||
const params = useParams();
|
||||
|
||||
useShallowEffect(() => {
|
||||
stateDesaDigital.findUnique.load(params?.id as string);
|
||||
}, []);
|
||||
|
||||
if (!stateDesaDigital.findUnique.data) {
|
||||
return (
|
||||
<Stack py={40} align="center">
|
||||
<Skeleton height={400} radius="md" w={{ base: "90%", md: "60%" }} />
|
||||
<Text fz="lg" c="dimmed">Memuat data...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const data = stateDesaDigital.findUnique.data;
|
||||
|
||||
return (
|
||||
<Stack bg={colors.Bg} py="xl" align="center" px={{ base: "md", md: "lg" }}>
|
||||
{/* Tombol Back */}
|
||||
<Box w={{ base: "100%", md: "60%" }}>
|
||||
<BackButton/>
|
||||
</Box>
|
||||
|
||||
{/* Card Detail */}
|
||||
<Paper
|
||||
w={{ base: "100%", md: "60%" }}
|
||||
bg={colors["white-1"]}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text ta={"center"} fz={{ base: "xl", md: "2xl" }} fw="bold" c={colors["blue-button"]}>
|
||||
{data?.name || "Desa Digital"}
|
||||
</Text>
|
||||
|
||||
{/* Gambar */}
|
||||
{data?.image?.link ? (
|
||||
<Center>
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.name || "Gambar Desa Digital"}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
w={{ base: "100%", md: "80%" }}
|
||||
h={{ base: 250, md: 350 }}
|
||||
style={{ objectPosition: "center", borderRadius: 12 }}
|
||||
/>
|
||||
</Center>
|
||||
) : (
|
||||
<Center>
|
||||
<Text c="dimmed">Tidak ada gambar</Text>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box pt="md">
|
||||
<Text
|
||||
fz={{ base: "md", md: "lg" }}
|
||||
c="dimmed"
|
||||
style={{ lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: data?.deskripsi || "-" }}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailDesaDigitalUser;
|
||||
@@ -1,17 +1,19 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Stack, Box, Text, Paper, SimpleGrid, Image, Skeleton, Center, Pagination, Grid, GridCol, TextInput } from '@mantine/core';
|
||||
import { Stack, Box, Text, Paper, SimpleGrid, Image, Skeleton, Center, Pagination, Grid, GridCol, TextInput, Button } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import desaDigitalState from '@/app/admin/(dashboard)/_state/inovasi/desa-digital';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
function Page() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
|
||||
const state = useProxy(desaDigitalState)
|
||||
const router = useRouter()
|
||||
const {
|
||||
data,
|
||||
page,
|
||||
@@ -39,10 +41,10 @@ function Page() {
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }} >
|
||||
<Grid align='center'>
|
||||
<Grid align='center'>
|
||||
<GridCol span={{ base: 12, md: 9 }}>
|
||||
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
|
||||
Desa Digital / Smart Village
|
||||
Desa Digital / Smart Village
|
||||
</Text>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3 }}>
|
||||
@@ -70,12 +72,62 @@ function Page() {
|
||||
>
|
||||
{filteredData.map((v, k) => {
|
||||
return (
|
||||
<Paper p={'xl'} key={k}>
|
||||
<Image src={v.image.link? v.image.link : ''} pb={10} radius={10} alt='' loading="lazy"/>
|
||||
<Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>{v.name}</Text>
|
||||
<Box>
|
||||
<Text fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: v.deskripsi }} />
|
||||
</Box>
|
||||
<Paper
|
||||
key={k}
|
||||
radius="xl"
|
||||
shadow="md"
|
||||
withBorder
|
||||
p="lg"
|
||||
bg={colors['white-trans-1']}
|
||||
style={{
|
||||
transition: 'all 200ms ease',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between', // ✅ biar button selalu di bawah
|
||||
height: '100%', // ✅ bikin tinggi seragam
|
||||
}}
|
||||
>
|
||||
<Stack gap={"xs"}>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={v.image.link}
|
||||
alt={v.name}
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transition: 'transform 0.4s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.05)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||
/>
|
||||
</Box>
|
||||
<Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>{v.name}</Text>
|
||||
<Box>
|
||||
<Text lineClamp={3} truncate="end" fz={"md"} style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: v.deskripsi }} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Center mt="md">
|
||||
<Button
|
||||
bg={colors['blue-button']}
|
||||
radius="lg"
|
||||
w="100%"
|
||||
onClick={() => router.push(`/darmasaba/inovasi/desa-digital-smart-village/${v.id}`)}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Center>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Paper, Stack, Text, Skeleton } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import programKreatifState from '@/app/admin/(dashboard)/_state/inovasi/program-kreatif';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
import { IconMapper, IconKey } from '@/app/admin/(dashboard)/_com/iconMap';
|
||||
import { IconKey, IconMapper } from '@/app/admin/(dashboard)/_com/iconMap';
|
||||
import BackButton from '../../../desa/layanan/_com/BackButto';
|
||||
|
||||
function Page() {
|
||||
const stateProgramKreatif = useProxy(programKreatifState);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
useShallowEffect(() => {
|
||||
@@ -31,14 +31,7 @@ function Page() {
|
||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||
{/* Tombol Kembali */}
|
||||
<Box mb="md">
|
||||
<Text
|
||||
c={colors['blue-button']}
|
||||
fw="bold"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
← Kembali
|
||||
</Text>
|
||||
<BackButton/>
|
||||
</Box>
|
||||
|
||||
{/* Konten Utama */}
|
||||
|
||||
@@ -117,7 +117,7 @@ function Page() {
|
||||
)}
|
||||
</Center>
|
||||
<Text ta={'center'} fz={'h3'} fw={'bold'} c={colors['blue-button']}>{v.name}</Text>
|
||||
<Text py={10} ta={'center'} fz={'lg'} c={'black'}>{v.slug}</Text>
|
||||
<Text lineClamp={2} lh={"1.9"} py={10} ta={'center'} fz={'lg'} c={'black'}>{v.slug}</Text>
|
||||
<Center>
|
||||
<Button onClick={() => router.push(`/darmasaba/inovasi/program-kreatif-desa/${v.id}`)} bg={colors['blue-button']}>Selengkapnya</Button>
|
||||
</Center>
|
||||
|
||||
@@ -1,80 +1,132 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong';
|
||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Container, Image, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { Box, Image, Paper, Skeleton, Stack, Text, Title, Group } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import colors from '@/con/colors';
|
||||
import gotongRoyongState from '@/app/admin/(dashboard)/_state/lingkungan/gotong-royong';
|
||||
|
||||
function DetailKegiatanDesaUser() {
|
||||
const kegiatanDesaState = useProxy(gotongRoyongState.kegiatanDesa);
|
||||
const params = useParams();
|
||||
|
||||
useShallowEffect(() => {
|
||||
kegiatanDesaState.findUnique.load(params?.id as string);
|
||||
}, []);
|
||||
|
||||
const data = kegiatanDesaState.findUnique.data;
|
||||
|
||||
function Page() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
const state = useProxy(gotongRoyongState.kegiatanDesa)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await state.findUnique.load(id);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
if (!data) {
|
||||
return (
|
||||
<Center>
|
||||
<Skeleton height={500} />
|
||||
</Center>
|
||||
<Stack py={20}>
|
||||
<Skeleton height={400} radius="md" />
|
||||
<Skeleton height={20} width="70%" />
|
||||
<Skeleton height={15} width="50%" />
|
||||
<Skeleton height={300} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.findUnique.data) {
|
||||
return (
|
||||
<Center>
|
||||
<Text>Data tidak ditemukan</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
|
||||
<Box px={{ base: "md", md: 100 }}><BackButton /></Box>
|
||||
<Container w={{ base: "100%", md: "50%" }} >
|
||||
<Box pb={20}>
|
||||
<Text ta={"center"} fz={"2.4rem"} c={colors["blue-button"]} fw={"bold"}>
|
||||
{state.findUnique.data?.judul}
|
||||
</Text>
|
||||
<Text
|
||||
ta={"center"}
|
||||
fw={"bold"}
|
||||
fz={"1.5rem"}
|
||||
>
|
||||
Informasi Kegiatan Gotong Royong
|
||||
</Text>
|
||||
</Box>
|
||||
<Image src={state.findUnique.data?.image?.link || ''} alt='' w={"100%"} loading="lazy"/>
|
||||
</Container>
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<Stack gap={"xs"}>
|
||||
<Text py={20} style={{wordBreak: "break-word", whiteSpace: "normal"}} fz={{ base: "sm", md: "lg" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: state.findUnique.data?.deskripsiLengkap || '' }} />
|
||||
<Box py={30}>
|
||||
{/* Header Gambar */}
|
||||
|
||||
{/* Konten */}
|
||||
<Paper
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
maw={900}
|
||||
mx="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{data.image?.link && (
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul || 'Kegiatan Desa'}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
h={300}
|
||||
mb="xl"
|
||||
style={{ objectPosition: 'center', width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{/* Judul */}
|
||||
<Title order={2} c={colors['blue-button']}>
|
||||
{data.judul || 'Kegiatan Desa'}
|
||||
</Title>
|
||||
|
||||
{/* Meta Info */}
|
||||
<Group gap="xl" c="dimmed">
|
||||
<Group gap={6}>
|
||||
<IconCalendar size={18} />
|
||||
<Text fz="sm">
|
||||
{data.tanggal ? new Date(data.tanggal).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) : '-'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{data.lokasi && (
|
||||
<Group gap={6}>
|
||||
<IconMapPin size={18} />
|
||||
<Text fz="sm">{data.lokasi}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{data.partisipan && (
|
||||
<Group gap={6}>
|
||||
<IconUsers size={18} />
|
||||
<Text fz="sm">{data.partisipan}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Deskripsi Singkat */}
|
||||
{data.deskripsiSingkat && (
|
||||
<Box>
|
||||
<Text fw={600} fz="lg" mb={4}>
|
||||
Ringkasan
|
||||
</Text>
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsiSingkat }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Deskripsi Lengkap */}
|
||||
{data.deskripsiLengkap && (
|
||||
<Box>
|
||||
<Text fw={600} fz="lg" mb={4}>
|
||||
Detail Kegiatan
|
||||
</Text>
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
style={{ lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Kategori */}
|
||||
{data.kategoriKegiatan?.nama && (
|
||||
<Box mt="md">
|
||||
<Text fw={600} fz="lg">
|
||||
Kategori
|
||||
</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.kategoriKegiatan.nama}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default DetailKegiatanDesaUser;
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
'use client';
|
||||
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
|
||||
import colors from "@/con/colors";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import { Box, Button, Container, Group, Paper, Skeleton, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Box, Button, Container, Group, Paper, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { IconArrowRight, IconAward } from "@tabler/icons-react";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import { useTransitionRouter } from "next-view-transitions";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useProxy } from "valtio/utils";
|
||||
@@ -19,7 +17,6 @@ export default function Page() {
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Container w={{ base: "100%", md: "90%", lg: "60%" }}>
|
||||
|
||||
<Stack align="center" gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconAward size={40} color={colors["blue-button"]} />
|
||||
@@ -30,21 +27,35 @@ export default function Page() {
|
||||
<Text fz="lg" c="dimmed" ta="center">
|
||||
Desa Darmasaba berhasil meraih beragam penghargaan bergengsi yang mencerminkan dedikasi dan kerja keras masyarakat dalam membangun desa yang maju dan berkelanjutan.
|
||||
</Text>
|
||||
<Slider />
|
||||
</Stack>
|
||||
</Container>
|
||||
<Slider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Slider() {
|
||||
const theme = useMantineTheme();
|
||||
const mobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
|
||||
const tablet = useMediaQuery(`(max-width: ${theme.breakpoints.md})`);
|
||||
const autoplay = useRef(Autoplay({ delay: 3000, stopOnInteraction: false }));
|
||||
const mobile = useMediaQuery("(max-width: 768px)", false);
|
||||
const state = useProxy(penghargaanState);
|
||||
const router = useTransitionRouter();
|
||||
|
||||
// Refs for smooth animation
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const isHoveredRef = useRef(false);
|
||||
|
||||
// Refs for drag functionality
|
||||
const isDraggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const scrollLeftRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// Speed configuration
|
||||
const normalSpeed = 1.0; // pixels per frame
|
||||
const hoverSpeed = 0.5; // slower speed on hover
|
||||
|
||||
useEffect(() => {
|
||||
state.findMany.load();
|
||||
}, []);
|
||||
@@ -52,12 +63,128 @@ function Slider() {
|
||||
const data = state.findMany.data || [];
|
||||
const loading = state.findMany.loading;
|
||||
|
||||
// Duplicate slides for seamless infinite loop
|
||||
const slidesData = [...data, ...data, ...data];
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !containerRef.current || slidesData.length === 0) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const slideWidth = container.scrollWidth / slidesData.length;
|
||||
const originalDataLength = data.length;
|
||||
|
||||
// Start from the middle set of slides
|
||||
scrollPositionRef.current = slideWidth * originalDataLength;
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
|
||||
const animate = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const slideWidth = container.scrollWidth / slidesData.length;
|
||||
|
||||
// Check if user recently scrolled manually
|
||||
const timeSinceLastScroll = Date.now() - lastScrollTimeRef.current;
|
||||
const isUserScrolling = timeSinceLastScroll < 100;
|
||||
|
||||
// Only auto-scroll if user is not actively scrolling or dragging
|
||||
if (!isDraggingRef.current && !isUserScrolling) {
|
||||
const currentSpeed = isHoveredRef.current ? hoverSpeed : normalSpeed;
|
||||
scrollPositionRef.current += currentSpeed;
|
||||
|
||||
// Reset position for infinite loop
|
||||
if (scrollPositionRef.current >= slideWidth * (originalDataLength * 2)) {
|
||||
scrollPositionRef.current -= slideWidth * originalDataLength;
|
||||
}
|
||||
|
||||
if (scrollPositionRef.current <= 0) {
|
||||
scrollPositionRef.current += slideWidth * originalDataLength;
|
||||
}
|
||||
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
} else {
|
||||
// Sync scroll position when user is scrolling
|
||||
scrollPositionRef.current = container.scrollLeft;
|
||||
|
||||
// Apply momentum/velocity for smooth drag release
|
||||
if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
|
||||
scrollPositionRef.current += velocityRef.current;
|
||||
velocityRef.current *= 0.95; // Decay velocity
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [loading, slidesData.length, data.length, mobile]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHoveredRef.current = true;
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.current = false;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
// Mouse drag handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
isDraggingRef.current = true;
|
||||
startXRef.current = e.pageX - containerRef.current.offsetLeft;
|
||||
scrollLeftRef.current = containerRef.current.scrollLeft;
|
||||
velocityRef.current = 0;
|
||||
|
||||
containerRef.current.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const x = e.pageX - containerRef.current.offsetLeft;
|
||||
const walk = (x - startXRef.current) * 2;
|
||||
const newScrollLeft = scrollLeftRef.current - walk;
|
||||
|
||||
velocityRef.current = containerRef.current.scrollLeft - newScrollLeft;
|
||||
|
||||
containerRef.current.scrollLeft = newScrollLeft;
|
||||
scrollPositionRef.current = newScrollLeft;
|
||||
lastScrollTimeRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
isDraggingRef.current = false;
|
||||
containerRef.current.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
// Wheel scroll handler
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
containerRef.current.scrollLeft += e.deltaY;
|
||||
scrollPositionRef.current = containerRef.current.scrollLeft;
|
||||
lastScrollTimeRef.current = Date.now();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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" />
|
||||
<Skeleton w={300} h={400} radius="lg" />
|
||||
<Skeleton w={300} h={400} radius="lg" visibleFrom="sm" />
|
||||
<Skeleton w={300} h={400} radius="lg" visibleFrom="md" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -73,138 +200,129 @@ function Slider() {
|
||||
);
|
||||
}
|
||||
|
||||
const slides = data.map((item) => (
|
||||
<Carousel.Slide key={item.id}>
|
||||
<Paper
|
||||
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.8), rgba(0,0,0,0.2))"
|
||||
style={{ borderRadius: 16 }}
|
||||
/>
|
||||
<Stack justify="flex-end" h="100%" gap="sm" p="lg" pos="relative">
|
||||
<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}`)
|
||||
}
|
||||
size="md"
|
||||
radius="xl"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: "#1C6EA4", to: "#69BFF8" }}
|
||||
>
|
||||
Lihat Detail
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Carousel.Slide>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box
|
||||
pos="relative"
|
||||
w="100%"
|
||||
mx="auto"
|
||||
px={{ base: "md", sm: "xl", md: "2rem", lg: "3rem" }}
|
||||
style={{
|
||||
maxWidth: 1300,
|
||||
<Box
|
||||
ref={containerRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
py="xl"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
{/* Blur edges effect */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "120px",
|
||||
height: "100%",
|
||||
background: "linear-gradient(to right, rgba(249, 250, 251, 1), rgba(249, 250, 251, 0))",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: "120px",
|
||||
height: "100%",
|
||||
background: "linear-gradient(to left, rgba(249, 250, 251, 1), rgba(249, 250, 251, 0))",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: mobile ? "1rem" : "1.5rem",
|
||||
paddingLeft: mobile ? "1rem" : "2rem",
|
||||
paddingRight: mobile ? "1rem" : "2rem",
|
||||
}}
|
||||
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>
|
||||
{slidesData.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.id}-${index}`}
|
||||
style={{
|
||||
flex: `0 0 ${mobile ? "90%" : "calc(33.333% - 1rem)"}`,
|
||||
minWidth: mobile ? "90%" : "calc(33.333% - 1rem)",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="lg"
|
||||
shadow="md"
|
||||
pos="relative"
|
||||
style={{
|
||||
height: mobile ? "380px" : "450px",
|
||||
backgroundImage: `url(${item.image?.link})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
transition: "transform 0.3s ease, box-shadow 0.3s ease",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-8px) scale(1.02)";
|
||||
e.currentTarget.style.boxShadow = "0 12px 28px rgba(0,0,0,0.25)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0) scale(1)";
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
bg="linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0.3))"
|
||||
style={{ borderRadius: 16 }}
|
||||
/>
|
||||
<Stack justify="flex-end" h="100%" gap="sm" p="lg" pos="relative">
|
||||
<Text
|
||||
fz={{ base: "lg", sm: "xl", md: "1.5rem" }}
|
||||
fw={700}
|
||||
ta="center"
|
||||
c="white"
|
||||
lineClamp={3}
|
||||
style={{ textShadow: "0 2px 8px rgba(0,0,0,0.8)" }}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
onClick={() => router.push(`/darmasaba/penghargaan/${item.id}`)}
|
||||
size="md"
|
||||
radius="xl"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: "#1C6EA4", to: "#69BFF8" }}
|
||||
style={{
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1.05)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
}}
|
||||
>
|
||||
Lihat Detail
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
"use client";
|
||||
import stateLayananDesa from "@/app/admin/(dashboard)/_state/desa/layananDesa";
|
||||
import colors from "@/con/colors";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -13,10 +12,8 @@ import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import _ from "lodash";
|
||||
import { useTransitionRouter } from "next-view-transitions";
|
||||
import Link from "next/link";
|
||||
@@ -24,123 +21,309 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useProxy } from "valtio/utils";
|
||||
|
||||
const textHeading = {
|
||||
title: "Layanan",
|
||||
des: "Layanan adalah fitur yang membantu warga desa mengakses berbagai kebutuhan administrasi, informasi, dan bantuan secara cepat, mudah, dan transparan. Dengan fitur ini, semua layanan desa ada dalam genggaman Anda!",
|
||||
title: "Layanan",
|
||||
des: "Layanan adalah fitur yang membantu warga desa mengakses berbagai kebutuhan administrasi, informasi, dan bantuan secara cepat, mudah, dan transparan. Dengan fitur ini, semua layanan desa ada dalam genggaman Anda!",
|
||||
};
|
||||
function Layanan() {
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.grey[1]} gap={"xl"} py={"md"}>
|
||||
<Container w={{ base: "100%", md: "80%" }} p={"md"} >
|
||||
<Stack align="center" gap={"0"}>
|
||||
<Text fw={"bold"} c={colors["blue-button"]} fz={{ base: "1.8rem", md: "3.4rem" }}>
|
||||
{textHeading.title}
|
||||
</Text>
|
||||
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
|
||||
{textHeading.des}
|
||||
</Text>
|
||||
<Box p={"md"}>
|
||||
<Button component={Link} href={"/darmasaba/desa/layanan"} variant="filled" bg={colors["blue-button"]} radius={100}>
|
||||
Detail
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Slider />
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
function Layanan() {
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.grey[1]} gap={"xl"} py={"md"}>
|
||||
<Container w={{ base: "100%", md: "80%" }} p={"md"}>
|
||||
<Stack align="center" gap={"0"}>
|
||||
<Text
|
||||
fw={"bold"}
|
||||
c={colors["blue-button"]}
|
||||
fz={{ base: "1.8rem", md: "3.4rem" }}
|
||||
>
|
||||
{textHeading.title}
|
||||
</Text>
|
||||
<Text ta={"center"} fz={{ base: "1rem", md: "1.3rem" }}>
|
||||
{textHeading.des}
|
||||
</Text>
|
||||
<Box p={"md"}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={"/darmasaba/desa/layanan"}
|
||||
variant="filled"
|
||||
bg={colors["blue-button"]}
|
||||
radius={100}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Slider />
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const height = 720;
|
||||
|
||||
function Slider() {
|
||||
const state = useProxy(stateLayananDesa)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
const mobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
|
||||
const autoplay = useRef(Autoplay({ delay: 2000 }));
|
||||
const router = useTransitionRouter()
|
||||
const state = useProxy(stateLayananDesa);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mobile = useMediaQuery("(max-width: 768px)", false);
|
||||
const router = useTransitionRouter();
|
||||
|
||||
useEffect(()=> {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await state.suratKeterangan.findMany.load()
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
// Refs for smooth animation
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const isHoveredRef = useRef(false);
|
||||
|
||||
// Refs for drag functionality
|
||||
const isDraggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const scrollLeftRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// Speed configuration
|
||||
const normalSpeed = 1.0; // pixels per frame
|
||||
const hoverSpeed = 0.3; // slower speed on hover
|
||||
|
||||
const data = (state.suratKeterangan.findMany.data || []).slice(0, 8);
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await state.suratKeterangan.findMany.load();
|
||||
} catch (error) {
|
||||
console.error("Error loading data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const slides = data.map((item) => (
|
||||
<Carousel.Slide key={item.id} >
|
||||
<Paper h={height} pos={"relative"} style={{
|
||||
backgroundImage: `url(${item.image?.link})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}>
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
zIndex: 0,
|
||||
}}
|
||||
pos={"absolute"}
|
||||
w={"100%"}
|
||||
h={"100%"}
|
||||
bg={colors.trans.dark[2]}
|
||||
/>
|
||||
<Stack justify="space-between" h={"100%"} gap={0} p={"lg"} pos={"relative"} >
|
||||
<Box p={"lg"}>
|
||||
<Text
|
||||
const data = state.suratKeterangan.findMany.data || [];
|
||||
|
||||
fw={"bold"}
|
||||
c={"white"}
|
||||
size={"3.5rem"}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{_.startCase(item.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group justify="center">
|
||||
<Button onClick={()=> router.push(`/darmasaba/desa/layanan/${item.id}`)} px={46} radius={"100"} size="md" bg={colors["blue-button"]}>
|
||||
Detail
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Carousel.Slide>
|
||||
));
|
||||
// Duplicate slides for seamless infinite loop
|
||||
// We need at least 3x the data for smooth infinite scrolling
|
||||
const slidesData = [...data, ...data, ...data];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{loading ? (
|
||||
<Skeleton height={height} />
|
||||
) : (
|
||||
<Carousel
|
||||
plugins={[autoplay.current]}
|
||||
onMouseEnter={autoplay.current.stop}
|
||||
onMouseLeave={autoplay.current.reset}
|
||||
height={height}
|
||||
slideSize={{ base: "100%", sm: "50%", md: "33.333333%" }}
|
||||
slideGap={{ base: "xl", sm: "md" }}
|
||||
loop
|
||||
align="start"
|
||||
slidesToScroll={mobile ? 1 : 2}
|
||||
useEffect(() => {
|
||||
if (loading || !containerRef.current || slidesData.length === 0) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const slideWidth = container.scrollWidth / slidesData.length;
|
||||
const originalDataLength = data.length;
|
||||
|
||||
// Start from the middle set of slides
|
||||
scrollPositionRef.current = slideWidth * originalDataLength;
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
|
||||
const animate = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const slideWidth = container.scrollWidth / slidesData.length;
|
||||
|
||||
// Check if user recently scrolled manually
|
||||
const timeSinceLastScroll = Date.now() - lastScrollTimeRef.current;
|
||||
const isUserScrolling = timeSinceLastScroll < 100;
|
||||
|
||||
// Only auto-scroll if user is not actively scrolling or dragging
|
||||
if (!isDraggingRef.current && !isUserScrolling) {
|
||||
const currentSpeed = isHoveredRef.current ? hoverSpeed : normalSpeed;
|
||||
scrollPositionRef.current += currentSpeed;
|
||||
|
||||
// Reset position for infinite loop
|
||||
if (scrollPositionRef.current >= slideWidth * (originalDataLength * 2)) {
|
||||
scrollPositionRef.current -= slideWidth * originalDataLength;
|
||||
}
|
||||
|
||||
if (scrollPositionRef.current <= 0) {
|
||||
scrollPositionRef.current += slideWidth * originalDataLength;
|
||||
}
|
||||
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
} else {
|
||||
// Sync scroll position when user is scrolling
|
||||
scrollPositionRef.current = container.scrollLeft;
|
||||
|
||||
// Apply momentum/velocity for smooth drag release
|
||||
if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
|
||||
scrollPositionRef.current += velocityRef.current;
|
||||
velocityRef.current *= 0.95; // Decay velocity
|
||||
container.scrollLeft = scrollPositionRef.current;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [loading, slidesData.length, data.length, mobile]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHoveredRef.current = true;
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.current = false;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
// Mouse drag handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
isDraggingRef.current = true;
|
||||
startXRef.current = e.pageX - containerRef.current.offsetLeft;
|
||||
scrollLeftRef.current = containerRef.current.scrollLeft;
|
||||
velocityRef.current = 0;
|
||||
|
||||
containerRef.current.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const x = e.pageX - containerRef.current.offsetLeft;
|
||||
const walk = (x - startXRef.current) * 2; // Multiply for faster scroll
|
||||
const newScrollLeft = scrollLeftRef.current - walk;
|
||||
|
||||
// Calculate velocity for momentum
|
||||
velocityRef.current = containerRef.current.scrollLeft - newScrollLeft;
|
||||
|
||||
containerRef.current.scrollLeft = newScrollLeft;
|
||||
scrollPositionRef.current = newScrollLeft;
|
||||
lastScrollTimeRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
isDraggingRef.current = false;
|
||||
containerRef.current.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
// Wheel scroll handler
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
containerRef.current.scrollLeft += e.deltaY;
|
||||
scrollPositionRef.current = containerRef.current.scrollLeft;
|
||||
lastScrollTimeRef.current = Date.now();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton height={height} />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<Text ta="center" c="dimmed">
|
||||
Tidak ada layanan tersedia
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{slides}
|
||||
</Carousel>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: mobile ? "1rem" : "1.5rem",
|
||||
paddingLeft: mobile ? "1rem" : "1.5rem",
|
||||
paddingRight: mobile ? "1rem" : "1.5rem",
|
||||
}}
|
||||
>
|
||||
{slidesData.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.id}-${index}`}
|
||||
style={{
|
||||
flex: `0 0 ${mobile ? "100%" : "calc(33.333% - 1rem)"}`,
|
||||
minWidth: mobile ? "100%" : "calc(33.333% - 1rem)",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
h={height}
|
||||
pos={"relative"}
|
||||
style={{
|
||||
backgroundImage: `url(${item.image?.link})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
zIndex: 0,
|
||||
}}
|
||||
pos={"absolute"}
|
||||
w={"100%"}
|
||||
h={"100%"}
|
||||
bg={colors.trans.dark[2]}
|
||||
/>
|
||||
<Stack
|
||||
justify="space-between"
|
||||
h={"100%"}
|
||||
gap={0}
|
||||
p={"lg"}
|
||||
pos={"relative"}
|
||||
>
|
||||
<Box p={"lg"}>
|
||||
<Text
|
||||
fw={"bold"}
|
||||
c={"white"}
|
||||
size={"3.5rem"}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{_.startCase(item.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/darmasaba/desa/layanan/${item.id}`)
|
||||
}
|
||||
px={46}
|
||||
radius={"100"}
|
||||
size="md"
|
||||
bg={colors["blue-button"]}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layanan;
|
||||
|
||||
export default Layanan;
|
||||
Reference in New Issue
Block a user