Fix QC Keano FrontEnd #6
@@ -26,6 +26,7 @@
|
||||
"@mantine/dropzone": "^8.1.1",
|
||||
"@mantine/form": "^8.1.0",
|
||||
"@mantine/hooks": "^7.17.4",
|
||||
"@mantine/modals": "^8.3.6",
|
||||
"@mantine/tiptap": "^7.17.4",
|
||||
"@paljs/types": "^8.1.0",
|
||||
"@prisma/client": "^6.3.1",
|
||||
@@ -55,8 +56,9 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^17.2.3",
|
||||
"elysia": "^1.3.5",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-react": "^7.1.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.23.5",
|
||||
@@ -80,6 +82,7 @@
|
||||
"prisma": "^6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-exif-orientation-img": "^0.1.5",
|
||||
"react-international-phone": "^4.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-simple-toasts": "^6.1.0",
|
||||
|
||||
@@ -6,9 +6,9 @@ import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(1, "Nama minimal 1 karakter"),
|
||||
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
|
||||
name: z.string().min(5, "Nama minimal 5 karakter"),
|
||||
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
|
||||
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
|
||||
icon: z.string().min(1, "Icon minimal 1 karakter"),
|
||||
});
|
||||
|
||||
@@ -29,26 +29,33 @@ const programKreatifState = proxy({
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
toast.error(err);
|
||||
return false; // ⬅️ ini penting
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
programKreatifState.create.loading = true;
|
||||
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
|
||||
programKreatifState.create.form
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
programKreatifState.findMany.load();
|
||||
return toast.success("success create");
|
||||
toast.success("success create");
|
||||
return true;
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
toast.error("failed create");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat create");
|
||||
return false;
|
||||
} finally {
|
||||
programKreatifState.create.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
findMany: {
|
||||
data: null as any[] | null,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -21,6 +20,7 @@ import { useProxy } from 'valtio/utils';
|
||||
import CreateEditor from '../../../_com/createEditor';
|
||||
import desaDigitalState from '../../../_state/inovasi/desa-digital';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import ExifOrientationImg from 'react-exif-orientation-img';
|
||||
|
||||
export default function CreateDesaDigital() {
|
||||
const stateDesaDigital = useProxy(desaDigitalState);
|
||||
@@ -173,17 +173,16 @@ export default function CreateDesaDigital() {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ExifOrientationImg
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -54,8 +54,8 @@ function DetailInfoTeknologiTepatGuna() {
|
||||
{/* Card Utama */}
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: "100%", md: "70%", lg: "60%" }}
|
||||
bg="#ECEEF8"
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
@@ -65,7 +65,7 @@ function DetailInfoTeknologiTepatGuna() {
|
||||
Detail Info Teknologi Tepat Guna
|
||||
</Text>
|
||||
|
||||
<Paper bg={colors['BG-trans']} p="md" radius="md" shadow="xs">
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Judul</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import { IconKey } from '@/app/admin/(dashboard)/_com/iconMap';
|
||||
import programKreatifState from '@/app/admin/(dashboard)/_state/inovasi/program-kreatif';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
@@ -11,8 +12,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -20,7 +20,6 @@ import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import SelectIconProgramEdit from '../../../../_com/selectIconEdit';
|
||||
import { IconKey } from '@/app/admin/(dashboard)/_com/iconMap';
|
||||
|
||||
interface FormProgramKreatif {
|
||||
name: string;
|
||||
@@ -41,6 +40,15 @@ function EditProgramKreatifDesa() {
|
||||
icon: '',
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState<FormProgramKreatif>({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
});
|
||||
|
||||
const [isDataChanged, setIsDataChanged] = useState(false);
|
||||
|
||||
// Load data hanya sekali berdasarkan params.id
|
||||
useEffect(() => {
|
||||
const loadProgramKreatif = async () => {
|
||||
@@ -51,12 +59,14 @@ function EditProgramKreatifDesa() {
|
||||
const data = await stateProgramKreatif.update.load(id);
|
||||
if (data) {
|
||||
stateProgramKreatif.update.id = id;
|
||||
setFormData({
|
||||
const loadedData = {
|
||||
name: data.name || '',
|
||||
slug: data.slug || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
icon: data.icon || '',
|
||||
});
|
||||
};
|
||||
setFormData(loadedData);
|
||||
setOriginalData(loadedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading program kreatif:', error);
|
||||
@@ -67,12 +77,49 @@ function EditProgramKreatifDesa() {
|
||||
loadProgramKreatif();
|
||||
}, [params?.id]);
|
||||
|
||||
// Deteksi perubahan data
|
||||
useEffect(() => {
|
||||
const hasChanged =
|
||||
formData.name !== originalData.name ||
|
||||
formData.slug !== originalData.slug ||
|
||||
formData.deskripsi !== originalData.deskripsi ||
|
||||
formData.icon !== originalData.icon;
|
||||
|
||||
setIsDataChanged(hasChanged);
|
||||
}, [formData, originalData]);
|
||||
|
||||
// Prevent browser back/refresh jika ada perubahan
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDataChanged) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [isDataChanged]);
|
||||
|
||||
const handleChange =
|
||||
(field: keyof FormProgramKreatif) =>
|
||||
(value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (isDataChanged) {
|
||||
const confirmed = window.confirm(
|
||||
'Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin keluar dari halaman ini? Semua perubahan akan hilang.'
|
||||
);
|
||||
if (confirmed) {
|
||||
router.back();
|
||||
}
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
stateProgramKreatif.update.form = {
|
||||
@@ -82,6 +129,11 @@ function EditProgramKreatifDesa() {
|
||||
icon: formData.icon.trim(),
|
||||
};
|
||||
await stateProgramKreatif.update.submit();
|
||||
|
||||
// Reset isDataChanged agar tidak muncul konfirmasi setelah save
|
||||
setOriginalData(formData);
|
||||
setIsDataChanged(false);
|
||||
|
||||
router.push('/admin/inovasi/program-kreatif-desa');
|
||||
} catch (error) {
|
||||
console.error('Error updating program kreatif:', error);
|
||||
@@ -92,16 +144,14 @@ function EditProgramKreatifDesa() {
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleBackClick}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Program Kreatif Desa
|
||||
</Title>
|
||||
@@ -172,4 +222,4 @@ function EditProgramKreatifDesa() {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditProgramKreatifDesa;
|
||||
export default EditProgramKreatifDesa;
|
||||
@@ -32,10 +32,14 @@ function CreateProgramKreatifDesa() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await stateCreate.create.create();
|
||||
resetForm();
|
||||
router.push("/admin/inovasi/program-kreatif-desa");
|
||||
const success = await stateCreate.create.create();
|
||||
|
||||
if (success) {
|
||||
resetForm();
|
||||
router.push("/admin/inovasi/program-kreatif-desa");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import laporanPublikState from '@/app/admin/(dashboard)/_state/keamanan/laporan-publik';
|
||||
@@ -52,15 +53,14 @@ function EditLaporanPublik() {
|
||||
try {
|
||||
const data = await stateLaporan.edit.load(id);
|
||||
if (data) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
judul: data.judul ?? prev.judul,
|
||||
lokasi: data.lokasi ?? prev.lokasi,
|
||||
tanggalWaktu: data.tanggalWaktu ?? prev.tanggalWaktu,
|
||||
status: (data.status as Status) ?? prev.status,
|
||||
penanganan: data.penanganan?.[0]?.deskripsi ?? prev.penanganan,
|
||||
kronologi: data.kronologi ?? prev.kronologi,
|
||||
}));
|
||||
setFormData({
|
||||
judul: data.judul ?? '',
|
||||
lokasi: data.lokasi ?? '',
|
||||
tanggalWaktu: data.tanggalWaktu ?? '',
|
||||
status: (data.status as Status) ?? 'Proses',
|
||||
penanganan: data.penanganan?.[0]?.deskripsi ?? '',
|
||||
kronologi: data.kronologi ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading laporan publik:", error);
|
||||
@@ -69,7 +69,8 @@ function EditLaporanPublik() {
|
||||
};
|
||||
|
||||
loadLaporanPublik();
|
||||
}, [params?.id, stateLaporan.edit]);
|
||||
}, [params?.id]);
|
||||
|
||||
|
||||
|
||||
const handleChange = (field: string, value: string | Status) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Grid, GridCol, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
@@ -26,10 +25,10 @@ function Page() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={{ base: 'md', md: 'xl' }}>
|
||||
<Paper withBorder radius="md" p={{ base: 'md', md: 'lg' }} bg={colors['white-1']}>
|
||||
<Box p="md">
|
||||
<Paper withBorder p={{ base: 'md', md: 'lg' }} radius="md">
|
||||
{/* Header */}
|
||||
<Grid align="center" mb="lg">
|
||||
<Grid align="center" mb={{ base: 'md', md: 'lg' }}>
|
||||
<GridCol span={{ base: 12, md: 11 }}>
|
||||
<Title order={3} fw={600} c="dark">
|
||||
Preview Bentuk Konservasi Berdasarkan Adat
|
||||
@@ -55,8 +54,8 @@ function Page() {
|
||||
|
||||
{/* Konten */}
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" p={{ base: 'md', md: 'xl' }} bg={colors['BG-trans']} shadow="sm">
|
||||
<Box mb="md">
|
||||
<Paper p={{ base: 'md', md: 'xl' }} bg="#ECEEF8" radius="md">
|
||||
<Box mb="md" px={{ base: 0, md: 20 }}>
|
||||
<Text
|
||||
fz={{ base: 'xl', md: '2xl' }}
|
||||
fw={600}
|
||||
@@ -67,7 +66,7 @@ function Page() {
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box px={{ base: 0, md: 20 }}>
|
||||
<Text
|
||||
fz={{ base: 'md', md: 'lg' }}
|
||||
ta="justify"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconRecycle, IconTrash } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { IconTrash, IconRecycle } from '@tabler/icons-react';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@@ -16,14 +16,12 @@ function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.R
|
||||
value: "listpengelolaansampahbanksampah",
|
||||
href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah",
|
||||
icon: <IconTrash size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola data pengelolaan sampah bank sampah",
|
||||
},
|
||||
{
|
||||
label: "Keterangan Bank Sampah Terdekat",
|
||||
value: "keteranganbanksampahterdekat",
|
||||
href: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/keterangan-bank-sampah-terdekat",
|
||||
icon: <IconRecycle size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola data bank sampah terdekat",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -74,14 +72,8 @@ function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.R
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
>
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
@@ -92,7 +84,6 @@ function LayoutTabsPengelolaanSampahBankSampah({ children }: { children: React.R
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -65,26 +65,32 @@ function ListDataPerpustakaan({ search }: { search: string }) {
|
||||
<Table striped highlightOnHover withRowBorders style={{ minWidth: '700px' }}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>No</TableTh>
|
||||
<TableTh>Judul</TableTh>
|
||||
<TableTh>Kategori</TableTh>
|
||||
<TableTh>Detail</TableTh>
|
||||
<TableTh style={{ width: '5%' }}>No</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>Judul</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>Kategori</TableTh>
|
||||
<TableTh style={{ width: '23%' }}>Deskripsi</TableTh>
|
||||
<TableTh style={{ width: '22%' }}>Detail</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item, index) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>
|
||||
<TableTd style={{ width: '5%' }}>
|
||||
<Text truncate fz="sm">{index + 1}</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Text truncate fz="sm">{item.judul}</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Text truncate fz="sm">{item.kategori.name}</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Box w={150}>
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} lineClamp={1} truncate fz="sm"/>
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
|
||||
@@ -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;
|
||||
12
src/types/react-exif-orientation-img.d.ts
vendored
Normal file
12
src/types/react-exif-orientation-img.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare module 'react-exif-orientation-img' {
|
||||
import { ImgHTMLAttributes } from 'react';
|
||||
|
||||
interface ExifOrientationImgProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const ExifOrientationImg: React.FC<ExifOrientationImgProps>;
|
||||
export default ExifOrientationImg;
|
||||
}
|
||||
Reference in New Issue
Block a user